Merge branch 'feat/collaboration' into feat/collaboration2

This commit is contained in:
hjlarry
2025-11-13 15:31:21 +08:00
159 changed files with 10539 additions and 816 deletions

View File

@ -4,7 +4,6 @@ import {
import { produce } from 'immer'
import {
useReactFlow,
useStoreApi,
useViewport,
} from 'reactflow'
import { useEventListener } from 'ahooks'
@ -19,9 +18,9 @@ import CustomNode from './nodes'
import CustomNoteNode from './note-node'
import { CUSTOM_NOTE_NODE } from './note-node/constants'
import { BlockEnum } from './types'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
const CandidateNode = () => {
const store = useStoreApi()
const reactflow = useReactFlow()
const workflowStore = useWorkflowStore()
const candidateNode = useStore(s => s.candidateNode)
@ -31,18 +30,15 @@ const CandidateNode = () => {
const { saveStateToHistory } = useWorkflowHistory()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl()
const collaborativeWorkflow = useCollaborativeWorkflow()
useEventListener('click', (e) => {
const { candidateNode, mousePosition } = workflowStore.getState()
if (candidateNode) {
e.preventDefault()
const {
getNodes,
setNodes,
} = store.getState()
const { nodes, setNodes } = collaborativeWorkflow.getState()
const { screenToFlowPosition } = reactflow
const nodes = getNodes()
const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
const newNodes = produce(nodes, (draft) => {
draft.push({

View File

@ -0,0 +1,78 @@
import type { FC } from 'react'
import { useViewport } from 'reactflow'
import type { CursorPosition, OnlineUser } from '@/app/components/workflow/collaboration/types'
import { getUserColor } from '../utils/user-color'
type UserCursorsProps = {
cursors: Record<string, CursorPosition>
myUserId: string | null
onlineUsers: OnlineUser[]
}
const UserCursors: FC<UserCursorsProps> = ({
cursors,
myUserId,
onlineUsers,
}) => {
const viewport = useViewport()
const convertToScreenCoordinates = (cursor: CursorPosition) => {
// Convert world coordinates to screen coordinates using current viewport
const screenX = cursor.x * viewport.zoom + viewport.x
const screenY = cursor.y * viewport.zoom + viewport.y
return { x: screenX, y: screenY }
}
return (
<>
{Object.entries(cursors || {}).map(([userId, cursor]) => {
if (userId === myUserId)
return null
const userInfo = onlineUsers.find(user => user.user_id === userId)
const userName = userInfo?.username || `User ${userId.slice(-4)}`
const userColor = getUserColor(userId)
const screenPos = convertToScreenCoordinates(cursor)
return (
<div
key={userId}
className="pointer-events-none absolute z-[8] transition-all duration-150 ease-out"
style={{
left: screenPos.x,
top: screenPos.y,
}}
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="drop-shadow-md"
>
<path
d="M5 3L5 15L8 11.5L11 16L13 15L10 10.5L14 10.5L5 3Z"
fill={userColor}
stroke="white"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
<div
className="absolute left-4 top-4 max-w-[120px] overflow-hidden text-ellipsis whitespace-nowrap rounded px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm"
style={{
backgroundColor: userColor,
}}
>
{userName}
</div>
</div>
)
})}
</>
)
}
export default UserCursors

View File

@ -0,0 +1,239 @@
import { LoroDoc } from 'loro-crdt'
import { CollaborationManager } from '../collaboration-manager'
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,
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Start,
title: 'Start',
desc: '',
variables: variables.map(name => ({
variable: name,
label: name,
type: 'text-input',
required: true,
default: '',
max_length: 48,
placeholder: '',
options: [],
hint: '',
})),
},
})
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
;(manager as any).nodesMap = doc.getMap('nodes')
;(manager as any).edgesMap = doc.getMap('edges')
return manager
}
const deepClone = <T>(value: T): T => JSON.parse(JSON.stringify(value))
const exportNodes = (manager: CollaborationManager) => manager.getNodes()
describe('Loro merge behavior smoke test', () => {
it('inspects concurrent edits after merge', () => {
const docA = new LoroDoc()
const managerA = getManager(docA)
managerA.syncNodes([], [createNode(['a'])])
const snapshot = docA.export({ mode: 'snapshot' })
const docB = LoroDoc.fromSnapshot(snapshot)
const managerB = getManager(docB)
managerA.syncNodes([createNode(['a'])], [createNode(['a', 'b'])])
managerB.syncNodes([createNode(['a'])], [createNode(['a', 'c'])])
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)
const finalB = exportNodes(managerB)
console.log('Final nodes on docA:', JSON.stringify(finalA, null, 2))
console.log('Final nodes on docB:', JSON.stringify(finalB, null, 2))
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)
})
})

View File

@ -0,0 +1,659 @@
import { LoroDoc } from 'loro-crdt'
import { CollaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { BlockEnum } from '@/app/components/workflow/types'
import type { Edge, Node } from '@/app/components/workflow/types'
import type { NodePanelPresenceMap, NodePanelPresenceUser } from '@/app/components/workflow/collaboration/types/collaboration'
const NODE_ID = '1760342909316'
type WorkflowVariable = {
default: string
hint: string
label: string
max_length: number
options: string[]
placeholder: string
required: boolean
type: string
variable: string
}
type PromptTemplateItem = {
id: string
role: string
text: string
}
type ParameterItem = {
description: string
name: string
required: boolean
type: string
}
const createVariable = (name: string, overrides: Partial<WorkflowVariable> = {}): WorkflowVariable => ({
default: '',
hint: '',
label: name,
max_length: 48,
options: [],
placeholder: '',
required: true,
type: 'text-input',
variable: name,
...overrides,
})
const deepClone = <T>(value: T): T => JSON.parse(JSON.stringify(value))
const createNodeSnapshot = (variableNames: string[]): Node<{ variables: WorkflowVariable[] }> => ({
id: NODE_ID,
type: 'custom',
position: { x: 0, y: 24 },
positionAbsolute: { x: 0, y: 24 },
height: 88,
width: 242,
selected: true,
selectable: true,
draggable: true,
sourcePosition: 'right',
targetPosition: 'left',
data: {
selected: true,
title: '开始',
desc: '',
type: BlockEnum.Start,
variables: variableNames.map(createVariable),
},
})
const LLM_NODE_ID = 'llm-node'
const PARAM_NODE_ID = 'param-extractor-node'
const createLLMNodeSnapshot = (promptTemplates: PromptTemplateItem[]): Node<any> => ({
id: LLM_NODE_ID,
type: 'custom',
position: { x: 200, y: 120 },
positionAbsolute: { x: 200, y: 120 },
height: 320,
width: 460,
selected: false,
selectable: true,
draggable: true,
sourcePosition: 'right',
targetPosition: 'left',
data: {
type: 'llm',
title: 'LLM',
selected: false,
context: {
enabled: false,
variable_selector: [],
},
model: {
mode: 'chat',
name: 'gemini-2.5-pro',
provider: 'langgenius/gemini/google',
completion_params: {
temperature: 0.7,
},
},
vision: {
enabled: false,
},
prompt_template: promptTemplates,
},
})
const createParameterExtractorNodeSnapshot = (parameters: ParameterItem[]): Node<any> => ({
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)
}
const getVariableObject = (node: Node, name: string): WorkflowVariable | undefined => {
const variables = (node.data as any)?.variables ?? []
return variables.find((item: WorkflowVariable) => item.variable === name)
}
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
beforeEach(() => {
manager = new CollaborationManager()
// Bypass private guards for targeted unit testing
const doc = new LoroDoc()
;(manager as any).doc = doc
;(manager as any).nodesMap = doc.getMap('nodes')
;(manager as any).edgesMap = doc.getMap('edges')
const initialNode = createNodeSnapshot(['a'])
;(manager as any).syncNodes([], [deepClone(initialNode)])
})
it('updates collaborators map when a single client adds a variable', () => {
const base = [createNodeSnapshot(['a'])]
const next = [createNodeSnapshot(['a', 'b'])]
;(manager as any).syncNodes(base, next)
const stored = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
expect(stored).toBeDefined()
expect(getVariables(stored!)).toEqual(['a', 'b'])
})
it('applies the latest parallel additions derived from the same base snapshot', () => {
const base = [createNodeSnapshot(['a'])]
const userA = [createNodeSnapshot(['a', 'b'])]
const userB = [createNodeSnapshot(['a', 'c'])]
;(manager as any).syncNodes(base, userA)
const afterUserA = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
expect(getVariables(afterUserA!)).toEqual(['a', 'b'])
;(manager as any).syncNodes(base, userB)
const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
const finalVariables = getVariables(finalNode!)
expect(finalVariables).toEqual(['a', 'c'])
})
it('prefers the incoming mutation when the same variable is edited concurrently', () => {
const base = [createNodeSnapshot(['a'])]
const userA = [
{
...createNodeSnapshot(['a']),
data: {
...createNodeSnapshot(['a']).data,
variables: [
createVariable('a', { label: 'A from userA', hint: 'hintA' }),
],
},
},
]
const userB = [
{
...createNodeSnapshot(['a']),
data: {
...createNodeSnapshot(['a']).data,
variables: [
createVariable('a', { label: 'A from userB', hint: 'hintB' }),
],
},
},
]
;(manager as any).syncNodes(base, userA)
;(manager as any).syncNodes(base, userB)
const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
const finalVariable = getVariableObject(finalNode!, 'a')
expect(finalVariable?.label).toBe('A from userB')
expect(finalVariable?.hint).toBe('hintB')
})
it('reflects the last writer when concurrent removal and edits happen', () => {
const base = [createNodeSnapshot(['a', 'b'])]
;(manager as any).syncNodes([], [deepClone(base[0])])
const userA = [
{
...createNodeSnapshot(['a']),
data: {
...createNodeSnapshot(['a']).data,
variables: [
createVariable('a', { label: 'A after deletion' }),
],
},
},
]
const userB = [
{
...createNodeSnapshot(['a', 'b']),
data: {
...createNodeSnapshot(['a']).data,
variables: [
createVariable('a'),
createVariable('b', { label: 'B edited but should vanish' }),
],
},
},
]
;(manager as any).syncNodes(base, userA)
;(manager as any).syncNodes(base, userB)
const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
const finalVariables = getVariables(finalNode!)
expect(finalVariables).toEqual(['a', 'b'])
expect(getVariableObject(finalNode!, 'b')).toBeDefined()
})
it('synchronizes prompt_template list updates across collaborators', () => {
const promptManager = new CollaborationManager()
const doc = new LoroDoc()
;(promptManager as any).doc = doc
;(promptManager as any).nodesMap = doc.getMap('nodes')
;(promptManager as any).edgesMap = doc.getMap('edges')
const baseTemplate = [
{
id: 'abcfa5f9-3c44-4252-aeba-4b6eaf0acfc4',
role: 'system',
text: 'avc',
},
]
const baseNode = createLLMNodeSnapshot(baseTemplate)
;(promptManager as any).syncNodes([], [deepClone(baseNode)])
const updatedTemplates = [
...baseTemplate,
{
id: 'user-1',
role: 'user',
text: 'hello world',
},
]
const updatedNode = createLLMNodeSnapshot(updatedTemplates)
;(promptManager as any).syncNodes([deepClone(baseNode)], [deepClone(updatedNode)])
const stored = (promptManager.getNodes() as Node[]).find(node => node.id === LLM_NODE_ID)
expect(stored).toBeDefined()
const storedTemplates = getPromptTemplates(stored!)
expect(storedTemplates).toHaveLength(2)
expect(storedTemplates[0]).toEqual(baseTemplate[0])
expect(storedTemplates[1]).toEqual(updatedTemplates[1])
const editedTemplates = [
{
id: 'abcfa5f9-3c44-4252-aeba-4b6eaf0acfc4',
role: 'system',
text: 'updated system prompt',
},
]
const editedNode = createLLMNodeSnapshot(editedTemplates)
;(promptManager as any).syncNodes([deepClone(updatedNode)], [deepClone(editedNode)])
const final = (promptManager.getNodes() as Node[]).find(node => node.id === LLM_NODE_ID)
const finalTemplates = getPromptTemplates(final!)
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)
})
it('handles nodes without data gracefully', () => {
const emptyNode: Node = {
id: 'empty-node',
type: 'custom',
position: { x: 0, y: 0 },
data: undefined as any,
}
;(manager as any).syncNodes([], [deepClone(emptyNode)])
const stored = (manager.getNodes() as Node[]).find(node => node.id === 'empty-node')
expect(stored).toBeDefined()
expect(stored?.data).toEqual({})
})
it('preserves CRDT list instances when synchronizing parsed state back into the manager', () => {
const promptManager = new CollaborationManager()
const doc = new LoroDoc()
;(promptManager as any).doc = doc
;(promptManager as any).nodesMap = doc.getMap('nodes')
;(promptManager as any).edgesMap = doc.getMap('edges')
const base = createLLMNodeSnapshot([
{ id: 'system', role: 'system', text: 'base' },
])
;(promptManager as any).syncNodes([], [deepClone(base)])
const storedBefore = promptManager.getNodes().find(node => node.id === LLM_NODE_ID)
const firstTemplate = (storedBefore?.data as any).prompt_template?.[0]
expect(firstTemplate?.text).toBe('base')
// simulate consumer mutating the plain JSON array and syncing back
const mutatedNode = deepClone(storedBefore!)
mutatedNode.data.prompt_template.push({
id: 'user',
role: 'user',
text: 'mutated',
})
;(promptManager as any).syncNodes([storedBefore], [mutatedNode])
const storedAfter = promptManager.getNodes().find(node => node.id === LLM_NODE_ID)
const templatesAfter = (storedAfter?.data as any).prompt_template
expect(Array.isArray(templatesAfter)).toBe(true)
expect(templatesAfter).toHaveLength(2)
})
it('reuses CRDT list when syncing parameters repeatedly', () => {
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 initialParameters: ParameterItem[] = [
{ description: 'desc', name: 'param', required: false, type: 'string' },
]
const node = createParameterExtractorNodeSnapshot(initialParameters)
;(parameterManager as any).syncNodes([], [deepClone(node)])
const stored = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID)!
const mutatedNode = deepClone(stored)
mutatedNode.data.parameters[0].description = 'updated'
;(parameterManager as any).syncNodes([stored], [mutatedNode])
const storedAfter = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID)!
const params = (storedAfter.data as any).parameters
expect(params).toHaveLength(1)
expect(params[0].description).toBe('updated')
})
it('filters out transient/private data keys while keeping allowlisted ones', () => {
const nodeWithPrivate: Node = {
id: 'private-node',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Start,
_foo: 'should disappear',
_children: ['child-a'],
selected: true,
variables: [],
},
}
;(manager as any).syncNodes([], [deepClone(nodeWithPrivate)])
const stored = (manager.getNodes() as Node[]).find(node => node.id === 'private-node')!
expect((stored.data as any)._foo).toBeUndefined()
expect((stored.data as any)._children).toEqual(['child-a'])
expect((stored.data as any).selected).toBeUndefined()
})
it('removes list fields when they are omitted in the update snapshot', () => {
const baseNode = createNodeSnapshot(['alpha'])
;(manager as any).syncNodes([], [deepClone(baseNode)])
const withoutVariables: Node = {
...deepClone(baseNode),
data: {
...deepClone(baseNode).data,
},
}
delete (withoutVariables.data as any).variables
;(manager as any).syncNodes([deepClone(baseNode)], [withoutVariables])
const stored = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)!
expect((stored.data as any).variables).toBeUndefined()
})
it('treats non-array list inputs as empty lists during synchronization', () => {
const promptManager = new CollaborationManager()
const doc = new LoroDoc()
;(promptManager as any).doc = doc
;(promptManager as any).nodesMap = doc.getMap('nodes')
;(promptManager as any).edgesMap = doc.getMap('edges')
const nodeWithInvalidTemplate = createLLMNodeSnapshot([] as any)
;(promptManager as any).syncNodes([], [deepClone(nodeWithInvalidTemplate)])
const mutated = deepClone(nodeWithInvalidTemplate)
;(mutated.data as any).prompt_template = 'not-an-array'
;(promptManager as any).syncNodes([deepClone(nodeWithInvalidTemplate)], [mutated])
const stored = promptManager.getNodes().find(node => node.id === LLM_NODE_ID)!
expect(Array.isArray((stored.data as any).prompt_template)).toBe(true)
expect((stored.data as any).prompt_template).toHaveLength(0)
})
it('updates edges map when edges are added, modified, and removed', () => {
const edgeManager = new CollaborationManager()
const doc = new LoroDoc()
;(edgeManager as any).doc = doc
;(edgeManager as any).nodesMap = doc.getMap('nodes')
;(edgeManager as any).edgesMap = doc.getMap('edges')
const edge: Edge = {
id: 'edge-1',
source: 'node-a',
target: 'node-b',
type: 'default',
data: { label: 'initial' },
} as Edge
;(edgeManager as any).setEdges([], [edge])
expect(edgeManager.getEdges()).toHaveLength(1)
expect((edgeManager.getEdges()[0].data as any).label).toBe('initial')
const updatedEdge: Edge = {
...edge,
data: { label: 'updated' },
}
;(edgeManager as any).setEdges([edge], [updatedEdge])
expect(edgeManager.getEdges()).toHaveLength(1)
expect((edgeManager.getEdges()[0].data as any).label).toBe('updated')
;(edgeManager as any).setEdges([updatedEdge], [])
expect(edgeManager.getEdges()).toHaveLength(0)
})
})
describe('CollaborationManager public API wrappers', () => {
let manager: CollaborationManager
const baseNodes: Node[] = []
const updatedNodes: Node[] = [
{ id: 'new-node', type: 'custom', position: { x: 0, y: 0 }, data: {} } as Node,
]
const baseEdges: Edge[] = []
const updatedEdges: Edge[] = [
{ id: 'edge-1', source: 'source', target: 'target', type: 'default', data: {} } as Edge,
]
beforeEach(() => {
manager = new CollaborationManager()
})
it('setNodes delegates to syncNodes and commits the CRDT document', () => {
const commit = jest.fn()
;(manager as any).doc = { commit }
const syncSpy = jest.spyOn(manager as any, 'syncNodes').mockImplementation(() => undefined)
manager.setNodes(baseNodes, updatedNodes)
expect(syncSpy).toHaveBeenCalledWith(baseNodes, updatedNodes)
expect(commit).toHaveBeenCalled()
syncSpy.mockRestore()
})
it('setNodes skips syncing when undo/redo replay is running', () => {
const commit = jest.fn()
;(manager as any).doc = { commit }
;(manager as any).isUndoRedoInProgress = true
const syncSpy = jest.spyOn(manager as any, 'syncNodes').mockImplementation(() => undefined)
manager.setNodes(baseNodes, updatedNodes)
expect(syncSpy).not.toHaveBeenCalled()
expect(commit).not.toHaveBeenCalled()
syncSpy.mockRestore()
})
it('setEdges delegates to syncEdges and commits the CRDT document', () => {
const commit = jest.fn()
;(manager as any).doc = { commit }
const syncSpy = jest.spyOn(manager as any, 'syncEdges').mockImplementation(() => undefined)
manager.setEdges(baseEdges, updatedEdges)
expect(syncSpy).toHaveBeenCalledWith(baseEdges, updatedEdges)
expect(commit).toHaveBeenCalled()
syncSpy.mockRestore()
})
it('disconnect tears down the collaboration state only when last connection closes', () => {
const forceSpy = jest.spyOn(manager as any, 'forceDisconnect').mockImplementation(() => undefined)
;(manager as any).activeConnections.add('conn-a')
;(manager as any).activeConnections.add('conn-b')
manager.disconnect('conn-a')
expect(forceSpy).not.toHaveBeenCalled()
manager.disconnect('conn-b')
expect(forceSpy).toHaveBeenCalledTimes(1)
forceSpy.mockRestore()
})
it('applyNodePanelPresenceUpdate keeps a client visible on a single node at a time', () => {
const updates: NodePanelPresenceMap[] = []
manager.onNodePanelPresenceUpdate((presence) => {
updates.push(presence)
})
const user: NodePanelPresenceUser = { userId: 'user-1', username: 'Dana' }
;(manager as any).applyNodePanelPresenceUpdate({
nodeId: 'node-a',
action: 'open',
user,
clientId: 'client-1',
timestamp: 100,
})
;(manager as any).applyNodePanelPresenceUpdate({
nodeId: 'node-b',
action: 'open',
user,
clientId: 'client-1',
timestamp: 200,
})
const finalSnapshot = updates[updates.length - 1]!
expect(finalSnapshot).toEqual({
'node-b': {
'client-1': {
userId: 'user-1',
username: 'Dana',
clientId: 'client-1',
timestamp: 200,
},
},
})
})
it('applyNodePanelPresenceUpdate clears node entries when last viewer closes the panel', () => {
const updates: NodePanelPresenceMap[] = []
manager.onNodePanelPresenceUpdate((presence) => {
updates.push(presence)
})
const user: NodePanelPresenceUser = { userId: 'user-2', username: 'Kai' }
;(manager as any).applyNodePanelPresenceUpdate({
nodeId: 'node-a',
action: 'open',
user,
clientId: 'client-9',
timestamp: 300,
})
;(manager as any).applyNodePanelPresenceUpdate({
nodeId: 'node-a',
action: 'close',
user,
clientId: 'client-9',
timestamp: 301,
})
expect(updates[updates.length - 1]).toEqual({})
})
})

View File

@ -0,0 +1,121 @@
import type { Socket } from 'socket.io-client'
import { CRDTProvider } from '../crdt-provider'
type FakeDoc = {
export: jest.Mock<Uint8Array, [options?: { mode?: string }]>
import: jest.Mock<void, [Uint8Array]>
subscribe: jest.Mock<void, [(payload: any) => void]>
trigger: (event: any) => void
}
const createFakeDoc = (): FakeDoc => {
let handler: ((payload: any) => void) | null = null
return {
export: jest.fn(() => new Uint8Array([1, 2, 3])),
import: jest.fn(),
subscribe: jest.fn((cb: (payload: any) => void) => {
handler = cb
}),
trigger: (event: any) => {
handler?.(event)
},
}
}
const createMockSocket = () => {
const handlers = new Map<string, (...args: any[]) => void>()
const socket: any = {
emit: jest.fn(),
on: jest.fn((event: string, handler: (...args: any[]) => void) => {
handlers.set(event, handler)
}),
off: jest.fn((event: string) => {
handlers.delete(event)
}),
trigger: (event: string, ...args: any[]) => {
const handler = handlers.get(event)
if (handler)
handler(...args)
},
}
return socket as Socket & { trigger: (event: string, ...args: any[]) => void }
}
describe('CRDTProvider', () => {
it('emits graph_event when local changes happen', () => {
const doc = createFakeDoc()
const socket = createMockSocket()
const provider = new CRDTProvider(socket, doc as unknown as any)
expect(provider).toBeInstanceOf(CRDTProvider)
doc.trigger({ by: 'local' })
expect(socket.emit).toHaveBeenCalledWith(
'graph_event',
expect.any(Uint8Array),
)
expect(doc.export).toHaveBeenCalledWith({ mode: 'update' })
})
it('ignores non-local events', () => {
const doc = createFakeDoc()
const socket = createMockSocket()
const provider = new CRDTProvider(socket, doc as unknown as any)
doc.trigger({ by: 'remote' })
expect(socket.emit).not.toHaveBeenCalled()
provider.destroy()
})
it('imports remote updates on graph_update', () => {
const doc = createFakeDoc()
const socket = createMockSocket()
const provider = new CRDTProvider(socket, doc as unknown as any)
const payload = new Uint8Array([9, 9, 9])
socket.trigger('graph_update', payload)
expect(doc.import).toHaveBeenCalledWith(expect.any(Uint8Array))
expect(Array.from(doc.import.mock.calls[0][0])).toEqual([9, 9, 9])
provider.destroy()
})
it('removes graph_update listener on destroy', () => {
const doc = createFakeDoc()
const socket = createMockSocket()
const provider = new CRDTProvider(socket, doc as unknown as any)
provider.destroy()
expect(socket.off).toHaveBeenCalledWith('graph_update')
})
it('logs an error when graph_update import fails but continues operating', () => {
const doc = createFakeDoc()
const socket = createMockSocket()
doc.import.mockImplementation(() => {
throw new Error('boom')
})
const provider = new CRDTProvider(socket, doc as unknown as any)
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
socket.trigger('graph_update', new Uint8Array([1]))
expect(errorSpy).toHaveBeenCalledWith('Error importing graph update:', expect.any(Error))
doc.import.mockReset()
socket.trigger('graph_update', new Uint8Array([2, 3]))
expect(doc.import).toHaveBeenCalled()
provider.destroy()
errorSpy.mockRestore()
})
})

View File

@ -0,0 +1,93 @@
import { EventEmitter } from '../event-emitter'
describe('EventEmitter', () => {
it('registers and invokes handlers via on/emit', () => {
const emitter = new EventEmitter()
const handler = jest.fn()
emitter.on('test', handler)
emitter.emit('test', { value: 42 })
expect(handler).toHaveBeenCalledWith({ value: 42 })
})
it('removes specific handler with off', () => {
const emitter = new EventEmitter()
const handlerA = jest.fn()
const handlerB = jest.fn()
emitter.on('test', handlerA)
emitter.on('test', handlerB)
emitter.off('test', handlerA)
emitter.emit('test', 'payload')
expect(handlerA).not.toHaveBeenCalled()
expect(handlerB).toHaveBeenCalledWith('payload')
})
it('clears all listeners when off is called without handler', () => {
const emitter = new EventEmitter()
const handlerA = jest.fn()
const handlerB = jest.fn()
emitter.on('trigger', handlerA)
emitter.on('trigger', handlerB)
emitter.off('trigger')
emitter.emit('trigger', 'payload')
expect(handlerA).not.toHaveBeenCalled()
expect(handlerB).not.toHaveBeenCalled()
expect(emitter.getListenerCount('trigger')).toBe(0)
})
it('removeAllListeners clears every registered event', () => {
const emitter = new EventEmitter()
emitter.on('one', jest.fn())
emitter.on('two', jest.fn())
emitter.removeAllListeners()
expect(emitter.getListenerCount('one')).toBe(0)
expect(emitter.getListenerCount('two')).toBe(0)
})
it('returns an unsubscribe function from on', () => {
const emitter = new EventEmitter()
const handler = jest.fn()
const unsubscribe = emitter.on('detach', handler)
unsubscribe()
emitter.emit('detach', 'value')
expect(handler).not.toHaveBeenCalled()
})
it('continues emitting when a handler throws', () => {
const emitter = new EventEmitter()
const errorHandler = jest
.spyOn(console, 'error')
.mockImplementation()
const failingHandler = jest.fn(() => {
throw new Error('boom')
})
const succeedingHandler = jest.fn()
emitter.on('safe', failingHandler)
emitter.on('safe', succeedingHandler)
emitter.emit('safe', 7)
expect(failingHandler).toHaveBeenCalledWith(7)
expect(succeedingHandler).toHaveBeenCalledWith(7)
expect(errorHandler).toHaveBeenCalledWith(
expect.stringContaining('Error in event handler for safe:'),
expect.any(Error),
)
errorHandler.mockRestore()
})
})

View File

@ -0,0 +1,165 @@
import type { Socket } from 'socket.io-client'
const ioMock = jest.fn()
jest.mock('socket.io-client', () => ({
io: (...args: any[]) => ioMock(...args),
}))
const createMockSocket = (id: string): Socket & {
trigger: (event: string, ...args: any[]) => void
} => {
const handlers = new Map<string, (...args: any[]) => void>()
const socket: any = {
id,
connected: true,
emit: jest.fn(),
disconnect: jest.fn(() => {
socket.connected = false
}),
on: jest.fn((event: string, handler: (...args: any[]) => void) => {
handlers.set(event, handler)
}),
trigger: (event: string, ...args: any[]) => {
const handler = handlers.get(event)
if (handler)
handler(...args)
},
}
return socket as Socket & { trigger: (event: string, ...args: any[]) => void }
}
describe('WebSocketClient', () => {
let originalWindow: typeof window | undefined
beforeEach(() => {
jest.resetModules()
ioMock.mockReset()
originalWindow = globalThis.window
})
afterEach(() => {
if (originalWindow)
globalThis.window = originalWindow
else
delete (globalThis as any).window
})
it('connects with fallback url and registers base listeners when window is undefined', async () => {
delete (globalThis as any).window
const mockSocket = createMockSocket('socket-fallback')
ioMock.mockImplementation(() => mockSocket)
const { WebSocketClient } = await import('../websocket-manager')
const client = new WebSocketClient()
const socket = client.connect('app-1')
expect(ioMock).toHaveBeenCalledWith(
'ws://localhost:5001',
expect.objectContaining({
path: '/socket.io',
transports: ['websocket'],
withCredentials: true,
}),
)
expect(socket).toBe(mockSocket)
expect(mockSocket.on).toHaveBeenCalledWith('connect', expect.any(Function))
expect(mockSocket.on).toHaveBeenCalledWith('disconnect', expect.any(Function))
expect(mockSocket.on).toHaveBeenCalledWith('connect_error', expect.any(Function))
})
it('reuses existing connected socket and avoids duplicate connections', async () => {
const mockSocket = createMockSocket('socket-reuse')
ioMock.mockImplementation(() => mockSocket)
const { WebSocketClient } = await import('../websocket-manager')
const client = new WebSocketClient()
const first = client.connect('app-reuse')
const second = client.connect('app-reuse')
expect(ioMock).toHaveBeenCalledTimes(1)
expect(second).toBe(first)
})
it('attaches auth token from localStorage and emits user_connect on connect', async () => {
const mockSocket = createMockSocket('socket-auth')
ioMock.mockImplementation((url, options) => {
expect(options.auth).toEqual({ token: 'secret-token' })
return mockSocket
})
globalThis.window = {
location: { protocol: 'https:', host: 'example.com' },
localStorage: {
getItem: jest.fn(() => 'secret-token'),
},
} as unknown as typeof window
const { WebSocketClient } = await import('../websocket-manager')
const client = new WebSocketClient()
client.connect('app-auth')
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1] as () => void
expect(connectHandler).toBeDefined()
connectHandler()
expect(mockSocket.emit).toHaveBeenCalledWith('user_connect', { workflow_id: 'app-auth' })
})
it('disconnects a specific app and clears internal maps', async () => {
const mockSocket = createMockSocket('socket-disconnect-one')
ioMock.mockImplementation(() => mockSocket)
const { WebSocketClient } = await import('../websocket-manager')
const client = new WebSocketClient()
client.connect('app-disconnect')
expect(client.isConnected('app-disconnect')).toBe(true)
client.disconnect('app-disconnect')
expect(mockSocket.disconnect).toHaveBeenCalled()
expect(client.getSocket('app-disconnect')).toBeNull()
expect(client.isConnected('app-disconnect')).toBe(false)
})
it('disconnects all apps when no id is provided', async () => {
const socketA = createMockSocket('socket-a')
const socketB = createMockSocket('socket-b')
ioMock.mockImplementationOnce(() => socketA).mockImplementationOnce(() => socketB)
const { WebSocketClient } = await import('../websocket-manager')
const client = new WebSocketClient()
client.connect('app-a')
client.connect('app-b')
client.disconnect()
expect(socketA.disconnect).toHaveBeenCalled()
expect(socketB.disconnect).toHaveBeenCalled()
expect(client.getConnectedApps()).toEqual([])
})
it('reports connected apps, sockets, and debug info correctly', async () => {
const socketA = createMockSocket('socket-debug-a')
const socketB = createMockSocket('socket-debug-b')
socketB.connected = false
ioMock.mockImplementationOnce(() => socketA).mockImplementationOnce(() => socketB)
const { WebSocketClient } = await import('../websocket-manager')
const client = new WebSocketClient()
client.connect('app-a')
client.connect('app-b')
expect(client.getConnectedApps()).toEqual(['app-a'])
const debugInfo = client.getDebugInfo()
expect(debugInfo).toMatchObject({
'app-a': { connected: true, socketId: 'socket-debug-a' },
'app-b': { connected: false, socketId: 'socket-debug-b' },
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
import type { LoroDoc } from 'loro-crdt'
import type { Socket } from 'socket.io-client'
import { emitWithAuthGuard } from './websocket-manager'
export class CRDTProvider {
private doc: LoroDoc
private socket: Socket
private onUnauthorized?: () => void
constructor(socket: Socket, doc: LoroDoc, onUnauthorized?: () => void) {
this.socket = socket
this.doc = doc
this.onUnauthorized = onUnauthorized
this.setupEventListeners()
}
private setupEventListeners(): void {
this.doc.subscribe((event: any) => {
if (event.by === 'local') {
const update = this.doc.export({ mode: 'update' })
emitWithAuthGuard(this.socket, 'graph_event', update, { onUnauthorized: this.onUnauthorized })
}
})
this.socket.on('graph_update', (updateData: Uint8Array) => {
try {
const data = new Uint8Array(updateData)
this.doc.import(data)
}
catch (error) {
console.error('Error importing graph update:', error)
}
})
}
destroy(): void {
this.socket.off('graph_update')
}
}

View File

@ -0,0 +1,49 @@
export type EventHandler<T = any> = (data: T) => void
export class EventEmitter {
private events: Map<string, Set<EventHandler>> = new Map()
on<T = any>(event: string, handler: EventHandler<T>): () => void {
if (!this.events.has(event))
this.events.set(event, new Set())
this.events.get(event)!.add(handler)
return () => this.off(event, handler)
}
off<T = any>(event: string, handler?: EventHandler<T>): void {
if (!this.events.has(event)) return
const handlers = this.events.get(event)!
if (handler)
handlers.delete(handler)
else
handlers.clear()
if (handlers.size === 0)
this.events.delete(event)
}
emit<T = any>(event: string, data: T): void {
if (!this.events.has(event)) return
const handlers = this.events.get(event)!
handlers.forEach((handler) => {
try {
handler(data)
}
catch (error) {
console.error(`Error in event handler for ${event}:`, error)
}
})
}
removeAllListeners(): void {
this.events.clear()
}
getListenerCount(event: string): number {
return this.events.get(event)?.size || 0
}
}

View File

@ -0,0 +1,168 @@
import type { Socket } from 'socket.io-client'
import { io } from 'socket.io-client'
import { ACCESS_TOKEN_LOCAL_STORAGE_NAME } from '@/config'
import type { DebugInfo, WebSocketConfig } from '../types/websocket'
const isUnauthorizedAck = (...ackArgs: any[]): boolean => {
const [first, second] = ackArgs
if (second === 401 || first === 401)
return true
if (first && typeof first === 'object' && first.msg === 'unauthorized')
return true
return false
}
export type EmitAckOptions = {
onAck?: (...ackArgs: any[]) => void
onUnauthorized?: (...ackArgs: any[]) => void
}
export const emitWithAuthGuard = (
socket: Socket | null | undefined,
event: string,
payload: any,
options?: EmitAckOptions,
): void => {
if (!socket)
return
socket.emit(
event,
payload,
(...ackArgs: any[]) => {
options?.onAck?.(...ackArgs)
if (isUnauthorizedAck(...ackArgs))
options?.onUnauthorized?.(...ackArgs)
},
)
}
export class WebSocketClient {
private connections: Map<string, Socket> = new Map()
private connecting: Set<string> = new Set()
private config: WebSocketConfig
constructor(config: WebSocketConfig = {}) {
const inferUrl = () => {
if (typeof window === 'undefined')
return 'ws://localhost:5001'
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${scheme}//${window.location.host}`
}
this.config = {
url: config.url || process.env.NEXT_PUBLIC_SOCKET_URL || inferUrl(),
transports: config.transports || ['websocket'],
withCredentials: config.withCredentials !== false,
...config,
}
}
connect(appId: string): Socket {
const existingSocket = this.connections.get(appId)
if (existingSocket?.connected)
return existingSocket
if (this.connecting.has(appId)) {
const pendingSocket = this.connections.get(appId)
if (pendingSocket)
return pendingSocket
}
if (existingSocket && !existingSocket.connected) {
existingSocket.disconnect()
this.connections.delete(appId)
}
this.connecting.add(appId)
const authToken = typeof window === 'undefined'
? undefined
: window.localStorage.getItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME) ?? undefined
const socketOptions: {
path: string
transports: WebSocketConfig['transports']
withCredentials?: boolean
auth?: { token: string }
} = {
path: '/socket.io',
transports: this.config.transports,
withCredentials: this.config.withCredentials,
}
if (authToken)
socketOptions.auth = { token: authToken }
const socket = io(this.config.url!, socketOptions)
this.connections.set(appId, socket)
this.setupBaseEventListeners(socket, appId)
return socket
}
disconnect(appId?: string): void {
if (appId) {
const socket = this.connections.get(appId)
if (socket) {
socket.disconnect()
this.connections.delete(appId)
this.connecting.delete(appId)
}
}
else {
this.connections.forEach(socket => socket.disconnect())
this.connections.clear()
this.connecting.clear()
}
}
getSocket(appId: string): Socket | null {
return this.connections.get(appId) || null
}
isConnected(appId: string): boolean {
return this.connections.get(appId)?.connected || false
}
getConnectedApps(): string[] {
const connectedApps: string[] = []
this.connections.forEach((socket, appId) => {
if (socket.connected)
connectedApps.push(appId)
})
return connectedApps
}
getDebugInfo(): DebugInfo {
const info: DebugInfo = {}
this.connections.forEach((socket, appId) => {
info[appId] = {
connected: socket.connected,
connecting: this.connecting.has(appId),
socketId: socket.id,
}
})
return info
}
private setupBaseEventListeners(socket: Socket, appId: string): void {
socket.on('connect', () => {
this.connecting.delete(appId)
emitWithAuthGuard(socket, 'user_connect', { workflow_id: appId })
})
socket.on('disconnect', () => {
this.connecting.delete(appId)
})
socket.on('connect_error', () => {
this.connecting.delete(appId)
})
}
}
export const webSocketClient = new WebSocketClient()

View File

@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react'
import type { ReactFlowInstance } from 'reactflow'
import { collaborationManager } from '../core/collaboration-manager'
import { CursorService } from '../services/cursor-service'
import type { CollaborationState } from '../types/collaboration'
import { useGlobalPublicStore } from '@/context/global-public-context'
export function useCollaboration(appId: string, reactFlowStore?: any) {
const [state, setState] = useState<Partial<CollaborationState & { isLeader: boolean }>>({
isConnected: false,
onlineUsers: [],
cursors: {},
nodePanelPresence: {},
isLeader: false,
})
const cursorServiceRef = useRef<CursorService | null>(null)
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
useEffect(() => {
if (!appId || !isCollaborationEnabled) {
setState({
isConnected: false,
onlineUsers: [],
cursors: {},
nodePanelPresence: {},
isLeader: false,
})
return
}
let connectionId: string | null = null
let isUnmounted = false
if (!cursorServiceRef.current)
cursorServiceRef.current = new CursorService()
const initCollaboration = async () => {
try {
const id = await collaborationManager.connect(appId, reactFlowStore)
if (isUnmounted) {
collaborationManager.disconnect(id)
return
}
connectionId = id
setState((prev: any) => ({ ...prev, appId, isConnected: collaborationManager.isConnected() }))
}
catch (error) {
console.error('Failed to initialize collaboration:', error)
}
}
initCollaboration()
const unsubscribeStateChange = collaborationManager.onStateChange((newState: any) => {
console.log('Collaboration state change:', newState)
setState((prev: any) => ({ ...prev, ...newState }))
})
const unsubscribeCursors = collaborationManager.onCursorUpdate((cursors: any) => {
setState((prev: any) => ({ ...prev, cursors }))
})
const unsubscribeUsers = collaborationManager.onOnlineUsersUpdate((users: any) => {
console.log('Online users update:', users)
setState((prev: any) => ({ ...prev, onlineUsers: users }))
})
const unsubscribeNodePanelPresence = collaborationManager.onNodePanelPresenceUpdate((presence) => {
setState((prev: any) => ({ ...prev, nodePanelPresence: presence }))
})
const unsubscribeLeaderChange = collaborationManager.onLeaderChange((isLeader: boolean) => {
console.log('Leader status changed:', isLeader)
setState((prev: any) => ({ ...prev, isLeader }))
})
return () => {
isUnmounted = true
unsubscribeStateChange()
unsubscribeCursors()
unsubscribeUsers()
unsubscribeNodePanelPresence()
unsubscribeLeaderChange()
cursorServiceRef.current?.stopTracking()
if (connectionId)
collaborationManager.disconnect(connectionId)
}
}, [appId, reactFlowStore, isCollaborationEnabled])
const startCursorTracking = (containerRef: React.RefObject<HTMLElement>, reactFlowInstance?: ReactFlowInstance) => {
if (!isCollaborationEnabled || !cursorServiceRef.current)
return
if (cursorServiceRef.current) {
cursorServiceRef.current.startTracking(containerRef, (position) => {
collaborationManager.emitCursorMove(position)
}, reactFlowInstance)
}
}
const stopCursorTracking = () => {
cursorServiceRef.current?.stopTracking()
}
const result = {
isConnected: state.isConnected || false,
onlineUsers: state.onlineUsers || [],
cursors: state.cursors || {},
nodePanelPresence: state.nodePanelPresence || {},
isLeader: state.isLeader || false,
leaderId: collaborationManager.getLeaderId(),
isEnabled: isCollaborationEnabled,
startCursorTracking,
stopCursorTracking,
}
return result
}

View File

@ -0,0 +1,5 @@
export { collaborationManager } from './core/collaboration-manager'
export { webSocketClient } from './core/websocket-manager'
export { CursorService } from './services/cursor-service'
export { useCollaboration } from './hooks/use-collaboration'
export * from './types'

View File

@ -0,0 +1,88 @@
import type { RefObject } from 'react'
import type { CursorPosition } from '../types/collaboration'
import type { ReactFlowInstance } from 'reactflow'
const CURSOR_MIN_MOVE_DISTANCE = 10
const CURSOR_THROTTLE_MS = 500
export class CursorService {
private containerRef: RefObject<HTMLElement> | null = null
private reactFlowInstance: ReactFlowInstance | null = null
private isTracking = false
private onCursorUpdate: ((cursors: Record<string, CursorPosition>) => void) | null = null
private onEmitPosition: ((position: CursorPosition) => void) | null = null
private lastEmitTime = 0
private lastPosition: { x: number; y: number } | null = null
startTracking(
containerRef: RefObject<HTMLElement>,
onEmitPosition: (position: CursorPosition) => void,
reactFlowInstance?: ReactFlowInstance,
): void {
if (this.isTracking) this.stopTracking()
this.containerRef = containerRef
this.onEmitPosition = onEmitPosition
this.reactFlowInstance = reactFlowInstance || null
this.isTracking = true
if (containerRef.current)
containerRef.current.addEventListener('mousemove', this.handleMouseMove)
}
stopTracking(): void {
if (this.containerRef?.current)
this.containerRef.current.removeEventListener('mousemove', this.handleMouseMove)
this.containerRef = null
this.reactFlowInstance = null
this.onEmitPosition = null
this.isTracking = false
this.lastPosition = null
}
setCursorUpdateHandler(handler: (cursors: Record<string, CursorPosition>) => void): void {
this.onCursorUpdate = handler
}
updateCursors(cursors: Record<string, CursorPosition>): void {
if (this.onCursorUpdate)
this.onCursorUpdate(cursors)
}
private handleMouseMove = (event: MouseEvent): void => {
if (!this.containerRef?.current || !this.onEmitPosition) return
const rect = this.containerRef.current.getBoundingClientRect()
let x = event.clientX - rect.left
let y = event.clientY - rect.top
// Transform coordinates to ReactFlow world coordinates if ReactFlow instance is available
if (this.reactFlowInstance) {
const viewport = this.reactFlowInstance.getViewport()
// Convert screen coordinates to world coordinates
// World coordinates = (screen coordinates - viewport translation) / zoom
x = (x - viewport.x) / viewport.zoom
y = (y - viewport.y) / viewport.zoom
}
// Always emit cursor position (remove boundary check since world coordinates can be negative)
const now = Date.now()
const timeThrottled = now - this.lastEmitTime > CURSOR_THROTTLE_MS
const minDistance = CURSOR_MIN_MOVE_DISTANCE / (this.reactFlowInstance?.getZoom() || 1)
const distanceThrottled = !this.lastPosition
|| (Math.abs(x - this.lastPosition.x) > minDistance)
|| (Math.abs(y - this.lastPosition.y) > minDistance)
if (timeThrottled && distanceThrottled) {
this.lastPosition = { x, y }
this.lastEmitTime = now
this.onEmitPosition({
x,
y,
userId: '',
timestamp: now,
})
}
}
}

View File

@ -0,0 +1,57 @@
import type { Edge, Node } from '../../types'
export type OnlineUser = {
user_id: string
username: string
avatar: string
sid: string
}
export type WorkflowOnlineUsers = {
workflow_id: string
users: OnlineUser[]
}
export type OnlineUserListResponse = {
data: WorkflowOnlineUsers[]
}
export type CursorPosition = {
x: number
y: number
userId: string
timestamp: number
}
export type NodePanelPresenceUser = {
userId: string
username: string
avatar?: string | null
}
export type NodePanelPresenceInfo = NodePanelPresenceUser & {
clientId: string
timestamp: number
}
export type NodePanelPresenceMap = Record<string, Record<string, NodePanelPresenceInfo>>
export type CollaborationState = {
appId: string
isConnected: boolean
onlineUsers: OnlineUser[]
cursors: Record<string, CursorPosition>
nodePanelPresence: NodePanelPresenceMap
}
export type GraphSyncData = {
nodes: Node[]
edges: Edge[]
}
export type CollaborationUpdate = {
type: 'mouse_move' | 'vars_and_features_update' | 'sync_request' | 'app_state_update' | 'app_meta_update' | 'mcp_server_update' | 'workflow_update' | 'comments_update' | 'node_panel_presence' | 'app_publish_update'
userId: string
data: any
timestamp: number
}

View File

@ -0,0 +1,38 @@
export type CollaborationEvent = {
type: string
data: any
timestamp: number
}
export type GraphUpdateEvent = {
type: 'graph_update'
data: Uint8Array
} & CollaborationEvent
export type CursorMoveEvent = {
type: 'cursor_move'
data: {
x: number
y: number
userId: string
}
} & CollaborationEvent
export type UserConnectEvent = {
type: 'user_connect'
data: {
workflow_id: string
}
} & CollaborationEvent
export type OnlineUsersEvent = {
type: 'online_users'
data: {
users: Array<{
user_id: string
username: string
avatar: string
sid: string
}>
}
} & CollaborationEvent

View File

@ -0,0 +1,3 @@
export * from './websocket'
export * from './collaboration'
export * from './events'

View File

@ -0,0 +1,16 @@
export type WebSocketConfig = {
url?: string
token?: string
transports?: string[]
withCredentials?: boolean
}
export type ConnectionInfo = {
connected: boolean
connecting: boolean
socketId?: string
}
export type DebugInfo = {
[appId: string]: ConnectionInfo
}

View File

@ -0,0 +1,12 @@
/**
* Generate a consistent color for a user based on their ID
* Used for cursor colors and avatar backgrounds
*/
export const getUserColor = (id: string): string => {
const colors = ['#155AEF', '#0BA5EC', '#444CE7', '#7839EE', '#4CA30D', '#0E9384', '#DD2590', '#FF4405', '#D92D20', '#F79009', '#828DAD']
const hash = id.split('').reduce((a, b) => {
a = ((a << 5) - a) + b.charCodeAt(0)
return a & a
}, 0)
return colors[Math.abs(hash) % colors.length]
}

View File

@ -0,0 +1,34 @@
import { useEventListener } from 'ahooks'
import { useWorkflowStore } from './store'
import { useWorkflowComment } from './hooks/use-workflow-comment'
const CommentManager = () => {
const workflowStore = useWorkflowStore()
const { handleCreateComment, handleCommentCancel } = useWorkflowComment()
useEventListener('click', (e) => {
const { controlMode, mousePosition, pendingComment } = workflowStore.getState()
if (controlMode === 'comment') {
const target = e.target as HTMLElement
const isInDropdown = target.closest('[data-mention-dropdown]')
const isInCommentInput = target.closest('[data-comment-input]')
const isOnCanvasPane = target.closest('.react-flow__pane')
// Only when clicking on the React Flow canvas pane (background),
// and not inside comment input or its dropdown
if (!isInDropdown && !isInCommentInput && isOnCanvasPane) {
e.preventDefault()
e.stopPropagation()
if (pendingComment)
handleCommentCancel()
else
handleCreateComment(mousePosition)
}
}
})
return null
}
export default CommentManager

View File

@ -0,0 +1,265 @@
'use client'
import type { FC, PointerEvent as ReactPointerEvent } from 'react'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { useReactFlow, useViewport } from 'reactflow'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import CommentPreview from './comment-preview'
import type { WorkflowCommentList } from '@/service/workflow-comment'
import { useAppContext } from '@/context/app-context'
type CommentIconProps = {
comment: WorkflowCommentList
onClick: () => void
isActive?: boolean
onPositionUpdate?: (position: { x: number; y: number }) => void
}
export const CommentIcon: FC<CommentIconProps> = memo(({ comment, onClick, isActive = false, onPositionUpdate }) => {
const { flowToScreenPosition, screenToFlowPosition } = useReactFlow()
const viewport = useViewport()
const { userProfile } = useAppContext()
const isAuthor = comment.created_by_account?.id === userProfile?.id
const [showPreview, setShowPreview] = useState(false)
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null)
const [isDragging, setIsDragging] = useState(false)
const dragStateRef = useRef<{
offsetX: number
offsetY: number
startX: number
startY: number
hasMoved: boolean
} | null>(null)
const workflowContainerRect = typeof document !== 'undefined'
? document.getElementById('workflow-container')?.getBoundingClientRect()
: null
const containerLeft = workflowContainerRect?.left ?? 0
const containerTop = workflowContainerRect?.top ?? 0
const screenPosition = useMemo(() => {
return flowToScreenPosition({
x: comment.position_x,
y: comment.position_y,
})
}, [comment.position_x, comment.position_y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition])
const effectiveScreenPosition = dragPosition ?? screenPosition
const canvasPosition = useMemo(() => ({
x: effectiveScreenPosition.x - containerLeft,
y: effectiveScreenPosition.y - containerTop,
}), [effectiveScreenPosition.x, effectiveScreenPosition.y, containerLeft, containerTop])
const cursorClass = useMemo(() => {
if (!isAuthor)
return 'cursor-pointer'
if (isActive)
return isDragging ? 'cursor-grabbing' : ''
return isDragging ? 'cursor-grabbing' : 'cursor-pointer'
}, [isActive, isAuthor, isDragging])
const handlePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0)
return
event.stopPropagation()
event.preventDefault()
if (!isAuthor) {
if (event.currentTarget.dataset.role !== 'comment-preview')
setShowPreview(false)
return
}
dragStateRef.current = {
offsetX: event.clientX - screenPosition.x,
offsetY: event.clientY - screenPosition.y,
startX: event.clientX,
startY: event.clientY,
hasMoved: false,
}
setDragPosition(screenPosition)
setIsDragging(false)
if (event.currentTarget.dataset.role !== 'comment-preview')
setShowPreview(false)
if (event.currentTarget.setPointerCapture)
event.currentTarget.setPointerCapture(event.pointerId)
}, [isAuthor, screenPosition])
const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current
if (!dragState)
return
event.stopPropagation()
event.preventDefault()
const nextX = event.clientX - dragState.offsetX
const nextY = event.clientY - dragState.offsetY
if (!dragState.hasMoved) {
const distance = Math.hypot(event.clientX - dragState.startX, event.clientY - dragState.startY)
if (distance > 4) {
dragState.hasMoved = true
setIsDragging(true)
}
}
setDragPosition({ x: nextX, y: nextY })
}, [])
const finishDrag = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current
if (!dragState)
return false
if (event.currentTarget.hasPointerCapture?.(event.pointerId))
event.currentTarget.releasePointerCapture(event.pointerId)
dragStateRef.current = null
setDragPosition(null)
setIsDragging(false)
return dragState.hasMoved
}, [])
const handlePointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation()
event.preventDefault()
const finalScreenPosition = dragPosition ?? screenPosition
const didDrag = finishDrag(event)
setShowPreview(false)
if (didDrag) {
if (onPositionUpdate) {
const flowPosition = screenToFlowPosition({
x: finalScreenPosition.x,
y: finalScreenPosition.y,
})
onPositionUpdate(flowPosition)
}
}
else if (!isActive) {
onClick()
}
}, [dragPosition, finishDrag, isActive, onClick, onPositionUpdate, screenPosition, screenToFlowPosition])
const handlePointerCancel = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation()
event.preventDefault()
finishDrag(event)
}, [finishDrag])
const handleMouseEnter = useCallback(() => {
if (isActive || isDragging)
return
setShowPreview(true)
}, [isActive, isDragging])
const handleMouseLeave = useCallback(() => {
setShowPreview(false)
}, [])
const participants = useMemo(() => {
const list = comment.participants ?? []
const author = comment.created_by_account
if (!author)
return [...list]
const rest = list.filter(user => user.id !== author.id)
return [author, ...rest]
}, [comment.created_by_account, comment.participants])
// Calculate dynamic width based on number of participants
const participantCount = participants.length
const maxVisible = Math.min(3, participantCount)
const showCount = participantCount > 3
const avatarSize = 24
const avatarSpacing = 4 // -space-x-1 is about 4px overlap
// Width calculation: first avatar + (additional avatars * (size - spacing)) + padding
const dynamicWidth = Math.max(40, // minimum width
8 + avatarSize + Math.max(0, (showCount ? 2 : maxVisible - 1)) * (avatarSize - avatarSpacing) + 8,
)
const pointerEventHandlers = useMemo(() => ({
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onPointerCancel: handlePointerCancel,
}), [handlePointerCancel, handlePointerDown, handlePointerMove, handlePointerUp])
return (
<>
<div
className="absolute z-10"
style={{
left: canvasPosition.x,
top: canvasPosition.y,
transform: 'translate(-50%, -50%)',
}}
data-role='comment-marker'
{...pointerEventHandlers}
>
<div
className={cursorClass}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div
className={'relative h-10 rounded-br-full rounded-tl-full rounded-tr-full'}
style={{ width: dynamicWidth }}
>
<div className={`absolute inset-[6px] overflow-hidden rounded-br-full rounded-tl-full rounded-tr-full border bg-components-panel-bg transition-shadow ${
isActive
? 'border-primary-500 ring-1 ring-primary-500'
: 'border-components-panel-border'
}`}>
<div className="flex h-full w-full items-center justify-center px-1">
<UserAvatarList
users={participants}
maxVisible={3}
size={24}
/>
</div>
</div>
</div>
</div>
</div>
{/* Preview panel */}
{showPreview && !isActive && (
<div
className="absolute z-20"
style={{
left: (effectiveScreenPosition.x - containerLeft) - dynamicWidth / 2,
top: (effectiveScreenPosition.y - containerTop) + 20,
transform: 'translateY(-100%)',
}}
data-role='comment-preview'
{...pointerEventHandlers}
onMouseEnter={() => setShowPreview(true)}
onMouseLeave={() => setShowPreview(false)}
>
<CommentPreview comment={comment} onClick={() => {
setShowPreview(false)
onClick()
}} />
</div>
)}
</>
)
}, (prevProps, nextProps) => {
return (
prevProps.comment.id === nextProps.comment.id
&& prevProps.comment.position_x === nextProps.comment.position_x
&& prevProps.comment.position_y === nextProps.comment.position_y
&& prevProps.onClick === nextProps.onClick
&& prevProps.isActive === nextProps.isActive
&& prevProps.onPositionUpdate === nextProps.onPositionUpdate
)
})
CommentIcon.displayName = 'CommentIcon'

View File

@ -0,0 +1,87 @@
import type { FC } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Avatar from '@/app/components/base/avatar'
import { useAppContext } from '@/context/app-context'
import { MentionInput } from './mention-input'
import cn from '@/utils/classnames'
type CommentInputProps = {
position: { x: number; y: number }
onSubmit: (content: string, mentionedUserIds: string[]) => void
onCancel: () => void
}
export const CommentInput: FC<CommentInputProps> = memo(({ position, onSubmit, onCancel }) => {
const [content, setContent] = useState('')
const { t } = useTranslation()
const { userProfile } = useAppContext()
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
onCancel()
}
}
document.addEventListener('keydown', handleGlobalKeyDown, true)
return () => {
document.removeEventListener('keydown', handleGlobalKeyDown, true)
}
}, [onCancel])
const handleMentionSubmit = useCallback((content: string, mentionedUserIds: string[]) => {
onSubmit(content, mentionedUserIds)
setContent('')
}, [onSubmit])
return (
<div
className="absolute z-[60] w-96"
style={{
left: position.x,
top: position.y,
}}
data-comment-input
>
<div className="flex items-center gap-3">
<div className="relative shrink-0">
<div className="relative h-8 w-8 overflow-hidden rounded-br-full rounded-tl-full rounded-tr-full bg-primary-500">
<div className="absolute inset-[2px] overflow-hidden rounded-br-full rounded-tl-full rounded-tr-full bg-white">
<div className="flex h-full w-full items-center justify-center">
<div className="h-6 w-6 overflow-hidden rounded-full">
<Avatar
avatar={userProfile.avatar_url}
name={userProfile.name}
size={24}
className="h-full w-full"
/>
</div>
</div>
</div>
</div>
</div>
<div
className={cn(
'relative z-10 flex-1 rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[4px] shadow-md',
)}
>
<div className='relative px-[9px] pt-[4px]'>
<MentionInput
value={content}
onChange={setContent}
onSubmit={handleMentionSubmit}
placeholder={t('workflow.comments.placeholder.add')}
autoFocus
className="relative"
/>
</div>
</div>
</div>
</div>
)
})
CommentInput.displayName = 'CommentInput'

View File

@ -0,0 +1,59 @@
'use client'
import type { FC } from 'react'
import { memo, useEffect, useMemo } from 'react'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import type { WorkflowCommentList } from '@/service/workflow-comment'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useStore } from '../store'
type CommentPreviewProps = {
comment: WorkflowCommentList
onClick?: () => void
}
const CommentPreview: FC<CommentPreviewProps> = ({ comment, onClick }) => {
const { formatTimeFromNow } = useFormatTimeFromNow()
const setCommentPreviewHovering = useStore(s => s.setCommentPreviewHovering)
const participants = useMemo(() => {
const list = comment.participants ?? []
const author = comment.created_by_account
if (!author)
return [...list]
const rest = list.filter(user => user.id !== author.id)
return [author, ...rest]
}, [comment.created_by_account, comment.participants])
useEffect(() => () => {
setCommentPreviewHovering(false)
}, [setCommentPreviewHovering])
return (
<div
className="w-80 cursor-pointer rounded-3xl rounded-bl-[3px] border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-4 shadow-lg backdrop-blur-[10px] transition-colors hover:bg-components-panel-on-panel-item-bg-hover"
onClick={onClick}
onMouseEnter={() => setCommentPreviewHovering(true)}
onMouseLeave={() => setCommentPreviewHovering(false)}
>
<div className="mb-3 flex items-center justify-between">
<UserAvatarList
users={participants}
maxVisible={3}
size={24}
/>
</div>
<div className="mb-2 flex items-start">
<div className="flex min-w-0 items-center gap-2">
<div className="system-sm-medium truncate text-text-primary">{comment.created_by_account.name}</div>
<div className="system-2xs-regular shrink-0 text-text-tertiary">
{formatTimeFromNow(comment.updated_at * 1000)}
</div>
</div>
</div>
<div className="system-sm-regular break-words text-text-secondary">{comment.content}</div>
</div>
)
}
export default memo(CommentPreview)

View File

@ -0,0 +1,28 @@
import type { FC } from 'react'
import { memo } from 'react'
import { useStore } from '../store'
import { ControlMode } from '../types'
import { Comment } from '@/app/components/base/icons/src/public/other'
export const CommentCursor: FC = memo(() => {
const controlMode = useStore(s => s.controlMode)
const mousePosition = useStore(s => s.mousePosition)
if (controlMode !== ControlMode.Comment)
return null
return (
<div
className="pointer-events-none absolute z-50 flex h-6 w-6 items-center justify-center"
style={{
left: mousePosition.elementX,
top: mousePosition.elementY,
transform: 'translate(-50%, -50%)',
}}
>
<Comment className="text-text-primary" />
</div>
)
})
CommentCursor.displayName = 'CommentCursor'

View File

@ -0,0 +1,5 @@
export { CommentCursor } from './cursor'
export { CommentInput } from './comment-input'
export { CommentIcon } from './comment-icon'
export { CommentThread } from './thread'
export { MentionInput } from './mention-input'

View File

@ -0,0 +1,649 @@
'use client'
import type { ReactNode } from 'react'
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
import { useParams } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { RiArrowUpLine, RiAtLine, RiLoader2Line } from '@remixicon/react'
import Textarea from 'react-textarea-autosize'
import Button from '@/app/components/base/button'
import Avatar from '@/app/components/base/avatar'
import cn from '@/utils/classnames'
import { type UserProfile, fetchMentionableUsers } from '@/service/workflow-comment'
import { useStore, useWorkflowStore } from '../store'
import { EnterKey } from '@/app/components/base/icons/src/public/common'
type MentionInputProps = {
value: string
onChange: (value: string) => void
onSubmit: (content: string, mentionedUserIds: string[]) => void
onCancel?: () => void
placeholder?: string
disabled?: boolean
loading?: boolean
className?: string
isEditing?: boolean
autoFocus?: boolean
}
const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>(({
value,
onChange,
onSubmit,
onCancel,
placeholder,
disabled = false,
loading = false,
className,
isEditing = false,
autoFocus = false,
}, forwardedRef) => {
const params = useParams()
const { t } = useTranslation()
const appId = params.appId as string
const textareaRef = useRef<HTMLTextAreaElement>(null)
const highlightContentRef = useRef<HTMLDivElement>(null)
const actionContainerRef = useRef<HTMLDivElement | null>(null)
const actionRightRef = useRef<HTMLDivElement | null>(null)
const baseTextareaHeightRef = useRef<number | null>(null)
// Expose textarea ref to parent component
useImperativeHandle(forwardedRef, () => textareaRef.current!, [])
const workflowStore = useWorkflowStore()
const mentionUsersFromStore = useStore(state => (
appId ? state.mentionableUsersCache[appId] : undefined
))
const mentionUsers = mentionUsersFromStore ?? []
const [showMentionDropdown, setShowMentionDropdown] = useState(false)
const [mentionQuery, setMentionQuery] = useState('')
const [mentionPosition, setMentionPosition] = useState(0)
const [selectedMentionIndex, setSelectedMentionIndex] = useState(0)
const [mentionedUserIds, setMentionedUserIds] = useState<string[]>([])
const resolvedPlaceholder = placeholder ?? t('workflow.comments.placeholder.add')
const BASE_PADDING = 4
const [shouldReserveButtonGap, setShouldReserveButtonGap] = useState(isEditing)
const [shouldReserveHorizontalSpace, setShouldReserveHorizontalSpace] = useState(() => !isEditing)
const [paddingRight, setPaddingRight] = useState(() => BASE_PADDING + (isEditing ? 0 : 48))
const [paddingBottom, setPaddingBottom] = useState(() => BASE_PADDING + (isEditing ? 32 : 0))
const mentionNameList = useMemo(() => {
const names = mentionUsers
.map(user => user.name?.trim())
.filter((name): name is string => Boolean(name))
const uniqueNames = Array.from(new Set(names))
uniqueNames.sort((a, b) => b.length - a.length)
return uniqueNames
}, [mentionUsers])
const highlightedValue = useMemo<ReactNode>(() => {
if (!value)
return ''
if (mentionNameList.length === 0)
return value
const segments: ReactNode[] = []
let cursor = 0
let hasMention = false
while (cursor < value.length) {
let nextMatchStart = -1
let matchedName = ''
for (const name of mentionNameList) {
const searchStart = value.indexOf(`@${name}`, cursor)
if (searchStart === -1)
continue
const previousChar = searchStart > 0 ? value[searchStart - 1] : ''
if (searchStart > 0 && !/\s/.test(previousChar))
continue
if (
nextMatchStart === -1
|| searchStart < nextMatchStart
|| (searchStart === nextMatchStart && name.length > matchedName.length)
) {
nextMatchStart = searchStart
matchedName = name
}
}
if (nextMatchStart === -1)
break
if (nextMatchStart > cursor)
segments.push(<span key={`text-${cursor}`}>{value.slice(cursor, nextMatchStart)}</span>)
const mentionEnd = nextMatchStart + matchedName.length + 1
segments.push(
<span key={`mention-${nextMatchStart}`} className='text-primary-600'>
{value.slice(nextMatchStart, mentionEnd)}
</span>,
)
hasMention = true
cursor = mentionEnd
}
if (!hasMention)
return value
if (cursor < value.length)
segments.push(<span key={`text-${cursor}`}>{value.slice(cursor)}</span>)
return segments
}, [value, mentionNameList])
const loadMentionableUsers = useCallback(async () => {
if (!appId)
return
const state = workflowStore.getState()
if (state.mentionableUsersCache[appId] !== undefined)
return
if (state.mentionableUsersLoading[appId])
return
state.setMentionableUsersLoading(appId, true)
try {
const users = await fetchMentionableUsers(appId)
workflowStore.getState().setMentionableUsersCache(appId, users)
}
catch (error) {
console.error('Failed to load mentionable users:', error)
}
finally {
workflowStore.getState().setMentionableUsersLoading(appId, false)
}
}, [appId, workflowStore])
useEffect(() => {
loadMentionableUsers()
}, [loadMentionableUsers])
const syncHighlightScroll = useCallback(() => {
const textarea = textareaRef.current
const highlightContent = highlightContentRef.current
if (!textarea || !highlightContent)
return
const { scrollTop, scrollLeft } = textarea
highlightContent.style.transform = `translate(${-scrollLeft}px, ${-scrollTop}px)`
}, [])
const evaluateContentLayout = useCallback(() => {
const textarea = textareaRef.current
if (!textarea)
return
const extraBottom = Math.max(0, paddingBottom - BASE_PADDING)
const effectiveClientHeight = textarea.clientHeight - extraBottom
if (baseTextareaHeightRef.current === null)
baseTextareaHeightRef.current = effectiveClientHeight
const baseHeight = baseTextareaHeightRef.current ?? effectiveClientHeight
const hasMultiline = effectiveClientHeight > baseHeight + 1
const shouldReserveVertical = isEditing ? true : hasMultiline
setShouldReserveButtonGap(shouldReserveVertical)
setShouldReserveHorizontalSpace(!hasMultiline)
}, [isEditing, paddingBottom])
const updateLayoutPadding = useCallback(() => {
const actionEl = actionContainerRef.current
const rect = actionEl?.getBoundingClientRect()
const rightRect = actionRightRef.current?.getBoundingClientRect()
let actionWidth = 0
if (rightRect)
actionWidth = Math.ceil(rightRect.width)
else if (rect)
actionWidth = Math.ceil(rect.width)
const actionHeight = rect ? Math.ceil(rect.height) : 0
const fallbackWidth = Math.max(0, paddingRight - BASE_PADDING)
const fallbackHeight = Math.max(0, paddingBottom - BASE_PADDING)
const effectiveWidth = actionWidth > 0 ? actionWidth : fallbackWidth
const effectiveHeight = actionHeight > 0 ? actionHeight : fallbackHeight
const nextRight = BASE_PADDING + (shouldReserveHorizontalSpace ? effectiveWidth : 0)
const nextBottom = BASE_PADDING + (shouldReserveButtonGap ? effectiveHeight : 0)
setPaddingRight(prev => (prev === nextRight ? prev : nextRight))
setPaddingBottom(prev => (prev === nextBottom ? prev : nextBottom))
}, [shouldReserveButtonGap, shouldReserveHorizontalSpace, paddingRight, paddingBottom])
const setActionContainerRef = useCallback((node: HTMLDivElement | null) => {
actionContainerRef.current = node
if (!isEditing)
actionRightRef.current = node
else if (!node)
actionRightRef.current = null
if (node && typeof window !== 'undefined')
window.requestAnimationFrame(() => updateLayoutPadding())
}, [isEditing, updateLayoutPadding])
const setActionRightRef = useCallback((node: HTMLDivElement | null) => {
actionRightRef.current = node
if (node && typeof window !== 'undefined')
window.requestAnimationFrame(() => updateLayoutPadding())
}, [updateLayoutPadding])
useLayoutEffect(() => {
syncHighlightScroll()
}, [value, syncHighlightScroll])
useLayoutEffect(() => {
evaluateContentLayout()
}, [value, evaluateContentLayout])
useLayoutEffect(() => {
updateLayoutPadding()
}, [updateLayoutPadding, isEditing, shouldReserveButtonGap])
useEffect(() => {
const handleResize = () => {
evaluateContentLayout()
updateLayoutPadding()
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [evaluateContentLayout, updateLayoutPadding])
useEffect(() => {
baseTextareaHeightRef.current = null
evaluateContentLayout()
setShouldReserveHorizontalSpace(!isEditing)
}, [isEditing, evaluateContentLayout])
const filteredMentionUsers = useMemo(() => {
if (!mentionQuery) return mentionUsers
return mentionUsers.filter(user =>
user.name.toLowerCase().includes(mentionQuery.toLowerCase())
|| user.email.toLowerCase().includes(mentionQuery.toLowerCase()),
)
}, [mentionUsers, mentionQuery])
const shouldDisableMentionButton = useMemo(() => {
if (showMentionDropdown)
return true
const textarea = textareaRef.current
if (!textarea)
return false
const cursorPosition = textarea.selectionStart || 0
const textBeforeCursor = value.slice(0, cursorPosition)
return /@\w*$/.test(textBeforeCursor)
}, [showMentionDropdown, value])
const dropdownPosition = useMemo(() => {
if (!showMentionDropdown || !textareaRef.current)
return { x: 0, y: 0, placement: 'bottom' as const }
const textareaRect = textareaRef.current.getBoundingClientRect()
const dropdownHeight = 160 // max-h-40 = 10rem = 160px
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - textareaRect.bottom
const spaceAbove = textareaRect.top
const shouldPlaceAbove = spaceBelow < dropdownHeight && spaceAbove > spaceBelow
return {
x: textareaRect.left,
y: shouldPlaceAbove ? textareaRect.top - 4 : textareaRect.bottom + 4,
placement: shouldPlaceAbove ? 'top' as const : 'bottom' as const,
}
}, [showMentionDropdown])
const handleContentChange = useCallback((newValue: string) => {
onChange(newValue)
setTimeout(() => {
const cursorPosition = textareaRef.current?.selectionStart || 0
const textBeforeCursor = newValue.slice(0, cursorPosition)
const mentionMatch = textBeforeCursor.match(/@(\w*)$/)
if (mentionMatch) {
setMentionQuery(mentionMatch[1])
setMentionPosition(cursorPosition - mentionMatch[0].length)
setShowMentionDropdown(true)
setSelectedMentionIndex(0)
}
else {
setShowMentionDropdown(false)
}
if (typeof window !== 'undefined') {
window.requestAnimationFrame(() => {
evaluateContentLayout()
syncHighlightScroll()
})
}
}, 0)
}, [onChange, evaluateContentLayout, syncHighlightScroll])
const handleMentionButtonClick = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
const textarea = textareaRef.current
if (!textarea)
return
const cursorPosition = textarea.selectionStart || 0
const textBeforeCursor = value.slice(0, cursorPosition)
if (showMentionDropdown)
return
if (/@\w*$/.test(textBeforeCursor))
return
const newContent = `${value.slice(0, cursorPosition)}@${value.slice(cursorPosition)}`
onChange(newContent)
setTimeout(() => {
const newCursorPos = cursorPosition + 1
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
setMentionQuery('')
setMentionPosition(cursorPosition)
setShowMentionDropdown(true)
setSelectedMentionIndex(0)
if (typeof window !== 'undefined') {
window.requestAnimationFrame(() => {
evaluateContentLayout()
syncHighlightScroll()
})
}
}, 0)
}, [value, onChange, evaluateContentLayout, syncHighlightScroll, showMentionDropdown])
const insertMention = useCallback((user: UserProfile) => {
const textarea = textareaRef.current
if (!textarea) return
const beforeMention = value.slice(0, mentionPosition)
const afterMention = value.slice(textarea.selectionStart || 0)
const needsSpaceBefore = mentionPosition > 0 && !/\s/.test(value[mentionPosition - 1])
const prefix = needsSpaceBefore ? ' ' : ''
const newContent = `${beforeMention}${prefix}@${user.name} ${afterMention}`
onChange(newContent)
setShowMentionDropdown(false)
const newMentionedUserIds = [...mentionedUserIds, user.id]
setMentionedUserIds(newMentionedUserIds)
setTimeout(() => {
const extraSpace = needsSpaceBefore ? 1 : 0
const newCursorPos = mentionPosition + extraSpace + user.name.length + 2 // (space) + @ + name + space
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
if (typeof window !== 'undefined') {
window.requestAnimationFrame(() => {
evaluateContentLayout()
syncHighlightScroll()
})
}
}, 0)
}, [value, mentionPosition, onChange, mentionedUserIds, evaluateContentLayout, syncHighlightScroll])
const handleSubmit = useCallback(async (e?: React.MouseEvent) => {
if (e) {
e.preventDefault()
e.stopPropagation()
}
if (value.trim()) {
try {
await onSubmit(value.trim(), mentionedUserIds)
setMentionedUserIds([])
setShowMentionDropdown(false)
}
catch (error) {
console.error('Failed to submit', error)
}
}
}, [value, mentionedUserIds, onSubmit])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// Ignore key events during IME composition (e.g., Chinese, Japanese input)
if (e.nativeEvent.isComposing)
return
if (showMentionDropdown) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedMentionIndex(prev =>
prev < filteredMentionUsers.length - 1 ? prev + 1 : 0,
)
}
else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedMentionIndex(prev =>
prev > 0 ? prev - 1 : filteredMentionUsers.length - 1,
)
}
else if (e.key === 'Enter') {
e.preventDefault()
if (filteredMentionUsers[selectedMentionIndex])
insertMention(filteredMentionUsers[selectedMentionIndex])
return
}
else if (e.key === 'Escape') {
e.preventDefault()
setShowMentionDropdown(false)
return
}
}
if (e.key === 'Enter' && !e.shiftKey && !showMentionDropdown) {
e.preventDefault()
handleSubmit()
}
}, [showMentionDropdown, filteredMentionUsers, selectedMentionIndex, insertMention, handleSubmit])
const resetMentionState = useCallback(() => {
setMentionedUserIds([])
setShowMentionDropdown(false)
setMentionQuery('')
setMentionPosition(0)
setSelectedMentionIndex(0)
}, [])
useEffect(() => {
if (!value)
resetMentionState()
}, [value, resetMentionState])
useEffect(() => {
if (autoFocus && textareaRef.current) {
const textarea = textareaRef.current
setTimeout(() => {
textarea.focus()
const length = textarea.value.length
textarea.setSelectionRange(length, length)
}, 0)
}
}, [autoFocus])
return (
<>
<div className={cn('relative flex items-center', className)}>
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words p-1 leading-6',
'body-lg-regular text-text-primary',
)}
style={{ paddingRight, paddingBottom }}
>
<div
ref={highlightContentRef}
className="min-h-full"
style={{ willChange: 'transform' }}
>
{highlightedValue}
{''}
</div>
</div>
<Textarea
ref={textareaRef}
className={cn(
'body-lg-regular relative z-10 w-full resize-none bg-transparent p-1 leading-6 text-transparent caret-primary-500 outline-none',
'placeholder:text-text-tertiary',
)}
style={{ paddingRight, paddingBottom }}
placeholder={resolvedPlaceholder}
autoFocus={autoFocus}
minRows={isEditing ? 4 : 1}
maxRows={4}
value={value}
disabled={disabled || loading}
onChange={e => handleContentChange(e.target.value)}
onKeyDown={handleKeyDown}
onScroll={syncHighlightScroll}
/>
{!isEditing && (
<div
ref={setActionContainerRef}
className="absolute bottom-0 right-1 z-20 flex items-end gap-1"
>
<div
className={cn(
'z-20 flex h-8 w-8 items-center justify-center rounded-lg transition-opacity',
shouldDisableMentionButton
? 'cursor-not-allowed opacity-40'
: 'cursor-pointer hover:bg-state-base-hover',
)}
onClick={shouldDisableMentionButton ? undefined : handleMentionButtonClick}
>
<RiAtLine className="h-4 w-4 text-text-tertiary" />
</div>
<Button
className='z-20 ml-2 w-8 px-0'
variant='primary'
disabled={!value.trim() || disabled || loading}
onClick={handleSubmit}
>
{loading
? <RiLoader2Line className='h-4 w-4 animate-spin text-components-button-primary-text' />
: <RiArrowUpLine className='h-4 w-4 text-components-button-primary-text' />}
</Button>
</div>
)}
{isEditing && (
<div
ref={setActionContainerRef}
className="absolute bottom-0 left-1 right-1 z-20 flex items-end justify-between"
>
<div
className={cn(
'z-20 flex h-8 w-8 items-center justify-center rounded-lg transition-opacity',
shouldDisableMentionButton
? 'cursor-not-allowed opacity-40'
: 'cursor-pointer hover:bg-state-base-hover',
)}
onClick={shouldDisableMentionButton ? undefined : handleMentionButtonClick}
>
<RiAtLine className="h-4 w-4 text-text-tertiary" />
</div>
<div
ref={setActionRightRef}
className='flex items-center gap-2'
>
<Button variant='secondary' size='small' onClick={onCancel} disabled={loading}>
{t('common.operation.cancel')}
</Button>
<Button
variant='primary'
size='small'
disabled={loading || !value.trim()}
onClick={() => handleSubmit()}
className='gap-1'
>
{loading && <RiLoader2Line className='mr-1 h-3.5 w-3.5 animate-spin' />}
<span>{t('common.operation.save')}</span>
{!loading && (
<EnterKey className='h-4 w-4' />
)}
</Button>
</div>
</div>
)}
</div>
{showMentionDropdown && filteredMentionUsers.length > 0 && typeof document !== 'undefined' && createPortal(
<div
className="bg-components-panel-bg/95 fixed z-[9999] max-h-[248px] w-[280px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border shadow-lg backdrop-blur-[10px]"
style={{
left: dropdownPosition.x,
[dropdownPosition.placement === 'top' ? 'bottom' : 'top']: dropdownPosition.placement === 'top'
? window.innerHeight - dropdownPosition.y
: dropdownPosition.y,
}}
data-mention-dropdown
>
{filteredMentionUsers.map((user, index) => (
<div
key={user.id}
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md py-1 pl-2 pr-3 hover:bg-state-base-hover',
index === selectedMentionIndex && 'bg-state-base-hover',
)}
onClick={() => insertMention(user)}
>
<Avatar
avatar={user.avatar_url || null}
name={user.name}
size={24}
className="shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-text-primary">
{user.name}
</div>
<div className="truncate text-xs text-text-tertiary">
{user.email}
</div>
</div>
</div>
))}
</div>,
document.body,
)}
</>
)
})
MentionInputInner.displayName = 'MentionInputInner'
export const MentionInput = memo(MentionInputInner)

View File

@ -0,0 +1,618 @@
'use client'
import type { FC, ReactNode } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import { useReactFlow, useViewport } from 'reactflow'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine, RiArrowUpSLine, RiCheckboxCircleFill, RiCheckboxCircleLine, RiCloseLine, RiDeleteBinLine, RiMoreFill } from '@remixicon/react'
import Avatar from '@/app/components/base/avatar'
import Divider from '@/app/components/base/divider'
import Tooltip from '@/app/components/base/tooltip'
import InlineDeleteConfirm from '@/app/components/base/inline-delete-confirm'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import cn from '@/utils/classnames'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import type { WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/service/workflow-comment'
import { useAppContext } from '@/context/app-context'
import { MentionInput } from './mention-input'
import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color'
import { useStore } from '../store'
type CommentThreadProps = {
comment: WorkflowCommentDetail
loading?: boolean
replySubmitting?: boolean
replyUpdating?: boolean
onClose: () => void
onDelete?: () => void
onResolve?: () => void
onPrev?: () => void
onNext?: () => void
canGoPrev?: boolean
canGoNext?: boolean
onReply?: (content: string, mentionedUserIds?: string[]) => Promise<void> | void
onReplyEdit?: (replyId: string, content: string, mentionedUserIds?: string[]) => Promise<void> | void
onReplyDelete?: (replyId: string) => void
onReplyDeleteDirect?: (replyId: string) => Promise<void> | void
}
const ThreadMessage: FC<{
authorId: string
authorName: string
avatarUrl?: string | null
createdAt: number
content: string
mentionableNames: string[]
className?: string
}> = ({ authorId, authorName, avatarUrl, createdAt, content, mentionableNames, className }) => {
const { formatTimeFromNow } = useFormatTimeFromNow()
const { userProfile } = useAppContext()
const currentUserId = userProfile?.id
const isCurrentUser = authorId === currentUserId
const userColor = isCurrentUser ? undefined : getUserColor(authorId)
const highlightedContent = useMemo<ReactNode>(() => {
if (!content)
return ''
// Extract valid user names from mentionableNames, sorted by length (longest first)
const normalizedNames = Array.from(new Set(mentionableNames
.map(name => name.trim())
.filter(Boolean)))
normalizedNames.sort((a, b) => b.length - a.length)
if (normalizedNames.length === 0)
return content
const segments: ReactNode[] = []
let hasMention = false
let cursor = 0
while (cursor < content.length) {
let nextMatchStart = -1
let matchedName = ''
for (const name of normalizedNames) {
const searchStart = content.indexOf(`@${name}`, cursor)
if (searchStart === -1)
continue
const previousChar = searchStart > 0 ? content[searchStart - 1] : ''
if (searchStart > 0 && !/\s/.test(previousChar))
continue
if (
nextMatchStart === -1
|| searchStart < nextMatchStart
|| (searchStart === nextMatchStart && name.length > matchedName.length)
) {
nextMatchStart = searchStart
matchedName = name
}
}
if (nextMatchStart === -1)
break
if (nextMatchStart > cursor)
segments.push(<span key={`text-${cursor}`}>{content.slice(cursor, nextMatchStart)}</span>)
const mentionEnd = nextMatchStart + matchedName.length + 1
segments.push(
<span key={`mention-${nextMatchStart}`} className='text-primary-600'>
{content.slice(nextMatchStart, mentionEnd)}
</span>,
)
hasMention = true
cursor = mentionEnd
}
if (!hasMention)
return content
if (cursor < content.length)
segments.push(<span key={`text-${cursor}`}>{content.slice(cursor)}</span>)
return segments
}, [content, mentionableNames])
return (
<div className={cn('flex gap-3 pt-1', className)}>
<div className='shrink-0'>
<Avatar
name={authorName}
avatar={avatarUrl || null}
size={24}
className={cn('h-8 w-8 rounded-full')}
backgroundColor={userColor}
/>
</div>
<div className='min-w-0 flex-1 pb-4 text-text-primary last:pb-0'>
<div className='flex flex-wrap items-center gap-x-2 gap-y-1'>
<span className='system-sm-medium text-text-primary'>{authorName}</span>
<span className='system-2xs-regular text-text-tertiary'>{formatTimeFromNow(createdAt * 1000)}</span>
</div>
<div className='system-sm-regular mt-1 whitespace-pre-wrap break-words text-text-secondary'>
{highlightedContent}
</div>
</div>
</div>
)
}
export const CommentThread: FC<CommentThreadProps> = memo(({
comment,
loading = false,
replySubmitting = false,
replyUpdating = false,
onClose,
onDelete,
onResolve,
onPrev,
onNext,
canGoPrev,
canGoNext,
onReply,
onReplyEdit,
onReplyDelete,
onReplyDeleteDirect,
}) => {
const params = useParams()
const appId = params.appId as string
const { flowToScreenPosition } = useReactFlow()
const viewport = useViewport()
const { userProfile } = useAppContext()
const { t } = useTranslation()
const [replyContent, setReplyContent] = useState('')
const [activeReplyMenuId, setActiveReplyMenuId] = useState<string | null>(null)
const [editingReply, setEditingReply] = useState<{ id: string; content: string }>({ id: '', content: '' })
const [deletingReplyId, setDeletingReplyId] = useState<string | null>(null)
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false)
// Focus management refs
const replyInputRef = useRef<HTMLTextAreaElement>(null)
const threadRef = useRef<HTMLDivElement>(null)
// Get mentionable users from store
const mentionUsersFromStore = useStore(state => (
appId ? state.mentionableUsersCache[appId] : undefined
))
const mentionUsers = mentionUsersFromStore ?? []
// Extract all mentionable names for highlighting
const mentionableNames = useMemo(() => {
const names = mentionUsers
.map(user => user.name?.trim())
.filter((name): name is string => Boolean(name))
return Array.from(new Set(names))
}, [mentionUsers])
useEffect(() => {
setReplyContent('')
}, [comment.id])
// P0: Auto-focus reply input when thread opens or comment changes
useEffect(() => {
const timer = setTimeout(() => {
if (replyInputRef.current && !editingReply.id && onReply)
replyInputRef.current.focus()
}, 100)
return () => clearTimeout(timer)
}, [comment.id, editingReply.id, onReply])
// P2: Handle Esc key to close thread
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't intercept if actively editing a reply
if (editingReply.id) return
// Don't intercept if mention dropdown is open (let MentionInput handle it)
if (document.querySelector('[data-mention-dropdown]')) return
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
onClose()
}
}
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [onClose, editingReply.id])
const handleReplySubmit = useCallback(async (content: string, mentionedUserIds: string[]) => {
if (!onReply || replySubmitting) return
setReplyContent('')
try {
await onReply(content, mentionedUserIds)
// P0: Restore focus to reply input after successful submission
setTimeout(() => {
replyInputRef.current?.focus()
}, 0)
}
catch (error) {
console.error('Failed to send reply', error)
setReplyContent(content)
}
}, [onReply, replySubmitting])
const screenPosition = useMemo(() => {
return flowToScreenPosition({
x: comment.position_x,
y: comment.position_y,
})
}, [comment.position_x, comment.position_y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition])
const workflowContainerRect = typeof document !== 'undefined'
? document.getElementById('workflow-container')?.getBoundingClientRect()
: null
const containerLeft = workflowContainerRect?.left ?? 0
const containerTop = workflowContainerRect?.top ?? 0
const canvasPosition = useMemo(() => ({
x: screenPosition.x - containerLeft,
y: screenPosition.y - containerTop,
}), [screenPosition.x, screenPosition.y, containerLeft, containerTop])
const handleStartEdit = useCallback((reply: WorkflowCommentDetailReply) => {
setEditingReply({ id: reply.id, content: reply.content })
setActiveReplyMenuId(null)
}, [])
const handleCancelEdit = useCallback(() => {
setEditingReply({ id: '', content: '' })
// P1: Restore focus to reply input after canceling edit
setTimeout(() => {
replyInputRef.current?.focus()
}, 0)
}, [])
const handleEditSubmit = useCallback(async (content: string, mentionedUserIds: string[]) => {
if (!onReplyEdit || !editingReply) return
const trimmed = content.trim()
if (!trimmed) return
setIsSubmittingEdit(true)
try {
await onReplyEdit(editingReply.id, trimmed, mentionedUserIds)
setEditingReply({ id: '', content: '' })
// P1: Restore focus to reply input after saving edit
setTimeout(() => {
replyInputRef.current?.focus()
}, 0)
}
catch (error) {
console.error('Failed to edit reply', error)
}
finally {
setIsSubmittingEdit(false)
}
}, [editingReply, onReplyEdit])
const replies = comment.replies || []
const messageListRef = useRef<HTMLDivElement>(null)
const previousReplyCountRef = useRef<number | undefined>(undefined)
const previousCommentIdRef = useRef<string | undefined>(undefined)
// Close dropdown when scrolling
useEffect(() => {
const container = messageListRef.current
if (!container || !activeReplyMenuId)
return
const handleScroll = () => {
setActiveReplyMenuId(null)
}
container.addEventListener('scroll', handleScroll)
return () => container.removeEventListener('scroll', handleScroll)
}, [activeReplyMenuId])
// Auto-scroll to bottom on new messages
useEffect(() => {
const container = messageListRef.current
if (!container)
return
const isFirstRender = previousCommentIdRef.current === undefined
const isNewComment = comment.id !== previousCommentIdRef.current
const hasNewReply = previousReplyCountRef.current !== undefined
&& replies.length > previousReplyCountRef.current
// Scroll on first render, new comment, or new reply
if (isFirstRender || isNewComment || hasNewReply) {
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth',
})
}
previousCommentIdRef.current = comment.id
previousReplyCountRef.current = replies.length
}, [comment.id, replies.length])
return (
<div
className='absolute z-50 w-[360px] max-w-[360px]'
style={{
left: canvasPosition.x + 40,
top: canvasPosition.y,
transform: 'translateY(-20%)',
}}
>
<div
ref={threadRef}
className='relative flex h-[360px] flex-col overflow-hidden rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl'
role='dialog'
aria-modal='true'
aria-labelledby='comment-thread-title'
>
<div className='flex items-center justify-between rounded-t-2xl border-b border-components-panel-border bg-components-panel-bg-blur px-4 py-3'>
<div
id='comment-thread-title'
className='font-semibold uppercase text-text-primary'
>
{t('workflow.comments.panelTitle')}
</div>
<div className='flex items-center gap-1'>
<Tooltip
popupContent={t('workflow.comments.aria.deleteComment')}
position='top'
popupClassName='!px-2 !py-1.5'
>
<button
type='button'
disabled={loading}
className={cn('flex h-6 w-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')}
onClick={onDelete}
aria-label={t('workflow.comments.aria.deleteComment')}
>
<RiDeleteBinLine className='h-4 w-4' />
</button>
</Tooltip>
<Tooltip
popupContent={t('workflow.comments.aria.resolveComment')}
position='top'
popupClassName='!px-2 !py-1.5'
>
<button
type='button'
disabled={comment.resolved || loading}
className={cn('flex h-6 w-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')}
onClick={onResolve}
aria-label={t('workflow.comments.aria.resolveComment')}
>
{comment.resolved ? <RiCheckboxCircleFill className='h-4 w-4' /> : <RiCheckboxCircleLine className='h-4 w-4' />}
</button>
</Tooltip>
<Divider type='vertical' className='h-3.5' />
<Tooltip
popupContent={t('workflow.comments.aria.previousComment')}
position='top'
popupClassName='!px-2 !py-1.5'
>
<button
type='button'
disabled={!canGoPrev || loading}
className={cn('flex h-6 w-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')}
onClick={onPrev}
aria-label={t('workflow.comments.aria.previousComment')}
>
<RiArrowUpSLine className='h-4 w-4' />
</button>
</Tooltip>
<Tooltip
popupContent={t('workflow.comments.aria.nextComment')}
position='top'
popupClassName='!px-2 !py-1.5'
>
<button
type='button'
disabled={!canGoNext || loading}
className={cn('flex h-6 w-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')}
onClick={onNext}
aria-label={t('workflow.comments.aria.nextComment')}
>
<RiArrowDownSLine className='h-4 w-4' />
</button>
</Tooltip>
<button
type='button'
className='flex h-6 w-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary'
onClick={onClose}
aria-label={t('workflow.comments.aria.closeComment')}
>
<RiCloseLine className='h-4 w-4' />
</button>
</div>
</div>
<div
ref={messageListRef}
className='relative mt-2 flex-1 overflow-y-auto px-4 pb-4'
>
<div className='-mx-4 rounded-lg px-4 py-2 transition-colors hover:bg-components-panel-on-panel-item-bg-hover'>
<ThreadMessage
authorId={comment.created_by_account?.id || ''}
authorName={comment.created_by_account?.name || t('workflow.comments.fallback.user')}
avatarUrl={comment.created_by_account?.avatar_url || null}
createdAt={comment.created_at}
content={comment.content}
mentionableNames={mentionableNames}
/>
</div>
{replies.length > 0 && (
<div className='mt-2 space-y-3 pt-3'>
{replies.map((reply) => {
const isReplyEditing = editingReply?.id === reply.id
const isOwnReply = reply.created_by_account?.id === userProfile?.id
return (
<div
key={reply.id}
className='group relative -mx-4 rounded-lg px-4 py-2 transition-colors hover:bg-components-panel-on-panel-item-bg-hover'
>
{isOwnReply && !isReplyEditing && (
<PortalToFollowElem
placement='bottom-end'
open={activeReplyMenuId === reply.id}
onOpenChange={(open) => {
if (!open) {
setDeletingReplyId(null)
setActiveReplyMenuId(null)
}
}}
>
<div
className={cn(
'absolute right-1 top-1 gap-1',
activeReplyMenuId === reply.id ? 'flex' : 'hidden group-hover:flex',
)}
data-reply-menu
>
<PortalToFollowElemTrigger asChild>
<button
type='button'
className='flex h-6 w-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary'
onClick={(e) => {
e.stopPropagation()
setDeletingReplyId(null)
setActiveReplyMenuId(prev => prev === reply.id ? null : reply.id)
}}
aria-label={t('workflow.comments.aria.replyActions')}
>
<RiMoreFill className='h-4 w-4' />
</button>
</PortalToFollowElemTrigger>
</div>
<PortalToFollowElemContent
className='z-[100] w-36 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[10px]'
data-reply-menu
>
{/* Menu buttons - hidden when showing delete confirm */}
<div className={cn(deletingReplyId === reply.id ? 'hidden' : 'block')}>
<button
className='flex w-full items-center justify-start rounded-t-xl px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover'
onClick={(e) => {
e.stopPropagation()
handleStartEdit(reply)
}}
>
{t('workflow.comments.actions.editReply')}
</button>
<button
className='text-negative flex w-full items-center justify-start rounded-b-xl px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover'
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
if (onReplyDeleteDirect) {
setDeletingReplyId(reply.id)
}
else {
setActiveReplyMenuId(null)
onReplyDelete?.(reply.id)
}
}}
>
{t('workflow.comments.actions.deleteReply')}
</button>
</div>
{/* Delete confirmation - shown when deletingReplyId matches */}
<div className={cn(deletingReplyId === reply.id ? 'block' : 'hidden')}>
<InlineDeleteConfirm
title={t('workflow.comments.actions.deleteReply')}
onConfirm={() => {
setDeletingReplyId(null)
setActiveReplyMenuId(null)
onReplyDeleteDirect?.(reply.id)
}}
onCancel={() => {
setDeletingReplyId(null)
}}
className='m-0 w-full border-0 shadow-none'
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)}
{isReplyEditing ? (
<div className='flex gap-3 pt-1'>
<div className='shrink-0'>
<Avatar
name={reply.created_by_account?.name || t('workflow.comments.fallback.user')}
avatar={reply.created_by_account?.avatar_url || null}
size={24}
className='h-8 w-8 rounded-full'
/>
</div>
<div className='min-w-0 flex-1'>
<div className='rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-1 shadow-md backdrop-blur-[10px]'>
<MentionInput
value={editingReply?.content ?? ''}
onChange={newContent => setEditingReply(prev => prev ? { ...prev, content: newContent } : prev)}
onSubmit={handleEditSubmit}
onCancel={handleCancelEdit}
placeholder={t('workflow.comments.placeholder.editReply')}
disabled={loading}
loading={replyUpdating || isSubmittingEdit}
isEditing={true}
className="system-sm-regular"
autoFocus
/>
</div>
</div>
</div>
) : (
<ThreadMessage
authorId={reply.created_by_account?.id || ''}
authorName={reply.created_by_account?.name || t('workflow.comments.fallback.user')}
avatarUrl={reply.created_by_account?.avatar_url || null}
createdAt={reply.created_at}
content={reply.content}
mentionableNames={mentionableNames}
/>
)}
</div>
)
})}
</div>
)}
</div>
{loading && (
<div className='bg-components-panel-bg/70 absolute inset-0 z-30 flex items-center justify-center text-sm text-text-tertiary'>
{t('workflow.comments.loading')}
</div>
)}
{onReply && (
<div className='border-t border-components-panel-border px-4 py-3'>
<div className='flex items-center gap-3'>
<Avatar
avatar={userProfile?.avatar_url || null}
name={userProfile?.name || t('common.you')}
size={24}
className='h-8 w-8'
/>
<div className='flex-1 rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-[2px] shadow-sm'>
<MentionInput
ref={replyInputRef}
value={replyContent}
onChange={setReplyContent}
onSubmit={handleReplySubmit}
placeholder={t('workflow.comments.placeholder.reply')}
disabled={loading}
loading={replySubmitting}
/>
</div>
</div>
</div>
)}
</div>
</div>
)
})
CommentThread.displayName = 'CommentThread'

View File

@ -7,21 +7,23 @@ import { useStore } from './store'
import {
useIsChatMode,
useNodesReadOnly,
useNodesSyncDraft,
} from './hooks'
import { type CommonNodeType, type InputVar, InputVarType, type Node } from './types'
import useConfig from './nodes/start/use-config'
import type { StartNodeType } from './nodes/start/types'
import type { PromptVariable } from '@/models/debug'
import NewFeaturePanel from '@/app/components/base/features/new-feature-panel'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import { type WorkflowDraftFeaturesPayload, updateFeatures } from '@/service/workflow'
const Features = () => {
const setShowFeaturesPanel = useStore(s => s.setShowFeaturesPanel)
const appId = useStore(s => s.appId)
const isChatMode = useIsChatMode()
const { nodesReadOnly } = useNodesReadOnly()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const featuresStore = useFeaturesStore()
const nodes = useNodes<CommonNodeType>()
const startNode = nodes.find(node => node.data.type === 'start')
const { id, data } = startNode as Node<StartNodeType>
const { handleAddVariable } = useConfig(id, data)
@ -39,10 +41,45 @@ const Features = () => {
handleAddVariable(startNodeVariable)
}
const handleFeaturesChange = useCallback(() => {
handleSyncWorkflowDraft()
const handleFeaturesChange = useCallback(async () => {
if (!appId || !featuresStore) return
try {
const currentFeatures = featuresStore.getState().features
// Transform features to match the expected server format (same as doSyncWorkflowDraft)
const transformedFeatures: WorkflowDraftFeaturesPayload = {
opening_statement: currentFeatures.opening?.enabled ? (currentFeatures.opening?.opening_statement || '') : '',
suggested_questions: currentFeatures.opening?.enabled ? (currentFeatures.opening?.suggested_questions || []) : [],
suggested_questions_after_answer: currentFeatures.suggested,
text_to_speech: currentFeatures.text2speech,
speech_to_text: currentFeatures.speech2text,
retriever_resource: currentFeatures.citation,
sensitive_word_avoidance: currentFeatures.moderation,
file_upload: currentFeatures.file,
}
console.log('Sending features to server:', transformedFeatures)
await updateFeatures({
appId,
features: transformedFeatures,
})
// Emit update event to other connected clients
const socket = webSocketClient.getSocket(appId)
if (socket) {
socket.emit('collaboration_event', {
type: 'vars_and_features_update',
})
}
}
catch (error) {
console.error('Failed to update features:', error)
}
setShowFeaturesPanel(true)
}, [handleSyncWorkflowDraft, setShowFeaturesPanel])
}, [appId, featuresStore, setShowFeaturesPanel])
return (
<NewFeaturePanel

View File

@ -18,6 +18,7 @@ import RunAndHistory from './run-and-history'
import EditingTitle from './editing-title'
import EnvButton from './env-button'
import VersionHistoryButton from './version-history-button'
import OnlineUsers from './online-users'
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
import ScrollToSelectedNodeButton from './scroll-to-selected-node-button'
import GlobalVariableButton from './global-variable-button'
@ -72,8 +73,11 @@ const HeaderInNormal = ({
<ScrollToSelectedNodeButton />
</div>
<div className='flex items-center gap-2'>
<OnlineUsers />
{components?.left}
<Divider type='vertical' className='mx-auto h-3.5' />
<EnvButton disabled={nodesReadOnly} />
<Divider type='vertical' className='mx-auto h-3.5' />
<RunAndHistory {...runAndHistoryProps} />
<div className='shrink-0 cursor-pointer rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs backdrop-blur-[10px]'>
{components?.chatVariableTrigger}

View File

@ -19,8 +19,10 @@ import RestoringTitle from './restoring-title'
import Button from '@/app/components/base/button'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import { useHooksStore } from '../hooks-store'
import { useStore as useAppStore } from '@/app/components/app/store'
import useTheme from '@/hooks/use-theme'
import cn from '@/utils/classnames'
import { collaborationManager } from '../collaboration/core/collaboration-manager'
export type HeaderInRestoringProps = {
onRestoreSettled?: () => void
@ -31,6 +33,7 @@ const HeaderInRestoring = ({
const { t } = useTranslation()
const { theme } = useTheme()
const workflowStore = useWorkflowStore()
const appDetail = useAppStore.getState().appDetail
const configsMap = useHooksStore(s => s.configsMap)
const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId)
const {
@ -60,6 +63,9 @@ const HeaderInRestoring = ({
type: 'success',
message: t('workflow.versionHistory.action.restoreSuccess'),
})
// Notify other collaboration clients about the workflow restore
if (appDetail)
collaborationManager.emitWorkflowUpdate(appDetail.id)
},
onError: () => {
Toast.notify({
@ -70,10 +76,10 @@ const HeaderInRestoring = ({
onSettled: () => {
onRestoreSettled?.()
},
})
}, true) // Enable forceUpload for restore operation
deleteAllInspectVars()
invalidAllLastRun()
}, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
}, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled, appDetail])
return (
<>

View File

@ -0,0 +1,238 @@
'use client'
import { useEffect, useState } from 'react'
import { useReactFlow } from 'reactflow'
import Avatar from '@/app/components/base/avatar'
import { useCollaboration } from '../collaboration/hooks/use-collaboration'
import { useStore } from '../store'
import cn from '@/utils/classnames'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { getUserColor } from '../collaboration/utils/user-color'
import Tooltip from '@/app/components/base/tooltip'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useAppContext } from '@/context/app-context'
import { getAvatar } from '@/service/common'
const useAvatarUrls = (users: any[]) => {
const [avatarUrls, setAvatarUrls] = useState<Record<string, string>>({})
useEffect(() => {
const fetchAvatars = async () => {
const newAvatarUrls: Record<string, string> = {}
await Promise.all(
users.map(async (user) => {
if (user.avatar) {
try {
const response = await getAvatar({ avatar: user.avatar })
newAvatarUrls[user.sid] = response.avatar_url
}
catch (error) {
console.error('Failed to fetch avatar:', error)
newAvatarUrls[user.sid] = user.avatar
}
}
}),
)
setAvatarUrls(newAvatarUrls)
}
if (users.length > 0)
fetchAvatars()
}, [users])
return avatarUrls
}
const OnlineUsers = () => {
const appId = useStore(s => s.appId)
const { onlineUsers, cursors, isEnabled: isCollaborationEnabled } = useCollaboration(appId as string)
const { userProfile } = useAppContext()
const reactFlow = useReactFlow()
const [dropdownOpen, setDropdownOpen] = useState(false)
const avatarUrls = useAvatarUrls(onlineUsers || [])
const currentUserId = userProfile?.id
const renderDisplayName = (
user: any,
baseClassName: string,
suffixClassName: string,
) => {
const baseName = user.username || 'User'
const isCurrentUser = user.user_id === currentUserId
return (
<span className={cn('inline-flex items-center gap-1', baseClassName)}>
<span>{baseName}</span>
{isCurrentUser && (
<span className={suffixClassName}>
(You)
</span>
)}
</span>
)
}
// Function to jump to user's cursor position
const jumpToUserCursor = (userId: string) => {
const cursor = cursors[userId]
if (!cursor) return
// Convert world coordinates to center the view on the cursor
reactFlow.setCenter(cursor.x, cursor.y, { zoom: 1, duration: 800 })
}
if (!isCollaborationEnabled || !onlineUsers || onlineUsers.length === 0)
return null
// Display logic:
// 1-3 users: show all avatars
// 4+ users: show 2 avatars + count + arrow
const shouldShowCount = onlineUsers.length >= 4
const maxVisible = shouldShowCount ? 2 : 3
const visibleUsers = onlineUsers.slice(0, maxVisible)
const remainingCount = onlineUsers.length - maxVisible
const getAvatarUrl = (user: any) => {
return avatarUrls[user.sid] || user.avatar
}
const hasCounter = remainingCount > 0
return (
<div
className={cn(
'flex h-8 items-center rounded-full border-[0.5px] border-components-panel-border',
'bg-components-panel-bg py-1 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px]',
hasCounter ? 'min-w-[87px] gap-px pl-1 pr-1.5' : 'gap-1 px-1.5',
)}
>
<div className="flex h-6 items-center">
<div className="flex items-center">
{visibleUsers.map((user, index) => {
const isCurrentUser = user.user_id === currentUserId
const userColor = isCurrentUser ? undefined : getUserColor(user.user_id)
return (
<Tooltip
key={`${user.sid}-${index}`}
popupContent={renderDisplayName(
user,
'system-xs-medium text-text-secondary',
'text-text-quaternary',
)}
position="bottom"
triggerMethod="hover"
needsDelay={false}
asChild
popupClassName="flex h-[28px] w-[85px] items-center justify-center gap-1 rounded-md border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-3 py-[6px] shadow-lg shadow-shadow-shadow-5 backdrop-blur-[10px]"
noDecoration
>
<div
className={cn(
'relative flex size-6 items-center justify-center',
index > 0 && '-ml-1.5',
!isCurrentUser && 'cursor-pointer transition-transform hover:scale-110',
)}
style={{ zIndex: visibleUsers.length - index }}
onClick={() => !isCurrentUser && jumpToUserCursor(user.user_id)}
>
<Avatar
name={user.username || 'User'}
avatar={getAvatarUrl(user)}
size={24}
className="ring-1 ring-components-panel-bg"
backgroundColor={userColor}
/>
</div>
</Tooltip>
)
})}
{remainingCount > 0 && (
<PortalToFollowElem
open={dropdownOpen}
onOpenChange={setDropdownOpen}
placement="bottom-start"
offset={{
mainAxis: 8,
crossAxis: -48,
}}
>
<PortalToFollowElemTrigger
onClick={() => setDropdownOpen(prev => !prev)}
asChild
>
<div className="flex items-center gap-1">
<div
className={cn(
'flex h-6 w-6 cursor-pointer select-none items-center justify-center rounded-full bg-components-icon-bg-midnight-solid text-[10px] font-semibold uppercase leading-[12px] text-white ring-1 ring-components-panel-bg',
visibleUsers.length > 0 && '-ml-1',
)}
>
+{remainingCount}
</div>
<ChevronDownIcon className="h-3 w-3 cursor-pointer text-gray-500" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent
className="z-[9999]"
>
<div
className={cn(
'mt-1.5',
'flex flex-col',
'max-h-[200px] w-[240px] overflow-y-auto',
'rounded-xl border-[0.5px] border-components-panel-border',
'bg-components-panel-bg-blur p-1',
'shadow-lg shadow-shadow-shadow-5',
'backdrop-blur-[10px]',
)}
>
{onlineUsers.map((user) => {
const isCurrentUser = user.user_id === currentUserId
const userColor = isCurrentUser ? undefined : getUserColor(user.user_id)
return (
<div
key={user.sid}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5',
!isCurrentUser && 'cursor-pointer hover:bg-components-panel-on-panel-item-bg-hover',
)}
onClick={() => {
if (!isCurrentUser) {
jumpToUserCursor(user.user_id)
setDropdownOpen(false)
}
}}
>
<div className="relative">
<Avatar
name={user.username || 'User'}
avatar={getAvatarUrl(user)}
size={24}
backgroundColor={userColor}
/>
</div>
{renderDisplayName(
user,
'system-xs-medium text-text-secondary',
'text-text-tertiary',
)}
</div>
)
})}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)}
</div>
</div>
</div>
)
}
export default OnlineUsers

View File

@ -6,27 +6,39 @@ import {
RiArrowGoForwardFill,
} from '@remixicon/react'
import TipPopup from '../operator/tip-popup'
import { useWorkflowHistoryStore } from '../workflow-history-store'
import Divider from '../../base/divider'
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import classNames from '@/utils/classnames'
export type UndoRedoProps = { handleUndo: () => void; handleRedo: () => void }
const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
const { t } = useTranslation()
const { store } = useWorkflowHistoryStore()
const [buttonsDisabled, setButtonsDisabled] = useState({ undo: true, redo: true })
useEffect(() => {
const unsubscribe = store.temporal.subscribe((state) => {
// Update button states based on Loro's UndoManager
const updateButtonStates = () => {
setButtonsDisabled({
undo: state.pastStates.length === 0,
redo: state.futureStates.length === 0,
undo: !collaborationManager.canUndo(),
redo: !collaborationManager.canRedo(),
})
}
// Initial state
updateButtonStates()
// Listen for undo/redo state changes
const unsubscribe = collaborationManager.onUndoRedoStateChange((state) => {
setButtonsDisabled({
undo: !state.canUndo,
redo: !state.canRedo,
})
})
return () => unsubscribe()
}, [store])
}, [])
const { nodesReadOnly } = useNodesReadOnly()

View File

@ -32,8 +32,9 @@ export type CommonHooksFnMap = {
callback?: {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void,
onSettled?: () => void
},
forceUpload?: boolean,
) => Promise<void>
syncWorkflowDraftWhenPageClose: () => void
handleRefreshWorkflowDraft: () => void

View File

@ -24,3 +24,4 @@ export * from './use-set-workflow-vars-with-value'
export * from './use-workflow-search'
export * from './use-auto-generate-webhook-url'
export * from './use-serial-async-callback'
export * from './use-workflow-comment'

View File

@ -157,6 +157,9 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
const checkData = getCheckData(node.data)
const validator = nodesExtraData?.[node.data.type as BlockEnum]?.checkValid
let errorMessage = validator ? validator(checkData, t, moreDataForCheckValid).errorMessage : undefined
// temp fix nodeMetaData is undefined
// const nodeMetaData = nodesExtraData?.[node.data.type]
// let { errorMessage } = nodeMetaData?.checkValid ? nodeMetaData.checkValid(checkData, t, moreDataForCheckValid) : { errorMessage: undefined }
if (!errorMessage) {
const availableVars = map[node.id].availableVars

View File

@ -0,0 +1,84 @@
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import type { Edge, Node } from '../types'
import { collaborationManager } from '../collaboration/core/collaboration-manager'
const sanitizeNodeForBroadcast = (node: Node): Node => {
if (!node.data)
return node
if (!Object.prototype.hasOwnProperty.call(node.data, 'selected'))
return node
const sanitizedData = { ...node.data }
delete (sanitizedData as Record<string, unknown>).selected
return {
...node,
data: sanitizedData,
}
}
const sanitizeEdgeForBroadcast = (edge: Edge): Edge => {
if (!edge.data)
return edge
if (!Object.prototype.hasOwnProperty.call(edge.data, '_connectedNodeIsSelected'))
return edge
const sanitizedData = { ...edge.data }
delete (sanitizedData as Record<string, unknown>)._connectedNodeIsSelected
return {
...edge,
data: sanitizedData,
}
}
export const useCollaborativeWorkflow = () => {
const store = useStoreApi()
const { setNodes: collabSetNodes, setEdges: collabSetEdges } = collaborationManager
const setNodes = useCallback((newNodes: Node[], shouldBroadcast: boolean = true) => {
const { getNodes, setNodes: reactFlowSetNodes } = store.getState()
if (shouldBroadcast) {
const oldNodes = getNodes()
collabSetNodes(
oldNodes.map(sanitizeNodeForBroadcast),
newNodes.map(sanitizeNodeForBroadcast),
)
}
reactFlowSetNodes(newNodes)
}, [store, collabSetNodes])
const setEdges = useCallback((newEdges: Edge[], shouldBroadcast: boolean = true) => {
const { edges, setEdges: reactFlowSetEdges } = store.getState()
if (shouldBroadcast) {
collabSetEdges(
edges.map(sanitizeEdgeForBroadcast),
newEdges.map(sanitizeEdgeForBroadcast),
)
}
reactFlowSetEdges(newEdges)
}, [store, collabSetEdges])
const collaborativeStore = useCallback(() => {
const state = store.getState()
return {
nodes: state.getNodes(),
edges: state.edges,
setNodes,
setEdges,
}
}, [store, setNodes, setEdges])
return {
getState: collaborativeStore,
setNodes,
setEdges,
}
}

View File

@ -4,9 +4,7 @@ import type {
EdgeMouseHandler,
OnEdgesChange,
} from 'reactflow'
import {
useStoreApi,
} from 'reactflow'
import type {
Node,
} from '../types'
@ -14,61 +12,55 @@ import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useNodesReadOnly } from './use-workflow'
import { WorkflowHistoryEvent, useWorkflowHistory } from './use-workflow-history'
import { useCollaborativeWorkflow } from './use-collaborative-workflow'
export const useEdgesInteractions = () => {
const store = useStoreApi()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { getNodesReadOnly } = useNodesReadOnly()
const { saveStateToHistory } = useWorkflowHistory()
const collaborativeWorkflow = useCollaborativeWorkflow()
const handleEdgeEnter = useCallback<EdgeMouseHandler>((_, edge) => {
if (getNodesReadOnly())
return
const {
edges,
setEdges,
} = store.getState()
const { edges, setEdges } = collaborativeWorkflow.getState()
const newEdges = produce(edges, (draft) => {
const currentEdge = draft.find(e => e.id === edge.id)!
currentEdge.data._hovering = true
})
setEdges(newEdges)
}, [store, getNodesReadOnly])
setEdges(newEdges, false)
}, [collaborativeWorkflow, getNodesReadOnly])
const handleEdgeLeave = useCallback<EdgeMouseHandler>((_, edge) => {
if (getNodesReadOnly())
return
const {
edges,
setEdges,
} = store.getState()
const { edges, setEdges } = collaborativeWorkflow.getState()
const newEdges = produce(edges, (draft) => {
const currentEdge = draft.find(e => e.id === edge.id)!
currentEdge.data._hovering = false
})
setEdges(newEdges)
}, [store, getNodesReadOnly])
setEdges(newEdges, false)
}, [collaborativeWorkflow, getNodesReadOnly])
const handleEdgeDeleteByDeleteBranch = useCallback((nodeId: string, branchId: string) => {
if (getNodesReadOnly())
return
const {
getNodes,
nodes,
setNodes,
edges,
setEdges,
} = store.getState()
} = collaborativeWorkflow.getState()
const edgeWillBeDeleted = edges.filter(edge => edge.source === nodeId && edge.sourceHandle === branchId)
if (!edgeWillBeDeleted.length)
return
const nodes = getNodes()
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
edgeWillBeDeleted.map(edge => ({ type: 'remove', edge })),
nodes,
@ -90,24 +82,23 @@ export const useEdgesInteractions = () => {
setEdges(newEdges)
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch)
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
}, [getNodesReadOnly, collaborativeWorkflow, handleSyncWorkflowDraft, saveStateToHistory])
const handleEdgeDelete = useCallback(() => {
if (getNodesReadOnly())
return
const {
getNodes,
nodes,
setNodes,
edges,
setEdges,
} = store.getState()
} = collaborativeWorkflow.getState()
const currentEdgeIndex = edges.findIndex(edge => edge.selected)
if (currentEdgeIndex < 0)
return
const currentEdge = edges[currentEdgeIndex]
const nodes = getNodes()
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
[
{ type: 'remove', edge: currentEdge },
@ -131,7 +122,7 @@ export const useEdgesInteractions = () => {
setEdges(newEdges)
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.EdgeDelete)
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
}, [getNodesReadOnly, collaborativeWorkflow, handleSyncWorkflowDraft, saveStateToHistory])
const handleEdgesChange = useCallback<OnEdgesChange>((changes) => {
if (getNodesReadOnly())
@ -140,7 +131,7 @@ export const useEdgesInteractions = () => {
const {
edges,
setEdges,
} = store.getState()
} = collaborativeWorkflow.getState()
const newEdges = produce(edges, (draft) => {
changes.forEach((change) => {
@ -149,7 +140,7 @@ export const useEdgesInteractions = () => {
})
})
setEdges(newEdges)
}, [store, getNodesReadOnly])
}, [collaborativeWorkflow, getNodesReadOnly])
return {
handleEdgeEnter,

View File

@ -4,6 +4,7 @@ import { useStoreApi } from 'reactflow'
import type { SyncCallback } from './use-nodes-sync-draft'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useNodesReadOnly } from './use-workflow'
import { useCollaborativeWorkflow } from './use-collaborative-workflow'
type NodeDataUpdatePayload = {
id: string
@ -14,13 +15,11 @@ export const useNodeDataUpdate = () => {
const store = useStoreApi()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { getNodesReadOnly } = useNodesReadOnly()
const collaborativeWorkflow = useCollaborativeWorkflow()
const handleNodeDataUpdate = useCallback(({ id, data }: NodeDataUpdatePayload) => {
const {
getNodes,
setNodes,
} = store.getState()
const newNodes = produce(getNodes(), (draft) => {
const { nodes, setNodes } = collaborativeWorkflow.getState()
const newNodes = produce(nodes, (draft) => {
const currentNode = draft.find(node => node.id === id)!
if (currentNode)

View File

@ -14,11 +14,10 @@ import {
getConnectedEdges,
getOutgoers,
useReactFlow,
useStoreApi,
} from 'reactflow'
import type { PluginDefaultValue } from '../block-selector/types'
import type { Edge, Node, OnNodeAdd } from '../types'
import { BlockEnum, isTriggerNode } from '../types'
import { BlockEnum, ControlMode, isTriggerNode } from '../types'
import { useWorkflowStore } from '../store'
import {
CUSTOM_EDGE,
@ -46,7 +45,7 @@ import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types'
import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions'
import { useNodeLoopInteractions } from '../nodes/loop/use-interactions'
import { useWorkflowHistoryStore } from '../workflow-history-store'
import { collaborationManager } from '../collaboration/core/collaboration-manager'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useHelpline } from './use-helpline'
import {
@ -62,6 +61,7 @@ import { useNodesMetaData } from './use-nodes-meta-data'
import type { RAGPipelineVariables } from '@/models/pipeline'
import useInspectVarsCrud from './use-inspect-vars-crud'
import { getNodeUsedVars } from '../nodes/_base/components/variable/utils'
import { useCollaborativeWorkflow } from './use-collaborative-workflow'
// Entry node deletion restriction has been removed to allow empty workflows
@ -74,10 +74,9 @@ const ENTRY_NODE_WRAPPER_OFFSET = {
export const useNodesInteractions = () => {
const { t } = useTranslation()
const store = useStoreApi()
const collaborativeWorkflow = useCollaborativeWorkflow()
const workflowStore = useWorkflowStore()
const reactflow = useReactFlow()
const { store: workflowHistoryStore } = useWorkflowHistoryStore()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { getAfterNodesInSameBranch } = useWorkflow()
const { getNodesReadOnly } = useNodesReadOnly()
@ -93,7 +92,7 @@ export const useNodesInteractions = () => {
})
const { nodesMap: nodesMetaDataMap } = useNodesMetaData()
const { saveStateToHistory, undo, redo } = useWorkflowHistory()
const { saveStateToHistory } = useWorkflowHistory()
const handleNodeDragStart = useCallback<NodeDragHandler>(
(_, node) => {
@ -129,10 +128,9 @@ export const useNodesInteractions = () => {
if (node.type === CUSTOM_LOOP_START_NODE) return
const { getNodes, setNodes } = store.getState()
e.stopPropagation()
const nodes = getNodes()
const { nodes, setNodes } = collaborativeWorkflow.getState()
const { restrictPosition } = handleNodeIterationChildDrag(node)
const { restrictPosition: restrictLoopPosition }
@ -194,15 +192,7 @@ export const useNodesInteractions = () => {
}
})
setNodes(newNodes)
},
[
getNodesReadOnly,
store,
handleNodeIterationChildDrag,
handleNodeLoopChildDrag,
handleSetHelpline,
],
)
}, [getNodesReadOnly, collaborativeWorkflow, handleNodeIterationChildDrag, handleNodeLoopChildDrag, handleSetHelpline])
const handleNodeDragStop = useCallback<NodeDragHandler>(
(_, node) => {
@ -249,11 +239,11 @@ export const useNodesInteractions = () => {
)
return
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const { connectingNodePayload, setEnteringNodePayload }
= workflowStore.getState()
const { nodes, edges, setNodes, setEdges } = collaborativeWorkflow.getState()
const {
connectingNodePayload,
setEnteringNodePayload,
} = workflowStore.getState()
if (connectingNodePayload) {
if (connectingNodePayload.nodeId === node.id) return
const connectingNode: Node = nodes.find(
@ -272,25 +262,25 @@ export const useNodesInteractions = () => {
draft.forEach((n) => {
if (
n.id === node.id
&& fromType === 'source'
&& (node.data.type === BlockEnum.VariableAssigner
|| node.data.type === BlockEnum.VariableAggregator)
&& fromType === 'source'
&& (node.data.type === BlockEnum.VariableAssigner
|| node.data.type === BlockEnum.VariableAggregator)
) {
if (!node.data.advanced_settings?.group_enabled)
n.data._isEntering = true
}
if (
n.id === node.id
&& fromType === 'target'
&& (connectingNode.data.type === BlockEnum.VariableAssigner
|| connectingNode.data.type === BlockEnum.VariableAggregator)
&& node.data.type !== BlockEnum.IfElse
&& node.data.type !== BlockEnum.QuestionClassifier
&& fromType === 'target'
&& (connectingNode.data.type === BlockEnum.VariableAssigner
|| connectingNode.data.type === BlockEnum.VariableAggregator)
&& node.data.type !== BlockEnum.IfElse
&& node.data.type !== BlockEnum.QuestionClassifier
)
n.data._isEntering = true
})
})
setNodes(newNodes)
setNodes(newNodes, false)
}
}
const newEdges = produce(edges, (draft) => {
@ -301,9 +291,9 @@ export const useNodesInteractions = () => {
if (currentEdge) currentEdge.data._connectedNodeIsHovering = true
})
})
setEdges(newEdges)
setEdges(newEdges, false)
},
[store, workflowStore, getNodesReadOnly],
[collaborativeWorkflow, workflowStore, getNodesReadOnly],
)
const handleNodeLeave = useCallback<NodeMouseHandler>(
@ -324,21 +314,21 @@ export const useNodesInteractions = () => {
const { setEnteringNodePayload } = workflowStore.getState()
setEnteringNodePayload(undefined)
const { getNodes, setNodes, edges, setEdges } = store.getState()
const newNodes = produce(getNodes(), (draft) => {
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
node.data._isEntering = false
})
})
setNodes(newNodes)
setNodes(newNodes, false)
const newEdges = produce(edges, (draft) => {
draft.forEach((edge) => {
edge.data._connectedNodeIsHovering = false
})
})
setEdges(newEdges)
setEdges(newEdges, false)
},
[store, workflowStore, getNodesReadOnly],
[collaborativeWorkflow, workflowStore, getNodesReadOnly],
)
const handleNodeSelect = useCallback(
@ -349,9 +339,7 @@ export const useNodesInteractions = () => {
) => {
if (initShowLastRunTab)
workflowStore.setState({ initShowLastRunTab: true })
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
const selectedNode = nodes.find(node => node.data.selected)
if (!cancelSelection && selectedNode?.id === nodeId) return
@ -362,7 +350,7 @@ export const useNodesInteractions = () => {
else node.data.selected = false
})
})
setNodes(newNodes)
setNodes(newNodes, false)
const connectedEdges = getConnectedEdges(
[{ id: nodeId } as Node],
@ -384,22 +372,20 @@ export const useNodesInteractions = () => {
}
})
})
setEdges(newEdges)
handleSyncWorkflowDraft()
},
[store, handleSyncWorkflowDraft],
)
setEdges(newEdges, false)
}, [collaborativeWorkflow])
const handleNodeClick = useCallback<NodeMouseHandler>(
(_, node) => {
const { controlMode } = workflowStore.getState()
if (controlMode === ControlMode.Comment) return
if (node.type === CUSTOM_ITERATION_START_NODE) return
if (node.type === CUSTOM_LOOP_START_NODE) return
if (node.data.type === BlockEnum.DataSourceEmpty) return
if (node.data._pluginInstallLocked) return
handleNodeSelect(node.id)
},
[handleNodeSelect],
[handleNodeSelect, workflowStore],
)
const handleNodeConnect = useCallback<OnConnect>(
@ -407,8 +393,7 @@ export const useNodesInteractions = () => {
if (source === target) return
if (getNodesReadOnly()) return
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const { nodes, edges, setNodes, setEdges } = collaborativeWorkflow.getState()
const targetNode = nodes.find(node => node.id === target!)
const sourceNode = nodes.find(node => node.id === source!)
@ -486,7 +471,7 @@ export const useNodesInteractions = () => {
},
[
getNodesReadOnly,
store,
collaborativeWorkflow,
workflowStore,
handleSyncWorkflowDraft,
saveStateToHistory,
@ -499,8 +484,8 @@ export const useNodesInteractions = () => {
if (nodeId && handleType) {
const { setConnectingNodePayload } = workflowStore.getState()
const { getNodes } = store.getState()
const node = getNodes().find(n => n.id === nodeId)!
const { nodes } = collaborativeWorkflow.getState()
const node = nodes.find(n => n.id === nodeId)!
if (node.type === CUSTOM_NOTE_NODE) return
@ -517,9 +502,7 @@ export const useNodesInteractions = () => {
handleId,
})
}
},
[store, workflowStore, getNodesReadOnly],
)
}, [collaborativeWorkflow, workflowStore, getNodesReadOnly])
const handleNodeConnectEnd = useCallback<OnConnectEnd>(
(e: any) => {
@ -535,8 +518,7 @@ export const useNodesInteractions = () => {
const { setShowAssignVariablePopup, hoveringAssignVariableGroupId }
= workflowStore.getState()
const { screenToFlowPosition } = reactflow
const { getNodes, setNodes } = store.getState()
const nodes = getNodes()
const { nodes, setNodes } = collaborativeWorkflow.getState()
const fromHandleType = connectingNodePayload.handleType
const fromHandleId = connectingNodePayload.handleId
const fromNode = nodes.find(
@ -593,7 +575,7 @@ export const useNodesInteractions = () => {
setConnectingNodePayload(undefined)
setEnteringNodePayload(undefined)
},
[store, handleNodeConnect, getNodesReadOnly, workflowStore, reactflow],
[collaborativeWorkflow, handleNodeConnect, getNodesReadOnly, workflowStore, reactflow],
)
const { deleteNodeInspectorVars } = useInspectVarsCrud()
@ -602,9 +584,7 @@ export const useNodesInteractions = () => {
(nodeId: string) => {
if (getNodesReadOnly()) return
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
const currentNodeIndex = nodes.findIndex(node => node.id === nodeId)
const currentNode = nodes[currentNodeIndex]
@ -759,7 +739,7 @@ export const useNodesInteractions = () => {
},
[
getNodesReadOnly,
store,
collaborativeWorkflow,
handleSyncWorkflowDraft,
saveStateToHistory,
workflowStore,
@ -781,8 +761,7 @@ export const useNodesInteractions = () => {
) => {
if (getNodesReadOnly()) return
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
const nodesWithSameType = nodes.filter(
node => node.data.type === nodeType,
)
@ -1312,7 +1291,7 @@ export const useNodesInteractions = () => {
},
[
getNodesReadOnly,
store,
collaborativeWorkflow,
handleSyncWorkflowDraft,
saveStateToHistory,
workflowStore,
@ -1330,8 +1309,7 @@ export const useNodesInteractions = () => {
) => {
if (getNodesReadOnly()) return
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
const currentNode = nodes.find(node => node.id === currentNodeId)!
const connectedEdges = getConnectedEdges([currentNode], edges)
const nodesWithSameType = nodes.filter(
@ -1409,7 +1387,7 @@ export const useNodesInteractions = () => {
},
[
getNodesReadOnly,
store,
collaborativeWorkflow,
handleSyncWorkflowDraft,
saveStateToHistory,
nodesMetaDataMap,
@ -1417,16 +1395,14 @@ export const useNodesInteractions = () => {
)
const handleNodesCancelSelected = useCallback(() => {
const { getNodes, setNodes } = store.getState()
const nodes = getNodes()
const { nodes, setNodes } = collaborativeWorkflow.getState()
const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
node.data.selected = false
})
})
setNodes(newNodes)
}, [store])
}, [collaborativeWorkflow])
const handleNodeContextMenu = useCallback(
(e: MouseEvent, node: Node) => {
@ -1463,9 +1439,7 @@ export const useNodesInteractions = () => {
const { setClipboardElements } = workflowStore.getState()
const { getNodes } = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
if (nodeId) {
// If nodeId is provided, copy that specific node
@ -1506,7 +1480,7 @@ export const useNodesInteractions = () => {
if (selectedNode) setClipboardElements([selectedNode])
}
},
[getNodesReadOnly, store, workflowStore],
[getNodesReadOnly, collaborativeWorkflow, workflowStore],
)
const handleNodesPaste = useCallback(() => {
@ -1514,11 +1488,10 @@ export const useNodesInteractions = () => {
const { clipboardElements, mousePosition } = workflowStore.getState()
const { getNodes, setNodes, edges, setEdges } = store.getState()
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
const nodesToPaste: Node[] = []
const edgesToPaste: Edge[] = []
const nodes = getNodes()
if (clipboardElements.length) {
const { x, y } = getTopLeftNodePosition(clipboardElements)
@ -1670,7 +1643,7 @@ export const useNodesInteractions = () => {
}, [
getNodesReadOnly,
workflowStore,
store,
collaborativeWorkflow,
reactflow,
saveStateToHistory,
handleSyncWorkflowDraft,
@ -1692,9 +1665,8 @@ export const useNodesInteractions = () => {
const handleNodesDelete = useCallback(() => {
if (getNodesReadOnly()) return
const { getNodes, edges } = store.getState()
const { nodes, edges } = collaborativeWorkflow.getState()
const nodes = getNodes()
const bundledNodes = nodes.filter(
node => node.data._isBundled,
)
@ -1713,16 +1685,15 @@ export const useNodesInteractions = () => {
)
if (selectedNode) handleNodeDelete(selectedNode.id)
}, [store, getNodesReadOnly, handleNodeDelete])
}, [collaborativeWorkflow, getNodesReadOnly, handleNodeDelete])
const handleNodeResize = useCallback(
(nodeId: string, params: ResizeParamsWithDirection) => {
if (getNodesReadOnly()) return
const { getNodes, setNodes } = store.getState()
const { nodes, setNodes } = collaborativeWorkflow.getState()
const { x, y, width, height } = params
const nodes = getNodes()
const currentNode = nodes.find(n => n.id === nodeId)!
const childrenNodes = nodes.filter(n =>
currentNode.data._children?.find((c: any) => c.nodeId === n.id),
@ -1781,15 +1752,14 @@ export const useNodesInteractions = () => {
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeResize, { nodeId })
},
[getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory],
[getNodesReadOnly, collaborativeWorkflow, handleSyncWorkflowDraft, saveStateToHistory],
)
const handleNodeDisconnect = useCallback(
(nodeId: string) => {
if (getNodesReadOnly()) return
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
const currentNode = nodes.find(node => node.id === nodeId)!
const connectedEdges = getConnectedEdges([currentNode], edges)
const nodesConnectedSourceOrTargetHandleIdsMap
@ -1820,24 +1790,24 @@ export const useNodesInteractions = () => {
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.EdgeDelete)
},
[store, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory],
[collaborativeWorkflow, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory],
)
const handleHistoryBack = useCallback(() => {
if (getNodesReadOnly() || getWorkflowReadOnly()) return
const { setEdges, setNodes } = store.getState()
undo()
// Use collaborative undo from Loro
const undoResult = collaborationManager.undo()
const { edges, nodes } = workflowHistoryStore.getState()
if (edges.length === 0 && nodes.length === 0) return
setEdges(edges)
setNodes(nodes)
if (undoResult) {
// The undo operation will automatically trigger subscriptions
// which will update the nodes and edges through setupSubscriptions
console.log('Collaborative undo performed')
}
else {
console.log('Nothing to undo')
}
}, [
store,
undo,
workflowHistoryStore,
getNodesReadOnly,
getWorkflowReadOnly,
])
@ -1845,18 +1815,18 @@ export const useNodesInteractions = () => {
const handleHistoryForward = useCallback(() => {
if (getNodesReadOnly() || getWorkflowReadOnly()) return
const { setEdges, setNodes } = store.getState()
redo()
// Use collaborative redo from Loro
const redoResult = collaborationManager.redo()
const { edges, nodes } = workflowHistoryStore.getState()
if (edges.length === 0 && nodes.length === 0) return
setEdges(edges)
setNodes(nodes)
if (redoResult) {
// The redo operation will automatically trigger subscriptions
// which will update the nodes and edges through setupSubscriptions
console.log('Collaborative redo performed')
}
else {
console.log('Nothing to redo')
}
}, [
redo,
store,
workflowHistoryStore,
getNodesReadOnly,
getWorkflowReadOnly,
])
@ -1865,8 +1835,7 @@ export const useNodesInteractions = () => {
/** Add opacity-30 to all nodes except the nodeId */
const dimOtherNodes = useCallback(() => {
if (isDimming) return
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
const selectedNode = nodes.find(n => n.data.selected)
if (!selectedNode) return
@ -1959,12 +1928,11 @@ export const useNodesInteractions = () => {
draft.push(...tempEdges)
})
setEdges(newEdges)
}, [isDimming, store])
}, [isDimming, collaborativeWorkflow])
/** Restore all nodes to full opacity */
const undimAllNodes = useCallback(() => {
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
setIsDimming(false)
const newNodes = produce(nodes, (draft) => {
@ -1984,7 +1952,7 @@ export const useNodesInteractions = () => {
},
)
setEdges(newEdges)
}, [store])
}, [collaborativeWorkflow])
return {
handleNodeDragStart,

View File

@ -18,13 +18,18 @@ export const useNodesSyncDraft = () => {
const handleSyncWorkflowDraft = useCallback((
sync?: boolean,
notRefreshWhenSyncError?: boolean,
callback?: SyncCallback,
callback?: {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void
},
forceUpload?: boolean,
) => {
if (getNodesReadOnly())
return
if (sync)
doSyncWorkflowDraft(notRefreshWhenSyncError, callback)
doSyncWorkflowDraft(notRefreshWhenSyncError, callback, forceUpload)
else
debouncedSyncWorkflowDraft(doSyncWorkflowDraft)
}, [debouncedSyncWorkflowDraft, doSyncWorkflowDraft, getNodesReadOnly])

View File

@ -34,6 +34,8 @@ export const useShortcuts = (): void => {
const {
handleModeHand,
handleModePointer,
handleModeComment,
isCommentModeAvailable,
} = useWorkflowMoveMode()
const { handleLayout } = useWorkflowOrganize()
const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize()
@ -145,6 +147,16 @@ export const useShortcuts = (): void => {
useCapture: true,
})
useKeyPress('c', (e) => {
if (shouldHandleShortcut(e) && isCommentModeAvailable) {
e.preventDefault()
handleModeComment()
}
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.o`, (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()

View File

@ -0,0 +1,516 @@
import { useCallback, useEffect, useRef } from 'react'
import { useParams } from 'next/navigation'
import { useReactFlow } from 'reactflow'
import { useStore } from '../store'
import { ControlMode } from '../types'
import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
import { createWorkflowComment, createWorkflowCommentReply, deleteWorkflowComment, deleteWorkflowCommentReply, fetchWorkflowComment, fetchWorkflowComments, resolveWorkflowComment, updateWorkflowComment, updateWorkflowCommentReply } from '@/service/workflow-comment'
import { collaborationManager } from '@/app/components/workflow/collaboration'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
export const useWorkflowComment = () => {
const params = useParams()
const appId = params.appId as string
const reactflow = useReactFlow()
const controlMode = useStore(s => s.controlMode)
const pendingComment = useStore(s => s.pendingComment)
const setPendingComment = useStore(s => s.setPendingComment)
const setActiveCommentId = useStore(s => s.setActiveCommentId)
const activeCommentId = useStore(s => s.activeCommentId)
const comments = useStore(s => s.comments)
const setComments = useStore(s => s.setComments)
const loading = useStore(s => s.commentsLoading)
const setCommentsLoading = useStore(s => s.setCommentsLoading)
const activeComment = useStore(s => s.activeCommentDetail)
const setActiveComment = useStore(s => s.setActiveCommentDetail)
const activeCommentLoading = useStore(s => s.activeCommentDetailLoading)
const setActiveCommentLoading = useStore(s => s.setActiveCommentDetailLoading)
const replySubmitting = useStore(s => s.replySubmitting)
const setReplySubmitting = useStore(s => s.setReplySubmitting)
const replyUpdating = useStore(s => s.replyUpdating)
const setReplyUpdating = useStore(s => s.setReplyUpdating)
const commentDetailCache = useStore(s => s.commentDetailCache)
const setCommentDetailCache = useStore(s => s.setCommentDetailCache)
const rightPanelWidth = useStore(s => s.rightPanelWidth)
const nodePanelWidth = useStore(s => s.nodePanelWidth)
const mentionableUsers = useStore(state => (
appId ? state.mentionableUsersCache[appId] ?? [] : []
))
const { userProfile } = useAppContext()
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
const commentDetailCacheRef = useRef<Record<string, WorkflowCommentDetail>>(commentDetailCache)
const activeCommentIdRef = useRef<string | null>(null)
useEffect(() => {
activeCommentIdRef.current = activeCommentId ?? null
}, [activeCommentId])
useEffect(() => {
commentDetailCacheRef.current = commentDetailCache
}, [commentDetailCache])
const refreshActiveComment = useCallback(async (commentId: string) => {
if (!appId) return
const detailResponse = await fetchWorkflowComment(appId, commentId)
const detail = (detailResponse as any)?.data ?? detailResponse
commentDetailCacheRef.current = {
...commentDetailCacheRef.current,
[commentId]: detail,
}
setCommentDetailCache(commentDetailCacheRef.current)
setActiveComment(detail)
}, [appId, setActiveComment, setCommentDetailCache])
const loadComments = useCallback(async () => {
if (!appId || !isCollaborationEnabled) return
setCommentsLoading(true)
try {
const commentsData = await fetchWorkflowComments(appId)
setComments(commentsData)
}
catch (error) {
console.error('Failed to fetch comments:', error)
}
finally {
setCommentsLoading(false)
}
}, [appId, isCollaborationEnabled, setComments, setCommentsLoading])
// Setup collaboration
useEffect(() => {
if (!appId || !isCollaborationEnabled) return
const unsubscribe = collaborationManager.onCommentsUpdate(() => {
loadComments()
if (activeCommentIdRef.current)
refreshActiveComment(activeCommentIdRef.current)
})
return unsubscribe
}, [appId, isCollaborationEnabled, loadComments, refreshActiveComment])
useEffect(() => {
loadComments()
}, [loadComments])
const handleCommentSubmit = useCallback(async (content: string, mentionedUserIds: string[] = []) => {
if (!pendingComment) return
console.log('Submitting comment:', { appId, pendingComment, content, mentionedUserIds })
if (!appId) {
console.error('AppId is missing')
return
}
try {
// Convert screen position to flow position when submitting
const { screenToFlowPosition } = reactflow
const flowPosition = screenToFlowPosition({
x: pendingComment.pageX,
y: pendingComment.pageY,
})
const newComment = await createWorkflowComment(appId, {
position_x: flowPosition.x,
position_y: flowPosition.y,
content,
mentioned_user_ids: mentionedUserIds,
})
console.log('Comment created successfully:', newComment)
const createdAt = (newComment as any)?.created_at
const createdByAccount = {
id: userProfile?.id ?? '',
name: userProfile?.name ?? '',
email: userProfile?.email ?? '',
avatar_url: userProfile?.avatar_url || userProfile?.avatar || undefined,
}
const mentionedUsers = mentionedUserIds
.map(mentionedId => mentionableUsers.find(user => user.id === mentionedId))
.filter((user): user is NonNullable<typeof user> => Boolean(user))
const uniqueParticipantsMap = new Map<string, typeof createdByAccount>()
if (createdByAccount.id)
uniqueParticipantsMap.set(createdByAccount.id, createdByAccount)
for (const user of mentionedUsers) {
if (!uniqueParticipantsMap.has(user.id)) {
uniqueParticipantsMap.set(user.id, {
id: user.id,
name: user.name,
email: user.email,
avatar_url: user.avatar_url,
})
}
}
const participants = Array.from(uniqueParticipantsMap.values())
const composedComment: WorkflowCommentList = {
id: newComment.id,
position_x: flowPosition.x,
position_y: flowPosition.y,
content,
created_by: createdByAccount.id,
created_by_account: createdByAccount,
created_at: createdAt,
updated_at: createdAt,
resolved: false,
mention_count: mentionedUserIds.length,
reply_count: 0,
participants,
}
const composedDetail: WorkflowCommentDetail = {
id: newComment.id,
position_x: flowPosition.x,
position_y: flowPosition.y,
content,
created_by: createdByAccount.id,
created_by_account: createdByAccount,
created_at: createdAt,
updated_at: createdAt,
resolved: false,
replies: [],
mentions: mentionedUserIds.map(mentionedId => ({
mentioned_user_id: mentionedId,
mentioned_user_account: mentionableUsers.find(user => user.id === mentionedId) ?? null,
reply_id: null,
})),
}
setComments([...comments, composedComment])
commentDetailCacheRef.current = {
...commentDetailCacheRef.current,
[newComment.id]: composedDetail,
}
setCommentDetailCache(commentDetailCacheRef.current)
collaborationManager.emitCommentsUpdate(appId)
setPendingComment(null)
}
catch (error) {
console.error('Failed to create comment:', error)
setPendingComment(null)
}
}, [appId, pendingComment, setPendingComment, reactflow, comments, setComments, userProfile, setCommentDetailCache, mentionableUsers])
const handleCommentCancel = useCallback(() => {
setPendingComment(null)
}, [setPendingComment])
useEffect(() => {
if (controlMode !== ControlMode.Comment)
setPendingComment(null)
}, [controlMode, setPendingComment])
const handleCommentIconClick = useCallback(async (comment: WorkflowCommentList) => {
setPendingComment(null)
activeCommentIdRef.current = comment.id
setActiveCommentId(comment.id)
const cachedDetail = commentDetailCacheRef.current[comment.id]
setActiveComment(cachedDetail || comment)
const hasSelectedNode = reactflow.getNodes().some(node => node.data?.selected)
const commentPanelWidth = controlMode === ControlMode.Comment ? 420 : 0
const fallbackPanelWidth = (hasSelectedNode ? nodePanelWidth : 0) + commentPanelWidth
const effectivePanelWidth = Math.max(rightPanelWidth ?? 0, fallbackPanelWidth)
const baseHorizontalOffsetPx = 220
const panelCompensationPx = effectivePanelWidth / 2
const desiredHorizontalOffsetPx = baseHorizontalOffsetPx + panelCompensationPx
const maxOffset = Math.max(0, (window.innerWidth / 2) - 60)
const horizontalOffsetPx = Math.min(desiredHorizontalOffsetPx, maxOffset)
reactflow.setCenter(
comment.position_x + horizontalOffsetPx,
comment.position_y,
{ zoom: 1, duration: 600 },
)
if (!appId) return
setActiveCommentLoading(!cachedDetail)
try {
const detailResponse = await fetchWorkflowComment(appId, comment.id)
const detail = (detailResponse as any)?.data ?? detailResponse
commentDetailCacheRef.current = {
...commentDetailCacheRef.current,
[comment.id]: detail,
}
setCommentDetailCache(commentDetailCacheRef.current)
if (activeCommentIdRef.current === comment.id)
setActiveComment(detail)
}
catch (e) {
console.warn('Failed to load workflow comment detail', e)
}
finally {
setActiveCommentLoading(false)
}
}, [
appId,
controlMode,
nodePanelWidth,
reactflow,
rightPanelWidth,
setActiveComment,
setActiveCommentId,
setActiveCommentLoading,
setCommentDetailCache,
setPendingComment,
])
const handleCommentResolve = useCallback(async (commentId: string) => {
if (!appId) return
setActiveCommentLoading(true)
try {
await resolveWorkflowComment(appId, commentId)
collaborationManager.emitCommentsUpdate(appId)
await refreshActiveComment(commentId)
await loadComments()
}
catch (error) {
console.error('Failed to resolve comment:', error)
}
finally {
setActiveCommentLoading(false)
}
}, [appId, loadComments, refreshActiveComment, setActiveCommentLoading])
const handleCommentDelete = useCallback(async (commentId: string) => {
if (!appId) return
setActiveCommentLoading(true)
try {
await deleteWorkflowComment(appId, commentId)
collaborationManager.emitCommentsUpdate(appId)
const updatedCache = { ...commentDetailCacheRef.current }
delete updatedCache[commentId]
commentDetailCacheRef.current = updatedCache
setCommentDetailCache(updatedCache)
const currentComments = comments.filter(c => c.id !== commentId)
const commentIndex = comments.findIndex(c => c.id === commentId)
const fallbackTarget = commentIndex >= 0 ? comments[commentIndex + 1] ?? comments[commentIndex - 1] : undefined
await loadComments()
if (fallbackTarget) {
handleCommentIconClick(fallbackTarget)
}
else if (currentComments.length > 0) {
const nextComment = currentComments[0]
handleCommentIconClick(nextComment)
}
else {
setActiveComment(null)
setActiveCommentId(null)
activeCommentIdRef.current = null
}
}
catch (error) {
console.error('Failed to delete comment:', error)
}
finally {
setActiveCommentLoading(false)
}
}, [appId, comments, handleCommentIconClick, loadComments, setActiveComment, setActiveCommentId, setActiveCommentLoading, setCommentDetailCache])
const handleCommentPositionUpdate = useCallback(async (commentId: string, position: { x: number; y: number }) => {
if (!appId) return
const targetComment = comments.find(c => c.id === commentId)
if (!targetComment) return
const nextPosition = {
position_x: position.x,
position_y: position.y,
}
const previousComments = comments
const updatedComments = comments.map(c =>
c.id === commentId
? { ...c, ...nextPosition }
: c,
)
setComments(updatedComments)
const cachedDetail = commentDetailCacheRef.current[commentId]
const updatedDetail = cachedDetail ? { ...cachedDetail, ...nextPosition } : null
if (updatedDetail) {
commentDetailCacheRef.current = {
...commentDetailCacheRef.current,
[commentId]: updatedDetail,
}
setCommentDetailCache(commentDetailCacheRef.current)
if (activeCommentIdRef.current === commentId)
setActiveComment(updatedDetail)
}
else if (activeComment?.id === commentId) {
setActiveComment({ ...activeComment, ...nextPosition })
}
try {
await updateWorkflowComment(appId, commentId, {
content: targetComment.content,
position_x: nextPosition.position_x,
position_y: nextPosition.position_y,
})
collaborationManager.emitCommentsUpdate(appId)
}
catch (error) {
console.error('Failed to update comment position:', error)
setComments(previousComments)
if (cachedDetail) {
commentDetailCacheRef.current = {
...commentDetailCacheRef.current,
[commentId]: cachedDetail,
}
setCommentDetailCache(commentDetailCacheRef.current)
if (activeCommentIdRef.current === commentId)
setActiveComment(cachedDetail)
}
else if (activeComment?.id === commentId) {
setActiveComment(activeComment)
}
}
}, [activeComment, appId, comments, setComments, setCommentDetailCache, setActiveComment])
const handleCommentReply = useCallback(async (commentId: string, content: string, mentionedUserIds: string[] = []) => {
if (!appId) return
const trimmed = content.trim()
if (!trimmed) return
setReplySubmitting(true)
try {
await createWorkflowCommentReply(appId, commentId, { content: trimmed, mentioned_user_ids: mentionedUserIds })
collaborationManager.emitCommentsUpdate(appId)
await refreshActiveComment(commentId)
await loadComments()
}
catch (error) {
console.error('Failed to create reply:', error)
}
finally {
setReplySubmitting(false)
}
}, [appId, loadComments, refreshActiveComment, setReplySubmitting])
const handleCommentReplyUpdate = useCallback(async (commentId: string, replyId: string, content: string, mentionedUserIds: string[] = []) => {
if (!appId) return
const trimmed = content.trim()
if (!trimmed) return
setReplyUpdating(true)
try {
await updateWorkflowCommentReply(appId, commentId, replyId, { content: trimmed, mentioned_user_ids: mentionedUserIds })
collaborationManager.emitCommentsUpdate(appId)
await refreshActiveComment(commentId)
await loadComments()
}
catch (error) {
console.error('Failed to update reply:', error)
}
finally {
setReplyUpdating(false)
}
}, [appId, loadComments, refreshActiveComment, setReplyUpdating])
const handleCommentReplyDelete = useCallback(async (commentId: string, replyId: string) => {
if (!appId) return
setActiveCommentLoading(true)
try {
await deleteWorkflowCommentReply(appId, commentId, replyId)
collaborationManager.emitCommentsUpdate(appId)
await refreshActiveComment(commentId)
await loadComments()
}
catch (error) {
console.error('Failed to delete reply:', error)
}
finally {
setActiveCommentLoading(false)
}
}, [appId, loadComments, refreshActiveComment, setActiveCommentLoading])
const handleCommentNavigate = useCallback((direction: 'prev' | 'next') => {
const currentId = activeCommentIdRef.current
if (!currentId) return
const idx = comments.findIndex(c => c.id === currentId)
if (idx === -1) return
const target = direction === 'prev' ? comments[idx - 1] : comments[idx + 1]
if (target)
handleCommentIconClick(target)
}, [comments, handleCommentIconClick])
const handleActiveCommentClose = useCallback(() => {
setActiveComment(null)
setActiveCommentLoading(false)
setActiveCommentId(null)
activeCommentIdRef.current = null
}, [setActiveComment, setActiveCommentId, setActiveCommentLoading])
const handleCreateComment = useCallback((mousePosition: {
pageX: number
pageY: number
elementX: number
elementY: number
}) => {
if (controlMode === ControlMode.Comment) {
console.log('Setting pending comment at screen position:', mousePosition)
setPendingComment(mousePosition)
}
else {
console.log('Control mode is not Comment:', controlMode)
}
}, [controlMode, setPendingComment])
return {
comments,
loading,
pendingComment,
activeComment,
activeCommentLoading,
replySubmitting,
replyUpdating,
handleCommentSubmit,
handleCommentCancel,
handleCommentIconClick,
handleActiveCommentClose,
handleCommentResolve,
handleCommentDelete,
handleCommentNavigate,
handleCommentReply,
handleCommentReplyUpdate,
handleCommentReplyDelete,
handleCommentPositionUpdate,
refreshActiveComment,
handleCreateComment,
loadComments,
}
}

View File

@ -1,7 +1,7 @@
import {
useCallback,
} from 'react'
import { useReactFlow, useStoreApi } from 'reactflow'
import { useReactFlow } from 'reactflow'
import { produce } from 'immer'
import { useStore, useWorkflowStore } from '../store'
import {
@ -29,6 +29,9 @@ import { useNodesInteractionsWithoutSync } from './use-nodes-interactions-withou
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { WorkflowHistoryEvent, useWorkflowHistory } from './use-workflow-history'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useGlobalPublicStore } from '@/context/global-public-context'
export const useWorkflowInteractions = () => {
const workflowStore = useWorkflowStore()
@ -55,6 +58,9 @@ export const useWorkflowMoveMode = () => {
getNodesReadOnly,
} = useNodesReadOnly()
const { handleSelectionCancel } = useSelectionInteractions()
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
const appDetail = useAppStore(state => state.appDetail)
const isCommentModeAvailable = isCollaborationEnabled && (appDetail?.mode === 'workflow' || appDetail?.mode === 'advanced-chat')
const handleModePointer = useCallback(() => {
if (getNodesReadOnly())
@ -71,31 +77,40 @@ export const useWorkflowMoveMode = () => {
handleSelectionCancel()
}, [getNodesReadOnly, setControlMode, handleSelectionCancel])
const handleModeComment = useCallback(() => {
if (getNodesReadOnly() || !isCommentModeAvailable)
return
setControlMode(ControlMode.Comment)
handleSelectionCancel()
}, [getNodesReadOnly, setControlMode, handleSelectionCancel, isCommentModeAvailable])
return {
handleModePointer,
handleModeHand,
handleModeComment,
isCommentModeAvailable,
}
}
export const useWorkflowOrganize = () => {
const workflowStore = useWorkflowStore()
const store = useStoreApi()
const reactflow = useReactFlow()
const { getNodesReadOnly } = useNodesReadOnly()
const { saveStateToHistory } = useWorkflowHistory()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const collaborativeWorkflow = useCollaborativeWorkflow()
const handleLayout = useCallback(async () => {
if (getNodesReadOnly())
return
workflowStore.setState({ nodeAnimation: true })
const {
getNodes,
nodes,
edges,
setNodes,
} = store.getState()
} = collaborativeWorkflow.getState()
const { setViewport } = reactflow
const nodes = getNodes()
const loopAndIterationNodes = nodes.filter(
node => (node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration)
@ -232,7 +247,7 @@ export const useWorkflowOrganize = () => {
setTimeout(() => {
handleSyncWorkflowDraft()
})
}, [getNodesReadOnly, store, reactflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory])
}, [getNodesReadOnly, collaborativeWorkflow, reactflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory])
return {
handleLayout,

View File

@ -5,7 +5,6 @@ import { uniqBy } from 'lodash-es'
import {
getIncomers,
getOutgoers,
useStoreApi,
} from 'reactflow'
import type {
Connection,
@ -37,6 +36,8 @@ import { findUsedVarNodes, getNodeOutputVars, updateNodeVars } from '../nodes/_b
import { useAvailableBlocks } from './use-available-blocks'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
import { useNodesMetaData } from '.'
@ -49,26 +50,18 @@ export const useIsChatMode = () => {
}
export const useWorkflow = () => {
const store = useStoreApi()
const collaborativeWorkflow = useCollaborativeWorkflow()
const { getAvailableBlocks } = useAvailableBlocks()
const { nodesMap } = useNodesMetaData()
const getNodeById = useCallback((nodeId: string) => {
const {
getNodes,
} = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
const currentNode = nodes.find(node => node.id === nodeId)
return currentNode
}, [store])
}, [collaborativeWorkflow])
const getTreeLeafNodes = useCallback((nodeId: string) => {
const {
getNodes,
edges,
} = store.getState()
const nodes = getNodes()
// let startNode = getWorkflowEntryNode(nodes)
const { nodes, edges } = collaborativeWorkflow.getState()
const currentNode = nodes.find(node => node.id === nodeId)
let startNodes = nodes.filter(node => nodesMap?.[node.data.type as BlockEnum]?.metaData.isStart) || []
@ -111,14 +104,11 @@ export const useWorkflow = () => {
return uniqBy(list, 'id').filter((item: Node) => {
return SUPPORT_OUTPUT_VARS_NODE.includes(item.data.type)
})
}, [store, nodesMap])
}, [collaborativeWorkflow, nodesMap])
const getBeforeNodesInSameBranch = useCallback((nodeId: string, newNodes?: Node[], newEdges?: Edge[]) => {
const {
getNodes,
edges,
} = store.getState()
const nodes = newNodes || getNodes()
const { nodes: oldNodes, edges } = collaborativeWorkflow.getState()
const nodes = newNodes || oldNodes
const currentNode = nodes.find(node => node.id === nodeId)
const list: Node[] = []
@ -161,14 +151,11 @@ export const useWorkflow = () => {
}
return []
}, [store])
}, [collaborativeWorkflow])
const getBeforeNodesInSameBranchIncludeParent = useCallback((nodeId: string, newNodes?: Node[], newEdges?: Edge[]) => {
const nodes = getBeforeNodesInSameBranch(nodeId, newNodes, newEdges)
const {
getNodes,
} = store.getState()
const allNodes = getNodes()
const { nodes: allNodes } = collaborativeWorkflow.getState()
const node = allNodes.find(n => n.id === nodeId)
const parentNodeId = node?.parentId
const parentNode = allNodes.find(n => n.id === parentNodeId)
@ -176,14 +163,10 @@ export const useWorkflow = () => {
nodes.push(parentNode)
return nodes
}, [getBeforeNodesInSameBranch, store])
}, [getBeforeNodesInSameBranch, collaborativeWorkflow])
const getAfterNodesInSameBranch = useCallback((nodeId: string) => {
const {
getNodes,
edges,
} = store.getState()
const nodes = getNodes()
const { nodes, edges } = collaborativeWorkflow.getState()
const currentNode = nodes.find(node => node.id === nodeId)!
if (!currentNode)
@ -207,40 +190,29 @@ export const useWorkflow = () => {
})
return uniqBy(list, 'id')
}, [store])
}, [collaborativeWorkflow])
const getBeforeNodeById = useCallback((nodeId: string) => {
const {
getNodes,
edges,
} = store.getState()
const nodes = getNodes()
const { nodes, edges } = collaborativeWorkflow.getState()
const node = nodes.find(node => node.id === nodeId)!
return getIncomers(node, nodes, edges)
}, [store])
}, [collaborativeWorkflow])
const getIterationNodeChildren = useCallback((nodeId: string) => {
const {
getNodes,
} = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
return nodes.filter(node => node.parentId === nodeId)
}, [store])
}, [collaborativeWorkflow])
const getLoopNodeChildren = useCallback((nodeId: string) => {
const {
getNodes,
} = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
return nodes.filter(node => node.parentId === nodeId)
}, [store])
}, [collaborativeWorkflow])
const isFromStartNode = useCallback((nodeId: string) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
const currentNode = nodes.find(node => node.id === nodeId)
if (!currentNode)
@ -263,11 +235,10 @@ export const useWorkflow = () => {
}
return checkPreviousNodes(currentNode)
}, [store, getBeforeNodeById])
}, [collaborativeWorkflow, getBeforeNodeById])
const handleOutVarRenameChange = useCallback((nodeId: string, oldValeSelector: ValueSelector, newVarSelector: ValueSelector) => {
const { getNodes, setNodes } = store.getState()
const allNodes = getNodes()
const { nodes: allNodes, setNodes } = collaborativeWorkflow.getState()
const affectedNodes = findUsedVarNodes(oldValeSelector, allNodes)
if (affectedNodes.length > 0) {
const newNodes = allNodes.map((node) => {
@ -278,7 +249,7 @@ export const useWorkflow = () => {
})
setNodes(newNodes)
}
}, [store])
}, [collaborativeWorkflow])
const isVarUsedInNodes = useCallback((varSelector: ValueSelector) => {
const nodeId = varSelector[0]
@ -289,11 +260,11 @@ export const useWorkflow = () => {
const removeUsedVarInNodes = useCallback((varSelector: ValueSelector) => {
const nodeId = varSelector[0]
const { getNodes, setNodes } = store.getState()
const { nodes, setNodes } = collaborativeWorkflow.getState()
const afterNodes = getAfterNodesInSameBranch(nodeId)
const effectNodes = findUsedVarNodes(varSelector, afterNodes)
if (effectNodes.length > 0) {
const newNodes = getNodes().map((node) => {
const newNodes = nodes.map((node) => {
if (effectNodes.find(n => n.id === node.id))
return updateNodeVars(node, varSelector, [])
@ -301,7 +272,7 @@ export const useWorkflow = () => {
})
setNodes(newNodes)
}
}, [getAfterNodesInSameBranch, store])
}, [getAfterNodesInSameBranch, collaborativeWorkflow])
const isNodeVarsUsedInNodes = useCallback((node: Node, isChatMode: boolean) => {
const outputVars = getNodeOutputVars(node, isChatMode)
@ -312,11 +283,7 @@ export const useWorkflow = () => {
}, [isVarUsedInNodes])
const getRootNodesById = useCallback((nodeId: string) => {
const {
getNodes,
edges,
} = store.getState()
const nodes = getNodes()
const { nodes, edges } = collaborativeWorkflow.getState()
const currentNode = nodes.find(node => node.id === nodeId)
const rootNodes: Node[] = []
@ -356,7 +323,7 @@ export const useWorkflow = () => {
return uniqBy(rootNodes, 'id')
return []
}, [store])
}, [collaborativeWorkflow])
const getStartNodes = useCallback((nodes: Node[], currentNode?: Node) => {
const { id, parentId } = currentNode || {}
@ -382,11 +349,7 @@ export const useWorkflow = () => {
}, [nodesMap, getRootNodesById])
const isValidConnection = useCallback(({ source, sourceHandle: _sourceHandle, target }: Connection) => {
const {
edges,
getNodes,
} = store.getState()
const nodes = getNodes()
const { nodes, edges } = collaborativeWorkflow.getState()
const sourceNode: Node = nodes.find(node => node.id === source)!
const targetNode: Node = nodes.find(node => node.id === target)!
@ -422,14 +385,13 @@ export const useWorkflow = () => {
}
return !hasCycle(targetNode)
}, [store, getAvailableBlocks])
}, [collaborativeWorkflow, getAvailableBlocks])
const getNode = useCallback((nodeId?: string) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
return nodes.find(node => node.id === nodeId) || getWorkflowEntryNode(nodes)
}, [store])
}, [collaborativeWorkflow])
return {
getNodeById,
@ -489,13 +451,10 @@ export const useNodesReadOnly = () => {
}
export const useIsNodeInIteration = (iterationId: string) => {
const store = useStoreApi()
const collaborativeWorkflow = useCollaborativeWorkflow()
const isNodeInIteration = useCallback((nodeId: string) => {
const {
getNodes,
} = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
const node = nodes.find(node => node.id === nodeId)
if (!node)
@ -505,20 +464,17 @@ export const useIsNodeInIteration = (iterationId: string) => {
return true
return false
}, [iterationId, store])
}, [iterationId, collaborativeWorkflow])
return {
isNodeInIteration,
}
}
export const useIsNodeInLoop = (loopId: string) => {
const store = useStoreApi()
const collaborativeWorkflow = useCollaborativeWorkflow()
const isNodeInLoop = useCallback((nodeId: string) => {
const {
getNodes,
} = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
const node = nodes.find(node => node.id === nodeId)
if (!node)
@ -528,7 +484,7 @@ export const useIsNodeInLoop = (loopId: string) => {
return true
return false
}, [loopId, store])
}, [loopId, collaborativeWorkflow])
return {
isNodeInLoop,
}

View File

@ -2,6 +2,7 @@
import type { FC } from 'react'
import {
Fragment,
memo,
useCallback,
useEffect,
@ -9,6 +10,7 @@ import {
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { setAutoFreeze } from 'immer'
import {
useEventListener,
@ -66,11 +68,15 @@ import CustomEdge from './custom-edge'
import CustomConnectionLine from './custom-connection-line'
import HelpLine from './help-line'
import CandidateNode from './candidate-node'
import CommentManager from './comment-manager'
import PanelContextmenu from './panel-contextmenu'
import NodeContextmenu from './node-contextmenu'
import SelectionContextmenu from './selection-contextmenu'
import SyncingDataModal from './syncing-data-modal'
import { setupScrollToNodeListener } from './utils/node-navigation'
import { CommentCursor, CommentIcon, CommentInput, CommentThread } from './comment'
import { useWorkflowComment } from './hooks/use-workflow-comment'
import UserCursors from './collaboration/components/user-cursors'
import {
useStore,
useWorkflowStore,
@ -120,6 +126,9 @@ export type WorkflowProps = {
viewport?: Viewport
children?: React.ReactNode
onWorkflowDataUpdate?: (v: any) => void
cursors?: Record<string, any>
myUserId?: string | null
onlineUsers?: any[]
}
export const Workflow: FC<WorkflowProps> = memo(({
nodes: originalNodes,
@ -127,10 +136,14 @@ export const Workflow: FC<WorkflowProps> = memo(({
viewport,
children,
onWorkflowDataUpdate,
cursors,
myUserId,
onlineUsers,
}) => {
const workflowContainerRef = useRef<HTMLDivElement>(null)
const workflowStore = useWorkflowStore()
const reactflow = useReactFlow()
const [isMouseOverCanvas, setIsMouseOverCanvas] = useState(false)
const [nodes, setNodes] = useNodesState(originalNodes)
const [edges, setEdges] = useEdgesState(originalEdges)
const controlMode = useStore(s => s.controlMode)
@ -175,6 +188,29 @@ export const Workflow: FC<WorkflowProps> = memo(({
const { workflowReadOnly } = useWorkflowReadOnly()
const { nodesReadOnly } = useNodesReadOnly()
const { eventEmitter } = useEventEmitterContextContext()
const {
comments,
pendingComment,
activeComment,
activeCommentLoading,
replySubmitting,
replyUpdating,
handleCommentSubmit,
handleCommentCancel,
handleCommentIconClick,
handleActiveCommentClose,
handleCommentResolve,
handleCommentDelete,
handleCommentNavigate,
handleCommentReply,
handleCommentReplyUpdate,
handleCommentReplyDelete,
handleCommentPositionUpdate,
} = useWorkflowComment()
const showUserComments = useStore(s => s.showUserComments)
const showUserCursors = useStore(s => s.showUserCursors)
const isCommentPreviewHovering = useStore(s => s.isCommentPreviewHovering)
const { t } = useTranslation()
eventEmitter?.useSubscription((v: any) => {
if (v.type === WORKFLOW_DATA_UPDATE) {
@ -215,6 +251,33 @@ export const Workflow: FC<WorkflowProps> = memo(({
setTimeout(() => handleRefreshWorkflowDraft(), 500)
}, [syncWorkflowDraftWhenPageClose, handleRefreshWorkflowDraft])
// Optimized comment deletion using showConfirm
const handleCommentDeleteClick = useCallback((commentId: string) => {
if (!showConfirm) {
setShowConfirm({
title: t('workflow.comments.confirm.deleteThreadTitle'),
desc: t('workflow.comments.confirm.deleteThreadDesc'),
onConfirm: async () => {
await handleCommentDelete(commentId)
setShowConfirm(undefined)
},
})
}
}, [showConfirm, setShowConfirm, handleCommentDelete, t])
const handleCommentReplyDeleteClick = useCallback((commentId: string, replyId: string) => {
if (!showConfirm) {
setShowConfirm({
title: t('workflow.comments.confirm.deleteReplyTitle'),
desc: t('workflow.comments.confirm.deleteReplyDesc'),
onConfirm: async () => {
await handleCommentReplyDelete(commentId, replyId)
setShowConfirm(undefined)
},
})
}
}, [showConfirm, setShowConfirm, handleCommentReplyDelete, t])
useEffect(() => {
document.addEventListener('visibilitychange', handleSyncWorkflowDraftWhenPageClose)
@ -245,6 +308,9 @@ export const Workflow: FC<WorkflowProps> = memo(({
elementY: e.clientY - containerClientRect.top,
},
})
const target = e.target as HTMLElement
const onPane = !!target?.closest('.react-flow__pane')
setIsMouseOverCanvas(onPane)
}
})
@ -345,7 +411,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
<div
id='workflow-container'
className={cn(
'relative h-full w-full min-w-[960px]',
'relative h-full w-full min-w-[960px] overflow-hidden',
workflowReadOnly && 'workflow-panel-animation',
nodeAnimation && 'workflow-node-animation',
)}
@ -353,8 +419,9 @@ export const Workflow: FC<WorkflowProps> = memo(({
>
<SyncingDataModal />
<CandidateNode />
<CommentManager />
<div
className='pointer-events-none absolute left-0 top-0 z-10 flex w-12 items-center justify-center p-1 pl-2'
className='pointer-events-none absolute left-0 top-0 z-[65] flex w-12 items-center justify-center p-1 pl-2'
style={{ height: controlHeight }}
>
<Control />
@ -364,23 +431,81 @@ export const Workflow: FC<WorkflowProps> = memo(({
<NodeContextmenu />
<SelectionContextmenu />
<HelpLine />
{
!!showConfirm && (
<Confirm
isShow
onCancel={() => setShowConfirm(undefined)}
onConfirm={showConfirm.onConfirm}
title={showConfirm.title}
content={showConfirm.desc}
{!!showConfirm && (
<Confirm
isShow
onCancel={() => setShowConfirm(undefined)}
onConfirm={showConfirm.onConfirm}
title={showConfirm.title}
content={showConfirm.desc}
/>
)}
{controlMode === ControlMode.Comment && isMouseOverCanvas && (
<CommentCursor />
)}
{pendingComment && (
<CommentInput
position={{
x: pendingComment.elementX,
y: pendingComment.elementY,
}}
onSubmit={handleCommentSubmit}
onCancel={handleCommentCancel}
/>
)}
{comments.map((comment, index) => {
const isActive = activeComment?.id === comment.id
if (isActive && activeComment) {
const canGoPrev = index > 0
const canGoNext = index < comments.length - 1
return (
<Fragment key={comment.id}>
<CommentIcon
key={`${comment.id}-icon`}
comment={comment}
onClick={() => handleCommentIconClick(comment)}
isActive={true}
onPositionUpdate={position => handleCommentPositionUpdate(comment.id, position)}
/>
<CommentThread
key={`${comment.id}-thread`}
comment={activeComment}
loading={activeCommentLoading}
replySubmitting={replySubmitting}
replyUpdating={replyUpdating}
onClose={handleActiveCommentClose}
onResolve={() => handleCommentResolve(comment.id)}
onDelete={() => handleCommentDeleteClick(comment.id)}
onPrev={canGoPrev ? () => handleCommentNavigate('prev') : undefined}
onNext={canGoNext ? () => handleCommentNavigate('next') : undefined}
onReply={(content, ids) => handleCommentReply(comment.id, content, ids ?? [])}
onReplyEdit={(replyId, content, ids) => handleCommentReplyUpdate(comment.id, replyId, content, ids ?? [])}
onReplyDelete={replyId => handleCommentReplyDeleteClick(comment.id, replyId)}
onReplyDeleteDirect={replyId => handleCommentReplyDelete(comment.id, replyId)}
canGoPrev={canGoPrev}
canGoNext={canGoNext}
/>
</Fragment>
)
}
return (showUserComments || controlMode === ControlMode.Comment) ? (
<CommentIcon
key={comment.id}
comment={comment}
onClick={() => handleCommentIconClick(comment)}
onPositionUpdate={position => handleCommentPositionUpdate(comment.id, position)}
/>
)
}
) : null
})}
{children}
<ReactFlow
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodes={nodes}
edges={edges}
className={controlMode === ControlMode.Comment ? 'comment-mode-flow' : ''}
onNodeDragStart={handleNodeDragStart}
onNodeDrag={handleNodeDrag}
onNodeDragStop={handleNodeDragStop}
@ -405,13 +530,13 @@ export const Workflow: FC<WorkflowProps> = memo(({
defaultViewport={viewport}
multiSelectionKeyCode={null}
deleteKeyCode={null}
nodesDraggable={!nodesReadOnly}
nodesDraggable={!nodesReadOnly && controlMode !== ControlMode.Comment}
nodesConnectable={!nodesReadOnly}
nodesFocusable={!nodesReadOnly}
edgesFocusable={!nodesReadOnly}
panOnScroll={false}
panOnDrag={controlMode === ControlMode.Hand}
zoomOnPinch={true}
zoomOnPinch={!isCommentPreviewHovering}
zoomOnScroll={true}
zoomOnDoubleClick={true}
isValidConnection={isValidConnection}
@ -426,6 +551,13 @@ export const Workflow: FC<WorkflowProps> = memo(({
className="bg-workflow-canvas-workflow-bg"
color='var(--color-workflow-canvas-workflow-dot-color)'
/>
{showUserCursors && cursors && (
<UserCursors
cursors={cursors}
myUserId={myUserId || null}
onlineUsers={onlineUsers || []}
/>
)}
</ReactFlow>
</div>
)
@ -433,14 +565,25 @@ export const Workflow: FC<WorkflowProps> = memo(({
type WorkflowWithInnerContextProps = WorkflowProps & {
hooksStore?: Partial<HooksStoreShape>
cursors?: Record<string, any>
myUserId?: string | null
onlineUsers?: any[]
}
export const WorkflowWithInnerContext = memo(({
hooksStore,
cursors,
myUserId,
onlineUsers,
...restProps
}: WorkflowWithInnerContextProps) => {
return (
<HooksStoreContextProvider {...hooksStore}>
<Workflow {...restProps} />
<Workflow
{...restProps}
cursors={cursors}
myUserId={myUserId}
onlineUsers={onlineUsers}
/>
</HooksStoreContextProvider>
)
})

View File

@ -30,8 +30,15 @@ export const useDefaultValue = (
const index = default_value.findIndex(form => form.key === key)
if (index > -1) {
const newDefaultValue = [...default_value]
newDefaultValue[index].value = value
const newDefaultValue = default_value.map((form) => {
if (form.key !== key)
return form
// clone the entry so we do not mutate the original reference (which would block CRDT diffs)
return {
...form,
value,
}
})
handleNodeDataUpdateWithSyncDraft({
id,
data: {

View File

@ -1,6 +1,7 @@
import {
memo,
useCallback,
useEffect,
useState,
} from 'react'
import Textarea from 'react-textarea-autosize'
@ -28,10 +29,27 @@ export const TitleInput = memo(({
onBlur(localValue)
}
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setLocalValue(e.target.value)
}, [])
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
;(e.target as HTMLInputElement).blur()
}
}, [])
// Sync local state with incoming collaborative updates so remote title edits appear immediately.
useEffect(() => {
setLocalValue(value)
}, [value])
return (
<input
value={localValue}
onChange={e => setLocalValue(e.target.value)}
onChange={handleChange}
onKeyDown={handleKeyDown}
className={`
system-xl-semibold mr-2 h-7 min-w-0 grow appearance-none rounded-md border border-transparent bg-transparent px-1 text-text-primary
outline-none focus:shadow-xs

View File

@ -1,14 +1,6 @@
import { useStore as useAppStore } from '@/app/components/app/store'
import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import Tooltip from '@/app/components/base/tooltip'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import {
AuthCategory,
AuthorizedInDataSourceNode,
AuthorizedInNode,
PluginAuth,
PluginAuthInDataSourceNode,
} from '@/app/components/plugins/plugin-auth'
import { usePluginStore } from '@/app/components/plugins/plugin-detail-panel/store'
import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
@ -23,14 +15,7 @@ import {
useToolIcon,
useWorkflowHistory,
} from '@/app/components/workflow/hooks'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form'
import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types'
import { DataSourceClassification } from '@/app/components/workflow/nodes/data-source/types'
import { useLogs } from '@/app/components/workflow/run/hooks'
import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel'
import { useStore } from '@/app/components/workflow/store'
import { BlockEnum, type Node, NodeRunningStatus } from '@/app/components/workflow/types'
import {
@ -39,18 +24,14 @@ import {
hasRetryNode,
isSupportCustomRunForm,
} from '@/app/components/workflow/utils'
import { useModalContext } from '@/context/modal-context'
import { useAllBuiltInTools } from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { FlowType } from '@/types/common'
import { canFindTool } from '@/utils'
import cn from '@/utils/classnames'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
RiCloseLine,
RiPlayLargeLine,
} from '@remixicon/react'
import { debounce } from 'lodash-es'
import type { FC, ReactNode } from 'react'
import React, {
cloneElement,
@ -64,8 +45,6 @@ import React, {
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { useResizePanel } from '../../hooks/use-resize-panel'
import BeforeRunForm from '../before-run-form'
import PanelWrap from '../before-run-form/panel-wrap'
import ErrorHandleOnPanel from '../error-handle/error-handle-on-panel'
import HelpLink from '../help-link'
import NextStep from '../next-step'
@ -76,6 +55,31 @@ import LastRun from './last-run'
import useLastRun from './last-run/use-last-run'
import Tab, { TabType } from './tab'
import { TriggerSubscription } from './trigger-subscription'
import BeforeRunForm from '../before-run-form'
import { debounce } from 'lodash-es'
import { useLogs } from '@/app/components/workflow/run/hooks'
import PanelWrap from '../before-run-form/panel-wrap'
import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel'
import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import { FlowType } from '@/types/common'
import {
AuthorizedInDataSourceNode,
AuthorizedInNode,
PluginAuth,
PluginAuthInDataSourceNode,
} from '@/app/components/plugins/plugin-auth'
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
import { canFindTool } from '@/utils'
import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types'
import { DataSourceClassification } from '@/app/components/workflow/nodes/data-source/types'
import { useModalContext } from '@/context/modal-context'
import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form'
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { useAppContext } from '@/context/app-context'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
const getCustomRunForm = (params: CustomRunFormProps): React.JSX.Element => {
const nodeType = params.payload.type
@ -100,11 +104,51 @@ const BasePanel: FC<BasePanelProps> = ({
}) => {
const { t } = useTranslation()
const language = useLanguage()
const appId = useStore(s => s.appId)
const { userProfile } = useAppContext()
const { isConnected, nodePanelPresence } = useCollaboration(appId as string)
const { showMessageLogModal } = useAppStore(useShallow(state => ({
showMessageLogModal: state.showMessageLogModal,
})))
const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running
const currentUserPresence = useMemo(() => {
const userId = userProfile?.id || ''
const username = userProfile?.name || userProfile?.email || 'User'
const avatar = userProfile?.avatar_url || userProfile?.avatar || null
return {
userId,
username,
avatar,
}
}, [userProfile?.avatar, userProfile?.avatar_url, userProfile?.email, userProfile?.id, userProfile?.name])
useEffect(() => {
if (!isConnected || !currentUserPresence.userId)
return
collaborationManager.emitNodePanelPresence(id, true, currentUserPresence)
return () => {
collaborationManager.emitNodePanelPresence(id, false, currentUserPresence)
}
}, [id, isConnected, currentUserPresence])
const viewingUsers = useMemo(() => {
const presence = nodePanelPresence?.[id]
if (!presence)
return []
return Object.values(presence)
.filter(viewer => viewer.userId && viewer.userId !== currentUserPresence.userId)
.map(viewer => ({
id: viewer.userId,
name: viewer.username,
avatar_url: viewer.avatar || null,
}))
}, [currentUserPresence.userId, id, nodePanelPresence])
const showSingleRunPanel = useStore(s => s.showSingleRunPanel)
const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth)
const nodePanelWidth = useStore(s => s.nodePanelWidth)
@ -475,6 +519,15 @@ const BasePanel: FC<BasePanelProps> = ({
value={data.title || ''}
onBlur={handleTitleBlur}
/>
{viewingUsers.length > 0 && (
<div className='ml-3 shrink-0'>
<UserAvatarList
users={viewingUsers}
maxVisible={3}
size={24}
/>
</div>
)}
<div className='flex shrink-0 items-center text-text-tertiary'>
{
isSupportSingleRun && !nodesReadOnly && (

View File

@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next'
import type { NodeProps } from '@/app/components/workflow/types'
import {
BlockEnum,
ControlMode,
NodeRunningStatus,
isTriggerNode,
} from '@/app/components/workflow/types'
@ -41,8 +42,12 @@ import EntryNodeContainer, { StartNodeTypeEnum } from './components/entry-node-c
import cn from '@/utils/classnames'
import BlockIcon from '@/app/components/workflow/block-icon'
import Tooltip from '@/app/components/base/tooltip'
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
import { ToolTypeEnum } from '@/app/components/workflow/block-selector/types'
import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud'
import { ToolTypeEnum } from '../../block-selector/types'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { useAppContext } from '@/context/app-context'
import { useStore } from '@/app/components/workflow/store'
import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration'
type NodeChildProps = {
id: string
@ -67,6 +72,36 @@ const BaseNode: FC<BaseNodeProps> = ({
const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions()
const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions()
const toolIcon = useToolIcon(data)
const { userProfile } = useAppContext()
const appId = useStore(s => s.appId)
const { nodePanelPresence } = useCollaboration(appId as string)
const controlMode = useStore(s => s.controlMode)
const currentUserPresence = useMemo(() => {
const userId = userProfile?.id || ''
const username = userProfile?.name || userProfile?.email || 'User'
const avatar = userProfile?.avatar_url || userProfile?.avatar || null
return {
userId,
username,
avatar,
}
}, [userProfile?.avatar, userProfile?.avatar_url, userProfile?.email, userProfile?.id, userProfile?.name])
const viewingUsers = useMemo(() => {
const presence = nodePanelPresence?.[id]
if (!presence)
return []
return Object.values(presence)
.filter(viewer => viewer.userId && viewer.userId !== currentUserPresence.userId)
.map(viewer => ({
id: viewer.userId,
name: viewer.username,
avatar_url: viewer.avatar || null,
}))
}, [currentUserPresence.userId, id, nodePanelPresence])
useEffect(() => {
if (nodeRef.current && data.selected && data.isInIteration) {
@ -176,6 +211,7 @@ const BaseNode: FC<BaseNodeProps> = ({
className={cn(
'group relative pb-1 shadow-xs',
'rounded-[15px] border border-transparent',
(controlMode === ControlMode.Comment) && 'hover:cursor-none',
(data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop) && 'w-[240px] bg-workflow-block-bg',
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'flex h-full w-full flex-col border-workflow-block-border bg-workflow-block-bg-transparent',
!data._runningStatus && 'hover:shadow-lg',
@ -250,7 +286,7 @@ const BaseNode: FC<BaseNodeProps> = ({
/>
<div
title={data.title}
className='system-sm-semibold-uppercase mr-1 flex grow items-center truncate text-text-primary'
className='system-sm-semibold-uppercase mr-1 flex grow items-center justify-between truncate text-text-primary'
>
<div>
{data.title}
@ -271,6 +307,15 @@ const BaseNode: FC<BaseNodeProps> = ({
</Tooltip>
)
}
{viewingUsers.length > 0 && (
<div className='ml-3 shrink-0'>
<UserAvatarList
users={viewingUsers}
maxVisible={3}
size={24}
/>
</div>
)}
</div>
{
data._iterationLength && data._iterationIndex && data._runningStatus === NodeRunningStatus.Running && (

View File

@ -31,7 +31,7 @@ export const extractReturnType = (code: string, language: CodeLanguage): OutputV
if (returnIndex === -1)
return {}
// return から始まる部分文字列を取得
// Extract substring starting from return statement
const codeAfterReturn = codeWithoutComments.slice(returnIndex)
let bracketCount = 0

View File

@ -15,30 +15,48 @@ const strToKeyValueList = (value: string) => {
})
}
const normalizeList = (items: KeyValue[]) => {
return items.map(item => ({
...item,
id: item.id || uniqueId(UNIQUE_ID_PREFIX),
}))
}
const stringifyList = (items: KeyValue[], noFilter?: boolean) => {
const source = noFilter ? items : items.filter(item => item.key && item.value)
return source.map(item => `${item.key}:${item.value}`).join('\n')
}
const useKeyValueList = (value: string, onChange: (value: string) => void, noFilter?: boolean) => {
const [list, doSetList] = useState<KeyValue[]>(() => value ? strToKeyValueList(value) : [])
const setList = (l: KeyValue[]) => {
doSetList(l.map((item) => {
return {
...item,
id: item.id || uniqueId(UNIQUE_ID_PREFIX),
}
}))
}
useEffect(() => {
const setList = useCallback((nextList: KeyValue[]) => {
const normalized = normalizeList(nextList)
doSetList(normalized)
if (noFilter)
return
const newValue = list.filter(item => item.key && item.value).map(item => `${item.key}:${item.value}`).join('\n')
const newValue = stringifyList(normalized, noFilter)
if (newValue !== value)
onChange(newValue)
}, [list, noFilter])
}, [noFilter, onChange, value])
useEffect(() => {
doSetList((prev) => {
const targetItems = value ? strToKeyValueList(value) : []
const currentValue = stringifyList(prev, noFilter)
const targetValue = stringifyList(targetItems, noFilter)
if (currentValue === targetValue)
return prev
return normalizeList(targetItems)
})
}, [value, noFilter])
const addItem = useCallback(() => {
setList([...list, {
id: uniqueId(UNIQUE_ID_PREFIX),
key: '',
value: '',
}])
}, [list])
}, [list, setList])
const [isKeyValueEdit, {
toggle: toggleIsKeyValueEdit,

View File

@ -60,7 +60,7 @@ const Node: FC<NodeProps<IterationNodeType>> = ({
)
}
{
data._children!.length === 1 && (
data._children?.length === 1 && (
<AddBlock
iterationNodeId={id}
iterationNodeData={data}

View File

@ -1,7 +1,6 @@
import { useCallback } from 'react'
import { produce } from 'immer'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import type {
BlockEnum,
ChildNodeTypeCount,
@ -16,21 +15,20 @@ import {
} from '../../constants'
import { CUSTOM_ITERATION_START_NODE } from '../iteration-start/constants'
import { useNodesMetaData } from '@/app/components/workflow/hooks'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
export const useNodeIterationInteractions = () => {
const { t } = useTranslation()
const store = useStoreApi()
const { nodesMap: nodesMetaDataMap } = useNodesMetaData()
const collaborativeWorkflow = useCollaborativeWorkflow()
const handleNodeIterationRerender = useCallback((nodeId: string) => {
const {
getNodes,
setNodes,
} = store.getState()
const { nodes, setNodes } = collaborativeWorkflow.getState()
const nodes = getNodes()
const currentNode = nodes.find(n => n.id === nodeId)!
const childrenNodes = nodes.filter(n => n.parentId === nodeId)
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_ITERATION_START_NODE)
if (!childrenNodes.length)
return
let rightNode: Node
let bottomNode: Node
@ -72,11 +70,10 @@ export const useNodeIterationInteractions = () => {
setNodes(newNodes)
}
}, [store])
}, [collaborativeWorkflow])
const handleNodeIterationChildDrag = useCallback((node: Node) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
const restrictPosition: { x?: number; y?: number } = { x: undefined, y: undefined }
@ -98,21 +95,19 @@ export const useNodeIterationInteractions = () => {
return {
restrictPosition,
}
}, [store])
}, [collaborativeWorkflow])
const handleNodeIterationChildSizeChange = useCallback((nodeId: string) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
const currentNode = nodes.find(n => n.id === nodeId)!
const parentId = currentNode.parentId
if (parentId)
handleNodeIterationRerender(parentId)
}, [store, handleNodeIterationRerender])
}, [collaborativeWorkflow, handleNodeIterationRerender])
const handleNodeIterationChildrenCopy = useCallback((nodeId: string, newNodeId: string, idMapping: Record<string, string>) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_ITERATION_START_NODE)
const newIdMapping = { ...idMapping }
const childNodeTypeCount: ChildNodeTypeCount = {}
@ -154,7 +149,7 @@ export const useNodeIterationInteractions = () => {
copyChildren,
newIdMapping,
}
}, [store, t])
}, [collaborativeWorkflow, t])
return {
handleNodeIterationRerender,

View File

@ -215,21 +215,39 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => {
const [selectedDatasetsLoaded, setSelectedDatasetsLoaded] = useState(false)
// datasets
// Fetch details whenever dataset IDs change so create/delete stays consistent across collaborators.
useEffect(() => {
(async () => {
const inputs = inputRef.current
const datasetIds = inputs.dataset_ids
if (datasetIds?.length > 0) {
const { data: dataSetsWithDetail } = await fetchDatasets({ url: '/datasets', params: { page: 1, ids: datasetIds } as any })
setSelectedDatasets(dataSetsWithDetail)
let aborted = false
const datasetIds = inputs.dataset_ids
const loadDatasets = async () => {
if (!datasetIds || datasetIds.length === 0) {
if (!aborted) {
setSelectedDatasets([])
setSelectedDatasetsLoaded(true)
}
return
}
const newInputs = produce(inputs, (draft) => {
draft.dataset_ids = datasetIds
})
setInputs(newInputs)
setSelectedDatasetsLoaded(true)
})()
}, [])
setSelectedDatasetsLoaded(false)
try {
const { data: dataSetsWithDetail } = await fetchDatasets({
url: '/datasets',
params: { page: 1, ids: datasetIds } as any,
})
if (aborted)
return
setSelectedDatasets(dataSetsWithDetail)
updateDatasetsDetail(dataSetsWithDetail)
}
finally {
if (!aborted)
setSelectedDatasetsLoaded(true)
}
}
loadDatasets()
return () => {
aborted = true
}
}, [inputs.dataset_ids, updateDatasetsDetail])
useEffect(() => {
const inputs = inputRef.current

View File

@ -41,6 +41,9 @@ const FormItem = ({
}: FormItemProps) => {
const { t } = useTranslation()
const { value_type, var_type, value } = item
const normalizedVarValue = useMemo(() => {
return Array.isArray(value) ? value : []
}, [value])
const handleInputChange = useCallback((e: any) => {
onChange(e.target.value)
@ -79,7 +82,7 @@ const FormItem = ({
readonly={false}
nodeId={nodeId}
isShowNodeName
value={value}
value={normalizedVarValue}
onChange={handleChange}
filterVar={filterVar}
placeholder={t('workflow.nodes.assigner.setParameter') as string}

View File

@ -46,7 +46,7 @@ const Node: FC<NodeProps<LoopNodeType>> = ({
)
}
{
data._children!.length === 1 && (
data._children?.length === 1 && (
<AddBlock
loopNodeId={id}
loopNodeData={data}

View File

@ -1,5 +1,6 @@
import {
useCallback,
useEffect,
useRef,
} from 'react'
import { produce } from 'immer'
@ -41,6 +42,9 @@ const useConfig = (id: string, payload: LoopNodeType) => {
const { inputs, setInputs } = useNodeCrud<LoopNodeType>(id, payload)
const inputsRef = useRef(inputs)
useEffect(() => {
inputsRef.current = inputs
}, [inputs])
const handleInputsChange = useCallback((newInputs: LoopNodeType) => {
inputsRef.current = newInputs
setInputs(newInputs)

View File

@ -1,6 +1,5 @@
import { useCallback } from 'react'
import { produce } from 'immer'
import { useStoreApi } from 'reactflow'
import type {
BlockEnum,
Node,
@ -14,20 +13,18 @@ import {
} from '../../constants'
import { CUSTOM_LOOP_START_NODE } from '../loop-start/constants'
import { useNodesMetaData } from '@/app/components/workflow/hooks'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
export const useNodeLoopInteractions = () => {
const store = useStoreApi()
const collaborativeWorkflow = useCollaborativeWorkflow()
const { nodesMap: nodesMetaDataMap } = useNodesMetaData()
const handleNodeLoopRerender = useCallback((nodeId: string) => {
const {
getNodes,
setNodes,
} = store.getState()
const nodes = getNodes()
const { nodes, setNodes } = collaborativeWorkflow.getState()
const currentNode = nodes.find(n => n.id === nodeId)!
const childrenNodes = nodes.filter(n => n.parentId === nodeId)
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_LOOP_START_NODE)
if (!childrenNodes.length)
return
let rightNode: Node
let bottomNode: Node
@ -69,11 +66,10 @@ export const useNodeLoopInteractions = () => {
setNodes(newNodes)
}
}, [store])
}, [collaborativeWorkflow])
const handleNodeLoopChildDrag = useCallback((node: Node) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
const restrictPosition: { x?: number; y?: number } = { x: undefined, y: undefined }
@ -95,21 +91,19 @@ export const useNodeLoopInteractions = () => {
return {
restrictPosition,
}
}, [store])
}, [collaborativeWorkflow])
const handleNodeLoopChildSizeChange = useCallback((nodeId: string) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
const currentNode = nodes.find(n => n.id === nodeId)!
const parentId = currentNode.parentId
if (parentId)
handleNodeLoopRerender(parentId)
}, [store, handleNodeLoopRerender])
}, [collaborativeWorkflow, handleNodeLoopRerender])
const handleNodeLoopChildrenCopy = useCallback((nodeId: string, newNodeId: string) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_LOOP_START_NODE)
return childrenNodes.map((child, index) => {
@ -140,7 +134,7 @@ export const useNodeLoopInteractions = () => {
newNode.id = `${newNodeId}${newNode.id + index}`
return newNode
})
}, [store, nodesMetaDataMap])
}, [collaborativeWorkflow, nodesMetaDataMap])
return {
handleNodeLoopRerender,

View File

@ -3,22 +3,73 @@
import {
createContext,
memo,
useEffect,
useRef,
} from 'react'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { LinkNode } from '@lexical/link'
import {
ListItemNode,
ListNode,
} from '@lexical/list'
import { $getRoot } from 'lexical'
import { createNoteEditorStore } from './store'
import theme from './theme'
const NoteEditorContentSynchronizer = ({ value }: { value?: string }) => {
const [editor] = useLexicalComposerContext()
const lastSyncedValueRef = useRef<string | null>(null)
useEffect(() => {
const normalizedValue = normalizeEditorState(value)
if (normalizedValue === lastSyncedValueRef.current)
return
const currentSerializedState = JSON.stringify(editor.getEditorState().toJSON())
if (normalizedValue === currentSerializedState) {
lastSyncedValueRef.current = normalizedValue
return
}
if (!normalizedValue) {
let hasContent = false
editor.getEditorState().read(() => {
hasContent = !$getRoot().isEmpty()
})
if (!hasContent) {
lastSyncedValueRef.current = normalizedValue
return
}
editor.update(() => {
const root = $getRoot()
root.clear()
root.select()
})
lastSyncedValueRef.current = normalizedValue
return
}
try {
const nextState = editor.parseEditorState(normalizedValue)
editor.setEditorState(nextState)
lastSyncedValueRef.current = normalizedValue
}
catch {
lastSyncedValueRef.current = ''
}
}, [editor, value])
return null
}
type NoteEditorStore = ReturnType<typeof createNoteEditorStore>
const NoteEditorContext = createContext<NoteEditorStore | null>(null)
type NoteEditorContextProviderProps = {
value: string
value?: string
children: React.JSX.Element | string | (React.JSX.Element | string)[]
editable?: boolean
}
@ -34,7 +85,8 @@ export const NoteEditorContextProvider = memo(({
let initialValue = null
try {
initialValue = JSON.parse(value)
if (value)
initialValue = JSON.parse(value)
}
catch {
@ -58,6 +110,7 @@ export const NoteEditorContextProvider = memo(({
return (
<NoteEditorContext.Provider value={storeRef.current}>
<LexicalComposer initialConfig={{ ...initialConfig }}>
<NoteEditorContentSynchronizer value={value} />
{children}
</LexicalComposer>
</NoteEditorContext.Provider>
@ -66,3 +119,19 @@ export const NoteEditorContextProvider = memo(({
NoteEditorContextProvider.displayName = 'NoteEditorContextProvider'
export default NoteEditorContext
function normalizeEditorState(value?: string): string {
if (!value)
return ''
try {
const parsed = JSON.parse(value)
if (!parsed || typeof parsed !== 'object' || !parsed.root)
return ''
return JSON.stringify(parsed)
}
catch {
return ''
}
}

View File

@ -11,6 +11,7 @@ import {
RiHand,
RiStickyNoteAddLine,
} from '@remixicon/react'
import { Comment } from '@/app/components/base/icons/src/public/other'
import {
useNodesReadOnly,
useWorkflowCanvasMaximize,
@ -32,7 +33,12 @@ const Control = () => {
const { t } = useTranslation()
const controlMode = useStore(s => s.controlMode)
const maximizeCanvas = useStore(s => s.maximizeCanvas)
const { handleModePointer, handleModeHand } = useWorkflowMoveMode()
const {
handleModePointer,
handleModeHand,
handleModeComment,
isCommentModeAvailable,
} = useWorkflowMoveMode()
const { handleLayout } = useWorkflowOrganize()
const { handleAddNote } = useOperator()
const {
@ -88,6 +94,20 @@ const Control = () => {
<RiHand className='h-4 w-4' />
</div>
</TipPopup>
{isCommentModeAvailable && (
<TipPopup title={t('workflow.common.commentMode')} shortcuts={['c']}>
<div
className={cn(
'ml-[1px] flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg',
controlMode === ControlMode.Comment ? 'bg-state-accent-active text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary',
`${nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`,
)}
onClick={handleModeComment}
>
<Comment className='h-4 w-4' />
</div>
</TipPopup>
)}
<Divider className='my-1 w-3.5' />
<TipPopup title={t('workflow.panel.organizeBlocks')} shortcuts={['ctrl', 'o']}>
<div

View File

@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Node } from 'reactflow'
import { MiniMap } from 'reactflow'
import UndoRedo from '../header/undo-redo'
@ -6,6 +6,7 @@ import ZoomInOut from './zoom-in-out'
import VariableTrigger from '../variable-inspect/trigger'
import VariableInspectPanel from '../variable-inspect'
import { useStore } from '../store'
import { ControlMode } from '../types'
export type OperatorProps = {
handleUndo: () => void
@ -14,6 +15,26 @@ export type OperatorProps = {
const Operator = ({ handleUndo, handleRedo }: OperatorProps) => {
const bottomPanelRef = useRef<HTMLDivElement>(null)
const [showMiniMap, setShowMiniMap] = useState(true)
const showUserCursors = useStore(s => s.showUserCursors)
const setShowUserCursors = useStore(s => s.setShowUserCursors)
const showUserComments = useStore(s => s.showUserComments)
const setShowUserComments = useStore(s => s.setShowUserComments)
const controlMode = useStore(s => s.controlMode)
const isCommentMode = controlMode === ControlMode.Comment
const handleToggleMiniMap = useCallback(() => {
setShowMiniMap(prev => !prev)
}, [])
const handleToggleUserCursors = useCallback(() => {
setShowUserCursors(!showUserCursors)
}, [showUserCursors, setShowUserCursors])
const handleToggleUserComments = useCallback(() => {
setShowUserComments(!showUserComments)
}, [showUserComments, setShowUserComments])
const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth)
const rightPanelWidth = useStore(s => s.rightPanelWidth)
const setBottomPanelWidth = useStore(s => s.setBottomPanelWidth)
@ -51,7 +72,7 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => {
return (
<div
ref={bottomPanelRef}
className='absolute bottom-0 left-0 right-0 z-10 px-1'
className='absolute bottom-0 left-0 right-0 z-[60] px-1'
style={
{
width: bottomPanelWidth,
@ -64,20 +85,30 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => {
</div>
<VariableTrigger />
<div className='relative'>
<MiniMap
pannable
zoomable
style={{
width: 102,
height: 72,
}}
maskColor='var(--color-workflow-minimap-bg)'
nodeClassName={getMiniMapNodeClassName}
nodeStrokeWidth={3}
className='!absolute !bottom-10 z-[9] !m-0 !h-[73px] !w-[103px] !rounded-lg !border-[0.5px]
!border-divider-subtle !bg-background-default-subtle !shadow-md !shadow-shadow-shadow-5'
{showMiniMap && (
<MiniMap
pannable
zoomable
style={{
width: 102,
height: 72,
}}
maskColor='var(--color-workflow-minimap-bg)'
nodeClassName={getMiniMapNodeClassName}
nodeStrokeWidth={3}
className='!absolute !bottom-10 z-[9] !m-0 !h-[73px] !w-[103px] !rounded-lg !border-[0.5px]
!border-divider-subtle !bg-background-default-subtle !shadow-md !shadow-shadow-shadow-5'
/>
)}
<ZoomInOut
showMiniMap={showMiniMap}
onToggleMiniMap={handleToggleMiniMap}
showUserCursors={showUserCursors}
onToggleUserCursors={handleToggleUserCursors}
showUserComments={showUserComments}
onToggleUserComments={handleToggleUserComments}
isCommentMode={isCommentMode}
/>
<ZoomInOut />
</div>
</div>
<VariableInspectPanel />

View File

@ -6,6 +6,8 @@ import {
useState,
} from 'react'
import {
RiCheckLine,
RiFullscreenLine,
RiZoomInLine,
RiZoomOutLine,
} from '@remixicon/react'
@ -28,6 +30,7 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useGlobalPublicStore } from '@/context/global-public-context'
enum ZoomType {
zoomIn = 'zoomIn',
@ -38,9 +41,30 @@ enum ZoomType {
zoomTo75 = 'zoomTo75',
zoomTo100 = 'zoomTo100',
zoomTo200 = 'zoomTo200',
toggleUserComments = 'toggleUserComments',
toggleUserCursors = 'toggleUserCursors',
toggleMiniMap = 'toggleMiniMap',
}
const ZoomInOut: FC = () => {
type ZoomInOutProps = {
showMiniMap?: boolean
onToggleMiniMap?: () => void
showUserCursors?: boolean
onToggleUserCursors?: () => void
showUserComments?: boolean
onToggleUserComments?: () => void
isCommentMode?: boolean
}
const ZoomInOut: FC<ZoomInOutProps> = ({
showMiniMap = true,
onToggleMiniMap,
showUserCursors = true,
onToggleUserCursors,
showUserComments = true,
onToggleUserComments,
isCommentMode = false,
}) => {
const { t } = useTranslation()
const {
zoomIn,
@ -55,6 +79,7 @@ const ZoomInOut: FC = () => {
workflowReadOnly,
getWorkflowReadOnly,
} = useWorkflowReadOnly()
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
const ZOOM_IN_OUT_OPTIONS = [
[
@ -78,13 +103,32 @@ const ZoomInOut: FC = () => {
key: ZoomType.zoomTo25,
text: '25%',
},
],
[
{
key: ZoomType.zoomToFit,
text: t('workflow.operator.zoomToFit'),
},
],
isCollaborationEnabled
? [
{
key: ZoomType.toggleUserComments,
text: t('workflow.operator.showUserComments'),
},
{
key: ZoomType.toggleUserCursors,
text: t('workflow.operator.showUserCursors'),
},
{
key: ZoomType.toggleMiniMap,
text: t('workflow.operator.showMiniMap'),
},
]
: [
{
key: ZoomType.toggleMiniMap,
text: t('workflow.operator.showMiniMap'),
},
],
]
const handleZoom = (type: string) => {
@ -109,6 +153,23 @@ const ZoomInOut: FC = () => {
if (type === ZoomType.zoomTo200)
zoomTo(2)
if (type === ZoomType.toggleUserComments) {
if (!isCommentMode)
onToggleUserComments?.()
return
}
if (type === ZoomType.toggleUserCursors) {
onToggleUserCursors?.()
return
}
if (type === ZoomType.toggleMiniMap) {
onToggleMiniMap?.()
return
}
handleSyncWorkflowDraft()
}
@ -177,8 +238,8 @@ const ZoomInOut: FC = () => {
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='w-[145px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]'>
<PortalToFollowElemContent className='z-[60]'>
<div className='w-[192px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]'>
{
ZOOM_IN_OUT_OPTIONS.map((options, i) => (
<Fragment key={i}>
@ -192,10 +253,43 @@ const ZoomInOut: FC = () => {
options.map(option => (
<div
key={option.key}
className='system-md-regular flex h-8 cursor-pointer items-center justify-between space-x-1 rounded-lg py-1.5 pl-3 pr-2 text-text-secondary hover:bg-state-base-hover'
className={`system-md-regular flex h-8 cursor-pointer items-center justify-between space-x-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover ${
option.key === ZoomType.toggleUserComments && isCommentMode
? 'cursor-not-allowed opacity-50'
: ''
}`}
onClick={() => handleZoom(option.key)}
>
<span>{option.text}</span>
<div className='flex items-center space-x-2'>
{option.key === ZoomType.toggleUserComments && showUserComments && (
<RiCheckLine className='h-4 w-4 text-text-accent' />
)}
{option.key === ZoomType.toggleUserComments && !showUserComments && (
<div className='h-4 w-4' />
)}
{option.key === ZoomType.toggleUserCursors && showUserCursors && (
<RiCheckLine className='h-4 w-4 text-text-accent' />
)}
{option.key === ZoomType.toggleUserCursors && !showUserCursors && (
<div className='h-4 w-4' />
)}
{option.key === ZoomType.toggleMiniMap && showMiniMap && (
<RiCheckLine className='h-4 w-4 text-text-accent' />
)}
{option.key === ZoomType.toggleMiniMap && !showMiniMap && (
<div className='h-4 w-4' />
)}
{option.key === ZoomType.zoomToFit && (
<RiFullscreenLine className='h-4 w-4 text-text-tertiary' />
)}
{option.key !== ZoomType.toggleUserComments
&& option.key !== ZoomType.toggleUserCursors
&& option.key !== ZoomType.toggleMiniMap
&& option.key !== ZoomType.zoomToFit && (
<div className='h-4 w-4' />
)}
<span>{option.text}</span>
</div>
<div className='flex items-center space-x-0.5'>
{
option.key === ZoomType.zoomToFit && (

View File

@ -3,9 +3,6 @@ import {
useCallback,
useState,
} from 'react'
import {
useStoreApi,
} from 'reactflow'
import { RiBookOpenLine, RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/workflow/store'
@ -19,30 +16,24 @@ import type {
ConversationVariable,
} from '@/app/components/workflow/types'
import { findUsedVarNodes, updateNodeVars } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft'
import { BlockEnum } from '@/app/components/workflow/types'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { useDocLink } from '@/context/i18n'
import cn from '@/utils/classnames'
import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud'
import { updateConversationVariables } from '@/service/workflow'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
const ChatVariablePanel = () => {
const { t } = useTranslation()
const docLink = useDocLink()
const store = useStoreApi()
const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel)
const varList = useStore(s => s.conversationVariables) as ConversationVariable[]
const updateChatVarList = useStore(s => s.setConversationVariables)
const { doSyncWorkflowDraft } = useNodesSyncDraft()
const appId = useStore(s => s.appId) as string
const {
invalidateConversationVarValues,
} = useInspectVarsCrud()
const handleVarChanged = useCallback(() => {
doSyncWorkflowDraft(false, {
onSuccess() {
invalidateConversationVarValues()
},
})
}, [doSyncWorkflowDraft, invalidateConversationVarValues])
const [showTip, setShowTip] = useState(true)
const [showVariableModal, setShowVariableModal] = useState(false)
@ -50,40 +41,63 @@ const ChatVariablePanel = () => {
const [showRemoveVarConfirm, setShowRemoveConfirm] = useState(false)
const [cacheForDelete, setCacheForDelete] = useState<ConversationVariable>()
const collaborativeWorkflow = useCollaborativeWorkflow()
const getEffectedNodes = useCallback((chatVar: ConversationVariable) => {
const { getNodes } = store.getState()
const allNodes = getNodes()
const { nodes: allNodes } = collaborativeWorkflow.getState()
return findUsedVarNodes(
['conversation', chatVar.name],
allNodes,
)
}, [store])
}, [collaborativeWorkflow])
const removeUsedVarInNodes = useCallback((chatVar: ConversationVariable) => {
const { getNodes, setNodes } = store.getState()
const { nodes, setNodes } = collaborativeWorkflow.getState()
const effectedNodes = getEffectedNodes(chatVar)
const newNodes = getNodes().map((node) => {
const newNodes = nodes.map((node) => {
if (effectedNodes.find(n => n.id === node.id))
return updateNodeVars(node, ['conversation', chatVar.name], [])
return node
})
setNodes(newNodes)
}, [getEffectedNodes, store])
}, [getEffectedNodes, collaborativeWorkflow])
const handleEdit = (chatVar: ConversationVariable) => {
setCurrentVar(chatVar)
setShowVariableModal(true)
}
const handleDelete = useCallback((chatVar: ConversationVariable) => {
const handleDelete = useCallback(async (chatVar: ConversationVariable) => {
removeUsedVarInNodes(chatVar)
updateChatVarList(varList.filter(v => v.id !== chatVar.id))
const newVarList = varList.filter(v => v.id !== chatVar.id)
updateChatVarList(newVarList)
setCacheForDelete(undefined)
setShowRemoveConfirm(false)
handleVarChanged()
}, [handleVarChanged, removeUsedVarInNodes, updateChatVarList, varList])
// Use new dedicated conversation variables API instead of workflow draft sync
try {
await updateConversationVariables({
appId,
conversationVariables: newVarList,
})
// Emit update event to other connected clients
const socket = webSocketClient.getSocket(appId)
if (socket) {
socket.emit('collaboration_event', {
type: 'vars_and_features_update',
})
}
invalidateConversationVarValues()
}
catch (error) {
console.error('Failed to update conversation variables:', error)
// Revert local state on error
updateChatVarList(varList)
}
}, [removeUsedVarInNodes, updateChatVarList, varList, appId, invalidateConversationVarValues])
const deleteCheck = useCallback((chatVar: ConversationVariable) => {
const effectedNodes = getEffectedNodes(chatVar)
@ -97,21 +111,46 @@ const ChatVariablePanel = () => {
}, [getEffectedNodes, handleDelete])
const handleSave = useCallback(async (chatVar: ConversationVariable) => {
// add chatVar
let newList: ConversationVariable[]
if (!currentVar) {
const newList = [chatVar, ...varList]
// Adding new conversation variable
newList = [chatVar, ...varList]
updateChatVarList(newList)
handleVarChanged()
// Use new dedicated conversation variables API
try {
await updateConversationVariables({
appId,
conversationVariables: newList,
})
const socket = webSocketClient.getSocket(appId)
if (socket) {
socket.emit('collaboration_event', {
type: 'vars_and_features_update',
})
}
invalidateConversationVarValues()
}
catch (error) {
console.error('Failed to update conversation variables:', error)
// Revert local state on error
updateChatVarList(varList)
}
return
}
// edit chatVar
const newList = varList.map(v => v.id === currentVar.id ? chatVar : v)
// Updating existing conversation variable
newList = varList.map(v => v.id === currentVar.id ? chatVar : v)
updateChatVarList(newList)
// side effects of rename env
// side effects of rename conversation variable
if (currentVar.name !== chatVar.name) {
const { getNodes, setNodes } = store.getState()
const { nodes, setNodes } = collaborativeWorkflow.getState()
const effectedNodes = getEffectedNodes(currentVar)
const newNodes = getNodes().map((node) => {
const newNodes = nodes.map((node) => {
if (effectedNodes.find(n => n.id === node.id))
return updateNodeVars(node, ['conversation', currentVar.name], ['conversation', chatVar.name])
@ -119,8 +158,29 @@ const ChatVariablePanel = () => {
})
setNodes(newNodes)
}
handleVarChanged()
}, [currentVar, getEffectedNodes, handleVarChanged, store, updateChatVarList, varList])
// Use new dedicated conversation variables API
try {
await updateConversationVariables({
appId,
conversationVariables: newList,
})
const socket = webSocketClient.getSocket(appId)
if (socket) {
socket.emit('collaboration_event', {
type: 'vars_and_features_update',
})
}
invalidateConversationVarValues()
}
catch (error) {
console.error('Failed to update conversation variables:', error)
// Revert local state on error
updateChatVarList(varList)
}
}, [currentVar, getEffectedNodes, collaborativeWorkflow, updateChatVarList, varList, appId, invalidateConversationVarValues])
return (
<div

View File

@ -0,0 +1,197 @@
import { memo, useCallback, useMemo, useState } from 'react'
import { RiCheckLine, RiCheckboxCircleFill, RiCheckboxCircleLine, RiCloseLine, RiFilter3Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/workflow/store'
import type { WorkflowCommentList } from '@/service/workflow-comment'
import { useWorkflowComment } from '@/app/components/workflow/hooks/use-workflow-comment'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import cn from '@/utils/classnames'
import { ControlMode } from '@/app/components/workflow/types'
import { resolveWorkflowComment } from '@/service/workflow-comment'
import { useParams } from 'next/navigation'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useAppContext } from '@/context/app-context'
import { collaborationManager } from '@/app/components/workflow/collaboration'
import Divider from '@/app/components/base/divider'
import Switch from '@/app/components/base/switch'
const CommentsPanel = () => {
const { t } = useTranslation()
const activeCommentId = useStore(s => s.activeCommentId)
const setActiveCommentId = useStore(s => s.setActiveCommentId)
const setControlMode = useStore(s => s.setControlMode)
const { comments, loading, loadComments, handleCommentIconClick } = useWorkflowComment()
const params = useParams()
const appId = params.appId as string
const { formatTimeFromNow } = useFormatTimeFromNow()
const [showOnlyMine, setShowOnlyMine] = useState(false)
const [showOnlyUnresolved, setShowOnlyUnresolved] = useState(false)
const [showFilter, setShowFilter] = useState(false)
const handleSelect = useCallback((comment: WorkflowCommentList) => {
handleCommentIconClick(comment)
}, [handleCommentIconClick])
const { userProfile } = useAppContext()
const filteredSorted = useMemo(() => {
let data = comments
if (showOnlyUnresolved)
data = data.filter(c => !c.resolved)
if (showOnlyMine)
data = data.filter(c => c.created_by === userProfile?.id)
return data
}, [comments, showOnlyMine, showOnlyUnresolved, userProfile?.id])
const handleResolve = useCallback(async (comment: WorkflowCommentList) => {
if (comment.resolved) return
if (!appId) return
try {
await resolveWorkflowComment(appId, comment.id)
collaborationManager.emitCommentsUpdate(appId)
await loadComments()
setActiveCommentId(comment.id)
}
catch (e) {
console.error('Resolve comment failed', e)
}
}, [appId, loadComments, setActiveCommentId])
const hasActiveFilter = showOnlyMine || !showOnlyUnresolved
return (
<div className={cn('relative flex h-full w-[420px] flex-col rounded-l-2xl border border-components-panel-border bg-components-panel-bg')}>
<div className='flex items-center justify-between p-4 pb-2'>
<div className='system-xl-semibold font-semibold leading-6 text-text-primary'>{t('workflow.comments.panelTitle')}</div>
<div className='relative flex items-center gap-2'>
<button
className={cn(
'group flex h-6 w-6 items-center justify-center rounded-md hover:bg-state-accent-active',
hasActiveFilter && 'bg-state-accent-active',
)}
aria-label='Filter comments'
onClick={() => setShowFilter(v => !v)}
>
<RiFilter3Line className={cn(
'h-4 w-4 text-text-secondary group-hover:text-text-accent',
hasActiveFilter && 'text-text-accent',
)} />
</button>
{showFilter && (
<div className='absolute right-10 top-9 z-50 min-w-[184px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[10px]'>
<button
className={cn('flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm hover:bg-state-base-hover', !showOnlyMine && 'bg-components-panel-on-panel-item-bg')}
onClick={() => {
setShowOnlyMine(false)
setShowFilter(false)
}}
>
<span className='text-text-secondary'>All</span>
{!showOnlyMine && <RiCheckLine className='h-4 w-4 text-primary-600' />}
</button>
<button
className={cn('mt-1 flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm hover:bg-state-base-hover', showOnlyMine && 'bg-components-panel-on-panel-item-bg')}
onClick={() => {
setShowOnlyMine(true)
setShowFilter(false)
}}
>
<span className='text-text-secondary'>Only your threads</span>
{showOnlyMine && <RiCheckLine className='h-4 w-4 text-primary-600' />}
</button>
<Divider type='horizontal' className='my-1' />
<div
className='flex w-full items-center justify-between rounded-md px-2 py-2'
onClick={(e) => {
e.stopPropagation()
}}
>
<span className='text-sm text-text-secondary'>Show resolved</span>
<Switch
size='md'
defaultValue={!showOnlyUnresolved}
onChange={(checked) => {
setShowOnlyUnresolved(!checked)
}}
/>
</div>
</div>
)}
<Divider type='vertical' className='h-3.5' />
<div
className='flex h-6 w-6 cursor-pointer items-center justify-center'
onClick={() => {
setControlMode(ControlMode.Pointer)
setActiveCommentId(null)
}}
>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</div>
<div className='grow overflow-y-auto px-1'>
{filteredSorted.map((c) => {
const isActive = activeCommentId === c.id
return (
<div
key={c.id}
className={cn('group mb-2 cursor-pointer rounded-xl bg-components-panel-bg p-3 transition-colors hover:bg-components-panel-on-panel-item-bg-hover', isActive && 'bg-components-panel-on-panel-item-bg-hover')}
onClick={() => handleSelect(c)}
>
<div className='min-w-0'>
<div className='mb-1 flex items-center justify-between'>
<UserAvatarList
users={c.participants}
maxVisible={3}
size={24}
/>
<div className='ml-2 flex items-center'>
{c.resolved ? (
<RiCheckboxCircleFill className='h-4 w-4 text-text-secondary'/>
) : (
<RiCheckboxCircleLine
className='h-4 w-4 cursor-pointer text-text-tertiary hover:text-text-secondary'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleResolve(c)
}}
/>
)}
</div>
</div>
{/* Header row: creator + time */}
<div className='flex items-start'>
<div className='flex min-w-0 items-center gap-2'>
<div className='system-sm-medium truncate text-text-primary'>{c.created_by_account.name}</div>
<div className='system-2xs-regular shrink-0 text-text-tertiary'>
{formatTimeFromNow(c.updated_at * 1000)}
</div>
</div>
</div>
{/* Content */}
<div className='system-sm-regular mt-1 line-clamp-3 break-words text-text-secondary'>{c.content}</div>
{/* Footer */}
{c.reply_count > 0 && (
<div className='mt-2 flex items-center justify-between'>
<div className='system-2xs-regular text-text-tertiary'>
{c.reply_count} {t('workflow.comments.reply')}
</div>
</div>
)}
</div>
</div>
)
})}
{!loading && filteredSorted.length === 0 && (
<div className='system-sm-regular mt-6 text-center text-text-tertiary'>{t('workflow.comments.noComments')}</div>
)}
</div>
</div>
)
}
export default memo(CommentsPanel)

View File

@ -3,9 +3,6 @@ import {
useCallback,
useState,
} from 'react'
import {
useStoreApi,
} from 'reactflow'
import { RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/workflow/store'
@ -17,17 +14,20 @@ import type {
import { findUsedVarNodes, updateNodeVars } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import RemoveEffectVarConfirm from '@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm'
import cn from '@/utils/classnames'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { useStore as useWorkflowStore } from '@/app/components/workflow/store'
import { updateEnvironmentVariables } from '@/service/workflow'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
const EnvPanel = () => {
const { t } = useTranslation()
const store = useStoreApi()
const collaborativeWorkflow = useCollaborativeWorkflow()
const setShowEnvPanel = useStore(s => s.setShowEnvPanel)
const envList = useStore(s => s.environmentVariables) as EnvironmentVariable[]
const envSecrets = useStore(s => s.envSecrets)
const updateEnvList = useStore(s => s.setEnvironmentVariables)
const setEnvSecrets = useStore(s => s.setEnvSecrets)
const { doSyncWorkflowDraft } = useNodesSyncDraft()
const appId = useWorkflowStore(s => s.appId) as string
const [showVariableModal, setShowVariableModal] = useState(false)
const [currentVar, setCurrentVar] = useState<EnvironmentVariable>()
@ -40,43 +40,65 @@ const EnvPanel = () => {
}
const getEffectedNodes = useCallback((env: EnvironmentVariable) => {
const { getNodes } = store.getState()
const allNodes = getNodes()
const { nodes: allNodes } = collaborativeWorkflow.getState()
return findUsedVarNodes(
['env', env.name],
allNodes,
)
}, [store])
}, [collaborativeWorkflow])
const removeUsedVarInNodes = useCallback((env: EnvironmentVariable) => {
const { getNodes, setNodes } = store.getState()
const { nodes, setNodes } = collaborativeWorkflow.getState()
const effectedNodes = getEffectedNodes(env)
const newNodes = getNodes().map((node) => {
const newNodes = nodes.map((node) => {
if (effectedNodes.find(n => n.id === node.id))
return updateNodeVars(node, ['env', env.name], [])
return node
})
setNodes(newNodes)
}, [getEffectedNodes, store])
}, [getEffectedNodes, collaborativeWorkflow])
const handleEdit = (env: EnvironmentVariable) => {
setCurrentVar(env)
setShowVariableModal(true)
}
const handleDelete = useCallback((env: EnvironmentVariable) => {
const handleDelete = useCallback(async (env: EnvironmentVariable) => {
removeUsedVarInNodes(env)
updateEnvList(envList.filter(e => e.id !== env.id))
const newEnvList = envList.filter(e => e.id !== env.id)
updateEnvList(newEnvList)
setCacheForDelete(undefined)
setShowRemoveConfirm(false)
doSyncWorkflowDraft()
// Use new dedicated environment variables API instead of workflow draft sync
try {
await updateEnvironmentVariables({
appId,
environmentVariables: newEnvList,
})
// Emit update event to other connected clients
const socket = webSocketClient.getSocket(appId)
if (socket?.connected) {
socket.emit('collaboration_event', {
type: 'vars_and_features_update',
timestamp: Date.now(),
})
}
}
catch (error) {
console.error('Failed to update environment variables:', error)
// Revert local state on error
updateEnvList(envList)
}
if (env.value_type === 'secret') {
const newMap = { ...envSecrets }
delete newMap[env.id]
setEnvSecrets(newMap)
}
}, [doSyncWorkflowDraft, envList, envSecrets, removeUsedVarInNodes, setEnvSecrets, updateEnvList])
}, [envList, envSecrets, removeUsedVarInNodes, setEnvSecrets, updateEnvList, appId])
const deleteCheck = useCallback((env: EnvironmentVariable) => {
const effectedNodes = getEffectedNodes(env)
@ -92,20 +114,46 @@ const EnvPanel = () => {
const handleSave = useCallback(async (env: EnvironmentVariable) => {
// add env
let newEnv = env
let newList: EnvironmentVariable[]
if (!currentVar) {
// Adding new environment variable
if (env.value_type === 'secret') {
setEnvSecrets({
...envSecrets,
[env.id]: formatSecret(env.value),
})
}
const newList = [env, ...envList]
newList = [env, ...envList]
updateEnvList(newList)
await doSyncWorkflowDraft()
updateEnvList(newList.map(e => (e.id === env.id && env.value_type === 'secret') ? { ...e, value: '[__HIDDEN__]' } : e))
// Use new dedicated environment variables API
try {
await updateEnvironmentVariables({
appId,
environmentVariables: newList,
})
const socket = webSocketClient.getSocket(appId)
if (socket) {
socket.emit('collaboration_event', {
type: 'vars_and_features_update',
})
}
// Hide secret values in UI
updateEnvList(newList.map(e => (e.id === env.id && env.value_type === 'secret') ? { ...e, value: '[__HIDDEN__]' } : e))
}
catch (error) {
console.error('Failed to update environment variables:', error)
// Revert local state on error
updateEnvList(envList)
}
return
}
else if (currentVar.value_type === 'secret') {
// Updating existing environment variable
if (currentVar.value_type === 'secret') {
if (env.value_type === 'secret') {
if (envSecrets[currentVar.id] !== env.value) {
newEnv = env
@ -128,13 +176,15 @@ const EnvPanel = () => {
})
}
}
const newList = envList.map(e => e.id === currentVar.id ? newEnv : e)
newList = envList.map(e => e.id === currentVar.id ? newEnv : e)
updateEnvList(newList)
// side effects of rename env
if (currentVar.name !== env.name) {
const { getNodes, setNodes } = store.getState()
const { nodes, setNodes } = collaborativeWorkflow.getState()
const effectedNodes = getEffectedNodes(currentVar)
const newNodes = getNodes().map((node) => {
const newNodes = nodes.map((node) => {
if (effectedNodes.find(n => n.id === node.id))
return updateNodeVars(node, ['env', currentVar.name], ['env', env.name])
@ -142,9 +192,30 @@ const EnvPanel = () => {
})
setNodes(newNodes)
}
await doSyncWorkflowDraft()
updateEnvList(newList.map(e => (e.id === env.id && env.value_type === 'secret') ? { ...e, value: '[__HIDDEN__]' } : e))
}, [currentVar, doSyncWorkflowDraft, envList, envSecrets, getEffectedNodes, setEnvSecrets, store, updateEnvList])
// Use new dedicated environment variables API
try {
await updateEnvironmentVariables({
appId,
environmentVariables: newList,
})
const socket = webSocketClient.getSocket(appId)
if (socket) {
socket.emit('collaboration_event', {
type: 'vars_and_features_update',
})
}
// Hide secret values in UI
updateEnvList(newList.map(e => (e.id === env.id && env.value_type === 'secret') ? { ...e, value: '[__HIDDEN__]' } : e))
}
catch (error) {
console.error('Failed to update environment variables:', error)
// Revert local state on error
updateEnvList(envList)
}
}, [currentVar, envList, envSecrets, getEffectedNodes, setEnvSecrets, collaborativeWorkflow, updateEnvList, appId])
return (
<div

View File

@ -7,7 +7,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { useClickAway } from 'ahooks'
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
import { useStore as useReactFlowStore } from 'reactflow'
import {
RiAlignBottom,
RiAlignCenter,
@ -22,6 +22,7 @@ import { WorkflowHistoryEvent, useWorkflowHistory } from './hooks/use-workflow-h
import { useStore } from './store'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
import { useWorkflowStore } from './store'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
enum AlignType {
Left = 'left',
@ -42,8 +43,8 @@ const SelectionContextmenu = () => {
const selectionMenu = useStore(s => s.selectionMenu)
// Access React Flow methods
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const collaborativeWorkflow = useCollaborativeWorkflow()
// Get selected nodes for alignment logic
const selectedNodes = useReactFlowStore(state =>
@ -256,7 +257,7 @@ const SelectionContextmenu = () => {
workflowStore.setState({ nodeAnimation: false })
// Get all current nodes
const nodes = store.getState().getNodes()
const { nodes, setNodes } = collaborativeWorkflow.getState()
// Get all selected nodes
const selectedNodeIds = selectedNodes.map(node => node.id)
@ -312,7 +313,7 @@ const SelectionContextmenu = () => {
const distributeNodes = handleDistributeNodes(nodesToAlign, nodes, alignType)
if (distributeNodes) {
// Apply node distribution updates
store.getState().setNodes(distributeNodes)
setNodes(distributeNodes)
handleSelectionContextmenuCancel()
// Clear guide lines
@ -347,7 +348,7 @@ const SelectionContextmenu = () => {
// Apply node position updates - consistent with handleNodeDrag and handleNodeDragStop
try {
// Directly use setNodes to update nodes - consistent with handleNodeDrag
store.getState().setNodes(newNodes)
setNodes(newNodes)
// Close popup
handleSelectionContextmenuCancel()
@ -366,7 +367,7 @@ const SelectionContextmenu = () => {
catch (err) {
console.error('Failed to update nodes:', err)
}
}, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes])
}, [collaborativeWorkflow, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes])
if (!selectionMenu)
return null

View File

@ -0,0 +1,54 @@
import type { StateCreator } from 'zustand'
import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
export type CommentSliceShape = {
comments: WorkflowCommentList[]
setComments: (comments: WorkflowCommentList[]) => void
commentsLoading: boolean
setCommentsLoading: (loading: boolean) => void
activeCommentDetail: WorkflowCommentDetail | null
setActiveCommentDetail: (comment: WorkflowCommentDetail | null) => void
activeCommentDetailLoading: boolean
setActiveCommentDetailLoading: (loading: boolean) => void
replySubmitting: boolean
setReplySubmitting: (loading: boolean) => void
replyUpdating: boolean
setReplyUpdating: (loading: boolean) => void
commentDetailCache: Record<string, WorkflowCommentDetail>
setCommentDetailCache: (cache: Record<string, WorkflowCommentDetail>) => void
mentionableUsersCache: Record<string, UserProfile[]>
setMentionableUsersCache: (appId: string, users: UserProfile[]) => void
mentionableUsersLoading: Record<string, boolean>
setMentionableUsersLoading: (appId: string, loading: boolean) => void
}
export const createCommentSlice: StateCreator<CommentSliceShape> = set => ({
comments: [],
setComments: comments => set({ comments }),
commentsLoading: false,
setCommentsLoading: commentsLoading => set({ commentsLoading }),
activeCommentDetail: null,
setActiveCommentDetail: activeCommentDetail => set({ activeCommentDetail }),
activeCommentDetailLoading: false,
setActiveCommentDetailLoading: activeCommentDetailLoading => set({ activeCommentDetailLoading }),
replySubmitting: false,
setReplySubmitting: replySubmitting => set({ replySubmitting }),
replyUpdating: false,
setReplyUpdating: replyUpdating => set({ replyUpdating }),
commentDetailCache: {},
setCommentDetailCache: commentDetailCache => set({ commentDetailCache }),
mentionableUsersCache: {},
setMentionableUsersCache: (appId, users) => set(state => ({
mentionableUsersCache: {
...state.mentionableUsersCache,
[appId]: users,
},
})),
mentionableUsersLoading: {},
setMentionableUsersLoading: (appId, loading) => set(state => ({
mentionableUsersLoading: {
...state.mentionableUsersLoading,
[appId]: loading,
},
})),
})

View File

@ -20,6 +20,8 @@ import type { NodeSliceShape } from './node-slice'
import { createNodeSlice } from './node-slice'
import type { PanelSliceShape } from './panel-slice'
import { createPanelSlice } from './panel-slice'
import type { CommentSliceShape } from './comment-slice'
import { createCommentSlice } from './comment-slice'
import type { ToolSliceShape } from './tool-slice'
import { createToolSlice } from './tool-slice'
import type { VersionSliceShape } from './version-slice'
@ -49,6 +51,7 @@ export type Shape
& HistorySliceShape
& NodeSliceShape
& PanelSliceShape
& CommentSliceShape
& ToolSliceShape
& VersionSliceShape
& WorkflowDraftSliceShape
@ -74,6 +77,7 @@ export const createWorkflowStore = (params: CreateWorkflowStoreParams) => {
...createHistorySlice(...args),
...createNodeSlice(...args),
...createPanelSlice(...args),
...createCommentSlice(...args),
...createToolSlice(...args),
...createVersionSlice(...args),
...createWorkflowDraftSlice(...args),

View File

@ -10,6 +10,12 @@ export type PanelSliceShape = {
setShowInputsPanel: (showInputsPanel: boolean) => void
showDebugAndPreviewPanel: boolean
setShowDebugAndPreviewPanel: (showDebugAndPreviewPanel: boolean) => void
showCommentsPanel: boolean
setShowCommentsPanel: (showCommentsPanel: boolean) => void
showUserComments: boolean
setShowUserComments: (showUserComments: boolean) => void
showUserCursors: boolean
setShowUserCursors: (showUserCursors: boolean) => void
panelMenu?: {
top: number
left: number
@ -24,6 +30,8 @@ export type PanelSliceShape = {
setShowVariableInspectPanel: (showVariableInspectPanel: boolean) => void
initShowLastRunTab: boolean
setInitShowLastRunTab: (initShowLastRunTab: boolean) => void
activeCommentId?: string | null
setActiveCommentId: (commentId: string | null) => void
}
export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
@ -36,6 +44,12 @@ export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
setShowInputsPanel: showInputsPanel => set(() => ({ showInputsPanel })),
showDebugAndPreviewPanel: false,
setShowDebugAndPreviewPanel: showDebugAndPreviewPanel => set(() => ({ showDebugAndPreviewPanel })),
showCommentsPanel: false,
setShowCommentsPanel: showCommentsPanel => set(() => ({ showCommentsPanel })),
showUserComments: true,
setShowUserComments: showUserComments => set(() => ({ showUserComments })),
showUserCursors: true,
setShowUserCursors: showUserCursors => set(() => ({ showUserCursors })),
panelMenu: undefined,
setPanelMenu: panelMenu => set(() => ({ panelMenu })),
selectionMenu: undefined,
@ -44,4 +58,6 @@ export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
setShowVariableInspectPanel: showVariableInspectPanel => set(() => ({ showVariableInspectPanel })),
initShowLastRunTab: false,
setInitShowLastRunTab: initShowLastRunTab => set(() => ({ initShowLastRunTab })),
activeCommentId: null,
setActiveCommentId: (commentId: string | null) => set(() => ({ activeCommentId: commentId })),
})

View File

@ -11,6 +11,13 @@ type PreviewRunningData = WorkflowRunningData & {
resultText?: string
}
type MousePosition = {
pageX: number
pageY: number
elementX: number
elementY: number
}
export type WorkflowSliceShape = {
workflowRunningData?: PreviewRunningData
setWorkflowRunningData: (workflowData: PreviewRunningData) => void
@ -30,10 +37,14 @@ export type WorkflowSliceShape = {
setSelection: (selection: WorkflowSliceShape['selection']) => void
bundleNodeSize: { width: number; height: number } | null
setBundleNodeSize: (bundleNodeSize: WorkflowSliceShape['bundleNodeSize']) => void
controlMode: 'pointer' | 'hand'
controlMode: 'pointer' | 'hand' | 'comment'
setControlMode: (controlMode: WorkflowSliceShape['controlMode']) => void
mousePosition: { pageX: number; pageY: number; elementX: number; elementY: number }
pendingComment: MousePosition | null
setPendingComment: (pendingComment: WorkflowSliceShape['pendingComment']) => void
mousePosition: MousePosition
setMousePosition: (mousePosition: WorkflowSliceShape['mousePosition']) => void
isCommentPreviewHovering: boolean
setCommentPreviewHovering: (hovering: boolean) => void
showConfirm?: { title: string; desc?: string; onConfirm: () => void }
setShowConfirm: (showConfirm: WorkflowSliceShape['showConfirm']) => void
controlPromptEditorRerenderKey: number
@ -63,13 +74,23 @@ export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({
setSelection: selection => set(() => ({ selection })),
bundleNodeSize: null,
setBundleNodeSize: bundleNodeSize => set(() => ({ bundleNodeSize })),
controlMode: localStorage.getItem('workflow-operation-mode') === 'pointer' ? 'pointer' : 'hand',
controlMode: (() => {
const storedControlMode = localStorage.getItem('workflow-operation-mode')
if (storedControlMode === 'pointer' || storedControlMode === 'hand' || storedControlMode === 'comment')
return storedControlMode
return 'pointer'
})(),
setControlMode: (controlMode) => {
set(() => ({ controlMode }))
localStorage.setItem('workflow-operation-mode', controlMode)
},
pendingComment: null,
setPendingComment: pendingComment => set(() => ({ pendingComment })),
mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 },
setMousePosition: mousePosition => set(() => ({ mousePosition })),
isCommentPreviewHovering: false,
setCommentPreviewHovering: hovering => set(() => ({ isCommentPreviewHovering: hovering })),
showConfirm: undefined,
setShowConfirm: showConfirm => set(() => ({ showConfirm })),
controlPromptEditorRerenderKey: 0,

View File

@ -6,6 +6,12 @@
transition: transform 0.2s ease-in-out;
}
/* Comment mode cursor override */
.comment-mode-flow .react-flow__pane,
.comment-mode-flow .react-flow__viewport {
cursor: none !important;
}
#workflow-container .react-flow__nodesselection-rect {
border: 1px solid #528BFF;
background: rgba(21, 94, 239, 0.05);

View File

@ -55,6 +55,7 @@ export enum BlockEnum {
export enum ControlMode {
Pointer = 'pointer',
Hand = 'hand',
Comment = 'comment',
}
export enum ErrorHandleMode {
Terminated = 'terminated',

View File

@ -39,6 +39,7 @@ import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useStore as useAppStore } from '@/app/components/app/store'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { collaborationManager } from './collaboration/core/collaboration-manager'
type UpdateDSLModalProps = {
onCancel: () => void
@ -201,6 +202,8 @@ const UpdateDSLModal = ({
return
}
handleWorkflowUpdate(app_id)
// Notify other collaboration clients about the workflow update
collaborationManager.emitWorkflowUpdate(app_id)
await handleCheckPluginDependencies(app_id)
if (onImport)
onImport()