feat: add agent workflow snapshot center
This commit is contained in:
parent
ba46f97f3d
commit
9adb55c6a7
@ -32,6 +32,8 @@ import {
|
|||||||
MonitorPlay,
|
MonitorPlay,
|
||||||
Package,
|
Package,
|
||||||
Plus,
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
Settings2,
|
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 WORKFLOW_SETTINGS_DRAFT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
const AGENT_WORKFLOW_SETTINGS_ENDPOINT =
|
const AGENT_WORKFLOW_SETTINGS_ENDPOINT =
|
||||||
"/api/site-settings?scope=agent-workflow-admin";
|
"/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 AGENT_WORKFLOW_SAVE_TIMEOUT_MS = 30_000;
|
||||||
const DELETE_WORKFLOW_STEP_PASSWORD = "123456";
|
const DELETE_WORKFLOW_STEP_PASSWORD = "123456";
|
||||||
const BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE = "__agent_workflow_mode_default_model_empty__";
|
const BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE = "__agent_workflow_mode_default_model_empty__";
|
||||||
@ -1824,6 +1828,30 @@ interface AgentWorkflowDestructiveChangePayload {
|
|||||||
approvalToken: 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(
|
function formatAgentWorkflowDestructiveChangeConfirmText(
|
||||||
payload: AgentWorkflowDestructiveChangePayload
|
payload: AgentWorkflowDestructiveChangePayload
|
||||||
) {
|
) {
|
||||||
@ -1961,6 +1989,15 @@ export function AgentWorkflowSettingsClient({
|
|||||||
fetcher,
|
fetcher,
|
||||||
{ revalidateOnFocus: false }
|
{ 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 [agentWorkflowDefaultModelId, setAgentWorkflowDefaultModelId] = useState<string | null>(null);
|
||||||
const [presetLibraryConfig, setPresetLibraryConfig] = useState<AgentPresetLibraryConfig>(
|
const [presetLibraryConfig, setPresetLibraryConfig] = useState<AgentPresetLibraryConfig>(
|
||||||
defaultAgentPresetLibraryConfig
|
defaultAgentPresetLibraryConfig
|
||||||
@ -2016,6 +2053,9 @@ export function AgentWorkflowSettingsClient({
|
|||||||
const [autoDraftUpdatedAt, setAutoDraftUpdatedAt] = useState<number | null>(null);
|
const [autoDraftUpdatedAt, setAutoDraftUpdatedAt] = useState<number | null>(null);
|
||||||
const [autoDraftStatus, setAutoDraftStatus] = useState<"idle" | "restored" | "saved">("idle");
|
const [autoDraftStatus, setAutoDraftStatus] = useState<"idle" | "restored" | "saved">("idle");
|
||||||
const [recoverableDraftUpdatedAt, setRecoverableDraftUpdatedAt] = useState<number | null>(null);
|
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 fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
const brandImportInputRef = useRef<HTMLInputElement | null>(null);
|
const brandImportInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const hasAppliedInitialSettingsRef = useRef(false);
|
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 () => {
|
const handleDownloadBrandTemplate = async () => {
|
||||||
try {
|
try {
|
||||||
await downloadAgentWorkflowBrandImportTemplate(categoryLabels);
|
await downloadAgentWorkflowBrandImportTemplate(categoryLabels);
|
||||||
@ -5866,6 +5981,15 @@ export function AgentWorkflowSettingsClient({
|
|||||||
>
|
>
|
||||||
{recoverableDraftUpdatedAt ? "恢复旧草稿" : "查找旧草稿"}
|
{recoverableDraftUpdatedAt ? "恢复旧草稿" : "查找旧草稿"}
|
||||||
</Button>
|
</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">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href="/settings/agent-workflow/psd-templates">
|
<Link href="/settings/agent-workflow/psd-templates">
|
||||||
<Layers className="mr-2 h-4 w-4" />
|
<Layers className="mr-2 h-4 w-4" />
|
||||||
@ -5896,6 +6020,126 @@ export function AgentWorkflowSettingsClient({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 */}
|
{/* Stats Bar */}
|
||||||
<div className="border-b bg-muted/30">
|
<div className="border-b bg-muted/30">
|
||||||
<div className="container mx-auto px-6 py-3">
|
<div className="container mx-auto px-6 py-3">
|
||||||
|
|||||||
257
app/api/admin/system-settings/snapshots/route.ts
Normal file
257
app/api/admin/system-settings/snapshots/route.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2224,9 +2224,19 @@ export async function PUT(request: NextRequest) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [currentSiteSettings] = await db
|
||||||
|
.select({
|
||||||
|
agentWorkflowDefaultModelId: siteSettings.agentWorkflowDefaultModelId,
|
||||||
|
})
|
||||||
|
.from(siteSettings)
|
||||||
|
.limit(1);
|
||||||
const snapshotData = createSystemSettingSnapshotData(
|
const snapshotData = createSystemSettingSnapshotData(
|
||||||
AGENT_WORKFLOW_SNAPSHOT_SCOPE,
|
AGENT_WORKFLOW_SNAPSHOT_SCOPE,
|
||||||
currentWorkflowValues
|
currentWorkflowValues,
|
||||||
|
{
|
||||||
|
agentWorkflowDefaultModelId:
|
||||||
|
String(currentSiteSettings?.agentWorkflowDefaultModelId || "").trim() || null,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
const [snapshot] = await db
|
const [snapshot] = await db
|
||||||
.insert(systemSettingSnapshot)
|
.insert(systemSettingSnapshot)
|
||||||
|
|||||||
@ -38,6 +38,10 @@ export const SYSTEM_SETTING_SNAPSHOT_SCOPES = {
|
|||||||
],
|
],
|
||||||
} as const;
|
} 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 type SystemSettingSnapshotScope = keyof typeof SYSTEM_SETTING_SNAPSHOT_SCOPES;
|
||||||
|
|
||||||
export function getSystemSettingSnapshotScopeKeys(
|
export function getSystemSettingSnapshotScopeKeys(
|
||||||
@ -46,21 +50,83 @@ export function getSystemSettingSnapshotScopeKeys(
|
|||||||
return SYSTEM_SETTING_SNAPSHOT_SCOPES[scope];
|
return SYSTEM_SETTING_SNAPSHOT_SCOPES[scope];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSystemSettingSnapshotScopeSiteFields(
|
||||||
|
scope: SystemSettingSnapshotScope
|
||||||
|
) {
|
||||||
|
return SYSTEM_SETTING_SNAPSHOT_SITE_FIELDS[scope];
|
||||||
|
}
|
||||||
|
|
||||||
export function createSystemSettingSnapshotData(
|
export function createSystemSettingSnapshotData(
|
||||||
scope: SystemSettingSnapshotScope,
|
scope: SystemSettingSnapshotScope,
|
||||||
valueByKey: ReadonlyMap<string, unknown>
|
valueByKey: ReadonlyMap<string, unknown>,
|
||||||
|
siteSettingsValues: Record<string, unknown> = {}
|
||||||
) {
|
) {
|
||||||
const keys = getSystemSettingSnapshotScopeKeys(scope);
|
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<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(
|
export function summarizeSystemSettingSnapshotData(
|
||||||
data: Record<string, unknown>
|
data: Record<string, unknown>
|
||||||
) {
|
) {
|
||||||
const entries = Object.entries(data);
|
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 {
|
return {
|
||||||
keyCount: entries.length,
|
keyCount: entries.length,
|
||||||
nonNullKeyCount: entries.filter(([, value]) => value !== null && value !== undefined).length,
|
nonNullKeyCount: entries.filter(([, value]) => value !== null && value !== undefined).length,
|
||||||
byteLength: Buffer.byteLength(JSON.stringify(data), "utf8"),
|
byteLength: Buffer.byteLength(JSON.stringify(data), "utf8"),
|
||||||
|
siteFieldCount: siteEntries.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,7 @@ async function main() {
|
|||||||
const snapshotModule = await import("../../lib/system-settings/snapshot");
|
const snapshotModule = await import("../../lib/system-settings/snapshot");
|
||||||
|
|
||||||
const { db } = dbModule;
|
const { db } = dbModule;
|
||||||
const { systemSettingSnapshot, systemSettings } = schemaModule;
|
const { siteSettings, systemSettingSnapshot, systemSettings } = schemaModule;
|
||||||
const {
|
const {
|
||||||
createSystemSettingSnapshotData,
|
createSystemSettingSnapshotData,
|
||||||
getSystemSettingSnapshotScopeKeys,
|
getSystemSettingSnapshotScopeKeys,
|
||||||
@ -40,9 +40,19 @@ async function main() {
|
|||||||
.from(systemSettings)
|
.from(systemSettings)
|
||||||
.where(inArray(systemSettings.key, [...keys]));
|
.where(inArray(systemSettings.key, [...keys]));
|
||||||
const valueByKey = new Map(rows.map((row) => [row.key, row.value]));
|
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(
|
const snapshotData = createSystemSettingSnapshotData(
|
||||||
scopeArg as keyof typeof snapshotModule.SYSTEM_SETTING_SNAPSHOT_SCOPES,
|
scopeArg as keyof typeof snapshotModule.SYSTEM_SETTING_SNAPSHOT_SCOPES,
|
||||||
valueByKey
|
valueByKey,
|
||||||
|
{
|
||||||
|
agentWorkflowDefaultModelId:
|
||||||
|
String(currentSiteSettings?.agentWorkflowDefaultModelId || "").trim() || null,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const [snapshot] = await db
|
const [snapshot] = await db
|
||||||
|
|||||||
@ -21,9 +21,12 @@ async function main() {
|
|||||||
|
|
||||||
const dbModule = await import("../../lib/db");
|
const dbModule = await import("../../lib/db");
|
||||||
const schemaModule = await import("../../lib/db/schema");
|
const schemaModule = await import("../../lib/db/schema");
|
||||||
|
const snapshotModule = await import("../../lib/system-settings/snapshot");
|
||||||
|
|
||||||
const { db } = dbModule;
|
const { db } = dbModule;
|
||||||
const { systemSettingHistory, systemSettingSnapshot, systemSettings } = schemaModule;
|
const { siteSettings, systemSettingHistory, systemSettingSnapshot, systemSettings } =
|
||||||
|
schemaModule;
|
||||||
|
const { readSystemSettingSnapshotData } = snapshotModule;
|
||||||
|
|
||||||
const [snapshot] = await db
|
const [snapshot] = await db
|
||||||
.select({
|
.select({
|
||||||
@ -39,11 +42,11 @@ async function main() {
|
|||||||
throw new Error(`Snapshot not found: ${snapshotId}`);
|
throw new Error(`Snapshot not found: ${snapshotId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data =
|
const parsedSnapshot = readSystemSettingSnapshotData(
|
||||||
snapshot.data && typeof snapshot.data === "object" && !Array.isArray(snapshot.data)
|
snapshot.scope as keyof typeof snapshotModule.SYSTEM_SETTING_SNAPSHOT_SCOPES,
|
||||||
? (snapshot.data as Record<string, unknown>)
|
snapshot.data
|
||||||
: {};
|
);
|
||||||
const keys = Object.keys(data);
|
const keys = Object.keys(parsedSnapshot.systemSettings);
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
@ -53,7 +56,7 @@ async function main() {
|
|||||||
.where(eq(systemSettings.key, key))
|
.where(eq(systemSettings.key, key))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const nextValue = data[key] ?? null;
|
const nextValue = parsedSnapshot.systemSettings[key] ?? null;
|
||||||
|
|
||||||
await tx.insert(systemSettingHistory).values({
|
await tx.insert(systemSettingHistory).values({
|
||||||
key,
|
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(
|
console.log(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user