From d01428b5bc7f199730f67af4ef6fa3a24dfcc0d3 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Fri, 27 Mar 2026 16:02:47 +0800 Subject: [PATCH] feat(web): snippet graph draft sync --- .../snippets/__tests__/index.spec.tsx | 21 +++ .../snippets/components/snippet-main.tsx | 25 +++- .../snippets/hooks/use-configs-map.ts | 24 ++++ .../snippets/hooks/use-nodes-sync-draft.ts | 125 ++++++++++++++++++ .../hooks/use-snippet-refresh-draft.ts | 41 ++++++ web/service/utils.ts | 1 + web/types/common.ts | 1 + 7 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 web/app/components/snippets/hooks/use-configs-map.ts create mode 100644 web/app/components/snippets/hooks/use-nodes-sync-draft.ts create mode 100644 web/app/components/snippets/hooks/use-snippet-refresh-draft.ts diff --git a/web/app/components/snippets/__tests__/index.spec.tsx b/web/app/components/snippets/__tests__/index.spec.tsx index c16b98f3e7..65f2c8971c 100644 --- a/web/app/components/snippets/__tests__/index.spec.tsx +++ b/web/app/components/snippets/__tests__/index.spec.tsx @@ -10,6 +10,27 @@ vi.mock('../hooks/use-snippet-init', () => ({ useSnippetInit: (snippetId: string) => mockUseSnippetInit(snippetId), })) +vi.mock('../hooks/use-configs-map', () => ({ + useConfigsMap: () => ({ + flowId: 'snippet-1', + flowType: 'snippet', + fileSettings: {}, + }), +})) + +vi.mock('../hooks/use-nodes-sync-draft', () => ({ + useNodesSyncDraft: () => ({ + doSyncWorkflowDraft: vi.fn(), + syncWorkflowDraftWhenPageClose: vi.fn(), + }), +})) + +vi.mock('../hooks/use-snippet-refresh-draft', () => ({ + useSnippetRefreshDraft: () => ({ + handleRefreshWorkflowDraft: vi.fn(), + }), +})) + vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: vi.fn(), diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index b2ffdf71ea..85b8d9bf06 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -9,7 +9,11 @@ import { RiTerminalWindowFill, RiTerminalWindowLine, } from '@remixicon/react' -import { useEffect, useMemo, useState } from 'react' +import { + useEffect, + useMemo, + useState, +} from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import AppSideBar from '@/app/components/app-sidebar' @@ -20,6 +24,9 @@ import { toast } from '@/app/components/base/ui/toast' import Evaluation from '@/app/components/evaluation' import { WorkflowWithInnerContext } from '@/app/components/workflow' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { useConfigsMap } from '../hooks/use-configs-map' +import { useNodesSyncDraft } from '../hooks/use-nodes-sync-draft' +import { useSnippetRefreshDraft } from '../hooks/use-snippet-refresh-draft' import { useSnippetDetailStore } from '../store' import SnippetChildren from './snippet-children' @@ -52,6 +59,12 @@ const SnippetMain = ({ const media = useBreakpoints() const isMobile = media === MediaType.mobile const [fields, setFields] = useState(payload.inputFields) + const { + doSyncWorkflowDraft, + syncWorkflowDraftWhenPageClose, + } = useNodesSyncDraft(snippetId) + const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId) + const configsMap = useConfigsMap(snippetId) const setAppSidebarExpand = useAppStore(state => state.setAppSidebarExpand) const { editingField, @@ -130,6 +143,15 @@ const SnippetMain = ({ setInputPanelOpen(false) } + const hooksStore = useMemo(() => { + return { + doSyncWorkflowDraft, + syncWorkflowDraftWhenPageClose, + handleRefreshWorkflowDraft, + configsMap, + } + }, [configsMap, doSyncWorkflowDraft, handleRefreshWorkflowDraft, syncWorkflowDraftWhenPageClose]) + return (
{ + const fileUploadConfig = useStore(s => s.fileUploadConfig) + + return useMemo(() => { + return { + flowId: snippetId, + flowType: FlowType.snippet, + fileSettings: { + image: { + enabled: false, + detail: Resolution.high, + number_limits: 3, + transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url], + }, + fileUploadConfig, + }, + } + }, [fileUploadConfig, snippetId]) +} diff --git a/web/app/components/snippets/hooks/use-nodes-sync-draft.ts b/web/app/components/snippets/hooks/use-nodes-sync-draft.ts new file mode 100644 index 0000000000..e216876979 --- /dev/null +++ b/web/app/components/snippets/hooks/use-nodes-sync-draft.ts @@ -0,0 +1,125 @@ +import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store' +import { produce } from 'immer' +import { useCallback } from 'react' +import { useStoreApi } from 'reactflow' +import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback' +import { useNodesReadOnly } from '@/app/components/workflow/hooks/use-workflow' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { API_PREFIX } from '@/config' +import { consoleClient } from '@/service/client' +import { postWithKeepalive } from '@/service/fetch' +import { useSnippetRefreshDraft } from './use-snippet-refresh-draft' + +const isSyncConflictError = (error: unknown): error is { bodyUsed: boolean, json: () => Promise<{ code?: string }> } => { + return !!error + && typeof error === 'object' + && 'bodyUsed' in error + && 'json' in error + && typeof error.json === 'function' +} + +export const useNodesSyncDraft = (snippetId: string) => { + const store = useStoreApi() + const workflowStore = useWorkflowStore() + const { getNodesReadOnly } = useNodesReadOnly() + const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId) + + const getPostParams = useCallback(() => { + const { + getNodes, + edges, + transform, + } = store.getState() + const nodes = getNodes().filter(node => !node.data?._isTempNode) + const [x, y, zoom] = transform + const { syncWorkflowDraftHash } = workflowStore.getState() + + if (!snippetId) + return null + + const producedNodes = produce(nodes, (draft) => { + draft.forEach((node) => { + Object.keys(node.data).forEach((key) => { + if (key.startsWith('_')) + delete node.data[key] + }) + }) + }) + const producedEdges = produce(edges.filter(edge => !edge.data?._isTemp), (draft) => { + draft.forEach((edge) => { + Object.keys(edge.data).forEach((key) => { + if (key.startsWith('_')) + delete edge.data[key] + }) + }) + }) + + return { + url: `/snippets/${snippetId}/workflows/draft`, + params: { + graph: { + nodes: producedNodes, + edges: producedEdges, + viewport: { x, y, zoom }, + }, + hash: syncWorkflowDraftHash, + }, + } + }, [snippetId, store, workflowStore]) + + const syncWorkflowDraftWhenPageClose = useCallback(() => { + if (getNodesReadOnly()) + return + + const postParams = getPostParams() + if (postParams) + postWithKeepalive(`${API_PREFIX}${postParams.url}`, postParams.params) + }, [getNodesReadOnly, getPostParams]) + + const performSync = useCallback(async ( + notRefreshWhenSyncError?: boolean, + callback?: SyncDraftCallback, + ) => { + if (getNodesReadOnly()) + return + + const postParams = getPostParams() + if (!postParams) + return + + const { + setDraftUpdatedAt, + setSyncWorkflowDraftHash, + } = workflowStore.getState() + + try { + const response = await consoleClient.snippets.syncDraftWorkflow({ + params: { snippetId }, + body: postParams.params, + }) + + setSyncWorkflowDraftHash(response.hash) + setDraftUpdatedAt(response.updated_at) + callback?.onSuccess?.() + } + catch (error: unknown) { + if (isSyncConflictError(error) && !error.bodyUsed) { + error.json().then((err) => { + if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError) + handleRefreshWorkflowDraft() + }) + } + callback?.onError?.() + } + finally { + callback?.onSettled?.() + } + }, [getNodesReadOnly, getPostParams, handleRefreshWorkflowDraft, snippetId, workflowStore]) + + const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly) + + return { + doSyncWorkflowDraft, + syncWorkflowDraftWhenPageClose, + } +} diff --git a/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts b/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts new file mode 100644 index 0000000000..d37a338a37 --- /dev/null +++ b/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts @@ -0,0 +1,41 @@ +import type { WorkflowDataUpdater } from '@/app/components/workflow/types' +import { useCallback } from 'react' +import { useWorkflowUpdate } from '@/app/components/workflow/hooks' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { consoleClient } from '@/service/client' + +export const useSnippetRefreshDraft = (snippetId: string) => { + const workflowStore = useWorkflowStore() + const { handleUpdateWorkflowCanvas } = useWorkflowUpdate() + + const handleRefreshWorkflowDraft = useCallback(() => { + const { + setDraftUpdatedAt, + setIsSyncingWorkflowDraft, + setSyncWorkflowDraftHash, + } = workflowStore.getState() + + if (!snippetId) + return + + setIsSyncingWorkflowDraft(true) + consoleClient.snippets.draftWorkflow({ + params: { snippetId }, + }).then((response) => { + handleUpdateWorkflowCanvas({ + ...response.graph, + nodes: response.graph?.nodes || [], + edges: response.graph?.edges || [], + viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 }, + } as WorkflowDataUpdater) + setSyncWorkflowDraftHash(response.hash) + setDraftUpdatedAt(response.updated_at) + }).finally(() => { + setIsSyncingWorkflowDraft(false) + }) + }, [handleUpdateWorkflowCanvas, snippetId, workflowStore]) + + return { + handleRefreshWorkflowDraft, + } +} diff --git a/web/service/utils.ts b/web/service/utils.ts index 6d0c3ca88e..7691ea4e71 100644 --- a/web/service/utils.ts +++ b/web/service/utils.ts @@ -3,6 +3,7 @@ import { FlowType } from '@/types/common' export const flowPrefixMap = { [FlowType.appFlow]: 'apps', [FlowType.ragPipeline]: 'rag/pipelines', + [FlowType.snippet]: 'snippets', } export const getFlowPrefix = (type?: FlowType) => { diff --git a/web/types/common.ts b/web/types/common.ts index 19bc8acc8d..6c7cbebf20 100644 --- a/web/types/common.ts +++ b/web/types/common.ts @@ -1,4 +1,5 @@ export enum FlowType { appFlow = 'appFlow', ragPipeline = 'ragPipeline', + snippet = 'snippet', }