Compare commits
4 Commits
263748af4a
...
453de420fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 453de420fc | |||
| 9a07f863ea | |||
| ffd8d78df7 | |||
| a39e18950c |
Vendored
+1
-1
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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
@@ -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()
|
||||||
|
|||||||
@@ -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{})
|
||||||
|
|||||||
Reference in New Issue
Block a user