From 2c30c9d7f9cbb72bef84d208962826d6603356df Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 10 Nov 2026 17:02:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E9=BD=90=E8=AF=A6=E6=83=85=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E6=A8=A1=E5=9E=8B=E5=B9=B6=E6=94=AF=E6=8C=81=E6=95=B4?= =?UTF-8?q?=E5=A5=97=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/chat/_lib/detail-replacement.ts | 19 +---- app/(chat)/api/models/route.ts | 14 +++- .../detail-replacement/batches/route.ts | 16 +++- .../agents/AgentGenerationWorkspaceDarkV2.tsx | 71 ++++++++++++----- lib/agents/mode-workspace-state.ts | 2 + .../detail-replacement-image-model-config.ts | 27 +++++++ tests/routes/agent-project-recovery.test.ts | 79 +++++++++++++++++++ 7 files changed, 191 insertions(+), 37 deletions(-) diff --git a/app/(chat)/api/chat/_lib/detail-replacement.ts b/app/(chat)/api/chat/_lib/detail-replacement.ts index defadd30d..1abf5011e 100644 --- a/app/(chat)/api/chat/_lib/detail-replacement.ts +++ b/app/(chat)/api/chat/_lib/detail-replacement.ts @@ -4,7 +4,8 @@ import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY, - normalizeDetailReplacementImageModelConfig, + findConfiguredDetailReplacementImageModel, + isDetailReplacementImageModel, } from "@/lib/ai/detail-replacement-image-model-config"; import { compositeReplacementWithReferenceLocks, @@ -29,7 +30,6 @@ import { } from "@/lib/db/queries"; import { uploadFile } from "@/lib/storage/service"; import { bufferToArrayBuffer } from "./persistence"; -import { isVideoProviderType } from "./video-models"; import type { AgentPresetMessageImagePart } from "./agent-scene-workflow"; import type { PostRequestBody } from "../schema"; @@ -2161,22 +2161,10 @@ export async function resolveDirectReplacementModelRef(params: { models?: Awaited>; }) { 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({ key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY, }).catch(() => null); - const configuredModelId = normalizeDetailReplacementImageModelConfig( - configuredModelSetting?.value, - ).modelId; - const configuredModel = imageModels.find( - ({ model }) => - model.id === configuredModelId || model.modelId === configuredModelId, - ); + const configuredModel = findConfiguredDetailReplacementImageModel(models, configuredModelSetting?.value); if (configuredModel) { return configuredModel.model.id; } @@ -2190,6 +2178,7 @@ export async function resolveDirectReplacementModelRef(params: { return routedModel; } + const imageModels = models.filter(isDetailReplacementImageModel); const preferred = imageModels.find(({ model }) => model.isDefault) || imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) || diff --git a/app/(chat)/api/models/route.ts b/app/(chat)/api/models/route.ts index 131016b6e..17c6a180d 100644 --- a/app/(chat)/api/models/route.ts +++ b/app/(chat)/api/models/route.ts @@ -10,6 +10,10 @@ import { } from "@/lib/db/queries"; import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability"; 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 { isImageGenerationModelDescriptor, isVideoModelDescriptor, @@ -157,10 +161,17 @@ export async function GET(request: Request) { : await getEnabledAiModelsWithProvider(false, true); const agentModelWithProvider = await getAgentAiModelWithProvider(); const defaultModel = await getDefaultAiModel(); - const [siteSettings, agentWorkflowUiConfigSetting] = await Promise.all([ + const [siteSettings, agentWorkflowUiConfigSetting, detailReplacementModelSetting] = await Promise.all([ getSiteSettings(), 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]; const serializeModel = ({ model, provider }: ModelWithProvider) => ({ id: model.id, @@ -309,6 +320,7 @@ export async function GET(request: Request) { : null, agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null, agentWorkflowConfiguredModels, + detailReplacementModel: detailReplacementModel ? serializeModel(detailReplacementModel) : null, agentModel: agentModelWithProvider ? { id: agentModelWithProvider.model.id, diff --git a/app/api/agents/detail-replacement/batches/route.ts b/app/api/agents/detail-replacement/batches/route.ts index cce8997aa..a9e636d9c 100644 --- a/app/api/agents/detail-replacement/batches/route.ts +++ b/app/api/agents/detail-replacement/batches/route.ts @@ -4,6 +4,7 @@ import { auth } from "@/app/(auth)/auth"; import { db } from "@/lib/db"; import { canvasImageTask, canvasProject, message } from "@/lib/db/schema"; import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat"; +import { MAX_DETAIL_REPLACEMENT_REFERENCES } from "@/lib/agents/mode-workspace-state"; import { createCanvasImageTask, enqueueCanvasImageTask, @@ -146,6 +147,14 @@ export async function POST(request: Request) { .map((item) => [String(item.id), String(item.imageUrl)]), ); 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 item = raw && typeof raw === "object" ? raw as Record : {}; return { @@ -160,7 +169,6 @@ export async function POST(request: Request) { }; }); if ( - references.length === 0 || references.length > 40 || new Set(references.map((item) => item.id)).size !== references.length || 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)) { 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); if (!quota.allowed) { return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 }); diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index b51f4f924..491c8216a 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -159,6 +159,7 @@ import type { } from "@/types/agent"; import { createEmptyAgentModeWorkspaceState, + MAX_DETAIL_REPLACEMENT_REFERENCES, } from "@/lib/agents/mode-workspace-state"; import { createEmptyAgentGenerationProjectWorkspaceState, @@ -213,6 +214,7 @@ export interface AgentGenerationWorkspaceDarkV2Props { interface AgentWorkflowModelOption { id: string; displayName: string; + providerName?: string; providerType: string; supportsImageGen?: boolean; isMultimodal?: boolean; @@ -2643,7 +2645,9 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] { ? record.requestedCardIds .map((entry) => String(entry || "").trim()) .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, } satisfies LocalGenerationPendingState; }); @@ -3384,12 +3388,13 @@ export function AgentGenerationWorkspaceDarkV2({ } ); - const { data: modelsData } = useSWR<{ + const { data: modelsData, error: modelsError, mutate: refreshModels } = useSWR<{ models: AgentWorkflowModelOption[]; defaultModelId?: string | null; agentWorkflowConfiguredDefaultModelId?: string | null; agentWorkflowDefaultModelId?: string | null; agentWorkflowConfiguredModels?: AgentWorkflowModelOption[]; + detailReplacementModel?: AgentWorkflowModelOption | null; agentModel?: any; }>("/api/models", fetcher); @@ -3435,17 +3440,6 @@ export function AgentGenerationWorkspaceDarkV2({ const activeBusinessModeDefaultModelId = !requiresPresetSelection ? String(activeBusinessMode?.defaultModelId || "").trim() || 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 = selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement" ? detailReplacementMethod === "ai_detail_generate" @@ -3461,6 +3455,19 @@ export function AgentGenerationWorkspaceDarkV2({ const currentResultModeType: AgentGenerationResultModeType = requiresPresetSelection ? presetDrivenResultModeType : 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 isDetailReplacementPreset = currentResultModeType === "detail_replacement" || @@ -4112,12 +4119,15 @@ export function AgentGenerationWorkspaceDarkV2({ ); const executableModelOptions = useMemo(() => { const seen = new Set(); - return [...selectableModels, ...configuredWorkflowModels].filter((model) => { + const workflowModels = detailReplacementModel + ? [detailReplacementModel, ...configuredWorkflowModels] + : configuredWorkflowModels; + return [...selectableModels, ...workflowModels].filter((model) => { if (seen.has(model.id)) return false; seen.add(model.id); return true; }); - }, [configuredWorkflowModels, selectableModels]); + }, [configuredWorkflowModels, detailReplacementModel, selectableModels]); const executableModelIdSet = useMemo( () => new Set(executableModelOptions.map((model) => model.id)), [executableModelOptions] @@ -4136,8 +4146,9 @@ export function AgentGenerationWorkspaceDarkV2({ selectableModels[0], [executableModelOptions, lockedWorkflowModel, selectableModels, selectedModelId] ); - const lockedWorkflowModelLabel = - lockedWorkflowModel?.displayName || + const lockedWorkflowModelLabel = detailReplacementModel?.providerName + ? `${detailReplacementModel.displayName} 「${detailReplacementModel.providerName}」` + : lockedWorkflowModel?.displayName || selectedAgentPreset?.defaultModel?.displayName || (lockedWorkflowModelId ? `配置模型不可用(${lockedWorkflowModelId})` : "未配置"); const [activeToolStep, setActiveToolStep] = useState("settings"); @@ -6316,6 +6327,15 @@ export function AgentGenerationWorkspaceDarkV2({ toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`); return; } + if (isDetailReplacementModelLoading) { + if (modelsError) { + toast.error("详情替换模型加载失败,正在重新读取"); + void refreshModels().catch(() => {}); + } else { + toast.info("正在读取详情替换模型,请稍候"); + } + return; + } if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) { toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`); return; @@ -6383,6 +6403,10 @@ export function AgentGenerationWorkspaceDarkV2({ } if (replacementModeType === "detail_replacement") { if (detailBatchSubmittingRef.current) return; + if (replacementExecutionCards.length > MAX_DETAIL_REPLACEMENT_REFERENCES) { + toast.error(`本次选中了 ${replacementExecutionCards.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`); + return; + } if (!isValidChatUuid(projectId)) { toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换"); return; @@ -6403,13 +6427,14 @@ export function AgentGenerationWorkspaceDarkV2({ const saved = await onPersistProject?.(); if (saved === false) throw new Error("项目尚未保存,请重试"); const requestId = generateUUID(); - const response = await fetchWithErrorHandlers("/api/agents/detail-replacement/batches", { + const response = await fetch("/api/agents/detail-replacement/batches", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId, requestId, model: resolvedModelId, + expectedModelId: resolvedModelId === "auto" ? undefined : resolvedModelId, imageSize: imageParams.imageSize, replacementStrategy: detailReplacementStrategy, prompt: detailReplacementStrategy === "product_only" @@ -6433,6 +6458,7 @@ export function AgentGenerationWorkspaceDarkV2({ }); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload?.task?.id) { + if (payload?.code === "DETAIL_REPLACEMENT_MODEL_CHANGED") void refreshModels().catch(() => {}); throw new Error(payload?.error || "详情替换任务提交失败"); } setPendingGeneration({ @@ -6620,6 +6646,8 @@ export function AgentGenerationWorkspaceDarkV2({ input, isDetailAiGenerationMethod, isDetailReplacementPreset, + isDetailReplacementModelLoading, + modelsError, isSelectedBusinessModeUnavailable, isSkuReplacementPreset, effectiveDefaultModelId, @@ -6647,6 +6675,7 @@ export function AgentGenerationWorkspaceDarkV2({ onPersistProject, projectId, refreshDetailBatch, + refreshModels, ]); const handleStopGeneration = useCallback(() => { detailGenerationCancelRef.current = true; @@ -9419,9 +9448,11 @@ export function AgentGenerationWorkspaceDarkV2({ ) : (
- {isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"} + {isDetailReplacementModelLoading + ? modelsError ? "详情替换模型加载失败,点击批量替换可重试" : "正在读取详情替换模型" + : isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"} - {isWorkflowModelLocked + {isDetailReplacementModelLoading ? null : isWorkflowModelLocked ? lockedWorkflowModelLabel : currentModel?.displayName || "自动选择"} diff --git a/lib/agents/mode-workspace-state.ts b/lib/agents/mode-workspace-state.ts index 2a5b81146..e61bda419 100644 --- a/lib/agents/mode-workspace-state.ts +++ b/lib/agents/mode-workspace-state.ts @@ -14,6 +14,8 @@ import { sanitizeAgentDetailBrief, } from "@/lib/agents/detail-generation-config"; +export const MAX_DETAIL_REPLACEMENT_REFERENCES = 64; + export function createEmptyAgentModeWorkspaceState(): AgentModeWorkspaceState { return { detailReplacement: { diff --git a/lib/ai/detail-replacement-image-model-config.ts b/lib/ai/detail-replacement-image-model-config.ts index 1bc4a95e0..dad1170a2 100644 --- a/lib/ai/detail-replacement-image-model-config.ts +++ b/lib/ai/detail-replacement-image-model-config.ts @@ -1,3 +1,5 @@ +import { isVideoModelDescriptor } from "@/lib/ai/model-classification"; + export const DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY = "detail_replacement_image_model_config"; @@ -20,3 +22,28 @@ export function normalizeDetailReplacementImageModelConfig( 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[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) + ); +} diff --git a/tests/routes/agent-project-recovery.test.ts b/tests/routes/agent-project-recovery.test.ts index f97dfe132..7f1b143d1 100644 --- a/tests/routes/agent-project-recovery.test.ts +++ b/tests/routes/agent-project-recovery.test.ts @@ -72,6 +72,85 @@ function projectRoute() { 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 () => { const { route, projects } = projectRoute(); const post = (title: string) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify({ id, title }) }));