diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts index 786385c42c..7893ed74a4 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts @@ -4,6 +4,8 @@ import type { Node } from '@/app/components/workflow/types' import { BlockEnum } from '@/app/components/workflow/types' const NODE_ID = 'node-1' +const LLM_NODE_ID = 'llm-node' +const PARAM_NODE_ID = 'parameter-node' const createNode = (variables: string[]): Node => ({ id: NODE_ID, @@ -27,6 +29,58 @@ const createNode = (variables: string[]): Node => ({ }, }) +const createLLMNode = (templates: Array<{ id: string; role: string; text: string }>): Node => ({ + id: LLM_NODE_ID, + type: 'custom', + position: { x: 200, y: 200 }, + data: { + type: BlockEnum.LLM, + title: 'LLM', + selected: false, + model: { + mode: 'chat', + name: 'gemini-2.5-pro', + provider: 'langgenius/gemini/google', + completion_params: { + temperature: 0.7, + }, + }, + context: { + enabled: false, + variable_selector: [], + }, + vision: { + enabled: false, + }, + prompt_template: templates, + }, +}) + +const createParameterExtractorNode = (parameters: Array<{ description: string; name: string; required: boolean; type: string }>): Node => ({ + id: PARAM_NODE_ID, + type: 'custom', + position: { x: 400, y: 120 }, + data: { + type: BlockEnum.ParameterExtractor, + title: 'ParameterExtractor', + selected: true, + model: { + mode: 'chat', + name: '', + provider: '', + completion_params: { + temperature: 0.7, + }, + }, + query: [], + reasoning_mode: 'prompt', + parameters, + vision: { + enabled: false, + }, + }, +}) + const getManager = (doc: LoroDoc) => { const manager = new CollaborationManager() ;(manager as any).doc = doc @@ -35,6 +89,8 @@ const getManager = (doc: LoroDoc) => { return manager } +const deepClone = (value: T): T => JSON.parse(JSON.stringify(value)) + const exportNodes = (manager: CollaborationManager) => manager.getNodes() describe('Loro merge behavior smoke test', () => { @@ -66,4 +122,118 @@ describe('Loro merge behavior smoke test', () => { expect(finalA.length).toBe(1) expect(finalB.length).toBe(1) }) + + it('merges prompt template insertions and edits across replicas', () => { + const baseTemplate = [ + { + id: 'system-1', + role: 'system', + text: 'base instruction', + }, + ] + + const docA = new LoroDoc() + const managerA = getManager(docA) + managerA.syncNodes([], [createLLMNode(deepClone(baseTemplate))]) + + const snapshot = docA.export({ mode: 'snapshot' }) + const docB = LoroDoc.fromSnapshot(snapshot) + const managerB = getManager(docB) + + const additionTemplate = [ + ...baseTemplate, + { + id: 'user-1', + role: 'user', + text: 'hello from docA', + }, + ] + managerA.syncNodes([createLLMNode(deepClone(baseTemplate))], [createLLMNode(deepClone(additionTemplate))]) + + const editedTemplate = [ + { + id: 'system-1', + role: 'system', + text: 'updated by docB', + }, + ] + managerB.syncNodes([createLLMNode(deepClone(baseTemplate))], [createLLMNode(deepClone(editedTemplate))]) + + const updateForA = docB.export({ mode: 'update', from: docA.version() }) + docA.import(updateForA) + + const updateForB = docA.export({ mode: 'update', from: docB.version() }) + docB.import(updateForB) + + const finalA = exportNodes(managerA).find(node => node.id === LLM_NODE_ID) + const finalB = exportNodes(managerB).find(node => node.id === LLM_NODE_ID) + + expect(finalA).toBeDefined() + expect(finalB).toBeDefined() + + const expectedTemplates = [ + { + id: 'system-1', + role: 'system', + text: 'updated by docB', + }, + { + id: 'user-1', + role: 'user', + text: 'hello from docA', + }, + ] + + expect((finalA!.data as any).prompt_template).toEqual(expectedTemplates) + expect((finalB!.data as any).prompt_template).toEqual(expectedTemplates) + }) + + it('converges when parameter lists are edited concurrently', () => { + const baseParameters = [ + { description: 'bb', name: 'aa', required: false, type: 'string' }, + { description: 'dd', name: 'cc', required: false, type: 'string' }, + ] + + const docA = new LoroDoc() + const managerA = getManager(docA) + managerA.syncNodes([], [createParameterExtractorNode(deepClone(baseParameters))]) + + const snapshot = docA.export({ mode: 'snapshot' }) + const docB = LoroDoc.fromSnapshot(snapshot) + const managerB = getManager(docB) + + const docAUpdate = [ + { description: 'bb updated by A', name: 'aa', required: true, type: 'string' }, + { description: 'dd', name: 'cc', required: false, type: 'string' }, + { description: 'new from A', name: 'ee', required: false, type: 'number' }, + ] + managerA.syncNodes([createParameterExtractorNode(deepClone(baseParameters))], [createParameterExtractorNode(deepClone(docAUpdate))]) + + const docBUpdate = [ + { description: 'bb', name: 'aa', required: false, type: 'string' }, + { description: 'dd updated by B', name: 'cc', required: true, type: 'string' }, + ] + managerB.syncNodes([createParameterExtractorNode(deepClone(baseParameters))], [createParameterExtractorNode(deepClone(docBUpdate))]) + + const updateForA = docB.export({ mode: 'update', from: docA.version() }) + docA.import(updateForA) + + const updateForB = docA.export({ mode: 'update', from: docB.version() }) + docB.import(updateForB) + + const finalA = exportNodes(managerA).find(node => node.id === PARAM_NODE_ID) + const finalB = exportNodes(managerB).find(node => node.id === PARAM_NODE_ID) + + expect(finalA).toBeDefined() + expect(finalB).toBeDefined() + + const expectedParameters = [ + { description: 'bb updated by A', name: 'aa', required: true, type: 'string' }, + { description: 'dd updated by B', name: 'cc', required: true, type: 'string' }, + { description: 'new from A', name: 'ee', required: false, type: 'number' }, + ] + + expect((finalA!.data as any).parameters).toEqual(expectedParameters) + expect((finalB!.data as any).parameters).toEqual(expectedParameters) + }) }) diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.syncNodes.test.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.syncNodes.test.ts index f2ddc9f833..2ed19967c0 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.syncNodes.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.syncNodes.test.ts @@ -23,6 +23,13 @@ type PromptTemplateItem = { text: string } +type ParameterItem = { + description: string + name: string + required: boolean + type: string +} + const createVariable = (name: string, overrides: Partial = {}): WorkflowVariable => ({ default: '', hint: '', @@ -60,6 +67,7 @@ const createNodeSnapshot = (variableNames: string[]): Node<{ variables: Workflow }) const LLM_NODE_ID = 'llm-node' +const PARAM_NODE_ID = 'param-extractor-node' const createLLMNodeSnapshot = (promptTemplates: PromptTemplateItem[]): Node => ({ id: LLM_NODE_ID, @@ -96,6 +104,39 @@ const createLLMNodeSnapshot = (promptTemplates: PromptTemplateItem[]): Node }, }) +const createParameterExtractorNodeSnapshot = (parameters: ParameterItem[]): Node => ({ + id: PARAM_NODE_ID, + type: 'custom', + position: { x: 420, y: 220 }, + positionAbsolute: { x: 420, y: 220 }, + height: 260, + width: 420, + selected: true, + selectable: true, + draggable: true, + sourcePosition: 'right', + targetPosition: 'left', + data: { + type: 'parameter-extractor', + title: '参数提取器', + selected: true, + model: { + mode: 'chat', + name: '', + provider: '', + completion_params: { + temperature: 0.7, + }, + }, + reasoning_mode: 'prompt', + parameters, + query: [], + vision: { + enabled: false, + }, + }, +}) + const getVariables = (node: Node): string[] => { const variables = (node.data as any)?.variables ?? [] return variables.map((item: WorkflowVariable) => item.variable) @@ -110,6 +151,10 @@ const getPromptTemplates = (node: Node): PromptTemplateItem[] => { return ((node.data as any)?.prompt_template ?? []) as PromptTemplateItem[] } +const getParameters = (node: Node): ParameterItem[] => { + return ((node.data as any)?.parameters ?? []) as ParameterItem[] +} + describe('CollaborationManager syncNodes', () => { let manager: CollaborationManager @@ -279,4 +324,42 @@ describe('CollaborationManager syncNodes', () => { expect(finalTemplates).toHaveLength(1) expect(finalTemplates[0].text).toBe('updated system prompt') }) + + it('keeps parameter list in sync when nodes add, edit, or remove parameters', () => { + const parameterManager = new CollaborationManager() + const doc = new LoroDoc() + ;(parameterManager as any).doc = doc + ;(parameterManager as any).nodesMap = doc.getMap('nodes') + ;(parameterManager as any).edgesMap = doc.getMap('edges') + + const baseParameters: ParameterItem[] = [ + { description: 'bb', name: 'aa', required: false, type: 'string' }, + { description: 'dd', name: 'cc', required: false, type: 'string' }, + ] + + const baseNode = createParameterExtractorNodeSnapshot(baseParameters) + ;(parameterManager as any).syncNodes([], [deepClone(baseNode)]) + + const updatedParameters: ParameterItem[] = [ + ...baseParameters, + { description: 'ff', name: 'ee', required: true, type: 'number' }, + ] + + const updatedNode = createParameterExtractorNodeSnapshot(updatedParameters) + ;(parameterManager as any).syncNodes([deepClone(baseNode)], [deepClone(updatedNode)]) + + const stored = (parameterManager.getNodes() as Node[]).find(node => node.id === PARAM_NODE_ID) + expect(stored).toBeDefined() + expect(getParameters(stored!)).toEqual(updatedParameters) + + const editedParameters: ParameterItem[] = [ + { description: 'bb edited', name: 'aa', required: true, type: 'string' }, + ] + const editedNode = createParameterExtractorNodeSnapshot(editedParameters) + + ;(parameterManager as any).syncNodes([deepClone(updatedNode)], [deepClone(editedNode)]) + + const final = (parameterManager.getNodes() as Node[]).find(node => node.id === PARAM_NODE_ID) + expect(getParameters(final!)).toEqual(editedParameters) + }) }) diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index d1ebbf119b..99279cef24 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -87,6 +87,16 @@ export class CollaborationManager { return typeof list.getAttached === 'function' ? list.getAttached() ?? list : list } + private ensureParametersList(nodeContainer: LoroMap): LoroList { + const dataContainer = this.ensureDataContainer(nodeContainer) + let list = dataContainer.get('parameters') as any + + if (!list || typeof list.kind !== 'function' || list.kind() !== 'List') + list = dataContainer.setContainer('parameters', new LoroList()) + + return typeof list.getAttached === 'function' ? list.getAttached() ?? list : list + } + private exportNode(nodeId: string): Node { const container = this.getNodeContainer(nodeId) const json = container.toJSON() as any @@ -151,6 +161,8 @@ export class CollaborationManager { this.syncVariables(container, Array.isArray(value) ? value : []) else if (key === 'prompt_template') this.syncPromptTemplate(container, Array.isArray(value) ? value : []) + else if (key === 'parameters') + this.syncParameters(container, Array.isArray(value) ? value : []) else dataContainer.set(key, cloneDeep(value)) }) @@ -164,6 +176,8 @@ export class CollaborationManager { dataContainer.delete('variables') else if (key === 'prompt_template') dataContainer.delete('prompt_template') + else if (key === 'parameters') + dataContainer.delete('parameters') else dataContainer.delete(key) @@ -219,6 +233,28 @@ export class CollaborationManager { } } + private syncParameters(nodeContainer: LoroMap, desired: any[]): void { + const list = this.ensureParametersList(nodeContainer) + const current = list.toJSON() as any[] + const target = Array.isArray(desired) ? desired : [] + const minLength = Math.min(current.length, target.length) + + for (let i = 0; i < minLength; i += 1) { + if (!isEqual(current[i], target[i])) { + list.delete(i, 1) + list.insert(i, cloneDeep(target[i])) + } + } + + if (current.length > target.length) { + list.delete(target.length, current.length - target.length) + } + else if (target.length > current.length) { + for (let i = current.length; i < target.length; i += 1) + list.insert(i, cloneDeep(target[i])) + } + } + private getNodePanelPresenceSnapshot(): NodePanelPresenceMap { const snapshot: NodePanelPresenceMap = {} Object.entries(this.nodePanelPresence).forEach(([nodeId, viewers]) => {