Merge branch 'feat/collaboration2' into feat/support-agent-sandbox

This commit is contained in:
hjlarry
2026-01-25 00:00:03 +08:00
221 changed files with 13878 additions and 1226 deletions

View File

@ -35,7 +35,7 @@ const NodeSelectorWrapper = (props: NodeSelectorProps) => {
return true
})
}, [availableNodesMetaData?.nodes])
}, [availableNodesMetaData?.nodes]) as NodeSelectorProps['blocks']
return (
<NodeSelector

View File

@ -11,9 +11,9 @@ import {
} from 'react'
import {
useReactFlow,
useStoreApi,
useViewport,
} from 'reactflow'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import { CUSTOM_NODE } from './constants'
import { useAutoGenerateWebhookUrl, useNodesInteractions, useNodesSyncDraft, useWorkflowHistory, WorkflowHistoryEvent } from './hooks'
import CustomNode from './nodes'
@ -32,7 +32,6 @@ type Props = {
const CandidateNodeMain: FC<Props> = ({
candidateNode,
}) => {
const store = useStoreApi()
const reactflow = useReactFlow()
const workflowStore = useWorkflowStore()
const mousePosition = useStore(s => s.mousePosition)
@ -41,15 +40,12 @@ const CandidateNodeMain: FC<Props> = ({
const { saveStateToHistory } = useWorkflowHistory()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl()
const collaborativeWorkflow = useCollaborativeWorkflow()
useEventListener('click', (e) => {
e.preventDefault()
const {
getNodes,
setNodes,
} = store.getState()
const { screenToFlowPosition } = reactflow
const nodes = getNodes()
const { nodes, setNodes } = collaborativeWorkflow.getState()
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 type { CursorPosition, OnlineUser } from '@/app/components/workflow/collaboration/types'
import { useViewport } from 'reactflow'
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,331 @@
import type { LoroMap } from 'loro-crdt'
import type { Node } from '@/app/components/workflow/types'
import { LoroDoc } from 'loro-crdt'
import { BlockEnum } from '@/app/components/workflow/types'
import { CollaborationManager } from '../collaboration-manager'
const NODE_ID = 'node-1'
const LLM_NODE_ID = 'llm-node'
const PARAM_NODE_ID = 'parameter-node'
type WorkflowVariable = {
variable: string
label: string
type: string
required: boolean
default: string
max_length: number
placeholder: string
options: string[]
hint: string
}
type PromptTemplateItem = {
id: string
role: string
text: string
}
type ParameterItem = {
description: string
name: string
required: boolean
type: string
}
type StartNodeData = {
variables: WorkflowVariable[]
}
type LLMNodeData = {
model: {
mode: string
name: string
provider: string
completion_params: {
temperature: number
}
}
context: {
enabled: boolean
variable_selector: string[]
}
vision: {
enabled: boolean
}
prompt_template: PromptTemplateItem[]
}
type ParameterExtractorNodeData = {
model: {
mode: string
name: string
provider: string
completion_params: {
temperature: number
}
}
parameters: ParameterItem[]
query: unknown[]
reasoning_mode: string
vision: {
enabled: boolean
}
}
type CollaborationManagerInternals = {
doc: LoroDoc
nodesMap: LoroMap
edgesMap: LoroMap
syncNodes: (oldNodes: Node[], newNodes: Node[]) => void
}
const createNode = (variables: string[]): Node<StartNodeData> => ({
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: PromptTemplateItem[]): Node<LLMNodeData> => ({
id: LLM_NODE_ID,
type: 'custom',
position: { x: 200, y: 200 },
data: {
type: BlockEnum.LLM,
title: 'LLM',
desc: '',
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: ParameterItem[]): Node<ParameterExtractorNodeData> => ({
id: PARAM_NODE_ID,
type: 'custom',
position: { x: 400, y: 120 },
data: {
type: BlockEnum.ParameterExtractor,
title: 'ParameterExtractor',
desc: '',
selected: true,
model: {
mode: 'chat',
name: '',
provider: '',
completion_params: {
temperature: 0.7,
},
},
query: [],
reasoning_mode: 'prompt',
parameters,
vision: {
enabled: false,
},
},
})
const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals =>
manager as unknown as CollaborationManagerInternals
const getManager = (doc: LoroDoc) => {
const manager = new CollaborationManager()
const internals = getManagerInternals(manager)
internals.doc = doc
internals.nodesMap = doc.getMap('nodes')
internals.edgesMap = doc.getMap('edges')
return manager
}
const deepClone = <T>(value: T): T => JSON.parse(JSON.stringify(value))
const syncNodes = (manager: CollaborationManager, previous: Node[], next: Node[]) => {
const internals = getManagerInternals(manager)
internals.syncNodes(previous, next)
}
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)
syncNodes(managerA, [], [createNode(['a'])])
const snapshot = docA.export({ mode: 'snapshot' })
const docB = LoroDoc.fromSnapshot(snapshot)
const managerB = getManager(docB)
syncNodes(managerA, [createNode(['a'])], [createNode(['a', 'b'])])
syncNodes(managerB, [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)
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)
syncNodes(managerA, [], [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',
},
]
syncNodes(managerA, [createLLMNode(deepClone(baseTemplate))], [createLLMNode(deepClone(additionTemplate))])
const editedTemplate = [
{
id: 'system-1',
role: 'system',
text: 'updated by docB',
},
]
syncNodes(managerB, [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) as Node<LLMNodeData> | undefined
const finalB = exportNodes(managerB).find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | undefined
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.prompt_template).toEqual(expectedTemplates)
expect(finalB!.data.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)
syncNodes(managerA, [], [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' },
]
syncNodes(
managerA,
[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' },
]
syncNodes(
managerB,
[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) as
| Node<ParameterExtractorNodeData>
| undefined
const finalB = exportNodes(managerB).find(node => node.id === PARAM_NODE_ID) as
| Node<ParameterExtractorNodeData>
| undefined
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.parameters).toEqual(expectedParameters)
expect(finalB!.data.parameters).toEqual(expectedParameters)
})
})

View File

@ -0,0 +1,763 @@
import type { LoroMap } from 'loro-crdt'
import type {
NodePanelPresenceMap,
NodePanelPresenceUser,
} from '@/app/components/workflow/collaboration/types/collaboration'
import type { CommonNodeType, Edge, Node } from '@/app/components/workflow/types'
import { LoroDoc } from 'loro-crdt'
import { Position } from 'reactflow'
import { CollaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { BlockEnum } from '@/app/components/workflow/types'
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
}
type NodePanelPresenceEventData = {
nodeId: string
action: 'open' | 'close'
user: NodePanelPresenceUser
clientId: string
timestamp?: number
}
type StartNodeData = {
variables: WorkflowVariable[]
}
type LLMNodeData = {
context: {
enabled: boolean
variable_selector: string[]
}
model: {
mode: string
name: string
provider: string
completion_params: {
temperature: number
}
}
prompt_template: PromptTemplateItem[]
vision: {
enabled: boolean
}
}
type ParameterExtractorNodeData = {
model: {
mode: string
name: string
provider: string
completion_params: {
temperature: number
}
}
parameters: ParameterItem[]
query: unknown[]
reasoning_mode: string
vision: {
enabled: boolean
}
}
type LLMNodeDataWithUnknownTemplate = Omit<LLMNodeData, 'prompt_template'> & {
prompt_template: unknown
}
type ManagerDoc = LoroDoc | { commit: () => void }
type CollaborationManagerInternals = {
doc: ManagerDoc
nodesMap: LoroMap
edgesMap: LoroMap
syncNodes: (oldNodes: Node[], newNodes: Node[]) => void
syncEdges: (oldEdges: Edge[], newEdges: Edge[]) => void
applyNodePanelPresenceUpdate: (update: NodePanelPresenceEventData) => void
forceDisconnect: () => void
activeConnections: Set<string>
isUndoRedoInProgress: boolean
}
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<StartNodeData> => ({
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: Position.Right,
targetPosition: Position.Left,
data: {
selected: true,
title: '开始',
desc: '',
type: BlockEnum.Start,
variables: variableNames.map(name => createVariable(name)),
},
})
const LLM_NODE_ID = 'llm-node'
const PARAM_NODE_ID = 'param-extractor-node'
const createLLMNodeSnapshot = (promptTemplates: PromptTemplateItem[]): Node<LLMNodeData> => ({
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: Position.Right,
targetPosition: Position.Left,
data: {
type: BlockEnum.LLM,
title: 'LLM',
desc: '',
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<ParameterExtractorNodeData> => ({
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: Position.Right,
targetPosition: Position.Left,
data: {
type: BlockEnum.ParameterExtractor,
title: '参数提取器',
desc: '',
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 data = node.data as CommonNodeType<{ variables?: WorkflowVariable[] }>
const variables = data.variables ?? []
return variables.map(item => item.variable)
}
const getVariableObject = (node: Node, name: string): WorkflowVariable | undefined => {
const data = node.data as CommonNodeType<{ variables?: WorkflowVariable[] }>
const variables = data.variables ?? []
return variables.find(item => item.variable === name)
}
const getPromptTemplates = (node: Node): PromptTemplateItem[] => {
const data = node.data as CommonNodeType<{ prompt_template?: PromptTemplateItem[] }>
return data.prompt_template ?? []
}
const getParameters = (node: Node): ParameterItem[] => {
const data = node.data as CommonNodeType<{ parameters?: ParameterItem[] }>
return data.parameters ?? []
}
const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals =>
manager as unknown as CollaborationManagerInternals
const setupManager = (): { manager: CollaborationManager, internals: CollaborationManagerInternals } => {
const manager = new CollaborationManager()
const doc = new LoroDoc()
const internals = getManagerInternals(manager)
internals.doc = doc
internals.nodesMap = doc.getMap('nodes')
internals.edgesMap = doc.getMap('edges')
return { manager, internals }
}
describe('CollaborationManager syncNodes', () => {
let manager: CollaborationManager
let internals: CollaborationManagerInternals
beforeEach(() => {
const setup = setupManager()
manager = setup.manager
internals = setup.internals
const initialNode = createNodeSnapshot(['a'])
internals.syncNodes([], [deepClone(initialNode)])
})
it('updates collaborators map when a single client adds a variable', () => {
const base = [createNodeSnapshot(['a'])]
const next = [createNodeSnapshot(['a', 'b'])]
internals.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'])]
internals.syncNodes(base, userA)
const afterUserA = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
expect(getVariables(afterUserA!)).toEqual(['a', 'b'])
internals.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' }),
],
},
},
]
internals.syncNodes(base, userA)
internals.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'])]
internals.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' }),
],
},
},
]
internals.syncNodes(base, userA)
internals.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 { manager: promptManager, internals: promptInternals } = setupManager()
const baseTemplate = [
{
id: 'abcfa5f9-3c44-4252-aeba-4b6eaf0acfc4',
role: 'system',
text: 'avc',
},
]
const baseNode = createLLMNodeSnapshot(baseTemplate)
promptInternals.syncNodes([], [deepClone(baseNode)])
const updatedTemplates = [
...baseTemplate,
{
id: 'user-1',
role: 'user',
text: 'hello world',
},
]
const updatedNode = createLLMNodeSnapshot(updatedTemplates)
promptInternals.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)
promptInternals.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 { manager: parameterManager, internals: parameterInternals } = setupManager()
const baseParameters: ParameterItem[] = [
{ description: 'bb', name: 'aa', required: false, type: 'string' },
{ description: 'dd', name: 'cc', required: false, type: 'string' },
]
const baseNode = createParameterExtractorNodeSnapshot(baseParameters)
parameterInternals.syncNodes([], [deepClone(baseNode)])
const updatedParameters: ParameterItem[] = [
...baseParameters,
{ description: 'ff', name: 'ee', required: true, type: 'number' },
]
const updatedNode = createParameterExtractorNodeSnapshot(updatedParameters)
parameterInternals.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)
parameterInternals.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 unknown as CommonNodeType<Record<string, never>>,
}
internals.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 { manager: promptManager, internals: promptInternals } = setupManager()
const base = createLLMNodeSnapshot([
{ id: 'system', role: 'system', text: 'base' },
])
promptInternals.syncNodes([], [deepClone(base)])
const storedBefore = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | undefined
expect(storedBefore).toBeDefined()
const firstTemplate = storedBefore?.data.prompt_template?.[0]
expect(firstTemplate?.text).toBe('base')
// simulate consumer mutating the plain JSON array and syncing back
const baseNode = storedBefore!
const mutatedNode = deepClone(baseNode)
mutatedNode.data.prompt_template.push({
id: 'user',
role: 'user',
text: 'mutated',
})
promptInternals.syncNodes([baseNode], [mutatedNode])
const storedAfter = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | undefined
const templatesAfter = storedAfter?.data.prompt_template
expect(Array.isArray(templatesAfter)).toBe(true)
expect(templatesAfter).toHaveLength(2)
})
it('reuses CRDT list when syncing parameters repeatedly', () => {
const { manager: parameterManager, internals: parameterInternals } = setupManager()
const initialParameters: ParameterItem[] = [
{ description: 'desc', name: 'param', required: false, type: 'string' },
]
const node = createParameterExtractorNodeSnapshot(initialParameters)
parameterInternals.syncNodes([], [deepClone(node)])
const stored = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID) as Node<ParameterExtractorNodeData>
const mutatedNode = deepClone(stored)
mutatedNode.data.parameters[0].description = 'updated'
parameterInternals.syncNodes([stored], [mutatedNode])
const storedAfter = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID) as
| Node<ParameterExtractorNodeData>
| undefined
const params = storedAfter?.data.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<{ _foo: string, variables: WorkflowVariable[] }> = {
id: 'private-node',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Start,
title: 'private',
desc: '',
_foo: 'should disappear',
_children: [{ nodeId: 'child-a', nodeType: BlockEnum.Start }],
selected: true,
variables: [],
},
}
internals.syncNodes([], [deepClone(nodeWithPrivate)])
const stored = (manager.getNodes() as Node[]).find(node => node.id === 'private-node')!
const storedData = stored.data as CommonNodeType<{ _foo?: string }>
expect(storedData._foo).toBeUndefined()
expect(storedData._children).toEqual([{ nodeId: 'child-a', nodeType: BlockEnum.Start }])
expect(storedData.selected).toBeUndefined()
})
it('removes list fields when they are omitted in the update snapshot', () => {
const baseNode = createNodeSnapshot(['alpha'])
internals.syncNodes([], [deepClone(baseNode)])
const withoutVariables: Node<StartNodeData> = {
...deepClone(baseNode),
data: {
...deepClone(baseNode).data,
},
}
delete (withoutVariables.data as CommonNodeType<{ variables?: WorkflowVariable[] }>).variables
internals.syncNodes([deepClone(baseNode)], [withoutVariables])
const stored = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)!
const storedData = stored.data as CommonNodeType<{ variables?: WorkflowVariable[] }>
expect(storedData.variables).toBeUndefined()
})
it('treats non-array list inputs as empty lists during synchronization', () => {
const { manager: promptManager, internals: promptInternals } = setupManager()
const nodeWithInvalidTemplate = createLLMNodeSnapshot([])
promptInternals.syncNodes([], [deepClone(nodeWithInvalidTemplate)])
const mutated = deepClone(nodeWithInvalidTemplate) as Node<LLMNodeDataWithUnknownTemplate>
mutated.data.prompt_template = 'not-an-array'
promptInternals.syncNodes([deepClone(nodeWithInvalidTemplate)], [mutated])
const stored = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData>
expect(Array.isArray(stored.data.prompt_template)).toBe(true)
expect(stored.data.prompt_template).toHaveLength(0)
})
it('updates edges map when edges are added, modified, and removed', () => {
const { manager: edgeManager } = setupManager()
const edge: Edge = {
id: 'edge-1',
source: 'node-a',
target: 'node-b',
type: 'default',
data: {
sourceType: BlockEnum.Start,
targetType: BlockEnum.LLM,
_waitingRun: false,
},
}
edgeManager.setEdges([], [edge])
expect(edgeManager.getEdges()).toHaveLength(1)
const storedEdge = edgeManager.getEdges()[0]!
expect(storedEdge.data).toBeDefined()
expect(storedEdge.data!._waitingRun).toBe(false)
const updatedEdge: Edge = {
...edge,
data: {
sourceType: BlockEnum.Start,
targetType: BlockEnum.LLM,
_waitingRun: true,
},
}
edgeManager.setEdges([edge], [updatedEdge])
expect(edgeManager.getEdges()).toHaveLength(1)
const updatedStoredEdge = edgeManager.getEdges()[0]!
expect(updatedStoredEdge.data).toBeDefined()
expect(updatedStoredEdge.data!._waitingRun).toBe(true)
edgeManager.setEdges([updatedEdge], [])
expect(edgeManager.getEdges()).toHaveLength(0)
})
})
describe('CollaborationManager public API wrappers', () => {
let manager: CollaborationManager
let internals: CollaborationManagerInternals
const baseNodes: Node[] = []
const updatedNodes: Node[] = [
{
id: 'new-node',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Start,
title: 'New node',
desc: '',
},
},
]
const baseEdges: Edge[] = []
const updatedEdges: Edge[] = [
{
id: 'edge-1',
source: 'source',
target: 'target',
type: 'default',
data: {
sourceType: BlockEnum.Start,
targetType: BlockEnum.End,
},
},
]
beforeEach(() => {
manager = new CollaborationManager()
internals = getManagerInternals(manager)
})
it('setNodes delegates to syncNodes and commits the CRDT document', () => {
const commit = vi.fn()
internals.doc = { commit }
const syncSpy = vi.spyOn(internals, '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 = vi.fn()
internals.doc = { commit }
internals.isUndoRedoInProgress = true
const syncSpy = vi.spyOn(internals, '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 = vi.fn()
internals.doc = { commit }
const syncSpy = vi.spyOn(internals, '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 = vi.spyOn(internals, 'forceDisconnect').mockImplementation(() => undefined)
internals.activeConnections.add('conn-a')
internals.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' }
internals.applyNodePanelPresenceUpdate({
nodeId: 'node-a',
action: 'open',
user,
clientId: 'client-1',
timestamp: 100,
})
internals.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' }
internals.applyNodePanelPresenceUpdate({
nodeId: 'node-a',
action: 'open',
user,
clientId: 'client-9',
timestamp: 300,
})
internals.applyNodePanelPresenceUpdate({
nodeId: 'node-a',
action: 'close',
user,
clientId: 'client-9',
timestamp: 301,
})
expect(updates[updates.length - 1]).toEqual({})
})
})

View File

@ -0,0 +1,138 @@
import type { LoroDoc } from 'loro-crdt'
import type { Socket } from 'socket.io-client'
import { CRDTProvider } from '../crdt-provider'
type FakeDocEvent = {
by: string
}
type FakeDoc = {
export: ReturnType<typeof vi.fn>
import: ReturnType<typeof vi.fn>
subscribe: ReturnType<typeof vi.fn>
trigger: (event: FakeDocEvent) => void
}
const createFakeDoc = (): FakeDoc => {
let handler: ((payload: FakeDocEvent) => void) | null = null
const exportFn = vi.fn(() => new Uint8Array([1, 2, 3]))
const importFn = vi.fn()
const subscribeFn = vi.fn((cb: (payload: FakeDocEvent) => void) => {
handler = cb
})
return {
export: exportFn,
import: importFn,
subscribe: subscribeFn,
trigger: (event: FakeDocEvent) => {
handler?.(event)
},
}
}
type MockSocket = {
trigger: (event: string, ...args: unknown[]) => void
emit: ReturnType<typeof vi.fn>
on: ReturnType<typeof vi.fn>
off: ReturnType<typeof vi.fn>
}
const createMockSocket = (): MockSocket => {
const handlers = new Map<string, (...args: unknown[]) => void>()
const socket: MockSocket = {
emit: vi.fn(),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
handlers.set(event, handler)
}),
off: vi.fn((event: string) => {
handlers.delete(event)
}),
trigger: (event: string, ...args: unknown[]) => {
const handler = handlers.get(event)
if (handler)
handler(...args)
},
}
return socket
}
describe('CRDTProvider', () => {
it('emits graph_event when local changes happen', () => {
const doc = createFakeDoc()
const socket = createMockSocket()
const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc)
expect(provider).toBeInstanceOf(CRDTProvider)
doc.trigger({ by: 'local' })
expect(socket.emit).toHaveBeenCalledWith(
'graph_event',
expect.any(Uint8Array),
expect.any(Function),
)
expect(doc.export).toHaveBeenCalledWith({ mode: 'update' })
})
it('ignores non-local events', () => {
const doc = createFakeDoc()
const socket = createMockSocket()
const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc)
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 as unknown as Socket, doc as unknown as LoroDoc)
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 as unknown as Socket, doc as unknown as LoroDoc)
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 as unknown as Socket, doc as unknown as LoroDoc)
const errorSpy = vi.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 = vi.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 = vi.fn()
const handlerB = vi.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 = vi.fn()
const handlerB = vi.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', vi.fn())
emitter.on('two', vi.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 = vi.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 = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
const failingHandler = vi.fn(() => {
throw new Error('boom')
})
const succeedingHandler = vi.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,161 @@
type MockSocket = {
trigger: (event: string, ...args: unknown[]) => void
emit: ReturnType<typeof vi.fn>
on: ReturnType<typeof vi.fn>
disconnect: ReturnType<typeof vi.fn>
connected: boolean
}
type IoOptions = {
auth?: unknown
path?: string
transports?: string[]
withCredentials?: boolean
}
const ioMock = vi.hoisted(() => vi.fn())
vi.mock('socket.io-client', () => ({
io: (...args: Parameters<typeof ioMock>) => ioMock(...args),
}))
const createMockSocket = (id: string): MockSocket => {
const handlers = new Map<string, (...args: unknown[]) => void>()
const socket: MockSocket & { id: string } = {
id,
connected: true,
emit: vi.fn(),
disconnect: vi.fn(() => {
socket.connected = false
}),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
handlers.set(event, handler)
}),
trigger: (event: string, ...args: unknown[]) => {
const handler = handlers.get(event)
if (handler)
handler(...args)
},
}
return socket
}
describe('WebSocketClient', () => {
beforeEach(() => {
vi.resetModules()
ioMock.mockReset()
})
it('connects with default url and registers base listeners', async () => {
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('emits user_connect on connect without auth payload', async () => {
const mockSocket = createMockSocket('socket-auth')
ioMock.mockImplementation((url: string, options: IoOptions) => {
expect(options.auth).toBeUndefined()
return mockSocket
})
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' },
expect.any(Function),
)
})
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: { by?: string }) => {
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,51 @@
export type EventHandler<T = unknown> = (data: T) => void
export class EventEmitter {
private events: Map<string, Set<EventHandler<unknown>>> = new Map()
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
if (!this.events.has(event))
this.events.set(event, new Set())
this.events.get(event)!.add(handler as EventHandler<unknown>)
return () => this.off(event, handler)
}
off<T = unknown>(event: string, handler?: EventHandler<T>): void {
if (!this.events.has(event))
return
const handlers = this.events.get(event)!
if (handler)
handlers.delete(handler as EventHandler<unknown>)
else
handlers.clear()
if (handlers.size === 0)
this.events.delete(event)
}
emit<T = unknown>(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,157 @@
import type { Socket } from 'socket.io-client'
import type { DebugInfo, WebSocketConfig } from '../types/websocket'
import { io } from 'socket.io-client'
import { SOCKET_URL } from '@/config'
type AckArgs = unknown[]
const isUnauthorizedAck = (...ackArgs: AckArgs): boolean => {
const [first, second] = ackArgs
if (second === 401 || first === 401)
return true
if (first && typeof first === 'object' && 'msg' in first) {
const message = (first as { msg?: unknown }).msg
return message === 'unauthorized'
}
return false
}
export type EmitAckOptions = {
onAck?: (...ackArgs: AckArgs) => void
onUnauthorized?: (...ackArgs: AckArgs) => void
}
export const emitWithAuthGuard = (
socket: Socket | null | undefined,
event: string,
payload: unknown,
options?: EmitAckOptions,
): void => {
if (!socket)
return
socket.emit(
event,
payload,
(...ackArgs: AckArgs) => {
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 readonly url: string
private readonly transports: WebSocketConfig['transports']
private readonly withCredentials?: boolean
constructor(config: WebSocketConfig = {}) {
this.url = SOCKET_URL
this.transports = config.transports || ['websocket']
this.withCredentials = config.withCredentials !== false
}
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 socketOptions: {
path: string
transports: WebSocketConfig['transports']
withCredentials?: boolean
} = {
path: '/socket.io',
transports: this.transports,
withCredentials: this.withCredentials,
}
const socket = io(this.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,144 @@
import type { ReactFlowInstance } from 'reactflow'
import type {
CollaborationState,
CursorPosition,
NodePanelPresenceMap,
OnlineUser,
} from '../types/collaboration'
import { useEffect, useRef, useState } from 'react'
import Toast from '@/app/components/base/toast'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { collaborationManager } from '../core/collaboration-manager'
import { CursorService } from '../services/cursor-service'
type CollaborationViewState = {
isConnected: boolean
onlineUsers: OnlineUser[]
cursors: Record<string, CursorPosition>
nodePanelPresence: NodePanelPresenceMap
isLeader: boolean
}
type ReactFlowStore = NonNullable<Parameters<typeof collaborationManager.connect>[1]>
const initialState: CollaborationViewState = {
isConnected: false,
onlineUsers: [],
cursors: {},
nodePanelPresence: {},
isLeader: false,
}
export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) {
const [state, setState] = useState<CollaborationViewState>(initialState)
const cursorServiceRef = useRef<CursorService | null>(null)
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
useEffect(() => {
if (!appId || !isCollaborationEnabled) {
Promise.resolve().then(() => {
setState(initialState)
})
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 => ({ ...prev, isConnected: collaborationManager.isConnected() }))
}
catch (error) {
console.error('Failed to initialize collaboration:', error)
}
}
initCollaboration()
const unsubscribeStateChange = collaborationManager.onStateChange((newState: Partial<CollaborationState>) => {
if (newState.isConnected === undefined)
return
setState(prev => ({ ...prev, isConnected: newState.isConnected ?? prev.isConnected }))
})
const unsubscribeCursors = collaborationManager.onCursorUpdate((cursors: Record<string, CursorPosition>) => {
setState(prev => ({ ...prev, cursors }))
})
const unsubscribeUsers = collaborationManager.onOnlineUsersUpdate((users: OnlineUser[]) => {
setState(prev => ({ ...prev, onlineUsers: users }))
})
const unsubscribeNodePanelPresence = collaborationManager.onNodePanelPresenceUpdate((presence: NodePanelPresenceMap) => {
setState(prev => ({ ...prev, nodePanelPresence: presence }))
})
const unsubscribeLeaderChange = collaborationManager.onLeaderChange((isLeader: boolean) => {
setState(prev => ({ ...prev, isLeader }))
})
return () => {
isUnmounted = true
unsubscribeStateChange()
unsubscribeCursors()
unsubscribeUsers()
unsubscribeNodePanelPresence()
unsubscribeLeaderChange()
cursorServiceRef.current?.stopTracking()
if (connectionId)
collaborationManager.disconnect(connectionId)
}
}, [appId, reactFlowStore, isCollaborationEnabled])
const prevIsConnected = useRef(false)
useEffect(() => {
if (prevIsConnected.current && !state.isConnected) {
Toast.notify({
type: 'error',
message: 'Network connection lost. Please check your network.',
})
}
prevIsConnected.current = state.isConnected || false
}, [state.isConnected])
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 { useCollaboration } from './hooks/use-collaboration'
export { CursorService } from './services/cursor-service'
export * from './types'

View File

@ -0,0 +1,90 @@
import type { RefObject } from 'react'
import type { ReactFlowInstance } from 'reactflow'
import type { CursorPosition } from '../types/collaboration'
const CURSOR_MIN_MOVE_DISTANCE = 10
const CURSOR_THROTTLE_MS = 300
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,103 @@
import type { Viewport } from 'reactflow'
import type { ConversationVariable, Edge, EnvironmentVariable, Node } from '../../types'
import type { Features } from '@/app/components/base/features/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 CollaborationEventType
= | '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'
| 'graph_resync_request'
| 'workflow_restore_request'
| 'workflow_restore_intent'
| 'workflow_restore_complete'
export type CollaborationUpdate = {
type: CollaborationEventType
userId: string
data: Record<string, unknown>
timestamp: number
}
export type RestoreRequestData = {
versionId: string
versionName?: string
initiatorUserId: string
initiatorName: string
graphData: {
nodes: Node[]
edges: Edge[]
viewport?: Viewport
}
features?: Features
environmentVariables?: EnvironmentVariable[]
conversationVariables?: ConversationVariable[]
}
export type RestoreIntentData = {
versionId: string
versionName?: string
initiatorUserId: string
initiatorName: string
}
export type RestoreCompleteData = {
versionId: string
success: boolean
error?: string
}

View File

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

View File

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

View File

@ -0,0 +1,15 @@
export type WebSocketConfig = {
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 { useWorkflowComment } from './hooks/use-workflow-comment'
import { useWorkflowStore } from './store'
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,148 @@
import type { WorkflowCommentList } from '@/service/workflow-comment'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CommentIcon } from './comment-icon'
type Position = { x: number, y: number }
let mockUserId = 'user-1'
const mockFlowToScreenPosition = vi.fn((position: Position) => position)
const mockScreenToFlowPosition = vi.fn((position: Position) => position)
vi.mock('reactflow', () => ({
useReactFlow: () => ({
flowToScreenPosition: mockFlowToScreenPosition,
screenToFlowPosition: mockScreenToFlowPosition,
}),
useViewport: () => ({
x: 0,
y: 0,
zoom: 1,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: {
id: mockUserId,
name: 'User',
avatar_url: 'avatar',
},
}),
}))
vi.mock('@/app/components/base/user-avatar-list', () => ({
UserAvatarList: ({ users }: { users: Array<{ id: string }> }) => (
<div data-testid="avatar-list">{users.map(user => user.id).join(',')}</div>
),
}))
vi.mock('./comment-preview', () => ({
default: ({ onClick }: { onClick?: () => void }) => (
<button type="button" data-testid="comment-preview" onClick={onClick}>
Preview
</button>
),
}))
const createComment = (overrides: Partial<WorkflowCommentList> = {}): WorkflowCommentList => ({
id: 'comment-1',
position_x: 0,
position_y: 0,
content: 'Hello',
created_by: 'user-1',
created_by_account: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
created_at: 1,
updated_at: 2,
resolved: false,
mention_count: 0,
reply_count: 0,
participants: [],
...overrides,
})
describe('CommentIcon', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUserId = 'user-1'
})
it('toggles preview on hover when inactive', () => {
const comment = createComment()
const { container } = render(
<CommentIcon comment={comment} onClick={vi.fn()} isActive={false} />,
)
const marker = container.querySelector('[data-role="comment-marker"]') as HTMLElement
const hoverTarget = marker.firstElementChild as HTMLElement
fireEvent.mouseEnter(hoverTarget)
expect(screen.getByTestId('comment-preview')).toBeInTheDocument()
fireEvent.mouseLeave(hoverTarget)
expect(screen.queryByTestId('comment-preview')).not.toBeInTheDocument()
})
it('calls onPositionUpdate after dragging by author', () => {
const comment = createComment({ position_x: 0, position_y: 0 })
const onClick = vi.fn()
const onPositionUpdate = vi.fn()
const { container } = render(
<CommentIcon
comment={comment}
onClick={onClick}
onPositionUpdate={onPositionUpdate}
/>,
)
const marker = container.querySelector('[data-role="comment-marker"]') as HTMLElement
fireEvent.pointerDown(marker, {
pointerId: 1,
button: 0,
clientX: 100,
clientY: 100,
})
fireEvent.pointerMove(marker, {
pointerId: 1,
clientX: 110,
clientY: 110,
})
fireEvent.pointerUp(marker, {
pointerId: 1,
clientX: 110,
clientY: 110,
})
expect(mockScreenToFlowPosition).toHaveBeenCalledWith({ x: 10, y: 10 })
expect(onPositionUpdate).toHaveBeenCalledWith({ x: 10, y: 10 })
expect(onClick).not.toHaveBeenCalled()
})
it('calls onClick for non-author clicks', () => {
mockUserId = 'user-2'
const comment = createComment()
const onClick = vi.fn()
const { container } = render(
<CommentIcon comment={comment} onClick={onClick} isActive={false} />,
)
const marker = container.querySelector('[data-role="comment-marker"]') as HTMLElement
fireEvent.pointerDown(marker, {
pointerId: 1,
button: 0,
clientX: 50,
clientY: 60,
})
fireEvent.pointerUp(marker, {
pointerId: 1,
clientX: 50,
clientY: 60,
})
expect(onClick).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,268 @@
'use client'
import type { FC, PointerEvent as ReactPointerEvent } from 'react'
import type { WorkflowCommentList } from '@/service/workflow-comment'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { useReactFlow, useViewport } from 'reactflow'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { useAppContext } from '@/context/app-context'
import CommentPreview from './comment-preview'
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,106 @@
import type { FC } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CommentInput } from './comment-input'
type MentionInputProps = {
value: string
onChange: (value: string) => void
onSubmit: (content: string, mentionedUserIds: string[]) => void
placeholder?: string
autoFocus?: boolean
className?: string
}
const stableT = (key: string, options?: { ns?: string }) => (
options?.ns ? `${options.ns}.${key}` : key
)
let mentionInputProps: MentionInputProps | null = null
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: stableT,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: {
id: 'user-1',
name: 'Alice',
avatar_url: 'avatar',
},
}),
}))
vi.mock('@/app/components/base/avatar', () => ({
default: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
}))
vi.mock('./mention-input', () => ({
MentionInput: ((props: MentionInputProps) => {
mentionInputProps = props
return (
<button
type="button"
data-testid="mention-input"
onClick={() => props.onSubmit('Hello', ['user-2'])}
>
MentionInput
</button>
)
}) as FC<MentionInputProps>,
}))
describe('CommentInput', () => {
beforeEach(() => {
vi.clearAllMocks()
mentionInputProps = null
})
it('passes translated placeholder to mention input', () => {
render(
<CommentInput
position={{ x: 0, y: 0 }}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
)
expect(mentionInputProps?.placeholder).toBe('workflow.comments.placeholder.add')
expect(mentionInputProps?.autoFocus).toBe(true)
})
it('calls onCancel when Escape is pressed', () => {
const onCancel = vi.fn()
render(
<CommentInput
position={{ x: 0, y: 0 }}
onSubmit={vi.fn()}
onCancel={onCancel}
/>,
)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('forwards mention submit to onSubmit', () => {
const onSubmit = vi.fn()
render(
<CommentInput
position={{ x: 0, y: 0 }}
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('mention-input'))
expect(onSubmit).toHaveBeenCalledWith('Hello', ['user-2'])
})
})

View File

@ -0,0 +1,175 @@
import type { FC, PointerEvent as ReactPointerEvent } from 'react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Avatar from '@/app/components/base/avatar'
import { useAppContext } from '@/context/app-context'
import { cn } from '@/utils/classnames'
import { MentionInput } from './mention-input'
type CommentInputProps = {
position: { x: number, y: number }
onSubmit: (content: string, mentionedUserIds: string[]) => void
onCancel: () => void
onPositionChange?: (position: {
pageX: number
pageY: number
elementX: number
elementY: number
}) => void
}
export const CommentInput: FC<CommentInputProps> = memo(({ position, onSubmit, onCancel, onPositionChange }) => {
const [content, setContent] = useState('')
const { t } = useTranslation()
const { userProfile } = useAppContext()
const dragStateRef = useRef<{
pointerId: number | null
startPointerX: number
startPointerY: number
startX: number
startY: number
active: boolean
} & {
endHandler?: (event: PointerEvent) => void
}>({
pointerId: null,
startPointerX: 0,
startPointerY: 0,
startX: 0,
startY: 0,
active: false,
endHandler: undefined,
})
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])
const handleDragPointerMove = useCallback((event: PointerEvent) => {
const state = dragStateRef.current
if (!state.active || (state.pointerId !== null && event.pointerId !== state.pointerId))
return
if (!onPositionChange)
return
event.preventDefault()
const deltaX = event.clientX - state.startPointerX
const deltaY = event.clientY - state.startPointerY
onPositionChange({
pageX: event.clientX,
pageY: event.clientY,
elementX: state.startX + deltaX,
elementY: state.startY + deltaY,
})
}, [onPositionChange])
const stopDragging = useCallback((event?: PointerEvent) => {
const state = dragStateRef.current
if (!state.active)
return
if (event && state.pointerId !== null && event.pointerId !== state.pointerId)
return
state.active = false
state.pointerId = null
window.removeEventListener('pointermove', handleDragPointerMove)
if (state.endHandler) {
window.removeEventListener('pointerup', state.endHandler)
window.removeEventListener('pointercancel', state.endHandler)
state.endHandler = undefined
}
}, [handleDragPointerMove])
const handleDragPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0)
return
event.stopPropagation()
event.preventDefault()
if (!onPositionChange)
return
const endHandler = (pointerEvent: PointerEvent) => {
stopDragging(pointerEvent)
}
dragStateRef.current = {
pointerId: event.pointerId,
startPointerX: event.clientX,
startPointerY: event.clientY,
startX: position.x,
startY: position.y,
active: true,
endHandler,
}
window.addEventListener('pointermove', handleDragPointerMove, { passive: false })
window.addEventListener('pointerup', endHandler)
window.addEventListener('pointercancel', endHandler)
}, [handleDragPointerMove, onPositionChange, position.x, position.y, stopDragging])
useEffect(() => () => {
stopDragging()
}, [stopDragging])
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 cursor-move"
onPointerDown={handleDragPointerDown}
>
<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-components-panel-bg-blur">
<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 pl-[9px] pt-[4px]">
<MentionInput
value={content}
onChange={setContent}
onSubmit={handleMentionSubmit}
placeholder={t('comments.placeholder.add', { ns: 'workflow' })}
autoFocus
className="relative"
/>
</div>
</div>
</div>
</div>
)
})
CommentInput.displayName = 'CommentInput'

View File

@ -0,0 +1,86 @@
import type { WorkflowCommentList } from '@/service/workflow-comment'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CommentPreview from './comment-preview'
type UserProfile = WorkflowCommentList['created_by_account']
const mockSetHovering = vi.fn()
let capturedUsers: UserProfile[] = []
vi.mock('@/app/components/base/user-avatar-list', () => ({
UserAvatarList: ({ users }: { users: UserProfile[] }) => {
capturedUsers = users
return <div data-testid="avatar-list">{users.map(user => user.id).join(',')}</div>
},
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (value: number) => `time:${value}`,
}),
}))
vi.mock('../store', () => ({
useStore: (selector: (state: { setCommentPreviewHovering: (value: boolean) => void }) => unknown) =>
selector({ setCommentPreviewHovering: mockSetHovering }),
}))
const createComment = (overrides: Partial<WorkflowCommentList> = {}): WorkflowCommentList => {
const author = { id: 'user-1', name: 'Alice', email: 'alice@example.com' }
const participant = { id: 'user-2', name: 'Bob', email: 'bob@example.com' }
return {
id: 'comment-1',
position_x: 0,
position_y: 0,
content: 'Hello',
created_by: author.id,
created_by_account: author,
created_at: 1,
updated_at: 10,
resolved: false,
mention_count: 0,
reply_count: 0,
participants: [author, participant],
...overrides,
}
}
describe('CommentPreview', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedUsers = []
})
it('orders participants with author first and formats time', () => {
const comment = createComment()
render(<CommentPreview comment={comment} />)
expect(capturedUsers.map(user => user.id)).toEqual(['user-1', 'user-2'])
expect(screen.getByText('Hello')).toBeInTheDocument()
expect(screen.getByText('time:10000')).toBeInTheDocument()
})
it('updates hover state on enter and leave', () => {
const comment = createComment()
const { container } = render(<CommentPreview comment={comment} />)
const root = container.firstElementChild as HTMLElement
fireEvent.mouseEnter(root)
fireEvent.mouseLeave(root)
expect(mockSetHovering).toHaveBeenCalledWith(true)
expect(mockSetHovering).toHaveBeenCalledWith(false)
})
it('clears hover state on unmount', () => {
const comment = createComment()
const { unmount } = render(<CommentPreview comment={comment} />)
unmount()
expect(mockSetHovering).toHaveBeenCalledWith(false)
})
})

View File

@ -0,0 +1,59 @@
'use client'
import type { FC } from 'react'
import type { WorkflowCommentList } from '@/service/workflow-comment'
import { memo, useEffect, useMemo } from 'react'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
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,45 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ControlMode } from '../types'
import { CommentCursor } from './cursor'
const mockState = {
controlMode: ControlMode.Pointer,
mousePosition: {
elementX: 10,
elementY: 20,
},
}
vi.mock('@/app/components/base/icons/src/public/other', () => ({
Comment: (props: { className?: string }) => <svg data-testid="comment-icon" {...props} />,
}))
vi.mock('../store', () => ({
useStore: (selector: (state: typeof mockState) => unknown) => selector(mockState),
}))
describe('CommentCursor', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders nothing when not in comment mode', () => {
mockState.controlMode = ControlMode.Pointer
render(<CommentCursor />)
expect(screen.queryByTestId('comment-icon')).not.toBeInTheDocument()
})
it('renders at current mouse position when in comment mode', () => {
mockState.controlMode = ControlMode.Comment
render(<CommentCursor />)
const icon = screen.getByTestId('comment-icon')
const container = icon.parentElement as HTMLElement
expect(container).toHaveStyle({ left: '10px', top: '20px' })
})
})

View File

@ -0,0 +1,28 @@
import type { FC } from 'react'
import { memo } from 'react'
import { Comment } from '@/app/components/base/icons/src/public/other'
import { useStore } from '../store'
import { ControlMode } from '../types'
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 { CommentIcon } from './comment-icon'
export { CommentInput } from './comment-input'
export { CommentCursor } from './cursor'
export { MentionInput } from './mention-input'
export { CommentThread } from './thread'

View File

@ -0,0 +1,661 @@
'use client'
import type { ReactNode } from 'react'
import type { UserProfile } from '@/service/workflow-comment'
import { RiArrowUpLine, RiAtLine, RiLoader2Line } from '@remixicon/react'
import { useParams } from 'next/navigation'
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import Textarea from 'react-textarea-autosize'
import Avatar from '@/app/components/base/avatar'
import Button from '@/app/components/base/button'
import { EnterKey } from '@/app/components/base/icons/src/public/common'
import { fetchMentionableUsers } from '@/service/workflow-comment'
import { cn } from '@/utils/classnames'
import { useStore, useWorkflowStore } from '../store'
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('comments.placeholder.add', { ns: 'workflow' })
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(() => {
Promise.resolve().then(() => {
evaluateContentLayout()
})
}, [value, evaluateContentLayout])
useLayoutEffect(() => {
Promise.resolve().then(() => {
updateLayoutPadding()
})
}, [updateLayoutPadding, isEditing, shouldReserveButtonGap])
useEffect(() => {
const handleResize = () => {
evaluateContentLayout()
updateLayoutPadding()
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [evaluateContentLayout, updateLayoutPadding])
useEffect(() => {
Promise.resolve().then(() => {
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) {
Promise.resolve().then(() => {
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('operation.cancel', { ns: 'common' })}
</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('operation.save', { ns: 'common' })}</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,634 @@
'use client'
import type { FC, ReactNode } from 'react'
import type { WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/service/workflow-comment'
import { RiArrowDownSLine, RiArrowUpSLine, RiCheckboxCircleFill, RiCheckboxCircleLine, RiCloseLine, RiDeleteBinLine, RiMoreFill } from '@remixicon/react'
import { useParams } from 'next/navigation'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useReactFlow, useViewport } from 'reactflow'
import Avatar from '@/app/components/base/avatar'
import Divider from '@/app/components/base/divider'
import InlineDeleteConfirm from '@/app/components/base/inline-delete-confirm'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import Tooltip from '@/app/components/base/tooltip'
import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color'
import { useAppContext } from '@/context/app-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { cn } from '@/utils/classnames'
import { useStore } from '../store'
import { MentionInput } from './mention-input'
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 ?? []
const setCommentPreviewHovering = useStore(state => state.setCommentPreviewHovering)
// 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(() => {
Promise.resolve().then(() => {
setReplyContent('')
})
}, [comment.id])
useEffect(() => () => {
setCommentPreviewHovering(false)
}, [setCommentPreviewHovering])
// 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%)',
}}
onMouseEnter={() => setCommentPreviewHovering(true)}
onMouseLeave={() => setCommentPreviewHovering(false)}
>
<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('comments.panelTitle', { ns: 'workflow' })}
</div>
<div className="flex items-center gap-1">
<Tooltip
popupContent={t('comments.aria.deleteComment', { ns: 'workflow' })}
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('comments.aria.deleteComment', { ns: 'workflow' })}
>
<RiDeleteBinLine className="h-4 w-4" />
</button>
</Tooltip>
<Tooltip
popupContent={t('comments.aria.resolveComment', { ns: 'workflow' })}
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('comments.aria.resolveComment', { ns: 'workflow' })}
>
{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('comments.aria.previousComment', { ns: 'workflow' })}
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('comments.aria.previousComment', { ns: 'workflow' })}
>
<RiArrowUpSLine className="h-4 w-4" />
</button>
</Tooltip>
<Tooltip
popupContent={t('comments.aria.nextComment', { ns: 'workflow' })}
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('comments.aria.nextComment', { ns: 'workflow' })}
>
<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('comments.aria.closeComment', { ns: 'workflow' })}
>
<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('comments.fallback.user', { ns: 'workflow' })}
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('comments.aria.replyActions', { ns: 'workflow' })}
>
<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('comments.actions.editReply', { ns: 'workflow' })}
</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('comments.actions.deleteReply', { ns: 'workflow' })}
</button>
</div>
{/* Delete confirmation - shown when deletingReplyId matches */}
<div className={cn(deletingReplyId === reply.id ? 'block' : 'hidden')}>
<InlineDeleteConfirm
title={t('comments.actions.deleteReply', { ns: 'workflow' })}
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('comments.fallback.user', { ns: 'workflow' })}
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('comments.placeholder.editReply', { ns: 'workflow' })}
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('comments.fallback.user', { ns: 'workflow' })}
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('comments.loading', { ns: 'workflow' })}
</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('you', { ns: 'common' })}
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('comments.placeholder.reply', { ns: 'workflow' })}
disabled={loading}
loading={replySubmitting}
/>
</div>
</div>
</div>
)}
</div>
</div>
)
})
CommentThread.displayName = 'CommentThread'

View File

@ -1,16 +1,19 @@
import type { StartNodeType } from './nodes/start/types'
import type { CommonNodeType, InputVar, Node } from './types'
import type { PromptVariable } from '@/models/debug'
import type { WorkflowDraftFeaturesPayload } from '@/service/workflow'
import {
memo,
useCallback,
} from 'react'
import { useNodes } from 'reactflow'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import NewFeaturePanel from '@/app/components/base/features/new-feature-panel'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { updateFeatures } from '@/service/workflow'
import {
useIsChatMode,
useNodesReadOnly,
useNodesSyncDraft,
} from './hooks'
import useConfig from './nodes/start/use-config'
import { useStore } from './store'
@ -18,11 +21,11 @@ import { InputVarType } from './types'
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)
@ -40,10 +43,44 @@ 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,
}
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 {
import EditingTitle from './editing-title'
import EnvButton from './env-button'
import GlobalVariableButton from './global-variable-button'
import OnlineUsers from './online-users'
import RunAndHistory from './run-and-history'
import ScrollToSelectedNodeButton from './scroll-to-selected-node-button'
import VersionHistoryButton from './version-history-button'
@ -73,6 +74,8 @@ const HeaderInNormal = ({
<ScrollToSelectedNodeButton />
</div>
<div className="flex items-center gap-2">
<OnlineUsers />
{components?.left}
<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]">

View File

@ -4,12 +4,14 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import useTheme from '@/hooks/use-theme'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import { cn } from '@/utils/classnames'
import Toast from '../../base/toast'
import {
useNodesSyncDraft,
useLeaderRestore,
useWorkflowRun,
} from '../hooks'
import { useHooksStore } from '../hooks-store'
@ -31,6 +33,8 @@ const HeaderInRestoring = ({
const { t } = useTranslation()
const { theme } = useTheme()
const workflowStore = useWorkflowStore()
const userProfile = useAppContextSelector(s => s.userProfile)
const featuresStore = useFeaturesStore()
const configsMap = useHooksStore(s => s.configsMap)
const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId)
const {
@ -42,7 +46,7 @@ const HeaderInRestoring = ({
const {
handleLoadBackupDraft,
} = useWorkflowRun()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { requestRestore } = useLeaderRestore()
const handleCancelRestore = useCallback(() => {
handleLoadBackupDraft()
@ -51,10 +55,32 @@ const HeaderInRestoring = ({
}, [workflowStore, handleLoadBackupDraft, setShowWorkflowVersionHistoryPanel])
const handleRestore = useCallback(() => {
if (!currentVersion)
return
setShowWorkflowVersionHistoryPanel(false)
workflowStore.setState({ isRestoring: false })
workflowStore.setState({ backupDraft: undefined })
handleSyncWorkflowDraft(true, false, {
const { graph } = currentVersion
const features = featuresStore?.getState().features
const environmentVariables = currentVersion.environment_variables || []
const conversationVariables = currentVersion.conversation_variables || []
requestRestore({
versionId: currentVersion.id,
versionName: currentVersion.marked_name,
initiatorUserId: userProfile.id,
initiatorName: userProfile.name,
graphData: {
nodes: graph.nodes,
edges: graph.edges,
viewport: graph.viewport,
},
features,
environmentVariables,
conversationVariables,
}, {
onSuccess: () => {
Toast.notify({
type: 'success',
@ -73,7 +99,7 @@ const HeaderInRestoring = ({
})
deleteAllInspectVars()
invalidAllLastRun()
}, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
}, [currentVersion, featuresStore, setShowWorkflowVersionHistoryPanel, workflowStore, requestRestore, userProfile, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
return (
<>

View File

@ -0,0 +1,241 @@
'use client'
import type { OnlineUser } from '../collaboration/types'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { useEffect, useState } from 'react'
import { useReactFlow } from 'reactflow'
import Avatar from '@/app/components/base/avatar'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Tooltip from '@/app/components/base/tooltip'
import { useAppContext } from '@/context/app-context'
import { getAvatar } from '@/service/common'
import { cn } from '@/utils/classnames'
import { useCollaboration } from '../collaboration/hooks/use-collaboration'
import { getUserColor } from '../collaboration/utils/user-color'
import { useStore } from '../store'
const useAvatarUrls = (users: OnlineUser[]) => {
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: OnlineUser,
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: OnlineUser) => {
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

@ -5,28 +5,42 @@ import {
} from '@remixicon/react'
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
import { cn } from '@/utils/classnames'
import Divider from '../../base/divider'
import TipPopup from '../operator/tip-popup'
import { useWorkflowHistoryStore } from '../workflow-history-store'
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
Promise.resolve().then(() => {
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

@ -1,4 +1,5 @@
import type { FileUpload } from '../../base/features/types'
import type { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import type {
BlockEnum,
Node,
@ -9,7 +10,7 @@ import type {
import type { IOtherOptions } from '@/service/base'
import type { SchemaTypeDefinition } from '@/service/use-common'
import type { FlowType } from '@/types/common'
import type { VarInInspect } from '@/types/workflow'
import type { FetchWorkflowDraftResponse, VarInInspect } from '@/types/workflow'
import { noop } from 'es-toolkit/function'
import { useContext } from 'react'
import {
@ -19,6 +20,13 @@ import { createStore } from 'zustand/vanilla'
import { InteractionMode } from '@/app/components/workflow'
import { HooksStoreContext } from './provider'
export type WorkflowRunOptions = {
mode?: TriggerType
scheduleNodeId?: string
webhookNodeId?: string
pluginNodeId?: string
allNodeIds?: string[]
}
export type AvailableNodesMetaData = {
nodes: NodeDefaultBase[]
nodesMap: Record<BlockEnum, NodeDefaultBase>
@ -39,9 +47,9 @@ export type CommonHooksFnMap = {
handleRefreshWorkflowDraft: () => void
handleBackupDraft: () => void
handleLoadBackupDraft: () => void
handleRestoreFromPublishedWorkflow: (...args: any[]) => void
handleRun: (params: any, callback?: IOtherOptions, options?: any) => void
handleStopRun: (...args: any[]) => void
handleRestoreFromPublishedWorkflow: (publishedWorkflow: FetchWorkflowDraftResponse) => void
handleRun: (params: unknown, callback?: IOtherOptions, options?: WorkflowRunOptions) => void | Promise<void>
handleStopRun: (taskId: string) => void
handleStartWorkflowRun: () => void
handleWorkflowStartRunInWorkflow: () => void
handleWorkflowStartRunInChatflow: () => void
@ -58,7 +66,7 @@ export type CommonHooksFnMap = {
hasNodeInspectVars: (nodeId: string) => boolean
hasSetInspectVar: (nodeId: string, name: string, sysVars: VarInInspect[], conversationVars: VarInInspect[]) => boolean
fetchInspectVarValue: (selector: ValueSelector, schemaTypeDefinitions: SchemaTypeDefinition[]) => Promise<void>
editInspectVarValue: (nodeId: string, varId: string, value: any) => Promise<void>
editInspectVarValue: (nodeId: string, varId: string, value: unknown) => Promise<void>
renameInspectVarName: (nodeId: string, oldName: string, newName: string) => Promise<void>
appendNodeInspectVars: (nodeId: string, payload: VarInInspect[], allNodes: Node[]) => void
deleteInspectVar: (nodeId: string, varId: string) => Promise<void>
@ -72,7 +80,7 @@ export type CommonHooksFnMap = {
configsMap?: {
flowId: string
flowType: FlowType
fileSettings: FileUpload
fileSettings?: FileUpload
}
}
@ -87,9 +95,9 @@ export const createHooksStore = ({
handleRefreshWorkflowDraft = noop,
handleBackupDraft = noop,
handleLoadBackupDraft = noop,
handleRestoreFromPublishedWorkflow = noop,
handleRestoreFromPublishedWorkflow = (_publishedWorkflow: FetchWorkflowDraftResponse) => noop(),
handleRun = noop,
handleStopRun = noop,
handleStopRun = (_taskId: string) => noop(),
handleStartWorkflowRun = noop,
handleWorkflowStartRunInWorkflow = noop,
handleWorkflowStartRunInChatflow = noop,

View File

@ -1,9 +1,11 @@
export * from './use-auto-generate-webhook-url'
export * from './use-auto-generate-webhook-url'
export * from './use-available-blocks'
export * from './use-checklist'
export * from './use-DSL'
export * from './use-edges-interactions'
export * from './use-inspect-vars-crud'
export * from './use-leader-restore'
export * from './use-node-data-update'
export * from './use-nodes-interactions'
export * from './use-nodes-layout'
@ -12,10 +14,12 @@ export * from './use-nodes-sync-draft'
export * from './use-panel-interactions'
export * from './use-selection-interactions'
export * from './use-serial-async-callback'
export * from './use-serial-async-callback'
export * from './use-set-workflow-vars-with-value'
export * from './use-shortcuts'
export * from './use-tool-icon'
export * from './use-workflow'
export * from './use-workflow-comment'
export * from './use-workflow-history'
export * from './use-workflow-interactions'
export * from './use-workflow-mode'

View File

@ -363,7 +363,10 @@ export const useChecklistBeforePublish = () => {
usedVars = getNodeUsedVars(node).filter(v => v.length > 0)
}
const checkData = getCheckData(node.data, datasets)
const { errorMessage } = nodesExtraData![node.data.type as BlockEnum].checkValid(checkData, t, moreDataForCheckValid)
const nodeMetaData = nodesExtraData?.[node.data.type as BlockEnum]
if (!nodeMetaData)
continue
const { errorMessage } = nodeMetaData.checkValid(checkData, t, moreDataForCheckValid)
if (errorMessage) {
notify({ type: 'error', message: `[${node.data.title}] ${errorMessage}` })

View File

@ -0,0 +1,84 @@
import type { Edge, Node } from '../types'
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
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

@ -7,69 +7,60 @@ import type {
} from '../types'
import { produce } from 'immer'
import { useCallback } from 'react'
import {
useStoreApi,
} from 'reactflow'
import { BlockEnum } from '../types'
import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils'
import { useCollaborativeWorkflow } from './use-collaborative-workflow'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useNodesReadOnly } from './use-workflow'
import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history'
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,
@ -91,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()
// collect edges to delete (including corresponding real edges for temp edges)
const edgesToDelete: Set<string> = new Set([currentEdge.id])
@ -179,7 +169,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())
@ -188,7 +178,7 @@ export const useEdgesInteractions = () => {
const {
edges,
setEdges,
} = store.getState()
} = collaborativeWorkflow.getState()
const newEdges = produce(edges, (draft) => {
changes.forEach((change) => {
@ -197,7 +187,7 @@ export const useEdgesInteractions = () => {
})
})
setEdges(newEdges)
}, [store, getNodesReadOnly])
}, [collaborativeWorkflow, getNodesReadOnly])
return {
handleEdgeEnter,

View File

@ -0,0 +1,168 @@
import type { RestoreCompleteData, RestoreIntentData, RestoreRequestData } from '../collaboration/types/collaboration'
import type { SyncCallback } from './use-nodes-sync-draft'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useReactFlow } from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import Toast from '@/app/components/base/toast'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { collaborationManager } from '../collaboration/core/collaboration-manager'
import { useWorkflowStore } from '../store'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
type RestoreCallbacks = SyncCallback
export const usePerformRestore = () => {
const { doSyncWorkflowDraft } = useNodesSyncDraft()
const appDetail = useAppStore.getState().appDetail
const featuresStore = useFeaturesStore()
const workflowStore = useWorkflowStore()
const reactflow = useReactFlow()
return useCallback((data: RestoreRequestData, callbacks?: RestoreCallbacks) => {
collaborationManager.emitRestoreIntent({
versionId: data.versionId,
versionName: data.versionName,
initiatorUserId: data.initiatorUserId,
initiatorName: data.initiatorName,
})
if (data.features && featuresStore) {
const { setFeatures } = featuresStore.getState()
setFeatures(data.features)
}
if (data.environmentVariables) {
workflowStore.getState().setEnvironmentVariables(data.environmentVariables)
}
if (data.conversationVariables) {
workflowStore.getState().setConversationVariables(data.conversationVariables)
}
const { nodes, edges, viewport } = data.graphData
const currentNodes = collaborationManager.getNodes()
const currentEdges = collaborationManager.getEdges()
collaborationManager.setNodes(currentNodes, nodes)
collaborationManager.setEdges(currentEdges, edges)
collaborationManager.refreshGraphSynchronously()
if (viewport)
reactflow.setViewport(viewport)
doSyncWorkflowDraft(false, {
onSuccess: () => {
collaborationManager.emitRestoreComplete({
versionId: data.versionId,
success: true,
})
if (appDetail)
collaborationManager.emitWorkflowUpdate(appDetail.id)
callbacks?.onSuccess?.()
},
onError: () => {
collaborationManager.emitRestoreComplete({
versionId: data.versionId,
success: false,
error: 'Failed to sync restore to server',
})
callbacks?.onError?.()
},
onSettled: () => {
callbacks?.onSettled?.()
},
})
}, [appDetail, doSyncWorkflowDraft, featuresStore, reactflow, workflowStore])
}
export const useLeaderRestoreListener = () => {
const { t } = useTranslation()
const performRestore = usePerformRestore()
useEffect(() => {
const unsubscribe = collaborationManager.onRestoreRequest((data: RestoreRequestData) => {
Toast.notify({
type: 'info',
message: t('versionHistory.action.restoreInProgress', {
ns: 'workflow',
userName: data.initiatorName,
versionName: data.versionName || data.versionId,
}),
duration: 3000,
})
performRestore(data)
})
return unsubscribe
}, [performRestore, t])
useEffect(() => {
const unsubscribe = collaborationManager.onRestoreIntent((data: RestoreIntentData) => {
Toast.notify({
type: 'info',
message: t('versionHistory.action.restoreInProgress', {
ns: 'workflow',
userName: data.initiatorName,
versionName: data.versionName || data.versionId,
}),
duration: 3000,
})
})
return unsubscribe
}, [t])
}
export const useLeaderRestore = () => {
const performRestore = usePerformRestore()
const pendingCallbacksRef = useRef<{
versionId: string
callbacks: RestoreCallbacks | null
} | null>(null)
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
const requestRestore = useCallback((data: RestoreRequestData, callbacks?: RestoreCallbacks) => {
if (!isCollaborationEnabled || !collaborationManager.isConnected() || collaborationManager.getIsLeader()) {
performRestore(data, callbacks)
return
}
pendingCallbacksRef.current = {
versionId: data.versionId,
callbacks: callbacks || null,
}
collaborationManager.emitRestoreRequest(data)
}, [isCollaborationEnabled, performRestore])
useEffect(() => {
const unsubscribe = collaborationManager.onRestoreComplete((data: RestoreCompleteData) => {
const pending = pendingCallbacksRef.current
if (!pending || pending.versionId !== data.versionId)
return
const callbacks = pending.callbacks
if (!callbacks) {
pendingCallbacksRef.current = null
return
}
if (data.success)
callbacks.onSuccess?.()
else
callbacks.onError?.()
callbacks.onSettled?.()
pendingCallbacksRef.current = null
})
return unsubscribe
}, [])
return {
requestRestore,
}
}

View File

@ -1,33 +1,30 @@
import type { SyncCallback } from './use-nodes-sync-draft'
import { produce } from 'immer'
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import { useCollaborativeWorkflow } from './use-collaborative-workflow'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useNodesReadOnly } from './use-workflow'
type NodeDataUpdatePayload = {
id: string
data: Record<string, any>
data: Record<string, unknown>
}
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)
currentNode.data = { ...currentNode.data, ...data }
})
setNodes(newNodes)
}, [store])
}, [collaborativeWorkflow])
const handleNodeDataUpdateWithSyncDraft = useCallback((
payload: NodeDataUpdatePayload,

View File

@ -21,8 +21,8 @@ import {
getConnectedEdges,
getOutgoers,
useReactFlow,
useStoreApi,
} from 'reactflow'
import { collaborationManager } from '../collaboration/core/collaboration-manager'
import {
CUSTOM_EDGE,
ITERATION_CHILDREN_Z_INDEX,
@ -40,7 +40,8 @@ import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
import { useNodeLoopInteractions } from '../nodes/loop/use-interactions'
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
import { useWorkflowStore } from '../store'
import { BlockEnum, isTriggerNode } from '../types'
import { BlockEnum, ControlMode, isTriggerNode } from '../types'
import {
generateNewNode,
genNewNodeTitleFromOld,
@ -51,6 +52,7 @@ import {
} from '../utils'
import { useWorkflowHistoryStore } from '../workflow-history-store'
import { useAutoGenerateWebhookUrl } from './use-auto-generate-webhook-url'
import { useCollaborativeWorkflow } from './use-collaborative-workflow'
import { useHelpline } from './use-helpline'
import useInspectVarsCrud from './use-inspect-vars-crud'
import { checkMakeGroupAvailability } from './use-make-group'
@ -222,7 +224,7 @@ function createGroupInboundEdges(params: {
export const useNodesInteractions = () => {
const { t } = useTranslation()
const store = useStoreApi()
const collaborativeWorkflow = useCollaborativeWorkflow()
const workflowStore = useWorkflowStore()
const reactflow = useReactFlow()
const { store: workflowHistoryStore } = useWorkflowHistoryStore()
@ -241,7 +243,7 @@ export const useNodesInteractions = () => {
})
const { nodesMap: nodesMetaDataMap } = useNodesMetaData()
const { saveStateToHistory, undo, redo } = useWorkflowHistory()
const { saveStateToHistory } = useWorkflowHistory()
const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl()
const handleNodeDragStart = useCallback<NodeDragHandler>(
@ -284,10 +286,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 }
@ -350,13 +351,7 @@ export const useNodesInteractions = () => {
})
setNodes(newNodes)
},
[
getNodesReadOnly,
store,
handleNodeIterationChildDrag,
handleNodeLoopChildDrag,
handleSetHelpline,
],
[getNodesReadOnly, collaborativeWorkflow, handleNodeIterationChildDrag, handleNodeLoopChildDrag, handleSetHelpline],
)
const handleNodeDragStop = useCallback<NodeDragHandler>(
@ -408,11 +403,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
@ -451,7 +446,7 @@ export const useNodesInteractions = () => {
}
})
})
setNodes(newNodes)
setNodes(newNodes, false)
}
}
const newEdges = produce(edges, (draft) => {
@ -463,9 +458,9 @@ export const useNodesInteractions = () => {
currentEdge.data._connectedNodeIsHovering = true
})
})
setEdges(newEdges)
setEdges(newEdges, false)
},
[store, workflowStore, getNodesReadOnly],
[collaborativeWorkflow, workflowStore, getNodesReadOnly],
)
const handleNodeLeave = useCallback<NodeMouseHandler>(
@ -489,21 +484,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(
@ -514,9 +509,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)
@ -529,7 +522,7 @@ export const useNodesInteractions = () => {
else node.data.selected = false
})
})
setNodes(newNodes)
setNodes(newNodes, false)
const connectedEdges = getConnectedEdges(
[{ id: nodeId } as Node],
@ -551,15 +544,16 @@ export const useNodesInteractions = () => {
}
})
})
setEdges(newEdges)
handleSyncWorkflowDraft()
setEdges(newEdges, false)
},
[store, handleSyncWorkflowDraft],
[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)
@ -570,7 +564,7 @@ export const useNodesInteractions = () => {
return
handleNodeSelect(node.id)
},
[handleNodeSelect],
[handleNodeSelect, workflowStore],
)
const handleNodeConnect = useCallback<OnConnect>(
@ -580,8 +574,7 @@ export const useNodesInteractions = () => {
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!)
@ -802,7 +795,7 @@ export const useNodesInteractions = () => {
},
[
getNodesReadOnly,
store,
collaborativeWorkflow,
workflowStore,
handleSyncWorkflowDraft,
saveStateToHistory,
@ -816,8 +809,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
@ -838,7 +831,7 @@ export const useNodesInteractions = () => {
})
}
},
[store, workflowStore, getNodesReadOnly],
[collaborativeWorkflow, workflowStore, getNodesReadOnly],
)
const handleNodeConnectEnd = useCallback<OnConnectEnd>(
@ -856,8 +849,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(
@ -915,7 +907,7 @@ export const useNodesInteractions = () => {
setConnectingNodePayload(undefined)
setEnteringNodePayload(undefined)
},
[store, handleNodeConnect, getNodesReadOnly, workflowStore, reactflow],
[collaborativeWorkflow, handleNodeConnect, getNodesReadOnly, workflowStore, reactflow],
)
const { deleteNodeInspectorVars } = useInspectVarsCrud()
@ -925,9 +917,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 currentNodeIndex = nodes.findIndex(node => node.id === nodeId)
const currentNode = nodes[currentNodeIndex]
@ -1085,7 +1075,7 @@ export const useNodesInteractions = () => {
},
[
getNodesReadOnly,
store,
collaborativeWorkflow,
handleSyncWorkflowDraft,
saveStateToHistory,
workflowStore,
@ -1108,12 +1098,14 @@ 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,
)
const { defaultValue } = nodesMetaDataMap![nodeType]
const nodeMetaData = nodesMetaDataMap?.[nodeType]
if (!nodeMetaData)
return
const { defaultValue } = nodeMetaData
const { newNode, newIterationStartNode, newLoopStartNode }
= generateNewNode({
type: getNodeCustomTypeByNodeDataType(nodeType),
@ -1827,7 +1819,7 @@ export const useNodesInteractions = () => {
},
[
getNodesReadOnly,
store,
collaborativeWorkflow,
handleSyncWorkflowDraft,
saveStateToHistory,
workflowStore,
@ -1846,14 +1838,16 @@ 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(
node => node.data.type === nodeType,
)
const { defaultValue } = nodesMetaDataMap![nodeType]
const nodeMetaData = nodesMetaDataMap?.[nodeType]
if (!nodeMetaData)
return
const { defaultValue } = nodeMetaData
const {
newNode: newCurrentNode,
newIterationStartNode,
@ -1934,7 +1928,7 @@ export const useNodesInteractions = () => {
},
[
getNodesReadOnly,
store,
collaborativeWorkflow,
handleSyncWorkflowDraft,
saveStateToHistory,
nodesMetaDataMap,
@ -1943,16 +1937,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) => {
@ -1992,9 +1984,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
@ -2018,7 +2008,9 @@ export const useNodesInteractions = () => {
return false
if (node.type === CUSTOM_NOTE_NODE)
return true
const { metaData } = nodesMetaDataMap![node.data.type as BlockEnum]
const metaData = nodesMetaDataMap?.[node.data.type as BlockEnum]?.metaData
if (!metaData)
return false
if (metaData.isSingleton)
return false
return !node.data.isInIteration && !node.data.isInLoop
@ -2034,7 +2026,9 @@ export const useNodesInteractions = () => {
return false
if (node.type === CUSTOM_NOTE_NODE)
return true
const { metaData } = nodesMetaDataMap![node.data.type as BlockEnum]
const metaData = nodesMetaDataMap?.[node.data.type as BlockEnum]?.metaData
if (!metaData)
return false
return !metaData.isSingleton
})
@ -2042,7 +2036,7 @@ export const useNodesInteractions = () => {
setClipboardElements([selectedNode])
}
},
[getNodesReadOnly, store, workflowStore],
[getNodesReadOnly, collaborativeWorkflow, workflowStore],
)
const handleNodesPaste = useCallback(() => {
@ -2051,11 +2045,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)
@ -2070,12 +2063,15 @@ export const useNodesInteractions = () => {
const parentChildrenToAppend: { parentId: string, childId: string, childType: BlockEnum }[] = []
clipboardElements.forEach((nodeToPaste, index) => {
const nodeType = nodeToPaste.data.type
const nodeDefaultValue = nodeToPaste.type !== CUSTOM_NOTE_NODE
? nodesMetaDataMap?.[nodeType]?.defaultValue
: undefined
const { newNode, newIterationStartNode, newLoopStartNode }
= generateNewNode({
type: nodeToPaste.type,
data: {
...(nodeToPaste.type !== CUSTOM_NOTE_NODE && nodesMetaDataMap![nodeType].defaultValue),
...(nodeDefaultValue || {}),
...nodeToPaste.data,
selected: false,
_isBundled: false,
@ -2217,7 +2213,7 @@ export const useNodesInteractions = () => {
}, [
getNodesReadOnly,
workflowStore,
store,
collaborativeWorkflow,
reactflow,
saveStateToHistory,
handleSyncWorkflowDraft,
@ -2241,9 +2237,8 @@ export const useNodesInteractions = () => {
if (getNodesReadOnly())
return
const { getNodes, edges } = store.getState()
const { nodes, edges } = collaborativeWorkflow.getState()
const nodes = getNodes()
const bundledNodes = nodes.filter(
node => node.data._isBundled,
)
@ -2264,17 +2259,16 @@ 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),
@ -2335,7 +2329,7 @@ export const useNodesInteractions = () => {
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeResize, { nodeId })
},
[getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory],
[getNodesReadOnly, collaborativeWorkflow, handleSyncWorkflowDraft, saveStateToHistory],
)
const handleNodeDisconnect = useCallback(
@ -2343,8 +2337,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 === nodeId)!
const connectedEdges = getConnectedEdges([currentNode], edges)
const nodesConnectedSourceOrTargetHandleIdsMap
@ -2375,25 +2368,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
collaborationManager.undo()
const { edges, nodes } = workflowHistoryStore.getState()
if (edges.length === 0 && nodes.length === 0)
return
const { setNodes, setEdges } = collaborativeWorkflow.getState()
setEdges(edges)
setNodes(nodes)
}, [
store,
undo,
collaborativeWorkflow,
workflowHistoryStore,
getNodesReadOnly,
getWorkflowReadOnly,
@ -2403,18 +2395,17 @@ export const useNodesInteractions = () => {
if (getNodesReadOnly() || getWorkflowReadOnly())
return
const { setEdges, setNodes } = store.getState()
redo()
// Use collaborative redo from Loro
collaborationManager.redo()
const { edges, nodes } = workflowHistoryStore.getState()
if (edges.length === 0 && nodes.length === 0)
return
const { setNodes, setEdges } = collaborativeWorkflow.getState()
setEdges(edges)
setNodes(nodes)
}, [
redo,
store,
collaborativeWorkflow,
workflowHistoryStore,
getNodesReadOnly,
getWorkflowReadOnly,
@ -2425,8 +2416,7 @@ export const useNodesInteractions = () => {
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)
@ -2525,12 +2515,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) => {
@ -2550,18 +2539,16 @@ export const useNodesInteractions = () => {
},
)
setEdges(newEdges)
}, [store])
}, [collaborativeWorkflow])
// Check if there are any nodes selected via box selection
const hasBundledNodes = useCallback(() => {
const { getNodes } = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
return nodes.some(node => node.data._isBundled)
}, [store])
}, [collaborativeWorkflow])
const getCanMakeGroup = useCallback(() => {
const { getNodes, edges } = store.getState()
const nodes = getNodes()
const { nodes, edges } = collaborativeWorkflow.getState()
const bundledNodes = nodes.filter(node => node.data._isBundled)
if (bundledNodes.length <= 1)
@ -2578,11 +2565,10 @@ export const useNodesInteractions = () => {
const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges, hasGroupNode)
return canMakeGroup
}, [store])
}, [collaborativeWorkflow])
const handleMakeGroup = useCallback(() => {
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
const bundledNodes = nodes.filter(node => node.data._isBundled)
if (bundledNodes.length <= 1)
@ -2771,35 +2757,32 @@ export const useNodesInteractions = () => {
saveStateToHistory(WorkflowHistoryEvent.NodeAdd, {
nodeId: groupNode.id,
})
}, [handleSyncWorkflowDraft, saveStateToHistory, store, t, workflowStore])
}, [handleSyncWorkflowDraft, saveStateToHistory, collaborativeWorkflow, t, workflowStore])
// check if the current selection can be ungrouped (single selected Group node)
const getCanUngroup = useCallback(() => {
const { getNodes } = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
const selectedNodes = nodes.filter(node => node.selected)
if (selectedNodes.length !== 1)
return false
return selectedNodes[0].data.type === BlockEnum.Group
}, [store])
}, [collaborativeWorkflow])
// get the selected group node id for ungroup operation
const getSelectedGroupId = useCallback(() => {
const { getNodes } = store.getState()
const nodes = getNodes()
const { nodes } = collaborativeWorkflow.getState()
const selectedNodes = nodes.filter(node => node.selected)
if (selectedNodes.length === 1 && selectedNodes[0].data.type === BlockEnum.Group)
return selectedNodes[0].id
return undefined
}, [store])
}, [collaborativeWorkflow])
const handleUngroup = useCallback((groupId: string) => {
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
const groupNode = nodes.find(n => n.id === groupId)
if (!groupNode || groupNode.data.type !== BlockEnum.Group)
@ -2846,7 +2829,7 @@ export const useNodesInteractions = () => {
saveStateToHistory(WorkflowHistoryEvent.NodeDelete, {
nodeId: groupId,
})
}, [handleSyncWorkflowDraft, saveStateToHistory, store])
}, [handleSyncWorkflowDraft, saveStateToHistory, collaborativeWorkflow])
return {
handleNodeDragStart,

View File

@ -1,5 +1,6 @@
import { useCallback } from 'react'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import { collaborationManager } from '../collaboration/core/collaboration-manager'
import { useStore } from '../store'
import { useNodesReadOnly } from './use-workflow'
@ -18,11 +19,21 @@ export const useNodesSyncDraft = () => {
const handleSyncWorkflowDraft = useCallback((
sync?: boolean,
notRefreshWhenSyncError?: boolean,
callback?: SyncCallback,
callback?: {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void
},
) => {
if (getNodesReadOnly())
return
if (collaborationManager.isConnected() && !collaborationManager.getIsLeader()) {
if (sync)
collaborationManager.emitSyncRequest()
return
}
if (sync)
doSyncWorkflowDraft(notRefreshWhenSyncError, callback)
else

View File

@ -41,6 +41,8 @@ export const useShortcuts = (enabled = true): void => {
const {
handleModeHand,
handleModePointer,
handleModeComment,
isCommentModeAvailable,
} = useWorkflowMoveMode()
const { handleLayout } = useWorkflowOrganize()
const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize()
@ -182,6 +184,16 @@ export const useShortcuts = (enabled = true): 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,535 @@
import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
import { useParams } from 'next/navigation'
import { useCallback, useEffect, useRef } from 'react'
import { useReactFlow } from 'reactflow'
import { collaborationManager } from '@/app/components/workflow/collaboration'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { createWorkflowComment, createWorkflowCommentReply, deleteWorkflowComment, deleteWorkflowCommentReply, fetchWorkflowComment, fetchWorkflowComments, resolveWorkflowComment, updateWorkflowComment, updateWorkflowCommentReply } from '@/service/workflow-comment'
import { useStore } from '../store'
import { ControlMode } from '../types'
const EMPTY_USERS: UserProfile[] = []
type CommentDetailResponse = WorkflowCommentDetail | { data: WorkflowCommentDetail }
const getCommentDetail = (response: CommentDetailResponse): WorkflowCommentDetail => {
if ('data' in response)
return response.data
return response
}
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] ?? EMPTY_USERS : EMPTY_USERS
))
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) as CommentDetailResponse
const detail = getCommentDetail(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
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,
})
const createdAt = Number(newComment.created_at)
const createdAtSeconds = Number.isNaN(createdAt)
? Math.floor(Date.parse(newComment.created_at) / 1000)
: createdAt
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: createdAtSeconds,
updated_at: createdAtSeconds,
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: createdAtSeconds,
updated_at: createdAtSeconds,
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) as CommentDetailResponse
const detail = getCommentDetail(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)
setPendingComment(mousePosition)
}, [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

@ -4,8 +4,11 @@ import { produce } from 'immer'
import {
useCallback,
} from 'react'
import { useReactFlow, useStoreApi } from 'reactflow'
import { useReactFlow } from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useGlobalPublicStore } from '@/context/global-public-context'
import {
CUSTOM_NODE,
NODE_LAYOUT_HORIZONTAL_PADDING,
@ -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)
@ -233,7 +248,7 @@ export const useWorkflowOrganize = () => {
setTimeout(() => {
handleSyncWorkflowDraft()
})
}, [getNodesReadOnly, store, reactflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory])
}, [getNodesReadOnly, collaborativeWorkflow, reactflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory])
return {
handleLayout,

View File

@ -16,9 +16,9 @@ import {
import {
getIncomers,
getOutgoers,
useStoreApi,
} from 'reactflow'
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 { AppModeEnum } from '@/types/app'
@ -38,6 +38,7 @@ import {
getWorkflowEntryNode,
isWorkflowEntryNode,
} from '../utils/workflow-entry'
import { useAvailableBlocks } from './use-available-blocks'
export const useIsChatMode = () => {
@ -47,26 +48,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) || []
@ -109,14 +102,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[] = []
@ -159,14 +149,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)
@ -174,14 +161,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)
@ -205,40 +188,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)
@ -261,11 +233,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) => {
@ -276,7 +247,7 @@ export const useWorkflow = () => {
})
setNodes(newNodes)
}
}, [store])
}, [collaborativeWorkflow])
const isVarUsedInNodes = useCallback((varSelector: ValueSelector) => {
const nodeId = varSelector[0]
@ -287,11 +258,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, [])
@ -299,7 +270,7 @@ export const useWorkflow = () => {
})
setNodes(newNodes)
}
}, [getAfterNodesInSameBranch, store])
}, [getAfterNodesInSameBranch, collaborativeWorkflow])
const isNodeVarsUsedInNodes = useCallback((node: Node, isChatMode: boolean) => {
const outputVars = getNodeOutputVars(node, isChatMode)
@ -310,11 +281,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[] = []
@ -354,7 +321,7 @@ export const useWorkflow = () => {
return uniqBy(rootNodes, 'id')
return []
}, [store])
}, [collaborativeWorkflow])
const getStartNodes = useCallback((nodes: Node[], currentNode?: Node) => {
const { id, parentId } = currentNode || {}
@ -380,11 +347,7 @@ export const useWorkflow = () => {
}, [nodesMap, getRootNodesById])
const isValidConnection = useCallback(({ source, 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)!
@ -447,14 +410,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,
@ -510,13 +472,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)
@ -526,20 +485,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)
@ -550,7 +506,7 @@ export const useIsNodeInLoop = (loopId: string) => {
return true
return false
}, [loopId, store])
}, [loopId, collaborativeWorkflow])
return {
isNodeInLoop,
}

View File

@ -5,9 +5,13 @@ import type {
NodeMouseHandler,
Viewport,
} from 'reactflow'
import type { CursorPosition, OnlineUser } from './collaboration/types'
import type { Shape as HooksStoreShape } from './hooks-store'
import type { WorkflowSliceShape } from './store/workflow/workflow-slice'
import type {
ConversationVariable,
Edge,
EnvironmentVariable,
Node,
} from './types'
import type { VarInInspect } from '@/types/workflow'
@ -18,6 +22,7 @@ import { isEqual } from 'es-toolkit/predicate'
import { setAutoFreeze } from 'immer'
import dynamic from 'next/dynamic'
import {
Fragment,
memo,
useCallback,
useEffect,
@ -25,6 +30,7 @@ import {
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import ReactFlow, {
Background,
ReactFlowProvider,
@ -47,6 +53,10 @@ import {
import { fetchAllInspectVars } from '@/service/workflow'
import { cn } from '@/utils/classnames'
import CandidateNode from './candidate-node'
import { collaborationManager } from './collaboration'
import UserCursors from './collaboration/components/user-cursors'
import { CommentCursor, CommentIcon, CommentInput, CommentThread } from './comment'
import CommentManager from './comment-manager'
import {
CUSTOM_EDGE,
CUSTOM_NODE,
@ -67,6 +77,7 @@ import DatasetsDetailProvider from './datasets-detail-store/provider'
import HelpLine from './help-line'
import {
useEdgesInteractions,
useLeaderRestoreListener,
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
@ -79,6 +90,7 @@ import {
useWorkflowRefreshDraft,
} from './hooks'
import { HooksStoreContextProvider, useHooksStore } from './hooks-store'
import { useWorkflowComment } from './hooks/use-workflow-comment'
import { useWorkflowSearch } from './hooks/use-workflow-search'
import NodeContextmenu from './node-contextmenu'
import CustomNode from './nodes'
@ -139,15 +151,28 @@ export enum InteractionMode {
Subgraph = 'subgraph',
}
type WorkflowDataUpdatePayload = {
nodes: Node[]
edges: Edge[]
viewport?: Viewport
hash?: string
features?: unknown
conversation_variables?: ConversationVariable[]
environment_variables?: EnvironmentVariable[]
}
export type WorkflowProps = {
nodes: Node[]
edges: Edge[]
viewport?: Viewport
children?: React.ReactNode
onWorkflowDataUpdate?: (v: any) => void
onWorkflowDataUpdate?: (v: WorkflowDataUpdatePayload) => void
allowSelectionWhenReadOnly?: boolean
canvasReadOnly?: boolean
interactionMode?: InteractionMode
cursors?: Record<string, CursorPosition>
myUserId?: string | null
onlineUsers?: OnlineUser[]
}
export const Workflow: FC<WorkflowProps> = memo(({
nodes: originalNodes,
@ -158,10 +183,15 @@ export const Workflow: FC<WorkflowProps> = memo(({
allowSelectionWhenReadOnly = false,
canvasReadOnly = false,
interactionMode = 'default',
cursors,
myUserId,
onlineUsers,
}) => {
const workflowContainerRef = useRef<HTMLDivElement>(null)
const workflowStore = useWorkflowStore()
const reactflow = useReactFlow()
const store = useStoreApi()
const [isMouseOverCanvas, setIsMouseOverCanvas] = useState(false)
const [nodes, setNodes] = useNodesState(originalNodes)
const [edges, setEdges] = useEdgesState(originalEdges)
const controlMode = useStore(s => s.controlMode)
@ -217,6 +247,18 @@ export const Workflow: FC<WorkflowProps> = memo(({
useEffect(() => {
setNodesOnlyChangeWithData(currentNodes as Node[])
}, [currentNodes, setNodesOnlyChangeWithData])
useEffect(() => {
return collaborationManager.onGraphImport(({ nodes: importedNodes, edges: importedEdges }) => {
if (!isEqual(nodes, importedNodes)) {
setNodes(importedNodes)
store.getState().setNodes(importedNodes)
}
if (!isEqual(edges, importedEdges)) {
setEdges(importedEdges)
store.getState().setEdges(importedEdges)
}
})
}, [edges, nodes, setEdges, setNodes, store])
const {
handleSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose,
@ -224,8 +266,47 @@ 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,
handleCommentReply,
handleCommentReplyUpdate,
handleCommentReplyDelete,
handleCommentPositionUpdate,
} = useWorkflowComment()
const showUserComments = useStore(s => s.showUserComments)
const showUserCursors = useStore(s => s.showUserCursors)
const showResolvedComments = useStore(s => s.showResolvedComments)
const isCommentPreviewHovering = useStore(s => s.isCommentPreviewHovering)
const setPendingCommentState = useStore(s => s.setPendingComment)
const isCommentInputActive = Boolean(pendingComment)
const { t } = useTranslation()
const visibleComments = useMemo(() => {
if (showResolvedComments)
return comments
return comments.filter(comment => !comment.resolved)
}, [comments, showResolvedComments])
const handleVisibleCommentNavigate = useCallback((direction: 'prev' | 'next') => {
if (!activeComment)
return
const idx = visibleComments.findIndex(comment => comment.id === activeComment.id)
if (idx === -1)
return
const target = direction === 'prev' ? visibleComments[idx - 1] : visibleComments[idx + 1]
if (target)
handleCommentIconClick(target)
}, [activeComment, handleCommentIconClick, visibleComments])
const store = useStoreApi()
eventEmitter?.useSubscription((v: any) => {
if (v.type === WORKFLOW_DATA_UPDATE) {
if (interactionMode === InteractionMode.Subgraph)
@ -260,6 +341,10 @@ export const Workflow: FC<WorkflowProps> = memo(({
}
}, [handleSyncWorkflowDraft])
const handlePendingCommentPositionChange = useCallback((position: NonNullable<WorkflowSliceShape['pendingComment']>) => {
setPendingCommentState(position)
}, [setPendingCommentState])
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const handleSyncWorkflowDraftWhenPageClose = useCallback(() => {
if (document.visibilityState === 'hidden') {
@ -283,6 +368,33 @@ export const Workflow: FC<WorkflowProps> = memo(({
syncWorkflowDraftWhenPageClose()
}, [syncWorkflowDraftWhenPageClose])
// Optimized comment deletion using showConfirm
const handleCommentDeleteClick = useCallback((commentId: string) => {
if (!showConfirm) {
setShowConfirm({
title: t('comments.confirm.deleteThreadTitle', { ns: 'workflow' }),
desc: t('comments.confirm.deleteThreadDesc', { ns: 'workflow' }),
onConfirm: async () => {
await handleCommentDelete(commentId)
setShowConfirm(undefined)
},
})
}
}, [showConfirm, setShowConfirm, handleCommentDelete, t])
const handleCommentReplyDeleteClick = useCallback((commentId: string, replyId: string) => {
if (!showConfirm) {
setShowConfirm({
title: t('comments.confirm.deleteReplyTitle', { ns: 'workflow' }),
desc: t('comments.confirm.deleteReplyDesc', { ns: 'workflow' }),
onConfirm: async () => {
await handleCommentReplyDelete(commentId, replyId)
setShowConfirm(undefined)
},
})
}
}, [showConfirm, setShowConfirm, handleCommentReplyDelete, t])
useEffect(() => {
document.addEventListener('visibilitychange', handleSyncWorkflowDraftWhenPageClose)
window.addEventListener('beforeunload', handleBeforeUnload)
@ -315,9 +427,43 @@ 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)
}
})
// Prevent browser zoom interactions from hijacking gestures meant for the workflow canvas
useEffect(() => {
const preventBrowserZoom = (event: WheelEvent) => {
if (!isCommentPreviewHovering && !isCommentInputActive)
return
if (event.ctrlKey || event.metaKey)
event.preventDefault()
}
const preventGestureZoom = (event: Event) => {
if (!isCommentPreviewHovering && !isCommentInputActive)
return
event.preventDefault()
}
window.addEventListener('wheel', preventBrowserZoom, { passive: false })
const gestureEvents: Array<'gesturestart' | 'gesturechange' | 'gestureend'> = ['gesturestart', 'gesturechange', 'gestureend']
gestureEvents.forEach((eventName) => {
window.addEventListener(eventName, preventGestureZoom, { passive: false })
})
return () => {
window.removeEventListener('wheel', preventBrowserZoom)
gestureEvents.forEach((eventName) => {
window.removeEventListener(eventName, preventGestureZoom)
})
}
}, [isCommentPreviewHovering, isCommentInputActive])
const {
handleNodeDragStart,
handleNodeDrag,
@ -361,6 +507,8 @@ export const Workflow: FC<WorkflowProps> = memo(({
// Initialize workflow node search functionality
useWorkflowSearch()
useLeaderRestoreListener()
// Set up scroll to node event listener using the utility function
useEffect(() => {
return setupScrollToNodeListener(nodes, reactflow)
@ -431,7 +579,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',
)}
@ -439,8 +587,9 @@ export const Workflow: FC<WorkflowProps> = memo(({
>
<SyncingDataModal />
{!isSubGraph && <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-[60] flex w-12 items-center justify-center p-1 pl-2"
style={{ height: controlHeight }}
>
{!isSubGraph && <Control />}
@ -450,23 +599,84 @@ export const Workflow: FC<WorkflowProps> = memo(({
{!isSubGraph && <NodeContextmenu />}
{!isSubGraph && <SelectionContextmenu />}
{!isSubGraph && <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}
onPositionChange={handlePendingCommentPositionChange}
/>
)}
{visibleComments.map((comment, index) => {
const isActive = activeComment?.id === comment.id
if (isActive && activeComment) {
const canGoPrev = index > 0
const canGoNext = index < visibleComments.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 ? () => handleVisibleCommentNavigate('prev') : undefined}
onNext={canGoNext ? () => handleVisibleCommentNavigate('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}
@ -491,7 +701,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
defaultViewport={viewport}
multiSelectionKeyCode={null}
deleteKeyCode={null}
nodesDraggable={!(nodesReadOnly || canvasReadOnly || isSubGraph)}
nodesDraggable={!(nodesReadOnly || canvasReadOnly || isSubGraph) && controlMode !== ControlMode.Comment}
nodesConnectable={!(nodesReadOnly || canvasReadOnly || isSubGraph)}
nodesFocusable={allowSelectionWhenReadOnly ? true : !nodesReadOnly}
edgesFocusable={isSubGraph ? false : (allowSelectionWhenReadOnly ? true : !nodesReadOnly)}
@ -512,6 +722,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>
)
@ -519,14 +736,25 @@ export const Workflow: FC<WorkflowProps> = memo(({
type WorkflowWithInnerContextProps = WorkflowProps & {
hooksStore?: Partial<HooksStoreShape>
cursors?: Record<string, CursorPosition>
myUserId?: string | null
onlineUsers?: OnlineUser[]
}
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 { useTranslation } from 'react-i18next'
@ -28,10 +29,29 @@ 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(() => {
Promise.resolve().then(() => {
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

@ -22,6 +22,7 @@ import { useShallow } from 'zustand/react/shallow'
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 { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import {
@ -34,6 +35,8 @@ import {
import { usePluginStore } from '@/app/components/plugins/plugin-detail-panel/store'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import BlockIcon from '@/app/components/workflow/block-icon'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration'
import {
useAvailableBlocks,
useNodeDataUpdate,
@ -59,6 +62,7 @@ import {
hasRetryNode,
isSupportCustomRunForm,
} from '@/app/components/workflow/utils'
import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useAllBuiltInTools } from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
@ -109,11 +113,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)
@ -490,6 +534,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,19 +19,24 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import BlockIcon from '@/app/components/workflow/block-icon'
import { ToolTypeEnum } from '@/app/components/workflow/block-selector/types'
import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration'
import { useNodesReadOnly, useToolIcon } from '@/app/components/workflow/hooks'
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
import { useNodeIterationInteractions } from '@/app/components/workflow/nodes/iteration/use-interactions'
import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/use-interactions'
import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id'
import { useStore } from '@/app/components/workflow/store'
import {
BlockEnum,
ControlMode,
isTriggerNode,
NodeRunningStatus,
} from '@/app/components/workflow/types'
import { hasErrorHandleNode, hasRetryNode } from '@/app/components/workflow/utils'
import { useAppContext } from '@/context/app-context'
import { cn } from '@/utils/classnames'
import AddVariablePopupWithPosition from './components/add-variable-popup-with-position'
import EntryNodeContainer, { StartNodeTypeEnum } from './components/entry-node-container'
@ -72,6 +77,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) {
@ -223,6 +258,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',
@ -320,6 +356,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

@ -22,9 +22,10 @@ export const useReplaceDataSourceNode = (id: string) => {
if (emptyNodeIndex < 0)
return
const {
defaultValue,
} = nodesMetaDataMap![type]
const nodeMetaData = nodesMetaDataMap?.[type]
if (!nodeMetaData)
return
const { defaultValue } = nodeMetaData
const emptyNode = nodes[emptyNodeIndex]
const { newNode } = generateNewNode({
data: {

View File

@ -15,30 +15,50 @@ 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(() => {
Promise.resolve().then(() => {
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

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

View File

@ -6,8 +6,8 @@ import type {
import { produce } from 'immer'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import { useNodesMetaData } from '@/app/components/workflow/hooks'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import {
ITERATION_PADDING,
} from '../../constants'
@ -19,18 +19,16 @@ import { CUSTOM_ITERATION_START_NODE } from '../iteration-start/constants'
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 = {}
@ -120,6 +115,7 @@ export const useNodeIterationInteractions = () => {
const copyChildren = childrenNodes.map((child, index) => {
const childNodeType = child.data.type as BlockEnum
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
const defaultValue = nodesMetaDataMap?.[childNodeType]?.defaultValue ?? {}
if (!childNodeTypeCount[childNodeType])
childNodeTypeCount[childNodeType] = nodesWithSameType.length + 1
@ -129,7 +125,7 @@ export const useNodeIterationInteractions = () => {
const { newNode } = generateNewNode({
type: getNodeCustomTypeByNodeDataType(childNodeType),
data: {
...nodesMetaDataMap![childNodeType].defaultValue,
...defaultValue,
...child.data,
selected: false,
_isBundled: false,
@ -154,7 +150,7 @@ export const useNodeIterationInteractions = () => {
copyChildren,
newIdMapping,
}
}, [store, t])
}, [collaborativeWorkflow, t])
return {
handleNodeIterationRerender,

View File

@ -223,21 +223,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

@ -68,7 +68,7 @@ const AdvancedOptions: FC<AdvancedOptionsProps> = ({
>
<RiArrowDownDoubleLine className='h-3 w-3 text-text-tertiary' />
<span className='system-xs-regular text-text-tertiary'>
{t('workflow.nodes.llm.jsonSchema.showAdvancedOptions')}
{t('nodes.llm.jsonSchema.showAdvancedOptions', { ns: 'workflow' })}
</span>
</button>
)} */}

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('nodes.assigner.setParameter', { ns: 'workflow' }) as string}

View File

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

View File

@ -12,6 +12,7 @@ import type {
import { produce } from 'immer'
import {
useCallback,
useEffect,
useRef,
} from 'react'
import { v4 as uuid4 } from 'uuid'
@ -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

@ -4,8 +4,8 @@ import type {
} from '../../types'
import { produce } from 'immer'
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import { useNodesMetaData } from '@/app/components/workflow/hooks'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import {
LOOP_CHILDREN_Z_INDEX,
LOOP_PADDING,
@ -17,18 +17,15 @@ import {
import { CUSTOM_LOOP_START_NODE } from '../loop-start/constants'
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
@ -70,11 +67,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 }
@ -96,26 +92,24 @@ 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) => {
const childNodeType = child.data.type as BlockEnum
const { defaultValue } = nodesMetaDataMap![childNodeType]
const defaultValue = nodesMetaDataMap?.[childNodeType]?.defaultValue ?? {}
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
const { newNode } = generateNewNode({
type: getNodeCustomTypeByNodeDataType(childNodeType),
@ -141,7 +135,7 @@ export const useNodeLoopInteractions = () => {
newNode.id = `${newNodeId}${newNode.id + index}`
return newNode
})
}, [store, nodesMetaDataMap])
}, [collaborativeWorkflow, nodesMetaDataMap])
return {
handleNodeLoopRerender,

View File

@ -79,6 +79,7 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
<div className="flex gap-1" style={{ height: '32px' }}>
<div className="w-26 shrink-0">
<SimpleSelect
key={`${id}-method-${inputs.method}`}
items={HTTP_METHODS}
defaultValue={inputs.method}
onSelect={item => handleMethodChange(item.value as HttpMethod)}
@ -147,6 +148,7 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
<Field title={t(`${i18nPrefix}.contentType`, { ns: 'workflow' })}>
<div className="w-full">
<SimpleSelect
key={`${id}-content-type-${inputs.content_type}`}
items={CONTENT_TYPES}
defaultValue={inputs.content_type}
onSelect={item => handleContentTypeChange(item.value as string)}

View File

@ -6,19 +6,70 @@ import {
ListNode,
} from '@lexical/list'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $getRoot } from 'lexical'
import {
createContext,
memo,
useEffect,
useRef,
} from 'react'
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

@ -63,9 +63,10 @@ const AddBlock = ({
} = store.getState()
const nodes = getNodes()
const nodesWithSameType = nodes.filter(node => node.data.type === type)
const {
defaultValue,
} = nodesMetaDataMap![type]
const nodeMetaData = nodesMetaDataMap?.[type]
if (!nodeMetaData)
return
const { defaultValue } = nodeMetaData
const { newNode } = generateNewNode({
type: getNodeCustomTypeByNodeDataType(type),
data: {

View File

@ -11,6 +11,7 @@ import {
memo,
} from 'react'
import { useTranslation } from 'react-i18next'
import { Comment } from '@/app/components/base/icons/src/public/other'
import { cn } from '@/utils/classnames'
import Divider from '../../base/divider'
import {
@ -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('common.commentMode', { ns: 'workflow' })} 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('panel.organizeBlocks', { ns: 'workflow' })} shortcuts={['ctrl', 'o']}>
<div

View File

@ -1,8 +1,9 @@
import type { Node } from 'reactflow'
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { MiniMap } from 'reactflow'
import UndoRedo from '../header/undo-redo'
import { useStore } from '../store'
import { ControlMode } from '../types'
import VariableInspectPanel from '../variable-inspect'
import VariableTrigger from '../variable-inspect/trigger'
import ZoomInOut from './zoom-in-out'
@ -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

@ -1,5 +1,7 @@
import type { FC } from 'react'
import {
RiCheckLine,
RiFullscreenLine,
RiZoomInLine,
RiZoomOutLine,
} from '@remixicon/react'
@ -19,6 +21,7 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { cn } from '@/utils/classnames'
import Divider from '../../base/divider'
@ -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('operator.zoomToFit', { ns: 'workflow' }),
},
],
isCollaborationEnabled
? [
{
key: ZoomType.toggleUserComments,
text: t('operator.showUserComments', { ns: 'workflow' }),
},
{
key: ZoomType.toggleUserCursors,
text: t('operator.showUserCursors', { ns: 'workflow' }),
},
{
key: ZoomType.toggleMiniMap,
text: t('operator.showMiniMap', { ns: 'workflow' }),
},
]
: [
{
key: ZoomType.toggleMiniMap,
text: t('operator.showMiniMap', { ns: 'workflow' }),
},
],
]
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()
}
@ -182,8 +243,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}>
@ -197,10 +258,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

@ -8,39 +8,31 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
useStoreApi,
} from 'reactflow'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import { BubbleX, LongArrowLeft, LongArrowRight } from '@/app/components/base/icons/src/vender/line/others'
import BlockIcon from '@/app/components/workflow/block-icon'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import RemoveEffectVarConfirm from '@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm'
import { findUsedVarNodes, updateNodeVars } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import VariableItem from '@/app/components/workflow/panel/chat-variable-panel/components/variable-item'
import VariableModalTrigger from '@/app/components/workflow/panel/chat-variable-panel/components/variable-modal-trigger'
import { useStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { updateConversationVariables } from '@/service/workflow'
import { cn } from '@/utils/classnames'
import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud'
const ChatVariablePanel = () => {
const { t } = useTranslation()
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)
@ -48,40 +40,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)
@ -95,21 +110,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])
@ -117,8 +157,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,205 @@
import type { WorkflowCommentList } from '@/service/workflow-comment'
import { RiCheckboxCircleFill, RiCheckboxCircleLine, RiCheckLine, RiCloseLine, RiFilter3Line } from '@remixicon/react'
import { useParams } from 'next/navigation'
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Switch from '@/app/components/base/switch'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { collaborationManager } from '@/app/components/workflow/collaboration'
import { useWorkflowComment } from '@/app/components/workflow/hooks/use-workflow-comment'
import { useStore } from '@/app/components/workflow/store'
import { ControlMode } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { resolveWorkflowComment } from '@/service/workflow-comment'
import { cn } from '@/utils/classnames'
const CommentsPanel = () => {
const { t } = useTranslation()
const activeCommentId = useStore(s => s.activeCommentId)
const setActiveCommentId = useStore(s => s.setActiveCommentId)
const setControlMode = useStore(s => s.setControlMode)
const showResolvedComments = useStore(s => s.showResolvedComments)
const setShowResolvedComments = useStore(s => s.setShowResolvedComments)
const { comments, loading, loadComments, handleCommentIconClick } = useWorkflowComment()
const params = useParams()
const appId = params.appId as string
const { formatTimeFromNow } = useFormatTimeFromNow()
const [showOnlyMine, setShowOnlyMine] = 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 (!showResolvedComments)
data = data.filter(c => !c.resolved)
if (showOnlyMine)
data = data.filter(c => c.created_by === userProfile?.id)
return data
}, [comments, showOnlyMine, showResolvedComments, 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 || !showResolvedComments
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('comments.panelTitle', { ns: 'workflow' })}</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={showResolvedComments}
onChange={(checked) => {
setShowResolvedComments(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('comments.reply', { ns: 'workflow' })}
</div>
</div>
)}
</div>
</div>
)
})}
{!loading && filteredSorted.length === 0 && (
<div className="system-sm-regular mt-6 text-center text-text-tertiary">{t('comments.noComments', { ns: 'workflow' })}</div>
)}
</div>
</div>
)
}
export default memo(CommentsPanel)

View File

@ -8,26 +8,25 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
useStoreApi,
} from 'reactflow'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import RemoveEffectVarConfirm from '@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm'
import { findUsedVarNodes, updateNodeVars } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import EnvItem from '@/app/components/workflow/panel/env-panel/env-item'
import VariableTrigger from '@/app/components/workflow/panel/env-panel/variable-trigger'
import { useStore } from '@/app/components/workflow/store'
import { updateEnvironmentVariables } from '@/service/workflow'
import { cn } from '@/utils/classnames'
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 = useStore(s => s.appId) as string
const [showVariableModal, setShowVariableModal] = useState(false)
const [currentVar, setCurrentVar] = useState<EnvironmentVariable>()
@ -40,43 +39,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 +113,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 +175,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 +191,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

@ -71,11 +71,13 @@ vi.mock('@/service/use-workflow', () => ({
vi.mock('../../hooks', () => ({
useDSL: () => ({ handleExportDSL: vi.fn() }),
useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: vi.fn() }),
useWorkflowRun: () => ({
handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow,
handleLoadBackupDraft: mockHandleLoadBackupDraft,
}),
useLeaderRestore: () => ({
requestRestore: vi.fn(),
}),
}))
vi.mock('../../hooks-store', () => ({

View File

@ -7,10 +7,11 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import VersionInfoModal from '@/app/components/app/app-publisher/version-info-modal'
import Divider from '@/app/components/base/divider'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import Toast from '@/app/components/base/toast'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow'
import { useDSL, useNodesSyncDraft, useWorkflowRun } from '../../hooks'
import { useDSL, useLeaderRestore, useWorkflowRun } from '../../hooks'
import { useHooksStore } from '../../hooks-store'
import { useStore, useWorkflowStore } from '../../store'
import { VersionHistoryContextMenuOptions, WorkflowVersion, WorkflowVersionFilterOptions } from '../../types'
@ -43,8 +44,9 @@ export const VersionHistoryPanel = ({
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [editModalOpen, setEditModalOpen] = useState(false)
const workflowStore = useWorkflowStore()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun()
const { requestRestore } = useLeaderRestore()
const featuresStore = useFeaturesStore()
const { handleExportDSL } = useDSL()
const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel)
const currentVersion = useStore(s => s.currentVersion)
@ -150,7 +152,26 @@ export const VersionHistoryPanel = ({
handleRestoreFromPublishedWorkflow(item)
workflowStore.setState({ isRestoring: false })
workflowStore.setState({ backupDraft: undefined })
handleSyncWorkflowDraft(true, false, {
const { graph } = item
const features = featuresStore?.getState().features
const environmentVariables = item.environment_variables || []
const conversationVariables = item.conversation_variables || []
requestRestore({
versionId: item.id,
versionName: item.marked_name,
initiatorUserId: userProfile.id,
initiatorName: userProfile.name,
graphData: {
nodes: graph.nodes,
edges: graph.edges,
viewport: graph.viewport,
},
features,
environmentVariables,
conversationVariables,
}, {
onSuccess: () => {
Toast.notify({
type: 'success',
@ -169,7 +190,7 @@ export const VersionHistoryPanel = ({
resetWorkflowVersionHistory()
},
})
}, [setShowWorkflowVersionHistoryPanel, handleRestoreFromPublishedWorkflow, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, resetWorkflowVersionHistory])
}, [setShowWorkflowVersionHistoryPanel, handleRestoreFromPublishedWorkflow, workflowStore, featuresStore, requestRestore, userProfile, deleteAllInspectVars, invalidAllLastRun, t, resetWorkflowVersionHistory])
const { mutateAsync: deleteWorkflow } = useDeleteWorkflow()

View File

@ -18,9 +18,10 @@ import {
useRef,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
import { useStore as useReactFlowStore } from 'reactflow'
import { shallow } from 'zustand/shallow'
import Tooltip from '@/app/components/base/tooltip'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useMakeGroupAvailability } from './hooks/use-make-group'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
@ -90,8 +91,8 @@ const SelectionContextmenu = () => {
const selectionMenu = useStore(s => s.selectionMenu)
// Access React Flow methods
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const collaborativeWorkflow = useCollaborativeWorkflow()
const selectedNodeIds = useReactFlowStore((state) => {
const ids = state.getNodes().filter(node => node.selected).map(node => node.id)
@ -309,7 +310,7 @@ const SelectionContextmenu = () => {
workflowStore.setState({ nodeAnimation: false })
// Get all current nodes
const nodes = store.getState().getNodes()
const { nodes, setNodes } = collaborativeWorkflow.getState()
// Find container nodes and their children
// Container nodes (like Iteration and Loop) have child nodes that should not be aligned independently
@ -362,7 +363,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
@ -397,7 +398,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()
@ -416,7 +417,7 @@ const SelectionContextmenu = () => {
catch (err) {
console.error('Failed to update nodes:', err)
}
}, [getNodesReadOnly, handleAlignNode, handleDistributeNodes, handleSelectionContextmenuCancel, handleSyncWorkflowDraft, saveStateToHistory, selectedNodeIds, store, workflowStore])
}, [collaborativeWorkflow, workflowStore, selectedNodeIds, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes])
if (!selectionMenu)
return null

View File

@ -0,0 +1,58 @@
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
showResolvedComments: boolean
setShowResolvedComments: (showResolved: 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 }),
showResolvedComments: false,
setShowResolvedComments: showResolvedComments => set({ showResolvedComments }),
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

@ -2,6 +2,7 @@ import type {
StateCreator,
} from 'zustand'
import type { ChatVariableSliceShape } from './chat-variable-slice'
import type { CommentSliceShape } from './comment-slice'
import type { InspectVarsSliceShape } from './debug/inspect-vars-slice'
import type { EnvVariableSliceShape } from './env-variable-slice'
import type { FormSliceShape } from './form-slice'
@ -25,6 +26,7 @@ import {
import { createStore } from 'zustand/vanilla'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createChatVariableSlice } from './chat-variable-slice'
import { createCommentSlice } from './comment-slice'
import { createInspectVarsSlice } from './debug/inspect-vars-slice'
import { createEnvVariableSlice } from './env-variable-slice'
import { createFormSlice } from './form-slice'
@ -56,6 +58,7 @@ export type Shape
& VersionSliceShape
& WorkflowDraftSliceShape
& WorkflowSliceShape
& CommentSliceShape
& InspectVarsSliceShape
& LayoutSliceShape
& SkillEditorSliceShape
@ -78,6 +81,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,8 +37,12 @@ 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
pendingComment: MousePosition | null
setPendingComment: (pendingComment: WorkflowSliceShape['pendingComment']) => void
isCommentPreviewHovering: boolean
setCommentPreviewHovering: (hovering: boolean) => void
mousePosition: { pageX: number, pageY: number, elementX: number, elementY: number }
setMousePosition: (mousePosition: WorkflowSliceShape['mousePosition']) => void
showConfirm?: { title: string, desc?: string, onConfirm: () => void }
@ -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

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

View File

@ -37,6 +37,7 @@ import {
} from '@/service/apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { collaborationManager } from './collaboration/core/collaboration-manager'
import { WORKFLOW_DATA_UPDATE } from './constants'
import {
BlockEnum,
@ -236,6 +237,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()