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/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// 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";
const sessionStorageKey = "img-infinite-canvas.auth.session";
const accessTokenCookie = "usertoken";
const refreshTokenCookie = "refreshToken";
const legacyAccessTokenCookie = "usertoken";
const legacyRefreshTokenCookie = "refreshToken";
async function request<T>(path: string, body: unknown): Promise<T> {
let response: Response;
try {
response = await fetch(path, {
method: "POST",
credentials: "omit",
headers: {
"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> {
let response: Response;
try {
response = await fetch(path, { cache: "no-store" });
response = await fetch(path, { cache: "no-store", credentials: "omit" });
} catch (err) {
throw createNetworkFailure("auth", err);
}
@@ -55,7 +56,8 @@ async function apiRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
response = await fetch(path, {
...init,
headers,
cache: "no-store"
cache: "no-store",
credentials: "omit"
});
} catch (err) {
throw createNetworkFailure("auth", err);
@@ -131,6 +133,7 @@ export const authGateway = {
if (upload.uploadUrl && !upload.uploadUrl.includes("/memory-assets/")) {
await fetch(upload.uploadUrl, {
method: "PUT",
credentials: "omit",
headers: upload.headers,
body: file
});
@@ -141,6 +144,7 @@ export const authGateway = {
export function readStoredAuthSession(): StoredAuthSession | null {
if (typeof window === "undefined") return null;
clearLegacyAuthCookies();
const raw = window.localStorage.getItem(sessionStorageKey);
if (!raw) return null;
try {
@@ -160,10 +164,7 @@ export function storeAuthSession(session: AuthSession): StoredAuthSession {
const stored = { ...session, savedAt: Date.now() };
if (typeof window !== "undefined") {
window.localStorage.setItem(sessionStorageKey, JSON.stringify(stored));
setCookie(accessTokenCookie, session.accessToken, session.expiresIn);
if (session.refreshToken && session.refreshExpiresIn) {
setCookie(refreshTokenCookie, session.refreshToken, session.refreshExpiresIn);
}
clearLegacyAuthCookies();
}
return stored;
}
@@ -188,8 +189,7 @@ export function updateStoredAuthUser(userPatch: Partial<AuthUser>): StoredAuthSe
export function clearStoredAuthSession() {
if (typeof window === "undefined") return;
window.localStorage.removeItem(sessionStorageKey);
expireCookie(accessTokenCookie);
expireCookie(refreshTokenCookie);
clearLegacyAuthCookies();
}
export function getAuthAccessToken() {
@@ -197,11 +197,10 @@ export function getAuthAccessToken() {
}
export function authHeaders() {
const token = getAuthAccessToken();
if (!token) return {};
const accessToken = getAuthAccessToken();
if (!accessToken) return {};
return {
Authorization: `Bearer ${token}`,
token
Authorization: `Bearer ${accessToken}`
} satisfies Record<string, string>;
}
@@ -210,8 +209,9 @@ function isSessionExpired(session: StoredAuthSession) {
return expiresIn > 0 && session.savedAt + expiresIn * 1000 <= Date.now() + 30_000;
}
function setCookie(name: string, value: string, maxAge: number) {
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${Math.max(0, Math.floor(maxAge))}; SameSite=Lax`;
function clearLegacyAuthCookies() {
expireCookie(legacyAccessTokenCookie);
expireCookie(legacyRefreshTokenCookie);
}
function expireCookie(name: string) {
+99 -31
View File
@@ -159,7 +159,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
try {
response = await fetch(path, {
...init,
headers
headers,
credentials: "omit"
});
} catch (err) {
throw createNetworkFailure("design", err);
@@ -393,43 +394,56 @@ export const designGateway = {
},
projectEvents(projectId: string, handlers: ProjectEventHandlers, options: ProjectEventsOptions = {}) {
const source = new EventSource(projectEventsUrl(projectId, options));
const controller = new AbortController();
let settled = false;
const readProject = (event: MessageEvent) => {
const payload = JSON.parse(event.data) as { project: Project };
const readProject = (data: string) => {
const payload = JSON.parse(data) as { project: Project };
return payload.project;
};
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) => {
const close = () => {
settled = true;
handlers.onDone?.(readProject(event as MessageEvent));
source.close();
});
source.addEventListener("timeout", (event) => {
settled = true;
handlers.onTimeout?.(readProject(event as MessageEvent));
source.close();
});
source.addEventListener("stream_error", (event) => {
settled = true;
if ((event as MessageEvent).data) {
const payload = JSON.parse((event as MessageEvent).data) as { error?: string };
handlers.onError?.(new Error(payload.error ?? "Event stream failed"));
} else {
handlers.onError?.(new Error("Event stream failed"));
controller.abort();
};
const onEvent = (event: string, data: string) => {
if (event === "project") {
handlers.onProject?.(readProject(data));
return;
}
if (event === "thinking") {
handlers.onThinking?.(JSON.parse(data) as AgentMessage);
return;
}
if (event === "text_extraction") {
handlers.onTextExtraction?.(JSON.parse(data) as ExtractNodeTextResponse);
return;
}
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(
@@ -507,6 +521,7 @@ export const designGateway = {
if (upload.uploadUrl && !upload.uploadUrl.includes("/memory-assets/")) {
await fetch(upload.uploadUrl, {
method: "PUT",
credentials: "omit",
headers: upload.headers,
body: file
});
@@ -866,3 +881,56 @@ function projectEventsUrl(projectId: string, options: ProjectEventsOptions) {
const query = params.toString();
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 {
max-width: 112px;
max-width: 150px;
padding: 0 8px;
overflow: hidden;
white-space: nowrap;
@@ -2562,6 +2562,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
activeMode === "image"
? withImageModeSettings(baseContents, composerImageSettings, t, selectedGeneratorNode?.id)
: baseContents;
const visibleContents = activeMode === "image" ? baseContents : contents;
if (!project || !hasUserText) return;
const selectedThreadId = selectedAgentThreadIdRef.current;
const reusableAgentThreadId =
@@ -2594,7 +2595,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
if (source === "image-generator") {
imageGeneratorThreadIdsRef.current.add(threadId);
} else {
setMessages((current) => [...current, ...createOptimisticAgentMessages(threadId, contents)]);
setMessages((current) => [...current, ...createOptimisticAgentMessages(threadId, visibleContents)]);
}
try {
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`
@@ -124,7 +124,7 @@ After binding succeeds, later QR logins use only `code` and `state`.
## 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`
@@ -151,7 +151,7 @@ Removes non-current devices when a future server-side session registry exists. I
`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
@@ -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) {
ctx := context.Background()
repo := memory.NewProjectRepository()
+168 -38
View File
@@ -547,11 +547,12 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
actionID := newID()
targetNodeID := imageGeneratorTargetNodeID(req.Messages)
isolatedImageGenerator := isImageGeneratorRequest(mode, targetNodeID)
directImageMode := isDirectImageGenerationMode(mode, isolatedImageGenerator)
enableWebSearch := req.EnableWebSearch || toolConfigEnablesSearch(req.ToolConfig)
memory := buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages)
if !isolatedImageGenerator {
project.Messages = append(project.Messages,
design.Message{
nextMessages := []design.Message{
{
ID: newID(),
Role: "user",
Type: "user",
@@ -562,8 +563,11 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
StepID: newID(),
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.LastThreadID = threadID
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 {
return design.AgentThread{}, err
}
s.streamAgentPlanningMessage(ctx, project.ID, threadID, design.AgentTaskPlanRequest{
Project: projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID),
Prompt: prompt,
Mode: mode,
Messages: req.Messages,
Memory: buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages),
WebSearchEnabled: enableWebSearch,
})
project, err = s.repo.Get(ctx, project.ID)
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 {
return design.AgentThread{}, err
} else if cancelled {
return design.AgentThread{
ThreadID: threadID,
Status: "cancelled",
ThreadIDType: threadIDType,
Project: project,
}, nil
if !directImageMode {
s.streamAgentPlanningMessage(ctx, project.ID, threadID, design.AgentTaskPlanRequest{
Project: projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID),
Prompt: prompt,
Mode: mode,
Messages: req.Messages,
Memory: buildAgentMemory(projectScopedToAgentThread(project, threadID), prompt, mode, req.Messages),
WebSearchEnabled: enableWebSearch,
})
project, err = s.repo.Get(ctx, project.ID)
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 {
return design.AgentThread{}, err
} else if cancelled {
return design.AgentThread{
ThreadID: threadID,
Status: "cancelled",
ThreadIDType: threadIDType,
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 {
return design.AgentThread{}, err
} else if cancelled {
@@ -606,7 +617,7 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
Project: project,
}, 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 {
return design.AgentThread{}, err
}
@@ -647,7 +658,9 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
assistantMessageID := ""
if !isolatedImageGenerator {
if shouldGenerateImage {
applyPlannerTaskPlanMessage(&project, threadID, prompt, taskPlan)
if !directImageMode {
applyPlannerTaskPlanMessage(&project, threadID, prompt, taskPlan)
}
} else {
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)
}
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 {
connections = snapshot.Connections
if snapshot.Connections != nil {
connections = snapshot.Connections
} else {
connections = append([]design.Connection(nil), project.Connections...)
}
}
if viewport.K == 0 {
viewport = snapshot.Viewport
@@ -3468,6 +3489,84 @@ func shouldGenerateForAgentPrompt(prompt string, mode string, messages []design.
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 {
modeText := strings.ToLower(strings.TrimSpace(mode))
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) {
artifacts := generatedImageArtifacts(project.Messages)
if len(artifacts) == 0 {
return project, false
}
artifactsByID := generatedImageArtifactsByID(project.Messages)
usedArtifacts := make(map[string]bool)
next := make([]design.Node, 0, len(project.Nodes))
@@ -4068,6 +4164,10 @@ func (s *DesignService) restoreFinishedGeneratedImageNodes(project design.Projec
}
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) {
changed = true
continue
@@ -4155,6 +4255,17 @@ func restoreNodeFromGeneratedArtifact(node design.Node, artifact design.Generato
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 {
if !hasGeneratingCanvasWork(project.Nodes) {
return false
@@ -4361,20 +4472,24 @@ func imageGeneratorTargetNodeID(messages []design.AgentChatMessage) string {
func mergeGeneratedNodesIntoTarget(nodes []design.Node, generated []design.Node, targetNodeID string) []design.Node {
if strings.TrimSpace(targetNodeID) == "" || len(generated) == 0 {
return append(nodes, generated...)
return append(nodes, completedImageGeneratorNodes(generated)...)
}
targetNodeID = strings.TrimSpace(targetNodeID)
targetIndex := -1
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
break
}
}
if targetIndex < 0 {
return append(nodes, generated...)
return append(nodes, completedImageGeneratorNodes(generated)...)
}
primary := generated[0]
if normalized, ok := completedImageGeneratorNode(primary); ok {
primary = normalized
}
target := nodes[targetIndex]
centerX := target.X + target.Width/2
centerY := target.Y + target.Height/2
@@ -4383,6 +4498,9 @@ func mergeGeneratedNodesIntoTarget(nodes []design.Node, generated []design.Node,
target.Tone = primary.Tone
target.Status = primary.Status
target.LayerRole = ""
if strings.TrimSpace(target.Tone) == "" || target.Tone == "image-generator" {
target.Tone = "visual"
}
if primary.Width > 0 {
target.Width = primary.Width
target.X = centerX - target.Width/2
@@ -4393,11 +4511,23 @@ func mergeGeneratedNodesIntoTarget(nodes []design.Node, generated []design.Node,
}
nodes[targetIndex] = target
if len(generated) > 1 {
return append(nodes, generated[1:]...)
return append(nodes, completedImageGeneratorNodes(generated[1:])...)
}
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 {
for index, node := range nodes {
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) {
ctx := context.Background()
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) {
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) {
now := time.Now()
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) {
if tokenUserID, ok := authenticator.UserIDFromBearer(ctx, r.Header.Get("Authorization")); ok {
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
return authenticator.UserIDFromBearer(ctx, r.Header.Get("Authorization"))
}
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) {
if shouldUseDirectImageGeneration(req) {
return a.planDirectImageGeneration(ctx, req)
}
plan, err := a.base.Plan(ctx, req)
if err != nil {
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) {
if shouldUseDirectImageGeneration(req) {
return a.planDirectImageGenerationIncremental(ctx, req, onResult)
}
plan, err := a.base.Plan(ctx, req)
if err != nil {
return design.AgentPlan{}, err
@@ -115,6 +123,92 @@ func (a *DesignAgent) PlanIncremental(ctx context.Context, req design.AgentReque
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 {
count := plannedGenerationCount(req)
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)
nodes := make([]design.Node, 0, requestedCount)
for index := 0; index < requestedCount; index++ {
node := slots[index]
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)
nodes = append(nodes, generatedImageNodeFromSlot(slots[index], req, imageURLs[index], title, index, requestedCount))
}
plan.GeneratedNodes = nodes
plan.Connections = nil
@@ -731,7 +804,10 @@ func generatedImageNodeFromSlot(slot design.Node, req design.AgentRequest, image
if node.Height <= 0 {
node.Height = design.DefaultGeneratedImageHeight
}
if node.Tone == "" {
if node.LayerRole == "image-generator" {
node.LayerRole = ""
}
if node.Tone == "" || node.Tone == "image-generator" {
node.Tone = "visual"
}
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
}
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 {
mu sync.Mutex
called bool
@@ -114,6 +150,126 @@ func imageOptionIndex(prompt string, fallback int) int {
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 {
g.mu.Lock()
defer g.mu.Unlock()
@@ -168,7 +324,7 @@ func TestDesignAgentGeneratesImageThroughPiAgent(t *testing.T) {
plan, err := agent.Plan(context.Background(), design.AgentRequest{
Prompt: "生成一张 still day 官网首页图片",
Mode: "image",
Mode: "design",
ImageModel: "gpt-image-2",
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) {
imageGenerator := &failingImageGenerator{err: context.DeadlineExceeded}
agent := NewDesignAgent(fakeRunner{}, imageGenerator, fakeCreativeAgent{})