mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
feat(collaboration): enhance collaboration features with Loro integration and improved hooks
- Introduced a new module to manage Loro integration for web environments. - Updated to ensure Loro is initialized before establishing connections. - Enhanced hook to conditionally enable collaboration based on workflow state. - Added tests for to verify connection handling and initialization behavior. - Refactored components to utilize the updated collaboration hooks, improving overall workflow collaboration experience.
This commit is contained in:
parent
8843a62b4e
commit
e650109f94
@ -365,6 +365,22 @@ describe('PromptEditor', () => {
|
|||||||
expect(() => unmount()).not.toThrow()
|
expect(() => unmount()).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should rerender rapidly without triggering a ref update loop', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<PromptEditor value="first" />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
rerender(
|
||||||
|
<React.StrictMode>
|
||||||
|
<PromptEditor value="second" />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
it('should render hitl block when show=true', () => {
|
it('should render hitl block when show=true', () => {
|
||||||
render(
|
render(
|
||||||
<PromptEditor
|
<PromptEditor
|
||||||
|
|||||||
@ -38,7 +38,7 @@ import {
|
|||||||
TextNode,
|
TextNode,
|
||||||
} from 'lexical'
|
} from 'lexical'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||||
import { HooksStoreContext } from '@/app/components/workflow/hooks-store/provider'
|
import { HooksStoreContext } from '@/app/components/workflow/hooks-store/provider'
|
||||||
import { FileReferenceNode } from '@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/node'
|
import { FileReferenceNode } from '@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/node'
|
||||||
@ -344,10 +344,12 @@ const PromptEditorContent: FC<PromptEditorContentProps> = ({
|
|||||||
|
|
||||||
const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | null>(null)
|
const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const onRef = (floatingAnchorElement: HTMLDivElement | null) => {
|
const onRef = useCallback((floatingAnchorElement: HTMLDivElement | null) => {
|
||||||
if (floatingAnchorElement !== null)
|
if (floatingAnchorElement === null)
|
||||||
setFloatingAnchorElem(floatingAnchorElement)
|
return
|
||||||
}
|
|
||||||
|
setFloatingAnchorElem(prev => prev === floatingAnchorElement ? prev : floatingAnchorElement)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>
|
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const mockUseSubscription = vi.fn()
|
|||||||
const mockCollaborationSetNodes = vi.fn()
|
const mockCollaborationSetNodes = vi.fn()
|
||||||
const mockCollaborationSetEdges = vi.fn()
|
const mockCollaborationSetEdges = vi.fn()
|
||||||
const mockEmitGraphViewActive = vi.fn()
|
const mockEmitGraphViewActive = vi.fn()
|
||||||
|
const mockUseCollaboration = vi.fn()
|
||||||
|
|
||||||
let appStoreState: {
|
let appStoreState: {
|
||||||
appDetail?: {
|
appDetail?: {
|
||||||
@ -63,6 +64,8 @@ let searchParamsValue: string | null = null
|
|||||||
const workflowUiState = {
|
const workflowUiState = {
|
||||||
appId: 'app-1',
|
appId: 'app-1',
|
||||||
isResponding: false,
|
isResponding: false,
|
||||||
|
isRestoring: false,
|
||||||
|
historyWorkflowData: undefined as Record<string, unknown> | undefined,
|
||||||
showUpgradeRuntimeModal: false,
|
showUpgradeRuntimeModal: false,
|
||||||
setShowUpgradeRuntimeModal: mockSetShowUpgradeRuntimeModal,
|
setShowUpgradeRuntimeModal: mockSetShowUpgradeRuntimeModal,
|
||||||
}
|
}
|
||||||
@ -145,7 +148,7 @@ vi.mock('@/context/event-emitter', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/workflow/collaboration', () => ({
|
vi.mock('@/app/components/workflow/collaboration', () => ({
|
||||||
useCollaboration: () => undefined,
|
useCollaboration: (...args: unknown[]) => mockUseCollaboration(...args),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
|
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
|
||||||
@ -293,6 +296,8 @@ describe('WorkflowApp', () => {
|
|||||||
mockInitialEdges.mockReturnValue([{ id: 'edge-1' }])
|
mockInitialEdges.mockReturnValue([{ id: 'edge-1' }])
|
||||||
mockGetWorkflowRunAndTraceUrl.mockReturnValue({ runUrl: '/runs/run-1' })
|
mockGetWorkflowRunAndTraceUrl.mockReturnValue({ runUrl: '/runs/run-1' })
|
||||||
mockSyncWorkflowDraftImmediately.mockResolvedValue(undefined)
|
mockSyncWorkflowDraftImmediately.mockResolvedValue(undefined)
|
||||||
|
workflowUiState.isRestoring = false
|
||||||
|
workflowUiState.historyWorkflowData = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render the loading shell while workflow data is still loading', () => {
|
it('should render the loading shell while workflow data is still loading', () => {
|
||||||
@ -427,4 +432,27 @@ describe('WorkflowApp', () => {
|
|||||||
expect(mockSetShowInputsPanel).not.toHaveBeenCalled()
|
expect(mockSetShowInputsPanel).not.toHaveBeenCalled()
|
||||||
expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled()
|
expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
description: 'the workflow is restoring',
|
||||||
|
workflowState: {
|
||||||
|
isRestoring: true,
|
||||||
|
historyWorkflowData: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'a historical workflow version is selected',
|
||||||
|
workflowState: {
|
||||||
|
isRestoring: false,
|
||||||
|
historyWorkflowData: { id: 'history-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])('should disable the collaboration session when $description', ({ workflowState }) => {
|
||||||
|
Object.assign(workflowUiState, workflowState)
|
||||||
|
|
||||||
|
render(<WorkflowApp />)
|
||||||
|
|
||||||
|
expect(mockUseCollaboration).toHaveBeenCalledWith('app-1', undefined, false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -14,9 +14,12 @@ const mockGetNodes = vi.fn()
|
|||||||
const mockSetNodes = vi.fn()
|
const mockSetNodes = vi.fn()
|
||||||
const mockGetEdges = vi.fn()
|
const mockGetEdges = vi.fn()
|
||||||
const mockSetEdges = vi.fn()
|
const mockSetEdges = vi.fn()
|
||||||
|
const mockUseCollaboration = vi.fn()
|
||||||
|
|
||||||
const workflowUiState = {
|
const workflowUiState = {
|
||||||
appId: 'app-1',
|
appId: 'app-1',
|
||||||
|
isRestoring: false,
|
||||||
|
historyWorkflowData: undefined as Record<string, unknown> | undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
const hookFns = {
|
const hookFns = {
|
||||||
@ -97,14 +100,17 @@ vi.mock('@/app/components/workflow/collaboration', () => ({
|
|||||||
onWorkflowUpdate: vi.fn(() => vi.fn()),
|
onWorkflowUpdate: vi.fn(() => vi.fn()),
|
||||||
onSyncRequest: vi.fn(() => vi.fn()),
|
onSyncRequest: vi.fn(() => vi.fn()),
|
||||||
},
|
},
|
||||||
useCollaboration: () => ({
|
useCollaboration: (...args: unknown[]) => {
|
||||||
startCursorTracking: mockStartCursorTracking,
|
mockUseCollaboration(...args)
|
||||||
stopCursorTracking: mockStopCursorTracking,
|
return {
|
||||||
onlineUsers: [],
|
startCursorTracking: mockStartCursorTracking,
|
||||||
cursors: {},
|
stopCursorTracking: mockStopCursorTracking,
|
||||||
isConnected: false,
|
onlineUsers: [],
|
||||||
isEnabled: false,
|
cursors: {},
|
||||||
}),
|
isConnected: false,
|
||||||
|
isEnabled: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/workflow/block-selector/context/mcp-tool-availability-context', () => ({
|
vi.mock('@/app/components/workflow/block-selector/context/mcp-tool-availability-context', () => ({
|
||||||
@ -215,6 +221,8 @@ describe('WorkflowMain', () => {
|
|||||||
capturedContextProps = null
|
capturedContextProps = null
|
||||||
mockGetNodes.mockReturnValue([])
|
mockGetNodes.mockReturnValue([])
|
||||||
mockGetEdges.mockReturnValue([])
|
mockGetEdges.mockReturnValue([])
|
||||||
|
workflowUiState.isRestoring = false
|
||||||
|
workflowUiState.historyWorkflowData = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render the inner workflow context with children and forwarded graph props', () => {
|
it('should render the inner workflow context with children and forwarded graph props', () => {
|
||||||
@ -331,4 +339,39 @@ describe('WorkflowMain', () => {
|
|||||||
configsMap: { flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } },
|
configsMap: { flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
description: 'the workflow is restoring',
|
||||||
|
workflowState: {
|
||||||
|
isRestoring: true,
|
||||||
|
historyWorkflowData: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'viewing workflow history',
|
||||||
|
workflowState: {
|
||||||
|
isRestoring: false,
|
||||||
|
historyWorkflowData: { id: 'history-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])('should disable collaboration when $description', ({ workflowState }) => {
|
||||||
|
Object.assign(workflowUiState, workflowState)
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WorkflowMain
|
||||||
|
nodes={[]}
|
||||||
|
edges={[]}
|
||||||
|
viewport={{ x: 0, y: 0, zoom: 1 }}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockUseCollaboration).toHaveBeenCalledWith(
|
||||||
|
'app-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
getState: expect.any(Function),
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -50,6 +50,7 @@ const WorkflowMain = ({
|
|||||||
const featuresStore = useFeaturesStore()
|
const featuresStore = useFeaturesStore()
|
||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
const appId = useStore(s => s.appId)
|
const appId = useStore(s => s.appId)
|
||||||
|
const isWorkflowCollaborationEnabled = useStore(s => !s.isRestoring && !s.historyWorkflowData)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const reactFlow = useReactFlow()
|
const reactFlow = useReactFlow()
|
||||||
|
|
||||||
@ -68,7 +69,7 @@ const WorkflowMain = ({
|
|||||||
cursors,
|
cursors,
|
||||||
isConnected,
|
isConnected,
|
||||||
isEnabled: isCollaborationEnabled,
|
isEnabled: isCollaborationEnabled,
|
||||||
} = useCollaboration(appId || '', reactFlowStore)
|
} = useCollaboration(appId || '', reactFlowStore, isWorkflowCollaborationEnabled)
|
||||||
const myUserId = useMemo(
|
const myUserId = useMemo(
|
||||||
() => (isCollaborationEnabled && isConnected ? 'current-user' : null),
|
() => (isCollaborationEnabled && isConnected ? 'current-user' : null),
|
||||||
[isCollaborationEnabled, isConnected],
|
[isCollaborationEnabled, isConnected],
|
||||||
|
|||||||
@ -65,7 +65,8 @@ const SkillMain = dynamic(() => import('@/app/components/workflow/skill/main'),
|
|||||||
|
|
||||||
const CollaborationSession = () => {
|
const CollaborationSession = () => {
|
||||||
const appId = useStore(s => s.appId)
|
const appId = useStore(s => s.appId)
|
||||||
useCollaboration(appId || '')
|
const isCollaborationSessionEnabled = useStore(s => !s.isRestoring && !s.historyWorkflowData)
|
||||||
|
useCollaboration(appId || '', undefined, isCollaborationSessionEnabled)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,123 @@
|
|||||||
|
import { CollaborationManager } from '../collaboration-manager'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
const socket = {
|
||||||
|
connected: false,
|
||||||
|
emit: vi.fn(),
|
||||||
|
id: 'socket-1',
|
||||||
|
off: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectSocket: vi.fn(() => socket),
|
||||||
|
disconnectSocket: vi.fn(),
|
||||||
|
emitWithAuthGuard: vi.fn(),
|
||||||
|
getSocket: vi.fn(() => socket),
|
||||||
|
initLoro: vi.fn<() => Promise<void>>(),
|
||||||
|
isConnected: vi.fn(() => false),
|
||||||
|
LoroDoc: vi.fn(function MockLoroDoc(this: { getMap: ReturnType<typeof vi.fn> }) {
|
||||||
|
this.getMap = vi.fn(() => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
keys: vi.fn(() => []),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
values: vi.fn(() => []),
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
providerDestroy: vi.fn(),
|
||||||
|
UndoManager: vi.fn(function MockUndoManager(this: { canRedo: ReturnType<typeof vi.fn>, canUndo: ReturnType<typeof vi.fn> }) {
|
||||||
|
this.canRedo = vi.fn(() => false)
|
||||||
|
this.canUndo = vi.fn(() => false)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('../loro-web', () => ({
|
||||||
|
default: () => mocks.initLoro(),
|
||||||
|
LoroDoc: mocks.LoroDoc,
|
||||||
|
LoroList: class {},
|
||||||
|
LoroMap: class {},
|
||||||
|
UndoManager: mocks.UndoManager,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../crdt-provider', () => ({
|
||||||
|
CRDTProvider: vi.fn(function MockCRDTProvider(this: { destroy: typeof mocks.providerDestroy }) {
|
||||||
|
this.destroy = mocks.providerDestroy
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../websocket-manager', () => ({
|
||||||
|
emitWithAuthGuard: (...args: unknown[]) => mocks.emitWithAuthGuard(...args),
|
||||||
|
webSocketClient: {
|
||||||
|
connect: mocks.connectSocket,
|
||||||
|
disconnect: (appId?: string) => mocks.disconnectSocket(appId),
|
||||||
|
getSocket: mocks.getSocket,
|
||||||
|
isConnected: mocks.isConnected,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
type CollaborationManagerInternals = {
|
||||||
|
activeConnections: Set<string>
|
||||||
|
connectionInitializationPromise: Promise<void> | null
|
||||||
|
currentAppId: string | null
|
||||||
|
doc: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals =>
|
||||||
|
manager as unknown as CollaborationManagerInternals
|
||||||
|
|
||||||
|
const createDeferred = () => {
|
||||||
|
let resolve!: () => void
|
||||||
|
const promise = new Promise<void>((res) => {
|
||||||
|
resolve = res
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
promise,
|
||||||
|
resolve,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Covers Loro wasm bootstrapping during workflow collaboration startup.
|
||||||
|
describe('CollaborationManager connect', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mocks.initLoro.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should rollback connection state when Loro initialization fails', async () => {
|
||||||
|
const manager = new CollaborationManager()
|
||||||
|
const internals = getManagerInternals(manager)
|
||||||
|
const initError = new Error('init failed')
|
||||||
|
mocks.initLoro.mockRejectedValueOnce(initError)
|
||||||
|
|
||||||
|
await expect(manager.connect('app-1')).rejects.toThrow(initError)
|
||||||
|
|
||||||
|
expect(mocks.connectSocket).toHaveBeenCalledWith('app-1')
|
||||||
|
expect(mocks.disconnectSocket).toHaveBeenCalledWith('app-1')
|
||||||
|
expect(mocks.LoroDoc).not.toHaveBeenCalled()
|
||||||
|
expect(internals.currentAppId).toBeNull()
|
||||||
|
expect(internals.doc).toBeNull()
|
||||||
|
expect(internals.connectionInitializationPromise).toBeNull()
|
||||||
|
expect(internals.activeConnections.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reuse the in-flight initialization for concurrent callers', async () => {
|
||||||
|
const manager = new CollaborationManager()
|
||||||
|
const deferred = createDeferred()
|
||||||
|
mocks.initLoro.mockReturnValueOnce(deferred.promise)
|
||||||
|
|
||||||
|
const firstConnect = manager.connect('app-1')
|
||||||
|
const secondConnect = manager.connect('app-1')
|
||||||
|
|
||||||
|
expect(mocks.initLoro).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mocks.connectSocket).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
deferred.resolve()
|
||||||
|
|
||||||
|
await expect(firstConnect).resolves.toMatch(/[a-z0-9]{9}/)
|
||||||
|
await expect(secondConnect).resolves.toMatch(/[a-z0-9]{9}/)
|
||||||
|
expect(mocks.LoroDoc).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mocks.UndoManager).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import type { LoroMap } from 'loro-crdt'
|
import type { LoroDocInstance, LoroMapInstance } from '../loro-web'
|
||||||
import type { Node } from '@/app/components/workflow/types'
|
import type { Node } from '@/app/components/workflow/types'
|
||||||
import { LoroDoc } from 'loro-crdt'
|
|
||||||
import { BlockEnum } from '@/app/components/workflow/types'
|
import { BlockEnum } from '@/app/components/workflow/types'
|
||||||
import { CollaborationManager } from '../collaboration-manager'
|
import { CollaborationManager } from '../collaboration-manager'
|
||||||
|
import initLoro, { LoroDoc } from '../loro-web'
|
||||||
|
|
||||||
const NODE_ID = 'node-1'
|
const NODE_ID = 'node-1'
|
||||||
const LLM_NODE_ID = 'llm-node'
|
const LLM_NODE_ID = 'llm-node'
|
||||||
@ -74,9 +74,9 @@ type ParameterExtractorNodeData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CollaborationManagerInternals = {
|
type CollaborationManagerInternals = {
|
||||||
doc: LoroDoc
|
doc: LoroDocInstance
|
||||||
nodesMap: LoroMap
|
nodesMap: LoroMapInstance
|
||||||
edgesMap: LoroMap
|
edgesMap: LoroMapInstance
|
||||||
syncNodes: (oldNodes: Node[], newNodes: Node[]) => void
|
syncNodes: (oldNodes: Node[], newNodes: Node[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,7 +159,7 @@ const createParameterExtractorNode = (parameters: ParameterItem[]): Node<Paramet
|
|||||||
const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals =>
|
const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals =>
|
||||||
manager as unknown as CollaborationManagerInternals
|
manager as unknown as CollaborationManagerInternals
|
||||||
|
|
||||||
const getManager = (doc: LoroDoc) => {
|
const getManager = (doc: LoroDocInstance) => {
|
||||||
const manager = new CollaborationManager()
|
const manager = new CollaborationManager()
|
||||||
const internals = getManagerInternals(manager)
|
const internals = getManagerInternals(manager)
|
||||||
internals.doc = doc
|
internals.doc = doc
|
||||||
@ -177,6 +177,10 @@ const syncNodes = (manager: CollaborationManager, previous: Node[], next: Node[]
|
|||||||
|
|
||||||
const exportNodes = (manager: CollaborationManager) => manager.getNodes()
|
const exportNodes = (manager: CollaborationManager) => manager.getNodes()
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initLoro()
|
||||||
|
})
|
||||||
|
|
||||||
describe('Loro merge behavior smoke test', () => {
|
describe('Loro merge behavior smoke test', () => {
|
||||||
it('inspects concurrent edits after merge', () => {
|
it('inspects concurrent edits after merge', () => {
|
||||||
const docA = new LoroDoc()
|
const docA = new LoroDoc()
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import type { LoroMap } from 'loro-crdt'
|
import type { LoroDocInstance, LoroMapInstance } from '../loro-web'
|
||||||
import type {
|
import type {
|
||||||
NodePanelPresenceMap,
|
NodePanelPresenceMap,
|
||||||
NodePanelPresenceUser,
|
NodePanelPresenceUser,
|
||||||
} from '@/app/components/workflow/collaboration/types/collaboration'
|
} from '@/app/components/workflow/collaboration/types/collaboration'
|
||||||
import type { CommonNodeType, Edge, Node } from '@/app/components/workflow/types'
|
import type { CommonNodeType, Edge, Node } from '@/app/components/workflow/types'
|
||||||
import { LoroDoc } from 'loro-crdt'
|
|
||||||
import { Position } from 'reactflow'
|
import { Position } from 'reactflow'
|
||||||
import { CollaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
import { CollaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||||
import { BlockEnum } from '@/app/components/workflow/types'
|
import { BlockEnum } from '@/app/components/workflow/types'
|
||||||
|
import initLoro, { LoroDoc } from '../loro-web'
|
||||||
|
|
||||||
const NODE_ID = '1760342909316'
|
const NODE_ID = '1760342909316'
|
||||||
|
|
||||||
@ -88,12 +88,12 @@ type LLMNodeDataWithUnknownTemplate = Omit<LLMNodeData, 'prompt_template'> & {
|
|||||||
prompt_template: unknown
|
prompt_template: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManagerDoc = LoroDoc | { commit: () => void }
|
type ManagerDoc = LoroDocInstance | { commit: () => void }
|
||||||
|
|
||||||
type CollaborationManagerInternals = {
|
type CollaborationManagerInternals = {
|
||||||
doc: ManagerDoc
|
doc: ManagerDoc
|
||||||
nodesMap: LoroMap
|
nodesMap: LoroMapInstance
|
||||||
edgesMap: LoroMap
|
edgesMap: LoroMapInstance
|
||||||
syncNodes: (oldNodes: Node[], newNodes: Node[]) => void
|
syncNodes: (oldNodes: Node[], newNodes: Node[]) => void
|
||||||
syncEdges: (oldEdges: Edge[], newEdges: Edge[]) => void
|
syncEdges: (oldEdges: Edge[], newEdges: Edge[]) => void
|
||||||
applyNodePanelPresenceUpdate: (update: NodePanelPresenceEventData) => void
|
applyNodePanelPresenceUpdate: (update: NodePanelPresenceEventData) => void
|
||||||
@ -246,6 +246,10 @@ const setupManager = (): { manager: CollaborationManager, internals: Collaborati
|
|||||||
return { manager, internals }
|
return { manager, internals }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initLoro()
|
||||||
|
})
|
||||||
|
|
||||||
describe('CollaborationManager syncNodes', () => {
|
describe('CollaborationManager syncNodes', () => {
|
||||||
let manager: CollaborationManager
|
let manager: CollaborationManager
|
||||||
let internals: CollaborationManagerInternals
|
let internals: CollaborationManagerInternals
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import type { Value } from 'loro-crdt'
|
|
||||||
import type { Socket } from 'socket.io-client'
|
import type { Socket } from 'socket.io-client'
|
||||||
import type {
|
import type {
|
||||||
CommonNodeType,
|
CommonNodeType,
|
||||||
@ -16,11 +15,18 @@ import type {
|
|||||||
RestoreIntentData,
|
RestoreIntentData,
|
||||||
RestoreRequestData,
|
RestoreRequestData,
|
||||||
} from '../types/collaboration'
|
} from '../types/collaboration'
|
||||||
|
import type {
|
||||||
|
LoroDocInstance,
|
||||||
|
LoroListInstance,
|
||||||
|
LoroMapInstance,
|
||||||
|
UndoManagerInstance,
|
||||||
|
Value,
|
||||||
|
} from './loro-web'
|
||||||
import { cloneDeep } from 'es-toolkit/object'
|
import { cloneDeep } from 'es-toolkit/object'
|
||||||
import { isEqual } from 'es-toolkit/predicate'
|
import { isEqual } from 'es-toolkit/predicate'
|
||||||
import { LoroDoc, LoroList, LoroMap, UndoManager } from 'loro-crdt'
|
|
||||||
import { CRDTProvider } from './crdt-provider'
|
import { CRDTProvider } from './crdt-provider'
|
||||||
import { EventEmitter } from './event-emitter'
|
import { EventEmitter } from './event-emitter'
|
||||||
|
import initLoro, { LoroDoc, LoroList, LoroMap, UndoManager } from './loro-web'
|
||||||
import { emitWithAuthGuard, webSocketClient } from './websocket-manager'
|
import { emitWithAuthGuard, webSocketClient } from './websocket-manager'
|
||||||
|
|
||||||
type NodePanelPresenceEventData = {
|
type NodePanelPresenceEventData = {
|
||||||
@ -105,12 +111,28 @@ const SET_NODES_ANOMALY_LOG_LIMIT = 100
|
|||||||
|
|
||||||
const toLoroValue = (value: unknown): Value => cloneDeep(value) as Value
|
const toLoroValue = (value: unknown): Value => cloneDeep(value) as Value
|
||||||
const toLoroRecord = (value: unknown): Record<string, Value> => cloneDeep(value) as Record<string, Value>
|
const toLoroRecord = (value: unknown): Record<string, Value> => cloneDeep(value) as Record<string, Value>
|
||||||
|
|
||||||
|
let loroInitializationPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
const ensureLoroReady = async (): Promise<void> => {
|
||||||
|
if (!loroInitializationPromise) {
|
||||||
|
loroInitializationPromise = Promise.resolve(initLoro())
|
||||||
|
.then(() => undefined)
|
||||||
|
.catch((error) => {
|
||||||
|
loroInitializationPromise = null
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await loroInitializationPromise
|
||||||
|
}
|
||||||
|
|
||||||
export class CollaborationManager {
|
export class CollaborationManager {
|
||||||
private doc: LoroDoc | null = null
|
private doc: LoroDocInstance | null = null
|
||||||
private undoManager: UndoManager | null = null
|
private undoManager: UndoManagerInstance | null = null
|
||||||
private provider: CRDTProvider | null = null
|
private provider: CRDTProvider | null = null
|
||||||
private nodesMap: LoroMap<Record<string, Value>> | null = null
|
private nodesMap: LoroMapInstance<Record<string, Value>> | null = null
|
||||||
private edgesMap: LoroMap<Record<string, Value>> | null = null
|
private edgesMap: LoroMapInstance<Record<string, Value>> | null = null
|
||||||
private eventEmitter = new EventEmitter()
|
private eventEmitter = new EventEmitter()
|
||||||
private currentAppId: string | null = null
|
private currentAppId: string | null = null
|
||||||
private reactFlowStore: ReactFlowStore | null = null
|
private reactFlowStore: ReactFlowStore | null = null
|
||||||
@ -127,6 +149,7 @@ export class CollaborationManager {
|
|||||||
private graphViewActive: boolean | null = null
|
private graphViewActive: boolean | null = null
|
||||||
private graphImportLogs: GraphImportLogEntry[] = []
|
private graphImportLogs: GraphImportLogEntry[] = []
|
||||||
private setNodesAnomalyLogs: SetNodesAnomalyLogEntry[] = []
|
private setNodesAnomalyLogs: SetNodesAnomalyLogEntry[] = []
|
||||||
|
private connectionInitializationPromise: Promise<void> | null = null
|
||||||
private pendingImportLog: {
|
private pendingImportLog: {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
sources: Set<'nodes' | 'edges'>
|
sources: Set<'nodes' | 'edges'>
|
||||||
@ -191,13 +214,13 @@ export class CollaborationManager {
|
|||||||
emitWithAuthGuard(socket, 'graph_event', payload, { onUnauthorized: this.handleSessionUnauthorized })
|
emitWithAuthGuard(socket, 'graph_event', payload, { onUnauthorized: this.handleSessionUnauthorized })
|
||||||
}
|
}
|
||||||
|
|
||||||
private getNodeContainer(nodeId: string): LoroMap<Record<string, Value>> {
|
private getNodeContainer(nodeId: string): LoroMapInstance<Record<string, Value>> {
|
||||||
if (!this.nodesMap)
|
if (!this.nodesMap)
|
||||||
throw new Error('Nodes map not initialized')
|
throw new Error('Nodes map not initialized')
|
||||||
|
|
||||||
let container = this.nodesMap.get(nodeId) as unknown
|
let container = this.nodesMap.get(nodeId) as unknown
|
||||||
|
|
||||||
const isMapContainer = (value: unknown): value is LoroMap<Record<string, Value>> & LoroContainer => {
|
const isMapContainer = (value: unknown): value is LoroMapInstance<Record<string, Value>> & LoroContainer => {
|
||||||
return !!value && typeof (value as LoroContainer).kind === 'function' && (value as LoroContainer).kind?.() === 'Map'
|
return !!value && typeof (value as LoroContainer).kind === 'function' && (value as LoroContainer).kind?.() === 'Map'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,27 +230,27 @@ export class CollaborationManager {
|
|||||||
const attached = (newContainer as LoroContainer).getAttached?.() ?? newContainer
|
const attached = (newContainer as LoroContainer).getAttached?.() ?? newContainer
|
||||||
container = attached
|
container = attached
|
||||||
if (previousValue && typeof previousValue === 'object')
|
if (previousValue && typeof previousValue === 'object')
|
||||||
this.populateNodeContainer(container as LoroMap<Record<string, Value>>, previousValue as Node)
|
this.populateNodeContainer(container as LoroMapInstance<Record<string, Value>>, previousValue as Node)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const attached = (container as LoroContainer).getAttached?.() ?? container
|
const attached = (container as LoroContainer).getAttached?.() ?? container
|
||||||
container = attached
|
container = attached
|
||||||
}
|
}
|
||||||
|
|
||||||
return container as LoroMap<Record<string, Value>>
|
return container as LoroMapInstance<Record<string, Value>>
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureDataContainer(nodeContainer: LoroMap<Record<string, Value>>): LoroMap<Record<string, Value>> {
|
private ensureDataContainer(nodeContainer: LoroMapInstance<Record<string, Value>>): LoroMapInstance<Record<string, Value>> {
|
||||||
let dataContainer = nodeContainer.get('data') as unknown
|
let dataContainer = nodeContainer.get('data') as unknown
|
||||||
|
|
||||||
if (!dataContainer || typeof (dataContainer as LoroContainer).kind !== 'function' || (dataContainer as LoroContainer).kind?.() !== 'Map')
|
if (!dataContainer || typeof (dataContainer as LoroContainer).kind !== 'function' || (dataContainer as LoroContainer).kind?.() !== 'Map')
|
||||||
dataContainer = nodeContainer.setContainer('data', new LoroMap())
|
dataContainer = nodeContainer.setContainer('data', new LoroMap())
|
||||||
|
|
||||||
const attached = (dataContainer as LoroContainer).getAttached?.() ?? dataContainer
|
const attached = (dataContainer as LoroContainer).getAttached?.() ?? dataContainer
|
||||||
return attached as LoroMap<Record<string, Value>>
|
return attached as LoroMapInstance<Record<string, Value>>
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureList(nodeContainer: LoroMap<Record<string, Value>>, key: string): LoroList<unknown> {
|
private ensureList(nodeContainer: LoroMapInstance<Record<string, Value>>, key: string): LoroListInstance<unknown> {
|
||||||
const dataContainer = this.ensureDataContainer(nodeContainer)
|
const dataContainer = this.ensureDataContainer(nodeContainer)
|
||||||
let list = dataContainer.get(key) as unknown
|
let list = dataContainer.get(key) as unknown
|
||||||
|
|
||||||
@ -235,7 +258,7 @@ export class CollaborationManager {
|
|||||||
list = dataContainer.setContainer(key, new LoroList())
|
list = dataContainer.setContainer(key, new LoroList())
|
||||||
|
|
||||||
const attached = (list as LoroContainer).getAttached?.() ?? list
|
const attached = (list as LoroContainer).getAttached?.() ?? list
|
||||||
return attached as LoroList<unknown>
|
return attached as LoroListInstance<unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
private exportNode(nodeId: string): Node {
|
private exportNode(nodeId: string): Node {
|
||||||
@ -247,7 +270,7 @@ export class CollaborationManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private populateNodeContainer(container: LoroMap<Record<string, Value>>, node: Node): void {
|
private populateNodeContainer(container: LoroMapInstance<Record<string, Value>>, node: Node): void {
|
||||||
const listFields = new Set(['variables', 'prompt_template', 'parameters'])
|
const listFields = new Set(['variables', 'prompt_template', 'parameters'])
|
||||||
container.set('id', node.id)
|
container.set('id', node.id)
|
||||||
container.set('type', node.type)
|
container.set('type', node.type)
|
||||||
@ -325,7 +348,7 @@ export class CollaborationManager {
|
|||||||
return (syncDataAllowList.has(key) || !key.startsWith('_')) && key !== 'selected'
|
return (syncDataAllowList.has(key) || !key.startsWith('_')) && key !== 'selected'
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncList(nodeContainer: LoroMap<Record<string, Value>>, key: string, desired: Array<unknown>): void {
|
private syncList(nodeContainer: LoroMapInstance<Record<string, Value>>, key: string, desired: Array<unknown>): void {
|
||||||
const list = this.ensureList(nodeContainer, key)
|
const list = this.ensureList(nodeContainer, key)
|
||||||
const current = list.toJSON() as Array<unknown>
|
const current = list.toJSON() as Array<unknown>
|
||||||
const target = Array.isArray(desired) ? desired : []
|
const target = Array.isArray(desired) ? desired : []
|
||||||
@ -471,71 +494,107 @@ export class CollaborationManager {
|
|||||||
if (reactFlowStore)
|
if (reactFlowStore)
|
||||||
this.reactFlowStore = reactFlowStore
|
this.reactFlowStore = reactFlowStore
|
||||||
|
|
||||||
|
if (this.connectionInitializationPromise) {
|
||||||
|
try {
|
||||||
|
await this.connectionInitializationPromise
|
||||||
|
return connectionId
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
this.activeConnections.delete(connectionId)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initializationPromise = this.initializeConnection(appId)
|
||||||
|
this.connectionInitializationPromise = initializationPromise
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initializationPromise
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
this.activeConnections.delete(connectionId)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (this.connectionInitializationPromise === initializationPromise)
|
||||||
|
this.connectionInitializationPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return connectionId
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeConnection(appId: string): Promise<void> {
|
||||||
const socket = webSocketClient.connect(appId)
|
const socket = webSocketClient.connect(appId)
|
||||||
|
|
||||||
// Setup event listeners BEFORE any other operations
|
// Setup event listeners BEFORE any other operations
|
||||||
this.setupSocketEventListeners(socket)
|
this.setupSocketEventListeners(socket)
|
||||||
|
|
||||||
this.doc = new LoroDoc()
|
try {
|
||||||
this.nodesMap = this.doc.getMap('nodes') as LoroMap<Record<string, Value>>
|
await ensureLoroReady()
|
||||||
this.edgesMap = this.doc.getMap('edges') as LoroMap<Record<string, Value>>
|
|
||||||
|
|
||||||
// Initialize UndoManager for collaborative undo/redo
|
this.doc = new LoroDoc()
|
||||||
this.undoManager = new UndoManager(this.doc, {
|
this.nodesMap = this.doc.getMap('nodes') as LoroMapInstance<Record<string, Value>>
|
||||||
maxUndoSteps: 100,
|
this.edgesMap = this.doc.getMap('edges') as LoroMapInstance<Record<string, Value>>
|
||||||
mergeInterval: 500, // Merge operations within 500ms
|
|
||||||
excludeOriginPrefixes: [], // Don't exclude anything - let UndoManager track all local operations
|
|
||||||
onPush: (_isUndo, _range, _event) => {
|
|
||||||
// Store current selection state when an operation is pushed
|
|
||||||
const selectedNode = this.reactFlowStore?.getState().getNodes().find((n: Node) => n.data?.selected)
|
|
||||||
|
|
||||||
// Emit event to update UI button states when new operation is pushed
|
// Initialize UndoManager for collaborative undo/redo
|
||||||
setTimeout(() => {
|
this.undoManager = new UndoManager(this.doc, {
|
||||||
this.eventEmitter.emit('undoRedoStateChange', {
|
maxUndoSteps: 100,
|
||||||
canUndo: this.undoManager?.canUndo() || false,
|
mergeInterval: 500, // Merge operations within 500ms
|
||||||
canRedo: this.undoManager?.canRedo() || false,
|
excludeOriginPrefixes: [], // Don't exclude anything - let UndoManager track all local operations
|
||||||
})
|
onPush: (_isUndo, _range, _event) => {
|
||||||
}, 0)
|
// Store current selection state when an operation is pushed
|
||||||
|
const selectedNode = this.reactFlowStore?.getState().getNodes().find((n: Node) => n.data?.selected)
|
||||||
|
|
||||||
return {
|
// Emit event to update UI button states when new operation is pushed
|
||||||
value: {
|
setTimeout(() => {
|
||||||
selectedNodeId: selectedNode?.id || null,
|
this.eventEmitter.emit('undoRedoStateChange', {
|
||||||
timestamp: Date.now(),
|
canUndo: this.undoManager?.canUndo() || false,
|
||||||
},
|
canRedo: this.undoManager?.canRedo() || false,
|
||||||
cursors: [],
|
})
|
||||||
}
|
}, 0)
|
||||||
},
|
|
||||||
onPop: (_isUndo, value, _counterRange) => {
|
return {
|
||||||
// Restore selection state when undoing/redoing
|
value: {
|
||||||
if (value?.value && typeof value.value === 'object' && 'selectedNodeId' in value.value && this.reactFlowStore) {
|
selectedNodeId: selectedNode?.id || null,
|
||||||
const selectedNodeId = (value.value as { selectedNodeId?: string | null }).selectedNodeId
|
timestamp: Date.now(),
|
||||||
if (selectedNodeId) {
|
},
|
||||||
const state = this.reactFlowStore.getState()
|
cursors: [],
|
||||||
const { setNodes } = state
|
|
||||||
const nodes = state.getNodes()
|
|
||||||
const newNodes = nodes.map((n: Node) => ({
|
|
||||||
...n,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
selected: n.id === selectedNodeId,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
this.captureSetNodesAnomaly(nodes, newNodes, 'reactflow-native:undo-redo-selection-restore')
|
|
||||||
setNodes(newNodes)
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
onPop: (_isUndo, value, _counterRange) => {
|
||||||
})
|
// Restore selection state when undoing/redoing
|
||||||
|
if (value?.value && typeof value.value === 'object' && 'selectedNodeId' in value.value && this.reactFlowStore) {
|
||||||
|
const selectedNodeId = (value.value as { selectedNodeId?: string | null }).selectedNodeId
|
||||||
|
if (selectedNodeId) {
|
||||||
|
const state = this.reactFlowStore.getState()
|
||||||
|
const { setNodes } = state
|
||||||
|
const nodes = state.getNodes()
|
||||||
|
const newNodes = nodes.map((n: Node) => ({
|
||||||
|
...n,
|
||||||
|
data: {
|
||||||
|
...n.data,
|
||||||
|
selected: n.id === selectedNodeId,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
this.captureSetNodesAnomaly(nodes, newNodes, 'reactflow-native:undo-redo-selection-restore')
|
||||||
|
setNodes(newNodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
this.provider = new CRDTProvider(socket, this.doc, this.handleSessionUnauthorized)
|
this.provider = new CRDTProvider(socket, this.doc, this.handleSessionUnauthorized)
|
||||||
|
|
||||||
this.setupSubscriptions()
|
this.setupSubscriptions()
|
||||||
|
|
||||||
// Force user_connect if already connected
|
// Force user_connect if already connected
|
||||||
if (socket.connected)
|
if (socket.connected)
|
||||||
emitWithAuthGuard(socket, 'user_connect', { workflow_id: appId }, { onUnauthorized: this.handleSessionUnauthorized })
|
emitWithAuthGuard(socket, 'user_connect', { workflow_id: appId }, { onUnauthorized: this.handleSessionUnauthorized })
|
||||||
|
}
|
||||||
return connectionId
|
catch (error) {
|
||||||
|
this.forceDisconnect()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect = (connectionId?: string): void => {
|
disconnect = (connectionId?: string): void => {
|
||||||
@ -564,6 +623,7 @@ export class CollaborationManager {
|
|||||||
this.onlineUsers = []
|
this.onlineUsers = []
|
||||||
this.isUndoRedoInProgress = false
|
this.isUndoRedoInProgress = false
|
||||||
this.rejoinInProgress = false
|
this.rejoinInProgress = false
|
||||||
|
this.connectionInitializationPromise = null
|
||||||
this.clearGraphImportLog()
|
this.clearGraphImportLog()
|
||||||
|
|
||||||
// Only reset leader status when actually disconnecting
|
// Only reset leader status when actually disconnecting
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import type { LoroDoc } from 'loro-crdt'
|
|
||||||
import type { Socket } from 'socket.io-client'
|
import type { Socket } from 'socket.io-client'
|
||||||
|
import type { LoroDocInstance } from './loro-web'
|
||||||
import { emitWithAuthGuard } from './websocket-manager'
|
import { emitWithAuthGuard } from './websocket-manager'
|
||||||
|
|
||||||
export class CRDTProvider {
|
export class CRDTProvider {
|
||||||
private doc: LoroDoc
|
private doc: LoroDocInstance
|
||||||
private socket: Socket
|
private socket: Socket
|
||||||
private onUnauthorized?: () => void
|
private onUnauthorized?: () => void
|
||||||
|
|
||||||
constructor(socket: Socket, doc: LoroDoc, onUnauthorized?: () => void) {
|
constructor(socket: Socket, doc: LoroDocInstance, onUnauthorized?: () => void) {
|
||||||
this.socket = socket
|
this.socket = socket
|
||||||
this.doc = doc
|
this.doc = doc
|
||||||
this.onUnauthorized = onUnauthorized
|
this.onUnauthorized = onUnauthorized
|
||||||
|
|||||||
43
web/app/components/workflow/collaboration/core/loro-web.ts
Normal file
43
web/app/components/workflow/collaboration/core/loro-web.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import type {
|
||||||
|
Container,
|
||||||
|
LoroDoc as LoroDocShape,
|
||||||
|
LoroList as LoroListShape,
|
||||||
|
LoroMap as LoroMapShape,
|
||||||
|
UndoManager as UndoManagerShape,
|
||||||
|
Value,
|
||||||
|
} from 'loro-crdt'
|
||||||
|
import {
|
||||||
|
LoroDoc as NodeLoroDoc,
|
||||||
|
LoroList as NodeLoroList,
|
||||||
|
LoroMap as NodeLoroMap,
|
||||||
|
UndoManager as NodeUndoManager,
|
||||||
|
} from 'loro-crdt'
|
||||||
|
// eslint-disable-next-line antfu/no-import-node-modules-by-path -- loro-crdt does not export a browser-ready wasm entry, so collaboration must target the web bundle file directly.
|
||||||
|
import initWebLoro, {
|
||||||
|
LoroDoc as WebLoroDoc,
|
||||||
|
LoroList as WebLoroList,
|
||||||
|
LoroMap as WebLoroMap,
|
||||||
|
UndoManager as WebUndoManager,
|
||||||
|
} from '../../../../../node_modules/loro-crdt/web/loro_wasm.js'
|
||||||
|
|
||||||
|
const shouldUseWebLoro = typeof window !== 'undefined' && !import.meta.env?.VITEST
|
||||||
|
|
||||||
|
export type LoroDocInstance<T extends Record<string, Container> = Record<string, Container>> = LoroDocShape<T>
|
||||||
|
export type LoroListInstance<T = unknown> = LoroListShape<T>
|
||||||
|
export type LoroMapInstance<T extends Record<string, unknown> = Record<string, unknown>> = LoroMapShape<T>
|
||||||
|
export type UndoManagerInstance = UndoManagerShape
|
||||||
|
export type { Value }
|
||||||
|
|
||||||
|
export const LoroDoc = (shouldUseWebLoro ? WebLoroDoc : NodeLoroDoc) as typeof NodeLoroDoc
|
||||||
|
export const LoroList = (shouldUseWebLoro ? WebLoroList : NodeLoroList) as typeof NodeLoroList
|
||||||
|
export const LoroMap = (shouldUseWebLoro ? WebLoroMap : NodeLoroMap) as typeof NodeLoroMap
|
||||||
|
export const UndoManager = (shouldUseWebLoro ? WebUndoManager : NodeUndoManager) as typeof NodeUndoManager
|
||||||
|
|
||||||
|
const initLoro = async (): Promise<void> => {
|
||||||
|
if (!shouldUseWebLoro)
|
||||||
|
return
|
||||||
|
|
||||||
|
await initWebLoro()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default initLoro
|
||||||
@ -0,0 +1,105 @@
|
|||||||
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import { useCollaboration } from '../use-collaboration'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
connect: vi.fn<(appId: string) => Promise<string>>().mockResolvedValue('connection-1'),
|
||||||
|
disconnect: vi.fn<(connectionId?: string) => void>(),
|
||||||
|
onStateChange: vi.fn(() => vi.fn()),
|
||||||
|
onCursorUpdate: vi.fn(() => vi.fn()),
|
||||||
|
onOnlineUsersUpdate: vi.fn(() => vi.fn()),
|
||||||
|
onNodePanelPresenceUpdate: vi.fn(() => vi.fn()),
|
||||||
|
onLeaderChange: vi.fn(() => vi.fn()),
|
||||||
|
setReactFlowStore: vi.fn<(store: unknown) => void>(),
|
||||||
|
isConnected: vi.fn<() => boolean>(() => false),
|
||||||
|
getLeaderId: vi.fn<() => string | null>(() => null),
|
||||||
|
emitCursorMove: vi.fn<(position: unknown) => void>(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/context/global-public-context', () => ({
|
||||||
|
useGlobalPublicStore: <T,>(selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => T) => selector({
|
||||||
|
systemFeatures: {
|
||||||
|
enable_collaboration_mode: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../core/collaboration-manager', () => ({
|
||||||
|
collaborationManager: {
|
||||||
|
connect: (appId: string) => mocks.connect(appId),
|
||||||
|
disconnect: (connectionId?: string) => mocks.disconnect(connectionId),
|
||||||
|
onStateChange: () => mocks.onStateChange(),
|
||||||
|
onCursorUpdate: () => mocks.onCursorUpdate(),
|
||||||
|
onOnlineUsersUpdate: () => mocks.onOnlineUsersUpdate(),
|
||||||
|
onNodePanelPresenceUpdate: () => mocks.onNodePanelPresenceUpdate(),
|
||||||
|
onLeaderChange: () => mocks.onLeaderChange(),
|
||||||
|
setReactFlowStore: (store: unknown) => mocks.setReactFlowStore(store),
|
||||||
|
isConnected: () => mocks.isConnected(),
|
||||||
|
getLeaderId: () => mocks.getLeaderId(),
|
||||||
|
emitCursorMove: (position: unknown) => mocks.emitCursorMove(position),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useCollaboration', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should skip collaboration setup when disabled by the caller', async () => {
|
||||||
|
const reactFlowStore = {
|
||||||
|
getState: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useCollaboration('app-1', reactFlowStore as never, false))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.setReactFlowStore).toHaveBeenCalledWith(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mocks.connect).not.toHaveBeenCalled()
|
||||||
|
expect(result.current.isEnabled).toBe(false)
|
||||||
|
expect(result.current.onlineUsers).toEqual([])
|
||||||
|
expect(result.current.nodePanelPresence).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should connect and attach the react flow store when collaboration is enabled', async () => {
|
||||||
|
const reactFlowStore = {
|
||||||
|
getState: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useCollaboration('app-1', reactFlowStore as never, true))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.connect).toHaveBeenCalledWith('app-1')
|
||||||
|
expect(mocks.setReactFlowStore).toHaveBeenCalledWith(reactFlowStore)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.isEnabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disconnect and clear the react flow store when collaboration gets disabled', async () => {
|
||||||
|
const reactFlowStore = {
|
||||||
|
getState: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ enabled }) => useCollaboration('app-1', reactFlowStore as never, enabled),
|
||||||
|
{
|
||||||
|
initialProps: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.connect).toHaveBeenCalledWith('app-1')
|
||||||
|
expect(mocks.setReactFlowStore).toHaveBeenCalledWith(reactFlowStore)
|
||||||
|
})
|
||||||
|
|
||||||
|
rerender({ enabled: false })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.disconnect).toHaveBeenCalledWith('connection-1')
|
||||||
|
expect(mocks.setReactFlowStore).toHaveBeenLastCalledWith(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -28,15 +28,17 @@ const initialState: CollaborationViewState = {
|
|||||||
isLeader: false,
|
isLeader: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) {
|
export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore, enabled = true) {
|
||||||
const [state, setState] = useState<CollaborationViewState>(initialState)
|
const [state, setState] = useState<CollaborationViewState>(initialState)
|
||||||
|
|
||||||
const cursorServiceRef = useRef<CursorService | null>(null)
|
const cursorServiceRef = useRef<CursorService | null>(null)
|
||||||
const lastDisconnectReasonRef = useRef<string | null>(null)
|
const lastDisconnectReasonRef = useRef<string | null>(null)
|
||||||
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
|
const isCollaborationFeatureEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
|
||||||
|
const isCollaborationEnabled = isCollaborationFeatureEnabled && enabled
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!appId || !isCollaborationEnabled) {
|
if (!appId || !isCollaborationEnabled) {
|
||||||
|
lastDisconnectReasonRef.current = null
|
||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
setState(initialState)
|
setState(initialState)
|
||||||
})
|
})
|
||||||
@ -109,14 +111,16 @@ export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore)
|
|||||||
}, [appId, isCollaborationEnabled])
|
}, [appId, isCollaborationEnabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!reactFlowStore)
|
if (!isCollaborationEnabled || !reactFlowStore) {
|
||||||
|
collaborationManager.setReactFlowStore(null)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
collaborationManager.setReactFlowStore(reactFlowStore)
|
collaborationManager.setReactFlowStore(reactFlowStore)
|
||||||
return () => {
|
return () => {
|
||||||
collaborationManager.setReactFlowStore(null)
|
collaborationManager.setReactFlowStore(null)
|
||||||
}
|
}
|
||||||
}, [reactFlowStore])
|
}, [isCollaborationEnabled, reactFlowStore])
|
||||||
|
|
||||||
const prevIsConnected = useRef(false)
|
const prevIsConnected = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -83,7 +83,8 @@ const OnlineUserAvatar = ({
|
|||||||
const OnlineUsers = () => {
|
const OnlineUsers = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const appId = useStore(s => s.appId)
|
const appId = useStore(s => s.appId)
|
||||||
const { onlineUsers, cursors, isEnabled: isCollaborationEnabled } = useCollaboration(appId as string)
|
const isWorkflowCollaborationEnabled = useStore(s => !s.isRestoring && !s.historyWorkflowData)
|
||||||
|
const { onlineUsers, cursors, isEnabled: isCollaborationEnabled } = useCollaboration(appId as string, undefined, isWorkflowCollaborationEnabled)
|
||||||
const { userProfile } = useAppContext()
|
const { userProfile } = useAppContext()
|
||||||
const reactFlow = useReactFlow()
|
const reactFlow = useReactFlow()
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||||
|
|||||||
@ -102,8 +102,9 @@ const BasePanel: FC<BasePanelProps> = ({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const appId = useStore(s => s.appId)
|
const appId = useStore(s => s.appId)
|
||||||
|
const isWorkflowCollaborationEnabled = useStore(s => !s.isRestoring && !s.historyWorkflowData)
|
||||||
const { userProfile } = useAppContext()
|
const { userProfile } = useAppContext()
|
||||||
const { isConnected, nodePanelPresence } = useCollaboration(appId as string)
|
const { isConnected, nodePanelPresence } = useCollaboration(appId as string, undefined, isWorkflowCollaborationEnabled)
|
||||||
const { showMessageLogModal } = useAppStore(useShallow(state => ({
|
const { showMessageLogModal } = useAppStore(useShallow(state => ({
|
||||||
showMessageLogModal: state.showMessageLogModal,
|
showMessageLogModal: state.showMessageLogModal,
|
||||||
})))
|
})))
|
||||||
|
|||||||
@ -88,7 +88,8 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||||||
const resolvedWorkflowStore = workflowStore ?? fallbackWorkflowStoreRef.current
|
const resolvedWorkflowStore = workflowStore ?? fallbackWorkflowStoreRef.current
|
||||||
const appId = useZustandStore(resolvedWorkflowStore, s => s.appId)
|
const appId = useZustandStore(resolvedWorkflowStore, s => s.appId)
|
||||||
const controlMode = useZustandStore(resolvedWorkflowStore, s => s.controlMode)
|
const controlMode = useZustandStore(resolvedWorkflowStore, s => s.controlMode)
|
||||||
const { nodePanelPresence } = useCollaboration(appId as string)
|
const isWorkflowCollaborationEnabled = useZustandStore(resolvedWorkflowStore, s => !s.isRestoring && !s.historyWorkflowData)
|
||||||
|
const { nodePanelPresence } = useCollaboration(appId as string, undefined, isWorkflowCollaborationEnabled)
|
||||||
const { shouldDim: pluginDimmed, isChecking: pluginIsChecking, isMissing: pluginIsMissing, canInstall: pluginCanInstall, uniqueIdentifier: pluginUniqueIdentifier } = useNodePluginInstallation(data)
|
const { shouldDim: pluginDimmed, isChecking: pluginIsChecking, isMissing: pluginIsMissing, canInstall: pluginCanInstall, uniqueIdentifier: pluginUniqueIdentifier } = useNodePluginInstallation(data)
|
||||||
const pluginInstallLocked = !pluginIsChecking && pluginIsMissing && pluginCanInstall && Boolean(pluginUniqueIdentifier)
|
const pluginInstallLocked = !pluginIsChecking && pluginIsMissing && pluginCanInstall && Boolean(pluginUniqueIdentifier)
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,138 @@
|
|||||||
|
import type { AgentNodeType } from '../types'
|
||||||
|
import { renderHook } from '@testing-library/react'
|
||||||
|
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||||
|
import { BlockEnum } from '@/app/components/workflow/types'
|
||||||
|
import { VarType as ToolVarType } from '../../tool/types'
|
||||||
|
import useConfig from '../use-config'
|
||||||
|
|
||||||
|
const mockSetInputs = vi.fn()
|
||||||
|
const mockUseNodesReadOnly = vi.fn()
|
||||||
|
const mockUseNodeCrud = vi.fn()
|
||||||
|
const mockUseVarList = vi.fn()
|
||||||
|
const mockUseStrategyProviderDetail = vi.fn()
|
||||||
|
const mockUseFetchPluginsInMarketPlaceByIds = vi.fn()
|
||||||
|
const mockUseCheckInstalled = vi.fn()
|
||||||
|
const mockUseAvailableVarList = vi.fn()
|
||||||
|
const mockUseIsChatMode = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||||
|
useIsChatMode: () => mockUseIsChatMode(),
|
||||||
|
useNodesReadOnly: () => mockUseNodesReadOnly(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
|
||||||
|
default: (...args: unknown[]) => mockUseNodeCrud(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-var-list', () => ({
|
||||||
|
default: (...args: unknown[]) => mockUseVarList(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
|
||||||
|
default: (...args: unknown[]) => mockUseAvailableVarList(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/service/use-strategy', () => ({
|
||||||
|
useStrategyProviderDetail: (...args: unknown[]) => mockUseStrategyProviderDetail(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/service/use-plugins', () => ({
|
||||||
|
useFetchPluginsInMarketPlaceByIds: (...args: unknown[]) => mockUseFetchPluginsInMarketPlaceByIds(...args),
|
||||||
|
useCheckInstalled: (...args: unknown[]) => mockUseCheckInstalled(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const createPayload = (overrides: Partial<AgentNodeType> = {}): AgentNodeType => ({
|
||||||
|
title: 'Agent',
|
||||||
|
desc: '',
|
||||||
|
type: BlockEnum.Agent,
|
||||||
|
output_schema: {},
|
||||||
|
agent_strategy_provider_name: 'provider/agent',
|
||||||
|
agent_strategy_name: 'react',
|
||||||
|
agent_strategy_label: 'React Agent',
|
||||||
|
agent_parameters: {
|
||||||
|
toolParam: {
|
||||||
|
type: ToolVarType.constant,
|
||||||
|
value: {
|
||||||
|
settings: {},
|
||||||
|
parameters: {},
|
||||||
|
schemas: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createStrategyProviderDetail = () => ({
|
||||||
|
declaration: {
|
||||||
|
strategies: [{
|
||||||
|
identity: {
|
||||||
|
name: 'react',
|
||||||
|
},
|
||||||
|
parameters: [{
|
||||||
|
name: 'toolParam',
|
||||||
|
type: FormTypeEnum.toolSelector,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('agent useConfig', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
mockUseNodesReadOnly.mockReturnValue({
|
||||||
|
nodesReadOnly: false,
|
||||||
|
})
|
||||||
|
mockUseNodeCrud.mockImplementation((_id: string, payload: AgentNodeType) => ({
|
||||||
|
inputs: payload,
|
||||||
|
setInputs: mockSetInputs,
|
||||||
|
}))
|
||||||
|
mockUseVarList.mockReturnValue({
|
||||||
|
handleVarListChange: vi.fn(),
|
||||||
|
handleAddVariable: vi.fn(),
|
||||||
|
})
|
||||||
|
mockUseStrategyProviderDetail.mockReturnValue({
|
||||||
|
data: createStrategyProviderDetail(),
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
})
|
||||||
|
mockUseFetchPluginsInMarketPlaceByIds.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
plugins: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
})
|
||||||
|
mockUseCheckInstalled.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
plugins: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mockUseAvailableVarList.mockReturnValue({
|
||||||
|
availableVars: [],
|
||||||
|
availableNodesWithParent: [],
|
||||||
|
})
|
||||||
|
mockUseIsChatMode.mockReturnValue(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should skip legacy migration when the node is read-only', () => {
|
||||||
|
mockUseNodesReadOnly.mockReturnValue({
|
||||||
|
nodesReadOnly: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHook(() => useConfig('agent-node', createPayload()))
|
||||||
|
|
||||||
|
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should migrate legacy agent tool data when the node is editable', () => {
|
||||||
|
renderHook(() => useConfig('agent-node', createPayload()))
|
||||||
|
|
||||||
|
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
tool_node_version: '2',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -129,9 +129,7 @@ const useConfig = (id: string, payload: AgentNodeType) => {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattingLegacyData = () => {
|
const formattingLegacyData = useCallback(() => {
|
||||||
if (inputs.version || inputs.tool_node_version)
|
|
||||||
return inputs
|
|
||||||
const newData = produce(inputs, (draft) => {
|
const newData = produce(inputs, (draft) => {
|
||||||
const schemas = currentStrategy?.parameters || []
|
const schemas = currentStrategy?.parameters || []
|
||||||
Object.keys(draft.agent_parameters || {}).forEach((key) => {
|
Object.keys(draft.agent_parameters || {}).forEach((key) => {
|
||||||
@ -144,15 +142,17 @@ const useConfig = (id: string, payload: AgentNodeType) => {
|
|||||||
draft.tool_node_version = '2'
|
draft.tool_node_version = '2'
|
||||||
})
|
})
|
||||||
return newData
|
return newData
|
||||||
}
|
}, [currentStrategy?.parameters, inputs])
|
||||||
|
|
||||||
|
const shouldFormatLegacyData = Boolean(currentStrategy) && !readOnly && !inputs.version && !inputs.tool_node_version
|
||||||
|
|
||||||
// formatting legacy data
|
// formatting legacy data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentStrategy)
|
if (!shouldFormatLegacyData)
|
||||||
return
|
return
|
||||||
const newData = formattingLegacyData()
|
const newData = formattingLegacyData()
|
||||||
setInputs(newData)
|
setInputs(newData)
|
||||||
}, [currentStrategy])
|
}, [formattingLegacyData, setInputs, shouldFormatLegacyData])
|
||||||
|
|
||||||
// vars
|
// vars
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user