Browse Source

工作流自动更新流程

feature/group
TianYun 1 month ago
parent
commit
94bb0638ed
  1. 27
      src/app/api/canvas-workflows/[id]/__tests__/route.test.ts
  2. 16
      src/app/api/canvas-workflows/[id]/route.ts
  3. 6
      src/app/canvas/page.tsx
  4. 10
      src/components/CanvasWorkflowPickerModal.tsx
  5. 82
      src/components/Header.tsx
  6. 66
      src/components/WorkflowSaveModal.tsx
  7. 55
      src/components/__tests__/Header.test.tsx
  8. 120
      src/hooks/useCanvasWorkflowAutoUpdate.ts
  9. 6
      src/lib/canvasWorkflowApi.ts
  10. 10
      src/store/workflowStore.ts
  11. 2
      src/types/workflow.ts
  12. 34
      src/utils/canvasWorkflowCoverImage.ts

27
src/app/api/canvas-workflows/[id]/__tests__/route.test.ts

@ -67,6 +67,33 @@ describe("/api/canvas-workflows/[id] route", () => {
});
});
it("proxies update coverUrl and omits missing description", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ status: "0000", message: "ok", data: { id: 32 } }), { status: 200 })
);
global.fetch = fetchMock;
const body = {
title: "Workflow",
properties: "{\"nodes\":[],\"edges\":[]}",
coverUrl: "https://example.com/cover.png",
};
const response = await POST(createRequest("POST", body), context);
await expect(response.json()).resolves.toEqual({ success: true, data: { id: 32 } });
expect(fetchMock).toHaveBeenCalledWith("https://popi.example/api_client/canvas/update", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: "Bearer user-token",
token: "user-token",
},
body: JSON.stringify({ id: 32, ...body }),
cache: "no-store",
});
});
it("proxies delete requests to canvas delete", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ status: "0000", message: "ok", data: true }), { status: 200 })

16
src/app/api/canvas-workflows/[id]/route.ts

@ -78,11 +78,13 @@ export async function POST(request: NextRequest, context: RouteContext) {
title?: unknown;
desp?: unknown;
properties?: unknown;
coverUrl?: unknown;
} | null;
const title = typeof body?.title === "string" ? body.title.trim() : "";
const desp = typeof body?.desp === "string" ? body.desp : "";
const desp = typeof body?.desp === "string" ? body.desp : undefined;
const properties = typeof body?.properties === "string" ? body.properties : "";
const coverUrl = typeof body?.coverUrl === "string" && body.coverUrl.trim() ? body.coverUrl : undefined;
if (!title) {
return NextResponse.json(
@ -97,6 +99,14 @@ export async function POST(request: NextRequest, context: RouteContext) {
);
}
const upstreamBody = {
id,
title,
...(desp !== undefined ? { desp } : {}),
properties,
...(coverUrl ? { coverUrl } : {}),
};
const upstream = await fetch(getPopiBaseUrl(request) + POPI_CANVAS_UPDATE_PATH, {
method: "POST",
headers: {
@ -105,7 +115,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
Authorization: `Bearer ${login.token}`,
token: login.token,
},
body: JSON.stringify({ id, title, desp, properties }),
body: JSON.stringify(upstreamBody),
cache: "no-store",
});
@ -136,8 +146,6 @@ export async function DELETE(request: NextRequest, context: RouteContext) {
const upstreamUrl = new URL(getPopiBaseUrl(request) + POPI_CANVAS_DELETE_PATH);
upstreamUrl.searchParams.set("id", String(id));
console.log('upstreamUrl --------------', upstreamUrl)
const upstream = await fetch(upstreamUrl, {
method: "GET",
headers: {

6
src/app/canvas/page.tsx

@ -17,6 +17,8 @@ import { installAuthFetchInterceptor } from "@/utils/authFetch";
import { useModelStore } from "@/store/modelStore";
import { isPopiProviderMode } from "@/lib/providerMode";
import { LoginModal } from "@/components/auth/LoginModal";
import { useCanvasWorkflowLoader } from "@/hooks/useCanvasWorkflowLoader";
import { useCanvasWorkflowAutoUpdate } from "@/hooks/useCanvasWorkflowAutoUpdate";
if (typeof window !== "undefined") {
installAuthFetchInterceptor();
@ -34,6 +36,10 @@ function CanvasPageContent() {
const searchParams = useSearchParams();
const token = searchParams.get("token") || '';
const workflowId = searchParams.get("workflowId");
useCanvasWorkflowLoader(workflowId);
useCanvasWorkflowAutoUpdate();
useEffect(() => {
if (token || getStoredLoginToken()) {

10
src/components/CanvasWorkflowPickerModal.tsx

@ -35,6 +35,11 @@ function formatWorkflowTime(value?: string): string {
return date.toLocaleString();
}
function buildCanvasWorkflowUrl(searchParams: URLSearchParams): string {
const query = searchParams.toString();
return query ? `/canvas?${query}` : "/canvas";
}
export function CanvasWorkflowPickerModal({ isOpen, onClose }: CanvasWorkflowPickerModalProps) {
const { t } = useI18n();
const { message } = App.useApp();
@ -69,7 +74,7 @@ export function CanvasWorkflowPickerModal({ isOpen, onClose }: CanvasWorkflowPic
const openWorkflow = (workflow: CanvasWorkflowListItem) => {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set("workflowId", String(workflow.id));
router.push(`/?${nextParams.toString()}`);
router.push(buildCanvasWorkflowUrl(nextParams));
onClose();
};
@ -80,8 +85,7 @@ export function CanvasWorkflowPickerModal({ isOpen, onClose }: CanvasWorkflowPic
if (currentCanvasWorkflowId === workflow.id) {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.delete("workflowId");
const nextQuery = nextParams.toString();
router.replace(nextQuery ? `/?${nextQuery}` : "/");
router.replace(buildCanvasWorkflowUrl(nextParams));
clearCurrentCanvasWorkflow();
}
await loadWorkflows(pageInfo.page, pageInfo.pageSize);

82
src/components/Header.tsx

@ -1,8 +1,9 @@
"use client";
import { useState, useMemo, useCallback, type ReactNode } from "react";
import { useState, useMemo, useCallback, useEffect, type ReactNode } from "react";
import { Button } from "antd";
import { useWorkflowStore } from "@/store/workflowStore";
import { useCanvasWorkflowStore } from "@/store/canvasWorkflowStore";
import { useShallow } from "zustand/shallow";
import { ProjectSetupModal } from "./ProjectSetupModal";
import { CostIndicator } from "./CostIndicator";
@ -96,10 +97,9 @@ export function Header() {
workflowName,
workflowId,
saveDirectoryPath,
hasUnsavedChanges,
lastSavedAt,
isSaving,
setWorkflowMetadata,
setWorkflowName,
saveToFile,
loadWorkflow,
previousWorkflowSnapshot,
@ -111,10 +111,9 @@ export function Header() {
workflowName: state.workflowName,
workflowId: state.workflowId,
saveDirectoryPath: state.saveDirectoryPath,
hasUnsavedChanges: state.hasUnsavedChanges,
lastSavedAt: state.lastSavedAt,
isSaving: state.isSaving,
setWorkflowMetadata: state.setWorkflowMetadata,
setWorkflowName: state.setWorkflowName,
saveToFile: state.saveToFile,
loadWorkflow: state.loadWorkflow,
previousWorkflowSnapshot: state.previousWorkflowSnapshot,
@ -129,16 +128,41 @@ export function Header() {
const [showWorkflowBrowser, setShowWorkflowBrowser] = useState(false);
const [showWorkflowSaveModal, setShowWorkflowSaveModal] = useState(false);
const [showCanvasWorkflowPicker, setShowCanvasWorkflowPicker] = useState(false);
const [workflowNameDraft, setWorkflowNameDraft] = useState(workflowName || "");
const currentCanvasWorkflowId = useCanvasWorkflowStore((state) => state.currentCanvasWorkflowId);
const currentCanvasWorkflowDescription = useCanvasWorkflowStore((state) => state.currentCanvasWorkflowDescription);
const setCurrentCanvasWorkflow = useCanvasWorkflowStore((state) => state.setCurrentCanvasWorkflow);
const isProjectConfigured = !!workflowName;
const canSave = !!(workflowId && workflowName && saveDirectoryPath);
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString(language, {
hour: "numeric",
minute: "2-digit",
});
};
useEffect(() => {
setWorkflowNameDraft(workflowName || "");
}, [workflowName]);
const commitWorkflowName = useCallback(() => {
const nextName = workflowNameDraft.trim();
if (!nextName || nextName === workflowName) {
setWorkflowNameDraft(workflowName || "");
return;
}
setWorkflowName(nextName);
if (currentCanvasWorkflowId) {
setCurrentCanvasWorkflow({
id: currentCanvasWorkflowId,
title: nextName,
description: currentCanvasWorkflowDescription,
});
}
}, [
workflowNameDraft,
workflowName,
setWorkflowName,
currentCanvasWorkflowId,
currentCanvasWorkflowDescription,
setCurrentCanvasWorkflow,
]);
const handleNewProject = () => {
setProjectModalMode("new");
@ -278,8 +302,23 @@ export function Header() {
<div className="flex items-center gap-2 ml-4 pl-4 border-l border-neutral-700">
{isProjectConfigured ? (
<>
<span className="text-sm text-neutral-300">{workflowName}</span>
<span className="text-neutral-600">|</span>
<input
value={workflowNameDraft}
onChange={(event) => setWorkflowNameDraft(event.target.value)}
onBlur={commitWorkflowName}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
if (event.key === "Escape") {
setWorkflowNameDraft(workflowName || "");
event.currentTarget.blur();
}
}}
size={Math.max(workflowNameDraft.length, 1)}
className="min-w-0 bg-transparent p-0 text-sm text-neutral-300 outline-none"
aria-label={t("workflowSave.name")}
/>
<CostIndicator />
{/* File operations group */}
@ -304,9 +343,6 @@ export function Header() {
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m.75 12 3 3m0 0 3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg>
{hasUnsavedChanges && !isSaving && (
<span className="absolute top-0.5 right-0.5 w-2 h-2 rounded-full bg-red-500 ring-2 ring-neutral-900" />
)}
</button>
<button
onClick={() => setShowWorkflowSaveModal(true)}
@ -393,7 +429,6 @@ export function Header() {
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m.75 12 3 3m0 0 3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg>
<span className="absolute top-0.5 right-0.5 w-2 h-2 rounded-full bg-red-500 ring-2 ring-neutral-900" />
</button>
<button
onClick={() => setShowWorkflowSaveModal(true)}
@ -504,19 +539,6 @@ export function Header() {
</button>
)}
<CommentsNavigationIcon />
<span className="text-neutral-400">
{isProjectConfigured ? (
isSaving ? (
t("header.saving")
) : lastSavedAt ? (
t("header.savedAt", { time: formatTime(lastSavedAt) })
) : (
t("header.notSaved")
)
) : (
t("header.notSaved")
)}
</span>
<span className="text-neutral-500">·</span>
<label className="sr-only" htmlFor="language-select">
{t("language.label")}

66
src/components/WorkflowSaveModal.tsx

@ -2,16 +2,13 @@
import { useEffect, useState } from "react";
import { App, Form, Input, Modal } from "antd";
import { useRouter, useSearchParams } from "next/navigation";
import { useShallow } from "zustand/shallow";
import { useI18n } from "@/i18n";
import {
createCanvasWorkflow,
updateCanvasWorkflow,
} from "@/lib/canvasWorkflowApi";
import { updateCanvasWorkflow } from "@/lib/canvasWorkflowApi";
import { useCanvasWorkflowStore } from "@/store/canvasWorkflowStore";
import { useWorkflowStore } from "@/store/workflowStore";
import { buildSerializedWorkflow } from "@/store/utils/workflowSerialization";
import { pickCanvasWorkflowCoverImage } from "@/utils/canvasWorkflowCoverImage";
interface WorkflowSaveModalProps {
isOpen: boolean;
@ -23,29 +20,9 @@ interface WorkflowSaveFormValues {
desp?: string;
}
function readCreatedWorkflowId(value: unknown): number | null {
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
return value;
}
if (typeof value === "string") {
const numericValue = Number(value);
return Number.isInteger(numericValue) && numericValue > 0 ? numericValue : null;
}
if (typeof value !== "object" || value === null) {
return null;
}
const record = value as Record<string, unknown>;
const directId = readCreatedWorkflowId(record.id);
if (directId) return directId;
return readCreatedWorkflowId(record.data);
}
export function WorkflowSaveModal({ isOpen, onClose }: WorkflowSaveModalProps) {
const { t } = useI18n();
const { message } = App.useApp();
const router = useRouter();
const searchParams = useSearchParams();
const [form] = Form.useForm<WorkflowSaveFormValues>();
const [isSubmitting, setIsSubmitting] = useState(false);
const {
@ -55,6 +32,7 @@ export function WorkflowSaveModal({ isOpen, onClose }: WorkflowSaveModalProps) {
edges,
edgeStyle,
groups,
markAsSaved,
} = useWorkflowStore(useShallow((state) => ({
workflowId: state.workflowId,
workflowName: state.workflowName,
@ -62,6 +40,7 @@ export function WorkflowSaveModal({ isOpen, onClose }: WorkflowSaveModalProps) {
edges: state.edges,
edgeStyle: state.edgeStyle,
groups: state.groups,
markAsSaved: state.markAsSaved,
})));
const {
currentCanvasWorkflowId,
@ -97,6 +76,11 @@ export function WorkflowSaveModal({ isOpen, onClose }: WorkflowSaveModalProps) {
setIsSubmitting(true);
const title = values.title.trim();
const desp = values.desp?.trim() || "";
if (!currentCanvasWorkflowId) {
void message.error(t("workflowSave.failed"));
return;
}
const workflow = buildSerializedWorkflow({
workflowId,
workflowName,
@ -107,34 +91,18 @@ export function WorkflowSaveModal({ isOpen, onClose }: WorkflowSaveModalProps) {
groups,
});
const properties = JSON.stringify(workflow);
const coverUrl = pickCanvasWorkflowCoverImage(nodes);
if (isUpdate && currentCanvasWorkflowId) {
await updateCanvasWorkflow({
id: currentCanvasWorkflowId,
title,
desp,
properties,
});
setCurrentCanvasWorkflow({ id: currentCanvasWorkflowId, title, description: desp });
void message.success(t("workflowSave.updateSuccess"));
onClose();
return;
}
const result = await createCanvasWorkflow({
await updateCanvasWorkflow({
id: currentCanvasWorkflowId,
title,
desp,
...(desp ? { desp } : {}),
properties,
...(coverUrl ? { coverUrl } : {}),
});
const createdId = readCreatedWorkflowId(result.data);
if (createdId) {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set("workflowId", String(createdId));
router.push(`/?${nextParams.toString()}`);
setCurrentCanvasWorkflow({ id: createdId, title, description: desp });
}
void message.success(t("workflowSave.success"));
setCurrentCanvasWorkflow({ id: currentCanvasWorkflowId, title, description: desp });
markAsSaved();
void message.success(t("workflowSave.updateSuccess"));
onClose();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("workflowSave.failed");

55
src/components/__tests__/Header.test.tsx

@ -6,9 +6,11 @@ import { useUserStore } from "@/store/userStore";
// Mock the workflow store
const mockSetWorkflowMetadata = vi.fn();
const mockSetWorkflowName = vi.fn();
const mockSaveToFile = vi.fn();
const mockLoadWorkflow = vi.fn();
const mockUseWorkflowStore = vi.fn();
const mockSetCurrentCanvasWorkflow = vi.fn();
vi.mock("@/store/workflowStore", () => ({
useWorkflowStore: (selector?: (state: unknown) => unknown) => {
@ -19,6 +21,17 @@ vi.mock("@/store/workflowStore", () => ({
},
}));
vi.mock("@/store/canvasWorkflowStore", () => ({
useCanvasWorkflowStore: (selector?: (state: unknown) => unknown) => {
const state = {
currentCanvasWorkflowId: null,
currentCanvasWorkflowDescription: "",
setCurrentCanvasWorkflow: mockSetCurrentCanvasWorkflow,
};
return selector ? selector(state) : state;
},
}));
// Mock ProjectSetupModal
vi.mock("@/components/ProjectSetupModal", () => ({
ProjectSetupModal: ({ isOpen, mode }: { isOpen: boolean; mode: string }) => (
@ -53,6 +66,7 @@ const createDefaultState = (overrides = {}) => ({
lastSavedAt: null,
isSaving: false,
setWorkflowMetadata: mockSetWorkflowMetadata,
setWorkflowName: mockSetWorkflowName,
saveToFile: mockSaveToFile,
loadWorkflow: mockLoadWorkflow,
getNodesWithComments: mockGetNodesWithComments,
@ -184,15 +198,15 @@ describe("Header", () => {
expect(screen.getByText("Untitled")).toBeInTheDocument();
});
it("should show 'Not saved' status when project is not configured", () => {
it("should not show save status when project is not configured", () => {
render(<Header />);
expect(screen.getByText("Not saved")).toBeInTheDocument();
expect(screen.queryByText("Not saved")).not.toBeInTheDocument();
});
it("should show save button with unsaved indicator (red dot)", () => {
it("should show save button without unsaved indicator", () => {
const { container } = render(<Header />);
const redDot = container.querySelector(".bg-red-500.rounded-full");
expect(redDot).toBeInTheDocument();
expect(redDot).not.toBeInTheDocument();
});
it("should not render CostIndicator when project is not configured", () => {
@ -214,7 +228,17 @@ describe("Header", () => {
it("should show project name when configured", () => {
render(<Header />);
expect(screen.getByText("My Project")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Workflow name" })).toHaveValue("My Project");
});
it("should update project name on blur", () => {
render(<Header />);
const nameInput = screen.getByRole("textbox", { name: "Workflow name" });
fireEvent.change(nameInput, { target: { value: "Renamed Project" } });
fireEvent.blur(nameInput);
expect(mockSetWorkflowName).toHaveBeenCalledWith("Renamed Project");
});
it("should render CostIndicator when project is configured", () => {
@ -222,9 +246,9 @@ describe("Header", () => {
expect(screen.getByTestId("cost-indicator")).toBeInTheDocument();
});
it("should show 'Not saved' when no lastSavedAt timestamp", () => {
it("should not show save status when no lastSavedAt timestamp", () => {
render(<Header />);
expect(screen.getByText("Not saved")).toBeInTheDocument();
expect(screen.queryByText("Not saved")).not.toBeInTheDocument();
});
it("should render Open Project Folder button when saveDirectoryPath is set", () => {
@ -235,7 +259,7 @@ describe("Header", () => {
});
describe("Save State Display", () => {
it("should show 'Saving...' when isSaving is true", () => {
it("should not show visible saving status when isSaving is true", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
workflowName: "My Project",
@ -246,10 +270,10 @@ describe("Header", () => {
});
render(<Header />);
expect(screen.getByText("Saving...")).toBeInTheDocument();
expect(screen.queryByText("Saving...")).not.toBeInTheDocument();
});
it("should show formatted save time when lastSavedAt is set", () => {
it("should not show formatted save time when lastSavedAt is set", () => {
const timestamp = new Date("2024-01-15T14:30:00").getTime();
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
@ -261,13 +285,12 @@ describe("Header", () => {
});
render(<Header />);
// The exact format depends on locale, but should contain "Saved"
expect(screen.getByText(/Saved/)).toBeInTheDocument();
expect(screen.queryByText(/Saved/)).not.toBeInTheDocument();
});
});
describe("Unsaved Changes Indicator", () => {
it("should show red dot when hasUnsavedChanges is true", () => {
it("should not show red dot when hasUnsavedChanges is true", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
workflowName: "My Project",
@ -279,7 +302,7 @@ describe("Header", () => {
const { container } = render(<Header />);
const redDot = container.querySelector(".bg-red-500.rounded-full");
expect(redDot).toBeInTheDocument();
expect(redDot).not.toBeInTheDocument();
});
it("should not show red dot when hasUnsavedChanges is false and project is configured", () => {
@ -292,7 +315,7 @@ describe("Header", () => {
}));
});
const { container } = render(<Header />);
render(<Header />);
// Find the save button area and check there's no red dot inside it
const saveButton = screen.getByTitle("Save project");
const redDotInSaveButton = saveButton.querySelector(".bg-red-500.rounded-full");
@ -310,7 +333,7 @@ describe("Header", () => {
}));
});
const { container } = render(<Header />);
render(<Header />);
const saveButton = screen.getByTitle("Saving...");
const redDotInSaveButton = saveButton.querySelector(".bg-red-500.rounded-full");
expect(redDotInSaveButton).not.toBeInTheDocument();

120
src/hooks/useCanvasWorkflowAutoUpdate.ts

@ -0,0 +1,120 @@
"use client";
import { useEffect, useRef } from "react";
import { App } from "antd";
import { useShallow } from "zustand/shallow";
import { useI18n } from "@/i18n";
import { updateCanvasWorkflow } from "@/lib/canvasWorkflowApi";
import { useCanvasWorkflowStore } from "@/store/canvasWorkflowStore";
import { useWorkflowStore } from "@/store/workflowStore";
import { buildSerializedWorkflow } from "@/store/utils/workflowSerialization";
import { pickCanvasWorkflowCoverImage } from "@/utils/canvasWorkflowCoverImage";
const CANVAS_WORKFLOW_AUTO_UPDATE_DELAY_MS = 5_000;
export function useCanvasWorkflowAutoUpdate() {
const { t } = useI18n();
const { message } = App.useApp();
const saveVersionRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const {
currentCanvasWorkflowId,
currentCanvasWorkflowTitle,
currentCanvasWorkflowDescription,
isLoadingCanvasWorkflow,
} = useCanvasWorkflowStore(useShallow((state) => ({
currentCanvasWorkflowId: state.currentCanvasWorkflowId,
currentCanvasWorkflowTitle: state.currentCanvasWorkflowTitle,
currentCanvasWorkflowDescription: state.currentCanvasWorkflowDescription,
isLoadingCanvasWorkflow: state.isLoadingCanvasWorkflow,
})));
const {
workflowId,
workflowName,
nodes,
edges,
edgeStyle,
groups,
hasUnsavedChanges,
markAsSaved,
} = useWorkflowStore(useShallow((state) => ({
workflowId: state.workflowId,
workflowName: state.workflowName,
nodes: state.nodes,
edges: state.edges,
edgeStyle: state.edgeStyle,
groups: state.groups,
hasUnsavedChanges: state.hasUnsavedChanges,
markAsSaved: state.markAsSaved,
})));
useEffect(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (!currentCanvasWorkflowId || !hasUnsavedChanges || isLoadingCanvasWorkflow) {
return;
}
saveVersionRef.current += 1;
const scheduledVersion = saveVersionRef.current;
timerRef.current = setTimeout(() => {
const workflow = buildSerializedWorkflow({
workflowId,
workflowName,
nameOverride: workflowName || currentCanvasWorkflowTitle || undefined,
nodes,
edges,
edgeStyle,
groups,
});
const title = workflowName?.trim() || currentCanvasWorkflowTitle || workflow.name;
const description = currentCanvasWorkflowDescription.trim();
const coverUrl = pickCanvasWorkflowCoverImage(nodes);
void updateCanvasWorkflow({
id: currentCanvasWorkflowId,
title,
...(description ? { desp: description } : {}),
properties: JSON.stringify(workflow),
...(coverUrl ? { coverUrl } : {}),
})
.then(() => {
if (saveVersionRef.current === scheduledVersion) {
markAsSaved();
}
})
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : t("workflowSave.failed");
void message.error(errorMessage);
});
}, CANVAS_WORKFLOW_AUTO_UPDATE_DELAY_MS);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [
currentCanvasWorkflowId,
currentCanvasWorkflowTitle,
currentCanvasWorkflowDescription,
isLoadingCanvasWorkflow,
workflowId,
workflowName,
nodes,
edges,
edgeStyle,
groups,
hasUnsavedChanges,
markAsSaved,
message,
t,
]);
}

6
src/lib/canvasWorkflowApi.ts

@ -8,8 +8,12 @@ export interface CreateCanvasWorkflowRequest {
properties: string;
}
export interface UpdateCanvasWorkflowRequest extends CreateCanvasWorkflowRequest {
export interface UpdateCanvasWorkflowRequest {
id: number;
title: string;
desp?: string;
properties: string;
coverUrl?: string;
}
export interface CreateCanvasWorkflowResponse {

10
src/store/workflowStore.ts

@ -346,6 +346,7 @@ interface WorkflowStore {
setAutoSaveEnabled: (enabled: boolean) => void;
setUseExternalImageStorage: (enabled: boolean) => void;
markAsUnsaved: () => void;
markAsSaved: () => void;
saveToFile: () => Promise<boolean>;
saveAsFile: (name: string) => Promise<boolean>;
initializeAutoSave: () => void;
@ -2703,6 +2704,10 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
set({ hasUnsavedChanges: true });
},
markAsSaved: () => {
set({ hasUnsavedChanges: false });
},
saveToFile: async () => {
let {
nodes,
@ -2792,8 +2797,6 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
}).then((response) => response.json());
if (result.success) {
const timestamp = Date.now();
// If we externalized media, update store nodes with the refs
// This prevents duplicate media on subsequent saves
if (useExternalImageStorage && workflow.nodes !== currentNodes) {
@ -2852,14 +2855,12 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
set({
nodes: nodesWithRefs,
lastSavedAt: timestamp,
hasUnsavedChanges: false,
// Update imageRefBasePath to reflect new save location
imageRefBasePath: saveDirectoryPath,
});
} else {
set({
lastSavedAt: timestamp,
hasUnsavedChanges: false,
// Update imageRefBasePath to reflect save location
imageRefBasePath: useExternalImageStorage ? saveDirectoryPath : null,
@ -2872,7 +2873,6 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
name: workflowName,
directoryPath: saveDirectoryPath,
generationsPath: get().generationsPath,
lastSavedAt: timestamp,
useExternalImageStorage,
});

2
src/types/workflow.ts

@ -24,7 +24,7 @@ export interface WorkflowSaveConfig {
name: string;
directoryPath: string;
generationsPath: string | null;
lastSavedAt: number | null;
lastSavedAt?: number | null;
useExternalImageStorage?: boolean; // Whether to store images as files vs embedded base64
}

34
src/utils/canvasWorkflowCoverImage.ts

@ -0,0 +1,34 @@
import type { WorkflowNode } from "@/types";
const COVER_IMAGE_KEYS = [
"image",
"outputImage",
"previewImg",
"sourceImage",
"capturedImage",
"imageA",
"imageB",
] as const;
function isUsableImageValue(value: unknown): value is string {
return typeof value === "string" && (
value.startsWith("data:image/") ||
value.startsWith("http://") ||
value.startsWith("https://") ||
value.startsWith("/api/images/")
);
}
export function pickCanvasWorkflowCoverImage(nodes: WorkflowNode[]): string | undefined {
for (const node of nodes) {
const data = node.data as Record<string, unknown>;
for (const key of COVER_IMAGE_KEYS) {
const value = data[key];
if (isUsableImageValue(value)) {
return value;
}
}
}
return undefined;
}
Loading…
Cancel
Save