Compare commits

...

3 Commits

Author SHA1 Message Date
Your Name
fec4b9d5aa fix: refresh krea image provider integration 2026-05-30 12:53:18 +08:00
Your Name
9adb55c6a7 feat: add agent workflow snapshot center 2026-05-29 17:22:10 +08:00
Your Name
ba46f97f3d fix: guard agent workflow config rollback 2026-05-29 16:52:40 +08:00
14 changed files with 1481 additions and 135 deletions

View File

@ -32,6 +32,8 @@ import {
MonitorPlay,
Package,
Plus,
RefreshCw,
RotateCcw,
Save,
Search,
Settings2,
@ -187,6 +189,8 @@ const WORKFLOW_SETTINGS_STALE_DRAFT_STORAGE_KEY = "agent-workflow-settings-stale
const WORKFLOW_SETTINGS_DRAFT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const AGENT_WORKFLOW_SETTINGS_ENDPOINT =
"/api/site-settings?scope=agent-workflow-admin";
const AGENT_WORKFLOW_SNAPSHOTS_ENDPOINT =
"/api/admin/system-settings/snapshots?scope=agent-workflow&limit=12";
const AGENT_WORKFLOW_SAVE_TIMEOUT_MS = 30_000;
const DELETE_WORKFLOW_STEP_PASSWORD = "123456";
const BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE = "__agent_workflow_mode_default_model_empty__";
@ -1811,6 +1815,76 @@ interface AgentWorkflowSettingsPayload {
agentPsdTemplateSecondaryCategoryConfig?: AgentPsdTemplateSecondaryCategoryConfig;
}
interface AgentWorkflowDestructiveChangePayload {
totalBeforeOptionCount: number;
totalAfterOptionCount: number;
removedOptionCount: number;
removedEnabledOptionCount: number;
promptClearedCount: number;
previewClearedCount: number;
removedOptionSamples: string[];
promptClearedSamples: string[];
previewClearedSamples: string[];
approvalToken: string;
}
interface AgentWorkflowSnapshotItem {
id: string;
scope: string;
label: string | null;
summary?: {
keyCount?: number;
nonNullKeyCount?: number;
byteLength?: number;
siteFieldCount?: number;
} | null;
createdAt: string;
createdByName?: string | null;
createdByEmail?: string | null;
}
function formatSnapshotByteLength(value?: number | null) {
const byteLength = Number(value || 0);
if (!Number.isFinite(byteLength) || byteLength <= 0) {
return "0 KB";
}
return `${(byteLength / 1024).toFixed(byteLength >= 1024 * 1024 ? 1 : 0)} KB`;
}
function formatAgentWorkflowDestructiveChangeConfirmText(
payload: AgentWorkflowDestructiveChangePayload
) {
const lines = [
"这次保存会对风格化工作流做大规模删改,系统已拦截。",
"",
`删除节点:${payload.removedOptionCount}(其中启用中 ${payload.removedEnabledOptionCount})`,
`清空提示词:${payload.promptClearedCount}`,
`清空预览图:${payload.previewClearedCount}`,
"",
];
const sampleGroups: Array<[string, string[]]> = [
["将删除的分支示例", payload.removedOptionSamples],
["将清空提示词的示例", payload.promptClearedSamples],
["将清空预览图的示例", payload.previewClearedSamples],
];
for (const [label, samples] of sampleGroups) {
if (samples.length === 0) {
continue;
}
lines.push(`${label}:`);
for (const sample of samples.slice(0, 5)) {
lines.push(`- ${sample}`);
}
lines.push("");
}
lines.push("如果这是你确认要执行的结构性删除,请点击“确定”再次保存。");
return lines.join("\n");
}
interface AdminAiModelOption {
id: string;
modelId?: string | null;
@ -1915,6 +1989,15 @@ export function AgentWorkflowSettingsClient({
fetcher,
{ revalidateOnFocus: false }
);
const {
data: snapshotsData,
isLoading: isLoadingSnapshots,
mutate: mutateSnapshots,
} = useSWR<{ snapshots: AgentWorkflowSnapshotItem[] }>(
AGENT_WORKFLOW_SNAPSHOTS_ENDPOINT,
fetcher,
{ revalidateOnFocus: false }
);
const [agentWorkflowDefaultModelId, setAgentWorkflowDefaultModelId] = useState<string | null>(null);
const [presetLibraryConfig, setPresetLibraryConfig] = useState<AgentPresetLibraryConfig>(
defaultAgentPresetLibraryConfig
@ -1970,6 +2053,9 @@ export function AgentWorkflowSettingsClient({
const [autoDraftUpdatedAt, setAutoDraftUpdatedAt] = useState<number | null>(null);
const [autoDraftStatus, setAutoDraftStatus] = useState<"idle" | "restored" | "saved">("idle");
const [recoverableDraftUpdatedAt, setRecoverableDraftUpdatedAt] = useState<number | null>(null);
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
const [isCreatingSnapshot, setIsCreatingSnapshot] = useState(false);
const [restoringSnapshotId, setRestoringSnapshotId] = useState<string | null>(null);
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const brandImportInputRef = useRef<HTMLInputElement | null>(null);
const hasAppliedInitialSettingsRef = useRef(false);
@ -3899,39 +3985,77 @@ export function AgentWorkflowSettingsClient({
changedPayload.agentWorkflowSettingsBaseSignatures[field] = baseSignatures[field];
}
const response = await fetchAgentWorkflowSettingsWithTimeout(
"/api/site-settings",
{
const submitChangedPayload = async (payload: Record<string, unknown>) =>
fetchAgentWorkflowSettingsWithTimeout("/api/site-settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changedPayload),
body: JSON.stringify(payload),
});
let response = await submitChangedPayload(changedPayload as Record<string, unknown>);
let responsePayload = (await response.json().catch(() => null)) as
| {
error?: string;
details?: Array<{ message?: string; path?: Array<string | number> }>;
requiresDestructiveChangeApproval?: boolean;
destructiveChange?: AgentWorkflowDestructiveChangePayload;
}
| null;
if (
!response.ok &&
responsePayload?.requiresDestructiveChangeApproval &&
responsePayload?.destructiveChange?.approvalToken
) {
const shouldContinue = window.confirm(
formatAgentWorkflowDestructiveChangeConfirmText(
responsePayload.destructiveChange
)
);
if (!shouldContinue) {
toast.info("已取消本次高风险保存");
return;
}
);
response = await submitChangedPayload({
...changedPayload,
agentWorkflowDestructiveChangeApproval: {
token: responsePayload.destructiveChange.approvalToken,
},
});
responsePayload = (await response.json().catch(() => null)) as
| {
error?: string;
details?: Array<{ message?: string; path?: Array<string | number> }>;
}
| null;
}
if (!response.ok) {
let message = "保存失败";
try {
const payload = await response.json();
if (Array.isArray(payload?.details) && payload.details.length > 0) {
const firstIssue = payload.details[0] as {
message?: string;
path?: Array<string | number>;
};
const pathText =
Array.isArray(firstIssue.path) && firstIssue.path.length > 0
? firstIssue.path
.filter((segment) => segment !== undefined && segment !== null && segment !== "")
.join(" / ")
: "";
const reasonText = String(firstIssue.message || "").trim();
if (Array.isArray(responsePayload?.details) && responsePayload.details.length > 0) {
const firstIssue = responsePayload.details[0] as {
message?: string;
path?: Array<string | number>;
};
const pathText =
Array.isArray(firstIssue.path) && firstIssue.path.length > 0
? firstIssue.path
.filter((segment) => segment !== undefined && segment !== null && segment !== "")
.join(" / ")
: "";
const reasonText = String(firstIssue.message || "").trim();
message = [payload?.error, pathText, reasonText].filter(Boolean).join(":");
} else if (typeof payload?.error === "string" && payload.error.trim()) {
message = payload.error.trim();
}
} catch {
// Ignore JSON parsing issues and keep the generic message.
message = [responsePayload?.error, pathText, reasonText]
.filter(Boolean)
.join(":");
} else if (
typeof responsePayload?.error === "string" &&
responsePayload.error.trim()
) {
message = responsePayload.error.trim();
}
throw new Error(message);
@ -3990,6 +4114,81 @@ export function AgentWorkflowSettingsClient({
}
};
const handleCreateSnapshot = async () => {
try {
setIsCreatingSnapshot(true);
const response = await fetch("/api/admin/system-settings/snapshots", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "create",
scope: "agent-workflow",
}),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string; snapshot?: AgentWorkflowSnapshotItem | null }
| null;
if (!response.ok) {
throw new Error(payload?.error || "创建快照失败");
}
await mutateSnapshots();
toast.success("已创建当前工作流快照");
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建快照失败");
} finally {
setIsCreatingSnapshot(false);
}
};
const handleRestoreSnapshot = async (snapshot: AgentWorkflowSnapshotItem) => {
const shouldContinue = window.confirm(
[
"将把风格化工作流恢复到这个快照版本。",
"恢复前系统会自动再做一份当前快照,便于继续回退。",
"",
`快照标签:${snapshot.label || "未命名快照"}`,
`创建时间:${new Date(snapshot.createdAt).toLocaleString("zh-CN")}`,
].join("\n")
);
if (!shouldContinue) {
return;
}
try {
setRestoringSnapshotId(snapshot.id);
const response = await fetch("/api/admin/system-settings/snapshots", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "restore",
snapshotId: snapshot.id,
reason: `restore-snapshot:${snapshot.id}`,
}),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string; backupSnapshotId?: string | null }
| null;
if (!response.ok) {
throw new Error(payload?.error || "恢复快照失败");
}
await Promise.all([mutate(), mutateSnapshots()]);
toast.success(
payload?.backupSnapshotId
? `已恢复快照,并自动创建恢复前备份 ${payload.backupSnapshotId.slice(0, 8)}`
: "已恢复到所选快照"
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复快照失败");
} finally {
setRestoringSnapshotId(null);
}
};
const handleDownloadBrandTemplate = async () => {
try {
await downloadAgentWorkflowBrandImportTemplate(categoryLabels);
@ -5782,6 +5981,15 @@ export function AgentWorkflowSettingsClient({
>
{recoverableDraftUpdatedAt ? "恢复旧草稿" : "查找旧草稿"}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSnapshotDialogOpen(true)}
>
<RotateCcw className="mr-2 h-4 w-4" />
快照中心
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/settings/agent-workflow/psd-templates">
<Layers className="mr-2 h-4 w-4" />
@ -5812,6 +6020,126 @@ export function AgentWorkflowSettingsClient({
</div>
</header>
<Dialog open={snapshotDialogOpen} onOpenChange={setSnapshotDialogOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>风格化工作流快照中心</DialogTitle>
<DialogDescription>
查看最近快照、手动创建安全点,并一键恢复到指定版本。恢复前系统会自动再备份一次当前状态。
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 rounded-2xl border bg-muted/20 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">当前共有 {snapshotsData?.snapshots?.length || 0} 条最近快照</div>
<div className="text-xs text-muted-foreground">
自动快照会在高风险保存前生成,手动快照适合在批量编辑前先留一个恢复点。
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => mutateSnapshots()}
disabled={isLoadingSnapshots}
>
<RefreshCw
className={cn("mr-2 h-4 w-4", isLoadingSnapshots && "animate-spin")}
/>
刷新
</Button>
<Button
type="button"
size="sm"
onClick={handleCreateSnapshot}
disabled={isCreatingSnapshot}
>
{isCreatingSnapshot ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
创建快照
</Button>
</div>
</div>
<div className="max-h-[60vh] space-y-3 overflow-y-auto pr-1">
{isLoadingSnapshots ? (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
正在加载快照列表...
</div>
) : (snapshotsData?.snapshots?.length || 0) === 0 ? (
<div className="flex h-40 items-center justify-center rounded-2xl border border-dashed text-sm text-muted-foreground">
还没有可用快照,先创建一个安全点吧。
</div>
) : (
(snapshotsData?.snapshots || []).map((snapshot, index) => (
<div
key={snapshot.id}
className="rounded-2xl border bg-background p-4 shadow-sm"
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={index === 0 ? "default" : "secondary"}>
{index === 0 ? "最新" : `#${index + 1}`}
</Badge>
<span className="font-medium">
{snapshot.label || "未命名快照"}
</span>
<span className="font-mono text-xs text-muted-foreground">
{snapshot.id.slice(0, 8)}
</span>
</div>
<div className="text-sm text-muted-foreground">
{new Date(snapshot.createdAt).toLocaleString("zh-CN")}
{" · "}
{snapshot.createdByName ||
snapshot.createdByEmail ||
"系统自动创建"}
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-full border px-2 py-1">
配置键 {snapshot.summary?.keyCount ?? "-"}
</span>
<span className="rounded-full border px-2 py-1">
站点字段 {snapshot.summary?.siteFieldCount ?? 0}
</span>
<span className="rounded-full border px-2 py-1">
非空项 {snapshot.summary?.nonNullKeyCount ?? "-"}
</span>
<span className="rounded-full border px-2 py-1">
大小 {formatSnapshotByteLength(snapshot.summary?.byteLength)}
</span>
</div>
</div>
<Button
type="button"
variant="outline"
onClick={() => handleRestoreSnapshot(snapshot)}
disabled={restoringSnapshotId === snapshot.id}
>
{restoringSnapshotId === snapshot.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RotateCcw className="mr-2 h-4 w-4" />
)}
恢复到此版本
</Button>
</div>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
{/* Stats Bar */}
<div className="border-b bg-muted/30">
<div className="container mx-auto px-6 py-3">

View File

@ -0,0 +1,257 @@
import { NextRequest, NextResponse } from "next/server";
import { and, desc, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/lib/db";
import {
siteSettings,
systemSettingHistory,
systemSettingSnapshot,
systemSettings,
user,
} from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
import {
AGENT_WORKFLOW_SNAPSHOT_SCOPE,
createSystemSettingSnapshotData,
getSystemSettingSnapshotScopeKeys,
readSystemSettingSnapshotData,
summarizeSystemSettingSnapshotData,
type SystemSettingSnapshotScope,
} from "@/lib/system-settings/snapshot";
const snapshotScopeSchema = z.enum([AGENT_WORKFLOW_SNAPSHOT_SCOPE]);
const listQuerySchema = z.object({
scope: snapshotScopeSchema,
limit: z.coerce.number().int().min(1).max(50).optional(),
});
const mutationSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("create"),
scope: snapshotScopeSchema,
label: z.string().trim().min(1).max(128).optional(),
}),
z.object({
action: z.literal("restore"),
snapshotId: z.string().uuid(),
reason: z.string().trim().min(1).max(128).optional(),
}),
]);
async function loadSnapshotSource(scope: SystemSettingSnapshotScope) {
const keys = getSystemSettingSnapshotScopeKeys(scope);
const rows = await db
.select({
key: systemSettings.key,
value: systemSettings.value,
})
.from(systemSettings)
.where(inArray(systemSettings.key, [...keys]));
const valueByKey = new Map(rows.map((row) => [row.key, row.value]));
const [currentSiteSettings] = await db
.select({
agentWorkflowDefaultModelId: siteSettings.agentWorkflowDefaultModelId,
})
.from(siteSettings)
.limit(1);
return {
snapshotData: createSystemSettingSnapshotData(scope, valueByKey, {
agentWorkflowDefaultModelId:
String(currentSiteSettings?.agentWorkflowDefaultModelId || "").trim() || null,
}),
};
}
async function createSnapshot(params: {
scope: SystemSettingSnapshotScope;
label?: string;
createdBy: string;
}) {
const { snapshotData } = await loadSnapshotSource(params.scope);
const [snapshot] = await db
.insert(systemSettingSnapshot)
.values({
scope: params.scope,
label: params.label || `manual-${params.scope}-snapshot`,
summary: summarizeSystemSettingSnapshotData(snapshotData),
data: snapshotData,
createdAt: new Date(),
createdBy: params.createdBy,
})
.returning({
id: systemSettingSnapshot.id,
scope: systemSettingSnapshot.scope,
label: systemSettingSnapshot.label,
createdAt: systemSettingSnapshot.createdAt,
});
return snapshot ?? null;
}
export async function GET(request: NextRequest) {
try {
await requireAgentWorkflowAdmin();
const parsed = listQuerySchema.parse(
Object.fromEntries(new URL(request.url).searchParams.entries())
);
const snapshots = await db
.select({
id: systemSettingSnapshot.id,
scope: systemSettingSnapshot.scope,
label: systemSettingSnapshot.label,
summary: systemSettingSnapshot.summary,
createdAt: systemSettingSnapshot.createdAt,
createdByName: user.name,
createdByEmail: user.email,
})
.from(systemSettingSnapshot)
.leftJoin(user, eq(user.id, systemSettingSnapshot.createdBy))
.where(eq(systemSettingSnapshot.scope, parsed.scope))
.orderBy(desc(systemSettingSnapshot.createdAt))
.limit(parsed.limit ?? 12);
return NextResponse.json({ snapshots });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
return new ChatSDKError("offline:admin").toResponse();
}
}
export async function POST(request: NextRequest) {
try {
const session = await requireAgentWorkflowAdmin();
const body = mutationSchema.parse(await request.json());
if (body.action === "create") {
const snapshot = await createSnapshot({
scope: body.scope,
label: body.label,
createdBy: session.user.id,
});
return NextResponse.json({ snapshot });
}
const [targetSnapshot] = await db
.select({
id: systemSettingSnapshot.id,
scope: systemSettingSnapshot.scope,
data: systemSettingSnapshot.data,
})
.from(systemSettingSnapshot)
.where(eq(systemSettingSnapshot.id, body.snapshotId))
.limit(1);
if (!targetSnapshot) {
return NextResponse.json({ error: "快照不存在" }, { status: 404 });
}
const scope = snapshotScopeSchema.parse(targetSnapshot.scope);
const parsedSnapshot = readSystemSettingSnapshotData(scope, targetSnapshot.data);
const currentSource = await loadSnapshotSource(scope);
const [backupSnapshot] = await db.transaction(async (tx) => {
const [preRestoreSnapshot] = await tx
.insert(systemSettingSnapshot)
.values({
scope,
label: `before-restore-${body.snapshotId.slice(0, 8)}`,
summary: summarizeSystemSettingSnapshotData(currentSource.snapshotData),
data: currentSource.snapshotData,
createdAt: new Date(),
createdBy: session.user.id,
})
.returning({
id: systemSettingSnapshot.id,
});
for (const [key, nextValue] of Object.entries(parsedSnapshot.systemSettings)) {
const [existingSetting] = await tx
.select({ value: systemSettings.value })
.from(systemSettings)
.where(eq(systemSettings.key, key))
.limit(1);
await tx.insert(systemSettingHistory).values({
key,
previousValue: existingSetting?.value ?? null,
nextValue,
reason: body.reason || `restore-snapshot:${body.snapshotId}`,
changedAt: new Date(),
changedBy: session.user.id,
});
await tx
.insert(systemSettings)
.values({
key,
value: nextValue,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: nextValue,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if ("agentWorkflowDefaultModelId" in parsedSnapshot.siteSettings) {
const [existingSiteSettings] = await tx.select().from(siteSettings).limit(1);
const nextAgentWorkflowDefaultModelId =
String(parsedSnapshot.siteSettings.agentWorkflowDefaultModelId || "").trim() || null;
if (existingSiteSettings) {
await tx
.update(siteSettings)
.set({
agentWorkflowDefaultModelId: nextAgentWorkflowDefaultModelId,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.where(eq(siteSettings.id, existingSiteSettings.id));
} else {
await tx.insert(siteSettings).values({
siteName: "AI Chatbot",
agentWorkflowDefaultModelId: nextAgentWorkflowDefaultModelId,
updatedAt: new Date(),
updatedBy: session.user.id,
});
}
}
return [preRestoreSnapshot];
});
clearScopedSiteSettingsCache();
return NextResponse.json({
restoredSnapshotId: body.snapshotId,
backupSnapshotId: backupSnapshot?.id ?? null,
});
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: "请求参数不合法",
details: error.issues,
},
{ status: 400 }
);
}
return new ChatSDKError("offline:admin").toResponse();
}
}

View File

@ -328,7 +328,6 @@ async function createEnhanceProJob(params: {
width: params.width,
height: params.height,
image_url: params.sourceImageUrl,
batchSize: params.requestBody.batchSize,
seed: params.requestBody.seed,
prompt: String(params.requestBody.prompt || "").trim(),
output_format: params.requestBody.output_format || params.config.defaultOutputFormat,
@ -1065,9 +1064,8 @@ export async function POST(request: NextRequest) {
config.maxOutputEdge,
);
const batchSize = config.allowBatchSize
? clampInteger(body.batchSize, 1, config.maxBatchSize, 1)
: 1;
// Krea removed batch generation from the enhance endpoint.
const batchSize = 1;
const upscalingActivated = body.upscaling_activated === true;
const imageScalingFactor = clampNumber(
body.image_scaling_factor,

View File

@ -1,7 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { createHash } from "node:crypto";
import { z } from "zod";
import { db } from "@/lib/db";
import { aiProvider, siteSettings, systemSettingHistory, systemSettings } from "@/lib/db/schema";
import {
aiProvider,
siteSettings,
systemSettingHistory,
systemSettingSnapshot,
systemSettings,
} from "@/lib/db/schema";
import { and, eq, inArray } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { isAgentWorkflowAdminType, isSiteAdminType } from "@/lib/middleware/admin";
@ -35,8 +42,10 @@ import {
AGENT_WORKFLOW_UI_CONFIG_KEY,
AGENT_WORKFLOW_PROMPT_FRAGMENT_MAX_LENGTH,
defaultAgentWorkflowUiConfig,
isAgentWorkflowDestructiveChangeSignificant,
normalizeAgentWorkflowStepOrdering,
normalizeAgentWorkflowUiConfig,
summarizeAgentWorkflowDestructiveChange,
} from "@/lib/agents/workflow-ui-config";
import {
AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY,
@ -105,6 +114,11 @@ import {
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS,
setScopedSiteSettingsCache,
} from "@/lib/site-settings/public-cache";
import {
AGENT_WORKFLOW_SNAPSHOT_SCOPE,
createSystemSettingSnapshotData,
summarizeSystemSettingSnapshotData,
} from "@/lib/system-settings/snapshot";
import {
getHomeChatAnimationHtmlBytes,
HOME_CHAT_ANIMATION_CONFIG_KEY,
@ -1612,6 +1626,11 @@ const updateSchema = z.object({
agentWorkflowSettingsBaseSignatures: z
.record(z.string().trim().min(1).max(128))
.optional(),
agentWorkflowDestructiveChangeApproval: z
.object({
token: z.string().trim().min(16).max(128),
})
.optional(),
agentWorkflowUiConfig: z
.object({
inputOptions: z
@ -1965,6 +1984,7 @@ export async function PUT(request: NextRequest) {
"agentPresetLibraryConfig",
"agentWorkflowSettingsBaseSignature",
"agentWorkflowSettingsBaseSignatures",
"agentWorkflowDestructiveChangeApproval",
"agentWorkflowUiConfig",
"agentWorkflowMaterialApiConfig",
"agentWorkflowGoodsInfoApiConfig",
@ -1992,6 +2012,7 @@ export async function PUT(request: NextRequest) {
agentSecondaryTypeConfig,
agentWorkflowSettingsBaseSignature,
agentWorkflowSettingsBaseSignatures,
agentWorkflowDestructiveChangeApproval,
agentWorkflowUiConfig,
agentWorkflowMaterialApiConfig,
agentWorkflowGoodsInfoApiConfig,
@ -2033,6 +2054,29 @@ export async function PUT(request: NextRequest) {
.map(([field]) => field);
const isUpdatingAgentWorkflowSettings = updatedAgentWorkflowFields.length > 0;
let currentWorkflowValues = new Map<string, unknown>();
let currentWorkflowSectionSignatures:
| ReturnType<typeof createAgentWorkflowSettingsSectionSignatures>
| null = null;
let workflowDestructiveChangeSummary:
| ReturnType<typeof summarizeAgentWorkflowDestructiveChange>
| null = null;
let agentWorkflowSnapshotCreated = false;
const createAgentWorkflowDestructiveChangeApprovalToken = (params: {
currentSignature: string;
nextConfig: unknown;
summary: ReturnType<typeof summarizeAgentWorkflowDestructiveChange>;
}) =>
createHash("sha256")
.update(
JSON.stringify({
currentSignature: params.currentSignature,
nextConfig: params.nextConfig,
summary: params.summary,
})
)
.digest("hex");
if (isUpdatingAgentWorkflowSettings) {
if (!agentWorkflowSettingsBaseSignatures && !agentWorkflowSettingsBaseSignature) {
@ -2052,10 +2096,10 @@ export async function PUT(request: NextRequest) {
})
.from(systemSettings)
.where(inArray(systemSettings.key, [...AGENT_WORKFLOW_SETTINGS_CONFLICT_KEYS]));
const currentWorkflowValues = new Map<string, unknown>(
currentWorkflowValues = new Map<string, unknown>(
currentWorkflowRows.map((row) => [row.key, row.value])
);
const currentSectionSignatures =
currentWorkflowSectionSignatures =
createAgentWorkflowSettingsSectionSignatures(currentWorkflowValues);
const currentWorkflowSignature =
agentWorkflowSettingsBaseSignature && !agentWorkflowSettingsBaseSignatures
@ -2064,7 +2108,8 @@ export async function PUT(request: NextRequest) {
const hasWorkflowConflict = agentWorkflowSettingsBaseSignatures
? updatedAgentWorkflowFields.some(
(field) =>
currentSectionSignatures[field] !== agentWorkflowSettingsBaseSignatures[field]
currentWorkflowSectionSignatures![field] !==
agentWorkflowSettingsBaseSignatures[field]
)
: currentWorkflowSignature !== agentWorkflowSettingsBaseSignature;
@ -2077,6 +2122,44 @@ export async function PUT(request: NextRequest) {
{ status: 409 }
);
}
if (agentWorkflowUiConfig !== undefined) {
const currentWorkflowUiConfig = normalizeAgentWorkflowUiConfig(
currentWorkflowValues.get(AGENT_WORKFLOW_UI_CONFIG_KEY)
);
const nextWorkflowUiConfig = normalizeAgentWorkflowUiConfig(agentWorkflowUiConfig);
workflowDestructiveChangeSummary = summarizeAgentWorkflowDestructiveChange(
currentWorkflowUiConfig,
nextWorkflowUiConfig
);
if (isAgentWorkflowDestructiveChangeSignificant(workflowDestructiveChangeSummary)) {
const approvalToken = createAgentWorkflowDestructiveChangeApprovalToken({
currentSignature:
currentWorkflowSectionSignatures.agentWorkflowUiConfig ||
createAgentWorkflowSettingsSignature(
new Map([[AGENT_WORKFLOW_UI_CONFIG_KEY, currentWorkflowUiConfig]])
),
nextConfig: nextWorkflowUiConfig,
summary: workflowDestructiveChangeSummary,
});
if (agentWorkflowDestructiveChangeApproval?.token !== approvalToken) {
return NextResponse.json(
{
error:
"本次保存会删除大量风格化工作流节点或清空关键内容,已被系统拦截。请确认后再次提交。",
requiresDestructiveChangeApproval: true,
destructiveChange: {
...workflowDestructiveChangeSummary,
approvalToken,
},
},
{ status: 409 }
);
}
}
}
}
const persistSystemSettingWithHistory = async (
@ -2136,6 +2219,47 @@ export async function PUT(request: NextRequest) {
});
};
const createAgentWorkflowScopeSnapshot = async () => {
if (!isUpdatingAgentWorkflowSettings || agentWorkflowSnapshotCreated) {
return null;
}
const [currentSiteSettings] = await db
.select({
agentWorkflowDefaultModelId: siteSettings.agentWorkflowDefaultModelId,
})
.from(siteSettings)
.limit(1);
const snapshotData = createSystemSettingSnapshotData(
AGENT_WORKFLOW_SNAPSHOT_SCOPE,
currentWorkflowValues,
{
agentWorkflowDefaultModelId:
String(currentSiteSettings?.agentWorkflowDefaultModelId || "").trim() || null,
}
);
const [snapshot] = await db
.insert(systemSettingSnapshot)
.values({
scope: AGENT_WORKFLOW_SNAPSHOT_SCOPE,
label: workflowDestructiveChangeSummary
? "before-destructive-agent-workflow-save"
: "before-agent-workflow-save",
summary: {
...summarizeSystemSettingSnapshotData(snapshotData),
updatedFields: updatedAgentWorkflowFields,
destructiveChange: workflowDestructiveChangeSummary,
},
data: snapshotData,
createdAt: new Date(),
createdBy: session.user.id,
})
.returning({ id: systemSettingSnapshot.id });
agentWorkflowSnapshotCreated = true;
return snapshot?.id ?? null;
};
const persistHomeAnimationConfig = async () => {
if (homeAnimationConfig === undefined) {
return;
@ -2175,6 +2299,8 @@ export async function PUT(request: NextRequest) {
},
});
};
await createAgentWorkflowScopeSnapshot();
// 查找现有设置
const [existing] = await db.select().from(siteSettings).limit(1);

View File

@ -798,6 +798,139 @@ export function countAgentWorkflowOptionTree(
return flattenAgentWorkflowOptionTree(options).length;
}
export interface AgentWorkflowDestructiveChangeSummary {
totalBeforeOptionCount: number;
totalAfterOptionCount: number;
removedOptionCount: number;
removedEnabledOptionCount: number;
promptClearedCount: number;
previewClearedCount: number;
removedOptionSamples: string[];
promptClearedSamples: string[];
previewClearedSamples: string[];
}
const AGENT_WORKFLOW_DESTRUCTIVE_CHANGE_SAMPLE_LIMIT = 12;
function collectAgentWorkflowAuditEntries(
steps: AgentWorkflowStepUiConfig[],
stepKeyPath: string[] = [],
stepLabelPath: string[] = [],
output: Array<{
identityPath: string;
displayPath: string;
enabled: boolean;
hasPrompt: boolean;
hasPreview: boolean;
}> = []
) {
for (const step of [...(steps || [])].sort((left, right) => left.sortOrder - right.sortOrder)) {
const nextStepKeyPath = [...stepKeyPath, `s:${String(step.key || "").trim()}`];
const nextStepLabelPath = [
...stepLabelPath,
String(step.title || step.key || "未命名模块").trim() || "未命名模块",
];
const visitOptions = (
options: AgentWorkflowOptionUiConfig[],
parentKeyPath: string[],
parentLabelPath: string[]
) => {
for (const option of [...(options || [])].sort((left, right) => left.sortOrder - right.sortOrder)) {
const optionKey = String(option.key || "").trim() || "option";
const optionLabel =
String(option.buttonName || option.key || "未命名卡片").trim() || "未命名卡片";
const nextKeyPath = [...parentKeyPath, `o:${optionKey}`];
const nextLabelPath = [...parentLabelPath, optionLabel];
output.push({
identityPath: nextKeyPath.join(">"),
displayPath: nextLabelPath.join(" > "),
enabled: option.enabled !== false,
hasPrompt: Boolean(String(option.promptFragment || "").trim()),
hasPreview: hasAgentWorkflowOptionOwnPreview(option),
});
visitOptions(option.children || [], nextKeyPath, nextLabelPath);
}
};
visitOptions(step.options || [], nextStepKeyPath, nextStepLabelPath);
collectAgentWorkflowAuditEntries(
step.children || [],
nextStepKeyPath,
nextStepLabelPath,
output
);
}
return output;
}
export function summarizeAgentWorkflowDestructiveChange(
beforeConfig: AgentWorkflowUiConfig,
afterConfig: AgentWorkflowUiConfig
): AgentWorkflowDestructiveChangeSummary {
const beforeEntries = collectAgentWorkflowAuditEntries(beforeConfig.steps || []);
const afterEntries = collectAgentWorkflowAuditEntries(afterConfig.steps || []);
const afterByIdentityPath = new Map(
afterEntries.map((entry) => [entry.identityPath, entry])
);
const removedEntries = beforeEntries.filter(
(entry) => !afterByIdentityPath.has(entry.identityPath)
);
const promptClearedEntries = beforeEntries.filter((entry) => {
const nextEntry = afterByIdentityPath.get(entry.identityPath);
return Boolean(nextEntry && entry.hasPrompt && !nextEntry.hasPrompt);
});
const previewClearedEntries = beforeEntries.filter((entry) => {
const nextEntry = afterByIdentityPath.get(entry.identityPath);
return Boolean(nextEntry && entry.hasPreview && !nextEntry.hasPreview);
});
return {
totalBeforeOptionCount: beforeEntries.length,
totalAfterOptionCount: afterEntries.length,
removedOptionCount: removedEntries.length,
removedEnabledOptionCount: removedEntries.filter((entry) => entry.enabled).length,
promptClearedCount: promptClearedEntries.length,
previewClearedCount: previewClearedEntries.length,
removedOptionSamples: removedEntries
.slice(0, AGENT_WORKFLOW_DESTRUCTIVE_CHANGE_SAMPLE_LIMIT)
.map((entry) => entry.displayPath),
promptClearedSamples: promptClearedEntries
.slice(0, AGENT_WORKFLOW_DESTRUCTIVE_CHANGE_SAMPLE_LIMIT)
.map((entry) => entry.displayPath),
previewClearedSamples: previewClearedEntries
.slice(0, AGENT_WORKFLOW_DESTRUCTIVE_CHANGE_SAMPLE_LIMIT)
.map((entry) => entry.displayPath),
};
}
export function isAgentWorkflowDestructiveChangeSignificant(
summary: AgentWorkflowDestructiveChangeSummary
) {
if (summary.removedEnabledOptionCount >= 10) {
return true;
}
if (summary.removedOptionCount >= 20) {
return true;
}
if (
summary.totalBeforeOptionCount > 0 &&
summary.removedOptionCount / summary.totalBeforeOptionCount >= 0.03
) {
return true;
}
if (summary.promptClearedCount >= 10) {
return true;
}
return summary.previewClearedCount >= 5;
}
export function hasAgentWorkflowOptionOwnPreview(
option: AgentWorkflowOptionUiConfig
): boolean {

View File

@ -27,23 +27,33 @@ const REFERENCE_INPUT_FIELDS = new Set([
"imageUrl",
"imageUrls",
"image_url",
"image_urls",
"styleImages",
"style_images",
"imageStyleRefs",
"image_style_references",
"characterReferenceImages",
"character_reference_images",
"backgroundImage",
"referenceImages",
"reference_images",
"startImage",
"endImage",
"init_image_url",
"references",
"subjects",
"styleReferences",
"style_references",
"elementReferences",
"element_references",
"videoReference",
"video_reference",
"startVideo",
"init_video",
"referenceVideos",
"reference_videos",
"referenceAudios",
"reference_audios",
]);
function hasReferenceInput(fields: string[]) {
@ -68,12 +78,15 @@ function createDefaultParams(
function createImageConfig(fields: string[]) {
return {
maxImages: fields.includes("batchSize") ? 4 : 1,
// Krea removed batch generation; one request now produces one output.
maxImages: 1,
supportsSize: fields.includes("width") && fields.includes("height"),
supports1K: true,
supports2K: true,
supports4K: fields.includes("resolution") || fields.includes("width"),
supportsPromptExtend: fields.includes("skipPromptExpansion"),
supportsPromptExtend:
fields.includes("skipPromptExpansion") ||
fields.includes("skip_prompt_expansion"),
supportsNegativePrompt: fields.includes("negative_prompt"),
supportsWatermark: false,
supportsSeed: fields.includes("seed"),
@ -233,62 +246,65 @@ function videoPreset(
}
export const KREA_MODEL_PRESETS: KreaModelPreset[] = [
imagePreset(10, "/generate/image/bfl/flux-1-dev", "Flux", ["strength", "seed", "steps", "styleImages", "imageStyleRefs", "prompt", "width", "height", "guidance_scale_flux", "imageUrl", "relaxedModeAccess", "styles"], ["prompt"]),
imagePreset(20, "/generate/image/bfl/flux-1-kontext-dev", "Flux Kontext", ["strength", "seed", "steps", "styleImages", "imageStyleRefs", "prompt", "width", "height", "guidance_scale_flux", "imageUrl", "relaxedModeAccess", "styles"], ["prompt"]),
imagePreset(30, "/generate/image/google/nano-banana-pro", "Nano Banana Pro", ["batchSize", "prompt", "width", "height", "imageUrls", "aspectRatio", "styleImages", "resolution"], ["prompt"]),
imagePreset(40, "/generate/image/google/nano-banana-2", "Nano Banana 2", ["batchSize", "prompt", "width", "height", "imageUrls", "aspectRatio", "styleImages", "resolution"], ["prompt"]),
imagePreset(50, "/generate/image/google/nano-banana", "Nano Banana", ["batchSize", "prompt", "width", "height", "imageUrls", "styleImages", "aspectRatio"], ["prompt"]),
imagePreset(60, "/generate/image/bfl/flux-1.1-pro", "Flux 1.1 Pro", ["batchSize", "prompt", "width", "height", "seed"], ["prompt", "width", "height"]),
imagePreset(70, "/generate/image/bfl/flux-1.1-pro-ultra", "Flux 1.1 Pro Ultra", ["batchSize", "prompt", "width", "height", "seed", "raw"], ["prompt"]),
imagePreset(80, "/generate/image/ideogram/ideogram-2-turbo", "Ideogram 2.0A Turbo", ["batchSize", "prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(90, "/generate/image/ideogram/ideogram-3", "Ideogram 3.0", ["batchSize", "prompt", "width", "height", "seed", "styleImages", "characterReferenceImages"], ["prompt"]),
imagePreset(100, "/generate/image/google/imagen-3", "Imagen 3", ["batchSize", "prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(110, "/generate/image/google/imagen-4", "Imagen 4", ["batchSize", "prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(120, "/generate/image/google/imagen-4-fast", "Imagen 4 Fast", ["batchSize", "prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(130, "/generate/image/google/imagen-4-ultra", "Imagen 4 Ultra", ["batchSize", "prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(140, "/generate/image/runway/gen-4-image", "Runway Gen-4", ["batchSize", "prompt", "width", "height", "seed", "referenceImages"], ["prompt", "referenceImages"]),
imagePreset(150, "/generate/image/openai/gpt-image", "ChatGPT Image", ["batchSize", "prompt", "width", "height", "quality", "backgroundImage", "imageUrls", "styleImages", "styles"], ["prompt"]),
imagePreset(160, "/generate/image/bytedance/seedream-3", "Seedream 3", ["batchSize", "prompt", "width", "height", "seed", "model"], ["prompt", "model"]),
imagePreset(170, "/generate/image/bytedance/seedream-4", "Seedream 4", ["batchSize", "prompt", "seed", "width", "height", "styleImages"], ["prompt", "width", "height"]),
imagePreset(180, "/generate/image/bytedance/seedream-5-lite", "Seedream 5 Lite", ["batchSize", "prompt", "seed", "width", "height", "styleImages"], ["prompt", "width", "height"]),
imagePreset(190, "/generate/image/qwen/2512", "Qwen 2512", ["prompt", "negative_prompt", "width", "height", "num_inference_steps", "cfg_scale", "seed", "batchSize", "skipPromptExpansion", "styles"], ["prompt"]),
imagePreset(200, "/generate/image/z-image/z-image", "Z Image", ["prompt", "width", "height", "batchSize", "seed", "imageUrl", "denoising_strength", "styleImages", "skipPromptExpansion", "styles"], ["prompt", "width", "height"]),
imagePreset(210, "/generate/image/bytedance/seededit", "SeedEdit", ["batchSize", "prompt", "seed", "imageUrl"], ["prompt", "imageUrl"]),
enhancePreset(220, "/generate/enhance/topaz/generative-enhance", "Topaz Generative", ["batchSize", "width", "height", "seed", "prompt", "output_format", "subject_detection", "face_enhancement", "face_enhancement_creativity", "face_enhancement_strength", "crop_to_fill", "image_url", "model", "upscaling_activated", "image_scaling_factor", "creativity", "texture", "sharpen", "denoise", "detail"], ["width", "height", "image_url"]),
enhancePreset(230, "/generate/enhance/topaz/standard-enhance", "Topaz", ["batchSize", "width", "height", "seed", "prompt", "output_format", "subject_detection", "face_enhancement", "face_enhancement_creativity", "face_enhancement_strength", "crop_to_fill", "image_url", "model", "upscaling_activated", "image_scaling_factor", "sharpen", "denoise", "fix_compression", "strength"], ["width", "height", "image_url", "model"]),
enhancePreset(240, "/generate/enhance/topaz/bloom-enhance", "Topaz Bloom", ["batchSize", "width", "height", "seed", "prompt", "output_format", "crop_to_fill", "image_url", "model", "creativity", "face_preservation", "color_preservation", "upscaling_activated", "image_scaling_factor"], ["width", "height", "image_url", "model"]),
videoPreset(250, "/generate/video/kling/kling-1", "Kling 1.0", ["startImage", "prompt", "aspectRatio", "duration", "cameraControl", "endImage", "mode"], ["prompt"]),
videoPreset(260, "/generate/video/kling/kling-1.5", "Kling 1.5", ["startImage", "prompt", "aspectRatio", "duration", "cameraControl", "endImage"], ["prompt"]),
videoPreset(270, "/generate/video/kling/kling-1.6", "Kling 1.6", ["startImage", "prompt", "aspectRatio", "duration", "cameraControl", "endImage", "mode"], ["prompt"]),
videoPreset(280, "/generate/video/kling/kling-2", "Kling 2.0", ["startImage", "prompt", "aspectRatio", "endImage", "mode"], ["prompt"]),
videoPreset(290, "/generate/video/kling/kling-2.1", "Kling 2.1", ["startImage", "prompt", "aspectRatio", "duration", "endImage", "mode"], ["prompt"]),
videoPreset(300, "/generate/video/kling/kling-2.5", "Kling 2.5", ["startImage", "prompt", "aspectRatio", "duration", "endImage"], ["prompt"]),
videoPreset(310, "/generate/video/kling/kling-2.6", "Kling 2.6", ["startImage", "prompt", "aspectRatio", "duration", "endImage", "generateAudio"], ["prompt"]),
videoPreset(320, "/generate/video/kling/kling-o1", "Kling o1", ["startImage", "prompt", "aspectRatio", "duration", "endImage", "styleReferences", "elementReferences", "videoReference"], ["prompt"]),
videoPreset(330, "/generate/video/minimax/hailuo", "Hailuo", ["startImage", "prompt", "expandPrompt", "cameraControl", "model", "subjects"], ["prompt"]),
videoPreset(340, "/generate/video/minimax/hailuo-02", "Hailuo 02", ["startImage", "prompt", "expandPrompt", "endImage", "resolution", "duration"], ["prompt"]),
videoPreset(350, "/generate/video/minimax/hailuo-2.3", "Hailuo 2.3", ["startImage", "prompt", "expandPrompt", "endImage", "resolution", "duration"], ["prompt"]),
videoPreset(360, "/generate/video/minimax/hailuo-2.3-fast", "Hailuo 2.3 Fast", ["startImage", "prompt", "expandPrompt", "endImage", "resolution", "duration"], ["prompt"]),
videoPreset(370, "/generate/video/alibaba/wan-2.1", "Wan 2.1", ["startImage", "prompt", "width", "height", "endImage", "aspectRatio", "frame_num", "styles"], ["prompt", "width", "height"]),
videoPreset(380, "/generate/video/alibaba/wan-2.2", "Wan 2.2", ["startImage", "guide_scale", "prompt", "width", "height", "aspectRatio", "frame_num", "styles"], ["prompt", "width", "height"]),
videoPreset(390, "/generate/video/alibaba/wan-2.5", "Wan 2.5", ["startImage", "prompt", "aspectRatio", "resolution", "enablePromptExpansion", "duration", "seed", "audio_url", "generateAudio"], ["prompt"]),
videoPreset(400, "/generate/video/google/veo-2", "Veo 2", ["startImage", "prompt", "aspectRatio", "duration", "image_url"], ["prompt"]),
videoPreset(410, "/generate/video/google/veo-3", "Veo 3", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution"], ["prompt"]),
videoPreset(420, "/generate/video/google/veo-3-fast", "Veo 3 Fast", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution"], ["prompt"]),
videoPreset(430, "/generate/video/google/veo-3.1", "Veo 3.1", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution", "endImage", "referenceImages"], ["prompt"]),
videoPreset(440, "/generate/video/google/veo-3.1-fast", "Veo 3.1 Fast", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution", "endImage", "referenceImages"], ["prompt"]),
videoPreset(450, "/generate/video/google/veo-3.1-lite", "Veo 3.1 Lite", ["startImage", "prompt", "aspectRatio", "duration", "resolution", "endImage"], ["prompt"]),
videoPreset(460, "/generate/video/bytedance/seedance-1.0-lite", "Seedance Lite", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "duration", "resolution"], ["prompt"]),
videoPreset(470, "/generate/video/bytedance/seedance-1.0-pro", "Seedance Pro", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "duration", "resolution"], ["prompt"]),
videoPreset(480, "/generate/video/bytedance/seedance-1.0-pro-fast", "Seedance Pro Fast", ["startImage", "prompt", "aspectRatio", "duration", "resolution"], ["prompt"]),
videoPreset(490, "/generate/video/bytedance/seedance-2", "Seedance 2.0", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "referenceVideos", "referenceAudios", "duration", "generateAudio", "resolution", "seed"], ["prompt"]),
videoPreset(500, "/generate/video/bytedance/seedance-2-fast", "Seedance 2.0 Fast", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "referenceVideos", "referenceAudios", "duration", "generateAudio", "resolution", "seed"], ["prompt"]),
videoPreset(510, "/generate/video/openai/sora-2", "Sora 2", ["startImage", "prompt", "duration", "aspectRatio"], ["prompt"]),
videoPreset(520, "/generate/video/luma/ray-2", "Ray 2", ["startImage", "prompt", "aspectRatio", "loop", "width", "height"], ["prompt", "width", "height"]),
videoPreset(530, "/generate/video/runway/gen-3", "Runway Gen-3", ["startImage", "prompt", "aspectRatio", "duration", "seed"], ["prompt"]),
videoPreset(540, "/generate/video/runway/gen-4-video", "Runway Gen-4", ["startImage", "prompt", "aspectRatio", "duration", "seed"], ["prompt"]),
videoPreset(550, "/generate/video/runway/gen-4.5", "Runway Gen-4.5", ["startImage", "prompt", "aspectRatio", "duration", "seed"], ["prompt"]),
videoPreset(560, "/generate/video/runway/aleph", "Runway Aleph", ["startImage", "startVideo", "startVideoTrim", "endVideoTrim", "prompt", "init_video", "init_image_url", "references", "ratio", "seed", "contentModeration"], ["prompt", "init_video"]),
imagePreset(10, "/generate/image/krea/krea-2/medium", "Krea 2 Medium", ["prompt", "seed", "styles", "image_style_references", "aspect_ratio", "resolution", "creativity", "moodboards"], ["prompt", "aspect_ratio", "resolution"]),
imagePreset(20, "/generate/image/krea/krea-2/large", "Krea 2 Large", ["prompt", "seed", "styles", "image_style_references", "aspect_ratio", "resolution", "creativity", "moodboards"], ["prompt", "aspect_ratio", "resolution"]),
imagePreset(30, "/generate/image/bfl/flux-1-dev", "Flux", ["prompt", "seed", "steps", "width", "height", "image_url", "styles", "strength", "guidance_scale", "style_images", "image_style_references"], ["prompt"]),
imagePreset(40, "/generate/image/bfl/flux-1-kontext-dev", "Flux Kontext", ["prompt", "seed", "steps", "width", "height", "image_url", "strength", "guidance_scale", "style_images"], ["prompt"]),
imagePreset(50, "/generate/image/google/nano-banana-pro", "Nano Banana Pro", ["prompt", "width", "height", "image_urls", "aspect_ratio", "resolution", "style_images"], ["prompt"]),
imagePreset(60, "/generate/image/google/nano-banana-2", "Nano Banana 2", ["prompt", "width", "height", "image_urls", "aspect_ratio", "resolution", "style_images"], ["prompt"]),
imagePreset(70, "/generate/image/google/nano-banana", "Nano Banana", ["prompt", "width", "height", "image_urls", "aspect_ratio", "style_images"], ["prompt"]),
imagePreset(80, "/generate/image/bfl/flux-1.1-pro", "Flux 1.1 Pro", ["prompt", "width", "height", "seed"], ["prompt", "width", "height"]),
imagePreset(90, "/generate/image/bfl/flux-1.1-pro-ultra", "Flux 1.1 Pro Ultra", ["prompt", "width", "height", "seed", "raw"], ["prompt"]),
imagePreset(100, "/generate/image/ideogram/ideogram-2-turbo", "Ideogram 2.0A Turbo", ["prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(110, "/generate/image/ideogram/ideogram-3", "Ideogram 3.0", ["prompt", "width", "height", "seed", "character_reference_images", "style_images"], ["prompt"]),
imagePreset(120, "/generate/image/google/imagen-3", "Imagen 3", ["prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(130, "/generate/image/google/imagen-4", "Imagen 4", ["prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(140, "/generate/image/google/imagen-4-fast", "Imagen 4 Fast", ["prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(150, "/generate/image/google/imagen-4-ultra", "Imagen 4 Ultra", ["prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(160, "/generate/image/runway/gen-4-image", "Runway Gen-4", ["prompt", "width", "height", "seed", "reference_images"], ["prompt", "reference_images"]),
imagePreset(170, "/generate/image/openai/gpt-image", "ChatGPT Image", ["prompt", "width", "height", "quality", "image_urls", "styles", "style_images"], ["prompt"]),
imagePreset(180, "/generate/image/openai/gpt-image-2", "ChatGPT Image 2", ["prompt", "quality", "image_urls", "width", "height", "aspect_ratio", "resolution", "style_images"], ["prompt"]),
imagePreset(190, "/generate/image/luma/uni-1", "Luma Uni 1", ["prompt", "width", "height", "mode", "style", "output_format", "web_search", "style_images"], ["prompt"]),
imagePreset(200, "/generate/image/bytedance/seedream-4", "Seedream 4", ["prompt", "width", "height", "seed", "style_images"], ["prompt", "width", "height"]),
imagePreset(210, "/generate/image/bytedance/seedream-5-lite", "Seedream 5 Lite", ["prompt", "width", "height", "seed", "style_images"], ["prompt", "width", "height"]),
imagePreset(220, "/generate/image/qwen/2512", "Qwen 2512", ["prompt", "negative_prompt", "width", "height", "num_inference_steps", "cfg_scale", "seed", "skip_prompt_expansion", "styles"], ["prompt"]),
imagePreset(230, "/generate/image/z-image/z-image", "Z Image", ["prompt", "seed", "denoising_strength", "styles", "skip_prompt_expansion", "aspect_ratio", "resolution", "image_url", "style_images"], ["prompt", "aspect_ratio", "resolution"]),
imagePreset(240, "/generate/image/bytedance/seededit", "SeedEdit", ["prompt", "seed", "image_url"], ["prompt", "image_url"]),
enhancePreset(250, "/generate/enhance/topaz/generative-enhance", "Topaz Generative", ["prompt", "width", "height", "seed", "image_url", "model", "output_format", "subject_detection", "face_enhancement", "face_enhancement_creativity", "face_enhancement_strength", "crop_to_fill", "upscaling_activated", "image_scaling_factor", "creativity", "texture", "sharpen", "denoise", "detail"], ["width", "height", "image_url"]),
enhancePreset(260, "/generate/enhance/topaz/standard-enhance", "Topaz", ["prompt", "width", "height", "seed", "image_url", "model", "output_format", "subject_detection", "face_enhancement", "face_enhancement_creativity", "face_enhancement_strength", "crop_to_fill", "upscaling_activated", "image_scaling_factor", "sharpen", "denoise", "fix_compression", "strength"], ["width", "height", "image_url", "model"]),
enhancePreset(270, "/generate/enhance/topaz/bloom-enhance", "Topaz Bloom", ["prompt", "width", "height", "seed", "image_url", "output_format", "crop_to_fill", "creativity", "face_preservation", "color_preservation", "upscaling_activated", "image_scaling_factor"], ["width", "height", "image_url"]),
videoPreset(280, "/generate/video/kling/kling-1", "Kling 1.0", ["startImage", "prompt", "aspectRatio", "duration", "cameraControl", "endImage", "mode"], ["prompt"]),
videoPreset(290, "/generate/video/kling/kling-1.5", "Kling 1.5", ["startImage", "prompt", "aspectRatio", "duration", "cameraControl", "endImage"], ["prompt"]),
videoPreset(300, "/generate/video/kling/kling-1.6", "Kling 1.6", ["startImage", "prompt", "aspectRatio", "duration", "cameraControl", "endImage", "mode"], ["prompt"]),
videoPreset(310, "/generate/video/kling/kling-2", "Kling 2.0", ["startImage", "prompt", "aspectRatio", "endImage", "mode"], ["prompt"]),
videoPreset(320, "/generate/video/kling/kling-2.1", "Kling 2.1", ["startImage", "prompt", "aspectRatio", "duration", "endImage", "mode"], ["prompt"]),
videoPreset(330, "/generate/video/kling/kling-2.5", "Kling 2.5", ["startImage", "prompt", "aspectRatio", "duration", "endImage"], ["prompt"]),
videoPreset(340, "/generate/video/kling/kling-2.6", "Kling 2.6", ["startImage", "prompt", "aspectRatio", "duration", "endImage", "generateAudio"], ["prompt"]),
videoPreset(350, "/generate/video/kling/kling-o1", "Kling o1", ["startImage", "prompt", "aspectRatio", "duration", "endImage", "styleReferences", "elementReferences", "videoReference"], ["prompt"]),
videoPreset(360, "/generate/video/minimax/hailuo", "Hailuo", ["startImage", "prompt", "expandPrompt", "cameraControl", "model", "subjects"], ["prompt"]),
videoPreset(370, "/generate/video/minimax/hailuo-02", "Hailuo 02", ["startImage", "prompt", "expandPrompt", "endImage", "resolution", "duration"], ["prompt"]),
videoPreset(380, "/generate/video/minimax/hailuo-2.3", "Hailuo 2.3", ["startImage", "prompt", "expandPrompt", "endImage", "resolution", "duration"], ["prompt"]),
videoPreset(390, "/generate/video/minimax/hailuo-2.3-fast", "Hailuo 2.3 Fast", ["startImage", "prompt", "expandPrompt", "endImage", "resolution", "duration"], ["prompt"]),
videoPreset(400, "/generate/video/alibaba/wan-2.1", "Wan 2.1", ["startImage", "prompt", "width", "height", "endImage", "aspectRatio", "frame_num", "styles"], ["prompt", "width", "height"]),
videoPreset(410, "/generate/video/alibaba/wan-2.2", "Wan 2.2", ["startImage", "guide_scale", "prompt", "width", "height", "aspectRatio", "frame_num", "styles"], ["prompt", "width", "height"]),
videoPreset(420, "/generate/video/alibaba/wan-2.5", "Wan 2.5", ["startImage", "prompt", "aspectRatio", "resolution", "enablePromptExpansion", "duration", "seed", "audio_url", "generateAudio"], ["prompt"]),
videoPreset(430, "/generate/video/google/veo-2", "Veo 2", ["startImage", "prompt", "aspectRatio", "duration", "image_url"], ["prompt"]),
videoPreset(440, "/generate/video/google/veo-3", "Veo 3", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution"], ["prompt"]),
videoPreset(450, "/generate/video/google/veo-3-fast", "Veo 3 Fast", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution"], ["prompt"]),
videoPreset(460, "/generate/video/google/veo-3.1", "Veo 3.1", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution", "endImage", "referenceImages"], ["prompt"]),
videoPreset(470, "/generate/video/google/veo-3.1-fast", "Veo 3.1 Fast", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution", "endImage", "referenceImages"], ["prompt"]),
videoPreset(480, "/generate/video/google/veo-3.1-lite", "Veo 3.1 Lite", ["startImage", "prompt", "aspectRatio", "duration", "resolution", "endImage"], ["prompt"]),
videoPreset(490, "/generate/video/bytedance/seedance-1.0-lite", "Seedance Lite", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "duration", "resolution"], ["prompt"]),
videoPreset(500, "/generate/video/bytedance/seedance-1.0-pro", "Seedance Pro", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "duration", "resolution"], ["prompt"]),
videoPreset(510, "/generate/video/bytedance/seedance-1.0-pro-fast", "Seedance Pro Fast", ["startImage", "prompt", "aspectRatio", "duration", "resolution"], ["prompt"]),
videoPreset(520, "/generate/video/bytedance/seedance-2", "Seedance 2.0", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "referenceVideos", "referenceAudios", "duration", "generateAudio", "resolution", "seed"], ["prompt"]),
videoPreset(530, "/generate/video/bytedance/seedance-2-fast", "Seedance 2.0 Fast", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "referenceVideos", "referenceAudios", "duration", "generateAudio", "resolution", "seed"], ["prompt"]),
videoPreset(540, "/generate/video/openai/sora-2", "Sora 2", ["startImage", "prompt", "duration", "aspectRatio"], ["prompt"]),
videoPreset(550, "/generate/video/luma/ray-2", "Ray 2", ["startImage", "prompt", "aspectRatio", "loop", "width", "height"], ["prompt", "width", "height"]),
videoPreset(560, "/generate/video/runway/gen-3", "Runway Gen-3", ["startImage", "prompt", "aspectRatio", "duration", "seed"], ["prompt"]),
videoPreset(570, "/generate/video/runway/gen-4-video", "Runway Gen-4", ["startImage", "prompt", "aspectRatio", "duration", "seed"], ["prompt"]),
videoPreset(580, "/generate/video/runway/gen-4.5", "Runway Gen-4.5", ["startImage", "prompt", "aspectRatio", "duration", "seed"], ["prompt"]),
videoPreset(590, "/generate/video/runway/aleph", "Runway Aleph", ["startImage", "startVideo", "startVideoTrim", "endVideoTrim", "prompt", "init_video", "init_image_url", "references", "ratio", "seed", "contentModeration"], ["prompt", "init_video"]),
];
export function getKreaModelPresetById(modelId: string) {

View File

@ -318,11 +318,6 @@ function normalizeSeed(seed?: number) {
return clamp(Math.round(Number(seed)), 0, 4294967295);
}
function normalizeBatchSize(value?: number) {
if (!Number.isFinite(Number(value))) return 1;
return clamp(Math.round(Number(value)), 1, 4);
}
function normalizeVideoDuration(duration: unknown, endpoint: string) {
const value = Number.isFinite(Number(duration)) ? Math.round(Number(duration)) : 5;
if (endpoint.includes("/bytedance/seedance-2")) {
@ -387,9 +382,11 @@ function buildTaggedReferences(imageUrls: string[], tagPrefix = "ref", max = 3)
const KREA_SOURCE_IMAGE_FIELDS = new Set([
"imageUrls",
"image_urls",
"imageUrl",
"image_url",
"referenceImages",
"reference_images",
"init_image_url",
"references",
]);
@ -401,6 +398,19 @@ function hasKreaSourceImageField(fields: Iterable<string>) {
return false;
}
function hasAnyKreaField(fields: Set<string>, aliases: string[]) {
return aliases.some((field) => fields.has(field));
}
function getPreferredKreaField(fields: Set<string>, aliases: string[]) {
for (const field of aliases) {
if (fields.has(field)) {
return field;
}
}
return null;
}
function buildReferencePreservingPrompt(params: {
endpoint: string;
kind: KreaModelKind;
@ -449,19 +459,26 @@ function summarizeKreaReferenceFields(body: Record<string, any>) {
for (const field of [
"imageUrls",
"image_urls",
"imageUrl",
"image_url",
"styleImages",
"style_images",
"imageStyleRefs",
"image_style_references",
"characterReferenceImages",
"character_reference_images",
"referenceImages",
"reference_images",
"startImage",
"endImage",
"init_image_url",
"references",
"subjects",
"styleReferences",
"style_references",
"elementReferences",
"element_references",
]) {
const value = body[field];
if (Array.isArray(value)) {
@ -841,6 +858,7 @@ function buildKreaBody(params: {
const required = new Set(requiredFields);
const body: Record<string, any> = {};
const isVideo = kind === "video";
const isImageLike = !isVideo;
const videoMode =
isVideo
? videoParams?.mode ||
@ -864,7 +882,6 @@ function buildKreaBody(params: {
const videoDimensions = resolveVideoDimensions(ratio, videoResolution);
if (fields.has("prompt")) body.prompt = prompt;
if (fields.has("batchSize")) body.batchSize = normalizeBatchSize(imageParams?.imageCount);
if (
fields.has("width") &&
@ -878,13 +895,20 @@ function buildKreaBody(params: {
) {
body.height = isVideo ? videoDimensions.height : imageDimensions.height;
}
if (fields.has("aspectRatio") && shouldInferImageGeometryFromReference && inferredReferenceAspectRatio) {
body.aspectRatio = inferredReferenceAspectRatio;
const aspectRatioField = getPreferredKreaField(
fields,
isImageLike ? ["aspect_ratio", "aspectRatio"] : ["aspectRatio", "aspect_ratio"]
);
if (aspectRatioField && shouldInferImageGeometryFromReference && inferredReferenceAspectRatio) {
body[aspectRatioField] = inferredReferenceAspectRatio;
} else if (
fields.has("aspectRatio") &&
(isVideo || !shouldInferImageGeometryFromReference || required.has("aspectRatio"))
aspectRatioField &&
(isVideo ||
!shouldInferImageGeometryFromReference ||
required.has("aspectRatio") ||
required.has("aspect_ratio"))
) {
body.aspectRatio = ratio === "auto" ? "1:1" : ratio;
body[aspectRatioField] = ratio === "auto" ? "1:1" : ratio;
}
if (fields.has("ratio")) body.ratio = ratio === "auto" ? "16:9" : ratio;
if (fields.has("resolution")) {
@ -896,8 +920,12 @@ function buildKreaBody(params: {
if (fields.has("negative_prompt") && imageParams?.negativePrompt) {
body.negative_prompt = imageParams.negativePrompt;
}
if (fields.has("skipPromptExpansion") && typeof imageParams?.promptExtend === "boolean") {
body.skipPromptExpansion = !imageParams.promptExtend;
const skipPromptExpansionField = getPreferredKreaField(fields, [
"skip_prompt_expansion",
"skipPromptExpansion",
]);
if (skipPromptExpansionField && typeof imageParams?.promptExtend === "boolean") {
body[skipPromptExpansionField] = !imageParams.promptExtend;
}
if (fields.has("enablePromptExpansion")) {
body.enablePromptExpansion = true;
@ -908,8 +936,12 @@ function buildKreaBody(params: {
if (fields.has("duration")) {
body.duration = normalizeVideoDuration(videoParams?.duration, endpoint);
}
if (fields.has("generateAudio") && typeof videoParams?.generateAudio === "boolean") {
body.generateAudio = videoParams.generateAudio;
const generateAudioField = getPreferredKreaField(fields, [
"generate_audio",
"generateAudio",
]);
if (generateAudioField && typeof videoParams?.generateAudio === "boolean") {
body[generateAudioField] = videoParams.generateAudio;
}
if (fields.has("loop")) {
body.loop = false;
@ -924,31 +956,65 @@ function buildKreaBody(params: {
if (fields.has("output_format")) body.output_format = "jpg";
if (fields.has("backgroundImage")) body.backgroundImage = "auto";
if (fields.has("imageUrls") && imageUrls.length > 0) {
body.imageUrls = imageUrls.slice(0, 8);
}
if (fields.has("imageUrl") && imageUrls[0]) {
body.imageUrl = imageUrls[0];
}
if (fields.has("image_url") && imageUrls[0]) {
body.image_url = imageUrls[0];
}
if (fields.has("styleImages") && imageUrls.length > 0 && !body.imageUrls && !body.imageUrl) {
body.styleImages = buildStyleImages(imageUrls.slice(0, 4));
}
if (fields.has("imageStyleRefs") && imageUrls.length > 1) {
body.imageStyleRefs = buildStyleImages(imageUrls.slice(1, 5));
}
if (fields.has("characterReferenceImages") && imageUrls.length > 0) {
body.characterReferenceImages = imageUrls.slice(0, 4);
const imageUrlsField = getPreferredKreaField(
fields,
isImageLike ? ["image_urls", "imageUrls"] : ["imageUrls", "image_urls"]
);
if (imageUrlsField && imageUrls.length > 0) {
body[imageUrlsField] = imageUrls.slice(0, 8);
}
const imageUrlField = getPreferredKreaField(
fields,
isImageLike ? ["image_url", "imageUrl"] : ["imageUrl", "image_url"]
);
if (imageUrlField && imageUrls[0]) {
body[imageUrlField] = imageUrls[0];
}
const styleImagesField = getPreferredKreaField(
fields,
isImageLike ? ["style_images", "styleImages"] : ["styleImages", "style_images"]
);
if (
fields.has("referenceImages") &&
styleImagesField &&
imageUrls.length > 0 &&
!hasAnyKreaField(new Set(Object.keys(body)), [
"image_urls",
"imageUrls",
"image_url",
"imageUrl",
])
) {
body[styleImagesField] = buildStyleImages(imageUrls.slice(0, 4));
}
const imageStyleReferencesField = getPreferredKreaField(fields, [
"image_style_references",
"imageStyleRefs",
]);
if (imageStyleReferencesField && imageUrls.length > 1) {
body[imageStyleReferencesField] = buildStyleImages(imageUrls.slice(1, 5));
}
const characterReferenceImagesField = getPreferredKreaField(fields, [
"character_reference_images",
"characterReferenceImages",
]);
if (characterReferenceImagesField && imageUrls.length > 0) {
body[characterReferenceImagesField] = imageUrls.slice(0, 4);
}
const referenceImagesField = getPreferredKreaField(fields, [
"reference_images",
"referenceImages",
]);
if (
referenceImagesField &&
imageUrls.length > 0 &&
(!isVideo || videoMode === "reference-images")
) {
body.referenceImages = endpoint.includes("/image/runway/gen-4-image")
body[referenceImagesField] = endpoint.includes("/image/runway/gen-4-image")
? buildTaggedReferences(imageUrls, "ref", 3)
: imageUrls.slice(0, endpoint.includes("/seedance-2") ? 9 : 4);
}
@ -970,27 +1036,50 @@ function buildKreaBody(params: {
if (fields.has("subjects") && imageUrls[0] && videoParams?.mode === "reference-images") {
body.subjects = [{ type: "character", image: [imageUrls[0]] }];
}
if (fields.has("styleReferences") && imageUrls.length > 0) {
body.styleReferences = buildTaggedReferences(imageUrls, "style", 3);
const styleReferencesField = getPreferredKreaField(fields, [
"style_references",
"styleReferences",
]);
if (styleReferencesField && imageUrls.length > 0) {
body[styleReferencesField] = buildTaggedReferences(imageUrls, "style", 3);
}
if (fields.has("elementReferences") && imageUrls.length > 0) {
body.elementReferences = [
const elementReferencesField = getPreferredKreaField(fields, [
"element_references",
"elementReferences",
]);
if (elementReferencesField && imageUrls.length > 0) {
body[elementReferencesField] = [
{
tag: "element_1",
images: imageUrls.slice(0, 3),
},
];
}
if (fields.has("videoReference") && videoUrls[0]) {
body.videoReference = { url: videoUrls[0], tag: "video_1" };
const videoReferenceField = getPreferredKreaField(fields, [
"video_reference",
"videoReference",
]);
if (videoReferenceField && videoUrls[0]) {
body[videoReferenceField] = { url: videoUrls[0], tag: "video_1" };
}
if (fields.has("startVideo") && videoUrls[0]) body.startVideo = videoUrls[0];
if (fields.has("init_video") && videoUrls[0]) body.init_video = videoUrls[0];
if (fields.has("referenceVideos") && videoUrls.length > 0) {
body.referenceVideos = videoUrls.slice(0, 3);
const referenceVideosField = getPreferredKreaField(fields, [
"reference_videos",
"referenceVideos",
]);
if (referenceVideosField && videoUrls.length > 0) {
body[referenceVideosField] = videoUrls.slice(0, 3);
}
if (required.has("referenceImages") && !body.referenceImages) {
if (
(required.has("referenceImages") || required.has("reference_images")) &&
!body.referenceImages &&
!body.reference_images
) {
throw new Error("该 Krea 模型需要至少 1 张参考图");
}
if ((required.has("imageUrl") || required.has("image_url")) && !body.imageUrl && !body.image_url) {
@ -1038,7 +1127,7 @@ export function createKrea({ apiKey, baseURL }: KreaConfig) {
preset?.requestFields ||
(kind === "video"
? ["prompt", "aspectRatio", "duration", "startImage", "endImage", "referenceImages"]
: ["prompt", "width", "height", "imageUrls", "imageUrl", "batchSize"]);
: ["prompt", "width", "height", "image_urls", "image_url"]);
const requiredFields = preset?.requiredFields || ["prompt"];
const requestedImageAspectRatio = normalizeText(options.imageParams?.aspectRatio).toLowerCase();
const referenceAspectRatio =

View File

@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS "SystemSettingSnapshot" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"scope" varchar(64) NOT NULL,
"label" varchar(128),
"summary" jsonb,
"data" jsonb NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"createdBy" uuid
);
DO $$
BEGIN
ALTER TABLE "SystemSettingSnapshot"
ADD CONSTRAINT "SystemSettingSnapshot_createdBy_User_id_fk"
FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id")
ON DELETE NO ACTION
ON UPDATE NO ACTION;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS "SystemSettingSnapshot_scope_createdAt_idx"
ON "SystemSettingSnapshot" ("scope", "createdAt" DESC);

View File

@ -498,6 +498,13 @@
"when": 1779797137842,
"tag": "0084_optimize_featured_home_query",
"breakpoints": true
},
{
"idx": 71,
"version": "7",
"when": 1780075200000,
"tag": "0085_add_system_setting_snapshots",
"breakpoints": true
}
]
}

View File

@ -260,6 +260,18 @@ export const systemSettingHistory = pgTable("SystemSettingHistory", {
export type SystemSettingHistory = InferSelectModel<typeof systemSettingHistory>;
export const systemSettingSnapshot = pgTable("SystemSettingSnapshot", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
scope: varchar("scope", { length: 64 }).notNull(),
label: varchar("label", { length: 128 }),
summary: jsonb("summary"),
data: jsonb("data").notNull(),
createdAt: timestamp("createdAt").notNull().defaultNow(),
createdBy: uuid("createdBy").references(() => user.id),
});
export type SystemSettingSnapshot = InferSelectModel<typeof systemSettingSnapshot>;
export const dingTalkNotificationTask = pgTable("DingTalkNotificationTask", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
title: varchar("title", { length: 100 }).notNull(),

View File

@ -0,0 +1,132 @@
import {
AGENT_PRESET_LIBRARY_CONFIG_KEY,
} from "@/lib/agents/preset-library-config";
import {
AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY,
} from "@/lib/agents/psd-template-secondary-category-config";
import {
AGENT_PSD_TEXT_COLOR_CONFIG_KEY,
} from "@/lib/agents/psd-text-color-config";
import {
AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY,
} from "@/lib/agents/workflow-brand-model-config";
import {
AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY,
} from "@/lib/agents/workflow-goods-info-config";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
} from "@/lib/agents/workflow-material-config";
import {
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
} from "@/lib/agents/workflow-showcase-config";
import {
AGENT_WORKFLOW_UI_CONFIG_KEY,
} from "@/lib/agents/workflow-ui-config";
export const AGENT_WORKFLOW_SNAPSHOT_SCOPE = "agent-workflow";
export const SYSTEM_SETTING_SNAPSHOT_SCOPES = {
[AGENT_WORKFLOW_SNAPSHOT_SCOPE]: [
AGENT_PRESET_LIBRARY_CONFIG_KEY,
AGENT_WORKFLOW_UI_CONFIG_KEY,
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY,
AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY,
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
AGENT_PSD_TEXT_COLOR_CONFIG_KEY,
AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY,
],
} as const;
export const SYSTEM_SETTING_SNAPSHOT_SITE_FIELDS = {
[AGENT_WORKFLOW_SNAPSHOT_SCOPE]: ["agentWorkflowDefaultModelId"],
} as const;
export type SystemSettingSnapshotScope = keyof typeof SYSTEM_SETTING_SNAPSHOT_SCOPES;
export function getSystemSettingSnapshotScopeKeys(
scope: SystemSettingSnapshotScope
) {
return SYSTEM_SETTING_SNAPSHOT_SCOPES[scope];
}
export function getSystemSettingSnapshotScopeSiteFields(
scope: SystemSettingSnapshotScope
) {
return SYSTEM_SETTING_SNAPSHOT_SITE_FIELDS[scope];
}
export function createSystemSettingSnapshotData(
scope: SystemSettingSnapshotScope,
valueByKey: ReadonlyMap<string, unknown>,
siteSettingsValues: Record<string, unknown> = {}
) {
const keys = getSystemSettingSnapshotScopeKeys(scope);
const siteFields = getSystemSettingSnapshotScopeSiteFields(scope);
return {
version: 2,
scope,
systemSettings: Object.fromEntries(keys.map((key) => [key, valueByKey.get(key) ?? null])),
siteSettings: Object.fromEntries(
siteFields.map((field) => [field, siteSettingsValues[field] ?? null])
),
};
}
export function readSystemSettingSnapshotData(
scope: SystemSettingSnapshotScope,
rawData: unknown
) {
const normalized =
rawData && typeof rawData === "object" && !Array.isArray(rawData)
? (rawData as Record<string, unknown>)
: {};
if (
normalized.systemSettings &&
typeof normalized.systemSettings === "object" &&
!Array.isArray(normalized.systemSettings)
) {
return {
systemSettings: normalized.systemSettings as Record<string, unknown>,
siteSettings:
normalized.siteSettings &&
typeof normalized.siteSettings === "object" &&
!Array.isArray(normalized.siteSettings)
? (normalized.siteSettings as Record<string, unknown>)
: {},
};
}
const keys = new Set<string>(getSystemSettingSnapshotScopeKeys(scope));
return {
systemSettings: Object.fromEntries(
Object.entries(normalized).filter(([key]) => keys.has(key))
),
siteSettings: {},
};
}
export function summarizeSystemSettingSnapshotData(
data: Record<string, unknown>
) {
const systemEntries =
data.systemSettings &&
typeof data.systemSettings === "object" &&
!Array.isArray(data.systemSettings)
? Object.entries(data.systemSettings as Record<string, unknown>)
: Object.entries(data);
const siteEntries =
data.siteSettings &&
typeof data.siteSettings === "object" &&
!Array.isArray(data.siteSettings)
? Object.entries(data.siteSettings as Record<string, unknown>)
: [];
const entries = [...systemEntries, ...siteEntries];
return {
keyCount: entries.length,
nonNullKeyCount: entries.filter(([, value]) => value !== null && value !== undefined).length,
byteLength: Buffer.byteLength(JSON.stringify(data), "utf8"),
siteFieldCount: siteEntries.length,
};
}

View File

@ -43,6 +43,8 @@
"db:pull": "drizzle-kit pull",
"db:check": "drizzle-kit check",
"db:up": "drizzle-kit up",
"db:snapshot:system-settings": "pnpm exec tsx scripts/system-settings/create-snapshot.ts",
"db:restore:system-settings-snapshot": "pnpm exec tsx scripts/system-settings/restore-snapshot.ts",
"poc:nanobanana": "pnpm exec tsx scripts/nanobanana-browser-poc.ts",
"test": "export PLAYWRIGHT=True && pnpm exec playwright test",
"storage:configure-upload-cors": "pnpm exec tsx scripts/storage/configure-direct-upload-cors.ts",

View File

@ -0,0 +1,93 @@
import { config as loadEnv } from "dotenv";
import { inArray } from "drizzle-orm";
loadEnv({ path: ".env.local" });
function readArg(name: string) {
const prefix = `${name}=`;
const match = process.argv.find((arg) => arg.startsWith(prefix));
return match ? match.slice(prefix.length) : null;
}
async function main() {
const scopeArg = readArg("--scope");
const label = readArg("--label");
if (!scopeArg) {
throw new Error("Usage: pnpm tsx scripts/system-settings/create-snapshot.ts --scope=<scope> [--label=<label>]");
}
const dbModule = await import("../../lib/db");
const schemaModule = await import("../../lib/db/schema");
const snapshotModule = await import("../../lib/system-settings/snapshot");
const { db } = dbModule;
const { siteSettings, systemSettingSnapshot, systemSettings } = schemaModule;
const {
createSystemSettingSnapshotData,
getSystemSettingSnapshotScopeKeys,
summarizeSystemSettingSnapshotData,
} = snapshotModule;
const keys = getSystemSettingSnapshotScopeKeys(
scopeArg as keyof typeof snapshotModule.SYSTEM_SETTING_SNAPSHOT_SCOPES
);
const rows = await db
.select({
key: systemSettings.key,
value: systemSettings.value,
})
.from(systemSettings)
.where(inArray(systemSettings.key, [...keys]));
const valueByKey = new Map(rows.map((row) => [row.key, row.value]));
const [currentSiteSettings] = await db
.select({
agentWorkflowDefaultModelId: siteSettings.agentWorkflowDefaultModelId,
})
.from(siteSettings)
.limit(1);
const snapshotData = createSystemSettingSnapshotData(
scopeArg as keyof typeof snapshotModule.SYSTEM_SETTING_SNAPSHOT_SCOPES,
valueByKey,
{
agentWorkflowDefaultModelId:
String(currentSiteSettings?.agentWorkflowDefaultModelId || "").trim() || null,
}
);
const [snapshot] = await db
.insert(systemSettingSnapshot)
.values({
scope: scopeArg,
label: label || `manual-${scopeArg}-snapshot`,
summary: summarizeSystemSettingSnapshotData(snapshotData),
data: snapshotData,
createdAt: new Date(),
createdBy: null,
})
.returning({
id: systemSettingSnapshot.id,
scope: systemSettingSnapshot.scope,
label: systemSettingSnapshot.label,
createdAt: systemSettingSnapshot.createdAt,
});
console.log(
JSON.stringify(
{
snapshotId: snapshot?.id ?? null,
scope: snapshot?.scope ?? scopeArg,
label: snapshot?.label ?? label ?? null,
createdAt: snapshot?.createdAt ?? null,
keyCount: keys.length,
},
null,
2
)
);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@ -0,0 +1,130 @@
import { config as loadEnv } from "dotenv";
import { eq } from "drizzle-orm";
loadEnv({ path: ".env.local" });
function readArg(name: string) {
const prefix = `${name}=`;
const match = process.argv.find((arg) => arg.startsWith(prefix));
return match ? match.slice(prefix.length) : null;
}
async function main() {
const snapshotId = readArg("--snapshot-id");
const reason = readArg("--reason") || "manual-snapshot-restore";
if (!snapshotId) {
throw new Error(
"Usage: pnpm tsx scripts/system-settings/restore-snapshot.ts --snapshot-id=<id> [--reason=<reason>]"
);
}
const dbModule = await import("../../lib/db");
const schemaModule = await import("../../lib/db/schema");
const snapshotModule = await import("../../lib/system-settings/snapshot");
const { db } = dbModule;
const { siteSettings, systemSettingHistory, systemSettingSnapshot, systemSettings } =
schemaModule;
const { readSystemSettingSnapshotData } = snapshotModule;
const [snapshot] = await db
.select({
id: systemSettingSnapshot.id,
scope: systemSettingSnapshot.scope,
data: systemSettingSnapshot.data,
})
.from(systemSettingSnapshot)
.where(eq(systemSettingSnapshot.id, snapshotId))
.limit(1);
if (!snapshot) {
throw new Error(`Snapshot not found: ${snapshotId}`);
}
const parsedSnapshot = readSystemSettingSnapshotData(
snapshot.scope as keyof typeof snapshotModule.SYSTEM_SETTING_SNAPSHOT_SCOPES,
snapshot.data
);
const keys = Object.keys(parsedSnapshot.systemSettings);
await db.transaction(async (tx) => {
for (const key of keys) {
const [existing] = await tx
.select({ value: systemSettings.value })
.from(systemSettings)
.where(eq(systemSettings.key, key))
.limit(1);
const nextValue = parsedSnapshot.systemSettings[key] ?? null;
await tx.insert(systemSettingHistory).values({
key,
previousValue: existing?.value ?? null,
nextValue,
reason,
changedAt: new Date(),
changedBy: null,
});
await tx
.insert(systemSettings)
.values({
key,
value: nextValue,
updatedAt: new Date(),
updatedBy: null,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: nextValue,
updatedAt: new Date(),
updatedBy: null,
},
});
}
if ("agentWorkflowDefaultModelId" in parsedSnapshot.siteSettings) {
const [existingSiteSettings] = await tx.select().from(siteSettings).limit(1);
const nextAgentWorkflowDefaultModelId =
String(parsedSnapshot.siteSettings.agentWorkflowDefaultModelId || "").trim() || null;
if (existingSiteSettings) {
await tx
.update(siteSettings)
.set({
agentWorkflowDefaultModelId: nextAgentWorkflowDefaultModelId,
updatedAt: new Date(),
updatedBy: null,
})
.where(eq(siteSettings.id, existingSiteSettings.id));
} else {
await tx.insert(siteSettings).values({
siteName: "AI Chatbot",
agentWorkflowDefaultModelId: nextAgentWorkflowDefaultModelId,
updatedAt: new Date(),
updatedBy: null,
});
}
}
});
console.log(
JSON.stringify(
{
snapshotId,
scope: snapshot.scope,
restoredKeyCount: keys.length,
reason,
},
null,
2
)
);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});