修复风格化多张生成和结束状态
风格化请求 6 张图片时,98K 接入仍提交 n=1 且仅保留第一张结果,页面继续等待剩余图片。现在传递实际张数、保留全部返回结果,请求结束数量不足时结束等待并提示;刷新后也能从历史结果恢复结束状态。阿里通道参考图改为并行准备。 验证:46 项针对性测试通过,包括实际 Provider 流程提交一次并交付 6 张;TypeScript 检查通过。
This commit is contained in:
parent
02b9575a47
commit
6ba5d9606d
@ -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<string | null>(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.
|
||||
|
||||
15
lib/agents/scene-run-recovery.ts
Normal file
15
lib/agents/scene-run-recovery.ts
Normal file
@ -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 };
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) => ``).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([
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user