feat(web): snippet graph draft sync

This commit is contained in:
JzoNg
2026-03-27 16:02:47 +08:00
parent 0de1f17e5c
commit d01428b5bc
7 changed files with 237 additions and 1 deletions

View File

@@ -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(),

View File

@@ -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<SnippetInputField[]>(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 (
<div className="relative flex h-full overflow-hidden bg-background-body">
<AppSideBar
@@ -166,6 +188,7 @@ const SnippetMain = ({
nodes={nodes}
edges={edges}
viewport={viewport ?? graph.viewport}
hooksStore={hooksStore}
>
<SnippetChildren
fields={fields}

View File

@@ -0,0 +1,24 @@
import { useMemo } from 'react'
import { useStore } from '@/app/components/workflow/store'
import { Resolution, TransferMethod } from '@/types/app'
import { FlowType } from '@/types/common'
export const useConfigsMap = (snippetId: string) => {
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])
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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) => {

View File

@@ -1,4 +1,5 @@
export enum FlowType {
appFlow = 'appFlow',
ragPipeline = 'ragPipeline',
snippet = 'snippet',
}