diff --git a/app/api/agent-projects/route.ts b/app/api/agent-projects/route.ts index 6e4ed74dc..c7e2c4a3e 100644 --- a/app/api/agent-projects/route.ts +++ b/app/api/agent-projects/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { desc, eq } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; +import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { db } from "@/lib/db"; import { canvasProject } from "@/lib/db/schema"; @@ -56,14 +57,20 @@ export async function POST(request: NextRequest) { } const body = await request.json().catch(() => ({})); + const requestedId = body.id === undefined ? undefined : z.string().uuid().safeParse(body.id); + if (requestedId && !requestedId.success) { + return NextResponse.json({ success: false, error: "项目编号无效" }, { status: 400 }); + } + const id = requestedId?.data; const title = String(body.title || "").trim() || "未命名生图项目"; const description = String(body.description || "").trim() || null; const thumbnail = String(body.thumbnail || "").trim() || null; const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState); - const [project] = await db + const [insertedProject] = await db .insert(canvasProject) .values({ + id, userId: session.user.id, title, description, @@ -78,8 +85,22 @@ export async function POST(request: NextRequest) { agentProjectState: projectState, } as any, }) + .onConflictDoNothing({ target: canvasProject.id }) .returning(); + const project = insertedProject || (id ? (await db + .select() + .from(canvasProject) + .where(and( + eq(canvasProject.id, id), + eq(canvasProject.userId, session.user.id), + eq(canvasProject.workspaceType, "agent") + )) + .limit(1))[0] : undefined); + if (!project) { + return NextResponse.json({ success: false, error: "项目编号不可用,请重试" }, { status: 409 }); + } + return NextResponse.json({ success: true, data: mapAgentProjectRow(project) }); } catch (error) { console.error("[AgentProjects API] 创建项目失败:", error); diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 1a58be1bd..3c194fb27 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -153,7 +153,7 @@ import { } from "@/lib/ai-wash/config"; import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config"; import { withTimeout } from "@/lib/public-api/performance"; -import type { AgentCategoryTreeConfig } from "@/types/agent"; +import type { AgentCategoryTreeConfig, AgentWorkflowUiConfig } from "@/types/agent"; // Historically stored as "home_surprise_categories" but used for the featured gallery categories. const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories"; @@ -553,6 +553,7 @@ function normalizeAgentCategoryTreeConfig(value: unknown): AgentCategoryTreeConf type PublicSiteSettingsScope = | "agent-brand-model" | "agent-canvas" + | "agent-entry" | "agent-presets" | "auth" | "branding" @@ -564,6 +565,7 @@ type PublicSiteSettingsScope = const PUBLIC_SITE_SETTINGS_SCOPES = new Set([ "agent-brand-model", "agent-canvas", + "agent-entry", "agent-presets", "auth", "branding", @@ -607,22 +609,60 @@ const HOME_SITE_SETTINGS_FALLBACK: Record = { lovartHomeSidebarConfig: normalizeLovartHomeSidebarConfig(undefined), }; +const AGENT_CANVAS_SETTING_KEYS = [ + REALTIME_CANVAS_CONFIG_KEY, + REALTIME_CANVAS_MODE_ICON_CONFIG_KEY, + CANVAS_TOOLBAR_CONFIG_SETTING_KEY, + LAYER_SPLIT_CONFIG_KEY, + ANGLE_EDIT_CONFIG_KEY, + CANVAS_AGENT_MODE_ENABLED_KEY, + ...CANVAS_AGENT_FEATURE_SETTING_KEYS, + VIDEO_CANVAS_ENABLED_KEY, + VIDEO_CANVAS_SIDEBAR_ENABLED_KEY, + ENHANCE_PRO_KREA_SETTING_KEY, + AI_WASH_CONFIG_KEY, + STYLE_PRO_CONFIG_KEY, + AGENT_WORKFLOW_UI_CONFIG_KEY, + AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY, +]; + +async function getAgentCanvasSettingsEtag(scope: string) { + const [settings, rows, providers] = await Promise.all([ + db.select({ id: siteSettings.id, updatedAt: siteSettings.updatedAt }) + .from(siteSettings).limit(1), + db.select({ key: systemSettings.key, updatedAt: systemSettings.updatedAt }) + .from(systemSettings) + .where(inArray(systemSettings.key, AGENT_CANVAS_SETTING_KEYS)), + db.select({ id: aiProvider.id, updatedAt: aiProvider.updatedAt, enabled: aiProvider.isEnabled }) + .from(aiProvider) + .where(inArray(aiProvider.type, ["tripo-3d", "worldlabs-3d"])), + ]); + const versions = [ + ...settings.map((row) => `${row.id}:${row.updatedAt.getTime()}`), + ...rows.map((row) => `${row.key}:${row.updatedAt.getTime()}`), + ...providers.map((row) => `${row.id}:${row.updatedAt.getTime()}:${row.enabled}`), + ].sort(); + return `W/"${scope}/${versions.join(",")}"`; +} + function createScopedSiteSettingsResponse( scope: PublicSiteSettingsScope, data: Record, cacheStatus: "HIT" | "MISS" | "STALE" | "FALLBACK" = "MISS", cacheKey: string = scope, - options: { fallback?: boolean; startedAt?: number } = {} + options: { fallback?: boolean; startedAt?: number; etag?: string } = {} ) { if (cacheStatus === "MISS") { setScopedSiteSettingsCache( cacheKey, data, - PUBLIC_SITE_SETTINGS_CACHE_TTL_MS + PUBLIC_SITE_SETTINGS_CACHE_TTL_MS, + options.etag ); } const response = NextResponse.json(data); + if (options.etag) response.headers.set("ETag", options.etag); // Lightweight public settings change rarely; keep them browser-local briefly // so page opens do not repeatedly wait on the large full settings endpoint. response.headers.set( @@ -677,6 +717,7 @@ async function getScopedPublicSiteSettings( options: { brandName?: string; cacheKey?: string; + etag?: string; } = {} ) { if (scope === "auth") { @@ -738,23 +779,8 @@ async function getScopedPublicSiteSettings( }); } - if (scope === "agent-canvas") { - const { settings, valueByKey } = await readScopedSiteSettings([ - REALTIME_CANVAS_CONFIG_KEY, - REALTIME_CANVAS_MODE_ICON_CONFIG_KEY, - CANVAS_TOOLBAR_CONFIG_SETTING_KEY, - LAYER_SPLIT_CONFIG_KEY, - ANGLE_EDIT_CONFIG_KEY, - CANVAS_AGENT_MODE_ENABLED_KEY, - ...CANVAS_AGENT_FEATURE_SETTING_KEYS, - VIDEO_CANVAS_ENABLED_KEY, - VIDEO_CANVAS_SIDEBAR_ENABLED_KEY, - ENHANCE_PRO_KREA_SETTING_KEY, - AI_WASH_CONFIG_KEY, - STYLE_PRO_CONFIG_KEY, - AGENT_WORKFLOW_UI_CONFIG_KEY, - AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY, - ]); + if (scope === "agent-canvas" || scope === "agent-entry") { + const { settings, valueByKey } = await readScopedSiteSettings(AGENT_CANVAS_SETTING_KEYS); const canvasFeatureAvailability = await readCanvasFeatureAvailability( valueByKey ); @@ -762,6 +788,8 @@ async function getScopedPublicSiteSettings( const videoCanvasSidebarEnabled = normalizeVideoCanvasSidebarEnabled( valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY) ); + const rawUiConfig = valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY) as + Partial | undefined; return createScopedSiteSettingsResponse(scope, { ...createBrandingSettings(typedSettings), agentEnabled: typedSettings?.agentEnabled ?? true, @@ -797,12 +825,18 @@ async function getScopedPublicSiteSettings( tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled, worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled, agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig( - valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY) + scope === "agent-entry" + ? { + steps: [], + pageFeatureSettings: rawUiConfig?.pageFeatureSettings, + businessModeSettings: rawUiConfig?.businessModeSettings, + } + : rawUiConfig ), agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig( valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY) ), - }); + }, "MISS", options.cacheKey || scope, { etag: options.etag }); } if (scope === "branding") { @@ -1056,13 +1090,23 @@ export async function GET(request: NextRequest) { const cacheKey = brandName.trim() ? `${scopedScope}:${brandName.trim().toLowerCase()}` : scopedScope; + const etag = scopedScope === "agent-canvas" || scopedScope === "agent-entry" + ? await getAgentCanvasSettingsEtag(scopedScope) + : undefined; + if (etag && request.headers.get("If-None-Match") === etag) { + return new NextResponse(null, { + status: 304, + headers: { ETag: etag, "Cache-Control": "private, no-cache" }, + }); + } const cached = getScopedSiteSettingsCache(cacheKey); - if (cached && cached.expiresAt > Date.now()) { + if (cached && cached.expiresAt > Date.now() && (!etag || cached.etag === etag)) { return createScopedSiteSettingsResponse( scopedScope, cached.data, "HIT", - cacheKey + cacheKey, + { etag } ); } if (cached && scopedScope !== "home") { @@ -1073,6 +1117,7 @@ export async function GET(request: NextRequest) { const responsePromise = getScopedPublicSiteSettings(scopedScope, { brandName, cacheKey, + etag, }); return scopedScope === "home" ? await withTimeout(responsePromise, 1600, "site-settings:home") diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 1a0254020..c464da785 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -228,6 +228,7 @@ import { type WorkflowSceneRecord, } from "@/lib/video-workflow/runtime"; import { agentProjectService } from "@/services/agent-projects"; +import { fetchCanvasSiteSettings } from "@/lib/client/canvas-site-settings"; import { canvasService, imageGenerationService, @@ -265,30 +266,8 @@ import type { } from "@/types/canvas"; const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000; -const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000; const CANVAS_SURFACE_BACKGROUND = "#f5f5f5"; -const fetchCanvasSiteSettings = async (url: string) => { - const separator = url.includes("?") ? "&" : "?"; - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, CANVAS_SITE_SETTINGS_TIMEOUT_MS); - - const response = await fetch(`${url}${separator}t=${Date.now()}`, { - cache: "no-store", - signal: controller.signal, - }).finally(() => { - clearTimeout(timeoutId); - }); - - if (!response.ok) { - throw new Error("Failed to fetch canvas site settings"); - } - - return response.json(); -}; - function CanvasLazyPanelLoader({ label, className, @@ -2185,7 +2164,9 @@ export default function CanvasEditorLegacyPage({ [agentProjectId, id] ); const canvasSiteSettingsKey = requestedAgentMode - ? "/api/site-settings?scope=agent-canvas" + ? hasActiveAgentProject + ? "/api/site-settings?scope=agent-canvas" + : "/api/site-settings?scope=agent-entry" : "/api/site-settings?scope=canvas"; const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] = useState(false); @@ -2221,6 +2202,7 @@ export default function CanvasEditorLegacyPage({ ? 0 : CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS, errorRetryCount: 1, + dedupingInterval: 30_000, revalidateOnFocus: true, revalidateOnReconnect: true, }); @@ -2337,11 +2319,18 @@ export default function CanvasEditorLegacyPage({ }); const sanitizedWorkflowSelections = useMemo( () => - sanitizeAgentWorkflowSelections({ - config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, - selections: workflowSelections, - }), - [resolvedWorkflowUiConfig, workflowSelections] + requestedAgentMode && !siteSettingsData?.agentWorkflowUiConfig + ? workflowSelections + : sanitizeAgentWorkflowSelections({ + config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, + selections: workflowSelections, + }), + [ + requestedAgentMode, + siteSettingsData?.agentWorkflowUiConfig, + resolvedWorkflowUiConfig, + workflowSelections, + ] ); const deferredSanitizedWorkflowSelections = useDeferredValue( sanitizedWorkflowSelections @@ -4699,7 +4688,7 @@ export default function CanvasEditorLegacyPage({ title: nextTitle, thumbnail: agentProjectThumbnail, projectState: buildPersistedAgentProjectState(), - }); + }, { creationKey: `save-as:${id}:${agentProjectId}:${nextTitle}` }); if (!result.success || !result.data) { if (isUnauthorizedApiError(result.error)) { setIsAuthenticated(false); @@ -4724,6 +4713,7 @@ export default function CanvasEditorLegacyPage({ agentProjectThumbnail, agentProjectTitle, buildPersistedAgentProjectState, + id, openLoginDialog, persistAgentProject, updateCanvasSearchParams, @@ -4788,7 +4778,7 @@ export default function CanvasEditorLegacyPage({ const result = await agentProjectService.createProject({ title: buildDefaultAgentProjectTitle(), projectState: createEmptyAgentGenerationProjectPersistedState(), - }); + }, { creationKey: `blank:${id}:${agentProjectId}` }); if (!result.success || !result.data) { if (isUnauthorizedApiError(result.error)) { setIsAuthenticated(false); @@ -4811,6 +4801,7 @@ export default function CanvasEditorLegacyPage({ }, [ agentProjectId, isAgentProjectSaving, + id, openLoginDialog, persistAgentProject, updateCanvasSearchParams, @@ -7060,7 +7051,9 @@ export default function CanvasEditorLegacyPage({ const result = existingProject ? await agentProjectService.updateProject(existingProject.id, payload) - : await agentProjectService.createProject(payload); + : await agentProjectService.createProject(payload, { + creationKey: `workflow:${id}:${nodeId}`, + }); if (!result.success || !result.data) { if (isUnauthorizedApiError(result.error)) { diff --git a/lib/client/canvas-site-settings.ts b/lib/client/canvas-site-settings.ts new file mode 100644 index 000000000..70cebdb58 --- /dev/null +++ b/lib/client/canvas-site-settings.ts @@ -0,0 +1,24 @@ +const settingsCache = new Map(); + +export async function fetchCanvasSiteSettings(url: string) { + const cached = settingsCache.get(url); + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + url === "/api/site-settings?scope=agent-canvas" ? 30_000 : 10_000 + ); + try { + const response = await fetch(url, { + headers: cached ? { "If-None-Match": cached.etag } : undefined, + signal: controller.signal, + }); + if (response.status === 304 && cached) return cached.data; + if (!response.ok) throw new Error("Failed to fetch canvas site settings"); + const data = await response.json(); + const etag = response.headers.get("ETag"); + if (etag) settingsCache.set(url, { etag, data }); + return data; + } finally { + clearTimeout(timeoutId); + } +} diff --git a/lib/site-settings/public-cache.ts b/lib/site-settings/public-cache.ts index b7e588634..bbd29b88b 100644 --- a/lib/site-settings/public-cache.ts +++ b/lib/site-settings/public-cache.ts @@ -6,6 +6,7 @@ const PUBLIC_SITE_SETTINGS_CACHE_KEY = Symbol.for( type PublicSiteSettingsCacheEntry = { data: Record; expiresAt: number; + etag?: string; }; function getPublicSiteSettingsCacheStore() { @@ -34,10 +35,12 @@ export function setScopedSiteSettingsCache( cacheKey: string, data: Record, ttlMs: number = PUBLIC_SITE_SETTINGS_CACHE_TTL_MS, + etag?: string, ) { getPublicSiteSettingsCacheStore().set(cacheKey, { data, expiresAt: Date.now() + ttlMs, + etag, }); } diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 1b69fb7b7..4d4f7855e 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests", testMatch: [ + /routes\/agent-project-recovery\.test\.ts/, /routes\/admin-media-loading\.test\.ts/, /routes\/video-canvas-production\.test\.ts/, /routes\/video-canvas-media-import\.test\.ts/, diff --git a/services/agent-projects/index.ts b/services/agent-projects/index.ts index dbab23cc7..d883fc346 100644 --- a/services/agent-projects/index.ts +++ b/services/agent-projects/index.ts @@ -7,6 +7,7 @@ interface ApiResponse { success: boolean; data?: T; error?: string; + status?: number; } const AGENT_PROJECT_REQUEST_TIMEOUT_MS = 15_000; @@ -26,12 +27,38 @@ async function fetchAgentProjectApi( ...init, signal: controller.signal, }); - return await response.json(); + const result = await response.json(); + if (!response.ok) result.status = response.status; + return result; } finally { globalThis.clearTimeout(timeoutId); } } +type CreateProjectPayload = { + title?: string; + description?: string | null; + thumbnail?: string | null; + projectState?: AgentGenerationProjectPersistedState; +}; + +const pendingCreations = new Map>; +}>(); + +function pendingCreationStorage(key: string, value?: string | null) { + try { + if (typeof sessionStorage === "undefined") return null; + if (value === undefined) return sessionStorage.getItem(key); + if (value === null) sessionStorage.removeItem(key); + else sessionStorage.setItem(key, value); + } catch { + // Keep the in-memory request identity when browser storage is unavailable. + } + return null; +} + function getAgentProjectRequestErrorMessage(error: unknown, fallback: string) { if (error instanceof DOMException && error.name === "AbortError") { return `${fallback}:请求超时`; @@ -71,27 +98,52 @@ export const agentProjectService = { } }, - async createProject(payload: { - title?: string; - description?: string | null; - thumbnail?: string | null; - projectState?: AgentGenerationProjectPersistedState; - }): Promise> { - try { - return await fetchAgentProjectApi( - "/api/agent-projects", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + async createProject( + payload: CreateProjectPayload, + options: { creationKey?: string } = {} + ): Promise> { + const key = `agent-project:create:${options.creationKey || JSON.stringify(payload)}`; + let pending = pendingCreations.get(key); + if (!pending) { + pending = { id: pendingCreationStorage(key) || crypto.randomUUID() }; + pendingCreations.set(key, pending); + pendingCreationStorage(key, pending.id); + } + if (pending.promise) return pending.promise; + const current = pending; + const clearPending = () => { + pendingCreations.delete(key); + pendingCreationStorage(key, null); + }; + current.promise = (async () => { + try { + const result = await fetchAgentProjectApi( + "/api/agent-projects", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...payload, id: current.id }), + } + ); + if (result.success || (result.status && result.status < 500)) { + clearPending(); + return result; } - ); - } catch (error) { + } catch { + // A lost response does not establish whether the project was created. + } + const recovered = await agentProjectService.getProject(current.id); + if (recovered.success && recovered.data) { + clearPending(); + return recovered; + } + if (recovered.status === 401) return recovered; return { success: false, - error: getAgentProjectRequestErrorMessage(error, "创建生图项目失败"), + error: "创建结果尚未确认,请重试核对或打开历史项目;重试不会重复创建。", }; - } + })().finally(() => { current.promise = undefined; }); + return current.promise; }, async updateProject( diff --git a/tests/routes/agent-project-recovery.test.ts b/tests/routes/agent-project-recovery.test.ts new file mode 100644 index 000000000..7bb318967 --- /dev/null +++ b/tests/routes/agent-project-recovery.test.ts @@ -0,0 +1,229 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync, existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import vm from "node:vm"; +import { randomUUID, webcrypto } from "node:crypto"; +import ts from "typescript"; + +const root = path.resolve(__dirname, "../.."); +const id = randomUUID(); +const owner = randomUUID(); + +function loadRuntime(mocks: Record = {}, globals: Record = {}) { + const modules = new Map(); + const context = vm.createContext({ + console, Buffer, URL, Response, Request, Headers, DOMException, + AbortController, AbortSignal, crypto: webcrypto, setTimeout, clearTimeout, + process: { env: {} }, + fetch: () => { throw new Error("Unexpected network request"); }, + ...globals, + }); + const load = (relative: string): any => { + let filename = path.resolve(root, relative); + if (!existsSync(filename)) filename = path.join(filename.replace(/\.ts$/, ""), "index.ts"); + if (modules.has(filename)) return modules.get(filename).exports; + const module = { exports: {} }; + modules.set(filename, module); + const nativeRequire = createRequire(filename); + const requireMock = (name: string) => { + if (name === "server-only") return {}; + if (Object.hasOwn(mocks, name)) return mocks[name]; + if (name.startsWith("@/")) return load(`${name.slice(2)}.ts`); + if (name.startsWith(".")) return load(path.relative(root, path.resolve(path.dirname(filename), `${name}.ts`))); + return nativeRequire(name); + }; + const source = ts.transpileModule(readFileSync(filename, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true }, + }).outputText; + context.module = module; + context.exports = module.exports; + context.require = requireMock; + const fn = new vm.Script(`(function(module,exports,require){${source}\n})`, { filename }).runInContext(context); + fn(module, module.exports, requireMock); + return module.exports; + }; + return load; +} + +function memoryStorage() { + const values = new Map(); + return { getItem: (key: string) => values.get(key) || null, setItem: (key: string, value: string) => values.set(key, value), removeItem: (key: string) => values.delete(key) }; +} + +function projectRoute() { + const projects = new Map(); + let userId: string | null = owner; + const db = { + insert: () => ({ values: (value: any) => ({ onConflictDoNothing: () => ({ returning: async () => { + const row = { ...value, id: value.id || randomUUID(), createdAt: new Date(), updatedAt: new Date() }; + if (projects.has(row.id)) return []; + projects.set(row.id, row); + return [row]; + } }) }) }), + select: () => ({ from: () => ({ where: (conditions: any[]) => ({ limit: async () => [...projects.values()].filter((row) => conditions.every(([key, value]) => row[key] === value)) }) }) }), + }; + const load = loadRuntime({ + "@/lib/db": { db }, + "@/lib/db/schema": { canvasProject: { id: "id", userId: "userId", workspaceType: "workspaceType" } }, + "@/app/(auth)/auth": { auth: async () => userId ? { user: { id: userId } } : null }, + "drizzle-orm": { eq: (key: string, value: unknown) => [key, value], and: (...parts: unknown[]) => parts, desc: (value: unknown) => value }, + }); + return { route: load("app/api/agent-projects/route.ts"), projects, setUser: (value: string | null) => { userId = value; } }; +} + +test("duplicate POSTs return one project without overwriting saved state", async () => { + const { route, projects } = projectRoute(); + const post = (title: string) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify({ id, title }) })); + const responses = await Promise.all([post("原项目"), post("重试标题")]); + expect(projects.size).toBe(1); + for (const response of responses) { + expect(response.status).toBe(200); + expect((await response.json()).data).toMatchObject({ id, title: "原项目", userId: owner }); + } + projects.get(id).title = "已编辑项目"; + expect((await (await post("迟到的重试")).json()).data.title).toBe("已编辑项目"); +}); + +test("creation retains account ownership and accepts existing clients", async () => { + const { route, projects, setUser } = projectRoute(); + const post = (body: unknown) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify(body) })); + expect((await post({ id })).status).toBe(200); + setUser(randomUUID()); + const conflict = await post({ id }); + expect(conflict.status).toBe(409); + expect((await conflict.json()).data).toBeUndefined(); + expect(projects.size).toBe(1); + expect((await post({ title: "旧客户端" })).status).toBe(200); + expect((await post({ id: "invalid" })).status).toBe(400); + setUser(null); + expect((await post({ id: randomUUID() })).status).toBe(401); +}); + +test("a lost create response recovers the inserted project", async () => { + let project: any; + let posts = 0; + const fetchMock = async (_url: string, init?: RequestInit) => { + if (init?.method === "POST") { + posts++; + project = JSON.parse(String(init.body)); + throw new DOMException("Aborted", "AbortError"); + } + return Response.json({ success: true, data: project }); + }; + const { agentProjectService } = loadRuntime({}, { fetch: fetchMock, sessionStorage: memoryStorage() })("services/agent-projects/index.ts"); + const result = await agentProjectService.createProject({ title: "新项目" }, { creationKey: "blank:test" }); + expect(result.success).toBe(true); + expect(result.data.id).toBe(project.id); + expect(posts).toBe(1); +}); + +test("unconfirmed creation survives reload and retries with the same project ID", async () => { + const storage = memoryStorage(); + const ids: string[] = []; + let offline = true; + const fetchMock = async (_url: string, init?: RequestInit) => { + if (init?.method === "POST") { + const body = JSON.parse(String(init.body)); + ids.push(body.id); + if (!offline) return Response.json({ success: true, data: body }); + throw new TypeError("Connection closed"); + } + return Response.json({ success: false }, { status: 404 }); + }; + const service = () => loadRuntime({}, { fetch: fetchMock, sessionStorage: storage })("services/agent-projects/index.ts").agentProjectService; + const first = await service().createProject({ title: "09:18" }, { creationKey: "blank:test" }); + expect(first.error).toContain("尚未确认"); + offline = false; + const reloaded = service(); + expect((await reloaded.createProject({ title: "09:21" }, { creationKey: "blank:test" })).success).toBe(true); + expect(ids[1]).toBe(ids[0]); + await reloaded.createProject({ title: "新的独立项目" }, { creationKey: "blank:test" }); + expect(ids[2]).not.toBe(ids[0]); +}); + +test("simultaneous clicks share one create request", async () => { + let posts = 0; + let finish!: () => void; + const wait = new Promise((resolve) => { finish = resolve; }); + const service = loadRuntime({}, { fetch: async (_url: string, init: RequestInit) => { + posts++; + await wait; + return Response.json({ success: true, data: JSON.parse(String(init.body)) }); + } })("services/agent-projects/index.ts").agentProjectService; + const first = service.createProject({}, { creationKey: "blank:double" }); + const second = service.createProject({}, { creationKey: "blank:double" }); + finish(); + const results = await Promise.all([first, second]); + expect(posts).toBe(1); + expect(results[0].data.id).toBe(results[1].data.id); +}); + +test("conditional settings requests reuse the full config after 304", async () => { + const headers: Headers[] = []; + const config = { steps: [{ key: "style", promptFragment: "保留提示词" }] }; + const { fetchCanvasSiteSettings } = loadRuntime({}, { fetch: async (_url: string, init: RequestInit) => { + headers.push(new Headers(init.headers)); + return headers.length === 1 ? Response.json(config, { headers: { ETag: 'W/"revision-1"' } }) : new Response(null, { status: 304 }); + } })("lib/client/canvas-site-settings.ts"); + const first = await fetchCanvasSiteSettings("/api/site-settings?scope=agent-canvas"); + const second = await fetchCanvasSiteSettings("/api/site-settings?scope=agent-canvas"); + expect(second).toBe(first); + expect(second).toEqual(config); + expect(headers[1].get("If-None-Match")).toBe('W/"revision-1"'); +}); + +test("settings timeout also aborts a stalled response body", async () => { + const { fetchCanvasSiteSettings } = loadRuntime({}, { + setTimeout: (callback: () => void) => setTimeout(callback, 5), + fetch: async (_url: string, init: RequestInit) => ({ ok: true, status: 200, json: () => new Promise((_, reject) => { + init.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))); + }) }), + })("lib/client/canvas-site-settings.ts"); + await expect(fetchCanvasSiteSettings("/api/site-settings?scope=agent-canvas")).rejects.toHaveProperty("name", "AbortError"); +}); + +test("entry config stays small and unchanged config returns 304 before loading values", async () => { + let revision = new Date("2026-11-04T00:00:00Z"); + let fullReads = 0; + const tables = { siteSettings: { id: "siteId", updatedAt: "siteUpdatedAt" }, systemSettings: { key: "key", updatedAt: "updatedAt" }, aiProvider: { id: "providerId", updatedAt: "providerUpdatedAt", isEnabled: "enabled", type: "type" } }; + const ui = { steps: [{ key: "style", title: "风格", enabled: true, sortOrder: 0, options: [{ key: "test", buttonName: "风格一", promptFragment: "生成提示词".repeat(3000), enabled: true, sortOrder: 0 }] }] }; + const db = { select: (columns: unknown) => ({ from: (table: unknown) => { + const result = () => { + if (table === tables.aiProvider) return []; + if (table === tables.siteSettings) return [{ id: "site", siteName: "由壹点AI", updatedAt: revision }]; + if (!columns) fullReads++; + return [{ key: "agent_workflow_ui_config", value: ui, updatedAt: revision }]; + }; + const query: any = Object.assign(Promise.resolve(result()), { + where: () => query, + limit: () => query, + }); + return query; + } }) }; + const route = loadRuntime({ + "@/lib/db": { db }, "@/lib/db/schema": tables, + "@/app/(auth)/auth": { auth: async () => null }, + })("app/api/site-settings/route.ts"); + const request = (scope: string, etag?: string) => { + const req = new Request(`http://localhost/api/site-settings?scope=${scope}`, { headers: etag ? { "If-None-Match": etag } : {} }); + return Object.assign(req, { nextUrl: new URL(req.url) }); + }; + const entry = await route.GET(request("agent-entry")); + expect(entry.status).toBe(200); + const entryData = await entry.json(); + expect(entryData.agentWorkflowUiConfig.steps).toEqual([]); + expect(JSON.stringify(entryData).length).toBeLessThan(20_000); + const full = await route.GET(request("agent-canvas")); + expect((await full.json()).agentWorkflowUiConfig.steps[0].options[0].promptFragment).toBe("生成提示词".repeat(3000).slice(0, 20_000)); + const etag = full.headers.get("ETag"); + const reads = fullReads; + const unchanged = await route.GET(request("agent-canvas", etag)); + expect(unchanged.status).toBe(304); + expect(await unchanged.text()).toBe(""); + expect(fullReads).toBe(reads); + revision = new Date("2026-11-04T00:01:00Z"); + const changed = await route.GET(request("agent-canvas", etag)); + expect(changed.status).toBe(200); + expect(changed.headers.get("ETag")).not.toBe(etag); +});