Compare commits

..

4 Commits

Author SHA1 Message Date
root 453de420fc fix(ui): show real contents in optimistic image messages and widen settings chip
Use the user-visible contents (without injected image-mode settings) for the
optimistic agent messages so the settings blob is not echoed in the thread.
Widen the composer settings chip to 150px so longer labels are not clipped.
Also picks up regenerated next-env.d.ts and tsbuildinfo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:10:17 +08:00
root 9a07f863ea fix(design): preserve existing canvas nodes when compressed snapshot omits them
SaveCanvas treated a nil Nodes/Connections slice from a compressed snapshot
as an empty canvas, wiping existing content. Distinguish nil (field omitted,
keep the project's current nodes/connections) from an explicit empty slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:10:07 +08:00
root ffd8d78df7 feat(piagent): bypass planner for direct image generation modes
For image / pure-image / image-generator / image-action modes, skip the
task-planning round trip and hand the prompt straight to the image model,
deriving the requested image count from the composer settings. Also
normalize completed image-generator nodes back into regular image nodes
(clearing the image-generator layer role and defaulting Tone to "visual")
when merging generated results and when restoring finished nodes, so a
finished generator node is not re-appended as a duplicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:09:59 +08:00
root a39e18950c refactor(auth): require bearer token only, drop legacy token header and cookie
Authenticate requests solely from the Authorization: Bearer header on the
server; stop accepting the legacy `token` header and `usertoken` cookie.
The frontend no longer sets auth cookies (only clears legacy ones), omits
credentials on all requests, and drops the redundant `token` header. Project
event SSE now streams over fetch so it can carry the Authorization header,
which EventSource cannot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:09:39 +08:00
15 changed files with 821 additions and 130 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+16 -16
View File
@@ -2,14 +2,15 @@ import type { AccountDevice, AuthCodeResponse, AuthOptions, AuthSession, AuthUse
import { createApiFailure, createNetworkFailure } from "@/infrastructure/userMessages"; import { createApiFailure, createNetworkFailure } from "@/infrastructure/userMessages";
const sessionStorageKey = "img-infinite-canvas.auth.session"; const sessionStorageKey = "img-infinite-canvas.auth.session";
const accessTokenCookie = "usertoken"; const legacyAccessTokenCookie = "usertoken";
const refreshTokenCookie = "refreshToken"; const legacyRefreshTokenCookie = "refreshToken";
async function request<T>(path: string, body: unknown): Promise<T> { async function request<T>(path: string, body: unknown): Promise<T> {
let response: Response; let response: Response;
try { try {
response = await fetch(path, { response = await fetch(path, {
method: "POST", method: "POST",
credentials: "omit",
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
@@ -30,7 +31,7 @@ async function request<T>(path: string, body: unknown): Promise<T> {
async function get<T>(path: string): Promise<T> { async function get<T>(path: string): Promise<T> {
let response: Response; let response: Response;
try { try {
response = await fetch(path, { cache: "no-store" }); response = await fetch(path, { cache: "no-store", credentials: "omit" });
} catch (err) { } catch (err) {
throw createNetworkFailure("auth", err); throw createNetworkFailure("auth", err);
} }
@@ -55,7 +56,8 @@ async function apiRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
response = await fetch(path, { response = await fetch(path, {
...init, ...init,
headers, headers,
cache: "no-store" cache: "no-store",
credentials: "omit"
}); });
} catch (err) { } catch (err) {
throw createNetworkFailure("auth", err); throw createNetworkFailure("auth", err);
@@ -131,6 +133,7 @@ export const authGateway = {
if (upload.uploadUrl && !upload.uploadUrl.includes("/memory-assets/")) { if (upload.uploadUrl && !upload.uploadUrl.includes("/memory-assets/")) {
await fetch(upload.uploadUrl, { await fetch(upload.uploadUrl, {
method: "PUT", method: "PUT",
credentials: "omit",
headers: upload.headers, headers: upload.headers,
body: file body: file
}); });
@@ -141,6 +144,7 @@ export const authGateway = {
export function readStoredAuthSession(): StoredAuthSession | null { export function readStoredAuthSession(): StoredAuthSession | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
clearLegacyAuthCookies();
const raw = window.localStorage.getItem(sessionStorageKey); const raw = window.localStorage.getItem(sessionStorageKey);
if (!raw) return null; if (!raw) return null;
try { try {
@@ -160,10 +164,7 @@ export function storeAuthSession(session: AuthSession): StoredAuthSession {
const stored = { ...session, savedAt: Date.now() }; const stored = { ...session, savedAt: Date.now() };
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.localStorage.setItem(sessionStorageKey, JSON.stringify(stored)); window.localStorage.setItem(sessionStorageKey, JSON.stringify(stored));
setCookie(accessTokenCookie, session.accessToken, session.expiresIn); clearLegacyAuthCookies();
if (session.refreshToken && session.refreshExpiresIn) {
setCookie(refreshTokenCookie, session.refreshToken, session.refreshExpiresIn);
}
} }
return stored; return stored;
} }
@@ -188,8 +189,7 @@ export function updateStoredAuthUser(userPatch: Partial<AuthUser>): StoredAuthSe
export function clearStoredAuthSession() { export function clearStoredAuthSession() {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
window.localStorage.removeItem(sessionStorageKey); window.localStorage.removeItem(sessionStorageKey);
expireCookie(accessTokenCookie); clearLegacyAuthCookies();
expireCookie(refreshTokenCookie);
} }
export function getAuthAccessToken() { export function getAuthAccessToken() {
@@ -197,11 +197,10 @@ export function getAuthAccessToken() {
} }
export function authHeaders() { export function authHeaders() {
const token = getAuthAccessToken(); const accessToken = getAuthAccessToken();
if (!token) return {}; if (!accessToken) return {};
return { return {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${accessToken}`
token
} satisfies Record<string, string>; } satisfies Record<string, string>;
} }
@@ -210,8 +209,9 @@ function isSessionExpired(session: StoredAuthSession) {
return expiresIn > 0 && session.savedAt + expiresIn * 1000 <= Date.now() + 30_000; return expiresIn > 0 && session.savedAt + expiresIn * 1000 <= Date.now() + 30_000;
} }
function setCookie(name: string, value: string, maxAge: number) { function clearLegacyAuthCookies() {
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${Math.max(0, Math.floor(maxAge))}; SameSite=Lax`; expireCookie(legacyAccessTokenCookie);
expireCookie(legacyRefreshTokenCookie);
} }
function expireCookie(name: string) { function expireCookie(name: string) {
+99 -31
View File
@@ -159,7 +159,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
try { try {
response = await fetch(path, { response = await fetch(path, {
...init, ...init,
headers headers,
credentials: "omit"
}); });
} catch (err) { } catch (err) {
throw createNetworkFailure("design", err); throw createNetworkFailure("design", err);
@@ -393,43 +394,56 @@ export const designGateway = {
}, },
projectEvents(projectId: string, handlers: ProjectEventHandlers, options: ProjectEventsOptions = {}) { projectEvents(projectId: string, handlers: ProjectEventHandlers, options: ProjectEventsOptions = {}) {
const source = new EventSource(projectEventsUrl(projectId, options)); const controller = new AbortController();
let settled = false; let settled = false;
const readProject = (event: MessageEvent) => { const readProject = (data: string) => {
const payload = JSON.parse(event.data) as { project: Project }; const payload = JSON.parse(data) as { project: Project };
return payload.project; return payload.project;
}; };
const close = () => {
source.addEventListener("project", (event) => handlers.onProject?.(readProject(event as MessageEvent)));
source.addEventListener("thinking", (event) => handlers.onThinking?.(JSON.parse((event as MessageEvent).data) as AgentMessage));
source.addEventListener("text_extraction", (event) => handlers.onTextExtraction?.(JSON.parse((event as MessageEvent).data) as ExtractNodeTextResponse));
source.addEventListener("done", (event) => {
settled = true; settled = true;
handlers.onDone?.(readProject(event as MessageEvent)); controller.abort();
source.close(); };
}); const onEvent = (event: string, data: string) => {
source.addEventListener("timeout", (event) => { if (event === "project") {
settled = true; handlers.onProject?.(readProject(data));
handlers.onTimeout?.(readProject(event as MessageEvent)); return;
source.close(); }
}); if (event === "thinking") {
source.addEventListener("stream_error", (event) => { handlers.onThinking?.(JSON.parse(data) as AgentMessage);
settled = true; return;
if ((event as MessageEvent).data) { }
const payload = JSON.parse((event as MessageEvent).data) as { error?: string }; if (event === "text_extraction") {
handlers.onError?.(new Error(payload.error ?? "Event stream failed")); handlers.onTextExtraction?.(JSON.parse(data) as ExtractNodeTextResponse);
} else { return;
handlers.onError?.(new Error("Event stream failed")); }
if (event === "done") {
settled = true;
handlers.onDone?.(readProject(data));
controller.abort();
return;
}
if (event === "timeout") {
settled = true;
handlers.onTimeout?.(readProject(data));
controller.abort();
return;
}
if (event === "stream_error") {
settled = true;
const payload = data ? (JSON.parse(data) as { error?: string }) : {};
handlers.onError?.(new Error(payload.error ?? "Event stream failed"));
controller.abort();
} }
source.close();
});
source.onerror = () => {
if (settled || source.readyState === EventSource.CLOSED) return;
handlers.onError?.(new Error("Event stream failed"));
source.close();
}; };
return () => source.close(); void streamProjectEvents(projectEventsUrl(projectId, options), controller.signal, onEvent).catch((error: Error) => {
if (settled || controller.signal.aborted) return;
settled = true;
handlers.onError?.(error);
});
return close;
}, },
saveCanvas( saveCanvas(
@@ -507,6 +521,7 @@ export const designGateway = {
if (upload.uploadUrl && !upload.uploadUrl.includes("/memory-assets/")) { if (upload.uploadUrl && !upload.uploadUrl.includes("/memory-assets/")) {
await fetch(upload.uploadUrl, { await fetch(upload.uploadUrl, {
method: "PUT", method: "PUT",
credentials: "omit",
headers: upload.headers, headers: upload.headers,
body: file body: file
}); });
@@ -866,3 +881,56 @@ function projectEventsUrl(projectId: string, options: ProjectEventsOptions) {
const query = params.toString(); const query = params.toString();
return `/api/projects/${projectId}/events${query ? `?${query}` : ""}`; return `/api/projects/${projectId}/events${query ? `?${query}` : ""}`;
} }
async function streamProjectEvents(url: string, signal: AbortSignal, onEvent: (event: string, data: string) => void) {
const headers = new Headers();
Object.entries(authHeaders()).forEach(([key, value]) => headers.set(key, value));
const response = await fetch(url, {
headers,
signal,
cache: "no-store",
credentials: "omit"
});
if (!response.ok) {
const payload = await response.json().catch(() => ({ error: response.statusText }));
throw createApiFailure("design", response, payload);
}
if (!response.body) {
throw new Error("Event stream failed");
}
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const parts = buffer.split(/\r?\n\r?\n/);
buffer = parts.pop() ?? "";
parts.forEach((part) => emitServerSentEvent(part, onEvent));
}
if (buffer.trim()) {
emitServerSentEvent(buffer, onEvent);
}
} finally {
reader.releaseLock();
}
}
function emitServerSentEvent(raw: string, onEvent: (event: string, data: string) => void) {
let event = "message";
const data: string[] = [];
raw.split(/\r?\n/).forEach((line) => {
if (line.startsWith("event:")) {
event = line.slice("event:".length).trim();
return;
}
if (line.startsWith("data:")) {
data.push(line.slice("data:".length).trimStart());
}
});
if (data.length > 0) {
onEvent(event, data.join("\n"));
}
}
@@ -241,7 +241,7 @@
} }
.composer-settings-chip { .composer-settings-chip {
max-width: 112px; max-width: 150px;
padding: 0 8px; padding: 0 8px;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@@ -2562,6 +2562,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
activeMode === "image" activeMode === "image"
? withImageModeSettings(baseContents, composerImageSettings, t, selectedGeneratorNode?.id) ? withImageModeSettings(baseContents, composerImageSettings, t, selectedGeneratorNode?.id)
: baseContents; : baseContents;
const visibleContents = activeMode === "image" ? baseContents : contents;
if (!project || !hasUserText) return; if (!project || !hasUserText) return;
const selectedThreadId = selectedAgentThreadIdRef.current; const selectedThreadId = selectedAgentThreadIdRef.current;
const reusableAgentThreadId = const reusableAgentThreadId =
@@ -2594,7 +2595,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
if (source === "image-generator") { if (source === "image-generator") {
imageGeneratorThreadIdsRef.current.add(threadId); imageGeneratorThreadIdsRef.current.add(threadId);
} else { } else {
setMessages((current) => [...current, ...createOptimisticAgentMessages(threadId, contents)]); setMessages((current) => [...current, ...createOptimisticAgentMessages(threadId, visibleContents)]);
} }
try { try {
if (dirty) { if (dirty) {
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/layout.tsx","./src/app/page.tsx","./src/app/root.tsx","./src/app/api/[...path]/route.ts","./src/application/route.ts","./src/components/ui/button.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/popover.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/domain/auth.ts","./src/domain/design.ts","./src/i18n/i18n.tsx","./src/infrastructure/authgateway.ts","./src/infrastructure/designgateway.ts","./src/infrastructure/imageuploadencoding.ts","./src/infrastructure/usermessages.ts","./src/ui/app.tsx","./src/ui/auth/authprovider/index.tsx","./src/ui/auth/logindialog/brandlogo.tsx","./src/ui/auth/logindialog/otpinput.tsx","./src/ui/auth/logindialog/turnstilewidget.tsx","./src/ui/auth/logindialog/index.tsx","./src/ui/components/brandmark.tsx","./src/ui/components/accountmanagementdialog/index.tsx","./src/ui/components/artboardpreview/index.tsx","./src/ui/components/canvasagentcomposer/index.tsx","./src/ui/components/canvasagenttimeline/index.tsx","./src/ui/components/canvasannotationpreview/index.tsx","./src/ui/components/canvasannotations/index.tsx","./src/ui/components/canvasminimap/index.tsx","./src/ui/components/canvasnodecontextmenu/index.tsx","./src/ui/components/canvaspanels/index.tsx","./src/ui/components/canvastoast/index.tsx","./src/ui/components/canvasworkoverlay/index.tsx","./src/ui/components/canvaszoommenu/index.tsx","./src/ui/components/deleteprojectdialog/index.tsx","./src/ui/components/imagegeneratorfloatingcomposer/index.tsx","./src/ui/components/imagenodeactions/index.tsx","./src/ui/components/imagetexteditpanel/index.tsx","./src/ui/components/layermergeskeleton/index.tsx","./src/ui/components/markdowncontent/index.tsx","./src/ui/components/multianglepanel/index.tsx","./src/ui/components/multiselectionoverlay/index.tsx","./src/ui/components/projectcard/index.tsx","./src/ui/components/projectthumb/index.tsx","./src/ui/components/promptcomposer/index.tsx","./src/ui/components/textstyletoolbar/fontcatalog.ts","./src/ui/components/textstyletoolbar/index.tsx","./src/ui/components/toolbarcustomizedialog/index.tsx","./src/ui/components/workspacetitle/index.tsx","./src/ui/hooks/usefloatingtoolbarplacement.ts","./src/ui/hooks/useusermessage.ts","./src/ui/lib/imageadjustments.ts","./src/ui/lib/imagedelivery.ts","./src/ui/lib/promptimagereferences.ts","./src/ui/pages/canvasworkspace/index.tsx","./src/ui/pages/canvasworkspace/canvas/canvascolorpopover.tsx","./src/ui/pages/canvasworkspace/canvas/framestyletoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/frametooloverlay.tsx","./src/ui/pages/canvasworkspace/canvas/penstroketoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/pentool.tsx","./src/ui/pages/canvasworkspace/canvas/workspacetoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/agentmessagefilters.ts","./src/ui/pages/canvasworkspace/canvas/canvasgeometry.ts","./src/ui/pages/canvasworkspace/canvas/canvasnodeops.ts","./src/ui/pages/canvasworkspace/canvas/framestyle.ts","./src/ui/pages/canvasworkspace/canvas/generatortaskpolling.ts","./src/ui/pages/canvasworkspace/canvas/imagegeneratorsizing.ts","./src/ui/pages/canvasworkspace/canvas/nodeexport.ts","./src/ui/pages/canvasworkspace/canvas/nodereferenceimages.ts","./src/ui/pages/canvasworkspace/canvas/shapenode.ts","./src/ui/pages/canvasworkspace/canvas/textnodedefaults.ts","./src/ui/pages/canvasworkspace/canvas/types.ts","./src/ui/pages/canvasworkspace/canvas/canvasselectionframe/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapenodepreview/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapestyletoolbar/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetoolmenu/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetooloverlay/index.tsx","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasdocument.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvaspersistence.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasselection.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usegenerationstream.ts","./src/ui/pages/homepage/index.tsx","./src/ui/pages/homeshell/index.tsx","./src/ui/pages/projectspage/index.tsx","./src/ui/pages/projectsroutepage/index.tsx"],"version":"6.0.3"} {"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/layout.tsx","./src/app/page.tsx","./src/app/root.tsx","./src/app/[...path]/page.tsx","./src/app/api/[...path]/route.ts","./src/app/project/[id]/page.tsx","./src/app/projects/page.tsx","./src/app/projects/[id]/page.tsx","./src/application/route.ts","./src/components/ui/button.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/popover.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/domain/auth.ts","./src/domain/design.ts","./src/i18n/i18n.tsx","./src/infrastructure/authgateway.ts","./src/infrastructure/designgateway.ts","./src/infrastructure/imageuploadencoding.ts","./src/infrastructure/usermessages.ts","./src/ui/app.tsx","./src/ui/auth/authprovider/index.tsx","./src/ui/auth/logindialog/brandlogo.tsx","./src/ui/auth/logindialog/otpinput.tsx","./src/ui/auth/logindialog/turnstilewidget.tsx","./src/ui/auth/logindialog/index.tsx","./src/ui/components/brandmark.tsx","./src/ui/components/accountmanagementdialog/index.tsx","./src/ui/components/artboardpreview/index.tsx","./src/ui/components/canvasagentcomposer/index.tsx","./src/ui/components/canvasagenttimeline/index.tsx","./src/ui/components/canvasannotationpreview/index.tsx","./src/ui/components/canvasannotations/index.tsx","./src/ui/components/canvasminimap/index.tsx","./src/ui/components/canvasnodecontextmenu/index.tsx","./src/ui/components/canvaspanels/index.tsx","./src/ui/components/canvastoast/index.tsx","./src/ui/components/canvasworkoverlay/index.tsx","./src/ui/components/canvaszoommenu/index.tsx","./src/ui/components/deleteprojectdialog/index.tsx","./src/ui/components/imagegeneratorfloatingcomposer/index.tsx","./src/ui/components/imagenodeactions/index.tsx","./src/ui/components/imagetexteditpanel/index.tsx","./src/ui/components/layermergeskeleton/index.tsx","./src/ui/components/markdowncontent/index.tsx","./src/ui/components/multianglepanel/index.tsx","./src/ui/components/multiselectionoverlay/index.tsx","./src/ui/components/projectcard/index.tsx","./src/ui/components/projectthumb/index.tsx","./src/ui/components/promptcomposer/index.tsx","./src/ui/components/textstyletoolbar/fontcatalog.ts","./src/ui/components/textstyletoolbar/index.tsx","./src/ui/components/toolbarcustomizedialog/index.tsx","./src/ui/components/workspacetitle/index.tsx","./src/ui/hooks/usefloatingtoolbarplacement.ts","./src/ui/hooks/usetypewritertext.ts","./src/ui/hooks/useusermessage.ts","./src/ui/lib/friendlyagenterrors.ts","./src/ui/lib/imageadjustments.ts","./src/ui/lib/imagedelivery.ts","./src/ui/lib/promptimagereferences.ts","./src/ui/pages/canvasworkspace/index.tsx","./src/ui/pages/canvasworkspace/canvas/canvascolorpopover.tsx","./src/ui/pages/canvasworkspace/canvas/framestyletoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/frametooloverlay.tsx","./src/ui/pages/canvasworkspace/canvas/penstroketoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/pentool.tsx","./src/ui/pages/canvasworkspace/canvas/workspacetoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/agentmessagefilters.ts","./src/ui/pages/canvasworkspace/canvas/canvasgeometry.ts","./src/ui/pages/canvasworkspace/canvas/canvasnodeops.ts","./src/ui/pages/canvasworkspace/canvas/framestyle.ts","./src/ui/pages/canvasworkspace/canvas/generatortaskpolling.ts","./src/ui/pages/canvasworkspace/canvas/imagegeneratorsizing.ts","./src/ui/pages/canvasworkspace/canvas/nodeexport.ts","./src/ui/pages/canvasworkspace/canvas/nodereferenceimages.ts","./src/ui/pages/canvasworkspace/canvas/shapenode.ts","./src/ui/pages/canvasworkspace/canvas/textnodedefaults.ts","./src/ui/pages/canvasworkspace/canvas/types.ts","./src/ui/pages/canvasworkspace/canvas/canvasselectionframe/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapenodepreview/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapestyletoolbar/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetoolmenu/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetooloverlay/index.tsx","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasdocument.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvaspersistence.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasselection.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usegenerationstream.ts","./src/ui/pages/homepage/index.tsx","./src/ui/pages/homeshell/index.tsx","./src/ui/pages/notfoundpage/index.tsx","./src/ui/pages/projectspage/index.tsx","./src/ui/pages/projectsroutepage/index.tsx"],"version":"6.0.3"}
+3 -3
View File
@@ -86,7 +86,7 @@ Development response includes an email preview so the frontend can render the sa
} }
``` ```
Returns an authenticated session with `accessToken`, `refreshToken`, and the user profile. Project creation, generation, upload, save, and deletion require this login token. Returns an authenticated session with `accessToken`, `refreshToken`, and the user profile. Project creation, generation, upload, save, and deletion require `Authorization: Bearer <accessToken>`.
`POST /api/auth/global/google-login` `POST /api/auth/global/google-login`
@@ -124,7 +124,7 @@ After binding succeeds, later QR logins use only `code` and `state`.
## Account ## Account
All account endpoints require the bearer token returned by login. Account profile state is owned by the auth module and persisted in PostgreSQL or the configured in-memory store. All account endpoints require `Authorization: Bearer <accessToken>`. Cookie auth and the legacy `token` header are not used. Account profile state is owned by the auth module and persisted in PostgreSQL or the configured in-memory store.
`GET /api/account/profile` `GET /api/account/profile`
@@ -151,7 +151,7 @@ Removes non-current devices when a future server-side session registry exists. I
`POST /api/account/logout` `POST /api/account/logout`
Records a logout request server-side and returns `{"removed": 0}`. The frontend then clears its local access token and cookies. Records a logout request server-side and returns `{"removed": 0}`. The frontend then clears its locally stored session.
## Projects ## Projects
@@ -182,6 +182,60 @@ func TestSaveCanvasAcceptsCompressedSnapshotPayload(t *testing.T) {
} }
} }
func TestSaveCanvasPreservesNodesWhenCompressedSnapshotOmitsThem(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
service := NewDesignService(repo, nil, nil, nil, nil)
service.now = func() time.Time {
return time.Unix(101, 0).UTC()
}
project := design.Project{
ID: "project-null-snapshot-nodes",
Title: "Null snapshot nodes",
Status: design.StatusReady,
UpdatedAt: time.Unix(100, 0).UTC(),
Version: "100000",
SnapshotID: "snapshot-old",
Viewport: design.Viewport{X: 1, Y: 2, K: 1},
Nodes: []design.Node{
{ID: "existing-image", Type: design.NodeTypeImage, Title: "Existing", Content: "http://localhost:19000/canvas-assets/existing.png", Status: "success"},
},
Connections: []design.Connection{
{ID: "existing-connection", FromNodeID: "existing-image", ToNodeID: "existing-image"},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
snapshotProject := project
snapshotProject.SnapshotID = "snapshot-client"
snapshotProject.Viewport = design.Viewport{X: 12, Y: 34, K: 0.8}
snapshotProject.Nodes = nil
snapshotProject.Connections = nil
canvas, err := design.EncodeCanvasSnapshot(snapshotProject)
if err != nil {
t.Fatal(err)
}
saved, err := service.SaveCanvas(ctx, project.ID, design.Viewport{}, nil, nil, "", "", canvas)
if err != nil {
t.Fatal(err)
}
if saved.SnapshotID != "snapshot-client" {
t.Fatalf("expected client snapshot id, got %s", saved.SnapshotID)
}
if saved.Viewport != snapshotProject.Viewport {
t.Fatalf("unexpected viewport: %#v", saved.Viewport)
}
if len(saved.Nodes) != 1 || saved.Nodes[0].ID != "existing-image" {
t.Fatalf("expected existing nodes to be preserved, got %#v", saved.Nodes)
}
if len(saved.Connections) != 1 || saved.Connections[0].ID != "existing-connection" {
t.Fatalf("expected existing connections to be preserved, got %#v", saved.Connections)
}
}
func TestSaveCanvasDoesNotDeleteImageAssetReferencedByAgentMessage(t *testing.T) { func TestSaveCanvasDoesNotDeleteImageAssetReferencedByAgentMessage(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repo := memory.NewProjectRepository() repo := memory.NewProjectRepository()
+168 -38
View File
@@ -547,11 +547,12 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
actionID := newID() actionID := newID()
targetNodeID := imageGeneratorTargetNodeID(req.Messages) targetNodeID := imageGeneratorTargetNodeID(req.Messages)
isolatedImageGenerator := isImageGeneratorRequest(mode, targetNodeID) isolatedImageGenerator := isImageGeneratorRequest(mode, targetNodeID)
directImageMode := isDirectImageGenerationMode(mode, isolatedImageGenerator)
enableWebSearch := req.EnableWebSearch || toolConfigEnablesSearch(req.ToolConfig) enableWebSearch := req.EnableWebSearch || toolConfigEnablesSearch(req.ToolConfig)
memory := buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages) memory := buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages)
if !isolatedImageGenerator { if !isolatedImageGenerator {
project.Messages = append(project.Messages, nextMessages := []design.Message{
design.Message{ {
ID: newID(), ID: newID(),
Role: "user", Role: "user",
Type: "user", Type: "user",
@@ -562,8 +563,11 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
StepID: newID(), StepID: newID(),
CreatedAt: now, CreatedAt: now,
}, },
agentPlanningMessage(threadID, actionID, prompt, enableWebSearch, now.Add(time.Millisecond)), }
) if !directImageMode {
nextMessages = append(nextMessages, agentPlanningMessage(threadID, actionID, prompt, enableWebSearch, now.Add(time.Millisecond)))
}
project.Messages = append(project.Messages, nextMessages...)
project.Status = design.StatusExploring project.Status = design.StatusExploring
project.LastThreadID = threadID project.LastThreadID = threadID
project.UpdatedAt = now project.UpdatedAt = now
@@ -571,31 +575,38 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
if err := s.repo.Save(ctx, project); err != nil { if err := s.repo.Save(ctx, project); err != nil {
return design.AgentThread{}, err return design.AgentThread{}, err
} }
s.streamAgentPlanningMessage(ctx, project.ID, threadID, design.AgentTaskPlanRequest{ if !directImageMode {
Project: projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID), s.streamAgentPlanningMessage(ctx, project.ID, threadID, design.AgentTaskPlanRequest{
Prompt: prompt, Project: projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID),
Mode: mode, Prompt: prompt,
Messages: req.Messages, Mode: mode,
Memory: buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages), Messages: req.Messages,
WebSearchEnabled: enableWebSearch, Memory: buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages),
}) WebSearchEnabled: enableWebSearch,
project, err = s.repo.Get(ctx, project.ID) })
if err != nil { project, err = s.repo.Get(ctx, project.ID)
return design.AgentThread{}, err if err != nil {
} return design.AgentThread{}, err
memory = buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages) }
if cancelled, err := s.projectThreadCancelled(ctx, project.ID, threadID); err != nil { memory = buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages)
return design.AgentThread{}, err if cancelled, err := s.projectThreadCancelled(ctx, project.ID, threadID); err != nil {
} else if cancelled { return design.AgentThread{}, err
return design.AgentThread{ } else if cancelled {
ThreadID: threadID, return design.AgentThread{
Status: "cancelled", ThreadID: threadID,
ThreadIDType: threadIDType, Status: "cancelled",
Project: project, ThreadIDType: threadIDType,
}, nil Project: project,
}, nil
}
} }
} }
taskPlan := s.planAgentTask(ctx, projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID), prompt, mode, req.Messages, enableWebSearch, isolatedImageGenerator, memory) var taskPlan design.AgentTaskPlan
if directImageMode {
taskPlan = directImageTaskPlan(prompt, req.Messages)
} else {
taskPlan = s.planAgentTask(ctx, projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID), prompt, mode, req.Messages, enableWebSearch, isolatedImageGenerator, memory)
}
if cancelled, err := s.projectThreadCancelled(ctx, project.ID, threadID); err != nil { if cancelled, err := s.projectThreadCancelled(ctx, project.ID, threadID); err != nil {
return design.AgentThread{}, err return design.AgentThread{}, err
} else if cancelled { } else if cancelled {
@@ -606,7 +617,7 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
Project: project, Project: project,
}, nil }, nil
} }
if !isolatedImageGenerator { if !isolatedImageGenerator && !directImageMode {
if err := s.maybeAddPlannedResearchStep(ctx, project.ID, threadID, projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages, enableWebSearch, taskPlan); err != nil { if err := s.maybeAddPlannedResearchStep(ctx, project.ID, threadID, projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages, enableWebSearch, taskPlan); err != nil {
return design.AgentThread{}, err return design.AgentThread{}, err
} }
@@ -647,7 +658,9 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
assistantMessageID := "" assistantMessageID := ""
if !isolatedImageGenerator { if !isolatedImageGenerator {
if shouldGenerateImage { if shouldGenerateImage {
applyPlannerTaskPlanMessage(&project, threadID, prompt, taskPlan) if !directImageMode {
applyPlannerTaskPlanMessage(&project, threadID, prompt, taskPlan)
}
} else { } else {
assistantMessageID = preparePlannerForChatMessage(&project, threadID, actionID, prompt, now.Add(time.Millisecond)) assistantMessageID = preparePlannerForChatMessage(&project, threadID, actionID, prompt, now.Add(time.Millisecond))
} }
@@ -1069,10 +1082,18 @@ func (s *DesignService) SaveCanvas(ctx context.Context, projectID string, viewpo
return design.Project{}, fmt.Errorf("%w: invalid canvas snapshot", design.ErrInvalidInput) return design.Project{}, fmt.Errorf("%w: invalid canvas snapshot", design.ErrInvalidInput)
} }
if len(nodes) == 0 { if len(nodes) == 0 {
nodes = snapshot.Nodes if snapshot.Nodes != nil {
nodes = snapshot.Nodes
} else {
nodes = append([]design.Node(nil), project.Nodes...)
}
} }
if len(connections) == 0 { if len(connections) == 0 {
connections = snapshot.Connections if snapshot.Connections != nil {
connections = snapshot.Connections
} else {
connections = append([]design.Connection(nil), project.Connections...)
}
} }
if viewport.K == 0 { if viewport.K == 0 {
viewport = snapshot.Viewport viewport = snapshot.Viewport
@@ -3468,6 +3489,84 @@ func shouldGenerateForAgentPrompt(prompt string, mode string, messages []design.
return false return false
} }
func isDirectImageGenerationMode(mode string, isolated bool) bool {
if isolated {
return true
}
modeText := strings.ToLower(strings.TrimSpace(mode))
return modeText == "image" || modeText == "pure-image" || modeText == "image-generator" || strings.Contains(modeText, "image-action")
}
func directImageTaskPlan(prompt string, messages []design.AgentChatMessage) design.AgentTaskPlan {
count := imageModeSettingsCount(messages)
if count <= 0 {
count = 1
}
if count > 10 {
count = 10
}
tasks := make([]design.AgentImageTask, 0, count)
for index := 0; index < count; index++ {
tasks = append(tasks, design.AgentImageTask{
Title: generatedTaskFallbackTitle(prompt, index, count),
Brief: prompt,
})
}
return design.AgentTaskPlan{
Intent: "image",
Reason: "direct image mode",
ImageCount: count,
ImageTasks: tasks,
}
}
func imageModeSettingsCount(messages []design.AgentChatMessage) int {
for _, message := range messages {
for _, content := range message.Contents {
if !strings.EqualFold(strings.TrimSpace(content.TextSource), "settings") {
continue
}
if count := imageModeSettingsCountFromText(content.Text); count > 0 {
return count
}
}
}
return 0
}
func imageModeSettingsCountFromText(text string) int {
lower := strings.ToLower(text)
for _, marker := range []string{"数量", "count"} {
index := strings.Index(lower, marker)
if index < 0 {
continue
}
if count := firstPositiveInt(lower[index+len(marker):]); count > 0 {
return count
}
}
return 0
}
func firstPositiveInt(text string) int {
value := 0
seen := false
for _, char := range text {
if char < '0' || char > '9' {
if seen {
break
}
continue
}
seen = true
value = value*10 + int(char-'0')
if value > 10 {
return 10
}
}
return value
}
func shouldForceImageMode(mode string) bool { func shouldForceImageMode(mode string) bool {
modeText := strings.ToLower(strings.TrimSpace(mode)) modeText := strings.ToLower(strings.TrimSpace(mode))
return strings.Contains(modeText, "image-action") || modeText == "image" || modeText == "pure-image" return strings.Contains(modeText, "image-action") || modeText == "image" || modeText == "pure-image"
@@ -4048,9 +4147,6 @@ func (s *DesignService) clearStaleGeneratingPlaceholders(project design.Project,
func (s *DesignService) restoreFinishedGeneratedImageNodes(project design.Project, now time.Time) (design.Project, bool) { func (s *DesignService) restoreFinishedGeneratedImageNodes(project design.Project, now time.Time) (design.Project, bool) {
artifacts := generatedImageArtifacts(project.Messages) artifacts := generatedImageArtifacts(project.Messages)
if len(artifacts) == 0 {
return project, false
}
artifactsByID := generatedImageArtifactsByID(project.Messages) artifactsByID := generatedImageArtifactsByID(project.Messages)
usedArtifacts := make(map[string]bool) usedArtifacts := make(map[string]bool)
next := make([]design.Node, 0, len(project.Nodes)) next := make([]design.Node, 0, len(project.Nodes))
@@ -4068,6 +4164,10 @@ func (s *DesignService) restoreFinishedGeneratedImageNodes(project design.Projec
} }
changed = true changed = true
} }
if normalized, ok := completedImageGeneratorNode(node); ok {
node = normalized
changed = true
}
if project.Status == design.StatusReady && node.Type == design.NodeTypeImage && node.Status == "generating" && !isGeneratedImageContent(node.Content) { if project.Status == design.StatusReady && node.Type == design.NodeTypeImage && node.Status == "generating" && !isGeneratedImageContent(node.Content) {
changed = true changed = true
continue continue
@@ -4155,6 +4255,17 @@ func restoreNodeFromGeneratedArtifact(node design.Node, artifact design.Generato
return node return node
} }
func completedImageGeneratorNode(node design.Node) (design.Node, bool) {
if node.Type != design.NodeTypeImage || node.LayerRole != "image-generator" || node.Status != "success" || !isGeneratedImageContent(node.Content) {
return node, false
}
node.LayerRole = ""
if strings.TrimSpace(node.Tone) == "" || node.Tone == "image-generator" {
node.Tone = "visual"
}
return node, true
}
func generationPlaceholdersAreStale(project design.Project, now time.Time) bool { func generationPlaceholdersAreStale(project design.Project, now time.Time) bool {
if !hasGeneratingCanvasWork(project.Nodes) { if !hasGeneratingCanvasWork(project.Nodes) {
return false return false
@@ -4361,20 +4472,24 @@ func imageGeneratorTargetNodeID(messages []design.AgentChatMessage) string {
func mergeGeneratedNodesIntoTarget(nodes []design.Node, generated []design.Node, targetNodeID string) []design.Node { func mergeGeneratedNodesIntoTarget(nodes []design.Node, generated []design.Node, targetNodeID string) []design.Node {
if strings.TrimSpace(targetNodeID) == "" || len(generated) == 0 { if strings.TrimSpace(targetNodeID) == "" || len(generated) == 0 {
return append(nodes, generated...) return append(nodes, completedImageGeneratorNodes(generated)...)
} }
targetNodeID = strings.TrimSpace(targetNodeID)
targetIndex := -1 targetIndex := -1
for index, node := range nodes { for index, node := range nodes {
if node.ID == targetNodeID && node.Type == design.NodeTypeImage && node.LayerRole == "image-generator" { if node.ID == targetNodeID && node.Type == design.NodeTypeImage {
targetIndex = index targetIndex = index
break break
} }
} }
if targetIndex < 0 { if targetIndex < 0 {
return append(nodes, generated...) return append(nodes, completedImageGeneratorNodes(generated)...)
} }
primary := generated[0] primary := generated[0]
if normalized, ok := completedImageGeneratorNode(primary); ok {
primary = normalized
}
target := nodes[targetIndex] target := nodes[targetIndex]
centerX := target.X + target.Width/2 centerX := target.X + target.Width/2
centerY := target.Y + target.Height/2 centerY := target.Y + target.Height/2
@@ -4383,6 +4498,9 @@ func mergeGeneratedNodesIntoTarget(nodes []design.Node, generated []design.Node,
target.Tone = primary.Tone target.Tone = primary.Tone
target.Status = primary.Status target.Status = primary.Status
target.LayerRole = "" target.LayerRole = ""
if strings.TrimSpace(target.Tone) == "" || target.Tone == "image-generator" {
target.Tone = "visual"
}
if primary.Width > 0 { if primary.Width > 0 {
target.Width = primary.Width target.Width = primary.Width
target.X = centerX - target.Width/2 target.X = centerX - target.Width/2
@@ -4393,11 +4511,23 @@ func mergeGeneratedNodesIntoTarget(nodes []design.Node, generated []design.Node,
} }
nodes[targetIndex] = target nodes[targetIndex] = target
if len(generated) > 1 { if len(generated) > 1 {
return append(nodes, generated[1:]...) return append(nodes, completedImageGeneratorNodes(generated[1:])...)
} }
return nodes return nodes
} }
func completedImageGeneratorNodes(nodes []design.Node) []design.Node {
next := make([]design.Node, len(nodes))
for index, node := range nodes {
if normalized, ok := completedImageGeneratorNode(node); ok {
next[index] = normalized
continue
}
next[index] = node
}
return next
}
func findNodeIndex(nodes []design.Node, nodeID string) int { func findNodeIndex(nodes []design.Node, nodeID string) int {
for index, node := range nodes { for index, node := range nodes {
if node.ID == nodeID { if node.ID == nodeID {
@@ -554,6 +554,75 @@ func TestAgentChatRequiresTextPrompt(t *testing.T) {
} }
} }
func TestAgentChatImageModeBypassesPlannerAndHidesSettings(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
planner := &recordingTaskPlanner{
fakeCreativeChatAgent: fakeCreativeChatAgent{
decision: design.AgentDecision{Intent: "image"},
},
}
service := NewDesignService(repo, nil, nil, nil, nil, planner)
queue := &fakeJobQueue{}
service.SetJobQueue(queue)
project := design.Project{
ID: "project-direct-image",
Title: "Direct image",
Status: design.StatusReady,
UpdatedAt: time.Now(),
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
thread, err := service.AgentChat(ctx, project.ID, design.AgentChatRequest{
Mode: "image",
ImageSize: "1536x1024",
Messages: []design.AgentChatMessage{
{
Role: "user",
Contents: []design.AgentContent{
{Type: "text", Text: "a cute bird flatten ui", TextSource: "input"},
{Type: "text", Text: "图片生成参数:质量 自动,尺寸 1536x1024,比例 3:2,数量 3 张。纯生图模式,请直接围绕用户输入生成图片,不展开额外聊天。", TextSource: "settings"},
},
},
},
})
if err != nil {
t.Fatal(err)
}
if thread.Status != "running" {
t.Fatalf("expected running thread, got %s", thread.Status)
}
if planner.called != 0 {
t.Fatalf("expected image mode to bypass planner, got %d calls", planner.called)
}
if len(queue.jobs) != 1 {
t.Fatalf("expected one image job, got %#v", queue.jobs)
}
job := queue.jobs[0]
if job.TaskPlan.ImageCount != 3 || len(job.TaskPlan.ImageTasks) != 3 {
t.Fatalf("expected direct image count from settings, got %#v", job.TaskPlan)
}
if job.ImageSize != "1536x1024" {
t.Fatalf("expected image size to be preserved, got %s", job.ImageSize)
}
saved, err := repo.Get(ctx, project.ID)
if err != nil {
t.Fatal(err)
}
if len(saved.Messages) != 1 {
t.Fatalf("expected only the user message before generation starts, got %#v", saved.Messages)
}
if saved.Messages[0].Content != "a cute bird flatten ui" {
t.Fatalf("expected settings to stay out of visible messages, got %q", saved.Messages[0].Content)
}
if len(saved.Nodes) != 3 {
t.Fatalf("expected three direct image placeholders, got %#v", saved.Nodes)
}
}
func TestAgentChatCreatesPlaceholdersFromModelPlannedImageTasks(t *testing.T) { func TestAgentChatCreatesPlaceholdersFromModelPlannedImageTasks(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repo := memory.NewProjectRepository() repo := memory.NewProjectRepository()
@@ -50,6 +50,44 @@ func TestMergeGeneratedNodesIntoImageGeneratorTargetUsesGeneratedSizeAndKeepsCen
} }
} }
func TestMergeGeneratedNodesIntoCompletedImageGeneratorTargetDoesNotReappend(t *testing.T) {
nodes := []design.Node{
{
ID: "generator-1",
Type: design.NodeTypeImage,
Title: "旧图",
Content: "https://example.com/old.png",
Tone: "visual",
Status: "success",
LayerRole: "",
Width: 360,
Height: 240,
},
}
generated := []design.Node{
{
ID: "generator-1",
Type: design.NodeTypeImage,
Title: "新图",
Content: "https://example.com/new.png",
Tone: "image-generator",
Status: "success",
LayerRole: "image-generator",
Width: 1024,
Height: 1024,
},
}
merged := mergeGeneratedNodesIntoTarget(nodes, generated, "generator-1")
if len(merged) != 1 {
t.Fatalf("expected existing target to be updated in place, got %#v", merged)
}
node := merged[0]
if node.Content != "https://example.com/new.png" || node.LayerRole != "" || node.Tone != "visual" {
t.Fatalf("expected completed image generator to become regular image, got %#v", node)
}
}
func TestImageGeneratorTargetNodeIDParsesDirectiveCaseInsensitively(t *testing.T) { func TestImageGeneratorTargetNodeIDParsesDirectiveCaseInsensitively(t *testing.T) {
targetID := imageGeneratorTargetNodeID([]design.AgentChatMessage{ targetID := imageGeneratorTargetNodeID([]design.AgentChatMessage{
{ {
@@ -64,6 +102,36 @@ func TestImageGeneratorTargetNodeIDParsesDirectiveCaseInsensitively(t *testing.T
} }
} }
func TestRestoreFinishedGeneratedImageNodesConvertsCompletedImageGeneratorNode(t *testing.T) {
now := time.Now()
project := design.Project{
ID: "project-completed-generator",
Status: design.StatusReady,
Nodes: []design.Node{
{
ID: "generator-1",
Type: design.NodeTypeImage,
Title: "生成图片",
Content: "http://localhost:19000/canvas-assets/cat.png",
Tone: "image-generator",
Status: "success",
LayerRole: "image-generator",
Width: 1024,
Height: 1024,
},
},
}
restored, changed := (*DesignService)(nil).restoreFinishedGeneratedImageNodes(project, now.Add(time.Second))
if !changed {
t.Fatal("expected completed image generator node to be normalized")
}
node := restored.Nodes[0]
if node.LayerRole != "" || node.Tone != "visual" || node.Status != "success" || node.Content != "http://localhost:19000/canvas-assets/cat.png" {
t.Fatalf("expected regular completed image node, got %#v", node)
}
}
func TestRestoreFinishedGeneratedImageNodesRepairsStaleCanvasSnapshot(t *testing.T) { func TestRestoreFinishedGeneratedImageNodesRepairsStaleCanvasSnapshot(t *testing.T) {
now := time.Now() now := time.Now()
project := design.Project{ project := design.Project{
@@ -34,20 +34,7 @@ func UserContextMiddleware(authenticator bearerAuthenticator) func(http.HandlerF
} }
func userIDFromRequestAuth(ctx context.Context, r *http.Request, authenticator bearerAuthenticator) (string, bool) { func userIDFromRequestAuth(ctx context.Context, r *http.Request, authenticator bearerAuthenticator) (string, bool) {
if tokenUserID, ok := authenticator.UserIDFromBearer(ctx, r.Header.Get("Authorization")); ok { return authenticator.UserIDFromBearer(ctx, r.Header.Get("Authorization"))
return tokenUserID, true
}
if token := strings.TrimSpace(r.Header.Get("token")); token != "" {
if tokenUserID, ok := authenticator.UserIDFromBearer(ctx, "Bearer "+token); ok {
return tokenUserID, true
}
}
if cookie, err := r.Cookie("usertoken"); err == nil && strings.TrimSpace(cookie.Value) != "" {
if tokenUserID, ok := authenticator.UserIDFromBearer(ctx, "Bearer "+cookie.Value); ok {
return tokenUserID, true
}
}
return "", false
} }
func requiresAuthenticatedUser(r *http.Request) bool { func requiresAuthenticatedUser(r *http.Request) bool {
@@ -0,0 +1,60 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"img_infinite_canvas/internal/domain/design"
)
type fakeBearerAuthenticator struct{}
func (fakeBearerAuthenticator) UserIDFromBearer(_ context.Context, authorization string) (string, bool) {
if authorization == "Bearer valid" {
return "91cd197b-8255-4172-9981-77391fd38f33", true
}
return "", false
}
func TestUserContextMiddlewareUsesAuthorizationHeader(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/projects", nil)
req.Header.Set("Authorization", "Bearer valid")
rec := httptest.NewRecorder()
called := false
UserContextMiddleware(fakeBearerAuthenticator{})(func(w http.ResponseWriter, r *http.Request) {
called = true
if got := design.UserIDFromContext(r.Context()); got != "91cd197b-8255-4172-9981-77391fd38f33" {
t.Fatalf("expected authenticated user in context, got %s", got)
}
w.WriteHeader(http.StatusNoContent)
})(rec, req)
if !called {
t.Fatal("expected next handler to be called")
}
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status %d, got %d", http.StatusNoContent, rec.Code)
}
}
func TestUserContextMiddlewareIgnoresLegacyTokenHeaderAndCookie(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/projects", nil)
req.Header.Set("token", "valid")
req.AddCookie(&http.Cookie{Name: "usertoken", Value: "valid"})
rec := httptest.NewRecorder()
called := false
UserContextMiddleware(fakeBearerAuthenticator{})(func(w http.ResponseWriter, r *http.Request) {
called = true
})(rec, req)
if called {
t.Fatal("expected next handler not to be called")
}
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
}
}
@@ -46,6 +46,10 @@ func NewDesignAgent(base design.AgentRunner, imageAgent ImageGenerator, textAgen
} }
func (a *DesignAgent) Plan(ctx context.Context, req design.AgentRequest) (design.AgentPlan, error) { func (a *DesignAgent) Plan(ctx context.Context, req design.AgentRequest) (design.AgentPlan, error) {
if shouldUseDirectImageGeneration(req) {
return a.planDirectImageGeneration(ctx, req)
}
plan, err := a.base.Plan(ctx, req) plan, err := a.base.Plan(ctx, req)
if err != nil { if err != nil {
return design.AgentPlan{}, err return design.AgentPlan{}, err
@@ -76,6 +80,10 @@ func (a *DesignAgent) Plan(ctx context.Context, req design.AgentRequest) (design
} }
func (a *DesignAgent) PlanIncremental(ctx context.Context, req design.AgentRequest, onResult func(design.AgentImageGenerationResult) error) (design.AgentPlan, error) { func (a *DesignAgent) PlanIncremental(ctx context.Context, req design.AgentRequest, onResult func(design.AgentImageGenerationResult) error) (design.AgentPlan, error) {
if shouldUseDirectImageGeneration(req) {
return a.planDirectImageGenerationIncremental(ctx, req, onResult)
}
plan, err := a.base.Plan(ctx, req) plan, err := a.base.Plan(ctx, req)
if err != nil { if err != nil {
return design.AgentPlan{}, err return design.AgentPlan{}, err
@@ -115,6 +123,92 @@ func (a *DesignAgent) PlanIncremental(ctx context.Context, req design.AgentReque
return plan, nil return plan, nil
} }
func (a *DesignAgent) planDirectImageGeneration(ctx context.Context, req design.AgentRequest) (design.AgentPlan, error) {
if a.imageAgent == nil {
return design.AgentPlan{}, fmt.Errorf("gpt image 2 is not configured")
}
generationReq := req
generationReq.ImageSize = resolveImageSizeForGeneration(req.ImageSize, req.Prompt)
prompts := directImagePrompts(generationReq)
imageURLs, err := a.generatePlannedImages(ctx, prompts, generationReq)
if err != nil {
return design.AgentPlan{}, fmt.Errorf("gpt image 2 generation failed: %w", err)
}
plan := directImagePlan(generationReq)
applyPlannedGeneratedImages(&plan, generationReq, imageURLs, prompts)
title := ""
if len(prompts) > 0 {
title = prompts[0].Title
}
plan.Message.Title = generatedMessageTitle(title, req.Prompt)
return plan, nil
}
func (a *DesignAgent) planDirectImageGenerationIncremental(ctx context.Context, req design.AgentRequest, onResult func(design.AgentImageGenerationResult) error) (design.AgentPlan, error) {
if a.imageAgent == nil {
return design.AgentPlan{}, fmt.Errorf("gpt image 2 is not configured")
}
generationReq := req
generationReq.ImageSize = resolveImageSizeForGeneration(req.ImageSize, req.Prompt)
prompts := directImagePrompts(generationReq)
plan := directImagePlan(generationReq)
nodes, err := a.generatePlannedImagesIncremental(ctx, prompts, generationReq, plan.GeneratedNodes, onResult)
if err != nil {
if len(nodes) > 0 {
plan.GeneratedNodes = nodes
plan.Connections = nil
}
return plan, fmt.Errorf("gpt image 2 generation failed: %w", err)
}
plan.GeneratedNodes = nodes
plan.Connections = nil
title := ""
if len(prompts) > 0 {
title = prompts[0].Title
}
plan.Message.Title = generatedMessageTitle(title, req.Prompt)
return plan, nil
}
func directImagePlan(req design.AgentRequest) design.AgentPlan {
return design.AgentPlan{
Message: design.Message{
ID: uuid.NewString(),
Role: "assistant",
Type: "assistant",
Title: generatedMessageTitle("", req.Prompt),
Content: "",
},
}
}
func directImagePrompts(req design.AgentRequest) []design.AgentImagePrompt {
count := plannedGenerationCount(req)
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
prompt = fallbackDirectImagePrompt(req)
}
prompts := make([]design.AgentImagePrompt, 0, count)
for index := 0; index < count; index++ {
title := normalizeGeneratedTitle("", req.Prompt)
if index < len(req.TaskPlan.ImageTasks) {
if taskTitle := strings.TrimSpace(req.TaskPlan.ImageTasks[index].Title); taskTitle != "" {
title = normalizeGeneratedTitle(taskTitle, req.Prompt)
}
}
prompts = append(prompts, design.AgentImagePrompt{
Title: title,
Prompt: prepareGPTImagePrompt(variantImagePrompt(prompt, index+1, count), imageGenerationPromptMaxRunes),
})
}
return prompts
}
func shouldUseDirectImageGeneration(req design.AgentRequest) bool {
mode := strings.ToLower(strings.TrimSpace(req.Mode))
return mode == "image" || mode == "pure-image" || mode == "image-generator" || strings.Contains(mode, "image-action")
}
func (a *DesignAgent) buildImagePrompts(ctx context.Context, req design.AgentRequest) []design.AgentImagePrompt { func (a *DesignAgent) buildImagePrompts(ctx context.Context, req design.AgentRequest) []design.AgentImagePrompt {
count := plannedGenerationCount(req) count := plannedGenerationCount(req)
prompts := make([]design.AgentImagePrompt, 0, count) prompts := make([]design.AgentImagePrompt, 0, count)
@@ -667,28 +761,7 @@ func applyGeneratedImages(plan *design.AgentPlan, req design.AgentRequest, image
slots := generatedImageSlots(req, plan.GeneratedNodes, requestedCount) slots := generatedImageSlots(req, plan.GeneratedNodes, requestedCount)
nodes := make([]design.Node, 0, requestedCount) nodes := make([]design.Node, 0, requestedCount)
for index := 0; index < requestedCount; index++ { for index := 0; index < requestedCount; index++ {
node := slots[index] nodes = append(nodes, generatedImageNodeFromSlot(slots[index], req, imageURLs[index], title, index, requestedCount))
node.Type = design.NodeTypeImage
node.Title = generatedNodeTitle(title, req.Prompt, index, requestedCount)
node.Content = imageURLs[index]
node.Status = "success"
if node.ID == "" {
node.ID = uuid.NewString()
}
if width, height, ok := imageSizeDimensions(req.ImageSize); ok {
node.Width = width
node.Height = height
}
if node.Width <= 0 {
node.Width = design.DefaultGeneratedImageWidth
}
if node.Height <= 0 {
node.Height = design.DefaultGeneratedImageHeight
}
if node.Tone == "" {
node.Tone = "visual"
}
nodes = append(nodes, node)
} }
plan.GeneratedNodes = nodes plan.GeneratedNodes = nodes
plan.Connections = nil plan.Connections = nil
@@ -731,7 +804,10 @@ func generatedImageNodeFromSlot(slot design.Node, req design.AgentRequest, image
if node.Height <= 0 { if node.Height <= 0 {
node.Height = design.DefaultGeneratedImageHeight node.Height = design.DefaultGeneratedImageHeight
} }
if node.Tone == "" { if node.LayerRole == "image-generator" {
node.LayerRole = ""
}
if node.Tone == "" || node.Tone == "image-generator" {
node.Tone = "visual" node.Tone = "visual"
} }
return node return node
@@ -50,6 +50,42 @@ func (fakeRunner) Replay(ctx context.Context, project design.Project) (design.Ag
return design.AgentReplay{ProjectID: project.ID, Messages: project.Messages}, nil return design.AgentReplay{ProjectID: project.ID, Messages: project.Messages}, nil
} }
type recordingRunner struct {
calls int
}
func (r *recordingRunner) Plan(ctx context.Context, req design.AgentRequest) (design.AgentPlan, error) {
r.calls++
return design.AgentPlan{Message: design.Message{ID: "message-1", Role: "assistant", Title: "done", Content: "done"}}, nil
}
func (r *recordingRunner) Replay(ctx context.Context, project design.Project) (design.AgentReplay, error) {
return design.AgentReplay{ProjectID: project.ID, Messages: project.Messages}, nil
}
type recordingCreativeAgent struct {
buildCalls int
summarizeCalls int
}
func (a *recordingCreativeAgent) Decide(ctx context.Context, req design.AgentDecisionRequest) (design.AgentDecision, error) {
return design.AgentDecision{}, nil
}
func (a *recordingCreativeAgent) Respond(ctx context.Context, req design.AgentConversationRequest) (string, error) {
return "", nil
}
func (a *recordingCreativeAgent) SummarizeImage(ctx context.Context, req design.AgentImageSummaryRequest) (string, error) {
a.summarizeCalls++
return "summary", nil
}
func (a *recordingCreativeAgent) BuildImagePrompt(ctx context.Context, req design.AgentImagePromptRequest) (design.AgentImagePrompt, error) {
a.buildCalls++
return design.AgentImagePrompt{Title: "Rewritten", Prompt: "rewritten by gpt-5.5"}, nil
}
type fakeImageGenerator struct { type fakeImageGenerator struct {
mu sync.Mutex mu sync.Mutex
called bool called bool
@@ -114,6 +150,126 @@ func imageOptionIndex(prompt string, fallback int) int {
return fallback return fallback
} }
func TestDesignAgentImageModeCallsImageModelDirectly(t *testing.T) {
runner := &recordingRunner{}
creative := &recordingCreativeAgent{}
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(runner, imageGenerator, creative)
plan, err := agent.Plan(context.Background(), design.AgentRequest{
Project: design.Project{ID: "project-direct", Title: "Direct image"},
Prompt: "a cute bird flatten ui",
Mode: "image",
ImageModel: "gpt-image-2",
ImageSize: "1536x1024",
TaskPlan: design.AgentTaskPlan{
Intent: "image",
ImageCount: 1,
ImageTasks: []design.AgentImageTask{
{Title: "Cute bird", Brief: "a cute bird flatten ui"},
},
},
})
if err != nil {
t.Fatal(err)
}
if runner.calls != 0 {
t.Fatalf("expected direct image mode to bypass base runner, got %d calls", runner.calls)
}
if creative.buildCalls != 0 || creative.summarizeCalls != 0 {
t.Fatalf("expected direct image mode to bypass gpt-5.5 helpers, got build=%d summarize=%d", creative.buildCalls, creative.summarizeCalls)
}
if !imageGenerator.called {
t.Fatal("expected image generator to be called")
}
if !strings.Contains(imageGenerator.prompt, "a cute bird flatten ui") {
t.Fatalf("expected raw user prompt to reach image generator, got %q", imageGenerator.prompt)
}
if strings.Contains(imageGenerator.prompt, "rewritten by gpt-5.5") || strings.Contains(imageGenerator.prompt, "Create one polished") {
t.Fatalf("expected direct prompt without analysis wrapper, got %q", imageGenerator.prompt)
}
if len(plan.GeneratedNodes) != 1 || plan.GeneratedNodes[0].Content == "" {
t.Fatalf("expected one generated image node, got %#v", plan.GeneratedNodes)
}
}
func TestDesignAgentImageGeneratorModeCallsImageModelDirectly(t *testing.T) {
runner := &recordingRunner{}
creative := &recordingCreativeAgent{}
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(runner, imageGenerator, creative)
var emitted []design.Node
plan, err := agent.PlanIncremental(context.Background(), design.AgentRequest{
Project: design.Project{
ID: "project-image-generator",
Nodes: []design.Node{
{
ID: "generator-1",
Type: design.NodeTypeImage,
Tone: "image-generator",
Status: "generating",
LayerRole: "image-generator",
Width: 512,
Height: 512,
},
},
},
Prompt: "画一只蓝色小鸟",
Mode: "image-generator",
ImageModel: "gpt-image-2",
ImageSize: "1024x1024",
Messages: []design.AgentChatMessage{
{
Role: "user",
Contents: []design.AgentContent{
{Type: "image", ImageURL: "https://example.com/reference.png"},
{Type: "text", Text: "画一只蓝色小鸟", TextSource: "input"},
{Type: "text", Text: "Image generator target node: generator-1", TextSource: "settings"},
},
},
},
TaskPlan: design.AgentTaskPlan{
Intent: "image",
ImageCount: 1,
ImageTasks: []design.AgentImageTask{
{Title: "蓝色小鸟", Brief: "画一只蓝色小鸟"},
},
},
}, func(result design.AgentImageGenerationResult) error {
emitted = append(emitted, result.Node)
return nil
})
if err != nil {
t.Fatal(err)
}
if runner.calls != 0 {
t.Fatalf("expected image generator mode to bypass base runner, got %d calls", runner.calls)
}
if creative.buildCalls != 0 || creative.summarizeCalls != 0 {
t.Fatalf("expected image generator mode to bypass gpt-5.5 helpers, got build=%d summarize=%d", creative.buildCalls, creative.summarizeCalls)
}
if !imageGenerator.called {
t.Fatal("expected image generator to be called")
}
if !strings.Contains(imageGenerator.prompt, "画一只蓝色小鸟") {
t.Fatalf("expected raw image generator prompt to reach image generator, got %q", imageGenerator.prompt)
}
if strings.Contains(imageGenerator.prompt, "rewritten by gpt-5.5") || strings.Contains(imageGenerator.prompt, "Create one polished") {
t.Fatalf("expected direct image generator prompt without analysis wrapper, got %q", imageGenerator.prompt)
}
if len(imageGenerator.opts.Images) != 1 || imageGenerator.opts.Images[0] != "https://example.com/reference.png" {
t.Fatalf("expected reference image to be passed directly, got %#v", imageGenerator.opts.Images)
}
if len(emitted) != 1 || len(plan.GeneratedNodes) != 1 {
t.Fatalf("expected one generated node, emitted=%#v plan=%#v", emitted, plan.GeneratedNodes)
}
node := plan.GeneratedNodes[0]
if node.ID != "generator-1" || node.LayerRole != "" || node.Tone != "visual" || node.Status != "success" {
t.Fatalf("expected image generator slot to become regular image, got %#v", node)
}
}
func (g *fakeImageGenerator) callCount() int { func (g *fakeImageGenerator) callCount() int {
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() defer g.mu.Unlock()
@@ -168,7 +324,7 @@ func TestDesignAgentGeneratesImageThroughPiAgent(t *testing.T) {
plan, err := agent.Plan(context.Background(), design.AgentRequest{ plan, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "生成一张 still day 官网首页图片", Prompt: "生成一张 still day 官网首页图片",
Mode: "image", Mode: "design",
ImageModel: "gpt-image-2", ImageModel: "gpt-image-2",
ImageSize: "1536x1024", ImageSize: "1536x1024",
}) })
@@ -471,6 +627,28 @@ func TestGeneratedImageSlotsIgnoreCanvasActionPlaceholders(t *testing.T) {
} }
} }
func TestGeneratedImageNodeFromImageGeneratorSlotBecomesRegularImage(t *testing.T) {
node := generatedImageNodeFromSlot(
design.Node{
ID: "generator-1",
Type: design.NodeTypeImage,
Tone: "image-generator",
Status: "generating",
LayerRole: "image-generator",
Width: 320,
Height: 240,
},
design.AgentRequest{Prompt: "生成一张小猫图片"},
"https://example.com/cat.png",
"小猫",
0,
1,
)
if node.LayerRole != "" || node.Tone != "visual" || node.Status != "success" {
t.Fatalf("expected regular completed image node, got %#v", node)
}
}
func TestDesignAgentDoesNotRunFallbackAfterImageTimeout(t *testing.T) { func TestDesignAgentDoesNotRunFallbackAfterImageTimeout(t *testing.T) {
imageGenerator := &failingImageGenerator{err: context.DeadlineExceeded} imageGenerator := &failingImageGenerator{err: context.DeadlineExceeded}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{}) agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})