Compare commits

..

No commits in common. "main" and "codex/canvas-ocr-and-outpaint-20261110" have entirely different histories.

22 changed files with 140 additions and 639 deletions

View File

@ -2377,9 +2377,7 @@ export function LocalAiImagePipelineSections({
items={[
{
label:
extendConfig.provider === "fal"
? "Bria 扩图服务"
: extendConfig.provider === "vip98k"
extendConfig.provider === "vip98k"
? "VIP98K 服务商"
: "阿里云服务商",
tone: "enabled",
@ -2393,11 +2391,7 @@ export function LocalAiImagePipelineSections({
}
title="图像拓展API配置"
>
{extendConfig.provider === "fal" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 Bria 生成新增区域并保留原图主体和文字。密钥从已启用的 fal 官方服务商读取。
</div>
) : extendConfig.provider === "vip98k" ? (
{extendConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,仅在这里维护扩展比例。

View File

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

View File

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

View File

@ -4,8 +4,7 @@ import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import {
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
findConfiguredDetailReplacementImageModel,
isDetailReplacementImageModel,
normalizeDetailReplacementImageModelConfig,
} from "@/lib/ai/detail-replacement-image-model-config";
import {
compositeReplacementWithReferenceLocks,
@ -30,6 +29,7 @@ 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,10 +2161,22 @@ export async function resolveDirectReplacementModelRef(params: {
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}) {
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 configuredModel = findConfiguredDetailReplacementImageModel(models, configuredModelSetting?.value);
const configuredModelId = normalizeDetailReplacementImageModelConfig(
configuredModelSetting?.value,
).modelId;
const configuredModel = imageModels.find(
({ model }) =>
model.id === configuredModelId || model.modelId === configuredModelId,
);
if (configuredModel) {
return configuredModel.model.id;
}
@ -2178,7 +2190,6 @@ 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")) ||

View File

@ -10,10 +10,6 @@ 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,
@ -161,17 +157,10 @@ export async function GET(request: Request) {
: await getEnabledAiModelsWithProvider(false, true);
const agentModelWithProvider = await getAgentAiModelWithProvider();
const defaultModel = await getDefaultAiModel();
const [siteSettings, agentWorkflowUiConfigSetting, detailReplacementModelSetting] = await Promise.all([
const [siteSettings, agentWorkflowUiConfigSetting] = 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,
@ -320,7 +309,6 @@ export async function GET(request: Request) {
: null,
agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null,
agentWorkflowConfiguredModels,
detailReplacementModel: detailReplacementModel ? serializeModel(detailReplacementModel) : null,
agentModel: agentModelWithProvider
? {
id: agentModelWithProvider.model.id,

View File

@ -1,20 +0,0 @@
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,7 +4,6 @@ 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,
@ -147,14 +146,6 @@ 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<string, unknown> : {};
return {
@ -169,6 +160,7 @@ 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)
) {
@ -195,12 +187,6 @@ 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 });

View File

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

View File

@ -233,8 +233,7 @@ import {
type WorkflowSceneRecord,
} from "@/lib/video-workflow/runtime";
import { agentProjectService } from "@/services/agent-projects";
import { getWorkflowSelectionsRequest } from "@/lib/agents/workflow-loading";
import { fetchCanvasSiteSettings, fetchWorkflowTemplatePrompts } from "@/lib/client/canvas-site-settings";
import { fetchCanvasSiteSettings } from "@/lib/client/canvas-site-settings";
import {
canvasService,
imageGenerationService,
@ -274,17 +273,6 @@ import type {
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
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({
label,
className,
@ -2188,13 +2176,13 @@ export default function CanvasEditorLegacyPage({
);
const canvasSiteSettingsKey = requestedAgentMode
? hasActiveAgentProject
? "/api/site-settings?scope=agent-canvas-base"
? "/api/site-settings?scope=agent-canvas"
: "/api/site-settings?scope=agent-entry"
: "/api/site-settings?scope=canvas";
const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] =
useState(false);
const { data: siteSettingsData, error: siteSettingsError, isValidating: siteSettingsLoading, mutate: reloadSiteSettings } = useSWR<{
const { data: siteSettingsData } = useSWR<{
siteName?: string;
siteLogo?: string | null;
siteLogoDark?: string | null;
@ -2225,7 +2213,6 @@ export default function CanvasEditorLegacyPage({
? 0
: CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS,
errorRetryCount: 1,
shouldRetryOnError: !requestedAgentMode || !hasActiveAgentProject,
dedupingInterval: 30_000,
revalidateOnFocus: true,
revalidateOnReconnect: true,
@ -2292,34 +2279,9 @@ export default function CanvasEditorLegacyPage({
!requestedAgentMode ||
!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 base = siteSettingsData?.agentWorkflowUiConfig || defaultAgentWorkflowUiConfig;
return requestedAgentMode ? { ...base, steps: workflowTemplates?.steps ?? [] } : base;
},
[requestedAgentMode, siteSettingsData?.agentWorkflowUiConfig, workflowTemplates?.steps]
() => siteSettingsData?.agentWorkflowUiConfig || defaultAgentWorkflowUiConfig,
[siteSettingsData?.agentWorkflowUiConfig]
);
const workflowPageFeatureSettings = useMemo(
() => getAgentWorkflowPageFeatureSettings(resolvedWorkflowUiConfig),
@ -2368,7 +2330,7 @@ export default function CanvasEditorLegacyPage({
});
const sanitizedWorkflowSelections = useMemo(
() =>
requestedAgentMode && !workflowTemplates
requestedAgentMode && !siteSettingsData?.agentWorkflowUiConfig
? workflowSelections
: sanitizeAgentWorkflowSelections({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
@ -2376,7 +2338,7 @@ export default function CanvasEditorLegacyPage({
}),
[
requestedAgentMode,
workflowTemplates,
siteSettingsData?.agentWorkflowUiConfig,
resolvedWorkflowUiConfig,
workflowSelections,
]
@ -2384,14 +2346,6 @@ export default function CanvasEditorLegacyPage({
const deferredSanitizedWorkflowSelections = useDeferredValue(
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(
() =>
normalizeAgentWorkflowBrandModelConfig(
@ -9967,8 +9921,6 @@ export default function CanvasEditorLegacyPage({
selectedBrandModel,
appearanceRecognitionEnabled,
selections: deferredSanitizedWorkflowSelections,
resolvedSelections: workflowPromptData?.selectionRequest === getWorkflowSelectionsRequest(deferredSanitizedWorkflowSelections)
? workflowPromptData?.selections : undefined,
});
if (canceled) {
@ -9991,7 +9943,6 @@ export default function CanvasEditorLegacyPage({
selectedBrandModel,
workflowInputs,
workflowMaterials,
workflowPromptData,
]);
useEffect(() => {
workflowInputsRef.current = workflowInputs;
@ -10113,23 +10064,15 @@ export default function CanvasEditorLegacyPage({
[patchPersistedAgentWorkflowState]
);
const getLatestWorkflowContext = useCallback(() => {
const selections = workflowSelectionsRef.current;
return buildAgentWorkflowComposerContext({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
inputs: workflowInputsRef.current,
materials: workflowMaterialsRef.current,
selectedBrandModel: selectedBrandModelRef.current,
appearanceRecognitionEnabled: appearanceRecognitionEnabledRef.current,
selections,
resolvedSelections: workflowPromptData?.selectionRequest === getWorkflowSelectionsRequest(selections)
? workflowPromptData?.selections : undefined,
selections: workflowSelectionsRef.current,
});
}, [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]);
}, [resolvedWorkflowUiConfig]);
const effectiveSelectedAgentPresetId = null;
const { data: selectedAgentPresetResponse } = useSWR<{
success?: boolean;
@ -10504,37 +10447,30 @@ export default function CanvasEditorLegacyPage({
</div>
) : null;
const workflowStylePanel = useMemo(() => (
<div ref={setWorkflowTemplateMarker} className="space-y-3">
{workflowTemplates ? (
<AgentWorkflowPanelDarkV2
config={resolvedWorkflowUiConfig}
embedded
inputs={workflowInputs}
materials={workflowMaterials}
onInputsChange={handleWorkflowInputsChange}
onMaterialsChange={handleWorkflowMaterialsChange}
onSelectionsChange={handleWorkflowSelectionsChange}
selections={sanitizedWorkflowSelections}
showBrandStoreSection={false}
/>
) : siteSettingsError && !siteSettingsData && !siteSettingsLoading ? (
<p className="text-sm text-white/65">请先在上方重试加载品牌和类目。</p>
) : !workflowTemplatesRequested ? (
<Button type="button" variant="outline" onClick={() => setWorkflowTemplatesRequested(true)}>加载风格模板</Button>
) : (
<WorkflowLoadState name="风格模板" error={workflowTemplatesError} retrying={workflowTemplatesLoading} onRetry={() => void reloadWorkflowTemplates().catch(() => {})} />
)}
{workflowPromptError && (
<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,
]);
const workflowStylePanel = useMemo(
() => (
<AgentWorkflowPanelDarkV2
config={resolvedWorkflowUiConfig}
embedded
inputs={workflowInputs}
materials={workflowMaterials}
onInputsChange={handleWorkflowInputsChange}
onMaterialsChange={handleWorkflowMaterialsChange}
onSelectionsChange={handleWorkflowSelectionsChange}
selections={sanitizedWorkflowSelections}
showBrandStoreSection={false}
/>
),
[
handleWorkflowInputsChange,
handleWorkflowMaterialsChange,
handleWorkflowSelectionsChange,
resolvedWorkflowUiConfig,
sanitizedWorkflowSelections,
workflowInputs,
workflowMaterials,
]
);
if (canvasLoadError && !isAgentMode) {
return <div role="alert" className="flex h-dvh flex-col items-center justify-center gap-4">
@ -10577,20 +10513,17 @@ export default function CanvasEditorLegacyPage({
theme="dark"
/>
}
brandStorePanel={siteSettingsData?.agentWorkflowUiConfig ? (
brandStorePanel={
<AgentWorkflowBrandStorePanel
config={resolvedWorkflowUiConfig}
inputs={workflowInputs}
onInputsChange={handleWorkflowInputsChange}
theme="dark"
/>
) : (
<WorkflowLoadState name="品牌和类目" error={siteSettingsError} retrying={siteSettingsLoading} onRetry={() => void reloadSiteSettings().catch(() => {})} />
)}
}
chatId={agentModeChatId}
deletedResultCollections={agentProjectDeletedResultCollections}
getLatestWorkflowContext={getLatestWorkflowContext}
getWorkflowPromptStatus={getWorkflowPromptStatus}
materialSelectionPanel={
isWorkflowMaterialStepEnabled ? (
<AgentWorkflowMaterialSelectionPanel

View File

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

View File

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

View File

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

View File

@ -1,18 +0,0 @@
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

@ -1,5 +1,3 @@
import { isVideoModelDescriptor } from "@/lib/ai/model-classification";
export const DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY =
"detail_replacement_image_model_config";
@ -22,28 +20,3 @@ 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<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,19 +3,6 @@ export type ExtendProviderCredential = {
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 {
if (!value) return 0;
const timestamp = value instanceof Date ? value.getTime() : Date.parse(value);

View File

@ -1,93 +0,0 @@
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,15 +5,14 @@ import {
getSystemSetting,
upsertMediaAsset,
} from "@/lib/db/queries";
import { type ExtendProvider, resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
import { expandWithFalOutpaint, FAL_OUTPAINT_MODEL } from "@/lib/ai/extend/fal-outpaint";
import { resolveAlibabaExtendApiKey } from "@/lib/ai/extend/config";
import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { uploadFile } from "@/lib/storage/service";
type ExtendConfig = {
provider?: ExtendProvider;
provider?: "vip98k" | "dashscope";
apiKey?: string;
xScale?: number;
yScale?: number;
@ -84,12 +83,12 @@ async function createThumbnail(buffer: Buffer): Promise<Buffer> {
}
}
async function uploadBufferPairToS3(buffer: Buffer, originalExtension = "jpg"): Promise<{ thumbnail: string; original: string }> {
async function uploadBufferPairToS3(buffer: Buffer): Promise<{ thumbnail: string; original: string }> {
try {
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `extend-thumb-${timestamp}-${randomStr}.jpg`;
const originalFilename = `extend-orig-${timestamp}-${randomStr}.${originalExtension}`;
const originalFilename = `extend-orig-${timestamp}-${randomStr}.jpg`;
const thumbnailBuffer = await createThumbnail(buffer);
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
@ -474,15 +473,6 @@ async function getValidatedExtendConfig(): Promise<ExtendConfig> {
if (config.provider === "vip98k") {
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({
type: "alibaba-image",
@ -532,29 +522,35 @@ export async function executeExtendJob(
throw new ExtendServiceError("无法获取输入图片URL", 400);
}
const preparedInputImageUrl =
config.provider === "vip98k"
? inputImageUrl
: await normalizeDashscopeExpansionInput(inputImageUrl);
const startTime = Date.now();
let buffer: Buffer;
let modelId: string;
if (config.provider === "fal") {
buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale });
modelId = FAL_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) {
throw new ExtendServiceError("下载拓展图片失败", 500, `${imageResponse.status} ${imageResponse.statusText}`);
}
buffer = Buffer.from(await imageResponse.arrayBuffer());
modelId = config.provider === "vip98k" ? "image-expansion" : "extend-1.0";
const extendedImageUrl = await extendImage(
preparedInputImageUrl,
config,
xScale,
yScale,
requestId,
);
const imageResponse = await fetch(extendedImageUrl);
if (!imageResponse.ok) {
throw new ExtendServiceError(
"下载拓展图片失败",
500,
`${imageResponse.status} ${imageResponse.statusText}`,
);
}
const buffer = Buffer.from(await imageResponse.arrayBuffer());
const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined;
const uploadResult = await uploadBufferPairToS3(buffer, config.provider === "fal" ? "png" : "jpg");
const uploadResult = await uploadBufferPairToS3(buffer);
console.log("[Extend Service] Result uploaded:", {
requestId,
includeBase64,
inputImageUrl: inputImageUrl.slice(0, 120),
modelId,
extendedImageUrl: extendedImageUrl.slice(0, 120),
outputBytes: buffer.length,
});
let mediaAssetId: string | null = null;
@ -571,7 +567,7 @@ export async function executeExtendJob(
thumbnailUrl: uploadResult.thumbnail,
originalUrl: uploadResult.original,
prompt,
modelId,
modelId: config.provider === "vip98k" ? "image-expansion" : "extend-1.0",
imageSize: "2K",
})
.returning();
@ -595,7 +591,7 @@ export async function executeExtendJob(
thumbnailUrl: uploadResult.thumbnail,
displayName: prompt.slice(0, 80),
prompt,
modelId,
modelId: config.provider === "vip98k" ? "image-expansion" : "extend-1.0",
sourceMessageId: messageId,
sourceTable: "GeneratedImage",
sourceRowId: generatedImageId,

View File

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

View File

@ -1,27 +1,11 @@
import type { AgentWorkflowComposerContext } from "@/types/agent";
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) {
const cached = settingsCache.get(url);
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
url === "/api/site-settings?scope=agent-canvas" ||
url === "/api/site-settings?scope=agent-workflow-templates" ? 30_000 : 10_000
url === "/api/site-settings?scope=agent-canvas" ? 30_000 : 10_000
);
try {
const response = await fetch(url, {

View File

@ -72,85 +72,6 @@ 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 }) }));
@ -293,11 +214,6 @@ test("entry config stays small and unchanged config returns 304 before loading v
const entryData = await entry.json();
expect(entryData.agentWorkflowUiConfig.steps).toEqual([]);
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"));
expect((await full.json()).agentWorkflowUiConfig.steps[0].options[0].promptFragment).toBe("生成提示词".repeat(3000).slice(0, 20_000));
const etag = full.headers.get("ETag");
@ -311,25 +227,3 @@ test("entry config stays small and unchanged config returns 304 before loading v
expect(changed.status).toBe(200);
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

@ -1,7 +1,5 @@
import { expect, test } from "@playwright/test";
import sharp from "sharp";
import { resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
import { composeOutpaint, getOutpaintGeometry } from "@/lib/ai/extend/fal-outpaint";
import { resolveAlibabaExtendApiKey } from "@/lib/ai/extend/config";
test.describe("image extension provider configuration", () => {
test("uses the most recently updated Alibaba image provider key", () => {
@ -25,34 +23,4 @@ test.describe("image extension provider configuration", () => {
).toBe("legacy-key");
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);
});

View File

@ -30,12 +30,6 @@ const atlasHunyuan3D = {
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("classifies Qwen Image 3.0 as Alibaba text-to-image and image editing", () => {
expect(isAlibabaImageTextToImageModelId("qwen-image-3.0")).toBe(true);