fix: guard agent workflow config rollback
This commit is contained in:
parent
7fd3feb77c
commit
e160d7f217
@ -1811,6 +1811,52 @@ interface AgentWorkflowSettingsPayload {
|
|||||||
agentPsdTemplateSecondaryCategoryConfig?: AgentPsdTemplateSecondaryCategoryConfig;
|
agentPsdTemplateSecondaryCategoryConfig?: AgentPsdTemplateSecondaryCategoryConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AgentWorkflowDestructiveChangePayload {
|
||||||
|
totalBeforeOptionCount: number;
|
||||||
|
totalAfterOptionCount: number;
|
||||||
|
removedOptionCount: number;
|
||||||
|
removedEnabledOptionCount: number;
|
||||||
|
promptClearedCount: number;
|
||||||
|
previewClearedCount: number;
|
||||||
|
removedOptionSamples: string[];
|
||||||
|
promptClearedSamples: string[];
|
||||||
|
previewClearedSamples: string[];
|
||||||
|
approvalToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
interface AdminAiModelOption {
|
||||||
id: string;
|
id: string;
|
||||||
modelId?: string | null;
|
modelId?: string | null;
|
||||||
@ -3899,39 +3945,77 @@ export function AgentWorkflowSettingsClient({
|
|||||||
changedPayload.agentWorkflowSettingsBaseSignatures[field] = baseSignatures[field];
|
changedPayload.agentWorkflowSettingsBaseSignatures[field] = baseSignatures[field];
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetchAgentWorkflowSettingsWithTimeout(
|
const submitChangedPayload = async (payload: Record<string, unknown>) =>
|
||||||
"/api/site-settings",
|
fetchAgentWorkflowSettingsWithTimeout("/api/site-settings", {
|
||||||
{
|
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
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) {
|
if (!response.ok) {
|
||||||
let message = "保存失败";
|
let message = "保存失败";
|
||||||
|
|
||||||
try {
|
if (Array.isArray(responsePayload?.details) && responsePayload.details.length > 0) {
|
||||||
const payload = await response.json();
|
const firstIssue = responsePayload.details[0] as {
|
||||||
if (Array.isArray(payload?.details) && payload.details.length > 0) {
|
message?: string;
|
||||||
const firstIssue = payload.details[0] as {
|
path?: Array<string | number>;
|
||||||
message?: string;
|
};
|
||||||
path?: Array<string | number>;
|
const pathText =
|
||||||
};
|
Array.isArray(firstIssue.path) && firstIssue.path.length > 0
|
||||||
const pathText =
|
? firstIssue.path
|
||||||
Array.isArray(firstIssue.path) && firstIssue.path.length > 0
|
.filter((segment) => segment !== undefined && segment !== null && segment !== "")
|
||||||
? firstIssue.path
|
.join(" / ")
|
||||||
.filter((segment) => segment !== undefined && segment !== null && segment !== "")
|
: "";
|
||||||
.join(" / ")
|
const reasonText = String(firstIssue.message || "").trim();
|
||||||
: "";
|
|
||||||
const reasonText = String(firstIssue.message || "").trim();
|
|
||||||
|
|
||||||
message = [payload?.error, pathText, reasonText].filter(Boolean).join(":");
|
message = [responsePayload?.error, pathText, reasonText]
|
||||||
} else if (typeof payload?.error === "string" && payload.error.trim()) {
|
.filter(Boolean)
|
||||||
message = payload.error.trim();
|
.join(":");
|
||||||
}
|
} else if (
|
||||||
} catch {
|
typeof responsePayload?.error === "string" &&
|
||||||
// Ignore JSON parsing issues and keep the generic message.
|
responsePayload.error.trim()
|
||||||
|
) {
|
||||||
|
message = responsePayload.error.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/lib/db";
|
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 { and, eq, inArray } from "drizzle-orm";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { isAgentWorkflowAdminType, isSiteAdminType } from "@/lib/middleware/admin";
|
import { isAgentWorkflowAdminType, isSiteAdminType } from "@/lib/middleware/admin";
|
||||||
@ -35,8 +42,10 @@ import {
|
|||||||
AGENT_WORKFLOW_UI_CONFIG_KEY,
|
AGENT_WORKFLOW_UI_CONFIG_KEY,
|
||||||
AGENT_WORKFLOW_PROMPT_FRAGMENT_MAX_LENGTH,
|
AGENT_WORKFLOW_PROMPT_FRAGMENT_MAX_LENGTH,
|
||||||
defaultAgentWorkflowUiConfig,
|
defaultAgentWorkflowUiConfig,
|
||||||
|
isAgentWorkflowDestructiveChangeSignificant,
|
||||||
normalizeAgentWorkflowStepOrdering,
|
normalizeAgentWorkflowStepOrdering,
|
||||||
normalizeAgentWorkflowUiConfig,
|
normalizeAgentWorkflowUiConfig,
|
||||||
|
summarizeAgentWorkflowDestructiveChange,
|
||||||
} from "@/lib/agents/workflow-ui-config";
|
} from "@/lib/agents/workflow-ui-config";
|
||||||
import {
|
import {
|
||||||
AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY,
|
AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY,
|
||||||
@ -105,6 +114,11 @@ import {
|
|||||||
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS,
|
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS,
|
||||||
setScopedSiteSettingsCache,
|
setScopedSiteSettingsCache,
|
||||||
} from "@/lib/site-settings/public-cache";
|
} from "@/lib/site-settings/public-cache";
|
||||||
|
import {
|
||||||
|
AGENT_WORKFLOW_SNAPSHOT_SCOPE,
|
||||||
|
createSystemSettingSnapshotData,
|
||||||
|
summarizeSystemSettingSnapshotData,
|
||||||
|
} from "@/lib/system-settings/snapshot";
|
||||||
import {
|
import {
|
||||||
getHomeChatAnimationHtmlBytes,
|
getHomeChatAnimationHtmlBytes,
|
||||||
HOME_CHAT_ANIMATION_CONFIG_KEY,
|
HOME_CHAT_ANIMATION_CONFIG_KEY,
|
||||||
@ -1612,6 +1626,11 @@ const updateSchema = z.object({
|
|||||||
agentWorkflowSettingsBaseSignatures: z
|
agentWorkflowSettingsBaseSignatures: z
|
||||||
.record(z.string().trim().min(1).max(128))
|
.record(z.string().trim().min(1).max(128))
|
||||||
.optional(),
|
.optional(),
|
||||||
|
agentWorkflowDestructiveChangeApproval: z
|
||||||
|
.object({
|
||||||
|
token: z.string().trim().min(16).max(128),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
agentWorkflowUiConfig: z
|
agentWorkflowUiConfig: z
|
||||||
.object({
|
.object({
|
||||||
inputOptions: z
|
inputOptions: z
|
||||||
@ -1965,6 +1984,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
"agentPresetLibraryConfig",
|
"agentPresetLibraryConfig",
|
||||||
"agentWorkflowSettingsBaseSignature",
|
"agentWorkflowSettingsBaseSignature",
|
||||||
"agentWorkflowSettingsBaseSignatures",
|
"agentWorkflowSettingsBaseSignatures",
|
||||||
|
"agentWorkflowDestructiveChangeApproval",
|
||||||
"agentWorkflowUiConfig",
|
"agentWorkflowUiConfig",
|
||||||
"agentWorkflowMaterialApiConfig",
|
"agentWorkflowMaterialApiConfig",
|
||||||
"agentWorkflowGoodsInfoApiConfig",
|
"agentWorkflowGoodsInfoApiConfig",
|
||||||
@ -1992,6 +2012,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
agentSecondaryTypeConfig,
|
agentSecondaryTypeConfig,
|
||||||
agentWorkflowSettingsBaseSignature,
|
agentWorkflowSettingsBaseSignature,
|
||||||
agentWorkflowSettingsBaseSignatures,
|
agentWorkflowSettingsBaseSignatures,
|
||||||
|
agentWorkflowDestructiveChangeApproval,
|
||||||
agentWorkflowUiConfig,
|
agentWorkflowUiConfig,
|
||||||
agentWorkflowMaterialApiConfig,
|
agentWorkflowMaterialApiConfig,
|
||||||
agentWorkflowGoodsInfoApiConfig,
|
agentWorkflowGoodsInfoApiConfig,
|
||||||
@ -2033,6 +2054,29 @@ export async function PUT(request: NextRequest) {
|
|||||||
.map(([field]) => field);
|
.map(([field]) => field);
|
||||||
|
|
||||||
const isUpdatingAgentWorkflowSettings = updatedAgentWorkflowFields.length > 0;
|
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 (isUpdatingAgentWorkflowSettings) {
|
||||||
if (!agentWorkflowSettingsBaseSignatures && !agentWorkflowSettingsBaseSignature) {
|
if (!agentWorkflowSettingsBaseSignatures && !agentWorkflowSettingsBaseSignature) {
|
||||||
@ -2052,10 +2096,10 @@ export async function PUT(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
.from(systemSettings)
|
.from(systemSettings)
|
||||||
.where(inArray(systemSettings.key, [...AGENT_WORKFLOW_SETTINGS_CONFLICT_KEYS]));
|
.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])
|
currentWorkflowRows.map((row) => [row.key, row.value])
|
||||||
);
|
);
|
||||||
const currentSectionSignatures =
|
currentWorkflowSectionSignatures =
|
||||||
createAgentWorkflowSettingsSectionSignatures(currentWorkflowValues);
|
createAgentWorkflowSettingsSectionSignatures(currentWorkflowValues);
|
||||||
const currentWorkflowSignature =
|
const currentWorkflowSignature =
|
||||||
agentWorkflowSettingsBaseSignature && !agentWorkflowSettingsBaseSignatures
|
agentWorkflowSettingsBaseSignature && !agentWorkflowSettingsBaseSignatures
|
||||||
@ -2064,7 +2108,8 @@ export async function PUT(request: NextRequest) {
|
|||||||
const hasWorkflowConflict = agentWorkflowSettingsBaseSignatures
|
const hasWorkflowConflict = agentWorkflowSettingsBaseSignatures
|
||||||
? updatedAgentWorkflowFields.some(
|
? updatedAgentWorkflowFields.some(
|
||||||
(field) =>
|
(field) =>
|
||||||
currentSectionSignatures[field] !== agentWorkflowSettingsBaseSignatures[field]
|
currentWorkflowSectionSignatures![field] !==
|
||||||
|
agentWorkflowSettingsBaseSignatures[field]
|
||||||
)
|
)
|
||||||
: currentWorkflowSignature !== agentWorkflowSettingsBaseSignature;
|
: currentWorkflowSignature !== agentWorkflowSettingsBaseSignature;
|
||||||
|
|
||||||
@ -2077,6 +2122,44 @@ export async function PUT(request: NextRequest) {
|
|||||||
{ status: 409 }
|
{ 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 (
|
const persistSystemSettingWithHistory = async (
|
||||||
@ -2136,6 +2219,37 @@ export async function PUT(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createAgentWorkflowScopeSnapshot = async () => {
|
||||||
|
if (!isUpdatingAgentWorkflowSettings || agentWorkflowSnapshotCreated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshotData = createSystemSettingSnapshotData(
|
||||||
|
AGENT_WORKFLOW_SNAPSHOT_SCOPE,
|
||||||
|
currentWorkflowValues
|
||||||
|
);
|
||||||
|
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 () => {
|
const persistHomeAnimationConfig = async () => {
|
||||||
if (homeAnimationConfig === undefined) {
|
if (homeAnimationConfig === undefined) {
|
||||||
return;
|
return;
|
||||||
@ -2175,6 +2289,8 @@ export async function PUT(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await createAgentWorkflowScopeSnapshot();
|
||||||
|
|
||||||
// 查找现有设置
|
// 查找现有设置
|
||||||
const [existing] = await db.select().from(siteSettings).limit(1);
|
const [existing] = await db.select().from(siteSettings).limit(1);
|
||||||
|
|||||||
@ -798,6 +798,139 @@ export function countAgentWorkflowOptionTree(
|
|||||||
return flattenAgentWorkflowOptionTree(options).length;
|
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(
|
export function hasAgentWorkflowOptionOwnPreview(
|
||||||
option: AgentWorkflowOptionUiConfig
|
option: AgentWorkflowOptionUiConfig
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|||||||
23
lib/db/migrations/0085_add_system_setting_snapshots.sql
Normal file
23
lib/db/migrations/0085_add_system_setting_snapshots.sql
Normal 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);
|
||||||
@ -498,6 +498,13 @@
|
|||||||
"when": 1779797137842,
|
"when": 1779797137842,
|
||||||
"tag": "0084_optimize_featured_home_query",
|
"tag": "0084_optimize_featured_home_query",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 71,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780075200000,
|
||||||
|
"tag": "0085_add_system_setting_snapshots",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -260,6 +260,18 @@ export const systemSettingHistory = pgTable("SystemSettingHistory", {
|
|||||||
|
|
||||||
export type SystemSettingHistory = InferSelectModel<typeof 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", {
|
export const dingTalkNotificationTask = pgTable("DingTalkNotificationTask", {
|
||||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||||
title: varchar("title", { length: 100 }).notNull(),
|
title: varchar("title", { length: 100 }).notNull(),
|
||||||
|
|||||||
66
lib/system-settings/snapshot.ts
Normal file
66
lib/system-settings/snapshot.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
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 type SystemSettingSnapshotScope = keyof typeof SYSTEM_SETTING_SNAPSHOT_SCOPES;
|
||||||
|
|
||||||
|
export function getSystemSettingSnapshotScopeKeys(
|
||||||
|
scope: SystemSettingSnapshotScope
|
||||||
|
) {
|
||||||
|
return SYSTEM_SETTING_SNAPSHOT_SCOPES[scope];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSystemSettingSnapshotData(
|
||||||
|
scope: SystemSettingSnapshotScope,
|
||||||
|
valueByKey: ReadonlyMap<string, unknown>
|
||||||
|
) {
|
||||||
|
const keys = getSystemSettingSnapshotScopeKeys(scope);
|
||||||
|
return Object.fromEntries(keys.map((key) => [key, valueByKey.get(key) ?? null]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeSystemSettingSnapshotData(
|
||||||
|
data: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
const entries = Object.entries(data);
|
||||||
|
return {
|
||||||
|
keyCount: entries.length,
|
||||||
|
nonNullKeyCount: entries.filter(([, value]) => value !== null && value !== undefined).length,
|
||||||
|
byteLength: Buffer.byteLength(JSON.stringify(data), "utf8"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -43,6 +43,8 @@
|
|||||||
"db:pull": "drizzle-kit pull",
|
"db:pull": "drizzle-kit pull",
|
||||||
"db:check": "drizzle-kit check",
|
"db:check": "drizzle-kit check",
|
||||||
"db:up": "drizzle-kit up",
|
"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",
|
"poc:nanobanana": "pnpm exec tsx scripts/nanobanana-browser-poc.ts",
|
||||||
"test": "export PLAYWRIGHT=True && pnpm exec playwright test",
|
"test": "export PLAYWRIGHT=True && pnpm exec playwright test",
|
||||||
"storage:configure-upload-cors": "pnpm exec tsx scripts/storage/configure-direct-upload-cors.ts",
|
"storage:configure-upload-cors": "pnpm exec tsx scripts/storage/configure-direct-upload-cors.ts",
|
||||||
|
|||||||
83
scripts/system-settings/create-snapshot.ts
Normal file
83
scripts/system-settings/create-snapshot.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
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 { 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 snapshotData = createSystemSettingSnapshotData(
|
||||||
|
scopeArg as keyof typeof snapshotModule.SYSTEM_SETTING_SNAPSHOT_SCOPES,
|
||||||
|
valueByKey
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
103
scripts/system-settings/restore-snapshot.ts
Normal file
103
scripts/system-settings/restore-snapshot.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
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 { db } = dbModule;
|
||||||
|
const { systemSettingHistory, systemSettingSnapshot, systemSettings } = schemaModule;
|
||||||
|
|
||||||
|
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 data =
|
||||||
|
snapshot.data && typeof snapshot.data === "object" && !Array.isArray(snapshot.data)
|
||||||
|
? (snapshot.data as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const keys = Object.keys(data);
|
||||||
|
|
||||||
|
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 = data[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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
snapshotId,
|
||||||
|
scope: snapshot.scope,
|
||||||
|
restoredKeyCount: keys.length,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user