diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx index a7201415f..da40fd48d 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -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__"; @@ -1824,6 +1828,30 @@ interface AgentWorkflowDestructiveChangePayload { 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 ) { @@ -1961,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(null); const [presetLibraryConfig, setPresetLibraryConfig] = useState( defaultAgentPresetLibraryConfig @@ -2016,6 +2053,9 @@ export function AgentWorkflowSettingsClient({ const [autoDraftUpdatedAt, setAutoDraftUpdatedAt] = useState(null); const [autoDraftStatus, setAutoDraftStatus] = useState<"idle" | "restored" | "saved">("idle"); const [recoverableDraftUpdatedAt, setRecoverableDraftUpdatedAt] = useState(null); + const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false); + const [isCreatingSnapshot, setIsCreatingSnapshot] = useState(false); + const [restoringSnapshotId, setRestoringSnapshotId] = useState(null); const fileInputRefs = useRef>({}); const brandImportInputRef = useRef(null); const hasAppliedInitialSettingsRef = useRef(false); @@ -4074,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); @@ -5866,6 +5981,15 @@ export function AgentWorkflowSettingsClient({ > {recoverableDraftUpdatedAt ? "恢复旧草稿" : "查找旧草稿"} + + + + + +
+ {isLoadingSnapshots ? ( +
+ + 正在加载快照列表... +
+ ) : (snapshotsData?.snapshots?.length || 0) === 0 ? ( +
+ 还没有可用快照,先创建一个安全点吧。 +
+ ) : ( + (snapshotsData?.snapshots || []).map((snapshot, index) => ( +
+
+
+
+ + {index === 0 ? "最新" : `#${index + 1}`} + + + {snapshot.label || "未命名快照"} + + + {snapshot.id.slice(0, 8)} + +
+ +
+ {new Date(snapshot.createdAt).toLocaleString("zh-CN")} + {" · "} + {snapshot.createdByName || + snapshot.createdByEmail || + "系统自动创建"} +
+ +
+ + 配置键 {snapshot.summary?.keyCount ?? "-"} + + + 站点字段 {snapshot.summary?.siteFieldCount ?? 0} + + + 非空项 {snapshot.summary?.nonNullKeyCount ?? "-"} + + + 大小 {formatSnapshotByteLength(snapshot.summary?.byteLength)} + +
+
+ + +
+
+ )) + )} +
+ + + {/* Stats Bar */}
diff --git a/app/api/admin/system-settings/snapshots/route.ts b/app/api/admin/system-settings/snapshots/route.ts new file mode 100644 index 000000000..2559a625d --- /dev/null +++ b/app/api/admin/system-settings/snapshots/route.ts @@ -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(); + } +} diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index c00320735..5e798f6ed 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -2224,9 +2224,19 @@ export async function PUT(request: NextRequest) { return null; } + const [currentSiteSettings] = await db + .select({ + agentWorkflowDefaultModelId: siteSettings.agentWorkflowDefaultModelId, + }) + .from(siteSettings) + .limit(1); const snapshotData = createSystemSettingSnapshotData( AGENT_WORKFLOW_SNAPSHOT_SCOPE, - currentWorkflowValues + currentWorkflowValues, + { + agentWorkflowDefaultModelId: + String(currentSiteSettings?.agentWorkflowDefaultModelId || "").trim() || null, + } ); const [snapshot] = await db .insert(systemSettingSnapshot) diff --git a/lib/system-settings/snapshot.ts b/lib/system-settings/snapshot.ts index 7efe68b5f..aa3b4a468 100644 --- a/lib/system-settings/snapshot.ts +++ b/lib/system-settings/snapshot.ts @@ -38,6 +38,10 @@ export const SYSTEM_SETTING_SNAPSHOT_SCOPES = { ], } 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( @@ -46,21 +50,83 @@ export function getSystemSettingSnapshotScopeKeys( 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 + valueByKey: ReadonlyMap, + siteSettingsValues: Record = {} ) { const keys = getSystemSettingSnapshotScopeKeys(scope); - return Object.fromEntries(keys.map((key) => [key, valueByKey.get(key) ?? null])); + 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) + : {}; + + if ( + normalized.systemSettings && + typeof normalized.systemSettings === "object" && + !Array.isArray(normalized.systemSettings) + ) { + return { + systemSettings: normalized.systemSettings as Record, + siteSettings: + normalized.siteSettings && + typeof normalized.siteSettings === "object" && + !Array.isArray(normalized.siteSettings) + ? (normalized.siteSettings as Record) + : {}, + }; + } + + const keys = new Set(getSystemSettingSnapshotScopeKeys(scope)); + return { + systemSettings: Object.fromEntries( + Object.entries(normalized).filter(([key]) => keys.has(key)) + ), + siteSettings: {}, + }; } export function summarizeSystemSettingSnapshotData( data: Record ) { - const entries = Object.entries(data); + const systemEntries = + data.systemSettings && + typeof data.systemSettings === "object" && + !Array.isArray(data.systemSettings) + ? Object.entries(data.systemSettings as Record) + : Object.entries(data); + const siteEntries = + data.siteSettings && + typeof data.siteSettings === "object" && + !Array.isArray(data.siteSettings) + ? Object.entries(data.siteSettings as Record) + : []; + 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, }; } diff --git a/scripts/system-settings/create-snapshot.ts b/scripts/system-settings/create-snapshot.ts index b1dd22c83..379c03c25 100644 --- a/scripts/system-settings/create-snapshot.ts +++ b/scripts/system-settings/create-snapshot.ts @@ -22,7 +22,7 @@ async function main() { const snapshotModule = await import("../../lib/system-settings/snapshot"); const { db } = dbModule; - const { systemSettingSnapshot, systemSettings } = schemaModule; + const { siteSettings, systemSettingSnapshot, systemSettings } = schemaModule; const { createSystemSettingSnapshotData, getSystemSettingSnapshotScopeKeys, @@ -40,9 +40,19 @@ async function main() { .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 + valueByKey, + { + agentWorkflowDefaultModelId: + String(currentSiteSettings?.agentWorkflowDefaultModelId || "").trim() || null, + } ); const [snapshot] = await db diff --git a/scripts/system-settings/restore-snapshot.ts b/scripts/system-settings/restore-snapshot.ts index c92742309..eba69be20 100644 --- a/scripts/system-settings/restore-snapshot.ts +++ b/scripts/system-settings/restore-snapshot.ts @@ -21,9 +21,12 @@ async function main() { 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 { systemSettingHistory, systemSettingSnapshot, systemSettings } = schemaModule; + const { siteSettings, systemSettingHistory, systemSettingSnapshot, systemSettings } = + schemaModule; + const { readSystemSettingSnapshotData } = snapshotModule; const [snapshot] = await db .select({ @@ -39,11 +42,11 @@ async function main() { throw new Error(`Snapshot not found: ${snapshotId}`); } - const data = - snapshot.data && typeof snapshot.data === "object" && !Array.isArray(snapshot.data) - ? (snapshot.data as Record) - : {}; - const keys = Object.keys(data); + 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) { @@ -53,7 +56,7 @@ async function main() { .where(eq(systemSettings.key, key)) .limit(1); - const nextValue = data[key] ?? null; + const nextValue = parsedSnapshot.systemSettings[key] ?? null; await tx.insert(systemSettingHistory).values({ key, @@ -81,6 +84,30 @@ async function main() { }, }); } + + 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(