Compare commits

...

39 Commits

Author SHA1 Message Date
8fca3b25c5 Merge pull request '支持98K扩图线路替换 fal 上游' (#77) from codex/replace-fal-outpaint-vip98k-20261112 into main 2026-11-12 11:30:10 +08:00
b8799f4804 支持通过98K替换画布扩图上游 2026-11-12 11:25:57 +08:00
d1f9590d05 Merge pull request '避免页面更新打断图片下载' (#76) from codex/preserve-active-image-downloads-20261111 into main 2026-11-11 15:25:55 +08:00
74053490cc 避免页面更新打断图片下载 2026-11-11 15:25:48 +08:00
61b6eba4b4 Merge pull request '显示下载中的图片格式转换进度' (#75) from codex/download-conversion-progress-20261111 into main 2026-11-11 14:59:51 +08:00
bbecafc826 显示下载中的图片格式转换进度 2026-11-11 14:59:46 +08:00
ed3b1b5e8a Merge pull request '画布图片下载自动兼容 Photoshop' (#74) from codex/automatic-photoshop-download-20261111 into main 2026-11-11 14:29:51 +08:00
8f56b5a567 画布图片下载自动兼容 Photoshop 2026-11-11 14:29:42 +08:00
fd8d5404c6 Merge pull request '避免原图下载查询绕过七牛缓存' (#73) from codex/original-download-metadata-cache-20261111 into main 2026-11-11 13:50:43 +08:00
09db16f74c 避免原图下载查询绕过七牛缓存 2026-11-11 13:50:37 +08:00
70621f4261 Merge pull request '加快画布原图下载和 Agent 图片显示' (#72) from codex/original-delivery-speed-20261111 into main 2026-11-11 13:35:47 +08:00
5a95e7da49 加快画布原图下载和 Agent 图片显示 2026-11-11 13:35:37 +08:00
8cf113b554 Merge pull request '减少七牛 CDN 图片预览的重复回源' (#71) from codex/qiniu-cdn-preview-20261111 into main 2026-11-11 11:34:57 +08:00
5fc3f1bb11 减少七牛 CDN 图片预览的重复回源 2026-11-11 11:34:50 +08:00
1342bf384c Merge pull request '修复局部编辑合成图保存并覆盖直传原图' (#70) from codex/qiniu-local-edit-save-20261111 into main 2026-11-11 10:17:46 +08:00
759ee6af58 修复局部编辑合成图保存并覆盖直传原图 2026-11-11 10:17:27 +08:00
54eb35e21c Merge pull request '去水印直接复用已存七牛的图片' (#69) from codex/watermark-qiniu-direct-20261111 into main 2026-11-11 09:31:27 +08:00
f157161ad9 去水印直接复用已存七牛的图片 2026-11-11 09:31:22 +08:00
eb9ee0ce1e Merge pull request '对齐详情替换模型并支持整套图片' (#68) from codex/detail-replacement-full-set into main 2026-11-10 17:02:29 +08:00
2c30c9d7f9 对齐详情替换模型并支持整套图片 2026-11-10 17:02:20 +08:00
8428886a1d Merge pull request '品牌加载后再按需加载风格模板' (#67) from codex/stylization-defer-templates-20261110 into main 2026-11-10 15:17:31 +08:00
24cf77c9c2 品牌加载后再按需加载风格模板 2026-11-10 15:17:24 +08:00
9cbc32bb5d Merge pull request '改善画布扩图四周衔接' (#66) from codex/canvas-outpaint-20261110 into main 2026-11-10 15:12:36 +08:00
7ddaeb12a0 改善画布扩图四周衔接 2026-11-10 15:12:30 +08:00
5f24743bdb Merge pull request '分开加载风格化品牌与模板' (#65) from codex/stylization-load-20261110 into main 2026-11-10 15:04:19 +08:00
03a5613d80 分开加载风格化品牌与模板 2026-11-10 15:04:09 +08:00
6112892b85 Merge pull request '扩图保留原图主体和文字' (#64) from codex/canvas-fibo-outpaint-20261110 into main 2026-11-10 14:18:32 +08:00
8c50c1ce72 扩图保留原图主体和文字 2026-11-10 14:18:21 +08:00
b3c4f2763a Merge pull request '恢复画布文字识别' (#63) from codex/canvas-ocr-and-outpaint-20261110 into main 2026-11-10 13:57:28 +08:00
97d32fd2d3 恢复画布文字识别 2026-11-10 13:57:20 +08:00
4dd6885f1c Merge pull request '保留 16 位 PNG 原图和色彩信息' (#62) from codex/qiniu-rgb16-original-20261110 into main 2026-11-10 11:50:06 +08:00
7b776f96c1 保留 16 位 PNG 原图和色彩信息 2026-11-10 11:50:00 +08:00
17d5f6fec9 Merge pull request '加速 98K 原图归档和画布下载' (#61) from codex/qiniu-direct-image-archive-20261110 into main 2026-11-10 11:42:36 +08:00
b3549f25d7 加速 98K 原图归档和画布下载 2026-11-10 11:42:30 +08:00
74d9368744 Merge pull request '复用画布识图原图并区分提交前后超时' (#60) from codex/canvas-reference-read-timeout into main 2026-11-09 14:13:34 +08:00
e8e8edcefe 复用画布识图原图并区分提交前后超时 2026-11-09 14:13:17 +08:00
57e94b797c Merge pull request '修复 Agent 单次任务超出设定张数' (#59) from codex/canvas-agent-image-count-limit into main 2026-11-07 15:18:46 +08:00
ca6b27624c 修复 Agent 单次任务超出设定张数 2026-11-07 15:18:23 +08:00
fa3210a367 Merge pull request '修复画布保存状态判定和素材上传刷新保护' (#58) from codex/canvas-leave-pending-upload into main 2026-11-06 16:54:43 +08:00
68 changed files with 2130 additions and 398 deletions

View File

@ -2377,8 +2377,10 @@ export function LocalAiImagePipelineSections({
items={[ items={[
{ {
label: label:
extendConfig.provider === "vip98k" extendConfig.provider === "fal"
? "VIP98K 服务商" ? "fal · FLUX 2 Pro 扩图"
: extendConfig.provider === "vip98k"
? `98K · ${extendConfig.modelId || "image-expansion"}`
: "阿里云服务商", : "阿里云服务商",
tone: "enabled", tone: "enabled",
}, },
@ -2391,9 +2393,13 @@ export function LocalAiImagePipelineSections({
} }
title="图像拓展API配置" title="图像拓展API配置"
> >
{extendConfig.provider === "vip98k" ? ( {extendConfig.provider === "fal" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm"> <div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的 使用 FLUX 2 Pro Outpaint 扩展图片。密钥从已启用的 fal 官方服务商读取。
</div>
) : extendConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 98K 的 {extendConfig.modelId || "image-expansion"} 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,仅在这里维护扩展比例。 VIP98K 服务商读取,仅在这里维护扩展比例。
</div> </div>
) : ( ) : (

View File

@ -274,7 +274,9 @@ export type EnhanceConfig = {
}; };
export type ExtendConfig = { export type ExtendConfig = {
provider: "vip98k" | "dashscope"; provider: "vip98k" | "dashscope" | "fal";
modelId: string;
routeOptionId: string;
xScale: string; xScale: string;
yScale: string; yScale: string;
}; };

View File

@ -2576,7 +2576,9 @@ export function AdminSettingsClient() {
// 图像拓展API配置状态 // 图像拓展API配置状态
const [extendConfig, setExtendConfig] = useState({ const [extendConfig, setExtendConfig] = useState({
provider: "dashscope" as "vip98k" | "dashscope", provider: "dashscope" as "vip98k" | "dashscope" | "fal",
modelId: "",
routeOptionId: "",
xScale: "1.5", // X轴放大倍数 xScale: "1.5", // X轴放大倍数
yScale: "1.5", // Y轴放大倍数 yScale: "1.5", // Y轴放大倍数
}); });
@ -2891,12 +2893,16 @@ export function AdminSettingsClient() {
useEffect(() => { useEffect(() => {
if (extendConfigData?.setting?.value) { if (extendConfigData?.setting?.value) {
const config = extendConfigData.setting.value as { const config = extendConfigData.setting.value as {
provider?: "vip98k" | "dashscope"; provider?: "vip98k" | "dashscope" | "fal";
modelId?: string;
routeOptionId?: string;
xScale?: number; xScale?: number;
yScale?: number; yScale?: number;
}; };
setExtendConfig({ setExtendConfig({
provider: config.provider === "vip98k" ? "vip98k" : "dashscope", provider: config.provider === "vip98k" || config.provider === "fal" ? config.provider : "dashscope",
modelId: config.modelId || "",
routeOptionId: config.routeOptionId || "",
xScale: config.xScale ? String(config.xScale) : "1.5", xScale: config.xScale ? String(config.xScale) : "1.5",
yScale: config.yScale ? String(config.yScale) : "1.5", yScale: config.yScale ? String(config.yScale) : "1.5",
}); });
@ -5505,6 +5511,8 @@ export function AdminSettingsClient() {
key: "extend_api_config", key: "extend_api_config",
value: { value: {
provider: extendConfig.provider, provider: extendConfig.provider,
modelId: extendConfig.modelId,
routeOptionId: extendConfig.routeOptionId,
xScale, xScale,
yScale, yScale,
}, },

View File

@ -4,7 +4,8 @@ import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { import {
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY, DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
normalizeDetailReplacementImageModelConfig, findConfiguredDetailReplacementImageModel,
isDetailReplacementImageModel,
} from "@/lib/ai/detail-replacement-image-model-config"; } from "@/lib/ai/detail-replacement-image-model-config";
import { import {
compositeReplacementWithReferenceLocks, compositeReplacementWithReferenceLocks,
@ -29,7 +30,6 @@ import {
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { bufferToArrayBuffer } from "./persistence"; import { bufferToArrayBuffer } from "./persistence";
import { isVideoProviderType } from "./video-models";
import type { AgentPresetMessageImagePart } from "./agent-scene-workflow"; import type { AgentPresetMessageImagePart } from "./agent-scene-workflow";
import type { PostRequestBody } from "../schema"; import type { PostRequestBody } from "../schema";
@ -2161,22 +2161,10 @@ export async function resolveDirectReplacementModelRef(params: {
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>; models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}) { }) {
const models = params.models || (await getEnabledAiModelsWithProvider(false)); const models = params.models || (await getEnabledAiModelsWithProvider(false));
const imageModels = models.filter(
({ model, provider }) =>
model.supportsImageGen &&
!model.isSubModel &&
!isVideoProviderType(provider?.type, model),
);
const configuredModelSetting = await getSystemSetting({ const configuredModelSetting = await getSystemSetting({
key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY, key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
}).catch(() => null); }).catch(() => null);
const configuredModelId = normalizeDetailReplacementImageModelConfig( const configuredModel = findConfiguredDetailReplacementImageModel(models, configuredModelSetting?.value);
configuredModelSetting?.value,
).modelId;
const configuredModel = imageModels.find(
({ model }) =>
model.id === configuredModelId || model.modelId === configuredModelId,
);
if (configuredModel) { if (configuredModel) {
return configuredModel.model.id; return configuredModel.model.id;
} }
@ -2190,6 +2178,7 @@ export async function resolveDirectReplacementModelRef(params: {
return routedModel; return routedModel;
} }
const imageModels = models.filter(isDetailReplacementImageModel);
const preferred = const preferred =
imageModels.find(({ model }) => model.isDefault) || imageModels.find(({ model }) => model.isDefault) ||
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) || imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||

View File

@ -10,6 +10,10 @@ import {
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability"; import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability"; import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability";
import {
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
findConfiguredDetailReplacementImageModel,
} from "@/lib/ai/detail-replacement-image-model-config";
import { import {
isImageGenerationModelDescriptor, isImageGenerationModelDescriptor,
isVideoModelDescriptor, isVideoModelDescriptor,
@ -157,10 +161,17 @@ export async function GET(request: Request) {
: await getEnabledAiModelsWithProvider(false, true); : await getEnabledAiModelsWithProvider(false, true);
const agentModelWithProvider = await getAgentAiModelWithProvider(); const agentModelWithProvider = await getAgentAiModelWithProvider();
const defaultModel = await getDefaultAiModel(); const defaultModel = await getDefaultAiModel();
const [siteSettings, agentWorkflowUiConfigSetting] = await Promise.all([ const [siteSettings, agentWorkflowUiConfigSetting, detailReplacementModelSetting] = await Promise.all([
getSiteSettings(), getSiteSettings(),
getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY }).catch(() => null), getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY }).catch(() => null),
getSystemSetting({ key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY }).catch(() => null),
]); ]);
const detailReplacementModel = findConfiguredDetailReplacementImageModel(
includeUnavailable ? modelsWithProvider.filter(({ model, provider }) =>
provider.isEnabled && model.isEnabled && getAiModelFrontendAvailability(model).selectable !== false
) : modelsWithProvider,
detailReplacementModelSetting?.value,
);
type ModelWithProvider = (typeof modelsWithProvider)[number]; type ModelWithProvider = (typeof modelsWithProvider)[number];
const serializeModel = ({ model, provider }: ModelWithProvider) => ({ const serializeModel = ({ model, provider }: ModelWithProvider) => ({
id: model.id, id: model.id,
@ -309,6 +320,7 @@ export async function GET(request: Request) {
: null, : null,
agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null, agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null,
agentWorkflowConfiguredModels, agentWorkflowConfiguredModels,
detailReplacementModel: detailReplacementModel ? serializeModel(detailReplacementModel) : null,
agentModel: agentModelWithProvider agentModel: agentModelWithProvider
? { ? {
id: agentModelWithProvider.model.id, id: agentModelWithProvider.model.id,

View File

@ -0,0 +1,20 @@
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { resolveAgentWorkflowComposerSelections } from "@/lib/agents/workflow-composer";
import { AGENT_WORKFLOW_UI_CONFIG_KEY, normalizeAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
import { getSystemSetting } from "@/lib/db/queries";
const selectionsSchema = z.record(z.string().max(160), z.string().max(8192))
.refine((selections) => Object.keys(selections).length <= 100);
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return Response.json({ error: "请先登录" }, { status: 401 });
const selections = selectionsSchema.safeParse(await request.json().catch(() => null));
if (!selections.success) return Response.json({ error: "风格模板选择无效" }, { status: 400 });
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY });
const config = normalizeAgentWorkflowUiConfig(setting?.value);
return Response.json({ selections: resolveAgentWorkflowComposerSelections({ config, selections: selections.data }) });
}

View File

@ -4,6 +4,7 @@ import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canvasImageTask, canvasProject, message } from "@/lib/db/schema"; import { canvasImageTask, canvasProject, message } from "@/lib/db/schema";
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat"; import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
import { MAX_DETAIL_REPLACEMENT_REFERENCES } from "@/lib/agents/mode-workspace-state";
import { import {
createCanvasImageTask, createCanvasImageTask,
enqueueCanvasImageTask, enqueueCanvasImageTask,
@ -146,6 +147,14 @@ export async function POST(request: Request) {
.map((item) => [String(item.id), String(item.imageUrl)]), .map((item) => [String(item.id), String(item.imageUrl)]),
); );
const requested = Array.isArray(body.references) ? body.references : []; const requested = Array.isArray(body.references) ? body.references : [];
if (requested.length === 0) {
return NextResponse.json({ error: "请先选择详情参考图" }, { status: 400 });
}
if (requested.length > MAX_DETAIL_REPLACEMENT_REFERENCES) {
return NextResponse.json({
error: `本次选中了 ${requested.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`,
}, { status: 400 });
}
const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => { const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => {
const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {}; const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
return { return {
@ -160,7 +169,6 @@ export async function POST(request: Request) {
}; };
}); });
if ( if (
references.length === 0 || references.length > 40 ||
new Set(references.map((item) => item.id)).size !== references.length || new Set(references.map((item) => item.id)).size !== references.length ||
references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl) references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl)
) { ) {
@ -187,6 +195,12 @@ export async function POST(request: Request) {
if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) { if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) {
return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 }); return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 });
} }
if (typeof body.expectedModelId === "string" && body.expectedModelId !== model) {
return NextResponse.json({
code: "DETAIL_REPLACEMENT_MODEL_CHANGED",
error: "详情替换模型已更新,请确认页面显示的模型后重新提交",
}, { status: 409 });
}
const quota = await checkUserImageQuota(session.user.id); const quota = await checkUserImageQuota(session.user.id);
if (!quota.allowed) { if (!quota.allowed) {
return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 }); return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 });

View File

@ -329,6 +329,7 @@ export async function POST(request: Request) {
withModel, withModel,
imageSize: snapshot.imagePreferences?.parameters?.imageSize, imageSize: snapshot.imagePreferences?.parameters?.imageSize,
imagePreferences: snapshot.imagePreferences, imagePreferences: snapshot.imagePreferences,
priorCalls: snapshot.calls,
preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined), preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined),
operationSelectedNodeIds: selectedNodeIds, operationSelectedNodeIds: selectedNodeIds,
objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join( objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join(

View File

@ -976,7 +976,8 @@ export async function POST(request: NextRequest) {
const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`; const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
const annotatedUpload = await uploadFile( const annotatedUpload = await uploadFile(
annotatedFilename, annotatedFilename,
toArrayBuffer(annotatedBuffer) toArrayBuffer(annotatedBuffer),
{ preferQiniuForm: true }
); );
const prompt = buildLocalEditPrompt(regions); const prompt = buildLocalEditPrompt(regions);
const manualRegionReferenceImageUrls = regions.flatMap((region) => const manualRegionReferenceImageUrls = regions.flatMap((region) =>

View File

@ -1,7 +1,11 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { and, eq, inArray } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { callQwen3Vl } from "@/lib/ai/qwen3-vl"; import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
import { executeConfiguredVip98kTask, getVip98kRuntimeProviders } from "@/lib/ai/vip98k-runtime";
import { db } from "@/lib/db";
import { aiModel } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import sharp from "sharp"; import sharp from "sharp";
@ -90,6 +94,39 @@ function fallbackItemsFromRaw(raw: string): OcrItem[] {
})); }));
} }
async function detectTextWithVip98k(imageUrl: string): Promise<OcrItem[] | null> {
const providers = await getVip98kRuntimeProviders();
if (!providers.length) return null;
const model = await db.query.aiModel.findFirst({
columns: { providerId: true },
where: and(
inArray(aiModel.providerId, providers.map(({ id }) => id)),
eq(aiModel.modelId, "ocr"),
eq(aiModel.isEnabled, true),
),
});
if (!model) return null;
const result = await executeConfiguredVip98kTask({
modelId: "ocr",
kind: "image-tool",
imageUrls: [imageUrl],
preferredProviderId: model.providerId,
});
const textUrl = result.outputUrls[0];
if (!textUrl) throw new Error("98K 文字识别未返回文本文件");
const response = await fetch(textUrl, { signal: AbortSignal.timeout(15_000) });
const type = response.headers.get("content-type") || "";
if (!response.ok || !/^(?:text\/plain|application\/octet-stream)(?:;|$)/i.test(type)) {
await response.body?.cancel();
throw new Error(`无法读取 98K 文字识别结果(HTTP ${response.status})`);
}
return (await response.text()).split(/\r\n?|\n/).map((line) => line.trim())
.filter(Boolean).slice(0, 50).map((text, index) => ({ id: String(index + 1), text }));
}
function shouldUseDataUrlFallback(error: unknown) { function shouldUseDataUrlFallback(error: unknown) {
const message = const message =
error instanceof Error ? error.message.toLowerCase() : String(error || "").toLowerCase(); error instanceof Error ? error.message.toLowerCase() : String(error || "").toLowerCase();
@ -147,6 +184,11 @@ export async function POST(request: Request) {
return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 }); return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 });
} }
const detected = await detectTextWithVip98k(imageUrl);
if (detected !== null) {
return NextResponse.json({ success: true, data: { items: detected, model: "ocr" } });
}
logger.debug("model_selected", { model: "qwen3-vl" }); logger.debug("model_selected", { model: "qwen3-vl" });
const prompt = [ const prompt = [
"你是图片文字识别助手。", "你是图片文字识别助手。",
@ -191,15 +233,9 @@ export async function POST(request: Request) {
} }
const parsedJson = parseJsonText(text || ""); const parsedJson = parseJsonText(text || "");
let items: OcrItem[] = []; const items = Array.isArray(parsedJson?.items)
? sanitizeItems(parsedJson.items)
if (parsedJson && Array.isArray(parsedJson.items)) { : fallbackItemsFromRaw(text);
items = sanitizeItems(parsedJson.items);
}
if (items.length === 0 && text) {
items = fallbackItemsFromRaw(text);
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
@ -213,7 +249,7 @@ export async function POST(request: Request) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: error instanceof Error ? error.message : "文字识别失败", error: "图片文字识别暂不可用,请稍后重试",
}, },
{ status: 500 }, { status: 500 },
); );

View File

@ -133,15 +133,13 @@ function buildImageResponse(
etag: string; etag: string;
vary?: string; vary?: string;
cacheStatus?: "HIT" | "MISS"; cacheStatus?: "HIT" | "MISS";
cacheControl?: string;
attachment?: boolean;
} }
) { ) {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": params.contentType, "Content-Type": params.contentType,
"Content-Length": String(body.length), "Content-Length": String(body.length),
ETag: params.etag, ETag: params.etag,
"Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400", "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
}; };
if (params.vary) { if (params.vary) {
headers.Vary = params.vary; headers.Vary = params.vary;
@ -149,11 +147,6 @@ function buildImageResponse(
if (params.cacheStatus) { if (params.cacheStatus) {
headers["X-Image-Proxy-Cache"] = params.cacheStatus; headers["X-Image-Proxy-Cache"] = params.cacheStatus;
} }
if (params.attachment) {
headers["Content-Disposition"] = "attachment";
headers["X-Content-Type-Options"] = "nosniff";
headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
}
return new NextResponse(body, { return new NextResponse(body, {
status: 200, status: 200,
headers, headers,
@ -460,20 +453,29 @@ export async function GET(request: NextRequest) {
if (original) return downloadFailure("Source is not an image"); if (original) return downloadFailure("Source is not an image");
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 }); return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
} }
if (original) {
if (upstreamResponse.status !== 200 || !upstreamResponse.body) {
await upstreamResponse.body?.cancel();
return downloadFailure("源图片传输中断,请重试");
}
const headers = new Headers({
"Content-Type": contentType,
"Content-Disposition": "attachment",
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
});
const length = upstreamResponse.headers.get("content-length");
const encoding = upstreamResponse.headers.get("content-encoding");
if (length && (!encoding || encoding.toLowerCase() === "identity")) headers.set("Content-Length", length);
return new NextResponse(upstreamResponse.body, { headers });
}
try { try {
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer()); originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
} catch (error) { } catch (error) {
return upstreamFailure(error); return upstreamFailure(error);
} }
} }
if (original) {
return buildImageResponse(originalBuffer, {
contentType,
etag: `"${crypto.createHash("sha256").update(originalBuffer).digest("hex")}"`,
cacheControl: "private, no-store",
attachment: true,
});
}
const isSvg = contentType.toLowerCase().includes("image/svg"); const isSvg = contentType.toLowerCase().includes("image/svg");
const isGif = contentType.toLowerCase().includes("image/gif"); const isGif = contentType.toLowerCase().includes("image/gif");

View File

@ -10,7 +10,8 @@ import {
systemSettingSnapshot, systemSettingSnapshot,
systemSettings, systemSettings,
} from "@/lib/db/schema"; } from "@/lib/db/schema";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray, sql } from "drizzle-orm";
import { omitAgentWorkflowPrompts } from "@/lib/agents/workflow-loading";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { isAgentWorkflowAdminType, isSiteAdminType } from "@/lib/middleware/admin"; import { isAgentWorkflowAdminType, isSiteAdminType } from "@/lib/middleware/admin";
import { import {
@ -553,6 +554,8 @@ function normalizeAgentCategoryTreeConfig(value: unknown): AgentCategoryTreeConf
type PublicSiteSettingsScope = type PublicSiteSettingsScope =
| "agent-brand-model" | "agent-brand-model"
| "agent-canvas" | "agent-canvas"
| "agent-canvas-base"
| "agent-workflow-templates"
| "agent-entry" | "agent-entry"
| "agent-presets" | "agent-presets"
| "auth" | "auth"
@ -565,6 +568,8 @@ type PublicSiteSettingsScope =
const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([ const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
"agent-brand-model", "agent-brand-model",
"agent-canvas", "agent-canvas",
"agent-canvas-base",
"agent-workflow-templates",
"agent-entry", "agent-entry",
"agent-presets", "agent-presets",
"auth", "auth",
@ -682,7 +687,7 @@ function createScopedSiteSettingsResponse(
return response; return response;
} }
async function readScopedSiteSettings(keys: string[]) { async function readScopedSiteSettings(keys: string[], omitWorkflowSteps = false) {
const [settings, settingRows] = await Promise.all([ const [settings, settingRows] = await Promise.all([
db db
.select() .select()
@ -690,8 +695,14 @@ async function readScopedSiteSettings(keys: string[]) {
.limit(1) .limit(1)
.then((rows) => rows[0]), .then((rows) => rows[0]),
keys.length > 0 keys.length > 0
? db ? (omitWorkflowSteps
.select() ? db.select({
key: systemSettings.key,
value: sql<unknown>`case when ${systemSettings.key} = ${AGENT_WORKFLOW_UI_CONFIG_KEY}
and jsonb_typeof(${systemSettings.value}) = 'object'
then ${systemSettings.value} - 'steps' else ${systemSettings.value} end`,
})
: db.select())
.from(systemSettings) .from(systemSettings)
.where(inArray(systemSettings.key, keys)) .where(inArray(systemSettings.key, keys))
: Promise.resolve([]), : Promise.resolve([]),
@ -779,8 +790,17 @@ async function getScopedPublicSiteSettings(
}); });
} }
if (scope === "agent-canvas" || scope === "agent-entry") { if (scope === "agent-workflow-templates") {
const { settings, valueByKey } = await readScopedSiteSettings(AGENT_CANVAS_SETTING_KEYS); const [setting] = await db.select({ value: systemSettings.value })
.from(systemSettings).where(eq(systemSettings.key, AGENT_WORKFLOW_UI_CONFIG_KEY)).limit(1);
const config = normalizeAgentWorkflowUiConfig(setting?.value);
return createScopedSiteSettingsResponse(scope, {
steps: omitAgentWorkflowPrompts(config.steps),
}, "MISS", options.cacheKey || scope, { etag: options.etag });
}
if (scope === "agent-canvas" || scope === "agent-canvas-base" || scope === "agent-entry") {
const { settings, valueByKey } = await readScopedSiteSettings(AGENT_CANVAS_SETTING_KEYS, scope !== "agent-canvas");
const canvasFeatureAvailability = await readCanvasFeatureAvailability( const canvasFeatureAvailability = await readCanvasFeatureAvailability(
valueByKey valueByKey
); );
@ -831,6 +851,8 @@ async function getScopedPublicSiteSettings(
pageFeatureSettings: rawUiConfig?.pageFeatureSettings, pageFeatureSettings: rawUiConfig?.pageFeatureSettings,
businessModeSettings: rawUiConfig?.businessModeSettings, businessModeSettings: rawUiConfig?.businessModeSettings,
} }
: scope === "agent-canvas-base"
? { ...rawUiConfig, steps: [] }
: rawUiConfig : rawUiConfig
), ),
agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig( agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig(
@ -1090,7 +1112,8 @@ export async function GET(request: NextRequest) {
const cacheKey = brandName.trim() const cacheKey = brandName.trim()
? `${scopedScope}:${brandName.trim().toLowerCase()}` ? `${scopedScope}:${brandName.trim().toLowerCase()}`
: scopedScope; : scopedScope;
const etag = scopedScope === "agent-canvas" || scopedScope === "agent-entry" const etag = scopedScope === "agent-canvas" || scopedScope === "agent-entry" ||
scopedScope === "agent-canvas-base" || scopedScope === "agent-workflow-templates"
? await getAgentCanvasSettingsEtag(scopedScope) ? await getAgentCanvasSettingsEtag(scopedScope)
: undefined; : undefined;
if (etag && request.headers.get("If-None-Match") === etag) { if (etag && request.headers.get("If-None-Match") === etag) {

View File

@ -6,6 +6,7 @@ import { eq } from "drizzle-orm";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { import {
createMediaAssetRelation, createMediaAssetRelation,
getDefaultStorageConfig,
getPointsSystemConfig, getPointsSystemConfig,
getMediaAssetsByUserAndUrls, getMediaAssetsByUserAndUrls,
upsertMediaAsset, upsertMediaAsset,
@ -21,6 +22,27 @@ export const maxDuration = 600;
const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000; const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000;
const WATERMARK_POLL_INTERVAL_MS = 1200; const WATERMARK_POLL_INTERVAL_MS = 1200;
async function resolveStoredQiniuImageUrl(source: string): Promise<string | null> {
const config = await getDefaultStorageConfig().catch(() => null);
if (config?.type !== "qiniu-kodo" || !config.isEnabled || !config.publicUrl) return null;
try {
const base = new URL(config.publicUrl);
const url = new URL(source);
const prefix = base.pathname.replace(/\/$/, "") + "/";
if (base.protocol !== "https:" || url.origin !== base.origin ||
url.username || url.password || url.search || url.hash ||
!url.pathname.startsWith(prefix)) return null;
const objectKey = url.pathname.slice(prefix.length);
return /^uploads\/(?:direct\/[0-9a-f-]{36}\/)?[\w.-]+$/i.test(objectKey)
? url.toString()
: null;
} catch {
return null;
}
}
// 获取去水印API配置 // 获取去水印API配置
async function getWatermarkRemoveConfig() { async function getWatermarkRemoveConfig() {
try { try {
@ -131,13 +153,16 @@ async function persistWatermarkResult(params: {
processing_time?: number; processing_time?: number;
}> { }> {
try { try {
let resultUrl = await resolveStoredQiniuImageUrl(params.processedImageUrl);
if (!resultUrl) {
const imageResponse = await fetch(params.processedImageUrl); const imageResponse = await fetch(params.processedImageUrl);
if (!imageResponse.ok) { if (!imageResponse.ok) {
throw new Error("下载处理后的图片失败"); throw new Error("下载处理后的图片失败");
} }
const imageBuffer = await imageResponse.arrayBuffer(); const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-removed-${Date.now()}.png`; const filename = `watermark-removed-${Date.now()}.png`;
const uploadResult = await uploadFile(filename, imageBuffer); resultUrl = (await uploadFile(filename, imageBuffer)).url;
}
let saved = false; let saved = false;
let saveError: unknown = null; let saveError: unknown = null;
@ -150,8 +175,8 @@ async function persistWatermarkResult(params: {
try { try {
([savedImage] = await db.insert(generatedImage).values({ ([savedImage] = await db.insert(generatedImage).values({
userId: params.userId, userId: params.userId,
thumbnailUrl: uploadResult.url, thumbnailUrl: resultUrl,
originalUrl: uploadResult.url, originalUrl: resultUrl,
imageSize: "2K", imageSize: "2K",
prompt: "去水印处理", prompt: "去水印处理",
modelId: "watermark-remove", modelId: "watermark-remove",
@ -184,9 +209,9 @@ async function persistWatermarkResult(params: {
mediaType: "image", mediaType: "image",
sourceType: "edited", sourceType: "edited",
status: "ready", status: "ready",
originalUrl: uploadResult.url, originalUrl: resultUrl,
previewUrl: uploadResult.url, previewUrl: resultUrl,
thumbnailUrl: uploadResult.url, thumbnailUrl: resultUrl,
displayName: "去水印处理", displayName: "去水印处理",
prompt: "去水印处理", prompt: "去水印处理",
modelId: "watermark-remove", modelId: "watermark-remove",
@ -217,8 +242,8 @@ async function persistWatermarkResult(params: {
return { return {
success: true, success: true,
original_url: uploadResult.url, original_url: resultUrl,
thumbnail_url: uploadResult.url, thumbnail_url: resultUrl,
processing_time: processing_time:
typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined, typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined,
}; };
@ -362,7 +387,7 @@ export async function POST(request: NextRequest) {
const body = await request.json(); const body = await request.json();
const { image_url } = body; const { image_url } = body;
if (!image_url) { if (typeof image_url !== "string" || !image_url) {
return NextResponse.json( return NextResponse.json(
{ error: "请提供图片URL" }, { error: "请提供图片URL" },
{ status: 400 } { status: 400 }
@ -413,8 +438,11 @@ export async function POST(request: NextRequest) {
); );
} }
} }
// 如果是 HTTP/HTTPS URL,下载后重新上传确保公网可访问 // 七牛上的公开图片可以直接交给去水印服务读取。
else if (image_url.startsWith("http://") || image_url.startsWith("https://")) { else if (image_url.startsWith("http://") || image_url.startsWith("https://")) {
const storedImageUrl = await resolveStoredQiniuImageUrl(image_url);
publicImageUrl = storedImageUrl || image_url;
if (!storedImageUrl) {
try { try {
console.log("[WatermarkRemove] 下载原图并上传到S3..."); console.log("[WatermarkRemove] 下载原图并上传到S3...");
const imageResponse = await fetch(image_url); const imageResponse = await fetch(image_url);
@ -423,14 +451,11 @@ export async function POST(request: NextRequest) {
} }
const imageBuffer = await imageResponse.arrayBuffer(); const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-input-${Date.now()}.jpg`; const filename = `watermark-input-${Date.now()}.jpg`;
const uploadResult = await uploadFile(filename, imageBuffer); publicImageUrl = (await uploadFile(filename, imageBuffer)).url;
publicImageUrl = uploadResult.url;
console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl); console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl);
} catch (downloadError) { } catch (downloadError) {
console.error("[WatermarkRemove] 下载/上传原图失败:", downloadError); console.error("[WatermarkRemove] 下载/上传原图失败,使用原始URL:", downloadError);
// 如果下载失败,尝试使用原始URL }
console.log("[WatermarkRemove] 尝试使用原始URL");
publicImageUrl = image_url;
} }
} else { } else {
return NextResponse.json( return NextResponse.json(

View File

@ -233,7 +233,8 @@ import {
type WorkflowSceneRecord, type WorkflowSceneRecord,
} from "@/lib/video-workflow/runtime"; } from "@/lib/video-workflow/runtime";
import { agentProjectService } from "@/services/agent-projects"; import { agentProjectService } from "@/services/agent-projects";
import { fetchCanvasSiteSettings } from "@/lib/client/canvas-site-settings"; import { getWorkflowSelectionsRequest } from "@/lib/agents/workflow-loading";
import { fetchCanvasSiteSettings, fetchWorkflowTemplatePrompts } from "@/lib/client/canvas-site-settings";
import { import {
canvasService, canvasService,
imageGenerationService, imageGenerationService,
@ -273,6 +274,17 @@ import type {
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000; const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5"; const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
function WorkflowLoadState({ name, error, retrying, onRetry }: { name: string; error?: Error; retrying?: boolean; onRetry: () => void }) {
const failed = Boolean(error && !retrying);
const timedOut = error?.name === "AbortError" || error?.name === "TimeoutError";
return (
<div className="flex items-center gap-3 rounded-xl border border-white/10 px-4 py-3 text-sm text-white/65" role={failed ? "alert" : "status"}>
<span>{failed ? `${name}${timedOut ? "加载超时" : "加载失败"}` : `正在加载${name}…`}</span>
{failed && <Button type="button" size="sm" variant="outline" onClick={onRetry}>重试</Button>}
</div>
);
}
function CanvasLazyPanelLoader({ function CanvasLazyPanelLoader({
label, label,
className, className,
@ -2176,13 +2188,13 @@ export default function CanvasEditorLegacyPage({
); );
const canvasSiteSettingsKey = requestedAgentMode const canvasSiteSettingsKey = requestedAgentMode
? hasActiveAgentProject ? hasActiveAgentProject
? "/api/site-settings?scope=agent-canvas" ? "/api/site-settings?scope=agent-canvas-base"
: "/api/site-settings?scope=agent-entry" : "/api/site-settings?scope=agent-entry"
: "/api/site-settings?scope=canvas"; : "/api/site-settings?scope=canvas";
const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] = const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] =
useState(false); useState(false);
const { data: siteSettingsData } = useSWR<{ const { data: siteSettingsData, error: siteSettingsError, isValidating: siteSettingsLoading, mutate: reloadSiteSettings } = useSWR<{
siteName?: string; siteName?: string;
siteLogo?: string | null; siteLogo?: string | null;
siteLogoDark?: string | null; siteLogoDark?: string | null;
@ -2213,6 +2225,7 @@ export default function CanvasEditorLegacyPage({
? 0 ? 0
: CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS, : CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS,
errorRetryCount: 1, errorRetryCount: 1,
shouldRetryOnError: !requestedAgentMode || !hasActiveAgentProject,
dedupingInterval: 30_000, dedupingInterval: 30_000,
revalidateOnFocus: true, revalidateOnFocus: true,
revalidateOnReconnect: true, revalidateOnReconnect: true,
@ -2279,9 +2292,34 @@ export default function CanvasEditorLegacyPage({
!requestedAgentMode || !requestedAgentMode ||
!agentProjectId || !agentProjectId ||
loadedAgentProjectId === agentProjectId; loadedAgentProjectId === agentProjectId;
const [workflowTemplateMarker, setWorkflowTemplateMarker] = useState<HTMLDivElement | null>(null);
const [workflowTemplatesRequested, setWorkflowTemplatesRequested] = useState(false);
const hasWorkflowBase = Boolean(siteSettingsData?.agentWorkflowUiConfig);
useEffect(() => {
if (!isAgentMode || !hasActiveAgentProject || !hasWorkflowBase || workflowTemplatesRequested) return;
const element = workflowTemplateMarker;
if (!element) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting) setWorkflowTemplatesRequested(true);
});
observer.observe(element);
return () => observer.disconnect();
}, [hasActiveAgentProject, hasWorkflowBase, isAgentMode, workflowTemplateMarker, workflowTemplatesRequested]);
const { data: workflowTemplates, error: workflowTemplatesError, isValidating: workflowTemplatesLoading, mutate: reloadWorkflowTemplates } = useSWR<{
steps: AgentWorkflowUiConfig["steps"];
}>(
isAgentMode && hasActiveAgentProject && hasWorkflowBase && workflowTemplatesRequested
? "/api/site-settings?scope=agent-workflow-templates"
: null,
fetchCanvasSiteSettings,
{ shouldRetryOnError: false }
);
const resolvedWorkflowUiConfig = useMemo( const resolvedWorkflowUiConfig = useMemo(
() => siteSettingsData?.agentWorkflowUiConfig || defaultAgentWorkflowUiConfig, () => {
[siteSettingsData?.agentWorkflowUiConfig] const base = siteSettingsData?.agentWorkflowUiConfig || defaultAgentWorkflowUiConfig;
return requestedAgentMode ? { ...base, steps: workflowTemplates?.steps ?? [] } : base;
},
[requestedAgentMode, siteSettingsData?.agentWorkflowUiConfig, workflowTemplates?.steps]
); );
const workflowPageFeatureSettings = useMemo( const workflowPageFeatureSettings = useMemo(
() => getAgentWorkflowPageFeatureSettings(resolvedWorkflowUiConfig), () => getAgentWorkflowPageFeatureSettings(resolvedWorkflowUiConfig),
@ -2330,7 +2368,7 @@ export default function CanvasEditorLegacyPage({
}); });
const sanitizedWorkflowSelections = useMemo( const sanitizedWorkflowSelections = useMemo(
() => () =>
requestedAgentMode && !siteSettingsData?.agentWorkflowUiConfig requestedAgentMode && !workflowTemplates
? workflowSelections ? workflowSelections
: sanitizeAgentWorkflowSelections({ : sanitizeAgentWorkflowSelections({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
@ -2338,7 +2376,7 @@ export default function CanvasEditorLegacyPage({
}), }),
[ [
requestedAgentMode, requestedAgentMode,
siteSettingsData?.agentWorkflowUiConfig, workflowTemplates,
resolvedWorkflowUiConfig, resolvedWorkflowUiConfig,
workflowSelections, workflowSelections,
] ]
@ -2346,6 +2384,14 @@ export default function CanvasEditorLegacyPage({
const deferredSanitizedWorkflowSelections = useDeferredValue( const deferredSanitizedWorkflowSelections = useDeferredValue(
sanitizedWorkflowSelections sanitizedWorkflowSelections
); );
const workflowSelectionRequest = isAgentMode && hasActiveAgentProject && hasLoadedCurrentAgentProject
? getWorkflowSelectionsRequest(sanitizedWorkflowSelections)
: "";
const { data: workflowPromptData, error: workflowPromptError, isValidating: workflowPromptLoading, mutate: reloadWorkflowPrompts } = useSWR(
workflowSelectionRequest ? ["/api/agent-workflow/template-prompts", workflowSelectionRequest] as const : null,
fetchWorkflowTemplatePrompts,
{ shouldRetryOnError: false }
);
const resolvedBrandModelConfig = useMemo( const resolvedBrandModelConfig = useMemo(
() => () =>
normalizeAgentWorkflowBrandModelConfig( normalizeAgentWorkflowBrandModelConfig(
@ -9921,6 +9967,8 @@ export default function CanvasEditorLegacyPage({
selectedBrandModel, selectedBrandModel,
appearanceRecognitionEnabled, appearanceRecognitionEnabled,
selections: deferredSanitizedWorkflowSelections, selections: deferredSanitizedWorkflowSelections,
resolvedSelections: workflowPromptData?.selectionRequest === getWorkflowSelectionsRequest(deferredSanitizedWorkflowSelections)
? workflowPromptData?.selections : undefined,
}); });
if (canceled) { if (canceled) {
@ -9943,6 +9991,7 @@ export default function CanvasEditorLegacyPage({
selectedBrandModel, selectedBrandModel,
workflowInputs, workflowInputs,
workflowMaterials, workflowMaterials,
workflowPromptData,
]); ]);
useEffect(() => { useEffect(() => {
workflowInputsRef.current = workflowInputs; workflowInputsRef.current = workflowInputs;
@ -10064,15 +10113,23 @@ export default function CanvasEditorLegacyPage({
[patchPersistedAgentWorkflowState] [patchPersistedAgentWorkflowState]
); );
const getLatestWorkflowContext = useCallback(() => { const getLatestWorkflowContext = useCallback(() => {
const selections = workflowSelectionsRef.current;
return buildAgentWorkflowComposerContext({ return buildAgentWorkflowComposerContext({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
inputs: workflowInputsRef.current, inputs: workflowInputsRef.current,
materials: workflowMaterialsRef.current, materials: workflowMaterialsRef.current,
selectedBrandModel: selectedBrandModelRef.current, selectedBrandModel: selectedBrandModelRef.current,
appearanceRecognitionEnabled: appearanceRecognitionEnabledRef.current, appearanceRecognitionEnabled: appearanceRecognitionEnabledRef.current,
selections: workflowSelectionsRef.current, selections,
resolvedSelections: workflowPromptData?.selectionRequest === getWorkflowSelectionsRequest(selections)
? workflowPromptData?.selections : undefined,
}); });
}, [resolvedWorkflowUiConfig]); }, [resolvedWorkflowUiConfig, workflowPromptData]);
const getWorkflowPromptStatus = useCallback(() => {
const currentRequest = getWorkflowSelectionsRequest(workflowSelectionsRef.current);
if (!currentRequest || currentRequest === workflowPromptData?.selectionRequest) return "ready";
return workflowPromptError && currentRequest === workflowSelectionRequest ? "error" : "loading";
}, [workflowPromptData, workflowPromptError, workflowSelectionRequest]);
const effectiveSelectedAgentPresetId = null; const effectiveSelectedAgentPresetId = null;
const { data: selectedAgentPresetResponse } = useSWR<{ const { data: selectedAgentPresetResponse } = useSWR<{
success?: boolean; success?: boolean;
@ -10447,8 +10504,9 @@ export default function CanvasEditorLegacyPage({
</div> </div>
) : null; ) : null;
const workflowStylePanel = useMemo( const workflowStylePanel = useMemo(() => (
() => ( <div ref={setWorkflowTemplateMarker} className="space-y-3">
{workflowTemplates ? (
<AgentWorkflowPanelDarkV2 <AgentWorkflowPanelDarkV2
config={resolvedWorkflowUiConfig} config={resolvedWorkflowUiConfig}
embedded embedded
@ -10460,17 +10518,23 @@ export default function CanvasEditorLegacyPage({
selections={sanitizedWorkflowSelections} selections={sanitizedWorkflowSelections}
showBrandStoreSection={false} showBrandStoreSection={false}
/> />
), ) : siteSettingsError && !siteSettingsData && !siteSettingsLoading ? (
[ <p className="text-sm text-white/65">请先在上方重试加载品牌和类目。</p>
handleWorkflowInputsChange, ) : !workflowTemplatesRequested ? (
handleWorkflowMaterialsChange, <Button type="button" variant="outline" onClick={() => setWorkflowTemplatesRequested(true)}>加载风格模板</Button>
handleWorkflowSelectionsChange, ) : (
resolvedWorkflowUiConfig, <WorkflowLoadState name="风格模板" error={workflowTemplatesError} retrying={workflowTemplatesLoading} onRetry={() => void reloadWorkflowTemplates().catch(() => {})} />
sanitizedWorkflowSelections, )}
workflowInputs, {workflowPromptError && (
workflowMaterials, <WorkflowLoadState name="所选模板的提示词" error={workflowPromptError} retrying={workflowPromptLoading} onRetry={() => void reloadWorkflowPrompts().catch(() => {})} />
] )}
); </div>
), [
handleWorkflowInputsChange, handleWorkflowMaterialsChange, handleWorkflowSelectionsChange,
reloadWorkflowPrompts, reloadWorkflowTemplates, resolvedWorkflowUiConfig, sanitizedWorkflowSelections,
siteSettingsData, siteSettingsError, siteSettingsLoading, workflowInputs, workflowMaterials, workflowPromptError,
workflowPromptLoading, workflowTemplates, workflowTemplatesError, workflowTemplatesLoading, workflowTemplatesRequested,
]);
if (canvasLoadError && !isAgentMode) { if (canvasLoadError && !isAgentMode) {
return <div role="alert" className="flex h-dvh flex-col items-center justify-center gap-4"> return <div role="alert" className="flex h-dvh flex-col items-center justify-center gap-4">
@ -10513,17 +10577,20 @@ export default function CanvasEditorLegacyPage({
theme="dark" theme="dark"
/> />
} }
brandStorePanel={ brandStorePanel={siteSettingsData?.agentWorkflowUiConfig ? (
<AgentWorkflowBrandStorePanel <AgentWorkflowBrandStorePanel
config={resolvedWorkflowUiConfig} config={resolvedWorkflowUiConfig}
inputs={workflowInputs} inputs={workflowInputs}
onInputsChange={handleWorkflowInputsChange} onInputsChange={handleWorkflowInputsChange}
theme="dark" theme="dark"
/> />
} ) : (
<WorkflowLoadState name="品牌和类目" error={siteSettingsError} retrying={siteSettingsLoading} onRetry={() => void reloadSiteSettings().catch(() => {})} />
)}
chatId={agentModeChatId} chatId={agentModeChatId}
deletedResultCollections={agentProjectDeletedResultCollections} deletedResultCollections={agentProjectDeletedResultCollections}
getLatestWorkflowContext={getLatestWorkflowContext} getLatestWorkflowContext={getLatestWorkflowContext}
getWorkflowPromptStatus={getWorkflowPromptStatus}
materialSelectionPanel={ materialSelectionPanel={
isWorkflowMaterialStepEnabled ? ( isWorkflowMaterialStepEnabled ? (
<AgentWorkflowMaterialSelectionPanel <AgentWorkflowMaterialSelectionPanel

View File

@ -4,6 +4,7 @@ import { ThemeProvider } from "@/components/theme-provider";
import { FaviconUpdater } from "@/components/favicon-updater"; import { FaviconUpdater } from "@/components/favicon-updater";
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer"; import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
import { AppUpdateNotice } from "@/components/app-update-notice"; import { AppUpdateNotice } from "@/components/app-update-notice";
import { PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE } from "@/lib/client/page-refresh";
import { getSiteSettings } from "@/lib/db/queries"; import { getSiteSettings } from "@/lib/db/queries";
import "./globals.css"; import "./globals.css";
@ -142,7 +143,7 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
showManualRecoveryNotice(); showManualRecoveryNotice();
return; return;
} }
if (lastReloadAt) return; if (lastReloadAt || document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) return;
var now = Date.now(); var now = Date.now();
// If storage is blocked, a reload cannot remember its attempt and must not run. // If storage is blocked, a reload cannot remember its attempt and must not run.
if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return; if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return;
@ -153,6 +154,10 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
showManualRecoveryNotice(); showManualRecoveryNotice();
return; return;
} }
if (document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) {
if (safeSetSessionStorage(RECOVERY_KEY, '0')) lastReloadAt = 0;
return;
}
window.location.reload(); window.location.reload();
}, 800); }, 800);
} }

View File

@ -159,6 +159,7 @@ import type {
} from "@/types/agent"; } from "@/types/agent";
import { import {
createEmptyAgentModeWorkspaceState, createEmptyAgentModeWorkspaceState,
MAX_DETAIL_REPLACEMENT_REFERENCES,
} from "@/lib/agents/mode-workspace-state"; } from "@/lib/agents/mode-workspace-state";
import { import {
createEmptyAgentGenerationProjectWorkspaceState, createEmptyAgentGenerationProjectWorkspaceState,
@ -176,6 +177,7 @@ export interface AgentGenerationWorkspaceDarkV2Props {
workflowFeatureSettings?: AgentWorkflowPageFeatureSettings | null; workflowFeatureSettings?: AgentWorkflowPageFeatureSettings | null;
workflowPromptOptimizationEnabled?: boolean; workflowPromptOptimizationEnabled?: boolean;
getLatestWorkflowContext?: () => AgentWorkflowComposerContext | null; getLatestWorkflowContext?: () => AgentWorkflowComposerContext | null;
getWorkflowPromptStatus?: () => "ready" | "loading" | "error";
onBack?: () => void; onBack?: () => void;
brandStorePanel?: ReactNode; brandStorePanel?: ReactNode;
materialSelectionPanel?: ReactNode; materialSelectionPanel?: ReactNode;
@ -212,6 +214,7 @@ export interface AgentGenerationWorkspaceDarkV2Props {
interface AgentWorkflowModelOption { interface AgentWorkflowModelOption {
id: string; id: string;
displayName: string; displayName: string;
providerName?: string;
providerType: string; providerType: string;
supportsImageGen?: boolean; supportsImageGen?: boolean;
isMultimodal?: boolean; isMultimodal?: boolean;
@ -2642,7 +2645,9 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
? record.requestedCardIds ? record.requestedCardIds
.map((entry) => String(entry || "").trim()) .map((entry) => String(entry || "").trim())
.filter(Boolean) .filter(Boolean)
.slice(0, MAX_AGENT_MODE_REFERENCE_COUNT) .slice(0, record.modeType === "detail_replacement"
? MAX_DETAIL_REPLACEMENT_REFERENCES
: MAX_AGENT_MODE_REFERENCE_COUNT)
: null, : null,
} satisfies LocalGenerationPendingState; } satisfies LocalGenerationPendingState;
}); });
@ -3085,6 +3090,7 @@ export function AgentGenerationWorkspaceDarkV2({
workflowFeatureSettings = null, workflowFeatureSettings = null,
workflowPromptOptimizationEnabled = true, workflowPromptOptimizationEnabled = true,
getLatestWorkflowContext, getLatestWorkflowContext,
getWorkflowPromptStatus,
onBack, onBack,
brandStorePanel, brandStorePanel,
materialSelectionPanel, materialSelectionPanel,
@ -3382,12 +3388,13 @@ export function AgentGenerationWorkspaceDarkV2({
} }
); );
const { data: modelsData } = useSWR<{ const { data: modelsData, error: modelsError, mutate: refreshModels } = useSWR<{
models: AgentWorkflowModelOption[]; models: AgentWorkflowModelOption[];
defaultModelId?: string | null; defaultModelId?: string | null;
agentWorkflowConfiguredDefaultModelId?: string | null; agentWorkflowConfiguredDefaultModelId?: string | null;
agentWorkflowDefaultModelId?: string | null; agentWorkflowDefaultModelId?: string | null;
agentWorkflowConfiguredModels?: AgentWorkflowModelOption[]; agentWorkflowConfiguredModels?: AgentWorkflowModelOption[];
detailReplacementModel?: AgentWorkflowModelOption | null;
agentModel?: any; agentModel?: any;
}>("/api/models", fetcher); }>("/api/models", fetcher);
@ -3433,17 +3440,6 @@ export function AgentGenerationWorkspaceDarkV2({
const activeBusinessModeDefaultModelId = !requiresPresetSelection const activeBusinessModeDefaultModelId = !requiresPresetSelection
? String(activeBusinessMode?.defaultModelId || "").trim() || null ? String(activeBusinessMode?.defaultModelId || "").trim() || null
: null; : null;
const lockedWorkflowModelId =
activeBusinessModeDefaultModelId ||
selectedAgentPreset?.defaultModelId ||
modelsData?.agentWorkflowConfiguredDefaultModelId ||
modelsData?.agentWorkflowDefaultModelId ||
null;
const isWorkflowModelLocked = Boolean(lockedWorkflowModelId);
const effectiveDefaultModelId =
lockedWorkflowModelId ||
modelsData?.defaultModelId ||
null;
const presetDrivenResultModeType: AgentGenerationResultModeType = const presetDrivenResultModeType: AgentGenerationResultModeType =
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement" selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement"
? detailReplacementMethod === "ai_detail_generate" ? detailReplacementMethod === "ai_detail_generate"
@ -3459,6 +3455,19 @@ export function AgentGenerationWorkspaceDarkV2({
const currentResultModeType: AgentGenerationResultModeType = requiresPresetSelection const currentResultModeType: AgentGenerationResultModeType = requiresPresetSelection
? presetDrivenResultModeType ? presetDrivenResultModeType
: businessModeResultModeType; : businessModeResultModeType;
const detailReplacementModel = currentResultModeType === "detail_replacement"
? modelsData?.detailReplacementModel
: null;
const isDetailReplacementModelLoading = currentResultModeType === "detail_replacement" && !modelsData;
const lockedWorkflowModelId =
detailReplacementModel?.id ||
activeBusinessModeDefaultModelId ||
selectedAgentPreset?.defaultModelId ||
modelsData?.agentWorkflowConfiguredDefaultModelId ||
modelsData?.agentWorkflowDefaultModelId ||
null;
const isWorkflowModelLocked = Boolean(lockedWorkflowModelId);
const effectiveDefaultModelId = lockedWorkflowModelId || modelsData?.defaultModelId || null;
const isSceneGenerationPreset = currentResultModeType === "scene_generation"; const isSceneGenerationPreset = currentResultModeType === "scene_generation";
const isDetailReplacementPreset = const isDetailReplacementPreset =
currentResultModeType === "detail_replacement" || currentResultModeType === "detail_replacement" ||
@ -4110,12 +4119,15 @@ export function AgentGenerationWorkspaceDarkV2({
); );
const executableModelOptions = useMemo(() => { const executableModelOptions = useMemo(() => {
const seen = new Set<string>(); const seen = new Set<string>();
return [...selectableModels, ...configuredWorkflowModels].filter((model) => { const workflowModels = detailReplacementModel
? [detailReplacementModel, ...configuredWorkflowModels]
: configuredWorkflowModels;
return [...selectableModels, ...workflowModels].filter((model) => {
if (seen.has(model.id)) return false; if (seen.has(model.id)) return false;
seen.add(model.id); seen.add(model.id);
return true; return true;
}); });
}, [configuredWorkflowModels, selectableModels]); }, [configuredWorkflowModels, detailReplacementModel, selectableModels]);
const executableModelIdSet = useMemo( const executableModelIdSet = useMemo(
() => new Set(executableModelOptions.map((model) => model.id)), () => new Set(executableModelOptions.map((model) => model.id)),
[executableModelOptions] [executableModelOptions]
@ -4134,8 +4146,9 @@ export function AgentGenerationWorkspaceDarkV2({
selectableModels[0], selectableModels[0],
[executableModelOptions, lockedWorkflowModel, selectableModels, selectedModelId] [executableModelOptions, lockedWorkflowModel, selectableModels, selectedModelId]
); );
const lockedWorkflowModelLabel = const lockedWorkflowModelLabel = detailReplacementModel?.providerName
lockedWorkflowModel?.displayName || ? `${detailReplacementModel.displayName} 「${detailReplacementModel.providerName}」`
: lockedWorkflowModel?.displayName ||
selectedAgentPreset?.defaultModel?.displayName || selectedAgentPreset?.defaultModel?.displayName ||
(lockedWorkflowModelId ? `配置模型不可用(${lockedWorkflowModelId})` : "未配置"); (lockedWorkflowModelId ? `配置模型不可用(${lockedWorkflowModelId})` : "未配置");
const [activeToolStep, setActiveToolStep] = useState<ToolStepKey>("settings"); const [activeToolStep, setActiveToolStep] = useState<ToolStepKey>("settings");
@ -6314,10 +6327,24 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`); toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`);
return; return;
} }
if (isDetailReplacementModelLoading) {
if (modelsError) {
toast.error("详情替换模型加载失败,正在重新读取");
void refreshModels().catch(() => {});
} else {
toast.info("正在读取详情替换模型,请稍候");
}
return;
}
if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) { if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) {
toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`); toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`);
return; return;
} }
const promptStatus = getWorkflowPromptStatus?.();
if (promptStatus === "loading" || promptStatus === "error") {
toast.error(promptStatus === "error" ? "请先在风格模板区重试加载提示词" : "正在加载所选模板的提示词,请稍候");
return;
}
const resolvedWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext; const resolvedWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext;
if ( if (
isDetailReplacementPreset && isDetailReplacementPreset &&
@ -6376,6 +6403,10 @@ export function AgentGenerationWorkspaceDarkV2({
} }
if (replacementModeType === "detail_replacement") { if (replacementModeType === "detail_replacement") {
if (detailBatchSubmittingRef.current) return; if (detailBatchSubmittingRef.current) return;
if (replacementExecutionCards.length > MAX_DETAIL_REPLACEMENT_REFERENCES) {
toast.error(`本次选中了 ${replacementExecutionCards.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`);
return;
}
if (!isValidChatUuid(projectId)) { if (!isValidChatUuid(projectId)) {
toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换"); toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换");
return; return;
@ -6396,13 +6427,14 @@ export function AgentGenerationWorkspaceDarkV2({
const saved = await onPersistProject?.(); const saved = await onPersistProject?.();
if (saved === false) throw new Error("项目尚未保存,请重试"); if (saved === false) throw new Error("项目尚未保存,请重试");
const requestId = generateUUID(); const requestId = generateUUID();
const response = await fetchWithErrorHandlers("/api/agents/detail-replacement/batches", { const response = await fetch("/api/agents/detail-replacement/batches", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
projectId, projectId,
requestId, requestId,
model: resolvedModelId, model: resolvedModelId,
expectedModelId: resolvedModelId === "auto" ? undefined : resolvedModelId,
imageSize: imageParams.imageSize, imageSize: imageParams.imageSize,
replacementStrategy: detailReplacementStrategy, replacementStrategy: detailReplacementStrategy,
prompt: detailReplacementStrategy === "product_only" prompt: detailReplacementStrategy === "product_only"
@ -6426,6 +6458,7 @@ export function AgentGenerationWorkspaceDarkV2({
}); });
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.task?.id) { if (!response.ok || !payload?.task?.id) {
if (payload?.code === "DETAIL_REPLACEMENT_MODEL_CHANGED") void refreshModels().catch(() => {});
throw new Error(payload?.error || "详情替换任务提交失败"); throw new Error(payload?.error || "详情替换任务提交失败");
} }
setPendingGeneration({ setPendingGeneration({
@ -6613,6 +6646,8 @@ export function AgentGenerationWorkspaceDarkV2({
input, input,
isDetailAiGenerationMethod, isDetailAiGenerationMethod,
isDetailReplacementPreset, isDetailReplacementPreset,
isDetailReplacementModelLoading,
modelsError,
isSelectedBusinessModeUnavailable, isSelectedBusinessModeUnavailable,
isSkuReplacementPreset, isSkuReplacementPreset,
effectiveDefaultModelId, effectiveDefaultModelId,
@ -6629,6 +6664,7 @@ export function AgentGenerationWorkspaceDarkV2({
skuResultCards, skuResultCards,
userMessageCount, userMessageCount,
getLatestWorkflowContext, getLatestWorkflowContext,
getWorkflowPromptStatus,
isSceneGenerationPreset, isSceneGenerationPreset,
workflowContext, workflowContext,
sceneMessageAttachments, sceneMessageAttachments,
@ -6639,6 +6675,7 @@ export function AgentGenerationWorkspaceDarkV2({
onPersistProject, onPersistProject,
projectId, projectId,
refreshDetailBatch, refreshDetailBatch,
refreshModels,
]); ]);
const handleStopGeneration = useCallback(() => { const handleStopGeneration = useCallback(() => {
detailGenerationCancelRef.current = true; detailGenerationCancelRef.current = true;
@ -7802,6 +7839,11 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error(detailGenerationValidationIssues[0] || "请先补齐 AI详情生成参数"); toast.error(detailGenerationValidationIssues[0] || "请先补齐 AI详情生成参数");
return; return;
} }
const promptStatus = getWorkflowPromptStatus?.();
if (promptStatus === "loading" || promptStatus === "error") {
toast.error(promptStatus === "error" ? "请先在风格模板区重试加载提示词" : "正在加载所选模板的提示词,请稍候");
return;
}
const productAttachments = detailGenerationProductSlotIndexes.map((index) => const productAttachments = detailGenerationProductSlotIndexes.map((index) =>
detailGenerationSlots[index] detailGenerationSlots[index]
@ -9406,9 +9448,11 @@ export function AgentGenerationWorkspaceDarkV2({
</Select> </Select>
) : ( ) : (
<div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 text-sm text-white/70"> <div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 text-sm text-white/70">
{isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"} {isDetailReplacementModelLoading
? modelsError ? "详情替换模型加载失败,点击批量替换可重试" : "正在读取详情替换模型"
: isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"}
<span className="ml-2 font-medium text-white"> <span className="ml-2 font-medium text-white">
{isWorkflowModelLocked {isDetailReplacementModelLoading ? null : isWorkflowModelLocked
? lockedWorkflowModelLabel ? lockedWorkflowModelLabel
: currentModel?.displayName || "自动选择"} : currentModel?.displayName || "自动选择"}
</span> </span>

View File

@ -3,6 +3,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { Check, ImageIcon, Loader2, UserRound } from "lucide-react"; import { Check, ImageIcon, Loader2, UserRound } from "lucide-react";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config"; import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config";
@ -229,12 +230,11 @@ export function AgentWorkflowBrandModelPicker({
isDark ? "bg-white/[0.04]" : "bg-muted/40" isDark ? "bg-white/[0.04]" : "bg-muted/40"
)} )}
> >
<img <AgentPreviewImage
src={image.imageUrl} src={image.imageUrl}
alt={image.displayName || `${normalizedBrand} 模特图`} alt={image.displayName || `${normalizedBrand} 模特图`}
className="h-full w-full object-cover" className="h-full w-full object-cover"
loading="lazy" previewWidth={360}
decoding="async"
/> />
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2"> <div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white"> <span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">

View File

@ -1,14 +1,15 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, useSyncExternalStore } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog";
import { preparePageRefresh } from "@/lib/client/page-refresh"; import { isPageDownloadActive, preparePageRefresh, subscribeToPageDownloadActivity } from "@/lib/client/page-refresh";
export function AppUpdateNotice() { export function AppUpdateNotice() {
const [availableVersion, setAvailableVersion] = useState(""); const [availableVersion, setAvailableVersion] = useState("");
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const downloading = useSyncExternalStore(subscribeToPageDownloadActivity, isPageDownloadActive, () => false);
useEffect(() => { useEffect(() => {
let disposed = false; let disposed = false;
let controller: AbortController | undefined; let controller: AbortController | undefined;
@ -60,7 +61,7 @@ export function AppUpdateNotice() {
setRefreshing(false); setRefreshing(false);
} }
}; };
return <AlertDialog open={Boolean(availableVersion)}> return <AlertDialog open={Boolean(availableVersion) && !downloading}>
<AlertDialogContent className="z-[10001]" overlayClassName="z-[10000]" onEscapeKeyDown={event => event.preventDefault()}> <AlertDialogContent className="z-[10001]" overlayClassName="z-[10000]" onEscapeKeyDown={event => event.preventDefault()}>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>平台已更新</AlertDialogTitle> <AlertDialogTitle>平台已更新</AlertDialogTitle>

View File

@ -78,7 +78,7 @@ import {
type UploadProgress, type UploadProgress,
} from "@/lib/client/upload-with-retry"; } from "@/lib/client/upload-with-retry";
import { import {
downloadCanvasImageAsPng, downloadOriginalImage,
getCanvasImageDownloadErrorMessage, getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress, type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download"; } from "@/lib/client/canvas-image-download";
@ -1462,7 +1462,7 @@ function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
} }
// 获取图片尺寸(保留原图分辨率 + 画布显示尺寸) // 获取图片尺寸(保留原图分辨率 + 画布显示尺寸)
function getImageDimensions(url: string): Promise<{ function getImageDimensions(source: string | File): Promise<{
width: number; width: number;
height: number; height: number;
originalWidth: number; originalWidth: number;
@ -1470,6 +1470,7 @@ function getImageDimensions(url: string): Promise<{
}> { }> {
return new Promise((resolve) => { return new Promise((resolve) => {
const img = new Image(); const img = new Image();
const objectUrl = typeof source === "string" ? null : URL.createObjectURL(source);
let settled = false; let settled = false;
const resolveOnce = (dimensions: { const resolveOnce = (dimensions: {
width: number; width: number;
@ -1482,9 +1483,11 @@ function getImageDimensions(url: string): Promise<{
window.clearTimeout(timeout); window.clearTimeout(timeout);
img.onload = null; img.onload = null;
img.onerror = null; img.onerror = null;
if (objectUrl) URL.revokeObjectURL(objectUrl);
resolve(dimensions); resolve(dimensions);
}; };
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
img.removeAttribute("src");
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS); resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
}, CANVAS_IMAGE_DIMENSION_TIMEOUT_MS); }, CANVAS_IMAGE_DIMENSION_TIMEOUT_MS);
@ -1517,7 +1520,7 @@ function getImageDimensions(url: string): Promise<{
// 加载失败时使用默认尺寸 // 加载失败时使用默认尺寸
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS); resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
}; };
img.src = url; img.src = objectUrl ?? String(source);
}); });
} }
@ -4464,17 +4467,15 @@ export function CanvasChatPanel({
setDownloadProgress({ setDownloadProgress({
phase: "preparing", phase: "preparing",
message: "正在准备原图...", message: "正在准备原图...",
percent: 5,
}); });
try { try {
await downloadCanvasImageAsPng({ await downloadOriginalImage({
url: originalUrl, url: originalUrl,
candidates: [raw, buildImageProxyUrl(originalUrl, { delivery: "proxy" })],
signal: controller.signal, signal: controller.signal,
onProgress: setDownloadProgress, onProgress: setDownloadProgress,
}); });
toast.success("原图已开始下载"); toast.success("已开始下载");
} catch (error) { } catch (error) {
if (controller.signal.aborted) { if (controller.signal.aborted) {
toast.message("已取消下载"); toast.message("已取消下载");
@ -7806,7 +7807,6 @@ export function CanvasChatPanel({
existingMessageImageKeys.add(messageImageKey); existingMessageImageKeys.add(messageImageKey);
} }
const dimensions = await getImageDimensions(url);
if (useCanvasStore.getState().projectId !== canvasId || activeChatIdRef.current !== activeChatId) return; if (useCanvasStore.getState().projectId !== canvasId || activeChatIdRef.current !== activeChatId) return;
const compareSourceUrlRaw = const compareSourceUrlRaw =
sourceImageUrls[Math.min(index, Math.max(0, sourceImageUrls.length - 1))] || sourceImageUrls[Math.min(index, Math.max(0, sourceImageUrls.length - 1))] ||
@ -7839,7 +7839,7 @@ export function CanvasChatPanel({
const placeholderNode = reservedPlaceholderNode const placeholderNode = reservedPlaceholderNode
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null ? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
: null; : null;
const size = { width: dimensions.width, height: dimensions.height }; const size = placeholderNode?.size || anchorNode?.size || { width: 300, height: 300 };
const position = placeholderNode const position = placeholderNode
? getCenteredReplacementPosition(placeholderNode, size) ? getCenteredReplacementPosition(placeholderNode, size)
: getAnchoredGenerationPosition({ : getAnchoredGenerationPosition({
@ -7857,8 +7857,6 @@ export function CanvasChatPanel({
prompt: generationPrompt, prompt: generationPrompt,
originalSrc: canUseCompareSource ? compareSourceUrl : undefined, originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
metadata: { metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt, generationPrompt,
sourceMessageId: messageId || undefined, sourceMessageId: messageId || undefined,
sourceImageKey, sourceImageKey,
@ -9583,7 +9581,6 @@ export function CanvasChatPanel({
continue; continue;
} }
const dimensions = await getImageDimensions(url);
if (cancelled) return; if (cancelled) return;
const compareSourceUrlRaw = const compareSourceUrlRaw =
nearestUserImageUrls[Math.min(index, Math.max(0, nearestUserImageUrls.length - 1))] || nearestUserImageUrls[Math.min(index, Math.max(0, nearestUserImageUrls.length - 1))] ||
@ -9616,20 +9613,7 @@ export function CanvasChatPanel({
const placeholderNode = reservedPlaceholderNode const placeholderNode = reservedPlaceholderNode
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null ? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
: null; : null;
const size = placeholderNode const size = placeholderNode?.size || anchorNode?.size || { width: 300, height: 300 };
? { width: dimensions.width, height: dimensions.height }
: anchorNode
? {
width: Math.max(
1,
Math.round(Number(anchorNode.size?.width || dimensions.width))
),
height: Math.max(
1,
Math.round(Number(anchorNode.size?.height || dimensions.height))
),
}
: { width: dimensions.width, height: dimensions.height };
const position = placeholderNode const position = placeholderNode
? getCenteredReplacementPosition(placeholderNode, size) ? getCenteredReplacementPosition(placeholderNode, size)
: getAnchoredGenerationPosition({ : getAnchoredGenerationPosition({
@ -9648,8 +9632,6 @@ export function CanvasChatPanel({
prompt: generationPrompt || "对话生成", prompt: generationPrompt || "对话生成",
originalSrc: canUseCompareSource ? compareSourceUrl : undefined, originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
metadata: { metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt: generationPrompt || undefined, generationPrompt: generationPrompt || undefined,
sourceMessageId: message.id, sourceMessageId: message.id,
sourceImageKey: imageEntry.sourceImageKey, sourceImageKey: imageEntry.sourceImageKey,
@ -10376,7 +10358,7 @@ export function CanvasChatPanel({
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId }) buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
); );
const dimensions = await getImageDimensions(imageUrl); const dimensions = await getImageDimensions(file);
const nodeIndex = nodes.length + slotIndex; const nodeIndex = nodes.length + slotIndex;
const spacing = 20; const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing); const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
@ -10454,7 +10436,7 @@ export function CanvasChatPanel({
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId }) buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
); );
const dimensions = await getImageDimensions(imageUrl); const dimensions = await getImageDimensions(file);
const nodeIndex = nodes.length + slotIndex; const nodeIndex = nodes.length + slotIndex;
const spacing = 20; const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing); const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
@ -13740,7 +13722,7 @@ export function CanvasChatPanel({
}; };
onUploaded?.(uploadedBase); onUploaded?.(uploadedBase);
clearComposerUploadProgress(250); clearComposerUploadProgress(250);
const dimensions = await getImageDimensions(imageUrl); const dimensions = await getImageDimensions(file);
const spacing = 20; const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing); const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing); const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
@ -14929,7 +14911,7 @@ export function CanvasChatPanel({
firstImageUrl = imageUrl; firstImageUrl = imageUrl;
} }
const dimensions = await getImageDimensions(imageUrl); const dimensions = await getImageDimensions(file);
updateUploadedImageAttachmentDimensions(imageUrl, dimensions); updateUploadedImageAttachmentDimensions(imageUrl, dimensions);
// 获取图片原始尺寸后添加到画布 // 获取图片原始尺寸后添加到画布

View File

@ -28,6 +28,7 @@ export function CanvasImageDownloadProgressDialog({
: null; : null;
const loadedLabel = formatBytes(progress.loadedBytes); const loadedLabel = formatBytes(progress.loadedBytes);
const totalLabel = formatBytes(progress.totalBytes); const totalLabel = formatBytes(progress.totalBytes);
const converting = progress.phase === "converting";
return createPortal( return createPortal(
<div <div
@ -41,12 +42,12 @@ export function CanvasImageDownloadProgressDialog({
{progress.phase === "saving" ? ( {progress.phase === "saving" ? (
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
) : ( ) : (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 motion-safe:animate-spin" />
)} )}
</span> </span>
<div> <div>
<div className="text-[14px] font-semibold">下载原图</div> <div className="text-[14px] font-semibold">下载原图</div>
<div className="mt-0.5 text-[12px] text-white/58">{progress.message}</div> <div className="mt-0.5 text-[12px] text-white/58" role="status">{progress.message}</div>
</div> </div>
</div> </div>
{onCancel ? ( {onCancel ? (
@ -63,14 +64,19 @@ export function CanvasImageDownloadProgressDialog({
<div className="mt-4"> <div className="mt-4">
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55"> <div className="mb-2 flex items-center justify-between text-[12px] text-white/55">
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : "正在处理文件"}</span> <span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : converting ? "格式转换中" : "正在处理文件"}</span>
<span>{percent !== null ? `${percent}%` : "..."}</span> <span>{percent !== null ? `${percent}%` : "..."}</span>
</div> </div>
<div className="relative h-2 overflow-hidden rounded-full bg-white/10"> <div
className="relative h-2 overflow-hidden rounded-full bg-white/10"
role="progressbar"
aria-label={converting ? "图片格式转换" : "图片下载"}
aria-valuenow={percent ?? undefined}
>
<div <div
className={cn( className={cn(
"absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300", "absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300",
percent === null && "w-1/3 animate-[download-indeterminate_1.15s_ease-in-out_infinite]" percent === null && "w-1/3 motion-safe:animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
)} )}
style={percent !== null ? { width: `${percent}%` } : undefined} style={percent !== null ? { width: `${percent}%` } : undefined}
/> />

View File

@ -178,7 +178,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { import {
downloadCanvasImageAsPng, downloadOriginalImage,
getCanvasImageDownloadErrorMessage, getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress, type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download"; } from "@/lib/client/canvas-image-download";
@ -2482,14 +2482,11 @@ export function ImageNode({
[compareButtonVisualSize] [compareButtonVisualSize]
); );
const shouldUseHighFidelityDisplayImage = const shouldUseHighFidelityDisplayImage =
isSelected ||
isHovered ||
isCompareMode || isCompareMode ||
boxCutoutMode || boxCutoutMode ||
localEditMode || localEditMode ||
cropPanelOpen || cropPanelOpen ||
imageAdjustPanelOpen || imageAdjustPanelOpen ||
fullscreenPreviewOpen ||
isThisNodePicking; isThisNodePicking;
const displayImageWidth = useMemo(() => { const displayImageWidth = useMemo(() => {
const devicePixelRatio = const devicePixelRatio =
@ -2522,15 +2519,6 @@ export function ImageNode({
}), }),
[data.src, displayImageQuality, displayImageWidth] [data.src, displayImageQuality, displayImageWidth]
); );
const fullscreenPreviewSrc = useMemo(() => {
const originalUrl = resolveOriginalImageUrl(data.src || "");
return buildImageProxyUrl(originalUrl || data.src || "", {
delivery: "proxy",
width: 4096,
quality: 92,
format: "webp",
});
}, [data.src]);
const displayCompareSrc = useMemo( const displayCompareSrc = useMemo(
() => () =>
buildImageProxyUrl(compareSource, { buildImageProxyUrl(compareSource, {
@ -8235,7 +8223,6 @@ export function ImageNode({
} }
}; };
// 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。
const isDownloadingOriginalImage = Boolean(downloadProgress); const isDownloadingOriginalImage = Boolean(downloadProgress);
const handleDownload = async () => { const handleDownload = async () => {
if (!data.src) return; if (!data.src) return;
@ -8249,24 +8236,16 @@ export function ImageNode({
setDownloadProgress({ setDownloadProgress({
phase: "preparing", phase: "preparing",
message: "正在准备原图...", message: "正在准备原图...",
percent: 5,
}); });
try { try {
await downloadCanvasImageAsPng({ await downloadOriginalImage({
url: data.src, url: resolveOriginalImageUrl(data.src) || data.src,
name: displayTitle, name: displayTitle,
candidates: [
displayImageSrc,
fullscreenPreviewSrc,
buildImageProxyUrl(resolveOriginalImageUrl(data.src) || data.src, {
delivery: "proxy",
}),
],
signal: controller.signal, signal: controller.signal,
onProgress: setDownloadProgress, onProgress: setDownloadProgress,
}); });
toast.success("原图已开始下载"); toast.success("已开始下载");
} catch (error) { } catch (error) {
if (controller.signal.aborted) { if (controller.signal.aborted) {
toast.message("已取消下载"); toast.message("已取消下载");

View File

@ -14,6 +14,8 @@ import {
sanitizeAgentDetailBrief, sanitizeAgentDetailBrief,
} from "@/lib/agents/detail-generation-config"; } from "@/lib/agents/detail-generation-config";
export const MAX_DETAIL_REPLACEMENT_REFERENCES = 64;
export function createEmptyAgentModeWorkspaceState(): AgentModeWorkspaceState { export function createEmptyAgentModeWorkspaceState(): AgentModeWorkspaceState {
return { return {
detailReplacement: { detailReplacement: {

View File

@ -407,14 +407,10 @@ function buildPromptSection(title: string, lines: string[]) {
return `【${title}】\n${normalizedLines.join("\n")}`; return `【${title}】\n${normalizedLines.join("\n")}`;
} }
export function buildAgentWorkflowComposerContext(params: { export function resolveAgentWorkflowComposerSelections(params: {
config?: AgentWorkflowUiConfig | null; config?: AgentWorkflowUiConfig | null;
inputs: AgentWorkflowInputsValue;
materials?: AgentWorkflowMaterialSelections | null;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
appearanceRecognitionEnabled?: boolean;
selections: Partial<Record<AgentWorkflowStepKey, string>>; selections: Partial<Record<AgentWorkflowStepKey, string>>;
}): AgentWorkflowComposerContext | null { }): AgentWorkflowComposerContext["selections"] {
const config = params.config || defaultAgentWorkflowUiConfig; const config = params.config || defaultAgentWorkflowUiConfig;
const steps = flattenVisibleAgentWorkflowSteps( const steps = flattenVisibleAgentWorkflowSteps(
getEnabledAgentWorkflowStepTree(Array.isArray(config.steps) ? config.steps : []), getEnabledAgentWorkflowStepTree(Array.isArray(config.steps) ? config.steps : []),
@ -422,13 +418,11 @@ export function buildAgentWorkflowComposerContext(params: {
) )
.map(({ step }) => step) .map(({ step }) => step)
.filter((step) => Array.isArray(step.options) && step.options.length > 0); .filter((step) => Array.isArray(step.options) && step.options.length > 0);
const selections = steps return steps.flatMap((step) => {
.map((step) => {
const selectedOptions = getAgentWorkflowSelectedOptionPath({ const selectedOptions = getAgentWorkflowSelectedOptionPath({
step, step,
selectionValue: params.selections[step.key], selectionValue: params.selections[step.key],
}); });
if (selectedOptions.length === 0) return [];
return selectedOptions.map((option) => ({ return selectedOptions.map((option) => ({
stepKey: step.key, stepKey: step.key,
stepTitle: step.title, stepTitle: step.title,
@ -436,8 +430,19 @@ export function buildAgentWorkflowComposerContext(params: {
optionName: option.buttonName, optionName: option.buttonName,
promptFragment: option.promptFragment || option.buttonName, promptFragment: option.promptFragment || option.buttonName,
})); }));
}) });
.flat() as AgentWorkflowComposerContext["selections"]; }
export function buildAgentWorkflowComposerContext(params: {
config?: AgentWorkflowUiConfig | null;
inputs: AgentWorkflowInputsValue;
materials?: AgentWorkflowMaterialSelections | null;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
appearanceRecognitionEnabled?: boolean;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
resolvedSelections?: AgentWorkflowComposerContext["selections"];
}): AgentWorkflowComposerContext | null {
const selections = params.resolvedSelections ?? resolveAgentWorkflowComposerSelections(params);
const referenceImages = getSelectedWorkflowReferenceImages({ const referenceImages = getSelectedWorkflowReferenceImages({
inputs: params.inputs, inputs: params.inputs,
materials: params.materials, materials: params.materials,

View File

@ -0,0 +1,18 @@
import type { AgentWorkflowOptionUiConfig, AgentWorkflowStepUiConfig } from "@/types/agent";
export function omitAgentWorkflowPrompts(steps: AgentWorkflowStepUiConfig[]): AgentWorkflowStepUiConfig[] {
const omitOptionPrompt = ({ promptFragment: _prompt, children, ...option }: AgentWorkflowOptionUiConfig): AgentWorkflowOptionUiConfig => ({
...option,
...(children?.length ? { children: children.map(omitOptionPrompt) } : {}),
});
return steps.map(({ options, children, ...step }) => ({
...step,
options: options.map(omitOptionPrompt),
...(children?.length ? { children: omitAgentWorkflowPrompts(children) } : {}),
}));
}
export function getWorkflowSelectionsRequest(selections: Record<string, string | undefined>) {
const entries = Object.entries(selections).filter(([, value]) => value?.trim());
return entries.length ? JSON.stringify(Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b)))) : "";
}

View File

@ -13,13 +13,18 @@ export type AgentImageParameters = {
}; };
export type AgentImagePreferences = { modelId?: string; parameters?: AgentImageParameters }; export type AgentImagePreferences = { modelId?: string; parameters?: AgentImageParameters };
export function selectedAgentImageCount(preferences?: AgentImagePreferences) {
const count = preferences?.parameters?.imageCount ?? preferences?.parameters?.n;
return count === undefined ? undefined : Math.floor(count);
}
export function applyAgentImagePreferences<T extends Record<string, unknown>>( export function applyAgentImagePreferences<T extends Record<string, unknown>>(
input: T, input: T,
preferences?: AgentImagePreferences, preferences?: AgentImagePreferences,
): T & AgentImageParameters & { modelId?: string } { ): T & AgentImageParameters & { modelId?: string } {
const parameters = Object.fromEntries(Object.entries(preferences?.parameters || {}) const parameters = Object.fromEntries(Object.entries(preferences?.parameters || {})
.filter(([, value]) => value !== undefined)); .filter(([, value]) => value !== undefined));
const count = preferences?.parameters?.imageCount ?? preferences?.parameters?.n; const count = selectedAgentImageCount(preferences);
return { return {
...input, ...input,
...parameters, ...parameters,

View File

@ -1,5 +1,14 @@
import type { AgentCallRecord, AgentRunSnapshot } from "./protocol"; import type { AgentCallRecord, AgentRunSnapshot } from "./protocol";
export function countAgentGeneratedImages(calls: AgentCallRecord[]) {
return calls.reduce((count, call) => {
const output = call.output;
if (call.status !== "succeeded" || !output?.appliedImagePreferences ||
!Array.isArray(output.createdNodeIds)) return count;
return count + output.createdNodeIds.length;
}, 0);
}
// A spontaneous download command is not an unfinished image edit. Keep genuine // A spontaneous download command is not an unfinished image edit. Keep genuine
// download requests and all editor panels waiting for their actual interaction. // download requests and all editor panels waiting for their actual interaction.
export function isAncillaryDownload(snapshot: AgentRunSnapshot, call: AgentCallRecord) { export function isAncillaryDownload(snapshot: AgentRunSnapshot, call: AgentCallRecord) {

View File

@ -5,7 +5,8 @@ import sharp from "sharp";
import { z } from "zod"; import { z } from "zod";
import { readCanvasImages, readOwnedCanvas, type RuntimeImageNode } from "./canvas"; import { readCanvasImages, readOwnedCanvas, type RuntimeImageNode } from "./canvas";
import type { AgentRunSnapshot } from "./protocol"; import type { AgentRunSnapshot } from "./protocol";
import { finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results"; import { countAgentGeneratedImages, finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results";
import { selectedAgentImageCount } from "./image-preferences";
import { isCompletedSimpleCutout } from "./cutout-completion"; import { isCompletedSimpleCutout } from "./cutout-completion";
export async function inspectImageAlpha(source: Buffer) { export async function inspectImageAlpha(source: Buffer) {
@ -124,14 +125,16 @@ export async function verifyCanvasAgentResults(params: {
(call) => (call) =>
call.status !== "succeeded" || call.status !== "succeeded" ||
requiresCanvasInteraction(snapshot, call) || requiresCanvasInteraction(snapshot, call) ||
(typeof call.output?.status === "string" && call.output.status !== "succeeded"), (typeof call.output?.status === "string" && call.output.status !== "succeeded" &&
!(call.output.status === "partial" && call.output.appliedImagePreferences)),
); );
if (incomplete.length) return { passed: false, issues: ["部分操作仍在等待交互或未完成"] }; if (incomplete.length) return { passed: false, issues: ["部分操作仍在等待交互或未完成"] };
const plan = calls.filter((call) => call.name === "planCanvasTask").at(-1)?.output; const plan = calls.filter((call) => call.name === "planCanvasTask").at(-1)?.output;
if (!plan) return { passed: false, issues: ["缺少明确的任务验收计划"] }; if (!plan) return { passed: false, issues: ["缺少明确的任务验收计划"] };
// Intermediate images consumed by later write tools do not count as final deliverables. // Intermediate images consumed by later write tools do not count as final deliverables.
const finalIds = finalResultNodeIds(snapshot); const finalIds = finalResultNodeIds(snapshot);
const expectedCount = snapshot.requirements?.expectedImageCount ?? plan.expectedImageCount; const expectedCount = snapshot.requirements?.expectedImageCount ?? plan.expectedImageCount ??
(countAgentGeneratedImages(calls) > 0 ? selectedAgentImageCount(snapshot.imagePreferences) : undefined);
if (expectedCount && finalIds.length !== expectedCount) { if (expectedCount && finalIds.length !== expectedCount) {
return { return {
passed: false, passed: false,

View File

@ -1,5 +1,11 @@
import type { AgentVisionExecutor } from "../runtime/agent-vision-failover"; import type { AgentVisionExecutor } from "../runtime/agent-vision-failover";
import { resolveUserImageModel, applyAgentImagePreferences, agentProviderImageParameters, type AgentImagePreferences } from "./image-preferences"; import {
agentProviderImageParameters,
applyAgentImagePreferences,
resolveUserImageModel,
selectedAgentImageCount,
type AgentImagePreferences,
} from "./image-preferences";
import { tool, type LanguageModel, type ToolSet } from "ai"; import { tool, type LanguageModel, type ToolSet } from "ai";
import { z } from "zod"; import { z } from "zod";
import { createCanvasCapabilityTools } from "@/lib/canvas/capabilities/ai-sdk-tools"; import { createCanvasCapabilityTools } from "@/lib/canvas/capabilities/ai-sdk-tools";
@ -18,6 +24,8 @@ import {
sortStyleProImageModelEntries, sortStyleProImageModelEntries,
} from "@/lib/style-pro/model-options"; } from "@/lib/style-pro/model-options";
import type { CanvasAgentLoopPorts, CanvasAgentTool } from "./loop"; import type { CanvasAgentLoopPorts, CanvasAgentTool } from "./loop";
import { AgentToolRejectedError, type AgentCallRecord } from "./protocol";
import { countAgentGeneratedImages } from "./results";
import { readCanvasImages, readOwnedCanvas } from "./canvas"; import { readCanvasImages, readOwnedCanvas } from "./canvas";
import { analyzeRuntimeImages } from "./review"; import { analyzeRuntimeImages } from "./review";
import { isExplicitDestructiveIntent } from "./capability-access"; import { isExplicitDestructiveIntent } from "./capability-access";
@ -37,11 +45,13 @@ export async function buildCanvasRuntimeTools(params: {
imageSize?: string; imageSize?: string;
preferredModelId?: string; preferredModelId?: string;
imagePreferences?: AgentImagePreferences; imagePreferences?: AgentImagePreferences;
priorCalls?: AgentCallRecord[];
emit: (type: string, data: unknown) => void; emit: (type: string, data: unknown) => void;
objective?: string; objective?: string;
operationSelectedNodeIds?: string[]; operationSelectedNodeIds?: string[];
}) { }) {
const { nodes } = await readOwnedCanvas(params.userId, params.projectId); const { nodes } = await readOwnedCanvas(params.userId, params.projectId);
const selectedImageCount = selectedAgentImageCount(params.imagePreferences);
const imageNodeIds = nodes.filter((node) => node.type === "image").map((node) => node.id); const imageNodeIds = nodes.filter((node) => node.type === "image").map((node) => node.id);
const defaults = params.selectedNodeIds.filter((id) => imageNodeIds.includes(id)); const defaults = params.selectedNodeIds.filter((id) => imageNodeIds.includes(id));
const sdk = await createCanvasCapabilityTools({ const sdk = await createCanvasCapabilityTools({
@ -128,8 +138,8 @@ export async function buildCanvasRuntimeTools(params: {
visualCriteria: z.array(z.string().max(200)).max(10), visualCriteria: z.array(z.string().max(200)).max(10),
}), }),
async (input) => ({ ...input, async (input) => ({ ...input,
...(input.expectedImageCount !== undefined && params.imagePreferences?.parameters?.imageCount !== undefined ...(input.expectedImageCount !== undefined && selectedImageCount !== undefined
? { expectedImageCount: params.imagePreferences.parameters.imageCount } : {}), ? { expectedImageCount: selectedImageCount } : {}),
...(params.imagePreferences?.parameters?.aspectRatio && params.imagePreferences.parameters.aspectRatio !== "auto" ...(params.imagePreferences?.parameters?.aspectRatio && params.imagePreferences.parameters.aspectRatio !== "auto"
? { aspectRatio: params.imagePreferences.parameters.aspectRatio } : {}), ? { aspectRatio: params.imagePreferences.parameters.aspectRatio } : {}),
}), }),
@ -163,6 +173,8 @@ export async function buildCanvasRuntimeTools(params: {
}; };
}, },
); );
const inspectedImages = new Map<string, Buffer>();
let inspectedImageBytes = 0;
register( register(
"analyzeCanvasImages", "analyzeCanvasImages",
"检查图片", "检查图片",
@ -171,10 +183,28 @@ export async function buildCanvasRuntimeTools(params: {
nodeIds: z.array(z.string().min(1).max(180)).min(1).max(4), nodeIds: z.array(z.string().min(1).max(180)).min(1).max(4),
question: z.string().min(1).max(2000), question: z.string().min(1).max(2000),
}), }),
async (input) => analyzeRuntimeImages({ ...params, ...input }), async (input) => analyzeRuntimeImages({
...params,
...input,
reviewPorts: {
readImage: async (url) => {
const cached = inspectedImages.get(url);
if (cached) return cached;
const { fetchImageBufferFromUrl } = await import("@/lib/ai/rmbg/service");
const original = await fetchImageBufferFromUrl(url);
if (!inspectedImages.has(url) && inspectedImageBytes + original.length <= 32 * 1024 * 1024) {
inspectedImages.set(url, original);
inspectedImageBytes += original.length;
}
return original;
},
},
}),
); );
let generatedCount = 0; let submittedCount = 0;
let occupiedImageSlots = countAgentGeneratedImages(params.priorCalls ?? []);
const countLimitMessage = "本次设定的生成张数已占满,请先查看正在生成或已经生成的结果。";
register( register(
"generateCanvasImages", "generateCanvasImages",
"生成图片", "生成图片",
@ -189,9 +219,8 @@ export async function buildCanvasRuntimeTools(params: {
}), }),
async (toolInput, callId) => { async (toolInput, callId) => {
const input = applyAgentImagePreferences(toolInput, params.imagePreferences); const input = applyAgentImagePreferences(toolInput, params.imagePreferences);
// Bound per-task spend without introducing any global/model concurrency queue. if (selectedImageCount !== undefined && occupiedImageSlots >= selectedImageCount)
if (generatedCount + input.imageCount > 24) throw new AgentToolRejectedError(countLimitMessage);
throw new Error("本次任务已达到 24 张生成预算,请检查结果后继续。");
const sources = await readCanvasImages( const sources = await readCanvasImages(
params.userId, params.userId,
params.projectId, params.projectId,
@ -214,10 +243,17 @@ export async function buildCanvasRuntimeTools(params: {
if (!chosen) throw new Error("系统未配置可用的图片生成模型"); if (!chosen) throw new Error("系统未配置可用的图片生成模型");
const imageSize = input.imageSize || params.imageSize || "2K"; const imageSize = input.imageSize || params.imageSize || "2K";
const quota = await checkUserImageQuota(params.userId); const quota = await checkUserImageQuota(params.userId);
if (!quota.allowed || (quota.remaining !== null && input.imageCount > quota.remaining))
throw new Error("本月图片生成配额不足。");
const points = await getPointsSystemConfig(); const points = await getPointsSystemConfig();
const user = await getUserById({ id: params.userId }); const user = await getUserById({ id: params.userId });
// Reserve the remaining slots before dispatch, including calls still in flight.
if (selectedImageCount !== undefined) {
input.imageCount = Math.min(input.imageCount, selectedImageCount - occupiedImageSlots);
if (input.imageCount < 1) throw new AgentToolRejectedError(countLimitMessage);
}
if (submittedCount + input.imageCount > 24)
throw new AgentToolRejectedError("本次任务已达到 24 张生成预算,请检查结果后继续。");
if (!quota.allowed || (quota.remaining !== null && input.imageCount > quota.remaining))
throw new Error("本月图片生成配额不足。");
const cost = const cost =
getModelGenerationCreditCost( getModelGenerationCreditCost(
{ imageGenConfig: chosen.model.imageGenConfig as DiscountableImageGenConfigLike | null }, { imageGenConfig: chosen.model.imageGenConfig as DiscountableImageGenConfigLike | null },
@ -231,7 +267,8 @@ export async function buildCanvasRuntimeTools(params: {
(user?.pointsBalance ?? 0) < cost (user?.pointsBalance ?? 0) < cost
) )
throw new Error("积分不足,请充值或联系管理员。"); throw new Error("积分不足,请充值或联系管理员。");
generatedCount += input.imageCount; submittedCount += input.imageCount;
occupiedImageSlots += input.imageCount;
const result = await generateCanvasImage({ const result = await generateCanvasImage({
prompt: input.prompt, prompt: input.prompt,
model: chosen.model.id, model: chosen.model.id,
@ -247,6 +284,7 @@ export async function buildCanvasRuntimeTools(params: {
? Math.max(0, user?.pointsBalance ?? 0) / input.imageCount : undefined, ? Math.max(0, user?.pointsBalance ?? 0) / input.imageCount : undefined,
ownerKey: params.userId, ownerKey: params.userId,
referenceImages: sources.map((node) => node.data.src || node.data.url!), referenceImages: sources.map((node) => node.data.src || node.data.url!),
referenceImageBuffers: inspectedImages,
debugLabel: "canvas-agent-runtime", debugLabel: "canvas-agent-runtime",
debugMeta: { runId: params.runId, callId }, debugMeta: { runId: params.runId, callId },
}); });
@ -311,6 +349,7 @@ export async function buildCanvasRuntimeTools(params: {
}, },
); );
} }
occupiedImageSlots += result.images.length - input.imageCount;
return { return {
status: result.images.length < input.imageCount ? "partial" : "succeeded", status: result.images.length < input.imageCount ? "partial" : "succeeded",
appliedImagePreferences: { modelId: result.modelRef, imageSize, aspectRatio: input.aspectRatio, imageCount: input.imageCount }, appliedImagePreferences: { modelId: result.modelRef, imageSize, aspectRatio: input.aspectRatio, imageCount: input.imageCount },

View File

@ -1,3 +1,5 @@
import { isVideoModelDescriptor } from "@/lib/ai/model-classification";
export const DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY = export const DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY =
"detail_replacement_image_model_config"; "detail_replacement_image_model_config";
@ -20,3 +22,28 @@ export function normalizeDetailReplacementImageModelConfig(
modelId: modelId || DEFAULT_DETAIL_REPLACEMENT_IMAGE_MODEL_ID, modelId: modelId || DEFAULT_DETAIL_REPLACEMENT_IMAGE_MODEL_ID,
}; };
} }
export function isDetailReplacementImageModel({
model,
provider,
}: {
model: { modelId: string; supportsImageGen: boolean; isSubModel: boolean; capabilities?: unknown };
provider?: { type?: string | null } | null;
}) {
return model.supportsImageGen && !model.isSubModel && !isVideoModelDescriptor({
providerType: provider?.type,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
export function findConfiguredDetailReplacementImageModel<
T extends Parameters<typeof isDetailReplacementImageModel>[0] & { model: { id: string } },
>(models: T[], setting: unknown): T | undefined {
const { modelId } = normalizeDetailReplacementImageModelConfig(setting);
return models.find((entry) =>
(entry.model.id === modelId || entry.model.modelId === modelId) &&
isDetailReplacementImageModel(entry)
);
}

View File

@ -3,6 +3,19 @@ export type ExtendProviderCredential = {
updatedAt?: Date | string | null; updatedAt?: Date | string | null;
}; };
export type ExtendProvider = "vip98k" | "dashscope" | "fal";
export function resolveFalExtendApiKey(providers: Array<ExtendProviderCredential & { baseUrl?: string | null }>) {
for (const { apiKey, baseUrl } of providers) {
if (!apiKey || !baseUrl) continue;
const url = URL.canParse(baseUrl) ? new URL(baseUrl) : null;
if (url?.protocol === "https:" && ["queue.fal.run", "fal.run", "fal.ai", "api.fal.ai"].includes(url.hostname)) {
return apiKey.trim().replace(/^(?:Bearer|Key)\s+/i, "");
}
}
return null;
}
function getUpdatedAtTimestamp(value: Date | string | null | undefined): number { function getUpdatedAtTimestamp(value: Date | string | null | undefined): number {
if (!value) return 0; if (!value) return 0;
const timestamp = value instanceof Date ? value.getTime() : Date.parse(value); const timestamp = value instanceof Date ? value.getTime() : Date.parse(value);

View File

@ -0,0 +1,93 @@
import { createFalClient } from "@fal-ai/client";
import sharp from "sharp";
export const FAL_OUTPAINT_MODEL = "fal-ai/flux-2-pro/outpaint";
export function getOutpaintGeometry(width: number, height: number, xScale: number, yScale: number) {
const targetWidth = Math.round(width * xScale);
const targetHeight = Math.round(height * yScale);
const ratio = Math.min(1, 2048 / Math.max(targetWidth, targetHeight));
const modelWidth = Math.max(1, Math.floor(targetWidth * ratio));
const modelHeight = Math.max(1, Math.floor(targetHeight * ratio));
const sourceWidth = Math.max(1, Math.round(width * modelWidth / targetWidth));
const sourceHeight = Math.max(1, Math.round(height * modelHeight / targetHeight));
const left = Math.floor((targetWidth - width) / 2);
const top = Math.floor((targetHeight - height) / 2);
const modelLeft = Math.round(left * modelWidth / targetWidth);
const modelTop = Math.round(top * modelHeight / targetHeight);
// Align the model canvas to its 16-pixel grid, then remove the extra border.
const extraWidth = (16 - modelWidth % 16) % 16;
const extraHeight = (16 - modelHeight % 16) % 16;
const cropLeft = Math.floor(extraWidth / 2);
const cropTop = Math.floor(extraHeight / 2);
return {
targetWidth, targetHeight, left, top, modelWidth, modelHeight, sourceWidth, sourceHeight,
paddedWidth: modelWidth + extraWidth,
paddedHeight: modelHeight + extraHeight,
cropLeft,
cropTop,
expandLeft: modelLeft + cropLeft,
expandRight: modelWidth - sourceWidth - modelLeft + extraWidth - cropLeft,
expandTop: modelTop + cropTop,
expandBottom: modelHeight - sourceHeight - modelTop + extraHeight - cropTop,
};
}
export async function composeOutpaint(source: Buffer, expanded: Buffer, geometry: ReturnType<typeof getOutpaintGeometry>) {
const { targetWidth, targetHeight, left, top, modelWidth, modelHeight, paddedWidth, paddedHeight, cropLeft, cropTop } = geometry;
const modelImage = sharp(expanded);
const info = await modelImage.metadata();
if (info.width !== paddedWidth || info.height !== paddedHeight) {
throw new Error("扩图服务返回的图片尺寸不正确");
}
// Copy the source rectangle, including transparent pixels, without blending or resizing it.
const { data: original, info: sourceInfo } = await sharp(source).toColourspace("srgb").ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const result = await modelImage
.extract({ left: cropLeft, top: cropTop, width: modelWidth, height: modelHeight })
.resize(targetWidth, targetHeight, { fit: "fill" })
.toColourspace("srgb").ensureAlpha().raw().toBuffer();
const stride = sourceInfo.width * 4;
for (let row = 0; row < sourceInfo.height; row++) {
original.copy(result, ((top + row) * targetWidth + left) * 4, row * stride, (row + 1) * stride);
}
return sharp(result, { raw: { width: targetWidth, height: targetHeight, channels: 4 } }).png().toBuffer();
}
export async function expandWithFalOutpaint({ imageUrl, apiKey, xScale, yScale }: {
imageUrl: string;
apiKey: string;
xScale: number;
yScale: number;
}) {
const response = await fetch(imageUrl, { signal: AbortSignal.timeout(45_000) });
if (!response.ok) throw new Error("无法读取待扩展的原图");
const { data: source, info } = await sharp(Buffer.from(await response.arrayBuffer()))
.rotate().png().toBuffer({ resolveWithObject: true });
const geometry = getOutpaintGeometry(info.width, info.height, xScale, yScale);
if (geometry.targetWidth * geometry.targetHeight > 64_000_000) {
throw new Error("扩图后的尺寸过大,请降低扩展倍数");
}
const fal = createFalClient({ credentials: apiKey });
const modelSource = geometry.sourceWidth === info.width && geometry.sourceHeight === info.height
? source
: await sharp(source).resize(geometry.sourceWidth, geometry.sourceHeight, { fit: "fill" }).png().toBuffer();
const image = await fal.storage.upload(new Blob([new Uint8Array(modelSource)], { type: "image/png" }));
const { data } = await fal.subscribe(FAL_OUTPAINT_MODEL, {
input: {
image_url: image,
expand_left: geometry.expandLeft,
expand_right: geometry.expandRight,
expand_top: geometry.expandTop,
expand_bottom: geometry.expandBottom,
mode: "high",
output_format: "png",
},
timeout: 90_000,
});
const outputUrl = (data as { images?: Array<{ url?: string }> }).images?.[0]?.url;
if (!outputUrl) throw new Error("扩图服务未返回图片");
const output = await fetch(outputUrl, { signal: AbortSignal.timeout(25_000) });
if (!output.ok) throw new Error("无法读取扩图结果");
return composeOutpaint(source, Buffer.from(await output.arrayBuffer()), geometry);
}

View File

@ -5,17 +5,21 @@ import {
getSystemSetting, getSystemSetting,
upsertMediaAsset, upsertMediaAsset,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { resolveAlibabaExtendApiKey } from "@/lib/ai/extend/config"; import { type ExtendProvider, resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime"; import { expandWithFalOutpaint, FAL_OUTPAINT_MODEL } from "@/lib/ai/extend/fal-outpaint";
import { executeConfiguredVip98kTask, getVip98kRuntimeProvider } from "@/lib/ai/vip98k-runtime";
import { expandWithVip98kOutpaint, VIP98K_OUTPAINT_MODEL } from "@/lib/ai/extend/vip98k-outpaint";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema"; import { generatedImage } from "@/lib/db/schema";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
type ExtendConfig = { type ExtendConfig = {
provider?: "vip98k" | "dashscope"; provider?: ExtendProvider;
apiKey?: string; apiKey?: string;
xScale?: number; xScale?: number;
yScale?: number; yScale?: number;
modelId?: string;
routeOptionId?: string;
}; };
const VIP98K_EXPANSION_FALLBACK_MODEL = "google/nano-banana-pro/edit"; const VIP98K_EXPANSION_FALLBACK_MODEL = "google/nano-banana-pro/edit";
@ -83,12 +87,12 @@ async function createThumbnail(buffer: Buffer): Promise<Buffer> {
} }
} }
async function uploadBufferPairToS3(buffer: Buffer): Promise<{ thumbnail: string; original: string }> { async function uploadBufferPairToS3(buffer: Buffer, originalExtension = "jpg"): Promise<{ thumbnail: string; original: string }> {
try { try {
const timestamp = Date.now(); const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8); const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `extend-thumb-${timestamp}-${randomStr}.jpg`; const thumbnailFilename = `extend-thumb-${timestamp}-${randomStr}.jpg`;
const originalFilename = `extend-orig-${timestamp}-${randomStr}.jpg`; const originalFilename = `extend-orig-${timestamp}-${randomStr}.${originalExtension}`;
const thumbnailBuffer = await createThumbnail(buffer); const thumbnailBuffer = await createThumbnail(buffer);
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice( const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset, thumbnailBuffer.byteOffset,
@ -473,6 +477,15 @@ async function getValidatedExtendConfig(): Promise<ExtendConfig> {
if (config.provider === "vip98k") { if (config.provider === "vip98k") {
return config; return config;
} }
if (config.provider === "fal") {
const [imageProviders, videoProviders] = await Promise.all([
getAiProvidersByType({ type: "fal-image", enabledOnly: true }),
getAiProvidersByType({ type: "fal-video", enabledOnly: true }),
]);
const apiKey = resolveFalExtendApiKey([...imageProviders, ...videoProviders]);
if (!apiKey) throw new ExtendServiceError("扩图服务未配置可用的 fal 密钥", 503);
return { ...config, apiKey };
}
const alibabaProviders = await getAiProvidersByType({ const alibabaProviders = await getAiProvidersByType({
type: "alibaba-image", type: "alibaba-image",
@ -522,35 +535,37 @@ export async function executeExtendJob(
throw new ExtendServiceError("无法获取输入图片URL", 400); throw new ExtendServiceError("无法获取输入图片URL", 400);
} }
const preparedInputImageUrl =
config.provider === "vip98k"
? inputImageUrl
: await normalizeDashscopeExpansionInput(inputImageUrl);
const startTime = Date.now(); const startTime = Date.now();
const extendedImageUrl = await extendImage( let buffer: Buffer;
preparedInputImageUrl, let modelId: string;
config, if (config.provider === "fal") {
xScale, buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale });
yScale, modelId = FAL_OUTPAINT_MODEL;
requestId, } else if (config.provider === "vip98k" && config.modelId === VIP98K_OUTPAINT_MODEL) {
); if (!config.routeOptionId) throw new ExtendServiceError("98K 扩图线路未配置", 503);
const imageResponse = await fetch(extendedImageUrl); const provider = await getVip98kRuntimeProvider();
buffer = await expandWithVip98kOutpaint({
imageUrl: inputImageUrl, baseUrl: provider.baseUrl, apiKey: provider.apiKey,
routeOptionId: config.routeOptionId, xScale, yScale,
});
modelId = VIP98K_OUTPAINT_MODEL;
} else {
const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl);
const url = await extendImage(prepared, config, xScale, yScale, requestId);
const imageResponse = await fetch(url);
if (!imageResponse.ok) { if (!imageResponse.ok) {
throw new ExtendServiceError( throw new ExtendServiceError("下载拓展图片失败", 500, `${imageResponse.status} ${imageResponse.statusText}`);
"下载拓展图片失败", }
500, buffer = Buffer.from(await imageResponse.arrayBuffer());
`${imageResponse.status} ${imageResponse.statusText}`, modelId = config.provider === "vip98k" ? "image-expansion" : "extend-1.0";
);
} }
const buffer = Buffer.from(await imageResponse.arrayBuffer());
const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined; const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined;
const uploadResult = await uploadBufferPairToS3(buffer); const uploadResult = await uploadBufferPairToS3(buffer, config.provider === "fal" || modelId === VIP98K_OUTPAINT_MODEL ? "png" : "jpg");
console.log("[Extend Service] Result uploaded:", { console.log("[Extend Service] Result uploaded:", {
requestId, requestId,
includeBase64, includeBase64,
inputImageUrl: inputImageUrl.slice(0, 120), inputImageUrl: inputImageUrl.slice(0, 120),
extendedImageUrl: extendedImageUrl.slice(0, 120), modelId,
outputBytes: buffer.length, outputBytes: buffer.length,
}); });
let mediaAssetId: string | null = null; let mediaAssetId: string | null = null;
@ -567,7 +582,7 @@ export async function executeExtendJob(
thumbnailUrl: uploadResult.thumbnail, thumbnailUrl: uploadResult.thumbnail,
originalUrl: uploadResult.original, originalUrl: uploadResult.original,
prompt, prompt,
modelId: config.provider === "vip98k" ? "image-expansion" : "extend-1.0", modelId,
imageSize: "2K", imageSize: "2K",
}) })
.returning(); .returning();
@ -591,7 +606,7 @@ export async function executeExtendJob(
thumbnailUrl: uploadResult.thumbnail, thumbnailUrl: uploadResult.thumbnail,
displayName: prompt.slice(0, 80), displayName: prompt.slice(0, 80),
prompt, prompt,
modelId: config.provider === "vip98k" ? "image-expansion" : "extend-1.0", modelId,
sourceMessageId: messageId, sourceMessageId: messageId,
sourceTable: "GeneratedImage", sourceTable: "GeneratedImage",
sourceRowId: generatedImageId, sourceRowId: generatedImageId,

View File

@ -0,0 +1,85 @@
import sharp from "sharp";
export const VIP98K_OUTPAINT_MODEL = "gpt-image-2.5-flare";
const RATIOS = [[1, 1], [4, 3], [3, 4], [3, 2], [2, 3], [16, 9], [9, 16]];
export function getVip98kOutpaintGeometry(width: number, height: number, xScale: number, yScale: number) {
const targetWidth = Math.round(width * xScale);
const targetHeight = Math.round(height * yScale);
if (targetWidth * targetHeight > 64_000_000) throw new Error("扩图后的尺寸过大,请降低扩展倍数");
const ratio = targetWidth / targetHeight;
const [a, b] = RATIOS.reduce((best, next) =>
Math.abs(Math.log(ratio / (next[0] / next[1]))) < Math.abs(Math.log(ratio / (best[0] / best[1]))) ? next : best);
const paddedWidth = Math.max(targetWidth, Math.ceil(targetHeight * a / b));
const paddedHeight = Math.max(targetHeight, Math.ceil(targetWidth * b / a));
const factor = Math.min(1, 2048 / Math.max(paddedWidth, paddedHeight));
const modelWidth = Math.max(1, Math.round(paddedWidth * factor));
const modelHeight = Math.max(1, Math.round(paddedHeight * factor));
const sourceWidth = Math.max(1, Math.round(width * factor));
const sourceHeight = Math.max(1, Math.round(height * factor));
return {
targetWidth, targetHeight, modelWidth, modelHeight, sourceWidth, sourceHeight,
left: Math.floor((modelWidth - sourceWidth) / 2),
top: Math.floor((modelHeight - sourceHeight) / 2),
aspectRatio: `${a}:${b}`,
resolution: Math.max(targetWidth, targetHeight) > 1536 ? "2K" : "1K",
};
}
export async function expandWithVip98kOutpaint(params: {
imageUrl: string;
baseUrl: string;
apiKey: string;
routeOptionId: string;
xScale: number;
yScale: number;
}) {
const response = await fetch(params.imageUrl, { signal: AbortSignal.timeout(120_000) });
if (!response.ok) throw new Error("无法读取待扩展的原图");
const { data: source, info } = await sharp(Buffer.from(await response.arrayBuffer()))
.rotate().png().toBuffer({ resolveWithObject: true });
const g = getVip98kOutpaintGeometry(info.width, info.height, params.xScale, params.yScale);
const input = await sharp(source).resize(g.sourceWidth, g.sourceHeight).ensureAlpha().extend({
left: g.left, right: g.modelWidth - g.sourceWidth - g.left,
top: g.top, bottom: g.modelHeight - g.sourceHeight - g.top,
background: { r: 0, g: 0, b: 0, alpha: 0 },
}).png().toBuffer();
const body = new FormData();
const prompt = [
"Extend this product image outward on all four sides, continuing its background naturally.",
`The reference canvas is ${g.modelWidth}x${g.modelHeight}; the original image is centered at (${g.left},${g.top}) with size ${g.sourceWidth}x${g.sourceHeight}.`,
"Fill only the surrounding empty area as a continuation of the same scene.",
"Preserve the existing product identity, structure, colors, and all Chinese text and dimension numbers.",
"Keep approximately the same composition and the product centered.",
"Avoid adding new products or additional text. Return a complete seamless image.",
].join(" ");
for (const [key, value] of Object.entries({
model: VIP98K_OUTPAINT_MODEL, prompt, n: "1", size: g.aspectRatio,
resolution: g.resolution, quality: "high", response_format: "url",
})) body.append(key, value);
body.append("image[]", new Blob([new Uint8Array(input)], { type: "image/png" }), "outpaint.png");
const base = params.baseUrl.replace(/\/$/, "").replace(/\/v1$/, "");
const generated = await fetch(`${base}/v1/images/edits`, {
method: "POST", body,
headers: { Authorization: `Bearer ${params.apiKey}`, "X-Relay-Route-Option": params.routeOptionId },
signal: AbortSignal.timeout(180_000),
});
const result = await generated.json();
if (!generated.ok) throw new Error(`98K 扩图失败:${result?.error?.message || `HTTP ${generated.status}`}`);
const image = result?.data?.[0];
let expanded: Buffer;
if (typeof image?.b64_json === "string") {
expanded = Buffer.from(image.b64_json, "base64");
} else if (typeof image?.url === "string") {
const outputUrl = new URL(image.url);
const headers = new Headers();
if (outputUrl.origin === new URL(base).origin) headers.set("Authorization", `Bearer ${params.apiKey}`);
const output = await fetch(outputUrl, { headers, signal: AbortSignal.timeout(60_000) });
if (!output.ok) throw new Error(`无法读取扩图结果(HTTP ${output.status})`);
expanded = Buffer.from(await output.arrayBuffer());
} else {
throw new Error("98K 扩图未返回图片");
}
return sharp(expanded).resize(g.targetWidth, g.targetHeight, { fit: "cover", position: "centre" }).png().toBuffer();
}

View File

@ -12,6 +12,7 @@ import sharp from "sharp";
import { constrainImageDimensions } from "@/lib/ai/image-size-constraints"; import { constrainImageDimensions } from "@/lib/ai/image-size-constraints";
import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification"; import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification";
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order"; import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
import { ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage"; import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
@ -165,7 +166,7 @@ async function fetchImageBufferWithRetry(
} }
} }
throw lastError instanceof Error ? lastError : new Error(String(lastError || "下载图片失败")); throw lastError ?? new Error("下载图片失败");
} }
function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): string { function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): string {
@ -289,12 +290,20 @@ async function prepareAlibabaInputImageUrl(
imageUrl: string, imageUrl: string,
modelId: string, modelId: string,
inputRole?: AlibabaImageInputRole, inputRole?: AlibabaImageInputRole,
original?: Buffer,
): Promise<string> { ): Promise<string> {
const normalizedUrl = String(imageUrl || "").trim(); const normalizedUrl = String(imageUrl || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl; if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
const maxInputBytes = getAlibabaMaxInputBytes(modelId); const maxInputBytes = getAlibabaMaxInputBytes(modelId);
const { buffer, contentType } = await fetchImageBufferWithRetry(normalizedUrl); const { buffer, contentType } = original
? { buffer: original, contentType: null }
: await fetchImageBufferWithRetry(normalizedUrl).catch((error) => {
if (["AbortError", "TimeoutError"].includes(error?.name)) {
throw new ImageReferenceReadTimeoutError();
}
throw error;
});
let metadata: sharp.Metadata; let metadata: sharp.Metadata;
try { try {
metadata = await sharp(buffer, { failOn: "none" }).metadata(); metadata = await sharp(buffer, { failOn: "none" }).metadata();
@ -561,7 +570,7 @@ async function sendAlibabaImageAsyncRequest(params: {
url: params.requestURL, url: params.requestURL,
}); });
const taskId = String(submitted.output?.task_id || "").trim(); const taskId = String(submitted.output?.task_id || "").trim();
if (!taskId) throw new Error("阿里图片异步任务未返回 task_id"); if (!taskId) throw new Error("阿里图片异步任务未返回 task_id,结果尚未确认");
const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId); const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId);
const startedAt = Date.now(); const startedAt = Date.now();
@ -585,7 +594,7 @@ async function sendAlibabaImageAsyncRequest(params: {
console.log("[Alibaba Image] Async task completed:", taskId); console.log("[Alibaba Image] Async task completed:", taskId);
return task; return task;
} }
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) { if (["FAILED", "CANCELED"].includes(taskStatus)) {
const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`; const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`;
const code = task.output?.code || task.code; const code = task.output?.code || task.code;
throw new AlibabaImageTerminalTaskError( throw new AlibabaImageTerminalTaskError(
@ -601,7 +610,7 @@ async function sendAlibabaImageAsyncRequest(params: {
error.status < 500 && error.status < 500 &&
![408, 425, 429].includes(error.status) ![408, 425, 429].includes(error.status)
) { ) {
throw error; throw new Error(`阿里图片任务已提交,结果尚未确认(task_id: ${taskId})`, { cause: error });
} }
lastPollError = error; lastPollError = error;
console.warn("[Alibaba Image] Async task poll failed; retrying:", { console.warn("[Alibaba Image] Async task poll failed; retrying:", {
@ -723,6 +732,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
doGenerate: async (options: { doGenerate: async (options: {
messages: Array<{ role: string; content: string | Array<{ type: string; text?: string; image?: string }> }>; messages: Array<{ role: string; content: string | Array<{ type: string; text?: string; image?: string }> }>;
imageParams?: AlibabaImageParams & { aspectRatio?: string; imageSize?: string; imageCount?: number }; imageParams?: AlibabaImageParams & { aspectRatio?: string; imageSize?: string; imageCount?: number };
referenceImageBuffers?: ReadonlyMap<string, Buffer>;
modelDisplayName?: string; // 🆕 模型显示名称,用于错误提示 modelDisplayName?: string; // 🆕 模型显示名称,用于错误提示
overrideModelId?: string; // 🆕 备用模型 ID overrideModelId?: string; // 🆕 备用模型 ID
}) => { }) => {
@ -798,6 +808,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
imageUrl, imageUrl,
activeModelId, activeModelId,
options.imageParams?.inputImageRoleByUrl?.[imageUrl], options.imageParams?.inputImageRoleByUrl?.[imageUrl],
options.referenceImageBuffers?.get(imageUrl),
), ),
), ),
); );
@ -938,7 +949,9 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
); );
// 处理响应 // 处理响应
const images = await processResponse(response); const images = await processResponse(response).catch((error) => {
throw new Error("阿里图片任务已提交,结果尚未确认。", { cause: error });
});
const markdownText = formatImageResponse(images, params.size, imageSize); const markdownText = formatImageResponse(images, params.size, imageSize);
console.log(`[Alibaba Image] Generated ${images.length} images successfully`); console.log(`[Alibaba Image] Generated ${images.length} images successfully`);

View File

@ -99,6 +99,7 @@ const DEFAULT_POLL_INTERVAL_SECONDS = 8;
const DEFAULT_MAX_POLL_SECONDS = 600; const DEFAULT_MAX_POLL_SECONDS = 600;
const REFERENCE_DOWNLOAD_ATTEMPTS = 3; const REFERENCE_DOWNLOAD_ATTEMPTS = 3;
const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000; const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const REFERENCE_CDN_TIMEOUT_MS = 10_000;
const REFERENCE_CACHE_LOOKUP_TIMEOUT_MS = 15_000; const REFERENCE_CACHE_LOOKUP_TIMEOUT_MS = 15_000;
const MAX_REFERENCE_DOWNLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES; const MAX_REFERENCE_DOWNLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES;
const REFERENCE_STAGING_CACHE_MS = 60_000; const REFERENCE_STAGING_CACHE_MS = 60_000;
@ -682,25 +683,26 @@ async function findStagedReference(
} }
} }
async function downloadReferenceImage(mediaUrl: string) { async function fetchReferenceImage(mediaUrl: string, preferCdn = false) {
let lastError: unknown;
for (let attempt = 1; attempt <= REFERENCE_DOWNLOAD_ATTEMPTS; attempt += 1) {
try {
const response = await fetch(mediaUrl, { const response = await fetch(mediaUrl, {
cache: "no-store", cache: preferCdn ? "default" : "no-store",
redirect: "follow", redirect: preferCdn ? "error" : "follow",
headers: { "User-Agent": "yedir-ai-vip98k-staging/1.0" }, headers: { "User-Agent": "yedir-ai-vip98k-staging/1.0" },
signal: AbortSignal.timeout(REFERENCE_DOWNLOAD_TIMEOUT_MS), signal: AbortSignal.timeout(preferCdn ? REFERENCE_CDN_TIMEOUT_MS : REFERENCE_DOWNLOAD_TIMEOUT_MS),
}); });
if (!response.ok) { if (!response.ok || (preferCdn && response.status !== 200)) {
await response.body?.cancel();
throw new Error(`source returned HTTP ${response.status}`); throw new Error(`source returned HTTP ${response.status}`);
} }
const mimeType = response.headers.get("content-type") || "image/png";
if (preferCdn && !/^image\//i.test(mimeType) && !/^application\/octet-stream\b/i.test(mimeType)) {
await response.body?.cancel();
throw new Error("CDN response was not an image");
}
const declaredLength = Number(response.headers.get("content-length")); const declaredLength = Number(response.headers.get("content-length"));
if ( if (declaredLength > MAX_REFERENCE_DOWNLOAD_BYTES) {
Number.isFinite(declaredLength) && await response.body?.cancel();
declaredLength > MAX_REFERENCE_DOWNLOAD_BYTES
) {
throw new Vip98kReferenceTooLargeError(); throw new Vip98kReferenceTooLargeError();
} }
@ -708,17 +710,17 @@ async function downloadReferenceImage(mediaUrl: string) {
if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) { if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) {
throw new Vip98kReferenceTooLargeError(); throw new Vip98kReferenceTooLargeError();
} }
if ( if ((declaredLength > 0 && buffer.byteLength !== declaredLength) || (preferCdn && !buffer.byteLength)) {
Number.isFinite(declaredLength) &&
declaredLength > 0 &&
buffer.byteLength !== declaredLength
) {
throw new Error("source image download was incomplete"); throw new Error("source image download was incomplete");
} }
return { return { buffer, mimeType };
buffer, }
mimeType: response.headers.get("content-type") || "image/png",
}; async function downloadReferenceImage(mediaUrl: string) {
let lastError: unknown;
for (let attempt = 1; attempt <= REFERENCE_DOWNLOAD_ATTEMPTS; attempt += 1) {
try {
return await fetchReferenceImage(mediaUrl);
} catch (error) { } catch (error) {
lastError = error; lastError = error;
if (error instanceof Vip98kReferenceTooLargeError) throw error; if (error instanceof Vip98kReferenceTooLargeError) throw error;
@ -738,6 +740,22 @@ async function downloadReferenceImage(mediaUrl: string) {
); );
} }
async function downloadReferenceImageFromCdn(mediaUrl: string, stagingConfig: ReferenceStagingConfig) {
if (stagingConfig.storageConfig.provider !== "qiniu-kodo") return null;
const cdnUrl = process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "";
if (!URL.canParse(cdnUrl) || !getStorageObjectKeyFromPublicUrl(mediaUrl, stagingConfig.sourcePublicUrl)) return null;
const source = new URL(mediaUrl);
if (source.origin !== new URL(cdnUrl).origin || source.search || source.username || source.password) return null;
try {
return await fetchReferenceImage(mediaUrl, true);
} catch (error) {
if (error instanceof Vip98kReferenceTooLargeError) throw error;
logger.warn("reference CDN download failed; trying source storage", { sourceHost: source.hostname });
return null;
}
}
async function downloadReferenceImageFromStorage( async function downloadReferenceImageFromStorage(
mediaUrl: string, mediaUrl: string,
stagingConfig: ReferenceStagingConfig, stagingConfig: ReferenceStagingConfig,
@ -852,9 +870,10 @@ async function stageReferenceImage(
} }
const downloadStartedAt = Date.now(); const downloadStartedAt = Date.now();
const cdn = await downloadReferenceImageFromCdn(mediaUrl, stagingConfig);
const storage = cdn ? null : await downloadReferenceImageFromStorage(mediaUrl, stagingConfig);
const downloaded = const downloaded =
(await downloadReferenceImageFromStorage(mediaUrl, stagingConfig)) || cdn || storage || (await downloadReferenceImage(mediaUrl));
(await downloadReferenceImage(mediaUrl));
const downloadMs = Date.now() - downloadStartedAt; const downloadMs = Date.now() - downloadStartedAt;
const normalizeStartedAt = Date.now(); const normalizeStartedAt = Date.now();
const normalized = await normalizeReferenceImage( const normalized = await normalizeReferenceImage(
@ -872,6 +891,7 @@ async function stageReferenceImage(
cacheStagedReference(cacheKey, uploadedUrl, runtimeConfig); cacheStagedReference(cacheKey, uploadedUrl, runtimeConfig);
logger.info("reference image staged", { logger.info("reference image staged", {
sourceHost: getUrlHost(mediaUrl), sourceHost: getUrlHost(mediaUrl),
sourceDelivery: cdn ? "cdn" : storage ? "storage" : "public-url",
targetHost: getUrlHost(uploadedUrl), targetHost: getUrlHost(uploadedUrl),
sourceBytes: downloaded.buffer.byteLength, sourceBytes: downloaded.buffer.byteLength,
stagedBytes: normalized.buffer.byteLength, stagedBytes: normalized.buffer.byteLength,
@ -1647,6 +1667,15 @@ export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvoca
if (params.modelId.toLowerCase() === "image-expansion") { if (params.modelId.toLowerCase() === "image-expansion") {
bindVip98kExpansionParams(body, imageParams); bindVip98kExpansionParams(body, imageParams);
} }
if (params.kind === "image-tool" && params.modelId.toLowerCase() === "ocr") {
// Old synced examples include generic image fields and both language aliases.
const metadata = toRecord(body.metadata);
const language = String(metadata?.language || body.language || "").trim();
body.metadata = { language: language || "English,ChinesePRC,Digits" };
body.output_format = "txt";
delete body.language;
delete body.size;
}
const imageBindingKeys = bindVip98kImages(body, params.imageUrls); const imageBindingKeys = bindVip98kImages(body, params.imageUrls);
if (params.imageUrls.length > 0 && !invocation && imageBindingKeys.length === 0) { if (params.imageUrls.length > 0 && !invocation && imageBindingKeys.length === 0) {
body.images = params.imageUrls; body.images = params.imageUrls;

View File

@ -8,6 +8,12 @@ export class AgentImageGenerationError extends Error {
constructor(message: string, readonly confirmedNoResult: boolean) { super(message); } constructor(message: string, readonly confirmedNoResult: boolean) { super(message); }
} }
export class ImageReferenceReadTimeoutError extends AgentImageGenerationError {
constructor() {
super("提交生图前读取参考图超时,任务尚未提交,可以稍后重试。", true);
}
}
/** Preserve outcome evidence when replacing upstream diagnostics with safe UI text. */ /** Preserve outcome evidence when replacing upstream diagnostics with safe UI text. */
export function agentImageGenerationError(error: unknown, mode: "auto" | "pinned") { export function agentImageGenerationError(error: unknown, mode: "auto" | "pinned") {
const rejected = error instanceof ImageSubmissionRejectedError; const rejected = error instanceof ImageSubmissionRejectedError;

View File

@ -143,6 +143,7 @@ const IMAGE_TOOL_MODEL_IDS = new Set([
"id-photo", "id-photo",
"image-expansion", "image-expansion",
"image-upscaler", "image-upscaler",
"ocr",
"photo-cleanup", "photo-cleanup",
"remove-background", "remove-background",
"remove-object", "remove-object",
@ -1041,6 +1042,7 @@ function createToolPreset(
const inputModes = getInputModes(model); const inputModes = getInputModes(model);
const outputModes = getOutputModes(model); const outputModes = getOutputModes(model);
const isImageTool = type === "image_tool"; const isImageTool = type === "image_tool";
const isOcr = modelId.toLowerCase() === "ocr";
const toolOperation = IMAGE_TOOL_OPERATION_BY_MODEL_ID[modelId.toLowerCase()] || type; const toolOperation = IMAGE_TOOL_OPERATION_BY_MODEL_ID[modelId.toLowerCase()] || type;
return { return {
@ -1056,7 +1058,7 @@ function createToolPreset(
kind: type.replace(/_/g, "-"), kind: type.replace(/_/g, "-"),
mediaType: isImageTool ? "image" : type === "audio" ? "audio" : "document", mediaType: isImageTool ? "image" : type === "audio" ? "audio" : "document",
modelType: type, modelType: type,
outputType: outputModes[0] || (isImageTool ? "image" : "text"), outputType: isOcr ? "text" : outputModes[0] || (isImageTool ? "image" : "text"),
inputModes, inputModes,
outputModes, outputModes,
supportedModes: getCapabilities(model), supportedModes: getCapabilities(model),

View File

@ -7,7 +7,8 @@ import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop";
import { AgentToolRejectedError, publicAgentError, type AgentCallRecord, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol"; import { AgentToolRejectedError, publicAgentError, type AgentCallRecord, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error"; import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion"; import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion";
import { requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results"; import { countAgentGeneratedImages, requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results";
import { selectedAgentImageCount } from "@/lib/ai/canvas-agent-runtime/image-preferences";
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error"; import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
import { publicAssistantText } from "./assistant-output"; import { publicAssistantText } from "./assistant-output";
@ -49,6 +50,16 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
if (!details.recommendation) details.recommendation = "请联系管理员并提供请求编号,核对该步骤的错误。"; if (!details.recommendation) details.recommendation = "请联系管理员并提供请求编号,核对该步骤的错误。";
return `${call.title || "创作工具"}:${formatRequestFailure(details, ports.runId)}`; return `${call.title || "创作工具"}:${formatRequestFailure(details, ports.runId)}`;
} }
function imageCountReached() {
const count = snapshot.workspace === "video" ? undefined : selectedAgentImageCount(snapshot.imagePreferences);
return count !== undefined && countAgentGeneratedImages(snapshot.calls) >= count;
}
function reviewMessage(issues: string[]) {
const summary = `结果待核对:${issues.join(";")}`;
return imageCountReached()
? `${summary}\n\n已保留本次生成的图片。如需再生成,请发送新指令。`
: summary;
}
let modelError = ""; let modelError = "";
let toolFailure = ""; let toolFailure = "";
let nativeFailed = false; let nativeFailed = false;
@ -84,6 +95,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
`可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`, `可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`,
"原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。", "原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。",
"用户只要求生图或改图时,把结果写入画布并验收即可。除非用户明确要求下载或导出,不要主动调用 image_download,也不要为收尾打开交互面板。", "用户只要求生图或改图时,把结果写入画布并验收即可。除非用户明确要求下载或导出,不要主动调用 image_download,也不要为收尾打开交互面板。",
"用户设置的生成张数是本次任务的总数,包括验收未通过的已生成图片。达到张数后,保留结果并说明问题;如需再次生成,等待用户发送新指令。",
"用户指定的生图模型被明确拒绝时,停止本轮收费操作,询问用户希望切换生图模型还是稍后重试。切换需用户确认并更新模型选择;结果未知的超时必须先核对原任务,不可直接换模型重发。不要为普通图片编辑调用 appearance.post 或其他无关业务接口。", "用户指定的生图模型被明确拒绝时,停止本轮收费操作,询问用户希望切换生图模型还是稍后重试。切换需用户确认并更新模型选择;结果未知的超时必须先核对原任务,不可直接换模型重发。不要为普通图片编辑调用 appearance.post 或其他无关业务接口。",
"imagePreferences 是用户在输入框选择的生图模型和图片参数,与当前规划模型不同。生成和编辑图片优先用 generateCanvasImages;用户选定的模型不得被技能默认值、你选的其他模型或重试覆盖。自动选择生图模型时,图片工具内部会在明确失败后切换备用模型并补齐结果,不要自行重放付费操作;用户明确指定模型时禁止故障切换。比例、尺寸、数量、反向提示词等控件必须传入;不要通过其他生成接口绕过用户选择。分析、问答、排版等不需要出图的任务无需生成图片。", "imagePreferences 是用户在输入框选择的生图模型和图片参数,与当前规划模型不同。生成和编辑图片优先用 generateCanvasImages;用户选定的模型不得被技能默认值、你选的其他模型或重试覆盖。自动选择生图模型时,图片工具内部会在明确失败后切换备用模型并补齐结果,不要自行重放付费操作;用户明确指定模型时禁止故障切换。比例、尺寸、数量、反向提示词等控件必须传入;不要通过其他生成接口绕过用户选择。分析、问答、排版等不需要出图的任务无需生成图片。",
"转正面、换角度、旋转主体视角时,直接用 generateCanvasImages,referenceNodeIds 指向要编辑的真实图片,提示词描述目标视角并保留产品结构和未要求修改的细节。旧多视角功能、angleEditImage 和 image.multi_angle 已废弃,即使历史对话用过也不得再调用或打开旧面板。", "转正面、换角度、旋转主体视角时,直接用 generateCanvasImages,referenceNodeIds 指向要编辑的真实图片,提示词描述目标视角并保留产品结构和未要求修改的细节。旧多视角功能、angleEditImage 和 image.multi_angle 已废弃,即使历史对话用过也不得再调用或打开旧面板。",
@ -277,12 +289,12 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
const verification = await ports.verify(reviewSnapshot); snapshot.verification = verification; const verification = await ports.verify(reviewSnapshot); snapshot.verification = verification;
snapshot.inputTokens = reviewSnapshot.inputTokens; snapshot.outputTokens = reviewSnapshot.outputTokens; snapshot.inputTokens = reviewSnapshot.inputTokens; snapshot.outputTokens = reviewSnapshot.outputTokens;
if (verification.passed) return await save(result.response || "任务完成,结果已写入画布。", "completed"); if (verification.passed) return await save(result.response || "任务完成,结果已写入画布。", "completed");
if (verification.retryable && snapshot.repairs < 2) { if (verification.retryable && snapshot.repairs < 2 && !imageCountReached()) {
snapshot.repairs++; snapshot.repairs++;
prompt = `验收需要定点修复:${JSON.stringify(verification)}。只处理失败结果,禁止整批重新生成;保留所有通过结果。`; prompt = `验收需要定点修复:${JSON.stringify(verification)}。只处理失败结果,禁止整批重新生成;保留所有通过结果。`;
continue; continue;
} }
return await save(`结果待核对:${verification.issues.join(";")}`, "needs_review"); return await save(reviewMessage(verification.issues), "needs_review");
} }
} catch (error) { } catch (error) {
if (snapshot.nativeRuntime) snapshot.nativeRuntime.state = "stopped"; if (snapshot.nativeRuntime) snapshot.nativeRuntime.state = "stopped";
@ -306,7 +318,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
if (control.cancelled || ports.signal.aborted) return await save("已停止后续步骤,完成的结果保留在画布。", "cancelled"); if (control.cancelled || ports.signal.aborted) return await save("已停止后续步骤,完成的结果保留在画布。", "cancelled");
if (inputs !== JSON.stringify(snapshot.inputs)) return await save("已收到新的补充要求,图片已保留,请继续处理。", "needs_review"); if (inputs !== JSON.stringify(snapshot.inputs)) return await save("已收到新的补充要求,图片已保留,请继续处理。", "needs_review");
return await save(verification.passed ? "图片已完成并通过检查,结果已保留在画布。" return await save(verification.passed ? "图片已完成并通过检查,结果已保留在画布。"
: `图片已返回画布,结果待核对:${verification.issues.join(";")}`, verification.passed ? "completed" : "needs_review"); : `图片已返回画布,${reviewMessage(verification.issues)}`, verification.passed ? "completed" : "needs_review");
} catch { /* Keep the original interruption if independent inspection also fails. */ } } catch { /* Keep the original interruption if independent inspection also fails. */ }
} }
return await save(cancelled ? "已停止后续步骤,完成的结果保留在画布。" : toolFailure || modelError || publicAgentError(error), cancelled ? "cancelled" : "needs_review"); return await save(cancelled ? "已停止后续步骤,完成的结果保留在画布。" : toolFailure || modelError || publicAgentError(error), cancelled ? "cancelled" : "needs_review");

View File

@ -5,7 +5,7 @@ import sharp from "sharp";
import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store"; import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store";
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing"; import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover"; import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error"; import { agentImageGenerationError, ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error";
import { getAiModelById, getEnabledAiModelsWithProvider } from "@/lib/db/queries"; import { getAiModelById, getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service"; import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { createGeminiImage } from "@/lib/ai/providers/gemini-image"; import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
@ -181,6 +181,7 @@ export interface CanvasGenerateImageRequest {
providerImageParams?: Record<string, unknown>; providerImageParams?: Record<string, unknown>;
referenceImage?: string; referenceImage?: string;
referenceImages?: string[]; referenceImages?: string[];
referenceImageBuffers?: ReadonlyMap<string, Buffer>;
billingFeature?: "multi-view" | "repaint"; billingFeature?: "multi-view" | "repaint";
requiredReferenceImageCount?: number; requiredReferenceImageCount?: number;
ownerKey?: string; ownerKey?: string;
@ -3443,11 +3444,13 @@ async function applyLocalEditCompositeToImages(params: {
const [originalUpload, thumbnailUpload] = await Promise.all([ const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile( uploadFile(
`canvas-local-edit-composited-${suffix}.png`, `canvas-local-edit-composited-${suffix}.png`,
bufferToArrayBuffer(mergedBuffer) bufferToArrayBuffer(mergedBuffer),
{ preferQiniuForm: true }
), ),
uploadFile( uploadFile(
`canvas-local-edit-composited-thumb-${suffix}.png`, `canvas-local-edit-composited-thumb-${suffix}.png`,
bufferToArrayBuffer(thumbnailBuffer) bufferToArrayBuffer(thumbnailBuffer),
{ preferQiniuForm: true }
), ),
]); ]);
mergedImages.push({ mergedImages.push({
@ -5121,8 +5124,11 @@ export async function generateCanvasImage(
}); });
} }
const { kept: reachableReferenceImages, dropped: unreachableReferenceImages } = const referenceImageBuffers = provider.type === "alibaba-image" ? request.referenceImageBuffers : undefined;
await filterReachableRemoteUrls(normalizedReferenceImages, { timeoutMs: 3000 }); const referencesToCheck = normalizedReferenceImages.filter(url => !referenceImageBuffers?.has(url));
const { kept, dropped: unreachableReferenceImages } =
await filterReachableRemoteUrls(referencesToCheck, { timeoutMs: 3000 });
const reachableReferenceImages = normalizedReferenceImages.filter(url => referenceImageBuffers?.has(url) || kept.includes(url));
const effectiveReferenceImages = selectCanvasReferenceImages({ const effectiveReferenceImages = selectCanvasReferenceImages({
requested: normalizedReferenceImages, reachable: reachableReferenceImages, requested: normalizedReferenceImages, reachable: reachableReferenceImages,
requiredCount: request.requiredReferenceImageCount, requiredCount: request.requiredReferenceImageCount,
@ -5233,6 +5239,7 @@ export async function generateCanvasImage(
executeGenerateBatch = async (batchImageCount) => { executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({ const result = await modelInstance.doGenerate({
messages, messages,
referenceImageBuffers,
imageParams: { imageParams: {
...imageParams, ...imageParams,
imageCount: batchImageCount, imageCount: batchImageCount,
@ -5662,6 +5669,11 @@ export async function generateCanvasImage(
await slot.release(); await slot.release();
} }
if (caughtError instanceof ImageReferenceReadTimeoutError) {
if (!collectedImages.length) throw caughtError;
return { images: collectedImages, model: selectedModel.model.displayName, modelRef: selectedModel.model.id, prompt: resultPrompt };
}
// An accepted or uncertain request must not start another paid model/variant. // An accepted or uncertain request must not start another paid model/variant.
if (caughtError && ( if (caughtError && (
caughtError instanceof ProviderTaskUncertainError || caughtError instanceof ProviderTaskUncertainError ||

View File

@ -217,9 +217,13 @@ export async function convertImageToPhotoshopPng(bytes: Buffer): Promise<Buffer>
throw new Error("无法解析图片尺寸"); throw new Error("无法解析图片尺寸");
} }
// RGB/RGBA, 8-bit, single-frame PNG is already compatible; preserve its exact bytes. // RGB/RGBA, 8-bit, single-frame PNG is already compatible; preserve its exact bytes.
if (metadata.format === "png" && metadata.space === "srgb" && metadata.depth === "uchar" const rgb = (metadata.space === "srgb" && metadata.depth === "uchar")
|| (metadata.space === "rgb16" && metadata.depth === "ushort");
if (metadata.format === "png" && rgb
&& !metadata.isPalette && (metadata.pages || 1) === 1 && (!metadata.orientation || metadata.orientation === 1)) return bytes; && !metadata.isPalette && (metadata.pages || 1) === 1 && (!metadata.orientation || metadata.orientation === 1)) return bytes;
return image.rotate().toColorspace("srgb").png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer(); const output = image.rotate().toColorspace("srgb");
if (metadata.icc) output.withIccProfile("srgb");
return output.png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer();
} }
function buildCanvasAssetFilename(params: { function buildCanvasAssetFilename(params: {

View File

@ -1,8 +1,9 @@
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery"; import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url"; import { inferOriginalImageUrl } from "@/lib/image-url";
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error"; import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
import { withActivePageDownload } from "@/lib/client/page-refresh";
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving"; export type CanvasImageDownloadPhase = "preparing" | "converting" | "downloading" | "saving";
export type CanvasImageDownloadProgress = { export type CanvasImageDownloadProgress = {
phase: CanvasImageDownloadPhase; phase: CanvasImageDownloadPhase;
@ -189,8 +190,15 @@ function triggerCdnDownload(result: { downloadUrl: string; filename: string; byt
return { filename: result.filename, bytes: result.bytes || 0 }; return { filename: result.filename, bytes: result.bytes || 0 };
} }
/** Keep the source format and bytes for chat, gallery and reference downloads. */ const PHOTOSHOP_DOWNLOAD_EXTENSIONS = new Set(["jpg", "png", "gif", "bmp", "tiff"]);
export async function downloadOriginalImage({ url, name, signal, onProgress }: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
/** Preserve compatible originals and prepare a PNG for other stored images. */
export function downloadOriginalImage(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
return withActivePageDownload(() => downloadOriginalImageFile(params));
}
async function downloadOriginalImageFile(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
const { url, name, signal, onProgress } = params;
const source = String(url || "").trim(); const source = String(url || "").trim();
if (!source) throw new Error("图片地址无效"); if (!source) throw new Error("图片地址无效");
const proxy = buildImageProxyUrl(source, { delivery: "original" }); const proxy = buildImageProxyUrl(source, { delivery: "original" });
@ -218,6 +226,11 @@ export async function downloadOriginalImage({ url, name, signal, onProgress }: D
const metadata = await checkedFetch(target.pathname + target.search); const metadata = await checkedFetch(target.pathname + target.search);
const delivery = await metadata.json(); const delivery = await metadata.json();
if (delivery.delivery === "cdn") { if (delivery.delivery === "cdn") {
// The resolver names the attachment using the stored MIME type.
const extension = delivery.filename.split(".").pop();
if (!PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) {
return downloadPngFile(params, "converting");
}
const result = triggerCdnDownload(delivery, signal); const result = triggerCdnDownload(delivery, signal);
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" }); onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
return result; return result;
@ -225,9 +238,16 @@ export async function downloadOriginalImage({ url, name, signal, onProgress }: D
if (delivery.delivery !== "proxy") throw new Error("下载链接无效"); if (delivery.delivery !== "proxy") throw new Error("下载链接无效");
} }
const response = await checkedFetch(proxy); const response = await checkedFetch(proxy);
const contentType = response.headers.get("content-type") || "";
if (!contentType.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
const extension = getImageDownloadExtension(contentType);
// A local blob can still be downloaded while its upload is in progress.
if (/^https?:$/.test(target.protocol) && !PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) {
void response.body?.cancel().catch(() => {});
return downloadPngFile(params, "converting");
}
const blob = await readResponseBlobWithProgress(response, onProgress); const blob = await readResponseBlobWithProgress(response, onProgress);
if (!blob.type.toLowerCase().startsWith("image/")) throw new Error("Source is not an image"); signal?.throwIfAborted();
const extension = getImageDownloadExtension(blob.type);
const filename = extension ? `${filenameBase}.${extension}` : filenameBase; const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
triggerBlobDownload(blob, filename); triggerBlobDownload(blob, filename);
return { filename, bytes: blob.size }; return { filename, bytes: blob.size };
@ -252,14 +272,15 @@ function toDownloadCandidates(url: string, candidates: string[] = []) {
async function readResponseBlobWithProgress( async function readResponseBlobWithProgress(
response: Response, response: Response,
onProgress?: (progress: CanvasImageDownloadProgress) => void onProgress?: (progress: CanvasImageDownloadProgress) => void,
message = "正在下载原图..."
) { ) {
const totalBytes = Number(response.headers.get("content-length") || 0); const totalBytes = Number(response.headers.get("content-length") || 0);
if (!response.body) { if (!response.body) {
const blob = await response.blob(); const blob = await response.blob();
onProgress?.({ onProgress?.({
phase: "downloading", phase: "downloading",
message: "正在下载原图...", message,
loadedBytes: blob.size, loadedBytes: blob.size,
totalBytes: totalBytes || blob.size, totalBytes: totalBytes || blob.size,
percent: 100, percent: 100,
@ -279,7 +300,7 @@ async function readResponseBlobWithProgress(
loadedBytes += value.byteLength; loadedBytes += value.byteLength;
onProgress?.({ onProgress?.({
phase: "downloading", phase: "downloading",
message: "正在下载原图...", message,
loadedBytes, loadedBytes,
totalBytes: totalBytes || undefined, totalBytes: totalBytes || undefined,
percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined, percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined,
@ -291,13 +312,17 @@ async function readResponseBlobWithProgress(
}); });
} }
export async function downloadCanvasImageAsPng({ export function downloadCanvasImageAsPng(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
return withActivePageDownload(() => downloadPngFile(params, "preparing"));
}
async function downloadPngFile({
candidates, candidates,
name, name,
onProgress, onProgress,
signal, signal,
url, url,
}: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> { }: DownloadCanvasImageParams, preparationPhase: "preparing" | "converting"): Promise<CanvasImageDownloadResult> {
const raw = String(url || "").trim(); const raw = String(url || "").trim();
if (!raw) { if (!raw) {
throw new Error("图片地址无效"); throw new Error("图片地址无效");
@ -310,11 +335,9 @@ export async function downloadCanvasImageAsPng({
"canvas-image" "canvas-image"
); );
onProgress?.({ const isConverting = preparationPhase === "converting";
phase: "preparing", const conversionMessage = "正在转换为兼容 PNG,完成后自动下载";
message: "正在准备原图...", onProgress?.({ phase: preparationPhase, message: isConverting ? conversionMessage : "正在准备原图..." });
percent: 8,
});
let response: Response | null = null; let response: Response | null = null;
let errorPayload: DownloadErrorPayload | null = null; let errorPayload: DownloadErrorPayload | null = null;
@ -353,7 +376,10 @@ export async function downloadCanvasImageAsPng({
if (Date.now() >= preparationDeadline || preparationPolls >= 60) { if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId }); throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
} }
onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" }); onProgress?.({
phase: preparationPhase,
message: isConverting ? conversionMessage : preparing.message || "正在准备原图,完成后将自动下载",
});
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3)); const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
await waitForRetry(delaySeconds * 1000, signal); await waitForRetry(delaySeconds * 1000, signal);
continue; continue;
@ -384,7 +410,6 @@ export async function downloadCanvasImageAsPng({
retryAfterSeconds > 0 retryAfterSeconds > 0
? `请求较多,${retryAfterSeconds} 秒后自动重试...` ? `请求较多,${retryAfterSeconds} 秒后自动重试...`
: "请求较多,正在自动重试...", : "请求较多,正在自动重试...",
percent: 8,
}); });
await waitForRetry(retryAfterSeconds * 1000, signal); await waitForRetry(retryAfterSeconds * 1000, signal);
} }
@ -413,7 +438,9 @@ export async function downloadCanvasImageAsPng({
const filename = const filename =
parseAttachmentFilename(response.headers.get("content-disposition")) || parseAttachmentFilename(response.headers.get("content-disposition")) ||
`${filenameBase}.png`; `${filenameBase}.png`;
const blob = await readResponseBlobWithProgress(response, onProgress); const downloadMessage = isConverting ? "正在下载 PNG..." : "正在下载原图...";
onProgress?.({ phase: "downloading", message: downloadMessage });
const blob = await readResponseBlobWithProgress(response, onProgress, downloadMessage);
onProgress?.({ onProgress?.({
phase: "saving", phase: "saving",

View File

@ -1,11 +1,27 @@
import type { AgentWorkflowComposerContext } from "@/types/agent";
const settingsCache = new Map<string, { etag: string; data: unknown }>(); const settingsCache = new Map<string, { etag: string; data: unknown }>();
export async function fetchWorkflowTemplatePrompts([url, selectionRequest]: readonly [string, string]) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: selectionRequest,
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) throw new Error("风格模板提示词加载失败");
const data = await response.json() as { selections?: AgentWorkflowComposerContext["selections"] };
if (!Array.isArray(data.selections)) throw new Error("风格模板提示词返回异常");
return { selectionRequest, selections: data.selections };
}
export async function fetchCanvasSiteSettings(url: string) { export async function fetchCanvasSiteSettings(url: string) {
const cached = settingsCache.get(url); const cached = settingsCache.get(url);
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout( const timeoutId = setTimeout(
() => controller.abort(), () => controller.abort(),
url === "/api/site-settings?scope=agent-canvas" ? 30_000 : 10_000 url === "/api/site-settings?scope=agent-canvas" ||
url === "/api/site-settings?scope=agent-workflow-templates" ? 30_000 : 10_000
); );
try { try {
const response = await fetch(url, { const response = await fetch(url, {

View File

@ -1,10 +1,44 @@
export const BEFORE_PAGE_REFRESH = "app:before-page-refresh"; export const BEFORE_PAGE_REFRESH = "app:before-page-refresh";
export const PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE = "data-page-download-active";
export type PageRefreshRequest = { export type PageRefreshRequest = {
save: (action: () => Promise<void>) => void; save: (action: () => Promise<void>) => void;
block: (message: string) => void; block: (message: string) => void;
}; };
const downloadListeners = new Set<() => void>();
let activePageDownloads = 0;
export function isPageDownloadActive() {
return activePageDownloads > 0;
}
export function subscribeToPageDownloadActivity(listener: () => void) {
downloadListeners.add(listener);
return () => { downloadListeners.delete(listener); };
}
function publishDownloadActivity() {
if (typeof document !== "undefined") {
document.documentElement?.toggleAttribute(PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE, isPageDownloadActive());
}
for (const listener of downloadListeners) listener();
}
export async function withActivePageDownload<T>(download: () => Promise<T>): Promise<T> {
if (++activePageDownloads === 1) publishDownloadActivity();
try {
return await download();
} finally {
if (--activePageDownloads === 0) publishDownloadActivity();
}
}
function assertPageDownloadFinished() {
if (isPageDownloadActive()) throw new Error("文件正在下载,请等下载完成后再刷新。");
}
export async function preparePageRefresh() { export async function preparePageRefresh() {
assertPageDownloadFinished();
const actions: Array<() => Promise<void>> = []; const actions: Array<() => Promise<void>> = [];
let blocked = ""; let blocked = "";
window.dispatchEvent(new CustomEvent<PageRefreshRequest>(BEFORE_PAGE_REFRESH, { window.dispatchEvent(new CustomEvent<PageRefreshRequest>(BEFORE_PAGE_REFRESH, {
@ -12,4 +46,5 @@ export async function preparePageRefresh() {
})); }));
if (blocked) throw new Error(blocked); if (blocked) throw new Error(blocked);
for (const action of actions) await action(); for (const action of actions) await action();
assertPageDownloadFinished();
} }

View File

@ -1,6 +1,15 @@
import { normalizeLegacyAliyunStorageUrl } from "./storage/retired-storage"; import { normalizeLegacyAliyunStorageUrl } from "./storage/retired-storage";
const IMAGE_PROXY_PATH = "/api/image-proxy"; const IMAGE_PROXY_PATH = "/api/image-proxy";
const CDN_PREVIEW_WIDTHS = [64, 160, 192, 240, 320, 360, 480, 520, 640, 720, 840, 960, 1280, 1600, 2048, 2560, 2600, 3200, 4096];
function getConfiguredCdnUrl(): URL | undefined {
try {
return new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "");
} catch {
return undefined;
}
}
/** Error fallbacks must keep unavailable legacy sources behind archive resolution. */ /** Error fallbacks must keep unavailable legacy sources behind archive resolution. */
export function buildImageFallbackUrl(value: string | null | undefined): string { export function buildImageFallbackUrl(value: string | null | undefined): string {
@ -102,10 +111,7 @@ export function isAlibabaModelImageUrl(value: string): boolean {
function isTrustedDirectStorageImageHost(hostname: string): boolean { function isTrustedDirectStorageImageHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase(); const normalized = hostname.trim().toLowerCase();
let configuredCdnHost = "";
try { configuredCdnHost = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "").hostname; } catch { /* optional */ }
return ( return (
(configuredCdnHost !== "" && normalized === configuredCdnHost) ||
normalized === "yybb.top" || normalized === "yybb.top" ||
normalized.endsWith(".qiniucs.com") || normalized.endsWith(".qiniucs.com") ||
normalized.endsWith(".qiniudn.com") || normalized.endsWith(".qiniudn.com") ||
@ -127,20 +133,29 @@ function resolveTrustedDirectStorageDisplayUrl(
): string | null { ): string | null {
try { try {
const target = new URL(url); const target = new URL(url);
const configuredCdn = getConfiguredCdnUrl();
const isConfiguredCdn = target.origin === configuredCdn?.origin;
if ( if (
target.hostname !== configuredCdn?.hostname &&
!isTrustedDirectStorageImageHost(target.hostname) && !isTrustedDirectStorageImageHost(target.hostname) &&
!isFirstPartyQiniuAsset(target) !isFirstPartyQiniuAsset(target)
) { ) {
return null; return null;
} }
const width = normalizeWidth(options.width); const requestedWidth = normalizeWidth(options.width);
const quality = normalizeQuality(options.quality); let width = requestedWidth;
let quality = normalizeQuality(options.quality);
const format = options.format; const format = options.format;
if ((!width && !quality && !format) || target.search) { if ((!width && !quality && !format) || target.search) {
return target.toString(); return target.toString();
} }
if (isConfiguredCdn && format === "webp" && requestedWidth) {
width = CDN_PREVIEW_WIDTHS.find((size) => size >= requestedWidth) ?? requestedWidth;
if (quality && quality >= 84 && quality <= 92) quality = 88;
}
const operations = ["imageView2", "2"]; const operations = ["imageView2", "2"];
if (width) operations.push("w", String(width)); if (width) operations.push("w", String(width));
if (format) operations.push("format", format === "jpeg" ? "jpg" : format); if (format) operations.push("format", format === "jpeg" ? "jpg" : format);

View File

@ -26,14 +26,14 @@ function getBatchSize() {
return Math.max(1, Math.min(50, Math.round(parsed))); return Math.max(1, Math.min(50, Math.round(parsed)));
} }
export async function runImageArchiveJob() { export async function runImageArchiveJob({ preferredTaskId }: { preferredTaskId?: string } = {}) {
return trackRuntimeWork(async () => { return trackRuntimeWork(async () => {
const batchSize = getBatchSize(); const batchSize = getBatchSize();
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`); console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
await recoverStaleImageArchiveTasks(); await recoverStaleImageArchiveTasks();
scheduleImageDownloadPreparation(); scheduleImageDownloadPreparation();
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize }); const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize, preferredTaskId });
let storeSucceeded = 0; let storeSucceeded = 0;
let storeFailed = 0; let storeFailed = 0;
@ -41,12 +41,13 @@ export async function runImageArchiveJob() {
try { try {
if (!(await markImageArchiveTaskArchiving(task.id))) return; if (!(await markImageArchiveTaskArchiving(task.id))) return;
const metadata = task.metadata as { inlineSource?: string } | null; const metadata = task.metadata as { inlineSource?: string } | null;
const archived = await archiveTemporaryImage(metadata?.inlineSource || task.sourceUrl); const archived = await archiveTemporaryImage(metadata?.inlineSource || task.sourceUrl, task.id);
await markImageArchiveTaskStored({ await markImageArchiveTaskStored({
id: task.id, id: task.id,
targetUrl: archived.url, targetUrl: archived.url,
downloadAsset: archived.downloadAsset,
}); });
scheduleImageDownloadPreparation(task.id); if (!archived.downloadAsset) scheduleImageDownloadPreparation(task.id);
storeSucceeded += 1; storeSucceeded += 1;
console.log("[ImageArchiveJob] stored", { console.log("[ImageArchiveJob] stored", {
taskId: task.id, taskId: task.id,

View File

@ -293,6 +293,9 @@ export function getRequestFailureDetails(
if (/AOS结果下载超时/.test(text)) { if (/AOS结果下载超时/.test(text)) {
return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown"); return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
} }
if (/提交生图前读取参考图超时/.test(text)) {
return detail("IMAGE_REFERENCE_READ_TIMEOUT", "提交生图前读取参考图超时,任务尚未提交。", "可以稍后重试。");
}
const errorName = isRecord(error) ? String(error.name || "") : ""; const errorName = isRecord(error) ? String(error.name || "") : "";
if (["AbortError", "TimeoutError"].includes(errorName) || /operation was aborted/i.test(text)) { if (["AbortError", "TimeoutError"].includes(errorName) || /operation was aborted/i.test(text)) {
return detail("RESULT_UNCONFIRMED", errorName === "TimeoutError" ? "请求等待超时,任务结果尚未确认。" : "本次请求连接已中止,任务结果尚未确认。", "请先核对原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown"); return detail("RESULT_UNCONFIRMED", errorName === "TimeoutError" ? "请求等待超时,任务结果尚未确认。" : "本次请求连接已中止,任务结果尚未确认。", "请先核对原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");

View File

@ -98,8 +98,11 @@ export async function enqueueImageArchiveTasks(params: {
return tasks; return tasks;
} }
export async function getImageArchiveTasksToStore(params: { limit: number }) { export async function getImageArchiveTasksToStore(params: { limit: number; preferredTaskId?: string }) {
const now = new Date(); const now = new Date();
const priority = params.preferredTaskId
? [asc(sql`case when ${imageArchiveTask.id} = ${params.preferredTaskId} then 0 else 1 end`)]
: [];
return await db return await db
.select() .select()
.from(imageArchiveTask) .from(imageArchiveTask)
@ -110,7 +113,7 @@ export async function getImageArchiveTasksToStore(params: { limit: number }) {
lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS), lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS),
), ),
) )
.orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id)) .orderBy(...priority, asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
.limit(Math.max(1, Math.min(50, params.limit))); .limit(Math.max(1, Math.min(50, params.limit)));
} }
@ -138,6 +141,7 @@ export async function markImageArchiveTaskStored(params: {
id: string; id: string;
targetUrl: string; targetUrl: string;
targetStorageConfigId?: string | null; targetStorageConfigId?: string | null;
downloadAsset?: import("./qiniu-image-archive").ReadyImageDownload;
}) { }) {
await db await db
.update(imageArchiveTask) .update(imageArchiveTask)
@ -145,7 +149,7 @@ export async function markImageArchiveTaskStored(params: {
status: "stored", status: "stored",
targetUrl: params.targetUrl, targetUrl: params.targetUrl,
targetStorageConfigId: params.targetStorageConfigId || null, targetStorageConfigId: params.targetStorageConfigId || null,
metadata: sql`coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) || jsonb_build_object('downloadAsset', jsonb_build_object('status', 'pending'))`, metadata: sql`coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) || jsonb_build_object('downloadAsset', ${JSON.stringify(params.downloadAsset ?? { status: "pending" })}::jsonb)`,
archivedAt: new Date(), archivedAt: new Date(),
replaceAfterAt: new Date(), replaceAfterAt: new Date(),
lastError: null, lastError: null,

View File

@ -6,6 +6,7 @@ type Worker = {
running: boolean; running: boolean;
scheduled: boolean; scheduled: boolean;
requested: boolean; requested: boolean;
preferredTaskId?: string;
retryTimer?: ReturnType<typeof setTimeout>; retryTimer?: ReturnType<typeof setTimeout>;
}; };
function getWorker(): Worker { function getWorker(): Worker {
@ -13,8 +14,9 @@ function getWorker(): Worker {
return (root[workerKey] ??= { running: false, scheduled: false, requested: false }); return (root[workerKey] ??= { running: false, scheduled: false, requested: false });
} }
export function scheduleImageArchiveWorker(deliveryConfirmed = false) { export function scheduleImageArchiveWorker(deliveryConfirmed = false, preferredTaskId?: string) {
const worker = getWorker(); const worker = getWorker();
worker.preferredTaskId ??= preferredTaskId;
worker.requested = true; worker.requested = true;
if (worker.running || (worker.scheduled && !deliveryConfirmed)) return; if (worker.running || (worker.scheduled && !deliveryConfirmed)) return;
worker.scheduled = true; worker.scheduled = true;
@ -31,7 +33,9 @@ export function scheduleImageArchiveWorker(deliveryConfirmed = false) {
const { runImageArchiveJob } = await import("@/lib/jobs/image-archive-job"); const { runImageArchiveJob } = await import("@/lib/jobs/image-archive-job");
do { do {
worker.requested = false; worker.requested = false;
const result = await runImageArchiveJob(); const nextTaskId = worker.preferredTaskId;
worker.preferredTaskId = undefined;
const result = await runImageArchiveJob({ preferredTaskId: nextTaskId });
retryNeeded = result.stats.pendingStore > 0 || result.stats.pendingReplace > 0 || result.pendingDownloads === true; retryNeeded = result.stats.pendingStore > 0 || result.stats.pendingReplace > 0 || result.pendingDownloads === true;
// Stop when only backoff tasks remain; do not spin while storage is down. // Stop when only backoff tasks remain; do not spin while storage is down.
if (!worker.requested && result.store.total === 0 && result.replace.total === 0 && result.downloadTasks === 0) break; if (!worker.requested && result.store.total === 0 && result.replace.total === 0 && result.downloadTasks === 0) break;

View File

@ -12,6 +12,7 @@ import {
imageArchiveSourceKey, imageArchiveSourceKey,
} from "@/lib/storage/image-archive-db"; } from "@/lib/storage/image-archive-db";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { archiveImageThroughQiniu } from "./qiniu-image-archive";
const logger = createLogger("image-archive"); const logger = createLogger("image-archive");
@ -182,7 +183,7 @@ export async function downloadTemporaryImage(params: { sourceUrl: string; retrie
}); });
return { ...data, extension: getImageExtension(data.contentType, params.sourceUrl) }; return { ...data, extension: getImageExtension(data.contentType, params.sourceUrl) };
} catch (error) { } catch (error) {
if (attempt >= retries) throw error; if (attempt >= retries || /\bHTTP (?:404|410)\b/.test(String(error))) throw error;
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1))); await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
} }
} }
@ -190,7 +191,14 @@ export async function downloadTemporaryImage(params: { sourceUrl: string; retrie
throw new Error("Image download failed"); throw new Error("Image download failed");
} }
export async function archiveTemporaryImage(sourceUrl: string) { export async function archiveTemporaryImage(sourceUrl: string, archiveId?: string) {
if (archiveId) {
const direct = await archiveImageThroughQiniu(sourceUrl, archiveId).catch(error => {
logger.warn("cloud_archive_fallback", error, { archiveId });
return null;
});
if (direct) return direct;
}
const downloaded = await downloadTemporaryImage({ sourceUrl }); const downloaded = await downloadTemporaryImage({ sourceUrl });
const objectSuffix = `${Date.now()}-${randomUUID()}`; const objectSuffix = `${Date.now()}-${randomUUID()}`;
const filename = `ai-generated-archive-${objectSuffix}.${downloaded.extension}`; const filename = `ai-generated-archive-${objectSuffix}.${downloaded.extension}`;
@ -202,5 +210,6 @@ export async function archiveTemporaryImage(sourceUrl: string) {
pathname: uploaded.pathname, pathname: uploaded.pathname,
contentType: uploaded.contentType, contentType: uploaded.contentType,
bytes: downloaded.buffer.length, bytes: downloaded.buffer.length,
downloadAsset: undefined,
}; };
} }

View File

@ -4,6 +4,7 @@ import { fetchImageBytes, cacheImageBytes } from "./image-byte-cache";
import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download"; import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks, hasPendingImageDownloads } from "./image-archive-db"; import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks, hasPendingImageDownloads } from "./image-archive-db";
import { uploadFile } from "./service"; import { uploadFile } from "./service";
import { getReadyQiniuImageDownload } from "./qiniu-image-archive";
import { getDownloadFailure } from "@/lib/canvas/download-error"; import { getDownloadFailure } from "@/lib/canvas/download-error";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { trackRuntimeWork } from "@/lib/observability/active-work"; import { trackRuntimeWork } from "@/lib/observability/active-work";
@ -15,6 +16,11 @@ export async function prepareImageArchiveDownload(id: string) {
if (!task?.targetUrl) return; if (!task?.targetUrl) return;
let phase: "read" | "convert" | "store" = "read"; let phase: "read" | "convert" | "store" = "read";
try { try {
const ready = await getReadyQiniuImageDownload(task.targetUrl).catch(() => undefined);
if (ready) {
await saveImageDownloadAsset(id, ready);
return;
}
const source = await fetchImageBytes(task.targetUrl); const source = await fetchImageBytes(task.targetUrl);
phase = "convert"; phase = "convert";
const asset = await normalizeCanvasDownloadAsset({ const asset = await normalizeCanvasDownloadAsset({

View File

@ -4,6 +4,7 @@ import { findImageArchiveDownloadTask } from "./image-archive-db";
import { inferOriginalImageUrl } from "@/lib/image-url"; import { inferOriginalImageUrl } from "@/lib/image-url";
import { getImageDownloadExtension } from "@/lib/image-delivery"; import { getImageDownloadExtension } from "@/lib/image-delivery";
import { sanitizeCanvasDownloadFileName } from "@/lib/canvas/photoshop-safe-download"; import { sanitizeCanvasDownloadFileName } from "@/lib/canvas/photoshop-safe-download";
import { HeadObjectCommand } from "@aws-sdk/client-s3";
/** Resolve the archived original without waiting for a separate PNG conversion. */ /** Resolve the archived original without waiting for a separate PNG conversion. */
export async function resolveOriginalCdnDownload(source: string, name: string, signal: AbortSignal) { export async function resolveOriginalCdnDownload(source: string, name: string, signal: AbortSignal) {
@ -14,6 +15,10 @@ export async function resolveOriginalCdnDownload(source: string, name: string, s
let target = new URL(source); let target = new URL(source);
if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) { if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) {
const task = await findImageArchiveDownloadTask([...new Set([source, inferOriginalImageUrl(source)])]); const task = await findImageArchiveDownloadTask([...new Set([source, inferOriginalImageUrl(source)])]);
if (task?.status === "pending" && !task.targetUrl) {
const { scheduleImageArchiveWorker } = await import("./image-archive-worker");
scheduleImageArchiveWorker(false, task.id);
}
target = new URL(task?.targetUrl || source); target = new URL(task?.targetUrl || source);
} }
if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) { if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) {
@ -28,8 +33,32 @@ export async function resolveOriginalCdnDownload(source: string, name: string, s
// Only metadata crosses the application server. The browser receives the file // Only metadata crosses the application server. The browser receives the file
// from the CDN, including when cross-origin fetch is disabled by storage CORS. // from the CDN, including when cross-origin fetch is disabled by storage CORS.
const response = await fetch(target, { const response = await fetch(target, {
method: "HEAD", cache: "no-store", method: "HEAD", cache: "default",
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]), signal: AbortSignal.any([signal, AbortSignal.timeout(3_000)]),
}).catch(async (error) => {
signal.throwIfAborted();
if (target.search) throw error;
const { createS3Client, toDbS3UploadConfig } = await import("./service");
const storage = toDbS3UploadConfig(config);
if (!storage) throw error;
const key = decodeURIComponent(target.pathname.slice(base.pathname.replace(/\/$/, "").length + 1));
if (!key) throw error;
const client = createS3Client(storage);
try {
const object = await client.send(new HeadObjectCommand({ Bucket: storage.bucket, Key: key }), {
abortSignal: AbortSignal.any([signal, AbortSignal.timeout(5_000)]),
});
return new Response(null, { headers: {
"content-type": object.ContentType || "",
"content-length": String(object.ContentLength ?? 0),
} });
} catch (storageError) {
const status = (storageError as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
if (status === 403 || status === 404) throw new Error(`HTTP ${status}`);
throw error;
} finally {
client.destroy();
}
}); });
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
const contentType = response.headers.get("content-type") || ""; const contentType = response.headers.get("content-type") || "";

View File

@ -0,0 +1,121 @@
import "server-only";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { createHmac } from "node:crypto";
import {
buildS3PublicUrl,
createS3Client,
resolveS3UploadTarget,
} from "@/lib/storage/service";
const QINIU_EAST_CHINA_IO = "iovip-z0.qiniuio.com";
const PNG_HEADER_LIMIT = 64 * 1024;
const PNG_SIGNATURE = Buffer.from("89504e470d0a1a0a", "hex");
export type ReadyImageDownload = {
status: "ready";
url: string;
contentType: "image/png";
bytes: number;
preparedAt: string;
};
function encodeQiniu(value: string | Buffer) {
return (typeof value === "string" ? Buffer.from(value) : value)
.toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
}
function isDownloadReadyPng(prefix: Buffer) {
if (prefix.length < 41 || !prefix.subarray(0, 8).equals(PNG_SIGNATURE)
|| prefix.readUInt32BE(8) !== 13 || prefix.toString("ascii", 12, 16) !== "IHDR"
|| !prefix.readUInt32BE(16) || !prefix.readUInt32BE(20)
|| (prefix[24] !== 8 && prefix[24] !== 16) || (prefix[25] !== 2 && prefix[25] !== 6)) return false;
// Animation and EXIF orientation need the existing full-image preparation.
// An embedded color profile is fine: the browser gets the exact stored bytes.
for (let offset = 33; offset + 8 <= prefix.length;) {
const type = prefix.toString("ascii", offset + 4, offset + 8);
if (type === "IDAT") return true;
if (type === "acTL" || type === "eXIf" || type === "IEND") return false;
offset += prefix.readUInt32BE(offset) + 12;
}
return false;
}
export async function getReadyQiniuImageDownload(url: string, target?: Awaited<ReturnType<typeof resolveS3UploadTarget>>) {
const config = (target ?? await resolveS3UploadTarget())?.config;
if (config?.provider !== "qiniu-kodo" || !config.publicUrl) return;
const base = buildS3PublicUrl(config, "");
if (!url.startsWith(base)) return;
const key = url.slice(base.length);
if (!/^uploads\/[\w.-]+$/.test(key)) return;
const client = createS3Client(config);
try {
const response = await client.send(new GetObjectCommand({
Bucket: config.bucket, Key: key, Range: `bytes=0-${PNG_HEADER_LIMIT - 1}`,
}), { abortSignal: AbortSignal.timeout(10_000) });
const range = response.ContentRange?.match(/^bytes 0-(\d+)\/(\d+)$/);
const length = response.ContentLength ?? 0;
const bytes = Number(range?.[2]);
if (response.$metadata.httpStatusCode !== 206 || response.ContentType !== "image/png"
|| !range || !response.Body || length <= 0 || length > PNG_HEADER_LIMIT
|| Number(range[1]) + 1 !== length || bytes < length) {
(response.Body as NodeJS.ReadableStream & { destroy?: () => void } | undefined)?.destroy?.();
return;
}
const prefix = Buffer.from(await response.Body.transformToByteArray());
if (prefix.length === length && isDownloadReadyPng(prefix)) {
return { status: "ready", url, contentType: "image/png", bytes, preparedAt: new Date().toISOString() } satisfies ReadyImageDownload;
}
} finally {
client.destroy();
}
}
export async function archiveImageThroughQiniu(sourceUrl: string, archiveId: string) {
if (!/^https:\/\/api\.vip98k\.com\/api\/external\/assets\/[A-Za-z0-9_-]+$/.test(sourceUrl)) {
return null;
}
const target = await resolveS3UploadTarget();
const config = target?.config;
if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) {
return null;
}
// The archive task owns this object key, including retries after a timeout.
const pathname = `uploads/ai-generated-archive-${archiveId}`;
const path = `/fetch/${encodeQiniu(sourceUrl)}/to/${encodeQiniu(`${config.bucket}:${pathname}`)}`;
const contentType = "application/x-www-form-urlencoded";
const signingData = `POST ${path}\nHost: ${QINIU_EAST_CHINA_IO}\nContent-Type: ${contentType}\n\n`;
const signature = encodeQiniu(createHmac("sha1", config.secretAccessKey).update(signingData).digest());
const response = await fetch(`https://${QINIU_EAST_CHINA_IO}${path}`, {
method: "POST",
headers: {
Authorization: `Qiniu ${config.accessKeyId}:${signature}`,
"Content-Type": contentType,
},
body: "",
redirect: "error",
signal: AbortSignal.timeout(40_000),
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Qiniu archive fetch returned HTTP ${response.status}`);
}
const result = await response.json() as { key?: string; fsize?: number; mimeType?: string };
if (result.key !== pathname || !result.fsize || !result.mimeType?.startsWith("image/")) {
throw new Error("Qiniu archive fetch did not return an image");
}
const url = buildS3PublicUrl(config, pathname);
return {
url,
thumbnailUrl: url,
pathname,
contentType: result.mimeType,
bytes: result.fsize,
downloadAsset: result.mimeType === "image/png"
? await getReadyQiniuImageDownload(url, target).catch(() => undefined)
: undefined,
};
}

View File

@ -3,6 +3,7 @@ import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, RETIRED_STORAGE_MESSA
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { put } from "@vercel/blob"; import { put } from "@vercel/blob";
import { createHmac } from "node:crypto";
import { import {
getDefaultStorageConfig, getDefaultStorageConfig,
getStorageConfigById, getStorageConfigById,
@ -239,12 +240,48 @@ export async function maybeEnqueueStorageMigrationTask(params: {
await maybeEnqueueAliyunMigrationTask(params); await maybeEnqueueAliyunMigrationTask(params);
} }
async function uploadQiniuForm(
config: S3UploadConfig,
key: string,
buffer: ArrayBuffer,
contentType: string
) {
const encode = (value: Buffer) =>
value.toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
const policy = encode(Buffer.from(JSON.stringify({
scope: `${config.bucket}:${key}`,
deadline: Math.floor(Date.now() / 1000) + 120,
insertOnly: 1,
})));
const signature = encode(createHmac("sha1", config.secretAccessKey).update(policy).digest());
const form = new FormData();
form.append("token", `${config.accessKeyId}:${signature}:${policy}`);
form.append("key", key);
form.append("file", new Blob([buffer], { type: contentType }), key.split("/").at(-1));
const startedAt = Date.now();
const response = await fetch("https://upload.qiniup.com/", {
method: "POST",
body: form,
redirect: "error",
signal: AbortSignal.timeout(45_000),
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Qiniu form upload returned HTTP ${response.status}`);
}
const result = await response.json() as { key?: string };
if (result.key !== key) throw new Error("Qiniu form upload returned an unexpected key");
logger.info("qiniu_form_upload_completed", { bytes: buffer.byteLength, elapsedMs: Date.now() - startedAt });
}
// S3/MinIO 上传函数(使用 AWS SDK) // S3/MinIO 上传函数(使用 AWS SDK)
async function uploadToS3( async function uploadToS3(
filename: string, filename: string,
buffer: ArrayBuffer, buffer: ArrayBuffer,
config: S3UploadConfig, config: S3UploadConfig,
s3Client = createS3Client(config) s3Client = createS3Client(config),
preferQiniuForm = false
) { ) {
const { const {
endpoint, endpoint,
@ -260,6 +297,14 @@ async function uploadToS3(
const contentType = getContentType(filename); const contentType = getContentType(filename);
try { try {
if (preferQiniuForm && config.provider === "qiniu-kodo" && config.region === "cn-east-1") {
try {
await uploadQiniuForm(config, key, buffer, contentType);
return { url: buildS3PublicUrl(config, key), pathname: key, contentType };
} catch (error) {
logger.warn("[Upload] Qiniu form upload failed, retrying the same object through S3", error);
}
}
logger.debug(`[S3 Upload] Uploading to: ${normalizedEndpoint}/${bucket}/${key}`); logger.debug(`[S3 Upload] Uploading to: ${normalizedEndpoint}/${bucket}/${key}`);
// 上传文件 // 上传文件
@ -551,7 +596,8 @@ async function uploadWithAnyEnabledStorageConfig(
// 上传文件到存储服务 // 上传文件到存储服务
export async function uploadFile( export async function uploadFile(
filename: string, filename: string,
buffer: ArrayBuffer buffer: ArrayBuffer,
options?: { preferQiniuForm?: boolean }
): Promise<UploadedFileResult> { ): Promise<UploadedFileResult> {
logger.debug(`[Upload] Starting upload for file: ${filename}`); logger.debug(`[Upload] Starting upload for file: ${filename}`);
@ -572,7 +618,7 @@ export async function uploadFile(
logger.debug(`[Upload] Using database S3 config: ${selectedStorageConfig?.name}`); logger.debug(`[Upload] Using database S3 config: ${selectedStorageConfig?.name}`);
logger.debug(`[Upload] Endpoint: ${dbS3Config.endpoint}`); logger.debug(`[Upload] Endpoint: ${dbS3Config.endpoint}`);
logger.debug(`[Upload] Bucket: ${dbS3Config.bucket}`); logger.debug(`[Upload] Bucket: ${dbS3Config.bucket}`);
const uploaded = await uploadToS3(filename, buffer, dbS3Config); const uploaded = await uploadToS3(filename, buffer, dbS3Config, undefined, options?.preferQiniuForm);
lastKnownGoodDbS3Config = dbS3Config; lastKnownGoodDbS3Config = dbS3Config;
await maybeEnqueueAliyunMigrationTask({ await maybeEnqueueAliyunMigrationTask({
uploaded, uploaded,

View File

@ -15,6 +15,7 @@ export default defineConfig({
/routes\/runtime-active-work\.test\.ts/, /routes\/runtime-active-work\.test\.ts/,
/routes\/canvas-capability-task-recovery\.test\.ts/, /routes\/canvas-capability-task-recovery\.test\.ts/,
/routes\/canvas-toolbar-capability\.test\.ts/, /routes\/canvas-toolbar-capability\.test\.ts/,
/routes\/canvas-text-detect\.test\.ts/,
/routes\/agent-project-recovery\.test\.ts/, /routes\/agent-project-recovery\.test\.ts/,
/routes\/admin-media-loading\.test\.ts/, /routes\/admin-media-loading\.test\.ts/,
/routes\/video-canvas-production\.test\.ts/, /routes\/video-canvas-production\.test\.ts/,

View File

@ -72,6 +72,85 @@ function projectRoute() {
return { route: load("app/api/agent-projects/route.ts"), projects, setUser: (value: string | null) => { userId = value; } }; return { route: load("app/api/agent-projects/route.ts"), projects, setUser: (value: string | null) => { userId = value; } };
} }
test("a saved full detail set is queued, while overflow and changed references have distinct errors", async () => {
const references = Array.from({ length: 65 }, (_, index) => ({
id: `detail-${index + 1}`,
imageUrl: `https://example.test/detail-${index + 1}.webp`,
}));
const project = {
id, userId: owner, title: "全套详情", workspaceType: "agent",
canvasData: { agentProjectState: { workflowState: {
agentModeState: { detailReplacement: { references } },
} } },
};
const canvasProject = { id: "id", userId: "userId" };
const created: Array<{ id: string; request: { references: typeof references }; status: string }> = [];
const queued: string[] = [];
const db = {
select: () => ({ from: (table: unknown) => ({
where: () => ({
limit: async () => table === canvasProject ? [project] : [],
orderBy: () => ({ limit: async () => [] }),
}),
}) }),
insert: () => ({ values: () => ({ onConflictDoNothing: async () => {} }) }),
};
const route = loadRuntime({
"@/app/(auth)/auth": { auth: async () => ({ user: { id: owner } }) },
"@/lib/db": { db },
"@/lib/db/schema": { canvasProject, canvasImageTask: { request: "request" }, message: { id: "id" } },
"drizzle-orm": { eq: () => null, and: () => null, inArray: () => null, desc: () => null, sql: () => null },
"@/lib/canvas/image-task-queue": {
createCanvasImageTask: async ({ taskId, request }: { taskId: string; request: { references: typeof references } }) => {
const task = { id: taskId, request, status: "pending" };
created.push(task);
return task;
},
getCanvasImageTaskForUser: async ({ taskId }: { taskId: string }) => created.find((task) => task.id === taskId),
enqueueCanvasImageTask: async (taskId: string) => { queued.push(taskId); },
},
"@/lib/db/queries": {
getEnabledAiModelsWithProvider: async () => [{ model: { id: "fixture-model" } }],
checkUserImageQuota: async () => ({ allowed: true, limit: null }),
getPointsSystemConfig: async () => ({ enabled: false }),
getChatById: async () => ({ userId: owner }),
},
"@/app/(chat)/api/chat/_lib/detail-replacement": { resolveDirectReplacementModelRef: async () => "fixture-model" },
"@/app/(chat)/api/chat/_lib/agent-scene-workflow": {
buildAgentSlotPrompt: ({ workflowPrompt }: { workflowPrompt: string }) => workflowPrompt,
normalizeAgentSupplementPrompt: () => "",
},
})("app/api/agents/detail-replacement/batches/route.ts");
const post = (items: typeof references, replacementStrategy = "product_only", expectedModelId = "fixture-model") => route.POST(
new Request("http://localhost/api/agents/detail-replacement/batches", {
method: "POST",
body: JSON.stringify({
projectId: id, requestId: randomUUID(), references: items, replacementStrategy, expectedModelId,
prompt: "沿用品牌场景", productImageUrls: ["https://example.test/product.webp"],
}),
}),
);
for (const [count, strategy] of [[48, "product_only"], [64, "scene_replace"]] as const) {
const expectedIds = references.slice(0, count).map((ref) => ref.id);
const response = await post(references.slice(0, count), strategy);
expect(response.status).toBe(200);
expect((await response.json()).task.requestedCardIds).toEqual(expectedIds);
expect(created.at(-1)?.request.references.map((ref) => ref.id)).toEqual(expectedIds);
}
const overflow = await post(references);
expect(overflow.status).toBe(400);
expect((await overflow.json()).error).toContain("一次最多替换 64 张");
const changed = await post([{ ...references[0], imageUrl: "https://example.test/changed.webp" }]);
expect(changed.status).toBe(400);
expect((await changed.json()).error).toContain("尚未保存或已发生变化");
const changedModel = await post(references.slice(0, 48), "scene_replace", "outdated-model");
expect(changedModel.status).toBe(409);
expect((await changedModel.json()).code).toBe("DETAIL_REPLACEMENT_MODEL_CHANGED");
expect(queued).toEqual(created.map((task) => task.id));
expect(queued).toHaveLength(2);
});
test("duplicate POSTs return one project without overwriting saved state", async () => { test("duplicate POSTs return one project without overwriting saved state", async () => {
const { route, projects } = projectRoute(); const { route, projects } = projectRoute();
const post = (title: string) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify({ id, title }) })); const post = (title: string) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify({ id, title }) }));
@ -214,6 +293,11 @@ test("entry config stays small and unchanged config returns 304 before loading v
const entryData = await entry.json(); const entryData = await entry.json();
expect(entryData.agentWorkflowUiConfig.steps).toEqual([]); expect(entryData.agentWorkflowUiConfig.steps).toEqual([]);
expect(JSON.stringify(entryData).length).toBeLessThan(20_000); expect(JSON.stringify(entryData).length).toBeLessThan(20_000);
const base = await (await route.GET(request("agent-canvas-base"))).json();
expect(base.agentWorkflowUiConfig.steps).toEqual([]);
const templates = await (await route.GET(request("agent-workflow-templates"))).json();
expect(templates.steps[0].options[0]).toMatchObject({ key: "test", buttonName: "风格一" });
expect(JSON.stringify(templates)).not.toContain("生成提示词");
const full = await route.GET(request("agent-canvas")); const full = await route.GET(request("agent-canvas"));
expect((await full.json()).agentWorkflowUiConfig.steps[0].options[0].promptFragment).toBe("生成提示词".repeat(3000).slice(0, 20_000)); expect((await full.json()).agentWorkflowUiConfig.steps[0].options[0].promptFragment).toBe("生成提示词".repeat(3000).slice(0, 20_000));
const etag = full.headers.get("ETag"); const etag = full.headers.get("ETag");
@ -227,3 +311,25 @@ test("entry config stays small and unchanged config returns 304 before loading v
expect(changed.status).toBe(200); expect(changed.status).toBe(200);
expect(changed.headers.get("ETag")).not.toBe(etag); expect(changed.headers.get("ETag")).not.toBe(etag);
}); });
test("selected template prompts preserve the full path without downloading unrelated prompts", async () => {
const option = (key: string, promptFragment: string, children: unknown[] = []) => ({
key, buttonName: key, enabled: true, sortOrder: 0, promptFragment, children, previewImageUrl: "https://example.com/style.webp",
});
const value = { steps: [{ key: "style", title: "风格", enabled: true, sortOrder: 0, options: [
option("selected", "完整风格提示词", [option("leaf", "选中的子模板提示词")]),
option("unrelated", "不应下载的其他提示词"),
] }] };
const load = loadRuntime({
"@/app/(auth)/auth": { auth: async () => ({ user: { id: owner } }) },
"@/lib/db/queries": { getSystemSetting: async () => ({ value }) },
});
const { serializeAgentWorkflowSelectionPath } = load("lib/agents/workflow-ui-config.ts");
const { POST } = load("app/api/agent-workflow/template-prompts/route.ts");
const result = await POST(new Request("http://localhost/api/agent-workflow/template-prompts", {
method: "POST", body: JSON.stringify({ style: serializeAgentWorkflowSelectionPath(["selected", "leaf"]) }),
}));
expect(result.status).toBe(200);
const { selections } = await result.json();
expect(selections.map((s: { promptFragment: string }) => s.promptFragment)).toEqual(["完整风格提示词", "选中的子模板提示词"]);
});

View File

@ -32,6 +32,7 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
const jpeg = await sharp(png).jpeg().toBuffer(); const jpeg = await sharp(png).jpeg().toBuffer();
const webp = await sharp(png).webp().toBuffer(); const webp = await sharp(png).webp().toBuffer();
const originals = [png, jpeg, webp]; const originals = [png, jpeg, webp];
const downloads: number[] = [];
let submitted: any; let submitted: any;
let posts = 0; let posts = 0;
const loader = serverLoader({ const loader = serverLoader({
@ -41,14 +42,16 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
if (init.method === "POST") { posts++; submitted = JSON.parse(String(init.body)); return Response.json({ output: { task_id: "inline-task", task_status: "PENDING" } }); } if (init.method === "POST") { posts++; submitted = JSON.parse(String(init.body)); return Response.json({ output: { task_id: "inline-task", task_status: "PENDING" } }); }
if (url.includes("/tasks/")) return Response.json({ output: { task_status: "FAILED", message: "transport-inspection-complete" } }); if (url.includes("/tasks/")) return Response.json({ output: { task_status: "FAILED", message: "transport-inspection-complete" } });
const index = Number(new URL(url).pathname.slice(1)); const index = Number(new URL(url).pathname.slice(1));
downloads.push(index);
return new Response(originals[index], { headers: { "content-type": ["image/png", "image/jpeg", "image/webp"][index] } }); return new Response(originals[index], { headers: { "content-type": ["image/png", "image/jpeg", "image/webp"][index] } });
}, },
}); });
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" }); const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
await expect(provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [ await expect(provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [
{ type: "text", text: "fixture" }, ...originals.map((_bytes, index) => ({ type: "image", image: `https://source.example/${index}` })), { type: "text", text: "fixture" }, ...originals.map((_bytes, index) => ({ type: "image", image: `https://source.example/${index}` })),
] }], imageParams: { aspectRatio: "auto", imageSize: "2K" } })).rejects.toThrow("transport-inspection-complete"); ] }], imageParams: { aspectRatio: "auto", imageSize: "2K" }, referenceImageBuffers: new Map([["https://source.example/0", png]]) })).rejects.toThrow("transport-inspection-complete");
expect(posts).toBe(1); expect(posts).toBe(1);
expect(downloads).toEqual([1, 2]);
expect(submitted.parameters.size).toBe("1024*2048"); expect(submitted.parameters.size).toBe("1024*2048");
const images = submitted.input.messages[0].content.filter((part: any) => part.image).map((part: any) => part.image); const images = submitted.input.messages[0].content.filter((part: any) => part.image).map((part: any) => part.image);
expect(images).toHaveLength(3); expect(images).toHaveLength(3);
@ -57,3 +60,43 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
expect(Buffer.from(value.split(",")[1], "base64").equals(originals[index])).toBe(true); expect(Buffer.from(value.split(",")[1], "base64").equals(originals[index])).toBe(true);
}); });
}); });
test("a timed out reference read is safe to retry because no generation was submitted", async () => {
const methods: string[] = [];
const loader = serverLoader({
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 500 ? 0 : ms),
fetch: async (_url: string, init: RequestInit = {}) => {
methods.push(init.method || "GET");
throw new DOMException("This operation was aborted", "AbortError");
},
});
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
const error = await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [
{ type: "text", text: "fixture" }, { type: "image", image: "https://source.example/slow.png" },
] }] }).catch((error: unknown) => error);
expect(methods).toEqual(["GET", "GET"]);
expect(error).toMatchObject({ confirmedNoResult: true });
expect(loader("lib/request-error.ts").getRequestFailureDetails(error)).toMatchObject({
code: "IMAGE_REFERENCE_READ_TIMEOUT", outcome: "failed", recommendation: "可以稍后重试。",
});
});
test("a failed status lookup after Alibaba accepts the request keeps its outcome unknown", async () => {
const methods: string[] = [];
const loader = serverLoader({
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
fetch: async (_url: string, init: RequestInit = {}) => {
methods.push(init.method || "GET");
return init.method === "POST"
? Response.json({ output: { task_id: "accepted-task", task_status: "PENDING" } })
: Response.json({ code: "NotFound", message: "Task lookup failed" }, { status: 404 });
},
});
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
const error = await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: "fixture" }] }).catch((error: unknown) => error);
expect(methods).toEqual(["POST", "GET"]);
expect(String(error)).toContain("accepted-task");
expect(loader("lib/request-error.ts").getRequestFailureDetails(error)).toMatchObject({ code: "RESULT_UNCONFIRMED", outcome: "unknown" });
});

View File

@ -1,21 +1,38 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { serverLoader } from "../helpers/canvas-download-server-loader"; import { serverLoader } from "../helpers/canvas-download-server-loader";
import { applyAgentImagePreferences, agentProviderImageParameters } from "@/lib/ai/canvas-agent-runtime/image-preferences"; import { applyAgentImagePreferences, agentProviderImageParameters } from "@/lib/ai/canvas-agent-runtime/image-preferences";
import type { AgentCallRecord } from "@/lib/ai/canvas-agent-runtime/protocol";
const preferences = { const preferences = {
modelId: "user-image-model", modelId: "user-image-model",
parameters: { imageCount: 3, aspectRatio: "3:4", imageSize: "4K", negativePrompt: "不要文字", parameters: { imageCount: 3, aspectRatio: "3:4", imageSize: "4K", negativePrompt: "不要文字",
quality: "high" as const, size: "1536*2048", promptExtend: false, watermark: false, seed: 0 }, quality: "high" as const, size: "1536*2048", promptExtend: false, watermark: false, seed: 0 },
}; };
function harness(results?: any[]) { function harness(results?: any[] | ((count: number) => any[])) {
const saved: any[] = []; const saved: any[] = [];
const generated: any[] = []; const generated: any[] = [];
const operations: any[] = []; const operations: any[] = [];
const referenceUrl = "https://test.example/reference.png";
const original = Buffer.from("original reference bytes");
const mock = serverLoader({ const mock = serverLoader({
"@/lib/canvas/capabilities/ai-sdk-tools": { createCanvasCapabilityTools: async () => ({ editTargetImage: {} }) }, "@/lib/canvas/capabilities/ai-sdk-tools": { createCanvasCapabilityTools: async () => ({ editTargetImage: {} }) },
"@/lib/canvas/capabilities/registry": { CANVAS_CAPABILITY_REGISTRY: [{ id: "image.prompt_regenerate", title: "重绘", risk: "external", agent: { toolName: "editTargetImage" } }] }, "@/lib/canvas/capabilities/registry": { CANVAS_CAPABILITY_REGISTRY: [{ id: "image.prompt_regenerate", title: "重绘", risk: "external", agent: { toolName: "editTargetImage" } }] },
"@/lib/canvas/agent-mutations": { applyCanvasOperationPlan: async (value: any) => { operations.push(value); return { createdNodeIds: ["new-image"] }; } }, "@/lib/canvas/agent-mutations": {
"@/lib/canvas/generate-image": { generateCanvasImage: async (value: any) => { generated.push(value); return { modelRef: results ? "backup" : value.model, images: results || [{ originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" }] }; } }, applyCanvasOperationPlan: async (value: any) => {
operations.push(value);
return { createdNodeIds: value.plan.mediaItems.map((item: { mediaItemId: string }) => item.mediaItemId) };
},
},
"@/lib/canvas/generate-image": {
generateCanvasImage: async (value: any) => {
generated.push(value);
const images = typeof results === "function" ? results(value.imageCount) : results;
return {
modelRef: Array.isArray(results) ? "backup" : value.model,
images: images ?? [{ originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" }],
};
},
},
"@/lib/ai/runtime/generated-image-persistence": { saveGeneratedImages: async (...args: any[]) => { saved.push(args); } }, "@/lib/ai/runtime/generated-image-persistence": { saveGeneratedImages: async (...args: any[]) => { saved.push(args); } },
"@/lib/ai/model-router": { routeToModel: async () => ({ id: "system-default" }) }, "@/lib/ai/model-router": { routeToModel: async () => ({ id: "system-default" }) },
"@/lib/db/queries": { checkUserImageQuota: async () => ({ allowed: true, remaining: 10 }), getPointsSystemConfig: async () => ({ enabled: false }), getUserById: async () => ({}) }, "@/lib/db/queries": { checkUserImageQuota: async () => ({ allowed: true, remaining: 10 }), getPointsSystemConfig: async () => ({ enabled: false }), getUserById: async () => ({}) },
@ -23,17 +40,30 @@ function harness(results?: any[]) {
getStyleProImageModelEntries: async () => ["system-default", "user-image-model"].map(id => ({ model: { id, modelId: id, isDefault: id === "system-default", imageGenConfig: {} } })), getStyleProImageModelEntries: async () => ["system-default", "user-image-model"].map(id => ({ model: { id, modelId: id, isDefault: id === "system-default", imageGenConfig: {} } })),
sortStyleProImageModelEntries: (entries: any) => entries, sortStyleProImageModelEntries: (entries: any) => entries,
}, },
"./canvas": { readOwnedCanvas: async () => ({ nodes: [{ id: "reference", type: "image" }] }), readCanvasImages: async () => [{ id: "reference", data: { src: "https://test.example/reference.png" } }] }, "./canvas": { readOwnedCanvas: async () => ({ nodes: [{ id: "reference", type: "image" }] }), readCanvasImages: async () => [{ id: "reference", data: { src: referenceUrl } }] },
"./review": {}, "./review": { analyzeRuntimeImages: async ({ reviewPorts }: { reviewPorts: { readImage(url: string): Promise<Buffer> } }) => ({ bytes: (await reviewPorts.readImage(referenceUrl)).length }) },
"@/lib/ai/rmbg/service": { fetchImageBufferFromUrl: async () => original },
"./image-access-tools": { registerImageCanvasAccessTools() {} }, "./image-access-tools": { registerImageCanvasAccessTools() {} },
}); });
const build = async (imagePreferences: any = preferences) => mock("lib/ai/canvas-agent-runtime/tools.ts").buildCanvasRuntimeTools({ const build = async (imagePreferences: any = preferences, priorCalls?: AgentCallRecord[]) => mock("lib/ai/canvas-agent-runtime/tools.ts").buildCanvasRuntimeTools({
userId: "u", chatId: "c", projectId: "p", runId: "r", assistantMessageId: "a", selectedNodeIds: ["reference"], userId: "u", chatId: "c", projectId: "p", runId: "r", assistantMessageId: "a", selectedNodeIds: ["reference"],
request: new Request("https://test.example/api/canvas/agent-chat"), model: {}, supportsVision: true, request: new Request("https://test.example/api/canvas/agent-chat"), model: {}, supportsVision: true,
preferredModelId: "system-default", imagePreferences, emit() {}, preferredModelId: "system-default", imagePreferences, priorCalls, emit() {},
}); });
return { build, generated, operations, saved }; return { build, generated, operations, saved, referenceUrl, original };
} }
test("the generation tool can reuse original bytes read by inspection within the same task", async () => {
const h = harness();
const selection = { parameters: { imageCount: 1 } };
const tools = await h.build(selection);
await tools.executable.analyzeCanvasImages.execute({ nodeIds: ["reference"], question: "识别素材" }, "inspect");
await tools.executable.generateCanvasImages.execute({ prompt: "修改参考图", referenceNodeIds: ["reference"] }, "generate");
expect(h.generated[0].referenceImageBuffers.get(h.referenceUrl)).toBe(h.original);
const nextTask = await h.build(selection);
await nextTask.executable.generateCanvasImages.execute({ prompt: "修改参考图", referenceNodeIds: ["reference"] }, "next");
expect(h.generated[1].referenceImageBuffers.has(h.referenceUrl)).toBe(false);
});
test("explicit controls override conflicting planner settings without losing false or zero values", () => { test("explicit controls override conflicting planner settings without losing false or zero values", () => {
const resolved = applyAgentImagePreferences({ modelId: "planner-choice", imageCount: 1, imageSize: "1K", aspectRatio: "1:1", prompt: "产品图" }, preferences); const resolved = applyAgentImagePreferences({ modelId: "planner-choice", imageCount: 1, imageSize: "1K", aspectRatio: "1:1", prompt: "产品图" }, preferences);
expect(resolved).toMatchObject({ ...preferences.parameters, modelId: preferences.modelId, prompt: "产品图" }); expect(resolved).toMatchObject({ ...preferences.parameters, modelId: preferences.modelId, prompt: "产品图" });
@ -47,12 +77,35 @@ test("real generation tool honors the chosen image model and all parameters desp
providerImageParams: { quality: "high", size: "1536*2048", watermark: false, seed: 0 } }); providerImageParams: { quality: "high", size: "1536*2048", watermark: false, seed: 0 } });
expect(output.appliedImagePreferences).toEqual({ modelId: "user-image-model", imageCount: 3, imageSize: "4K", aspectRatio: "3:4" }); expect(output.appliedImagePreferences).toEqual({ modelId: "user-image-model", imageCount: 3, imageSize: "4K", aspectRatio: "3:4" });
}); });
test("redraw and a corrective retry inherit the same user model, batch and image controls", async () => { test("redraw and generation share the task limit and only fill the missing images", async () => {
const h = harness(); const tools = await h.build(); const image = { originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" };
await tools.executable.editTargetImage.execute({ nodeIds: ["reference"], args: { prompt: "换背景", modelId: "system-default" } }, "edit-1"); const h = harness(count => Array.from({ length: count === 3 ? 1 : count }, () => image));
await tools.executable.generateCanvasImages.execute({ prompt: "修正背景", modelId: "system-default" }, "retry-1"); const tools = await h.build();
const first = await tools.executable.editTargetImage.execute({ nodeIds: ["reference"], args: { prompt: "换背景", modelId: "system-default" } }, "edit-1");
expect(first.status).toBe("partial");
const filled = await tools.executable.generateCanvasImages.execute({ prompt: "补齐图片", modelId: "system-default" }, "fill-1");
expect(filled.status).toBe("succeeded");
await expect(tools.executable.generateCanvasImages.execute({ prompt: "再次修正" }, "retry-1")).rejects.toThrow("张数已占满");
expect(h.generated).toHaveLength(2);
expect(h.generated.map(call => call.imageCount)).toEqual([3, 2]);
for (const invocation of h.generated) expect(invocation).toMatchObject({ model: preferences.modelId, imageSize: "4K", aspectRatio: "3:4", allowFailover: false });
});
test("one selected image allows only one submission even when calls overlap or the task resumes", async () => {
const h = harness();
const selection = { parameters: { imageCount: 1 } };
const tools = await h.build(selection);
const generate = (callId: string) => tools.executable.generateCanvasImages.execute({ prompt: "修改图片" }, callId);
const [first, duplicate] = await Promise.allSettled([generate("original"), generate("parallel")]);
expect(first.status).toBe("fulfilled");
expect(duplicate.status).toBe("rejected");
if (first.status !== "fulfilled") throw first.reason;
const resumed = await h.build(selection, [{ id: "original", name: "generateCanvasImages", input: {}, status: "succeeded", output: first.value }]);
await expect(resumed.executable.generateCanvasImages.execute({ prompt: "继续" }, "resume")).rejects.toThrow("张数已占满");
expect(h.generated).toHaveLength(1);
const next = await h.build(selection);
await next.executable.generateCanvasImages.execute({ prompt: "用户新指令" }, "next");
expect(h.generated).toHaveLength(2); expect(h.generated).toHaveLength(2);
for (const invocation of h.generated) expect(invocation).toMatchObject({ model: preferences.modelId, imageSize: "4K", imageCount: 3, aspectRatio: "3:4", allowFailover: false });
}); });
test("an unavailable explicit choice never silently generates or charges through the default", async () => { test("an unavailable explicit choice never silently generates or charges through the default", async () => {
const h = harness(); const tools = await h.build({ ...preferences, modelId: "disabled-or-missing" }); const h = harness(); const tools = await h.build({ ...preferences, modelId: "disabled-or-missing" });

View File

@ -94,6 +94,17 @@ test("checks real image bytes and compares results to their source images", asyn
expect(result.issues).toEqual([]); expect(result.issues).toEqual([]);
}); });
test("a partial image batch can be filled and verified without discarding the first result", async () => {
const f = await fixture('{"passed":true,"issues":[],"failedNodeIds":[]}');
const { snapshot } = f.params;
snapshot.requirements = { expectedImageCount: 2 };
snapshot.calls[1].output = { status: "partial", createdNodeIds: ["result"], appliedImagePreferences: { imageCount: 2 } };
await expect(verifyCanvasAgentResults(f.params)).resolves.toMatchObject({ passed: false, retryable: true });
snapshot.resultNodeIds.push("remaining-result");
snapshot.calls.push({ id: "fill", name: "generateCanvasImages", input: {}, status: "succeeded", output: { status: "succeeded", createdNodeIds: ["remaining-result"] } });
await expect(verifyCanvasAgentResults(f.params)).resolves.toMatchObject({ passed: true });
});
test("malformed vision JSON switches models, while a valid failed review never triggers failover", async () => { test("malformed vision JSON switches models, while a valid failed review never triggers failover", async () => {
const bad = await fixture("not JSON"); const bad = await fixture("not JSON");
const validNegative = await fixture('{"passed":false,"issues":["主体不一致"],"failedNodeIds":["result"]}'); const validNegative = await fixture('{"passed":false,"issues":["主体不一致"],"failedNodeIds":["result"]}');

View File

@ -124,6 +124,7 @@ test("requested downloads are prioritized with two workers before scanning backg
}, },
"./image-byte-cache": { fetchImageBytes: async () => { active++; maximum = Math.max(maximum, active); await new Promise<void>(resolve => releases.push(resolve)); active--; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => {} }, "./image-byte-cache": { fetchImageBytes: async () => { active++; maximum = Math.max(maximum, active); await new Promise<void>(resolve => releases.push(resolve)); active--; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => {} },
"@/lib/canvas/photoshop-safe-download": { normalizeCanvasDownloadAsset: async () => ({ ok: true, bytes, contentType: "image/png" }) }, "@/lib/canvas/photoshop-safe-download": { normalizeCanvasDownloadAsset: async () => ({ ok: true, bytes, contentType: "image/png" }) },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined },
"./service": { uploadFile() { throw new Error("Compatible PNG must retain its object"); } }, "./service": { uploadFile() { throw new Error("Compatible PNG must retain its object"); } },
})("lib/storage/image-download-preparation.ts"); })("lib/storage/image-download-preparation.ts");
worker.scheduleImageDownloadPreparation("one"); worker.scheduleImageDownloadPreparation("two"); worker.scheduleImageDownloadPreparation("three"); worker.scheduleImageDownloadPreparation("one"); worker.scheduleImageDownloadPreparation("two"); worker.scheduleImageDownloadPreparation("three");

View File

@ -106,10 +106,16 @@ test("compatible PNG remains byte-identical; JPEG becomes RGB PNG without resizi
const module = serverLoader({})("lib/canvas/photoshop-safe-download.ts"); const module = serverLoader({})("lib/canvas/photoshop-safe-download.ts");
const bytes = await sharp({ create: { width: 32, height: 48, channels: 4, background: "#12345680" } }).png().toBuffer(); const bytes = await sharp({ create: { width: 32, height: 48, channels: 4, background: "#12345680" } }).png().toBuffer();
expect((await module.convertImageToPhotoshopPng(bytes)).equals(bytes)).toBe(true); expect((await module.convertImageToPhotoshopPng(bytes)).equals(bytes)).toBe(true);
const rgb16 = await sharp(bytes).toColourspace("rgb16").png().toBuffer();
expect((await module.convertImageToPhotoshopPng(rgb16)).equals(rgb16)).toBe(true);
const jpeg = await sharp(bytes).jpeg().toBuffer(); const jpeg = await sharp(bytes).jpeg().toBuffer();
const converted = await module.convertImageToPhotoshopPng(jpeg); const converted = await module.convertImageToPhotoshopPng(jpeg);
const metadata = await sharp(converted).metadata(); const metadata = await sharp(converted).metadata();
expect(metadata).toMatchObject({ format: "png", space: "srgb", width: 32, height: 48, depth: "uchar" }); expect(metadata).toMatchObject({ format: "png", space: "srgb", width: 32, height: 48, depth: "uchar" });
const webp = await sharp(bytes).withIccProfile("p3").webp().toBuffer();
const webpPng = await module.convertImageToPhotoshopPng(webp);
const webpPngMetadata = await sharp(webpPng).metadata();
expect(webpPngMetadata).toMatchObject({ format: "png", width: 32, height: 48, hasAlpha: true, hasProfile: true });
}); });
test("CDN delivery requires a ready compatible asset and a configured HTTPS storage origin", async () => { test("CDN delivery requires a ready compatible asset and a configured HTTPS storage origin", async () => {
let url = "https://cdn.example/uploads/test.png"; let url = "https://cdn.example/uploads/test.png";
@ -131,6 +137,7 @@ test("PNG preparation failure leaves the original archived and schedules a retry
const module = serverLoader({ const module = serverLoader({
"./image-archive-db": { claimImageDownloadTask: async () => ({ id: "task", targetUrl: "https://cdn.example/original.png", metadata: { downloadAsset: { attempts: 2 } } }), saveImageDownloadAsset: async (_id: string, asset: any) => { saved = asset; } }, "./image-archive-db": { claimImageDownloadTask: async () => ({ id: "task", targetUrl: "https://cdn.example/original.png", metadata: { downloadAsset: { attempts: 2 } } }), saveImageDownloadAsset: async (_id: string, asset: any) => { saved = asset; } },
"./image-byte-cache": { fetchImageBytes: async () => { throw new Error("storage offline"); } }, "./image-byte-cache": { fetchImageBytes: async () => { throw new Error("storage offline"); } },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined },
"./service": {}, "./service": {},
})("lib/storage/image-download-preparation.ts"); })("lib/storage/image-download-preparation.ts");
await module.prepareImageArchiveDownload("task"); await module.prepareImageArchiveDownload("task");

View File

@ -0,0 +1,75 @@
import { expect, test } from "@playwright/test";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { runInNewContext } from "node:vm";
import ts from "typescript";
const filename = resolve("app/api/canvas/text-detect/route.ts");
const requireModule = createRequire(filename);
const source = ts.transpileModule(readFileSync(filename, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, esModuleInterop: true },
}).outputText;
function createRoute(params: {
enableOcr: boolean;
recognize: () => unknown;
qwen: () => unknown;
fetchResult: () => Response;
}) {
const module = { exports: {} as { POST: (request: Request) => Promise<Response> } };
const mocks: Record<string, unknown> = {
"next/server": { NextResponse: Response },
"drizzle-orm": { and() {}, inArray() {}, eq() {} },
"@/app/(auth)/auth": { auth: async () => ({ user: { id: "signed-in" } }) },
"@/lib/ai/qwen3-vl": { callQwen3Vl: params.qwen },
"@/lib/ai/vip98k-runtime": {
getVip98kRuntimeProviders: async () => [{ id: "98k" }],
executeConfiguredVip98kTask: params.recognize,
},
"@/lib/db": {
db: { query: { aiModel: { findFirst: async () => params.enableOcr ? { providerId: "98k" } : undefined } } },
},
"@/lib/db/schema": { aiModel: {} },
"@/lib/observability/logger": { createLogger: () => ({ debug() {}, warn() {}, error() {} }) },
};
runInNewContext(source, {
exports: module.exports,
require: (id: string) => mocks[id] ?? requireModule(id),
fetch: params.fetchResult,
AbortSignal,
});
return module.exports.POST;
}
const request = () => new Request("http://test/api/canvas/text-detect", {
method: "POST",
body: JSON.stringify({ imageUrl: "https://assets.test/item.png" }),
});
test("configured OCR returns numbered product labels without sending a second model request", async () => {
const post = createRoute({
enableOcr: true,
recognize: () => ({ outputUrls: ["https://assets.test/result"] }),
fetchResult: () => new Response("\uFEFFSKU 731\r\n60 CM", { headers: { "Content-Type": "text/plain" } }),
qwen: () => { throw new Error("The working OCR tool must not call Qwen"); },
});
const response = await post(request());
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
success: true,
data: { model: "ocr", items: [{ id: "1", text: "SKU 731" }, { id: "2", text: "60 CM" }] },
});
});
test("a valid empty OCR response shows no editable text", async () => {
const post = createRoute({
enableOcr: false,
recognize: () => { throw new Error("Disabled OCR must not submit"); },
fetchResult: () => { throw new Error("No OCR file expected"); },
qwen: () => ({ model: "qwen", text: '{"items":[]}' }),
});
const response = await post(request());
expect(response.status).toBe(200);
expect((await response.json()).data.items).toEqual([]);
});

View File

@ -1,5 +1,8 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { resolveAlibabaExtendApiKey } from "@/lib/ai/extend/config"; import sharp from "sharp";
import { resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
import { composeOutpaint, getOutpaintGeometry } from "@/lib/ai/extend/fal-outpaint";
import { expandWithVip98kOutpaint, getVip98kOutpaintGeometry } from "@/lib/ai/extend/vip98k-outpaint";
test.describe("image extension provider configuration", () => { test.describe("image extension provider configuration", () => {
test("uses the most recently updated Alibaba image provider key", () => { test("uses the most recently updated Alibaba image provider key", () => {
@ -23,4 +26,100 @@ test.describe("image extension provider configuration", () => {
).toBe("legacy-key"); ).toBe("legacy-key");
expect(resolveAlibabaExtendApiKey([], "")).toBeNull(); expect(resolveAlibabaExtendApiKey([], "")).toBeNull();
}); });
test("uses an official fal provider key, not a key configured for a relay", () => {
expect(resolveFalExtendApiKey([
{ baseUrl: "https://relay.example.test", apiKey: "relay-key" },
{ baseUrl: "https://queue.fal.run", apiKey: "Key official-key" },
])).toBe("official-key");
});
});
test("outpainting restores every original pixel in place and keeps the generated border", async () => {
const geometry = getOutpaintGeometry(13, 9, 1.5, 1.5);
const { paddedWidth, paddedHeight, left, top } = geometry;
const pixel = Buffer.from([42, 93, 190, 255, 30, 80, 20, 75]);
const raw = Buffer.concat(Array.from({ length: 59 }, () => pixel)).subarray(0, 13 * 9 * 4);
const source = await sharp(raw, { raw: { width: 13, height: 9, channels: 4 } }).png().toBuffer();
const expanded = await sharp({ create: { width: paddedWidth, height: paddedHeight, channels: 3, background: "#d8bc9e" } }).png().toBuffer();
const composed = await composeOutpaint(source, expanded, geometry);
const area = await sharp(composed).extract({ left, top, width: 13, height: 9 }).raw().toBuffer();
expect(area.equals(raw)).toBe(true);
expect([...await sharp(composed).extract({ left: 0, top: 0, width: 1, height: 1 }).raw().toBuffer()]).toEqual([216, 188, 158, 255]);
await expect(composeOutpaint(source, source, geometry)).rejects.toThrow("尺寸不正确");
});
test("a 4K source keeps its target output size while the model works within its limit", () => {
const g = getOutpaintGeometry(4096, 4096, 1.5, 1.5);
expect([g.targetWidth, g.targetHeight, g.left, g.top]).toEqual([6144, 6144, 1024, 1024]);
expect([g.paddedWidth, g.paddedHeight]).toEqual([2048, 2048]);
expect(g.expandLeft + g.sourceWidth + g.expandRight).toBe(g.paddedWidth);
expect(g.expandTop + g.sourceHeight + g.expandBottom).toBe(g.paddedHeight);
expect(Math.abs(g.expandLeft - g.expandRight)).toBeLessThanOrEqual(1);
});
test("relay outpainting pads to a supported ratio without cutting the source", () => {
const g = getVip98kOutpaintGeometry(621, 515, 1.5, 1.5);
expect([g.targetWidth, g.targetHeight]).toEqual([932, 773]);
expect(g.aspectRatio).toBe("4:3");
expect(g.modelWidth).toBeGreaterThanOrEqual(g.targetWidth);
expect(g.modelHeight).toBeGreaterThanOrEqual(g.targetHeight);
expect(g.left + g.sourceWidth).toBeLessThanOrEqual(g.modelWidth);
const large = getVip98kOutpaintGeometry(4096, 4096, 1.5, 1.5);
expect([large.targetWidth, large.targetHeight]).toEqual([6144, 6144]);
expect(Math.max(large.modelWidth, large.modelHeight)).toBe(2048);
});
test("relay outpainting uses one image request and keeps the complete generated result", async () => {
const originalFetch = globalThis.fetch;
const source = await sharp({ create: { width: 40, height: 30, channels: 3, background: "red" } }).png().toBuffer();
const generated = await sharp({ create: { width: 80, height: 60, channels: 3, background: "blue" } }).png().toBuffer();
try {
for (const outputUrl of ["https://relay.test/api/external/assets/result", "https://cdn.test/result.png"]) {
let submitted = 0;
globalThis.fetch = async (input, init) => {
const url = String(input);
if (url === "https://source.test/input.png") return new Response(new Uint8Array(source));
if (url === "https://relay.test/v1/images/edits") {
submitted++;
const body = init?.body as FormData;
expect(body.get("model")).toBe("gpt-image-2.5-flare");
expect(body.get("n")).toBe("1");
expect(body.get("size")).toBe("4:3");
expect(body.get("response_format")).toBe("url");
expect(body.get("image[]")).toBeInstanceOf(Blob);
expect(new Headers(init?.headers).get("X-Relay-Route-Option")).toBe("selected-route");
return Response.json({ data: [{ url: outputUrl }] });
}
expect(url).toBe(outputUrl);
expect(new Headers(init?.headers).get("Authorization")).toBe(outputUrl.includes("relay.test") ? "Bearer test-key" : null);
return new Response(new Uint8Array(generated));
};
const result = await expandWithVip98kOutpaint({ imageUrl: "https://source.test/input.png", baseUrl: "https://relay.test/v1", apiKey: "test-key", routeOptionId: "selected-route", xScale: 1.5, yScale: 1.5 });
const metadata = await sharp(result).metadata();
expect([metadata.width, metadata.height]).toEqual([60, 45]);
const pixel = await sharp(result).extract({ left: 30, top: 22, width: 1, height: 1 }).removeAlpha().raw().toBuffer();
expect([...pixel]).toEqual([0, 0, 255]);
expect(submitted).toBe(1);
}
} finally {
globalThis.fetch = originalFetch;
}
});
test("relay outpainting does not resubmit a failed generation", async () => {
const originalFetch = globalThis.fetch;
const source = await sharp({ create: { width: 40, height: 30, channels: 3, background: "red" } }).png().toBuffer();
let submitted = 0;
try {
globalThis.fetch = async (input) => {
if (String(input) === "https://source.test/input.png") return new Response(new Uint8Array(source));
submitted++;
return Response.json({ error: { message: "service unavailable" } }, { status: 503 });
};
await expect(expandWithVip98kOutpaint({ imageUrl: "https://source.test/input.png", baseUrl: "https://relay.test", apiKey: "test-key", routeOptionId: "selected-route", xScale: 1.5, yScale: 1.5 })).rejects.toThrow("service unavailable");
expect(submitted).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
}); });

View File

@ -5,6 +5,7 @@ import path from "node:path";
import vm from "node:vm"; import vm from "node:vm";
import * as crypto from "node:crypto"; import * as crypto from "node:crypto";
import ts from "typescript"; import ts from "typescript";
import sharp from "sharp";
import { import {
collectImageArchiveSources, collectImageArchiveSources,
replaceArchivedImageUrls, replaceArchivedImageUrls,
@ -82,6 +83,55 @@ function serverLoader(mocks: Record<string, unknown>, env: Record<string, string
} }
const loggerMock = { createLogger: () => ({ info() {}, warn() {}, error() {} }) }; const loggerMock = { createLogger: () => ({ info() {}, warn() {}, error() {} }) };
test("Qiniu archives 98K originals without relaying full image bytes through the app", async () => {
const image = sharp({ create: { width: 4, height: 4, channels: 3, background: "blue" } });
const rgb = await image.clone().withIccProfile("srgb").png().toBuffer();
const rgb16 = await image.clone().toColourspace("rgb16").png().toBuffer();
const indexed = await image.clone().png({ palette: true }).toBuffer();
const config = { provider: "qiniu-kodo", region: "cn-east-1", bucket: "archive",
accessKeyId: "test-key", secretAccessKey: "test-secret", publicUrl: "https://bucket.example" };
const id = "test-task";
const key = `uploads/ai-generated-archive-${id}`;
const source = "https://api.vip98k.com/api/external/assets/test_asset";
const archiveUrl = `${config.publicUrl}/${key}`;
let stored = rgb;
const cloudCalls: string[] = [];
const reads: string[] = [];
const load = serverLoader({
"@/lib/storage/service": {
resolveS3UploadTarget: async () => ({ config }),
buildS3PublicUrl: (_: unknown, path: string) => `${config.publicUrl}/${path}`,
createS3Client: () => ({
send: async (cmd: { input: { Bucket: string; Key: string; Range: string } }) => {
expect(cmd.input).toEqual({ Bucket: config.bucket, Key: key, Range: "bytes=0-65535" });
reads.push(cmd.input.Range);
return { $metadata: { httpStatusCode: 206 }, ContentType: "image/png",
ContentLength: stored.length, ContentRange: `bytes 0-${stored.length - 1}/${stored.length}`,
Body: { transformToByteArray: async () => stored } };
},
destroy() {},
}),
},
fetch: async (url: string, options: RequestInit) => {
cloudCalls.push(url);
expect(options.method).toBe("POST");
const [, from, to] = new URL(url).pathname.split("/fetch/")[1].match(/^(.+)\/to\/(.+)$/)!;
expect(Buffer.from(from, "base64url").toString()).toBe(source);
expect(Buffer.from(to, "base64url").toString()).toBe(`${config.bucket}:${key}`);
return Response.json({ key, fsize: stored.length, mimeType: "image/png" });
},
})("lib/storage/qiniu-image-archive.ts");
const archived = await load.archiveImageThroughQiniu(source, id);
expect(archived.downloadAsset).toMatchObject({ status: "ready", url: archiveUrl, bytes: rgb.length });
stored = rgb16;
expect((await load.archiveImageThroughQiniu(source, id)).downloadAsset).toMatchObject({ status: "ready", url: archiveUrl, bytes: rgb16.length });
stored = indexed;
expect((await load.archiveImageThroughQiniu(source, id)).downloadAsset).toBeUndefined();
expect(await load.archiveImageThroughQiniu("https://another-provider.example/result.png", id)).toBeNull();
expect(cloudCalls).toHaveLength(3);
expect(reads).toHaveLength(3);
});
test("160 requests from the same user and model start without admission waiting", async () => { test("160 requests from the same user and model start without admission waiting", async () => {
const module = serverLoader({})("lib/canvas/image-concurrency.ts"); const module = serverLoader({})("lib/canvas/image-concurrency.ts");
let queued = 0; let queued = 0;
@ -192,7 +242,7 @@ test("inline outputs have short, stable preview identities and durable queue pay
test("background storage starts after delivery and coalesces repeated wakeups", async () => { test("background storage starts after delivery and coalesces repeated wakeups", async () => {
const callbacks: Array<() => Promise<void>> = []; const callbacks: Array<() => Promise<void>> = [];
let runs = 0; const preferences: Array<string | undefined> = [];
const module = serverLoader({ const module = serverLoader({
"next/server": { "next/server": {
after: (fn: () => Promise<void>) => { after: (fn: () => Promise<void>) => {
@ -200,10 +250,10 @@ test("background storage starts after delivery and coalesces repeated wakeups",
}, },
}, },
"@/lib/jobs/image-archive-job": { "@/lib/jobs/image-archive-job": {
runImageArchiveJob: async () => { runImageArchiveJob: async ({ preferredTaskId }: { preferredTaskId?: string }) => {
runs += 1; preferences.push(preferredTaskId);
return { return {
store: { total: runs === 1 ? 1 : 0 }, store: { total: preferences.length === 1 ? 1 : 0 },
replace: { total: 0 }, replace: { total: 0 },
stats: { pendingStore: 0, pendingReplace: 0 }, stats: { pendingStore: 0, pendingReplace: 0 },
downloadTasks: 0, downloadTasks: 0,
@ -212,11 +262,11 @@ test("background storage starts after delivery and coalesces repeated wakeups",
}, },
})("lib/storage/image-archive-worker.ts"); })("lib/storage/image-archive-worker.ts");
module.scheduleImageArchiveWorker(); module.scheduleImageArchiveWorker();
module.scheduleImageArchiveWorker(); module.scheduleImageArchiveWorker(false, "requested-image");
expect(callbacks).toHaveLength(1); expect(callbacks).toHaveLength(1);
expect(runs).toBe(0); expect(preferences).toEqual([]);
await callbacks[0](); await callbacks[0]();
expect(runs).toBe(2); expect(preferences).toEqual(["requested-image", undefined]);
}); });
test("bucket failure retains the provider URL and retry task", async () => { test("bucket failure retains the provider URL and retry task", async () => {

View File

@ -7,6 +7,177 @@ import { serverLoader } from "../helpers/canvas-download-server-loader";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
test.describe("Qiniu image delivery", () => { test.describe("Qiniu image delivery", () => {
test("coalesces nearby configured CDN preview sizes and quality while preserving original URLs", () => {
const source = "https://cdn.example/uploads/sample.png";
const { buildImageProxyUrl: build } = serverLoader({}, {
NEXT_PUBLIC_CANVAS_CDN_ORIGIN: "https://cdn.example",
})("lib/image-delivery.ts");
expect(build(source, { width: 329, quality: 86, format: "webp" })).toBe(
`${source}?imageView2/2/w/360/format/webp/q/88`,
);
expect(build(source, { width: 331, quality: 92, format: "webp" })).toBe(
build(source, { width: 360, quality: 88, format: "webp" }),
);
expect(build(source, { width: 640, quality: 76, format: "webp" })).toContain("w/640/format/webp/q/76");
expect(build(source, { width: 960, quality: 92, format: "webp" })).toContain("w/960/format/webp/q/88");
expect(build(`${source}?token=signed`, { width: 331, format: "webp" })).toBe(`${source}?token=signed`);
expect(build(source)).toBe(source);
expect(new URL(build(source, { delivery: "original" }), "https://app.example").searchParams.get("url")).toBe(source);
expect(build("https://yybb.top/uploads/sample.png", { width: 331, quality: 92, format: "webp" })).toContain("w/331/format/webp/q/92");
});
for (const mode of ["cdn", "storage", "signed"] as const) {
test(`98K reference staging reads complete image bytes via ${mode}`, async () => {
const original = await sharp({ create: { width: 64, height: 64, channels: 3, background: "#a45623" } }).png().toBuffer();
const cdn = "https://cdn.example";
const source = `${cdn}/media/uploads/reference.png${mode === "signed" ? "?token=signed" : ""}`;
const calls: string[] = [];
const command = (operation: string) => class {
operation = operation;
constructor(public input: { Key: string; Body?: Buffer }) {}
};
const config = { provider: "qiniu-kodo", bucket: "images", publicUrl: `${cdn}/media` };
const load = serverLoader({
"./vip98k-chat": {},
"@/lib/db/queries": { getDefaultStorageConfig: async () => config, getSystemSetting: async () => null },
"@/lib/storage/image-archive": {},
"@aws-sdk/client-s3": {
GetObjectCommand: command("get"), HeadObjectCommand: command("head"), PutObjectCommand: command("put"),
},
"@/lib/storage/service": {
toDbS3UploadConfig: () => config,
buildS3PublicUrl: (_: unknown, key: string) => `${config.publicUrl}/${key}`,
createS3Client: () => ({ send: async ({ operation, input }: { operation: string; input: { Key: string; Body: Buffer } }) => {
calls.push(operation);
if (operation === "head") return {};
if (operation === "get") {
expect(input.Key).toBe("uploads/reference.png");
return { Body: { transformToByteArray: async () => original }, ContentLength: original.length };
}
expect((await sharp(input.Body).metadata()).format).toBe("jpeg");
return {};
} }),
},
"@/lib/observability/logger": { createLogger: () => ({ debug() {}, info() {}, warn() {}, error() {} }) },
fetch: async (url: string, init: RequestInit) => {
if (url === source) {
calls.push("cdn");
expect(init.redirect).toBe("error");
return mode === "cdn"
? new Response(original, { headers: { "content-type": "image/png", "content-length": String(original.length) } })
: new Response(original.subarray(0, 4), { headers: { "content-type": "image/png", "content-length": String(original.length) } });
}
calls.push("submit");
expect(String(init.body)).toContain("vip98k-cache-v2");
return Response.json({ taskId: "test-task", task_id: "test-task" });
},
}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: cdn });
const { submitVip98kExternalTask } = load("lib/ai/providers/vip98k.ts");
await submitVip98kExternalTask({ apiKey: "test", modelId: "google/nano-banana-2", kind: "image", imageUrls: [source], durable: true });
expect(calls).toEqual(["head", ...(mode !== "signed" ? ["cdn"] : []), ...(mode !== "cdn" ? ["get"] : []), "put", "submit"]);
});
}
for (const [source, reusesSource] of [
["https://cdn.example/media/uploads/source.png", true],
["https://cdn.example/media/uploads/direct/1a868070-0d5b-4c99-937d-f10d6dc59612/source.png", true],
["https://cdn.example/media/uploads/source.png?token=temporary", false],
["https://cdn.example/media-other/uploads/source.png", false],
] as const) {
test(`watermark removal handles ${source} according to its storage origin and signature`, async () => {
const storedResult = "https://cdn.example/media/uploads/result.png";
const uploadedInput = "https://cdn.example/media/uploads/staged.png";
const transferred: string[] = [];
const uploaded: string[] = [];
const saved: string[] = [];
const route = serverLoader({
"next/server": { NextResponse: Response },
"@/app/(auth)/auth": { auth: async () => ({ user: { id: "user-1" } }) },
"@/lib/db": { db: {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ value: { apiKey: "fixture" } }] }) }) }),
insert: () => ({ values: (row: { originalUrl: string }) => ({
returning: async () => { saved.push(row.originalUrl); return [{ id: "image-1" }]; },
}) }),
} },
"@/lib/db/schema": { systemSettings: {}, generatedImage: {} },
"drizzle-orm": { eq: () => null },
"@/lib/db/queries": {
getDefaultStorageConfig: async () => ({ type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example/media" }),
getPointsSystemConfig: async () => ({}),
getMediaAssetsByUserAndUrls: async () => [],
upsertMediaAsset: async () => null,
},
"@/lib/storage/service": {
uploadFile: async (filename: string) => { uploaded.push(filename); return { url: uploadedInput }; },
},
"@/lib/points/feature-charge": { prepareFeaturePointsCharge: async () => ({ ok: true, chargeable: false }) },
"@/lib/points/config": { getImageToolCreditCost: () => 0 },
"@/lib/canvas/generate-image": {},
fetch: async (url: string, init?: RequestInit) => {
if (new URL(url).hostname === "techsz.aoscdn.com") {
if (init?.method === "POST") {
expect((init.body as FormData).get("url")).toBe(reusesSource ? source : uploadedInput);
return Response.json({ data: { task_id: "fixture-task" } });
}
return Response.json({ data: { state: 1, file: storedResult } });
}
transferred.push(url);
return new Response(new Uint8Array([1, 2, 3]));
},
})("app/api/watermark-remove/route.ts");
const response = await route.POST(new Request("https://app.example/api/watermark-remove", {
method: "POST",
body: JSON.stringify({ image_url: source }),
}));
expect(await response.json()).toMatchObject({ success: true, original_url: storedResult });
expect(transferred).toEqual(reusesSource ? [] : [source]);
expect(uploaded).toHaveLength(reusesSource ? 0 : 1);
expect(saved).toEqual([storedResult]);
});
}
for (const formWorks of [true, false]) {
test(`local edit storage ${formWorks ? "uses Qiniu form upload" : "reuses the same key on S3 fallback"}`, async () => {
const bytes = new Uint8Array([1, 2, 3]).buffer;
const config = {
id: "fixture", type: "qiniu-kodo", isEnabled: true, region: "cn-east-1",
endpoint: "https://s3.example", publicUrl: "https://cdn.example",
bucket: "images", accessKeyId: "fixture-ak", secretAccessKey: "fixture-sk",
};
let formKey = "";
const s3Keys: string[] = [];
const storage = serverLoader({
"@vercel/blob": {},
"@aws-sdk/client-s3": {
PutObjectCommand: class { constructor(public input: { Key: string }) {} },
S3Client: class {
async send(command: { input: { Key: string } }) { s3Keys.push(command.input.Key); }
},
},
"@/lib/db/queries": {
getDefaultStorageConfig: async () => config,
getSystemSetting: async () => ({ value: { enabled: false } }),
},
"@/lib/storage/migration-db": {},
"@/lib/observability/logger": { createLogger: () => ({ debug() {}, warn() {}, info() {}, error() {} }) },
fetch: async (url: string, init: RequestInit) => {
expect(url).toBe("https://upload.qiniup.com/");
const form = init.body as FormData;
formKey = String(form.get("key"));
const token = String(form.get("token"));
const policy = JSON.parse(Buffer.from(token.split(":")[2], "base64url").toString());
expect(policy).toMatchObject({ scope: `images:${formKey}`, insertOnly: 1 });
expect((form.get("file") as File).size).toBe(3);
return formWorks ? Response.json({ key: formKey }) : new Response(null, { status: 503 });
},
})("lib/storage/service.ts");
const saved = await storage.uploadFile("local-edit.png", bytes, { preferQiniuForm: true });
expect(saved).toMatchObject({ pathname: formKey, url: "https://cdn.example/" + formKey });
expect(s3Keys).toEqual(formWorks ? [] : [formKey]);
});
}
test("model result buckets use our thumbnail endpoint without forbidden OSS transforms", () => { test("model result buckets use our thumbnail endpoint without forbidden OSS transforms", () => {
const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/output.png"; const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/output.png";
const preview = new URL(buildImageProxyUrl(source, { width: 960, format: "webp" }), "https://app.example"); const preview = new URL(buildImageProxyUrl(source, { width: 960, format: "webp" }), "https://app.example");

View File

@ -30,6 +30,12 @@ const atlasHunyuan3D = {
tags: ["TEXT-TO-3D", "NEW"], tags: ["TEXT-TO-3D", "NEW"],
}; };
test("syncing the provider catalog keeps OCR out of image generation", () => {
const [ocr] = buildVip98kModelPresets([{ id: "ocr", type: "image", capabilities: ["media.tasks"] }]);
expect(ocr).toMatchObject({ modelId: "ocr", isHidden: true, supportsImageGen: false, capabilities: { outputType: "text" } });
expect(isImageGenerationModelDescriptor({ ...ocr, providerType: "vip98k" })).toBe(false);
});
test.describe("AI model media classification", () => { test.describe("AI model media classification", () => {
test("classifies Qwen Image 3.0 as Alibaba text-to-image and image editing", () => { test("classifies Qwen Image 3.0 as Alibaba text-to-image and image editing", () => {
expect(isAlibabaImageTextToImageModelId("qwen-image-3.0")).toBe(true); expect(isAlibabaImageTextToImageModelId("qwen-image-3.0")).toBe(true);

View File

@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { serverLoader } from "../helpers/canvas-download-server-loader"; import { serverLoader } from "../helpers/canvas-download-server-loader";
import type { CanvasImageDownloadProgress } from "@/lib/client/canvas-image-download";
const config = { type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example" }; const config = { type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example" };
@ -10,7 +11,8 @@ test("original downloads use the archived JPEG rather than the prepared PNG and
"./image-archive-db": { findImageArchiveDownloadTask: async () => ({ targetUrl: "https://cdn.example/original.jpg", metadata: { "./image-archive-db": { findImageArchiveDownloadTask: async () => ({ targetUrl: "https://cdn.example/original.jpg", metadata: {
downloadAsset: { status: "ready", url: "https://cdn.example/converted.png", contentType: "image/png" }, downloadAsset: { status: "ready", url: "https://cdn.example/converted.png", contentType: "image/png" },
} }) }, } }) },
fetch: async (url: URL, options: { method: string }) => { fetch: async (url: URL, options: { method: string; cache: string }) => {
expect(options.cache).toBe("default");
calls.push({ url: url.toString(), method: options.method }); calls.push({ url: url.toString(), method: options.method });
return { ok: true, headers: new Headers({ "content-type": "image/jpeg", "content-length": "12345" }), return { ok: true, headers: new Headers({ "content-type": "image/jpeg", "content-length": "12345" }),
arrayBuffer() { throw new Error("Image bytes must bypass the application"); } }; arrayBuffer() { throw new Error("Image bytes must bypass the application"); } };
@ -52,6 +54,40 @@ test("temporary third-party URLs are never checked with HEAD when no archive is
expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toBeNull(); expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toBeNull();
}); });
test("downloading a pending original prioritizes its existing archive task and keeps the proxy available", async () => {
const preferred: string[] = [];
const module = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
"./image-archive-db": { findImageArchiveDownloadTask: async () => ({ id: "pending-image", status: "pending", targetUrl: null }) },
"./image-archive-worker": { scheduleImageArchiveWorker: (_delivery: boolean, id: string) => preferred.push(id) },
fetch() { throw new Error("The original must remain available through the proxy while it is queued"); },
})("lib/storage/original-image-download.ts");
expect(await module.resolveOriginalCdnDownload("https://upstream.example/queued.png", "image", new AbortController().signal)).toBeNull();
expect(preferred).toEqual(["pending-image"]);
});
test("a CDN metadata timeout falls back to the original Kodo object without reading its body", async () => {
let closed = false;
const module = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
"./image-archive-db": {},
"./service": {
toDbS3UploadConfig: () => ({ bucket: "images" }),
createS3Client: () => ({
send: async (command: { input: { Bucket: string; Key: string } }) => {
expect(command.input).toEqual({ Bucket: "images", Key: "uploads/source.png" });
return { ContentType: "image/jpeg", ContentLength: 12345 };
},
destroy: () => { closed = true; },
}),
},
fetch: async () => { throw new DOMException("Timed out", "TimeoutError"); },
})("lib/storage/original-image-download.ts");
const result = await module.resolveOriginalCdnDownload("https://cdn.example/uploads/source.png", "图片.png", new AbortController().signal);
expect(result).toMatchObject({ delivery: "cdn", filename: "图片.jpg", bytes: 12345 });
expect(closed).toBe(true);
});
test("original download metadata reports missing sources with cause, advice and a feedback ID", async () => { test("original download metadata reports missing sources with cause, advice and a feedback ID", async () => {
const route = serverLoader({ const route = serverLoader({
"next/server": { NextResponse: Response }, "next/server": { NextResponse: Response },
@ -66,6 +102,30 @@ test("original download metadata reports missing sources with cause, advice and
expect(data.error).toContain(data.requestId); expect(data.error).toContain(data.requestId);
}); });
test("the original proxy returns the first bytes while the upstream is still sending the file", async () => {
const head = Uint8Array.from([255, 216, 255, 224]);
const tail = Uint8Array.from([4, 8, 255, 217]);
let upstream!: ReadableStreamDefaultController<Uint8Array>;
const body = new ReadableStream<Uint8Array>({
start(controller) { upstream = controller; controller.enqueue(head); },
});
const route = serverLoader({
"next/server": { NextResponse: Response },
fetch: async () => new Response(body, { headers: { "content-type": "image/jpeg", "content-length": "8" } }),
})("app/api/image-proxy/route.ts");
const response = await route.GET({
nextUrl: new URL("https://app.example/api/image-proxy?url=https%3A%2F%2Fimages.example%2Foriginal.jpg&original=1"),
signal: new AbortController().signal, headers: new Headers(),
});
expect(response.headers.get("content-length")).toBe("8");
const reader = response.body.getReader();
expect((await reader.read()).value).toEqual(head);
upstream.enqueue(tail);
upstream.close();
expect((await reader.read()).value).toEqual(tail);
expect((await reader.read()).done).toBe(true);
});
function browserMocks() { function browserMocks() {
const frames: any[] = []; const links: any[] = []; const frames: any[] = []; const links: any[] = [];
return { frames, links, return { frames, links,
@ -77,16 +137,18 @@ function browserMocks() {
test("chat original downloads use a native CDN download without fetching the image through CORS", async () => { test("chat original downloads use a native CDN download without fetching the image through CORS", async () => {
const dom = browserMocks(); const calls: string[] = []; const dom = browserMocks(); const calls: string[] = [];
const progress: CanvasImageDownloadProgress[] = [];
const client = serverLoader({ ...dom, fetch: async (url: string) => { const client = serverLoader({ ...dom, fetch: async (url: string) => {
calls.push(url); calls.push(url);
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/image.jpg?attname=original.jpg", filename: "original.jpg", bytes: 5000000 }); return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/image.jpg?attname=original.jpg", filename: "original.jpg", bytes: 5000000 });
} })("lib/client/canvas-image-download.ts"); } })("lib/client/canvas-image-download.ts");
const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg" }); const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event) });
expect(result).toEqual({ filename: "original.jpg", bytes: 5000000 }); expect(result).toEqual({ filename: "original.jpg", bytes: 5000000 });
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
expect(new URL(calls[0], "https://app.example").searchParams.get("download")).toBe("resolve"); expect(new URL(calls[0], "https://app.example").searchParams.get("download")).toBe("resolve");
expect(dom.frames[0].src).toBe("https://cdn.example/image.jpg?attname=original.jpg"); expect(dom.frames[0].src).toBe("https://cdn.example/image.jpg?attname=original.jpg");
expect(dom.links).toHaveLength(0); expect(dom.links).toHaveLength(0);
expect(progress.map(event => event.phase)).toEqual(["preparing", "downloading"]);
}); });
test("original fallback preserves JPEG bytes and the MIME-derived filename", async () => { test("original fallback preserves JPEG bytes and the MIME-derived filename", async () => {
@ -99,6 +161,62 @@ test("original fallback preserves JPEG bytes and the MIME-derived filename", asy
URL.revokeObjectURL(dom.links[0].href); URL.revokeObjectURL(dom.links[0].href);
}); });
test("one click prepares a compatible PNG when the CDN original is WebP", async () => {
const dom = browserMocks();
const calls: string[] = [];
const progress: CanvasImageDownloadProgress[] = [];
const url = "https://cdn.example/image.png";
const client = serverLoader({ ...dom, fetch: async (path: string, init?: RequestInit) => {
calls.push(path);
if (calls.length === 1) {
return Response.json({ delivery: "cdn", downloadUrl: url, filename: "商品.webp", bytes: 500 });
}
expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toMatchObject({ url, name: "商品", preparation: "async" });
if (calls.length === 2) {
return Response.json({ delivery: "preparing", retryAfter: 1, message: "正在准备原图" }, { status: 202 });
}
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/prepared.png", filename: "商品.png", bytes: 900 });
} })("lib/client/canvas-image-download.ts");
expect(await client.downloadOriginalImage({ url, name: "商品", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event) }))
.toEqual({ filename: "商品.png", bytes: 900 });
expect(calls.slice(1)).toEqual(["/api/canvas/download-image", "/api/canvas/download-image"]);
expect(progress.map(event => event.phase)).toEqual(["preparing", "converting", "converting", "downloading"]);
for (const event of progress.filter(event => event.phase === "converting")) {
expect(event.message).toContain("正在转换为兼容 PNG");
expect(event.percent).toBeUndefined();
}
expect(dom.frames.map(frame => frame.src)).toEqual(["https://cdn.example/prepared.png"]);
expect(dom.links).toHaveLength(0);
});
test("one click uses the PNG preparation route without buffering an incompatible proxy response", async () => {
const dom = browserMocks();
const calls: string[] = [];
const progress: CanvasImageDownloadProgress[] = [];
let cancelled = false;
const bytes = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
const client = serverLoader({ ...dom, fetch: async (path: string) => {
calls.push(path);
if (calls.length === 1) return Response.json({ delivery: "proxy" });
if (calls.length === 2) {
return new Response(new ReadableStream({ cancel() { cancelled = true; } }),
{ headers: { "content-type": "image/avif" } });
}
return new Response(bytes, { headers: { "content-type": "image/png", "content-length": String(bytes.length) } });
} })("lib/client/canvas-image-download.ts");
expect(await client.downloadOriginalImage({
url: "https://external.example/image", name: "商品", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event),
})).toEqual({ filename: "商品.png", bytes: bytes.length });
expect(cancelled).toBe(true);
expect(calls[2]).toBe("/api/canvas/download-image");
expect(progress).toContainEqual({
phase: "downloading", message: "正在下载 PNG...", loadedBytes: bytes.length, totalBytes: bytes.length, percent: 99,
});
expect(dom.links).toHaveLength(1);
URL.revokeObjectURL(dom.links[0].href);
});
test("original downloads reject processed references before network access", async () => { test("original downloads reject processed references before network access", async () => {
const client = serverLoader({ ...browserMocks(), fetch() { throw new Error("Must not fetch a processed image as an original"); } })("lib/client/canvas-image-download.ts"); const client = serverLoader({ ...browserMocks(), fetch() { throw new Error("Must not fetch a processed image as an original"); } })("lib/client/canvas-image-download.ts");
await expect(client.downloadOriginalImage({ url: "https://cdn.example/image.jpg?imageView2/2/w/320&token=signed" })).rejects.toThrow("无法确认原图"); await expect(client.downloadOriginalImage({ url: "https://cdn.example/image.jpg?imageView2/2/w/320&token=signed" })).rejects.toThrow("无法确认原图");

View File

@ -89,6 +89,26 @@ test("does not inject output count into a task contract without n", () => {
expect(body).not.toHaveProperty("n"); expect(body).not.toHaveProperty("n");
}); });
test("OCR submits a text request even with a stale synced example", () => {
const invocation: Vip98kTaskInvocation = {
kind: "task", method: "POST", path: "/extensions/v1/tasks",
contentType: "application/json", resultMethod: "GET",
resultPath: "/extensions/v1/tasks/{task_id}/result",
requestExample: {
model: "ocr", size: "auto", language: "", file_urls: [""],
output_format: "txt", metadata: { language: "English,ChinesePRC,Digits" },
},
};
const { body } = buildTaskBody({
apiKey: "test", modelId: "ocr", kind: "image-tool", prompt: "",
imageUrls: ["https://assets.test/item.png"], videoUrls: [], modelConfig: {},
}, invocation);
expect(body).toEqual({
model: "ocr", file_urls: ["https://assets.test/item.png"],
output_format: "txt", metadata: { language: "English,ChinesePRC,Digits" },
});
});
test("scene completion counts the current run and preserves partial results", () => { test("scene completion counts the current run and preserves partial results", () => {
const user = { id: "run", role: "user", parts: [] }; const user = { id: "run", role: "user", parts: [] };
const result = (count: number) => ({ id: "result", role: "assistant", parts: [{ type: "text", text: const result = (count: number) => ({ id: "result", role: "assistant", parts: [{ type: "text", text:

View File

@ -25,11 +25,31 @@ function fixture() {
consumeInputs: async () => ({ cancelled: false }), isCancelled: async () => false, consumeInputs: async () => ({ cancelled: false }), isCancelled: async () => false,
verify: async () => ({ passed: true, issues: [] }), verify: async () => ({ passed: true, issues: [] }),
}; };
return { ports, run, snapshot }; return { ports, run, snapshot, load };
} }
const result = { type: "result", sessionId: "test-session", response: "已完成" }; const result = { type: "result", sessionId: "test-session", response: "已完成" };
test("a failed quality check keeps the single generated image without opening a repair round", async () => {
const { ports, run, snapshot } = fixture();
snapshot.imagePreferences = { parameters: { imageCount: 1 } };
let attempts = 0;
ports.tools.generateCanvasImages.execute = async () => ({
createdNodeIds: ["generated-image"], appliedImagePreferences: { imageCount: 1 },
});
ports.native = async options => {
attempts++;
await options.execute("planCanvasTask", {}, "plan", options.signal!);
await options.execute("generateCanvasImages", { referenceNodeIds: ["original"] }, "gen", options.signal!);
return { ...result, response: "图片已生成,正在检查" };
};
ports.verify = async () => ({ passed: false, issues: ["主体细节不符"], retryable: true, failedNodeIds: ["generated-image"] });
expect(await run(ports)).toBe("needs_review");
expect(attempts).toBe(1);
expect(snapshot.resultNodeIds).toEqual(["generated-image"]);
expect(snapshot.message).toMatch(/主体细节不符[\s\S]*如需再生成,请发送新指令/);
});
test("an unconfirmed paid tool stops the planner and immediately preserves the reason and original request ID", async () => { test("an unconfirmed paid tool stops the planner and immediately preserves the reason and original request ID", async () => {
const { ports, run, snapshot } = fixture(); const { ports, run, snapshot } = fixture();
const emitted: string[] = [], saved: string[] = []; const emitted: string[] = [], saved: string[] = [];
@ -61,6 +81,26 @@ test("an unconfirmed paid tool stops the planner and immediately preserves the r
expect(submissions).toBe(1); expect(submissions).toBe(1);
}); });
test("a pre-submission reference timeout offers a safe retry without an automatic resubmission", async () => {
const { ports, run, snapshot, load } = fixture();
const { ImageReferenceReadTimeoutError } = load("lib/ai/runtime/image-generation-error.ts");
let attempts = 0;
ports.tools.generateCanvasImages.execute = async () => {
attempts++;
throw new ImageReferenceReadTimeoutError();
};
ports.native = async options => {
await options.execute("planCanvasTask", {}, "plan", options.signal!);
await options.execute("generateCanvasImages", { referenceNodeIds: ["original"] }, "timeout", options.signal!);
return result;
};
expect(await run(ports)).toBe("needs_review");
expect(attempts).toBe(1);
expect(snapshot.calls.find(call => call.id === "timeout")).toMatchObject({ confirmedNoResult: true, uncertain: false });
expect(snapshot.message).toContain("任务尚未提交,可以稍后重试");
expect(snapshot.message).toContain(ports.runId);
});
test("repairing a failed result permits original images as references without admitting another passed target", async () => { test("repairing a failed result permits original images as references without admitting another passed target", async () => {
const { ports, run, snapshot } = fixture(); const { ports, run, snapshot } = fixture();
let submissions = 0; let submissions = 0;