From 72037a1865f6d4c87b1d21bd8f3ab391bfe68585 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 21 Aug 2025 14:27:41 +0800 Subject: [PATCH 01/71] improve cursors logic --- .../workflow-app/components/workflow-main.tsx | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index f7b9264384..d310d0383c 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -41,7 +41,17 @@ const WorkflowMain = ({ const containerRef = useRef(null) const store = useStoreApi() - const { startCursorTracking, stopCursorTracking, onlineUsers } = useCollaboration(appId, store) + const { startCursorTracking, stopCursorTracking, onlineUsers, cursors, isConnected } = useCollaboration(appId, store) + const [myUserId, setMyUserId] = useState(null) + + useEffect(() => { + if (isConnected) + setMyUserId('current-user') + }, [isConnected]) + + const filteredCursors = Object.fromEntries( + Object.entries(cursors).filter(([userId]) => userId !== myUserId), + ) useEffect(() => { if (containerRef.current) @@ -133,18 +143,6 @@ const WorkflowMain = ({ handleWorkflowStartRunInWorkflow, } = useWorkflowStartRun() - const { cursors, isConnected } = useCollaboration(appId) - const [myUserId, setMyUserId] = useState(null) - - useEffect(() => { - if (isConnected) - setMyUserId('current-user') - }, [isConnected]) - - const filteredCursors = Object.fromEntries( - Object.entries(cursors).filter(([userId]) => userId !== myUserId), - ) - const { fetchInspectVars } = useSetWorkflowVarsWithValue({ flowId: appId, ...useConfigsMap(), From 7dc85570334f6628229936943e654d25d30a6d3b Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 21 Aug 2025 16:17:16 +0800 Subject: [PATCH 02/71] add Leader election --- api/controllers/console/app/online_user.py | 93 +++++++++++++++++- .../core/collaboration-manager.ts | 96 ++++++++++++++++--- .../collaboration/hooks/use-collaboration.ts | 12 ++- .../collaboration/test/leader-test.tsx | 61 ++++++++++++ 4 files changed, 243 insertions(+), 19 deletions(-) create mode 100644 web/app/components/workflow/collaboration/test/leader-test.tsx diff --git a/api/controllers/console/app/online_user.py b/api/controllers/console/app/online_user.py index 9720a1ae0e..1e054c3690 100644 --- a/api/controllers/console/app/online_user.py +++ b/api/controllers/console/app/online_user.py @@ -70,10 +70,17 @@ def handle_user_connect(sid, data): redis_client.hset(f"workflow_online_users:{workflow_id}", user_id, json.dumps(user_info)) redis_client.set(f"ws_sid_map:{sid}", json.dumps({"workflow_id": workflow_id, "user_id": user_id})) + # Leader election: first user becomes the leader + leader_id = get_or_set_leader(workflow_id, user_id) + is_leader = leader_id == user_id + sio.enter_room(sid, workflow_id) broadcast_online_users(workflow_id) + + # Notify user of their status + sio.emit("status", {"isLeader": is_leader}, room=sid) - return {"msg": "connected", "user_id": user_id, "sid": sid} + return {"msg": "connected", "user_id": user_id, "sid": sid, "isLeader": is_leader} @sio.on("disconnect") @@ -89,9 +96,83 @@ def handle_disconnect(sid): redis_client.hdel(f"workflow_online_users:{workflow_id}", user_id) redis_client.delete(f"ws_sid_map:{sid}") + # Handle leader re-election if the leader disconnected + handle_leader_disconnect(workflow_id, user_id) + broadcast_online_users(workflow_id) +def get_or_set_leader(workflow_id, user_id): + """ + Get current leader or set the user as leader if no leader exists. + Returns the leader user_id. + """ + leader_key = f"workflow_leader:{workflow_id}" + current_leader = redis_client.get(leader_key) + + if not current_leader: + # No leader exists, make this user the leader + redis_client.set(leader_key, user_id, ex=3600) # Expire in 1 hour + return user_id + + return current_leader.decode('utf-8') if isinstance(current_leader, bytes) else current_leader + + +def handle_leader_disconnect(workflow_id, disconnected_user_id): + """ + Handle leader re-election when a user disconnects. + """ + leader_key = f"workflow_leader:{workflow_id}" + current_leader = redis_client.get(leader_key) + + if current_leader: + current_leader = current_leader.decode('utf-8') if isinstance(current_leader, bytes) else current_leader + + if current_leader == disconnected_user_id: + # Leader disconnected, elect a new leader + users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") + + if users_json: + # Get the first remaining user as new leader + new_leader_id = list(users_json.keys())[0] + if isinstance(new_leader_id, bytes): + new_leader_id = new_leader_id.decode('utf-8') + + redis_client.set(leader_key, new_leader_id, ex=3600) + + # Notify all users about the new leader + broadcast_leader_change(workflow_id, new_leader_id) + else: + # No users left, remove leader + redis_client.delete(leader_key) + + +def broadcast_leader_change(workflow_id, new_leader_id): + """ + Broadcast leader change to all users in the workflow. + """ + users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") + + for user_id, user_info_json in users_json.items(): + try: + user_info = json.loads(user_info_json) + user_sid = user_info.get("sid") + if user_sid: + is_leader = (user_id.decode('utf-8') if isinstance(user_id, bytes) else user_id) == new_leader_id + sio.emit("status", {"isLeader": is_leader}, room=user_sid) + except Exception: + continue + + +def get_current_leader(workflow_id): + """ + Get the current leader for a workflow. + """ + leader_key = f"workflow_leader:{workflow_id}" + leader = redis_client.get(leader_key) + return leader.decode('utf-8') if leader and isinstance(leader, bytes) else leader + + def broadcast_online_users(workflow_id): """ broadcast online users to the workflow room @@ -103,7 +184,15 @@ def broadcast_online_users(workflow_id): users.append(json.loads(user_info_json)) except Exception: continue - sio.emit("online_users", {"workflow_id": workflow_id, "users": users}, room=workflow_id) + + # Get current leader + leader_id = get_current_leader(workflow_id) + + sio.emit("online_users", { + "workflow_id": workflow_id, + "users": users, + "leader": leader_id + }, room=workflow_id) @sio.on("collaboration_event") diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index e491ebc2fd..6d6177cb37 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -15,6 +15,8 @@ export class CollaborationManager { private currentAppId: string | null = null private reactFlowStore: any = null private cursors: Record = {} + private isLeader = false + private leaderId: string | null = null init = (appId: string, reactFlowStore: any): void => { if (!reactFlowStore) { @@ -70,6 +72,8 @@ export class CollaborationManager { this.currentAppId = null this.reactFlowStore = null this.cursors = {} + this.isLeader = false + this.leaderId = null this.eventEmitter.removeAllListeners() } @@ -115,6 +119,18 @@ export class CollaborationManager { return this.eventEmitter.on('varsAndFeaturesUpdate', callback) } + onLeaderChange(callback: (isLeader: boolean) => void): () => void { + return this.eventEmitter.on('leaderChange', callback) + } + + getLeaderId(): string | null { + return this.leaderId + } + + getIsLeader(): boolean { + return this.isLeader + } + private syncNodes(oldNodes: Node[], newNodes: Node[]): void { if (!this.nodesMap) return @@ -203,8 +219,6 @@ export class CollaborationManager { socket.on('collaboration_update', (update: any) => { if (update.type === 'mouseMove') { - console.log('Processing mouseMove event:', update) - // Update cursor state for this user this.cursors[update.userId] = { x: update.data.x, @@ -213,8 +227,6 @@ export class CollaborationManager { timestamp: update.timestamp, } - // Emit the complete cursor state - console.log('Emitting complete cursor state:', this.cursors) this.eventEmitter.emit('cursors', { ...this.cursors }) } else if (update.type === 'varsAndFeaturesUpdate') { @@ -223,26 +235,80 @@ export class CollaborationManager { } }) - socket.on('online_users', (data: { users: OnlineUser[] }) => { - const onlineUserIds = new Set(data.users.map(user => user.user_id)) + socket.on('online_users', (data: { users: OnlineUser[]; leader?: string }) => { + try { + if (!data || !Array.isArray(data.users)) { + console.warn('Invalid online_users data structure:', data) + return + } - // Remove cursors for offline users - Object.keys(this.cursors).forEach((userId) => { - if (!onlineUserIds.has(userId)) - delete this.cursors[userId] - }) + const onlineUserIds = new Set(data.users.map((user: OnlineUser) => user.user_id)) - console.log('Updated online users and cleaned offline cursors:', data.users) - this.eventEmitter.emit('onlineUsers', data.users) - this.eventEmitter.emit('cursors', { ...this.cursors }) + // Remove cursors for offline users + Object.keys(this.cursors).forEach((userId) => { + if (!onlineUserIds.has(userId)) + delete this.cursors[userId] + }) + + // Update leader information + if (data.leader && typeof data.leader === 'string') + this.leaderId = data.leader + + console.log('Updated online users and leader info:', { + users: data.users, + leader: data.leader, + currentLeader: this.leaderId, + }) + + this.eventEmitter.emit('onlineUsers', data.users) + this.eventEmitter.emit('cursors', { ...this.cursors }) + } + catch (error) { + console.error('Error processing online_users update:', error) + } + }) + + socket.on('status', (data: any) => { + try { + if (!data || typeof data.isLeader !== 'boolean') { + console.warn('Invalid status data:', data) + return + } + + const wasLeader = this.isLeader + this.isLeader = data.isLeader + + console.log(`Leader status update: ${wasLeader ? 'was' : 'was not'} leader, ${this.isLeader ? 'now is' : 'now is not'} leader`) + + if (wasLeader !== this.isLeader) + this.eventEmitter.emit('leaderChange', this.isLeader) + } + catch (error) { + console.error('Error processing status update:', error) + } }) socket.on('connect', () => { + console.log('WebSocket connected successfully') this.eventEmitter.emit('stateChange', { isConnected: true }) }) - socket.on('disconnect', () => { + socket.on('disconnect', (reason: string) => { + console.log('WebSocket disconnected:', reason) + this.cursors = {} + this.isLeader = false + this.leaderId = null this.eventEmitter.emit('stateChange', { isConnected: false }) + this.eventEmitter.emit('cursors', {}) + }) + + socket.on('connect_error', (error: any) => { + console.error('WebSocket connection error:', error) + this.eventEmitter.emit('stateChange', { isConnected: false, error: error.message }) + }) + + socket.on('error', (error: any) => { + console.error('WebSocket error:', error) }) } } diff --git a/web/app/components/workflow/collaboration/hooks/use-collaboration.ts b/web/app/components/workflow/collaboration/hooks/use-collaboration.ts index bd570508a9..8b6fc9ae77 100644 --- a/web/app/components/workflow/collaboration/hooks/use-collaboration.ts +++ b/web/app/components/workflow/collaboration/hooks/use-collaboration.ts @@ -4,10 +4,11 @@ import { CursorService } from '../services/cursor-service' import type { CollaborationState } from '../types/collaboration' export function useCollaboration(appId: string, reactFlowStore?: any) { - const [state, setState] = useState>({ + const [state, setState] = useState>({ isConnected: false, onlineUsers: [], cursors: {}, + isLeader: false, }) const cursorServiceRef = useRef(null) @@ -35,7 +36,6 @@ export function useCollaboration(appId: string, reactFlowStore?: any) { }) const unsubscribeCursors = collaborationManager.onCursorUpdate((cursors: any) => { - console.log('Cursor update received:', cursors) setState((prev: any) => ({ ...prev, cursors })) }) @@ -44,10 +44,16 @@ export function useCollaboration(appId: string, reactFlowStore?: any) { setState((prev: any) => ({ ...prev, onlineUsers: users })) }) + const unsubscribeLeaderChange = collaborationManager.onLeaderChange((isLeader: boolean) => { + console.log('Leader status changed:', isLeader) + setState((prev: any) => ({ ...prev, isLeader })) + }) + return () => { unsubscribeStateChange() unsubscribeCursors() unsubscribeUsers() + unsubscribeLeaderChange() cursorServiceRef.current?.stopTracking() collaborationManager.disconnect() } @@ -69,6 +75,8 @@ export function useCollaboration(appId: string, reactFlowStore?: any) { isConnected: state.isConnected || false, onlineUsers: state.onlineUsers || [], cursors: state.cursors || {}, + isLeader: state.isLeader || false, + leaderId: collaborationManager.getLeaderId(), startCursorTracking, stopCursorTracking, } diff --git a/web/app/components/workflow/collaboration/test/leader-test.tsx b/web/app/components/workflow/collaboration/test/leader-test.tsx new file mode 100644 index 0000000000..b6115b42b8 --- /dev/null +++ b/web/app/components/workflow/collaboration/test/leader-test.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { useCollaboration } from '../hooks/use-collaboration' + +type LeaderTestProps = { + appId: string +} + +export function LeaderTest({ appId }: LeaderTestProps) { + const { isConnected, isLeader, leaderId, onlineUsers } = useCollaboration(appId) + + return ( +
+

Leader Election Test

+ +
+
+ Connection: + + {isConnected ? 'Connected' : 'Disconnected'} + +
+ +
+ I am Leader: + + {isLeader ? 'YES' : 'NO'} + +
+ +
+ Current Leader ID: + + {leaderId || 'None'} + +
+ +
+ Online Users ({onlineUsers.length}): +
+ {onlineUsers.map((user: any) => ( +
+ + {user.user_id} + ({user.username}) + {user.user_id === leaderId && ( + 👑 Leader + )} +
+ ))} +
+
+
+
+ ) +} From d44be2d835f315dd7197091361c0830281ee2828 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 21 Aug 2025 17:53:39 +0800 Subject: [PATCH 03/71] add leader submit graph data --- .../hooks/use-nodes-sync-draft.ts | 26 +++++++ .../core/collaboration-manager.ts | 72 +++++++++++++++---- .../collaboration/hooks/use-collaboration.ts | 11 ++- .../collaboration/test/leader-test.tsx | 61 ---------------- 4 files changed, 92 insertions(+), 78 deletions(-) delete mode 100644 web/app/components/workflow/collaboration/test/leader-test.tsx diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index 312d38a259..c38e60cf97 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -13,6 +13,7 @@ import { syncWorkflowDraft } from '@/service/workflow' import { useFeaturesStore } from '@/app/components/base/features/hooks' import { API_PREFIX } from '@/config' import { useWorkflowRefreshDraft } from '.' +import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' export const useNodesSyncDraft = () => { const store = useStoreApi() @@ -93,9 +94,20 @@ export const useNodesSyncDraft = () => { const syncWorkflowDraftWhenPageClose = useCallback(() => { if (getNodesReadOnly()) return + + // Check leader status at sync time + const currentIsLeader = collaborationManager.getIsLeader() + + // Only allow leader to sync data + if (!currentIsLeader) { + console.log('Not leader, skipping sync on page close') + return + } + const postParams = getPostParams() if (postParams) { + console.log('Leader syncing workflow draft on page close') navigator.sendBeacon( `${API_PREFIX}/apps/${params.appId}/workflows/draft?_token=${localStorage.getItem('console_token')}`, JSON.stringify(postParams.params), @@ -113,6 +125,18 @@ export const useNodesSyncDraft = () => { ) => { if (getNodesReadOnly()) return + + // Check leader status at sync time + const currentIsLeader = collaborationManager.getIsLeader() + + // Only allow leader to sync data + if (!currentIsLeader) { + console.log('Not leader, skipping workflow draft sync') + callback?.onSettled?.() + return + } + + console.log('Leader performing workflow draft sync') const postParams = getPostParams() if (postParams) { @@ -124,9 +148,11 @@ export const useNodesSyncDraft = () => { const res = await syncWorkflowDraft(postParams) setSyncWorkflowDraftHash(res.hash) setDraftUpdatedAt(res.updated_at) + console.log('Leader successfully synced workflow draft') callback?.onSuccess && callback.onSuccess() } catch (error: any) { + console.error('Leader failed to sync workflow draft:', error) if (error && error.json && !error.bodyUsed) { error.json().then((err: any) => { if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError) diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index 6d6177cb37..a0075ecb1f 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -17,6 +17,7 @@ export class CollaborationManager { private cursors: Record = {} private isLeader = false private leaderId: string | null = null + private activeConnections = new Set() init = (appId: string, reactFlowStore: any): void => { if (!reactFlowStore) { @@ -42,25 +43,57 @@ export class CollaborationManager { this.disconnect() } - async connect(appId: string, reactFlowStore: any): Promise { - if (this.currentAppId === appId && this.doc) return + async connect(appId: string, reactFlowStore?: any): Promise { + const connectionId = Math.random().toString(36).substring(2, 11) - this.disconnect() + this.activeConnections.add(connectionId) + + if (this.currentAppId === appId && this.doc) { + // Already connected to the same app, only update store if provided and we don't have one + if (reactFlowStore && !this.reactFlowStore) + this.reactFlowStore = reactFlowStore + + return connectionId + } + + // Only disconnect if switching to a different app + if (this.currentAppId && this.currentAppId !== appId) + this.forceDisconnect() this.currentAppId = appId - this.reactFlowStore = reactFlowStore + // Only set store if provided + if (reactFlowStore) + this.reactFlowStore = reactFlowStore const socket = webSocketClient.connect(appId) + + // Setup event listeners BEFORE any other operations + this.setupSocketEventListeners(socket) + this.doc = new LoroDoc() this.nodesMap = this.doc.getMap('nodes') this.edgesMap = this.doc.getMap('edges') this.provider = new CRDTProvider(socket, this.doc) this.setupSubscriptions() - this.setupSocketEventListeners(socket) + + // Force user_connect if already connected + if (socket.connected) + socket.emit('user_connect', { workflow_id: appId }) + + return connectionId } - disconnect = (): void => { + disconnect = (connectionId?: string): void => { + if (connectionId) + this.activeConnections.delete(connectionId) + + // Only disconnect when no more connections + if (this.activeConnections.size === 0) + this.forceDisconnect() + } + + private forceDisconnect = (): void => { if (this.currentAppId) webSocketClient.disconnect(this.currentAppId) @@ -72,8 +105,16 @@ export class CollaborationManager { this.currentAppId = null this.reactFlowStore = null this.cursors = {} + + // Only reset leader status when actually disconnecting + const wasLeader = this.isLeader this.isLeader = false this.leaderId = null + + if (wasLeader) + this.eventEmitter.emit('leaderChange', false) + + this.activeConnections.clear() this.eventEmitter.removeAllListeners() } @@ -131,6 +172,17 @@ export class CollaborationManager { return this.isLeader } + debugLeaderStatus(): void { + console.log('=== Leader Status Debug ===') + console.log('Current leader status:', this.isLeader) + console.log('Current leader ID:', this.leaderId) + console.log('Active connections:', this.activeConnections.size) + console.log('Connected:', this.isConnected()) + console.log('Current app ID:', this.currentAppId) + console.log('Has ReactFlow store:', !!this.reactFlowStore) + console.log('========================') + } + private syncNodes(oldNodes: Node[], newNodes: Node[]): void { if (!this.nodesMap) return @@ -254,12 +306,6 @@ export class CollaborationManager { if (data.leader && typeof data.leader === 'string') this.leaderId = data.leader - console.log('Updated online users and leader info:', { - users: data.users, - leader: data.leader, - currentLeader: this.leaderId, - }) - this.eventEmitter.emit('onlineUsers', data.users) this.eventEmitter.emit('cursors', { ...this.cursors }) } @@ -278,8 +324,6 @@ export class CollaborationManager { const wasLeader = this.isLeader this.isLeader = data.isLeader - console.log(`Leader status update: ${wasLeader ? 'was' : 'was not'} leader, ${this.isLeader ? 'now is' : 'now is not'} leader`) - if (wasLeader !== this.isLeader) this.eventEmitter.emit('leaderChange', this.isLeader) } diff --git a/web/app/components/workflow/collaboration/hooks/use-collaboration.ts b/web/app/components/workflow/collaboration/hooks/use-collaboration.ts index 8b6fc9ae77..10fd1c7444 100644 --- a/web/app/components/workflow/collaboration/hooks/use-collaboration.ts +++ b/web/app/components/workflow/collaboration/hooks/use-collaboration.ts @@ -16,6 +16,8 @@ export function useCollaboration(appId: string, reactFlowStore?: any) { useEffect(() => { if (!appId) return + let connectionId: string | null = null + if (!cursorServiceRef.current) { cursorServiceRef.current = new CursorService({ minMoveDistance: 10, @@ -24,7 +26,7 @@ export function useCollaboration(appId: string, reactFlowStore?: any) { } const initCollaboration = async () => { - await collaborationManager.connect(appId, reactFlowStore) + connectionId = await collaborationManager.connect(appId, reactFlowStore) setState((prev: any) => ({ ...prev, appId, isConnected: collaborationManager.isConnected() })) } @@ -55,7 +57,8 @@ export function useCollaboration(appId: string, reactFlowStore?: any) { unsubscribeUsers() unsubscribeLeaderChange() cursorServiceRef.current?.stopTracking() - collaborationManager.disconnect() + if (connectionId) + collaborationManager.disconnect(connectionId) } }, [appId, reactFlowStore]) @@ -71,7 +74,7 @@ export function useCollaboration(appId: string, reactFlowStore?: any) { cursorServiceRef.current?.stopTracking() } - return { + const result = { isConnected: state.isConnected || false, onlineUsers: state.onlineUsers || [], cursors: state.cursors || {}, @@ -80,4 +83,6 @@ export function useCollaboration(appId: string, reactFlowStore?: any) { startCursorTracking, stopCursorTracking, } + + return result } diff --git a/web/app/components/workflow/collaboration/test/leader-test.tsx b/web/app/components/workflow/collaboration/test/leader-test.tsx deleted file mode 100644 index b6115b42b8..0000000000 --- a/web/app/components/workflow/collaboration/test/leader-test.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react' -import { useCollaboration } from '../hooks/use-collaboration' - -type LeaderTestProps = { - appId: string -} - -export function LeaderTest({ appId }: LeaderTestProps) { - const { isConnected, isLeader, leaderId, onlineUsers } = useCollaboration(appId) - - return ( -
-

Leader Election Test

- -
-
- Connection: - - {isConnected ? 'Connected' : 'Disconnected'} - -
- -
- I am Leader: - - {isLeader ? 'YES' : 'NO'} - -
- -
- Current Leader ID: - - {leaderId || 'None'} - -
- -
- Online Users ({onlineUsers.length}): -
- {onlineUsers.map((user: any) => ( -
- - {user.user_id} - ({user.username}) - {user.user_id === leaderId && ( - 👑 Leader - )} -
- ))} -
-
-
-
- ) -} From e082b6d59947730cb237e4028c91a37bb47c8c24 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Fri, 22 Aug 2025 11:27:58 +0800 Subject: [PATCH 04/71] add workflow comment models --- ...08_22_0001-add_workflow_comments_tables.py | 80 +++++++++ api/models/__init__.py | 8 + api/models/comment.py | 159 ++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 api/migrations/versions/2025_08_22_0001-add_workflow_comments_tables.py create mode 100644 api/models/comment.py diff --git a/api/migrations/versions/2025_08_22_0001-add_workflow_comments_tables.py b/api/migrations/versions/2025_08_22_0001-add_workflow_comments_tables.py new file mode 100644 index 0000000000..8a4bd5da36 --- /dev/null +++ b/api/migrations/versions/2025_08_22_0001-add_workflow_comments_tables.py @@ -0,0 +1,80 @@ +"""add workflow comments tables + +Revision ID: add_workflow_comments_tables +Revises: 1c9ba48be8e4 +Create Date: 2025-08-22 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'add_workflow_comments_tables' +down_revision = '1c9ba48be8e4' +branch_labels: None = None +depends_on: None = None + + +def upgrade(): + # Create workflow_comments table + op.create_table( + 'workflow_comments', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', postgresql.UUID(), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=False), + sa.Column('node_id', sa.String(length=255), nullable=True), + sa.Column('position_x', sa.Float(), nullable=True), + sa.Column('position_y', sa.Float(), nullable=True), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('created_by', postgresql.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('resolved', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('resolved_at', sa.DateTime(), nullable=True), + sa.Column('resolved_by', postgresql.UUID(), nullable=True), + sa.PrimaryKeyConstraint('id', name='workflow_comments_pkey') + ) + + # Create indexes for workflow_comments + op.create_index('workflow_comments_app_idx', 'workflow_comments', ['tenant_id', 'app_id']) + op.create_index('workflow_comments_node_idx', 'workflow_comments', ['tenant_id', 'node_id']) + op.create_index('workflow_comments_created_at_idx', 'workflow_comments', ['created_at']) + + # Create workflow_comment_replies table + op.create_table( + 'workflow_comment_replies', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('comment_id', postgresql.UUID(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('created_by', postgresql.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name='workflow_comment_replies_comment_id_fkey', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='workflow_comment_replies_pkey') + ) + + # Create indexes for workflow_comment_replies + op.create_index('comment_replies_comment_idx', 'workflow_comment_replies', ['comment_id']) + op.create_index('comment_replies_created_at_idx', 'workflow_comment_replies', ['created_at']) + + # Create workflow_comment_mentions table + op.create_table( + 'workflow_comment_mentions', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('comment_id', postgresql.UUID(), nullable=False), + sa.Column('mentioned_user_id', postgresql.UUID(), nullable=False), + sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name='workflow_comment_mentions_comment_id_fkey', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='workflow_comment_mentions_pkey') + ) + + # Create indexes for workflow_comment_mentions + op.create_index('comment_mentions_comment_idx', 'workflow_comment_mentions', ['comment_id']) + op.create_index('comment_mentions_user_idx', 'workflow_comment_mentions', ['mentioned_user_id']) + + +def downgrade(): + # Drop tables in reverse order due to foreign key constraints + op.drop_table('workflow_comment_mentions') + op.drop_table('workflow_comment_replies') + op.drop_table('workflow_comments') \ No newline at end of file diff --git a/api/models/__init__.py b/api/models/__init__.py index 1b4bdd32e4..83c94c232e 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -9,6 +9,11 @@ from .account import ( TenantStatus, ) from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint +from .comment import ( + WorkflowComment, + WorkflowCommentMention, + WorkflowCommentReply, +) from .dataset import ( AppDatasetJoin, Dataset, @@ -171,6 +176,9 @@ __all__ = [ "Workflow", "WorkflowAppLog", "WorkflowAppLogCreatedFrom", + "WorkflowComment", + "WorkflowCommentMention", + "WorkflowCommentReply", "WorkflowNodeExecutionModel", "WorkflowNodeExecutionTriggeredFrom", "WorkflowRun", diff --git a/api/models/comment.py b/api/models/comment.py new file mode 100644 index 0000000000..9af8bd7ef7 --- /dev/null +++ b/api/models/comment.py @@ -0,0 +1,159 @@ +"""Workflow comment models.""" + +from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import Index, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .account import Account +from .base import Base +from .engine import db +from .types import StringUUID + +if TYPE_CHECKING: + pass + + +class WorkflowComment(Base): + """Workflow comment model for canvas commenting functionality. + + Comments are associated with apps rather than specific workflow versions, + since an app has only one draft workflow at a time and comments should persist + across workflow version changes. + + Attributes: + id: Comment ID + tenant_id: Workspace ID + app_id: App ID (primary association, comments belong to apps) + node_id: Node ID (optional, for node-specific comments) + position_x: X coordinate on canvas + position_y: Y coordinate on canvas + content: Comment content + created_by: Creator account ID + created_at: Creation time + updated_at: Last update time + resolved: Whether comment is resolved + resolved_at: Resolution time + resolved_by: Resolver account ID + """ + + __tablename__ = "workflow_comments" + __table_args__ = ( + db.PrimaryKeyConstraint("id", name="workflow_comments_pkey"), + Index("workflow_comments_app_idx", "tenant_id", "app_id"), + Index("workflow_comments_node_idx", "tenant_id", "node_id"), + Index("workflow_comments_created_at_idx", "created_at"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + node_id: Mapped[Optional[str]] = mapped_column(db.String(255)) + position_x: Mapped[Optional[float]] = mapped_column(db.Float) + position_y: Mapped[Optional[float]] = mapped_column(db.Float) + content: Mapped[str] = mapped_column(db.Text, nullable=False) + created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) + created_at: Mapped[datetime] = mapped_column( + db.DateTime, nullable=False, server_default=func.current_timestamp() + ) + updated_at: Mapped[datetime] = mapped_column( + db.DateTime, + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp() + ) + resolved: Mapped[bool] = mapped_column(db.Boolean, nullable=False, server_default=db.text("false")) + resolved_at: Mapped[Optional[datetime]] = mapped_column(db.DateTime) + resolved_by: Mapped[Optional[str]] = mapped_column(StringUUID) + + # Relationships + replies: Mapped[list["WorkflowCommentReply"]] = relationship( + "WorkflowCommentReply", back_populates="comment", cascade="all, delete-orphan" + ) + mentions: Mapped[list["WorkflowCommentMention"]] = relationship( + "WorkflowCommentMention", back_populates="comment", cascade="all, delete-orphan" + ) + + @property + def created_by_account(self): + """Get creator account.""" + return db.session.get(Account, self.created_by) + + @property + def resolved_by_account(self): + """Get resolver account.""" + if self.resolved_by: + return db.session.get(Account, self.resolved_by) + return None + + +class WorkflowCommentReply(Base): + """Workflow comment reply model. + + Attributes: + id: Reply ID + comment_id: Parent comment ID + content: Reply content + created_by: Creator account ID + created_at: Creation time + """ + + __tablename__ = "workflow_comment_replies" + __table_args__ = ( + db.PrimaryKeyConstraint("id", name="workflow_comment_replies_pkey"), + Index("comment_replies_comment_idx", "comment_id"), + Index("comment_replies_created_at_idx", "created_at"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) + comment_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + content: Mapped[str] = mapped_column(db.Text, nullable=False) + created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) + created_at: Mapped[datetime] = mapped_column( + db.DateTime, nullable=False, server_default=func.current_timestamp() + ) + + # Relationships + comment: Mapped["WorkflowComment"] = relationship( + "WorkflowComment", back_populates="replies" + ) + + @property + def created_by_account(self): + """Get creator account.""" + return db.session.get(Account, self.created_by) + + +class WorkflowCommentMention(Base): + """Workflow comment mention model. + + Mentions are only for internal accounts since end users + cannot access workflow canvas and commenting features. + + Attributes: + id: Mention ID + comment_id: Parent comment ID + mentioned_user_id: Mentioned account ID + """ + + __tablename__ = "workflow_comment_mentions" + __table_args__ = ( + db.PrimaryKeyConstraint("id", name="workflow_comment_mentions_pkey"), + Index("comment_mentions_comment_idx", "comment_id"), + Index("comment_mentions_user_idx", "mentioned_user_id"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) + comment_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + mentioned_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + + # Relationships + comment: Mapped["WorkflowComment"] = relationship( + "WorkflowComment", back_populates="mentions" + ) + + @property + def mentioned_user_account(self): + """Get mentioned account.""" + return db.session.get(Account, self.mentioned_user_id) \ No newline at end of file From 5fa01132b96cd55bf8439e09a51ed42d85e91ccd Mon Sep 17 00:00:00 2001 From: hjlarry Date: Fri, 22 Aug 2025 16:46:30 +0800 Subject: [PATCH 05/71] add create and list comment api --- api/controllers/console/__init__.py | 1 + api/controllers/console/app/online_user.py | 40 ++- .../console/app/workflow_comment.py | 228 +++++++++++++++++ api/fields/workflow_comment_fields.py | 116 +++++++++ ...08_22_0001-add_workflow_comments_tables.py | 6 +- api/models/comment.py | 83 +++--- api/services/workflow_comment_service.py | 236 ++++++++++++++++++ 7 files changed, 655 insertions(+), 55 deletions(-) create mode 100644 api/controllers/console/app/workflow_comment.py create mode 100644 api/fields/workflow_comment_fields.py create mode 100644 api/services/workflow_comment_service.py diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 5055fbcc01..c59e92709d 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -65,6 +65,7 @@ from .app import ( statistic, workflow, workflow_app_log, + workflow_comment, workflow_draft_variable, workflow_run, workflow_statistic, diff --git a/api/controllers/console/app/online_user.py b/api/controllers/console/app/online_user.py index 1e054c3690..a4f0dea592 100644 --- a/api/controllers/console/app/online_user.py +++ b/api/controllers/console/app/online_user.py @@ -76,7 +76,7 @@ def handle_user_connect(sid, data): sio.enter_room(sid, workflow_id) broadcast_online_users(workflow_id) - + # Notify user of their status sio.emit("status", {"isLeader": is_leader}, room=sid) @@ -98,7 +98,7 @@ def handle_disconnect(sid): # Handle leader re-election if the leader disconnected handle_leader_disconnect(workflow_id, user_id) - + broadcast_online_users(workflow_id) @@ -109,13 +109,13 @@ def get_or_set_leader(workflow_id, user_id): """ leader_key = f"workflow_leader:{workflow_id}" current_leader = redis_client.get(leader_key) - + if not current_leader: # No leader exists, make this user the leader redis_client.set(leader_key, user_id, ex=3600) # Expire in 1 hour return user_id - - return current_leader.decode('utf-8') if isinstance(current_leader, bytes) else current_leader + + return current_leader.decode("utf-8") if isinstance(current_leader, bytes) else current_leader def handle_leader_disconnect(workflow_id, disconnected_user_id): @@ -124,22 +124,22 @@ def handle_leader_disconnect(workflow_id, disconnected_user_id): """ leader_key = f"workflow_leader:{workflow_id}" current_leader = redis_client.get(leader_key) - + if current_leader: - current_leader = current_leader.decode('utf-8') if isinstance(current_leader, bytes) else current_leader - + current_leader = current_leader.decode("utf-8") if isinstance(current_leader, bytes) else current_leader + if current_leader == disconnected_user_id: # Leader disconnected, elect a new leader users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") - + if users_json: # Get the first remaining user as new leader new_leader_id = list(users_json.keys())[0] if isinstance(new_leader_id, bytes): - new_leader_id = new_leader_id.decode('utf-8') - + new_leader_id = new_leader_id.decode("utf-8") + redis_client.set(leader_key, new_leader_id, ex=3600) - + # Notify all users about the new leader broadcast_leader_change(workflow_id, new_leader_id) else: @@ -152,13 +152,13 @@ def broadcast_leader_change(workflow_id, new_leader_id): Broadcast leader change to all users in the workflow. """ users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") - + for user_id, user_info_json in users_json.items(): try: user_info = json.loads(user_info_json) user_sid = user_info.get("sid") if user_sid: - is_leader = (user_id.decode('utf-8') if isinstance(user_id, bytes) else user_id) == new_leader_id + is_leader = (user_id.decode("utf-8") if isinstance(user_id, bytes) else user_id) == new_leader_id sio.emit("status", {"isLeader": is_leader}, room=user_sid) except Exception: continue @@ -170,7 +170,7 @@ def get_current_leader(workflow_id): """ leader_key = f"workflow_leader:{workflow_id}" leader = redis_client.get(leader_key) - return leader.decode('utf-8') if leader and isinstance(leader, bytes) else leader + return leader.decode("utf-8") if leader and isinstance(leader, bytes) else leader def broadcast_online_users(workflow_id): @@ -184,15 +184,11 @@ def broadcast_online_users(workflow_id): users.append(json.loads(user_info_json)) except Exception: continue - + # Get current leader leader_id = get_current_leader(workflow_id) - - sio.emit("online_users", { - "workflow_id": workflow_id, - "users": users, - "leader": leader_id - }, room=workflow_id) + + sio.emit("online_users", {"workflow_id": workflow_id, "users": users, "leader": leader_id}, room=workflow_id) @sio.on("collaboration_event") diff --git a/api/controllers/console/app/workflow_comment.py b/api/controllers/console/app/workflow_comment.py new file mode 100644 index 0000000000..ea2c7699dc --- /dev/null +++ b/api/controllers/console/app/workflow_comment.py @@ -0,0 +1,228 @@ +import logging + +from flask_restful import Resource, marshal_with, reqparse + +from controllers.console import api +from controllers.console.app.wraps import get_app_model +from controllers.console.wraps import account_initialization_required, setup_required +from fields.workflow_comment_fields import ( + workflow_comment_basic_fields, + workflow_comment_create_fields, + workflow_comment_detail_fields, + workflow_comment_reply_create_fields, + workflow_comment_reply_update_fields, + workflow_comment_resolve_fields, + workflow_comment_update_fields, +) +from libs.login import current_user, login_required +from models import App +from services.workflow_comment_service import WorkflowCommentService + +logger = logging.getLogger(__name__) + + +class WorkflowCommentListApi(Resource): + """API for listing and creating workflow comments.""" + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_basic_fields, envelope="data") + def get(self, app_model: App): + """Get all comments for a workflow.""" + comments = WorkflowCommentService.get_comments(tenant_id=current_user.current_tenant_id, app_id=app_model.id) + + return comments + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_create_fields) + def post(self, app_model: App): + """Create a new workflow comment.""" + parser = reqparse.RequestParser() + parser.add_argument("position_x", type=float, required=True, location="json") + parser.add_argument("position_y", type=float, required=True, location="json") + parser.add_argument("content", type=str, required=True, location="json") + parser.add_argument("mentioned_user_ids", type=list, location="json", default=[]) + args = parser.parse_args() + + result = WorkflowCommentService.create_comment( + tenant_id=current_user.current_tenant_id, + app_id=app_model.id, + created_by=current_user.id, + content=args.content, + position_x=args.position_x, + position_y=args.position_y, + mentioned_user_ids=args.mentioned_user_ids, + ) + + return result, 201 + + +class WorkflowCommentDetailApi(Resource): + """API for managing individual workflow comments.""" + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_detail_fields) + def get(self, app_model: App, comment_id: str): + """Get a specific workflow comment.""" + comment = WorkflowCommentService.get_comment( + tenant_id=current_user.current_tenant_id, app_id=app_model.id, comment_id=comment_id + ) + + return comment + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_update_fields) + def put(self, app_model: App, comment_id: str): + """Update a workflow comment.""" + parser = reqparse.RequestParser() + parser.add_argument("content", type=str, required=True, location="json") + parser.add_argument("mentioned_user_ids", type=list, location="json", default=[]) + args = parser.parse_args() + + comment = WorkflowCommentService.update_comment( + tenant_id=current_user.current_tenant_id, + app_id=app_model.id, + comment_id=comment_id, + user_id=current_user.id, + content=args.content, + mentioned_user_ids=args.mentioned_user_ids, + ) + + return comment + + @login_required + @setup_required + @account_initialization_required + @get_app_model + def delete(self, app_model: App, comment_id: str): + """Delete a workflow comment.""" + WorkflowCommentService.delete_comment( + tenant_id=current_user.current_tenant_id, + app_id=app_model.id, + comment_id=comment_id, + user_id=current_user.id, + ) + + return {"message": "Comment deleted successfully"}, 200 + + +class WorkflowCommentResolveApi(Resource): + """API for resolving and reopening workflow comments.""" + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_resolve_fields) + def post(self, app_model: App, comment_id: str): + """Resolve a workflow comment.""" + comment = WorkflowCommentService.resolve_comment( + tenant_id=current_user.current_tenant_id, + app_id=app_model.id, + comment_id=comment_id, + user_id=current_user.id, + ) + + return comment + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_resolve_fields) + def delete(self, app_model: App, comment_id: str): + """Reopen a resolved workflow comment.""" + comment = WorkflowCommentService.reopen_comment( + tenant_id=current_user.current_tenant_id, + app_id=app_model.id, + comment_id=comment_id, + user_id=current_user.id, + ) + + return comment + + +class WorkflowCommentReplyApi(Resource): + """API for managing comment replies.""" + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_reply_create_fields) + def post(self, app_model: App, comment_id: str): + """Add a reply to a workflow comment.""" + # Validate comment access first + WorkflowCommentService.validate_comment_access( + comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id + ) + + parser = reqparse.RequestParser() + parser.add_argument("content", type=str, required=True, location="json") + args = parser.parse_args() + + reply = WorkflowCommentService.create_reply( + comment_id=comment_id, content=args.content, created_by=current_user.id + ) + + return reply, 201 + + +class WorkflowCommentReplyDetailApi(Resource): + """API for managing individual comment replies.""" + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with(workflow_comment_reply_update_fields) + def put(self, app_model: App, comment_id: str, reply_id: str): + """Update a comment reply.""" + # Validate comment access first + WorkflowCommentService.validate_comment_access( + comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id + ) + + parser = reqparse.RequestParser() + parser.add_argument("content", type=str, required=True, location="json") + args = parser.parse_args() + + reply = WorkflowCommentService.update_reply(reply_id=reply_id, user_id=current_user.id, content=args.content) + + return reply + + @login_required + @setup_required + @account_initialization_required + @get_app_model + def delete(self, app_model: App, comment_id: str, reply_id: str): + """Delete a comment reply.""" + # Validate comment access first + WorkflowCommentService.validate_comment_access( + comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id + ) + + WorkflowCommentService.delete_reply(reply_id=reply_id, user_id=current_user.id) + + return {"message": "Reply deleted successfully"}, 200 + + +# Register API routes +api.add_resource(WorkflowCommentListApi, "/apps//workflow/comments") +api.add_resource(WorkflowCommentDetailApi, "/apps//workflow/comments/") +api.add_resource(WorkflowCommentResolveApi, "/apps//workflow/comments//resolve") +api.add_resource(WorkflowCommentReplyApi, "/apps//workflow/comments//replies") +api.add_resource( + WorkflowCommentReplyDetailApi, "/apps//workflow/comments//replies/" +) diff --git a/api/fields/workflow_comment_fields.py b/api/fields/workflow_comment_fields.py new file mode 100644 index 0000000000..7b1f0a08bc --- /dev/null +++ b/api/fields/workflow_comment_fields.py @@ -0,0 +1,116 @@ +from flask_restful import fields + +from libs.helper import TimestampField + +# Basic account fields for comment creators/resolvers +comment_account_fields = {"id": fields.String, "name": fields.String, "email": fields.String} + +# Comment mention fields +workflow_comment_mention_fields = { + "mentioned_user_id": fields.String, + "mentioned_user_account": fields.Nested(comment_account_fields, allow_null=True), +} + +# Comment reply fields +workflow_comment_reply_fields = { + "id": fields.String, + "content": fields.String, + "created_by": fields.String, + "created_by_account": fields.Nested(comment_account_fields, allow_null=True), + "created_at": TimestampField, +} + +# Participant info for showing avatars +workflow_comment_participant_fields = { + "id": fields.String, + "name": fields.String, + "email": fields.String, + "avatar": fields.String, +} + +# Basic comment fields (for list views) +workflow_comment_basic_fields = { + "id": fields.String, + "position_x": fields.Float, + "position_y": fields.Float, + "content": fields.String, + "created_by": fields.String, + "created_by_account": fields.Nested(comment_account_fields, allow_null=True), + "created_at": TimestampField, + "updated_at": TimestampField, + "resolved": fields.Boolean, + "resolved_at": TimestampField, + "resolved_by": fields.String, + "resolved_by_account": fields.Nested(comment_account_fields, allow_null=True), + "reply_count": fields.Integer, + "mention_count": fields.Integer, + "participants": fields.List(fields.Nested(workflow_comment_participant_fields)), +} + +# Detailed comment fields (for single comment view) +workflow_comment_detail_fields = { + "id": fields.String, + "position_x": fields.Float, + "position_y": fields.Float, + "content": fields.String, + "created_by": fields.String, + "created_by_account": fields.Nested(comment_account_fields, allow_null=True), + "created_at": TimestampField, + "updated_at": TimestampField, + "resolved": fields.Boolean, + "resolved_at": TimestampField, + "resolved_by": fields.String, + "resolved_by_account": fields.Nested(comment_account_fields, allow_null=True), + "replies": fields.List(fields.Nested(workflow_comment_reply_fields)), + "mentions": fields.List(fields.Nested(workflow_comment_mention_fields)), +} + +# Comment creation response fields (simplified) +workflow_comment_create_fields = { + "id": fields.String, + "created_at": TimestampField, +} + +# Comment update response fields +workflow_comment_update_fields = { + "id": fields.String, + "content": fields.String, + "updated_at": TimestampField, + "mentions": fields.List(fields.Nested(workflow_comment_mention_fields)), +} + +# Comment resolve response fields +workflow_comment_resolve_fields = { + "id": fields.String, + "resolved": fields.Boolean, + "resolved_at": TimestampField, + "resolved_by": fields.String, + "resolved_by_account": fields.Nested(comment_account_fields, allow_null=True), +} + +# Comment pagination fields +workflow_comment_pagination_fields = { + "data": fields.List(fields.Nested(workflow_comment_basic_fields), attribute="data"), + "has_more": fields.Boolean, + "total": fields.Integer, + "page": fields.Integer, + "limit": fields.Integer, +} + +# Reply creation response fields +workflow_comment_reply_create_fields = { + "id": fields.String, + "content": fields.String, + "created_by": fields.String, + "created_by_account": fields.Nested(comment_account_fields, allow_null=True), + "created_at": TimestampField, +} + +# Reply update response fields +workflow_comment_reply_update_fields = { + "id": fields.String, + "content": fields.String, + "created_by": fields.String, + "created_by_account": fields.Nested(comment_account_fields, allow_null=True), + "created_at": TimestampField, +} diff --git a/api/migrations/versions/2025_08_22_0001-add_workflow_comments_tables.py b/api/migrations/versions/2025_08_22_0001-add_workflow_comments_tables.py index 8a4bd5da36..c3f4b56e75 100644 --- a/api/migrations/versions/2025_08_22_0001-add_workflow_comments_tables.py +++ b/api/migrations/versions/2025_08_22_0001-add_workflow_comments_tables.py @@ -24,9 +24,8 @@ def upgrade(): sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), sa.Column('tenant_id', postgresql.UUID(), nullable=False), sa.Column('app_id', postgresql.UUID(), nullable=False), - sa.Column('node_id', sa.String(length=255), nullable=True), - sa.Column('position_x', sa.Float(), nullable=True), - sa.Column('position_y', sa.Float(), nullable=True), + sa.Column('position_x', sa.Float(), nullable=False), + sa.Column('position_y', sa.Float(), nullable=False), sa.Column('content', sa.Text(), nullable=False), sa.Column('created_by', postgresql.UUID(), nullable=False), sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), @@ -39,7 +38,6 @@ def upgrade(): # Create indexes for workflow_comments op.create_index('workflow_comments_app_idx', 'workflow_comments', ['tenant_id', 'app_id']) - op.create_index('workflow_comments_node_idx', 'workflow_comments', ['tenant_id', 'node_id']) op.create_index('workflow_comments_created_at_idx', 'workflow_comments', ['created_at']) # Create workflow_comment_replies table diff --git a/api/models/comment.py b/api/models/comment.py index 9af8bd7ef7..a06563e22f 100644 --- a/api/models/comment.py +++ b/api/models/comment.py @@ -17,16 +17,15 @@ if TYPE_CHECKING: class WorkflowComment(Base): """Workflow comment model for canvas commenting functionality. - + Comments are associated with apps rather than specific workflow versions, since an app has only one draft workflow at a time and comments should persist across workflow version changes. - + Attributes: id: Comment ID tenant_id: Workspace ID app_id: App ID (primary association, comments belong to apps) - node_id: Node ID (optional, for node-specific comments) position_x: X coordinate on canvas position_y: Y coordinate on canvas content: Comment content @@ -42,26 +41,19 @@ class WorkflowComment(Base): __table_args__ = ( db.PrimaryKeyConstraint("id", name="workflow_comments_pkey"), Index("workflow_comments_app_idx", "tenant_id", "app_id"), - Index("workflow_comments_node_idx", "tenant_id", "node_id"), Index("workflow_comments_created_at_idx", "created_at"), ) id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) - node_id: Mapped[Optional[str]] = mapped_column(db.String(255)) - position_x: Mapped[Optional[float]] = mapped_column(db.Float) - position_y: Mapped[Optional[float]] = mapped_column(db.Float) + position_x: Mapped[float] = mapped_column(db.Float) + position_y: Mapped[float] = mapped_column(db.Float) content: Mapped[str] = mapped_column(db.Text, nullable=False) created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column( - db.DateTime, nullable=False, server_default=func.current_timestamp() - ) + created_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp()) updated_at: Mapped[datetime] = mapped_column( - db.DateTime, - nullable=False, - server_default=func.current_timestamp(), - onupdate=func.current_timestamp() + db.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() ) resolved: Mapped[bool] = mapped_column(db.Boolean, nullable=False, server_default=db.text("false")) resolved_at: Mapped[Optional[datetime]] = mapped_column(db.DateTime) @@ -87,10 +79,45 @@ class WorkflowComment(Base): return db.session.get(Account, self.resolved_by) return None + @property + def reply_count(self): + """Get reply count.""" + return len(self.replies) + + @property + def mention_count(self): + """Get mention count.""" + return len(self.mentions) + + @property + def participants(self): + """Get all participants (creator + repliers + mentioned users).""" + participant_ids = set() + + # Add comment creator + participant_ids.add(self.created_by) + + # Add reply creators + for reply in self.replies: + participant_ids.add(reply.created_by) + + # Add mentioned users + for mention in self.mentions: + participant_ids.add(mention.mentioned_user_id) + + # Get account objects + participants = [] + for user_id in participant_ids: + account = db.session.get(Account, user_id) + if account: + participants.append(account) + + return participants + class WorkflowCommentReply(Base): """Workflow comment reply model. - + Attributes: id: Reply ID comment_id: Parent comment ID @@ -107,17 +134,15 @@ class WorkflowCommentReply(Base): ) id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) - comment_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + comment_id: Mapped[str] = mapped_column( + StringUUID, db.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False + ) content: Mapped[str] = mapped_column(db.Text, nullable=False) created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column( - db.DateTime, nullable=False, server_default=func.current_timestamp() - ) + created_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp()) # Relationships - comment: Mapped["WorkflowComment"] = relationship( - "WorkflowComment", back_populates="replies" - ) + comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="replies") @property def created_by_account(self): @@ -127,10 +152,10 @@ class WorkflowCommentReply(Base): class WorkflowCommentMention(Base): """Workflow comment mention model. - + Mentions are only for internal accounts since end users cannot access workflow canvas and commenting features. - + Attributes: id: Mention ID comment_id: Parent comment ID @@ -145,15 +170,15 @@ class WorkflowCommentMention(Base): ) id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) - comment_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + comment_id: Mapped[str] = mapped_column( + StringUUID, db.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False + ) mentioned_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False) # Relationships - comment: Mapped["WorkflowComment"] = relationship( - "WorkflowComment", back_populates="mentions" - ) + comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="mentions") @property def mentioned_user_account(self): """Get mentioned account.""" - return db.session.get(Account, self.mentioned_user_id) \ No newline at end of file + return db.session.get(Account, self.mentioned_user_id) diff --git a/api/services/workflow_comment_service.py b/api/services/workflow_comment_service.py new file mode 100644 index 0000000000..edb3850a88 --- /dev/null +++ b/api/services/workflow_comment_service.py @@ -0,0 +1,236 @@ +import logging +from typing import Optional + +from sqlalchemy import desc, select +from sqlalchemy.orm import Session, selectinload +from werkzeug.exceptions import Forbidden, NotFound + +from extensions.ext_database import db +from libs.helper import uuid_value +from models import WorkflowComment, WorkflowCommentMention, WorkflowCommentReply + +logger = logging.getLogger(__name__) + + +class WorkflowCommentService: + """Service for managing workflow comments.""" + + @staticmethod + def get_comments(tenant_id: str, app_id: str) -> list[WorkflowComment]: + """Get all comments for a workflow.""" + with Session(db.engine) as session: + # Get all comments with eager loading + stmt = ( + select(WorkflowComment) + .options(selectinload(WorkflowComment.replies), selectinload(WorkflowComment.mentions)) + .where(WorkflowComment.tenant_id == tenant_id, WorkflowComment.app_id == app_id) + .order_by(desc(WorkflowComment.created_at)) + ) + + comments = session.scalars(stmt).all() + return comments + + @staticmethod + def get_comment(tenant_id: str, app_id: str, comment_id: str) -> WorkflowComment: + """Get a specific comment.""" + with Session(db.engine) as session: + stmt = ( + select(WorkflowComment) + .options(selectinload(WorkflowComment.replies), selectinload(WorkflowComment.mentions)) + .where( + WorkflowComment.id == comment_id, + WorkflowComment.tenant_id == tenant_id, + WorkflowComment.app_id == app_id, + ) + ) + comment = session.scalar(stmt) + + if not comment: + raise NotFound("Comment not found") + + return comment + + @staticmethod + def create_comment( + tenant_id: str, + app_id: str, + created_by: str, + content: str, + position_x: float, + position_y: float, + mentioned_user_ids: Optional[list[str]] = None, + ) -> WorkflowComment: + """Create a new workflow comment.""" + if len(content.strip()) == 0: + raise ValueError("Comment content cannot be empty") + + if len(content) > 1000: + raise ValueError("Comment content cannot exceed 1000 characters") + with Session(db.engine) as session: + comment = WorkflowComment( + tenant_id=tenant_id, + app_id=app_id, + position_x=position_x, + position_y=position_y, + content=content, + created_by=created_by, + ) + + session.add(comment) + session.flush() # Get the comment ID for mentions + + # Create mentions if specified + mentioned_user_ids = mentioned_user_ids or [] + for user_id in mentioned_user_ids: + if isinstance(user_id, str) and uuid_value(user_id): + mention = WorkflowCommentMention(comment_id=comment.id, mentioned_user_id=user_id) + session.add(mention) + + session.commit() + + # Return only what we need - id and created_at + return {"id": comment.id, "created_at": comment.created_at} + + @staticmethod + def update_comment( + tenant_id: str, + app_id: str, + comment_id: str, + user_id: str, + content: str, + mentioned_user_ids: Optional[list[str]] = None, + ) -> WorkflowComment: + """Update a workflow comment.""" + + comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id) + + # Only the creator can update the comment + if comment.created_by != user_id: + raise Forbidden("Only the comment creator can update it") + + if len(content.strip()) == 0: + raise ValueError("Comment content cannot be empty") + + if len(content) > 1000: + raise ValueError("Comment content cannot exceed 1000 characters") + + comment.content = content + + # Update mentions - first remove existing mentions + existing_mentions = ( + db.session.query(WorkflowCommentMention).filter(WorkflowCommentMention.comment_id == comment.id).all() + ) + for mention in existing_mentions: + db.session.delete(mention) + + # Add new mentions + mentioned_user_ids = mentioned_user_ids or [] + for user_id_str in mentioned_user_ids: + if isinstance(user_id_str, str) and uuid_value(user_id_str): + mention = WorkflowCommentMention(comment_id=comment.id, mentioned_user_id=user_id_str) + db.session.add(mention) + + db.session.commit() + return comment + + @staticmethod + def delete_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> None: + """Delete a workflow comment.""" + comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id) + + # Only the creator can delete the comment + if comment.created_by != user_id: + raise Forbidden("Only the comment creator can delete it") + + db.session.delete(comment) + db.session.commit() + + @staticmethod + def resolve_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> WorkflowComment: + """Resolve a workflow comment.""" + comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id) + + if comment.resolved: + return comment + + comment.resolved = True + comment.resolved_at = db.func.current_timestamp() + comment.resolved_by = user_id + + db.session.commit() + return comment + + @staticmethod + def reopen_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> WorkflowComment: + """Reopen a resolved workflow comment.""" + comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id) + + if not comment.resolved: + return comment + + comment.resolved = False + comment.resolved_at = None + comment.resolved_by = None + + db.session.commit() + return comment + + @staticmethod + def create_reply(comment_id: str, content: str, created_by: str) -> WorkflowCommentReply: + """Add a reply to a workflow comment.""" + # Check if comment exists + comment = db.session.get(WorkflowComment, comment_id) + if not comment: + raise NotFound("Comment not found") + + if len(content.strip()) == 0: + raise ValueError("Reply content cannot be empty") + + if len(content) > 1000: + raise ValueError("Reply content cannot exceed 1000 characters") + + reply = WorkflowCommentReply(comment_id=comment_id, content=content, created_by=created_by) + + db.session.add(reply) + db.session.commit() + return reply + + @staticmethod + def update_reply(reply_id: str, user_id: str, content: str) -> WorkflowCommentReply: + """Update a comment reply.""" + reply = db.session.get(WorkflowCommentReply, reply_id) + if not reply: + raise NotFound("Reply not found") + + # Only the creator can update the reply + if reply.created_by != user_id: + raise Forbidden("Only the reply creator can update it") + + if len(content.strip()) == 0: + raise ValueError("Reply content cannot be empty") + + if len(content) > 1000: + raise ValueError("Reply content cannot exceed 1000 characters") + + reply.content = content + db.session.commit() + return reply + + @staticmethod + def delete_reply(reply_id: str, user_id: str) -> None: + """Delete a comment reply.""" + reply = db.session.get(WorkflowCommentReply, reply_id) + if not reply: + raise NotFound("Reply not found") + + # Only the creator can delete the reply + if reply.created_by != user_id: + raise Forbidden("Only the reply creator can delete it") + + db.session.delete(reply) + db.session.commit() + + @staticmethod + def validate_comment_access(comment_id: str, tenant_id: str, app_id: str) -> WorkflowComment: + """Validate that a comment belongs to the specified tenant and app.""" + return WorkflowCommentService.get_comment(tenant_id, app_id, comment_id) From 9f7321ca1ab384a13616712f506758a4b38755f1 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Fri, 22 Aug 2025 17:33:47 +0800 Subject: [PATCH 06/71] add create reply --- .../console/app/workflow_comment.py | 18 +++- api/fields/workflow_comment_fields.py | 6 +- ...08_22_0001-add_workflow_comments_tables.py | 78 ---------------- ...27822d22895_add_workflow_comments_table.py | 89 +++++++++++++++++++ api/models/comment.py | 5 ++ api/services/workflow_comment_service.py | 83 ++++++++++++++--- 6 files changed, 180 insertions(+), 99 deletions(-) delete mode 100644 api/migrations/versions/2025_08_22_0001-add_workflow_comments_tables.py create mode 100644 api/migrations/versions/2025_08_22_1726-227822d22895_add_workflow_comments_table.py diff --git a/api/controllers/console/app/workflow_comment.py b/api/controllers/console/app/workflow_comment.py index ea2c7699dc..e9004effb7 100644 --- a/api/controllers/console/app/workflow_comment.py +++ b/api/controllers/console/app/workflow_comment.py @@ -170,13 +170,17 @@ class WorkflowCommentReplyApi(Resource): parser = reqparse.RequestParser() parser.add_argument("content", type=str, required=True, location="json") + parser.add_argument("mentioned_user_ids", type=list, location="json", default=[]) args = parser.parse_args() - reply = WorkflowCommentService.create_reply( - comment_id=comment_id, content=args.content, created_by=current_user.id + result = WorkflowCommentService.create_reply( + comment_id=comment_id, + content=args.content, + created_by=current_user.id, + mentioned_user_ids=args.mentioned_user_ids ) - return reply, 201 + return result, 201 class WorkflowCommentReplyDetailApi(Resource): @@ -196,9 +200,15 @@ class WorkflowCommentReplyDetailApi(Resource): parser = reqparse.RequestParser() parser.add_argument("content", type=str, required=True, location="json") + parser.add_argument("mentioned_user_ids", type=list, location="json", default=[]) args = parser.parse_args() - reply = WorkflowCommentService.update_reply(reply_id=reply_id, user_id=current_user.id, content=args.content) + reply = WorkflowCommentService.update_reply( + reply_id=reply_id, + user_id=current_user.id, + content=args.content, + mentioned_user_ids=args.mentioned_user_ids + ) return reply diff --git a/api/fields/workflow_comment_fields.py b/api/fields/workflow_comment_fields.py index 7b1f0a08bc..33b1fc8067 100644 --- a/api/fields/workflow_comment_fields.py +++ b/api/fields/workflow_comment_fields.py @@ -9,6 +9,7 @@ comment_account_fields = {"id": fields.String, "name": fields.String, "email": f workflow_comment_mention_fields = { "mentioned_user_id": fields.String, "mentioned_user_account": fields.Nested(comment_account_fields, allow_null=True), + "reply_id": fields.String } # Comment reply fields @@ -97,12 +98,9 @@ workflow_comment_pagination_fields = { "limit": fields.Integer, } -# Reply creation response fields +# Reply creation response fields (simplified) workflow_comment_reply_create_fields = { "id": fields.String, - "content": fields.String, - "created_by": fields.String, - "created_by_account": fields.Nested(comment_account_fields, allow_null=True), "created_at": TimestampField, } diff --git a/api/migrations/versions/2025_08_22_0001-add_workflow_comments_tables.py b/api/migrations/versions/2025_08_22_0001-add_workflow_comments_tables.py deleted file mode 100644 index c3f4b56e75..0000000000 --- a/api/migrations/versions/2025_08_22_0001-add_workflow_comments_tables.py +++ /dev/null @@ -1,78 +0,0 @@ -"""add workflow comments tables - -Revision ID: add_workflow_comments_tables -Revises: 1c9ba48be8e4 -Create Date: 2025-08-22 00:00:00.000000 - -""" - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = 'add_workflow_comments_tables' -down_revision = '1c9ba48be8e4' -branch_labels: None = None -depends_on: None = None - - -def upgrade(): - # Create workflow_comments table - op.create_table( - 'workflow_comments', - sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), - sa.Column('tenant_id', postgresql.UUID(), nullable=False), - sa.Column('app_id', postgresql.UUID(), nullable=False), - sa.Column('position_x', sa.Float(), nullable=False), - sa.Column('position_y', sa.Float(), nullable=False), - sa.Column('content', sa.Text(), nullable=False), - sa.Column('created_by', postgresql.UUID(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.Column('resolved', sa.Boolean(), server_default=sa.text('false'), nullable=False), - sa.Column('resolved_at', sa.DateTime(), nullable=True), - sa.Column('resolved_by', postgresql.UUID(), nullable=True), - sa.PrimaryKeyConstraint('id', name='workflow_comments_pkey') - ) - - # Create indexes for workflow_comments - op.create_index('workflow_comments_app_idx', 'workflow_comments', ['tenant_id', 'app_id']) - op.create_index('workflow_comments_created_at_idx', 'workflow_comments', ['created_at']) - - # Create workflow_comment_replies table - op.create_table( - 'workflow_comment_replies', - sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), - sa.Column('comment_id', postgresql.UUID(), nullable=False), - sa.Column('content', sa.Text(), nullable=False), - sa.Column('created_by', postgresql.UUID(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name='workflow_comment_replies_comment_id_fkey', ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id', name='workflow_comment_replies_pkey') - ) - - # Create indexes for workflow_comment_replies - op.create_index('comment_replies_comment_idx', 'workflow_comment_replies', ['comment_id']) - op.create_index('comment_replies_created_at_idx', 'workflow_comment_replies', ['created_at']) - - # Create workflow_comment_mentions table - op.create_table( - 'workflow_comment_mentions', - sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), - sa.Column('comment_id', postgresql.UUID(), nullable=False), - sa.Column('mentioned_user_id', postgresql.UUID(), nullable=False), - sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name='workflow_comment_mentions_comment_id_fkey', ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id', name='workflow_comment_mentions_pkey') - ) - - # Create indexes for workflow_comment_mentions - op.create_index('comment_mentions_comment_idx', 'workflow_comment_mentions', ['comment_id']) - op.create_index('comment_mentions_user_idx', 'workflow_comment_mentions', ['mentioned_user_id']) - - -def downgrade(): - # Drop tables in reverse order due to foreign key constraints - op.drop_table('workflow_comment_mentions') - op.drop_table('workflow_comment_replies') - op.drop_table('workflow_comments') \ No newline at end of file diff --git a/api/migrations/versions/2025_08_22_1726-227822d22895_add_workflow_comments_table.py b/api/migrations/versions/2025_08_22_1726-227822d22895_add_workflow_comments_table.py new file mode 100644 index 0000000000..9a00b2a89a --- /dev/null +++ b/api/migrations/versions/2025_08_22_1726-227822d22895_add_workflow_comments_table.py @@ -0,0 +1,89 @@ +"""Add workflow comments table + +Revision ID: 227822d22895 +Revises: 1c9ba48be8e4 +Create Date: 2025-08-22 17:26:15.255980 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '227822d22895' +down_revision = '1c9ba48be8e4' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('workflow_comments', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('app_id', models.types.StringUUID(), nullable=False), + sa.Column('position_x', sa.Float(), nullable=False), + sa.Column('position_y', sa.Float(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('created_by', models.types.StringUUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('resolved', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('resolved_at', sa.DateTime(), nullable=True), + sa.Column('resolved_by', models.types.StringUUID(), nullable=True), + sa.PrimaryKeyConstraint('id', name='workflow_comments_pkey') + ) + with op.batch_alter_table('workflow_comments', schema=None) as batch_op: + batch_op.create_index('workflow_comments_app_idx', ['tenant_id', 'app_id'], unique=False) + batch_op.create_index('workflow_comments_created_at_idx', ['created_at'], unique=False) + + op.create_table('workflow_comment_replies', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('comment_id', models.types.StringUUID(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('created_by', models.types.StringUUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name=op.f('workflow_comment_replies_comment_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='workflow_comment_replies_pkey') + ) + with op.batch_alter_table('workflow_comment_replies', schema=None) as batch_op: + batch_op.create_index('comment_replies_comment_idx', ['comment_id'], unique=False) + batch_op.create_index('comment_replies_created_at_idx', ['created_at'], unique=False) + + op.create_table('workflow_comment_mentions', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('comment_id', models.types.StringUUID(), nullable=False), + sa.Column('reply_id', models.types.StringUUID(), nullable=True), + sa.Column('mentioned_user_id', models.types.StringUUID(), nullable=False), + sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name=op.f('workflow_comment_mentions_comment_id_fkey'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['reply_id'], ['workflow_comment_replies.id'], name=op.f('workflow_comment_mentions_reply_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='workflow_comment_mentions_pkey') + ) + with op.batch_alter_table('workflow_comment_mentions', schema=None) as batch_op: + batch_op.create_index('comment_mentions_comment_idx', ['comment_id'], unique=False) + batch_op.create_index('comment_mentions_reply_idx', ['reply_id'], unique=False) + batch_op.create_index('comment_mentions_user_idx', ['mentioned_user_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('workflow_comment_mentions', schema=None) as batch_op: + batch_op.drop_index('comment_mentions_user_idx') + batch_op.drop_index('comment_mentions_reply_idx') + batch_op.drop_index('comment_mentions_comment_idx') + + op.drop_table('workflow_comment_mentions') + with op.batch_alter_table('workflow_comment_replies', schema=None) as batch_op: + batch_op.drop_index('comment_replies_created_at_idx') + batch_op.drop_index('comment_replies_comment_idx') + + op.drop_table('workflow_comment_replies') + with op.batch_alter_table('workflow_comments', schema=None) as batch_op: + batch_op.drop_index('workflow_comments_created_at_idx') + batch_op.drop_index('workflow_comments_app_idx') + + op.drop_table('workflow_comments') + # ### end Alembic commands ### diff --git a/api/models/comment.py b/api/models/comment.py index a06563e22f..e88705b6d6 100644 --- a/api/models/comment.py +++ b/api/models/comment.py @@ -166,6 +166,7 @@ class WorkflowCommentMention(Base): __table_args__ = ( db.PrimaryKeyConstraint("id", name="workflow_comment_mentions_pkey"), Index("comment_mentions_comment_idx", "comment_id"), + Index("comment_mentions_reply_idx", "reply_id"), Index("comment_mentions_user_idx", "mentioned_user_id"), ) @@ -173,10 +174,14 @@ class WorkflowCommentMention(Base): comment_id: Mapped[str] = mapped_column( StringUUID, db.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False ) + reply_id: Mapped[Optional[str]] = mapped_column( + StringUUID, db.ForeignKey("workflow_comment_replies.id", ondelete="CASCADE"), nullable=True + ) mentioned_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False) # Relationships comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="mentions") + reply: Mapped[Optional["WorkflowCommentReply"]] = relationship("WorkflowCommentReply") @property def mentioned_user_account(self): diff --git a/api/services/workflow_comment_service.py b/api/services/workflow_comment_service.py index edb3850a88..cb6ee09d4b 100644 --- a/api/services/workflow_comment_service.py +++ b/api/services/workflow_comment_service.py @@ -83,7 +83,11 @@ class WorkflowCommentService: mentioned_user_ids = mentioned_user_ids or [] for user_id in mentioned_user_ids: if isinstance(user_id, str) and uuid_value(user_id): - mention = WorkflowCommentMention(comment_id=comment.id, mentioned_user_id=user_id) + mention = WorkflowCommentMention( + comment_id=comment.id, + reply_id=None, # This is a comment mention, not reply mention + mentioned_user_id=user_id + ) session.add(mention) session.commit() @@ -127,7 +131,11 @@ class WorkflowCommentService: mentioned_user_ids = mentioned_user_ids or [] for user_id_str in mentioned_user_ids: if isinstance(user_id_str, str) and uuid_value(user_id_str): - mention = WorkflowCommentMention(comment_id=comment.id, mentioned_user_id=user_id_str) + mention = WorkflowCommentMention( + comment_id=comment.id, + reply_id=None, # This is a comment mention + mentioned_user_id=user_id_str + ) db.session.add(mention) db.session.commit() @@ -176,27 +184,57 @@ class WorkflowCommentService: return comment @staticmethod - def create_reply(comment_id: str, content: str, created_by: str) -> WorkflowCommentReply: + def create_reply( + comment_id: str, + content: str, + created_by: str, + mentioned_user_ids: Optional[list[str]] = None + ) -> dict: """Add a reply to a workflow comment.""" - # Check if comment exists - comment = db.session.get(WorkflowComment, comment_id) - if not comment: - raise NotFound("Comment not found") - if len(content.strip()) == 0: raise ValueError("Reply content cannot be empty") if len(content) > 1000: raise ValueError("Reply content cannot exceed 1000 characters") - reply = WorkflowCommentReply(comment_id=comment_id, content=content, created_by=created_by) + with Session(db.engine) as session: + # Check if comment exists + comment = session.get(WorkflowComment, comment_id) + if not comment: + raise NotFound("Comment not found") - db.session.add(reply) - db.session.commit() - return reply + reply = WorkflowCommentReply(comment_id=comment_id, content=content, created_by=created_by) + + session.add(reply) + session.flush() # Get the reply ID for mentions + + # Create mentions if specified + mentioned_user_ids = mentioned_user_ids or [] + for user_id in mentioned_user_ids: + if isinstance(user_id, str) and uuid_value(user_id): + # Create mention linking to specific reply + mention = WorkflowCommentMention( + comment_id=comment_id, + reply_id=reply.id, # This is a reply mention + mentioned_user_id=user_id + ) + session.add(mention) + + session.commit() + + # Return only what we need - id and created_at + return { + "id": reply.id, + "created_at": reply.created_at + } @staticmethod - def update_reply(reply_id: str, user_id: str, content: str) -> WorkflowCommentReply: + def update_reply( + reply_id: str, + user_id: str, + content: str, + mentioned_user_ids: Optional[list[str]] = None + ) -> WorkflowCommentReply: """Update a comment reply.""" reply = db.session.get(WorkflowCommentReply, reply_id) if not reply: @@ -213,6 +251,25 @@ class WorkflowCommentService: raise ValueError("Reply content cannot exceed 1000 characters") reply.content = content + + # Handle mentions for reply updates - add new mentions to parent comment + mentioned_user_ids = mentioned_user_ids or [] + for user_id_str in mentioned_user_ids: + if isinstance(user_id_str, str) and uuid_value(user_id_str): + # Check if mention already exists to avoid duplicates + existing_mention = db.session.query(WorkflowCommentMention).filter( + WorkflowCommentMention.comment_id == reply.comment_id, + WorkflowCommentMention.mentioned_user_id == user_id_str + ).first() + + if not existing_mention: + mention = WorkflowCommentMention( + comment_id=reply.comment_id, + reply_id=reply.id, # This is a reply mention + mentioned_user_id=user_id_str + ) + db.session.add(mention) + db.session.commit() return reply From 9067c2a9c142a99a6ad551f0f8c1e9ed9146d028 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Fri, 22 Aug 2025 17:48:14 +0800 Subject: [PATCH 07/71] add update comment --- .../console/app/workflow_comment.py | 8 +- api/fields/workflow_comment_fields.py | 4 +- api/services/workflow_comment_service.py | 82 ++++++++++++------- 3 files changed, 61 insertions(+), 33 deletions(-) diff --git a/api/controllers/console/app/workflow_comment.py b/api/controllers/console/app/workflow_comment.py index e9004effb7..6088cfbbf9 100644 --- a/api/controllers/console/app/workflow_comment.py +++ b/api/controllers/console/app/workflow_comment.py @@ -87,19 +87,23 @@ class WorkflowCommentDetailApi(Resource): """Update a workflow comment.""" parser = reqparse.RequestParser() parser.add_argument("content", type=str, required=True, location="json") + parser.add_argument("position_x", type=float, required=False, location="json") + parser.add_argument("position_y", type=float, required=False, location="json") parser.add_argument("mentioned_user_ids", type=list, location="json", default=[]) args = parser.parse_args() - comment = WorkflowCommentService.update_comment( + result = WorkflowCommentService.update_comment( tenant_id=current_user.current_tenant_id, app_id=app_model.id, comment_id=comment_id, user_id=current_user.id, content=args.content, + position_x=args.position_x, + position_y=args.position_y, mentioned_user_ids=args.mentioned_user_ids, ) - return comment + return result @login_required @setup_required diff --git a/api/fields/workflow_comment_fields.py b/api/fields/workflow_comment_fields.py index 33b1fc8067..f4f22d4c49 100644 --- a/api/fields/workflow_comment_fields.py +++ b/api/fields/workflow_comment_fields.py @@ -72,12 +72,10 @@ workflow_comment_create_fields = { "created_at": TimestampField, } -# Comment update response fields +# Comment update response fields (simplified) workflow_comment_update_fields = { "id": fields.String, - "content": fields.String, "updated_at": TimestampField, - "mentions": fields.List(fields.Nested(workflow_comment_mention_fields)), } # Comment resolve response fields diff --git a/api/services/workflow_comment_service.py b/api/services/workflow_comment_service.py index cb6ee09d4b..d727020d4d 100644 --- a/api/services/workflow_comment_service.py +++ b/api/services/workflow_comment_service.py @@ -102,44 +102,70 @@ class WorkflowCommentService: comment_id: str, user_id: str, content: str, + position_x: Optional[float] = None, + position_y: Optional[float] = None, mentioned_user_ids: Optional[list[str]] = None, - ) -> WorkflowComment: + ) -> dict: """Update a workflow comment.""" - - comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id) - - # Only the creator can update the comment - if comment.created_by != user_id: - raise Forbidden("Only the comment creator can update it") - if len(content.strip()) == 0: raise ValueError("Comment content cannot be empty") if len(content) > 1000: raise ValueError("Comment content cannot exceed 1000 characters") - comment.content = content - - # Update mentions - first remove existing mentions - existing_mentions = ( - db.session.query(WorkflowCommentMention).filter(WorkflowCommentMention.comment_id == comment.id).all() - ) - for mention in existing_mentions: - db.session.delete(mention) - - # Add new mentions - mentioned_user_ids = mentioned_user_ids or [] - for user_id_str in mentioned_user_ids: - if isinstance(user_id_str, str) and uuid_value(user_id_str): - mention = WorkflowCommentMention( - comment_id=comment.id, - reply_id=None, # This is a comment mention - mentioned_user_id=user_id_str + with Session(db.engine) as session: + # Get comment with validation + stmt = ( + select(WorkflowComment) + .where( + WorkflowComment.id == comment_id, + WorkflowComment.tenant_id == tenant_id, + WorkflowComment.app_id == app_id, ) - db.session.add(mention) + ) + comment = session.scalar(stmt) + + if not comment: + raise NotFound("Comment not found") - db.session.commit() - return comment + # Only the creator can update the comment + if comment.created_by != user_id: + raise Forbidden("Only the comment creator can update it") + + # Update comment fields + comment.content = content + if position_x is not None: + comment.position_x = position_x + if position_y is not None: + comment.position_y = position_y + + # Update mentions - first remove existing mentions for this comment only (not replies) + existing_mentions = session.scalars( + select(WorkflowCommentMention).where( + WorkflowCommentMention.comment_id == comment.id, + WorkflowCommentMention.reply_id.is_(None) # Only comment mentions, not reply mentions + ) + ).all() + for mention in existing_mentions: + session.delete(mention) + + # Add new mentions + mentioned_user_ids = mentioned_user_ids or [] + for user_id_str in mentioned_user_ids: + if isinstance(user_id_str, str) and uuid_value(user_id_str): + mention = WorkflowCommentMention( + comment_id=comment.id, + reply_id=None, # This is a comment mention + mentioned_user_id=user_id_str + ) + session.add(mention) + + session.commit() + + return { + "id": comment.id, + "updated_at": comment.updated_at + } @staticmethod def delete_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> None: From e421db4005ea5acaee57b3b8d35e1c2ede4be9ac Mon Sep 17 00:00:00 2001 From: hjlarry Date: Sat, 30 Aug 2025 22:36:46 +0800 Subject: [PATCH 08/71] add resolve comment --- api/fields/workflow_comment_fields.py | 10 ----- api/services/workflow_comment_service.py | 50 ++++++++++-------------- 2 files changed, 21 insertions(+), 39 deletions(-) diff --git a/api/fields/workflow_comment_fields.py b/api/fields/workflow_comment_fields.py index f4f22d4c49..d285d6c1ab 100644 --- a/api/fields/workflow_comment_fields.py +++ b/api/fields/workflow_comment_fields.py @@ -84,16 +84,6 @@ workflow_comment_resolve_fields = { "resolved": fields.Boolean, "resolved_at": TimestampField, "resolved_by": fields.String, - "resolved_by_account": fields.Nested(comment_account_fields, allow_null=True), -} - -# Comment pagination fields -workflow_comment_pagination_fields = { - "data": fields.List(fields.Nested(workflow_comment_basic_fields), attribute="data"), - "has_more": fields.Boolean, - "total": fields.Integer, - "page": fields.Integer, - "limit": fields.Integer, } # Reply creation response fields (simplified) diff --git a/api/services/workflow_comment_service.py b/api/services/workflow_comment_service.py index d727020d4d..5e4034fd90 100644 --- a/api/services/workflow_comment_service.py +++ b/api/services/workflow_comment_service.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session, selectinload from werkzeug.exceptions import Forbidden, NotFound from extensions.ext_database import db +from libs.datetime_utils import naive_utc_now from libs.helper import uuid_value from models import WorkflowComment, WorkflowCommentMention, WorkflowCommentReply @@ -31,9 +32,9 @@ class WorkflowCommentService: return comments @staticmethod - def get_comment(tenant_id: str, app_id: str, comment_id: str) -> WorkflowComment: + def get_comment(tenant_id: str, app_id: str, comment_id: str, session: Session = None) -> WorkflowComment: """Get a specific comment.""" - with Session(db.engine) as session: + def _get_comment(session: Session) -> WorkflowComment: stmt = ( select(WorkflowComment) .options(selectinload(WorkflowComment.replies), selectinload(WorkflowComment.mentions)) @@ -45,10 +46,16 @@ class WorkflowCommentService: ) comment = session.scalar(stmt) - if not comment: - raise NotFound("Comment not found") + if not comment: + raise NotFound("Comment not found") - return comment + return comment + + if session is not None: + return _get_comment(session) + else: + with Session(db.engine, expire_on_commit=False) as session: + return _get_comment(session) @staticmethod def create_comment( @@ -182,31 +189,16 @@ class WorkflowCommentService: @staticmethod def resolve_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> WorkflowComment: """Resolve a workflow comment.""" - comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id) + with Session(db.engine, expire_on_commit=False) as session: + comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id, session) + if comment.resolved: + return comment - if comment.resolved: - return comment - - comment.resolved = True - comment.resolved_at = db.func.current_timestamp() - comment.resolved_by = user_id - - db.session.commit() - return comment - - @staticmethod - def reopen_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> WorkflowComment: - """Reopen a resolved workflow comment.""" - comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id) - - if not comment.resolved: - return comment - - comment.resolved = False - comment.resolved_at = None - comment.resolved_by = None - - db.session.commit() + comment.resolved = True + comment.resolved_at = naive_utc_now() + comment.resolved_by = user_id + session.commit() + return comment @staticmethod From d4a6acbd9973ce3d061d5dd9ad49a6530da07265 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Sat, 30 Aug 2025 23:49:27 +0800 Subject: [PATCH 09/71] add update reply --- api/fields/workflow_comment_fields.py | 5 +- ...27822d22895_add_workflow_comments_table.py | 1 + api/models/comment.py | 4 +- api/services/workflow_comment_service.py | 58 ++++++++++--------- 4 files changed, 36 insertions(+), 32 deletions(-) diff --git a/api/fields/workflow_comment_fields.py b/api/fields/workflow_comment_fields.py index d285d6c1ab..5f7bd6851a 100644 --- a/api/fields/workflow_comment_fields.py +++ b/api/fields/workflow_comment_fields.py @@ -95,8 +95,5 @@ workflow_comment_reply_create_fields = { # Reply update response fields workflow_comment_reply_update_fields = { "id": fields.String, - "content": fields.String, - "created_by": fields.String, - "created_by_account": fields.Nested(comment_account_fields, allow_null=True), - "created_at": TimestampField, + "updated_at": TimestampField, } diff --git a/api/migrations/versions/2025_08_22_1726-227822d22895_add_workflow_comments_table.py b/api/migrations/versions/2025_08_22_1726-227822d22895_add_workflow_comments_table.py index 9a00b2a89a..5bcd44070d 100644 --- a/api/migrations/versions/2025_08_22_1726-227822d22895_add_workflow_comments_table.py +++ b/api/migrations/versions/2025_08_22_1726-227822d22895_add_workflow_comments_table.py @@ -44,6 +44,7 @@ def upgrade(): sa.Column('content', sa.Text(), nullable=False), sa.Column('created_by', models.types.StringUUID(), nullable=False), sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name=op.f('workflow_comment_replies_comment_id_fkey'), ondelete='CASCADE'), sa.PrimaryKeyConstraint('id', name='workflow_comment_replies_pkey') ) diff --git a/api/models/comment.py b/api/models/comment.py index e88705b6d6..752d1d7a41 100644 --- a/api/models/comment.py +++ b/api/models/comment.py @@ -140,7 +140,9 @@ class WorkflowCommentReply(Base): content: Mapped[str] = mapped_column(db.Text, nullable=False) created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) created_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp()) - + updated_at: Mapped[datetime] = mapped_column( + db.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + ) # Relationships comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="replies") diff --git a/api/services/workflow_comment_service.py b/api/services/workflow_comment_service.py index 5e4034fd90..010dd3b1ed 100644 --- a/api/services/workflow_comment_service.py +++ b/api/services/workflow_comment_service.py @@ -198,7 +198,7 @@ class WorkflowCommentService: comment.resolved_at = naive_utc_now() comment.resolved_by = user_id session.commit() - + return comment @staticmethod @@ -215,7 +215,7 @@ class WorkflowCommentService: if len(content) > 1000: raise ValueError("Reply content cannot exceed 1000 characters") - with Session(db.engine) as session: + with Session(db.engine, expire_on_commit=False) as session: # Check if comment exists comment = session.get(WorkflowComment, comment_id) if not comment: @@ -240,7 +240,6 @@ class WorkflowCommentService: session.commit() - # Return only what we need - id and created_at return { "id": reply.id, "created_at": reply.created_at @@ -254,42 +253,47 @@ class WorkflowCommentService: mentioned_user_ids: Optional[list[str]] = None ) -> WorkflowCommentReply: """Update a comment reply.""" - reply = db.session.get(WorkflowCommentReply, reply_id) - if not reply: - raise NotFound("Reply not found") - - # Only the creator can update the reply - if reply.created_by != user_id: - raise Forbidden("Only the reply creator can update it") - if len(content.strip()) == 0: raise ValueError("Reply content cannot be empty") if len(content) > 1000: raise ValueError("Reply content cannot exceed 1000 characters") + + with Session(db.engine, expire_on_commit=False) as session: + reply = session.get(WorkflowCommentReply, reply_id) + if not reply: + raise NotFound("Reply not found") - reply.content = content + # Only the creator can update the reply + if reply.created_by != user_id: + raise Forbidden("Only the reply creator can update it") - # Handle mentions for reply updates - add new mentions to parent comment - mentioned_user_ids = mentioned_user_ids or [] - for user_id_str in mentioned_user_ids: - if isinstance(user_id_str, str) and uuid_value(user_id_str): - # Check if mention already exists to avoid duplicates - existing_mention = db.session.query(WorkflowCommentMention).filter( - WorkflowCommentMention.comment_id == reply.comment_id, - WorkflowCommentMention.mentioned_user_id == user_id_str - ).first() - - if not existing_mention: + reply.content = content + + # Update mentions - first remove existing mentions for this reply + existing_mentions = session.scalars( + select(WorkflowCommentMention).where(WorkflowCommentMention.reply_id == reply.id) + ).all() + for mention in existing_mentions: + session.delete(mention) + + # Add mentions + mentioned_user_ids = mentioned_user_ids or [] + for user_id_str in mentioned_user_ids: + if isinstance(user_id_str, str) and uuid_value(user_id_str): mention = WorkflowCommentMention( comment_id=reply.comment_id, - reply_id=reply.id, # This is a reply mention + reply_id=reply.id, mentioned_user_id=user_id_str ) - db.session.add(mention) + session.add(mention) - db.session.commit() - return reply + session.commit() + + return { + "id": reply.id, + "updated_at": reply.updated_at + } @staticmethod def delete_reply(reply_id: str, user_id: str) -> None: From 816bdf0320608690ab90d0ea575e197129bda594 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Sun, 31 Aug 2025 00:28:01 +0800 Subject: [PATCH 10/71] add delete comment and reply --- .../console/app/workflow_comment.py | 20 +----- api/services/workflow_comment_service.py | 63 +++++++++++++------ 2 files changed, 45 insertions(+), 38 deletions(-) diff --git a/api/controllers/console/app/workflow_comment.py b/api/controllers/console/app/workflow_comment.py index 6088cfbbf9..6896ee6789 100644 --- a/api/controllers/console/app/workflow_comment.py +++ b/api/controllers/console/app/workflow_comment.py @@ -118,7 +118,7 @@ class WorkflowCommentDetailApi(Resource): user_id=current_user.id, ) - return {"message": "Comment deleted successfully"}, 200 + return {"result": "success"}, 204 class WorkflowCommentResolveApi(Resource): @@ -140,22 +140,6 @@ class WorkflowCommentResolveApi(Resource): return comment - @login_required - @setup_required - @account_initialization_required - @get_app_model - @marshal_with(workflow_comment_resolve_fields) - def delete(self, app_model: App, comment_id: str): - """Reopen a resolved workflow comment.""" - comment = WorkflowCommentService.reopen_comment( - tenant_id=current_user.current_tenant_id, - app_id=app_model.id, - comment_id=comment_id, - user_id=current_user.id, - ) - - return comment - class WorkflowCommentReplyApi(Resource): """API for managing comment replies.""" @@ -229,7 +213,7 @@ class WorkflowCommentReplyDetailApi(Resource): WorkflowCommentService.delete_reply(reply_id=reply_id, user_id=current_user.id) - return {"message": "Reply deleted successfully"}, 200 + return {"result": "success"}, 204 # Register API routes diff --git a/api/services/workflow_comment_service.py b/api/services/workflow_comment_service.py index 010dd3b1ed..e205c515f5 100644 --- a/api/services/workflow_comment_service.py +++ b/api/services/workflow_comment_service.py @@ -177,14 +177,29 @@ class WorkflowCommentService: @staticmethod def delete_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> None: """Delete a workflow comment.""" - comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id) + with Session(db.engine, expire_on_commit=False) as session: + comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id, session) - # Only the creator can delete the comment - if comment.created_by != user_id: - raise Forbidden("Only the comment creator can delete it") + # Only the creator can delete the comment + if comment.created_by != user_id: + raise Forbidden("Only the comment creator can delete it") - db.session.delete(comment) - db.session.commit() + # Delete associated mentions (both comment and reply mentions) + mentions = session.scalars( + select(WorkflowCommentMention).where(WorkflowCommentMention.comment_id == comment_id) + ).all() + for mention in mentions: + session.delete(mention) + + # Delete associated replies + replies = session.scalars( + select(WorkflowCommentReply).where(WorkflowCommentReply.comment_id == comment_id) + ).all() + for reply in replies: + session.delete(reply) + + session.delete(comment) + session.commit() @staticmethod def resolve_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> WorkflowComment: @@ -233,7 +248,7 @@ class WorkflowCommentService: # Create mention linking to specific reply mention = WorkflowCommentMention( comment_id=comment_id, - reply_id=reply.id, # This is a reply mention + reply_id=reply.id, mentioned_user_id=user_id ) session.add(mention) @@ -289,25 +304,33 @@ class WorkflowCommentService: session.add(mention) session.commit() - - return { - "id": reply.id, - "updated_at": reply.updated_at - } + + return { + "id": reply.id, + "updated_at": reply.updated_at + } @staticmethod def delete_reply(reply_id: str, user_id: str) -> None: """Delete a comment reply.""" - reply = db.session.get(WorkflowCommentReply, reply_id) - if not reply: - raise NotFound("Reply not found") + with Session(db.engine, expire_on_commit=False) as session: + reply = session.get(WorkflowCommentReply, reply_id) + if not reply: + raise NotFound("Reply not found") - # Only the creator can delete the reply - if reply.created_by != user_id: - raise Forbidden("Only the reply creator can delete it") + # Only the creator can delete the reply + if reply.created_by != user_id: + raise Forbidden("Only the reply creator can delete it") - db.session.delete(reply) - db.session.commit() + # Delete associated mentions first + mentions = session.scalars( + select(WorkflowCommentMention).where(WorkflowCommentMention.reply_id == reply_id) + ).all() + for mention in mentions: + session.delete(mention) + + session.delete(reply) + session.commit() @staticmethod def validate_comment_access(comment_id: str, tenant_id: str, app_id: str) -> WorkflowComment: From a91c897fd3276d74617eae3764ca37cfcbe8ed39 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Sun, 31 Aug 2025 00:43:34 +0800 Subject: [PATCH 11/71] improve code --- api/services/workflow_comment_service.py | 34 ++++++++++-------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/api/services/workflow_comment_service.py b/api/services/workflow_comment_service.py index e205c515f5..f85420e258 100644 --- a/api/services/workflow_comment_service.py +++ b/api/services/workflow_comment_service.py @@ -16,6 +16,14 @@ logger = logging.getLogger(__name__) class WorkflowCommentService: """Service for managing workflow comments.""" + @staticmethod + def _validate_content(content: str) -> None: + if len(content.strip()) == 0: + raise ValueError("Comment content cannot be empty") + + if len(content) > 1000: + raise ValueError("Comment content cannot exceed 1000 characters") + @staticmethod def get_comments(tenant_id: str, app_id: str) -> list[WorkflowComment]: """Get all comments for a workflow.""" @@ -68,11 +76,8 @@ class WorkflowCommentService: mentioned_user_ids: Optional[list[str]] = None, ) -> WorkflowComment: """Create a new workflow comment.""" - if len(content.strip()) == 0: - raise ValueError("Comment content cannot be empty") + WorkflowCommentService._validate_content(content) - if len(content) > 1000: - raise ValueError("Comment content cannot exceed 1000 characters") with Session(db.engine) as session: comment = WorkflowComment( tenant_id=tenant_id, @@ -114,13 +119,9 @@ class WorkflowCommentService: mentioned_user_ids: Optional[list[str]] = None, ) -> dict: """Update a workflow comment.""" - if len(content.strip()) == 0: - raise ValueError("Comment content cannot be empty") + WorkflowCommentService._validate_content(content) - if len(content) > 1000: - raise ValueError("Comment content cannot exceed 1000 characters") - - with Session(db.engine) as session: + with Session(db.engine, expire_on_commit=False) as session: # Get comment with validation stmt = ( select(WorkflowComment) @@ -224,11 +225,7 @@ class WorkflowCommentService: mentioned_user_ids: Optional[list[str]] = None ) -> dict: """Add a reply to a workflow comment.""" - if len(content.strip()) == 0: - raise ValueError("Reply content cannot be empty") - - if len(content) > 1000: - raise ValueError("Reply content cannot exceed 1000 characters") + WorkflowCommentService._validate_content(content) with Session(db.engine, expire_on_commit=False) as session: # Check if comment exists @@ -268,11 +265,7 @@ class WorkflowCommentService: mentioned_user_ids: Optional[list[str]] = None ) -> WorkflowCommentReply: """Update a comment reply.""" - if len(content.strip()) == 0: - raise ValueError("Reply content cannot be empty") - - if len(content) > 1000: - raise ValueError("Reply content cannot exceed 1000 characters") + WorkflowCommentService._validate_content(content) with Session(db.engine, expire_on_commit=False) as session: reply = session.get(WorkflowCommentReply, reply_id) @@ -304,6 +297,7 @@ class WorkflowCommentService: session.add(mention) session.commit() + session.refresh(reply) # Refresh to get updated timestamp return { "id": reply.id, From ca8d15bc64c06c7ddc6d5446b86554a3aef48b99 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Sun, 31 Aug 2025 13:42:59 +0800 Subject: [PATCH 12/71] add mention user list api --- .../console/app/workflow_comment.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/api/controllers/console/app/workflow_comment.py b/api/controllers/console/app/workflow_comment.py index 6896ee6789..2cd76e6bfa 100644 --- a/api/controllers/console/app/workflow_comment.py +++ b/api/controllers/console/app/workflow_comment.py @@ -1,10 +1,11 @@ import logging -from flask_restful import Resource, marshal_with, reqparse +from flask_restful import Resource, fields, marshal_with, reqparse from controllers.console import api from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required +from fields.member_fields import account_with_role_fields from fields.workflow_comment_fields import ( workflow_comment_basic_fields, workflow_comment_create_fields, @@ -16,6 +17,7 @@ from fields.workflow_comment_fields import ( ) from libs.login import current_user, login_required from models import App +from services.account_service import TenantService from services.workflow_comment_service import WorkflowCommentService logger = logging.getLogger(__name__) @@ -216,6 +218,20 @@ class WorkflowCommentReplyDetailApi(Resource): return {"result": "success"}, 204 +class WorkflowCommentMentionUsersApi(Resource): + """API for getting mentionable users for workflow comments.""" + + @login_required + @setup_required + @account_initialization_required + @get_app_model + @marshal_with({"users": fields.List(fields.Nested(account_with_role_fields))}) + def get(self, app_model: App): + """Get all users in current tenant for mentions.""" + members = TenantService.get_tenant_members(current_user.current_tenant) + return {"users": members} + + # Register API routes api.add_resource(WorkflowCommentListApi, "/apps//workflow/comments") api.add_resource(WorkflowCommentDetailApi, "/apps//workflow/comments/") @@ -224,3 +240,4 @@ api.add_resource(WorkflowCommentReplyApi, "/apps//workflow/comments api.add_resource( WorkflowCommentReplyDetailApi, "/apps//workflow/comments//replies/" ) +api.add_resource(WorkflowCommentMentionUsersApi, "/apps//workflow/comments/mention-users") From cf72184ce48759867193a13919648c3816fc63aa Mon Sep 17 00:00:00 2001 From: hjlarry Date: Fri, 5 Sep 2025 22:19:16 +0800 Subject: [PATCH 13/71] each browser tab session a ws connected obj --- api/controllers/console/app/online_user.py | 120 +++++++++++---------- 1 file changed, 65 insertions(+), 55 deletions(-) diff --git a/api/controllers/console/app/online_user.py b/api/controllers/console/app/online_user.py index a4f0dea592..34423f40da 100644 --- a/api/controllers/console/app/online_user.py +++ b/api/controllers/console/app/online_user.py @@ -40,7 +40,7 @@ def socket_connect(sid, environ, auth): @sio.on("user_connect") def handle_user_connect(sid, data): """ - Handle user connect event, check login and get user info. + Handle user connect event. Each session (tab) is treated as an independent collaborator. """ workflow_id = data.get("workflow_id") @@ -53,31 +53,27 @@ def handle_user_connect(sid, data): if not user_id: return {"msg": "unauthorized"}, 401 - old_info_json = redis_client.hget(f"workflow_online_users:{workflow_id}", user_id) - if old_info_json: - old_info = json.loads(old_info_json) - old_sid = old_info.get("sid") - if old_sid and old_sid != sid: - sio.disconnect(sid=old_sid) - - user_info = { + # Each session is stored independently with sid as key + session_info = { "user_id": user_id, "username": session.get("username", "Unknown"), "avatar": session.get("avatar", None), "sid": sid, + "connected_at": int(time.time()), # Add timestamp to differentiate tabs } - redis_client.hset(f"workflow_online_users:{workflow_id}", user_id, json.dumps(user_info)) + # Store session info with sid as key + redis_client.hset(f"workflow_online_users:{workflow_id}", sid, json.dumps(session_info)) redis_client.set(f"ws_sid_map:{sid}", json.dumps({"workflow_id": workflow_id, "user_id": user_id})) - # Leader election: first user becomes the leader - leader_id = get_or_set_leader(workflow_id, user_id) - is_leader = leader_id == user_id + # Leader election: first session becomes the leader + leader_sid = get_or_set_leader(workflow_id, sid) + is_leader = leader_sid == sid sio.enter_room(sid, workflow_id) broadcast_online_users(workflow_id) - # Notify user of their status + # Notify this session of their leader status sio.emit("status", {"isLeader": is_leader}, room=sid) return {"msg": "connected", "user_id": user_id, "sid": sid, "isLeader": is_leader} @@ -86,41 +82,43 @@ def handle_user_connect(sid, data): @sio.on("disconnect") def handle_disconnect(sid): """ - Handle user disconnect event, remove user from workflow's online user list. + Handle session disconnect event. Remove the specific session from online users. """ mapping = redis_client.get(f"ws_sid_map:{sid}") if mapping: data = json.loads(mapping) workflow_id = data["workflow_id"] - user_id = data["user_id"] - redis_client.hdel(f"workflow_online_users:{workflow_id}", user_id) + + # Remove this specific session + redis_client.hdel(f"workflow_online_users:{workflow_id}", sid) redis_client.delete(f"ws_sid_map:{sid}") - # Handle leader re-election if the leader disconnected - handle_leader_disconnect(workflow_id, user_id) + # Handle leader re-election if the leader session disconnected + handle_leader_disconnect(workflow_id, sid) broadcast_online_users(workflow_id) -def get_or_set_leader(workflow_id, user_id): +def get_or_set_leader(workflow_id, sid): """ - Get current leader or set the user as leader if no leader exists. - Returns the leader user_id. + Get current leader session or set this session as leader if no leader exists. + Returns the leader session id (sid). """ leader_key = f"workflow_leader:{workflow_id}" current_leader = redis_client.get(leader_key) if not current_leader: - # No leader exists, make this user the leader - redis_client.set(leader_key, user_id, ex=3600) # Expire in 1 hour - return user_id + # No leader exists, make this session the leader + redis_client.set(leader_key, sid, ex=3600) # Expire in 1 hour + return sid return current_leader.decode("utf-8") if isinstance(current_leader, bytes) else current_leader -def handle_leader_disconnect(workflow_id, disconnected_user_id): +def handle_leader_disconnect(workflow_id, disconnected_sid): """ - Handle leader re-election when a user disconnects. + Handle leader re-election when a session disconnects. + If the disconnected session was the leader, elect a new leader from remaining sessions. """ leader_key = f"workflow_leader:{workflow_id}" current_leader = redis_client.get(leader_key) @@ -128,38 +126,37 @@ def handle_leader_disconnect(workflow_id, disconnected_user_id): if current_leader: current_leader = current_leader.decode("utf-8") if isinstance(current_leader, bytes) else current_leader - if current_leader == disconnected_user_id: - # Leader disconnected, elect a new leader - users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") + if current_leader == disconnected_sid: + # Leader session disconnected, elect a new leader + sessions_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") - if users_json: - # Get the first remaining user as new leader - new_leader_id = list(users_json.keys())[0] - if isinstance(new_leader_id, bytes): - new_leader_id = new_leader_id.decode("utf-8") + if sessions_json: + # Get the first remaining session as new leader + new_leader_sid = list(sessions_json.keys())[0] + if isinstance(new_leader_sid, bytes): + new_leader_sid = new_leader_sid.decode("utf-8") - redis_client.set(leader_key, new_leader_id, ex=3600) + redis_client.set(leader_key, new_leader_sid, ex=3600) - # Notify all users about the new leader - broadcast_leader_change(workflow_id, new_leader_id) + # Notify all sessions about the new leader + broadcast_leader_change(workflow_id, new_leader_sid) else: - # No users left, remove leader + # No sessions left, remove leader redis_client.delete(leader_key) -def broadcast_leader_change(workflow_id, new_leader_id): +def broadcast_leader_change(workflow_id, new_leader_sid): """ - Broadcast leader change to all users in the workflow. + Broadcast leader change to all sessions in the workflow. """ - users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") + sessions_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") - for user_id, user_info_json in users_json.items(): + for sid, session_info_json in sessions_json.items(): try: - user_info = json.loads(user_info_json) - user_sid = user_info.get("sid") - if user_sid: - is_leader = (user_id.decode("utf-8") if isinstance(user_id, bytes) else user_id) == new_leader_id - sio.emit("status", {"isLeader": is_leader}, room=user_sid) + sid_str = sid.decode("utf-8") if isinstance(sid, bytes) else sid + is_leader = sid_str == new_leader_sid + # Emit to each session whether they are the new leader + sio.emit("status", {"isLeader": is_leader}, room=sid_str) except Exception: continue @@ -175,20 +172,33 @@ def get_current_leader(workflow_id): def broadcast_online_users(workflow_id): """ - broadcast online users to the workflow room + Broadcast online users to the workflow room. + Each session is shown as a separate user (even if same person has multiple tabs). """ - users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") + sessions_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") users = [] - for _, user_info_json in users_json.items(): + + for sid, session_info_json in sessions_json.items(): try: - users.append(json.loads(user_info_json)) + session_info = json.loads(session_info_json) + # Each session appears as a separate "user" in the UI + users.append({ + "user_id": session_info["user_id"], + "username": session_info["username"], + "avatar": session_info.get("avatar"), + "sid": session_info["sid"], + "connected_at": session_info.get("connected_at"), + }) except Exception: continue - # Get current leader - leader_id = get_current_leader(workflow_id) + # Sort by connection time to maintain consistent order + users.sort(key=lambda x: x.get("connected_at") or 0) - sio.emit("online_users", {"workflow_id": workflow_id, "users": users, "leader": leader_id}, room=workflow_id) + # Get current leader session + leader_sid = get_current_leader(workflow_id) + + sio.emit("online_users", {"workflow_id": workflow_id, "users": users, "leader": leader_sid}, room=workflow_id) @sio.on("collaboration_event") From 4019c12d26969018ef4cf35e12a067d96397bde0 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Fri, 5 Sep 2025 22:20:07 +0800 Subject: [PATCH 14/71] fix missing import --- api/controllers/console/app/workflow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 9cf9af85db..e24deeb14e 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -24,6 +24,7 @@ from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.entities.app_invoke_entities import InvokeFrom from core.file.models import File from extensions.ext_database import db +from extensions.ext_redis import redis_client from factories import file_factory, variable_factory from fields.online_user_fields import online_user_list_fields from fields.workflow_fields import workflow_fields, workflow_pagination_fields From 20320f3a27162fbd5aee3d804aef72cfbf2cc21a Mon Sep 17 00:00:00 2001 From: hjlarry Date: Sat, 6 Sep 2025 00:08:17 +0800 Subject: [PATCH 15/71] show online users on the canvas --- .../workflow/header/header-in-normal.tsx | 3 ++ .../workflow/header/online-users.tsx | 50 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 web/app/components/workflow/header/online-users.tsx diff --git a/web/app/components/workflow/header/header-in-normal.tsx b/web/app/components/workflow/header/header-in-normal.tsx index 5768e6bc06..bfce06aca9 100644 --- a/web/app/components/workflow/header/header-in-normal.tsx +++ b/web/app/components/workflow/header/header-in-normal.tsx @@ -17,6 +17,7 @@ import RunAndHistory from './run-and-history' import EditingTitle from './editing-title' import EnvButton from './env-button' import VersionHistoryButton from './version-history-button' +import OnlineUsers from './online-users' export type HeaderInNormalProps = { components?: { @@ -60,6 +61,8 @@ const HeaderInNormal = ({
{components?.left} + + diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx new file mode 100644 index 0000000000..4d868cf57f --- /dev/null +++ b/web/app/components/workflow/header/online-users.tsx @@ -0,0 +1,50 @@ +'use client' +import Avatar from '@/app/components/base/avatar' +import { useCollaboration } from '../collaboration/hooks/use-collaboration' +import { useStore } from '../store' +import cn from '@/utils/classnames' + +const OnlineUsers = () => { + const appId = useStore(s => s.appId) + const { onlineUsers } = useCollaboration(appId) + + if (!onlineUsers || onlineUsers.length === 0) + return null + + // Show max 2 avatars directly, rest as count + const visibleUsers = onlineUsers.slice(0, 2) + const remainingCount = onlineUsers.length - 2 + + return ( +
+ {visibleUsers.map((user, index) => ( +
+ +
+ ))} + {remainingCount > 0 && ( +
+ +{remainingCount} +
+ )} +
+ ) +} + +export default OnlineUsers From 2e11b1298e0fed9e8d391715decb99e0228726b1 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Sat, 6 Sep 2025 11:19:47 +0800 Subject: [PATCH 16/71] add online users avatar --- web/app/components/base/avatar/index.tsx | 14 +++- .../workflow/header/online-users.tsx | 81 ++++++++++++------- 2 files changed, 65 insertions(+), 30 deletions(-) diff --git a/web/app/components/base/avatar/index.tsx b/web/app/components/base/avatar/index.tsx index 2a08f75f7b..85a024cbbf 100644 --- a/web/app/components/base/avatar/index.tsx +++ b/web/app/components/base/avatar/index.tsx @@ -8,6 +8,7 @@ export type AvatarProps = { size?: number className?: string textClassName?: string + backgroundColor?: string } const Avatar = ({ name, @@ -15,9 +16,18 @@ const Avatar = ({ size = 30, className, textClassName, + backgroundColor, }: AvatarProps) => { - const avatarClassName = 'shrink-0 flex items-center rounded-full bg-primary-600' - const style = { width: `${size}px`, height: `${size}px`, fontSize: `${size}px`, lineHeight: `${size}px` } + const avatarClassName = backgroundColor + ? 'shrink-0 flex items-center rounded-full' + : 'shrink-0 flex items-center rounded-full bg-primary-600' + const style = { + width: `${size}px`, + height: `${size}px`, + fontSize: `${size}px`, + lineHeight: `${size}px`, + ...(backgroundColor && !avatar ? { backgroundColor } : {}), + } const [imgError, setImgError] = useState(false) const handleError = () => { diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx index 4d868cf57f..a48ae40934 100644 --- a/web/app/components/workflow/header/online-users.tsx +++ b/web/app/components/workflow/header/online-users.tsx @@ -3,6 +3,16 @@ import Avatar from '@/app/components/base/avatar' import { useCollaboration } from '../collaboration/hooks/use-collaboration' import { useStore } from '../store' import cn from '@/utils/classnames' +import { ChevronDown } from '@/app/components/base/icons/src/vender/solid/arrows' + +const getUserColor = (id: string) => { + const colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'] + 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] +} const OnlineUsers = () => { const appId = useStore(s => s.appId) @@ -11,38 +21,53 @@ const OnlineUsers = () => { if (!onlineUsers || onlineUsers.length === 0) return null - // Show max 2 avatars directly, rest as count - const visibleUsers = onlineUsers.slice(0, 2) - const remainingCount = onlineUsers.length - 2 + // 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 return ( -
- {visibleUsers.map((user, index) => ( -
- -
- ))} - {remainingCount > 0 && ( -
+
+
+ {visibleUsers.map((user, index) => { + const userColor = getUserColor(user.user_id) + return ( +
+ +
+ ) + })} + {remainingCount > 0 && ( +
+ +{remainingCount} +
)} - > - +{remainingCount}
- )} + {remainingCount > 0 && ( + + )} +
) } From 81638c248ebf1a993f0311c372bb19f02c6bbbc4 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Sat, 6 Sep 2025 11:22:59 +0800 Subject: [PATCH 17/71] use one getUserColor func --- .../collaboration/components/user-cursors.tsx | 10 +--------- .../workflow/collaboration/utils/user-color.ts | 12 ++++++++++++ web/app/components/workflow/header/online-users.tsx | 10 +--------- 3 files changed, 14 insertions(+), 18 deletions(-) create mode 100644 web/app/components/workflow/collaboration/utils/user-color.ts diff --git a/web/app/components/workflow/collaboration/components/user-cursors.tsx b/web/app/components/workflow/collaboration/components/user-cursors.tsx index 799972b0b8..f0a827306e 100644 --- a/web/app/components/workflow/collaboration/components/user-cursors.tsx +++ b/web/app/components/workflow/collaboration/components/user-cursors.tsx @@ -1,5 +1,6 @@ import type { FC } from 'react' import type { CursorPosition, OnlineUser } from '@/app/components/workflow/collaboration/types' +import { getUserColor } from '../utils/user-color' type UserCursorsProps = { cursors: Record @@ -7,15 +8,6 @@ type UserCursorsProps = { onlineUsers: OnlineUser[] } -const getUserColor = (id: string) => { - const colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'] - 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] -} - const UserCursors: FC = ({ cursors, myUserId, diff --git a/web/app/components/workflow/collaboration/utils/user-color.ts b/web/app/components/workflow/collaboration/utils/user-color.ts new file mode 100644 index 0000000000..ecdba509b5 --- /dev/null +++ b/web/app/components/workflow/collaboration/utils/user-color.ts @@ -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 = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'] + 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] +} diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx index a48ae40934..ecccd3cb5b 100644 --- a/web/app/components/workflow/header/online-users.tsx +++ b/web/app/components/workflow/header/online-users.tsx @@ -4,15 +4,7 @@ import { useCollaboration } from '../collaboration/hooks/use-collaboration' import { useStore } from '../store' import cn from '@/utils/classnames' import { ChevronDown } from '@/app/components/base/icons/src/vender/solid/arrows' - -const getUserColor = (id: string) => { - const colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'] - 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] -} +import { getUserColor } from '../collaboration/utils/user-color' const OnlineUsers = () => { const appId = useStore(s => s.appId) From 6ec8bfdfee3733c54ba2f3349a1d8aca46ee8706 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Sat, 6 Sep 2025 11:29:45 +0800 Subject: [PATCH 18/71] add mouse over avatar display username --- .../workflow/header/online-users.tsx | 65 +++++++++++++------ 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx index ecccd3cb5b..7f6792d860 100644 --- a/web/app/components/workflow/header/online-users.tsx +++ b/web/app/components/workflow/header/online-users.tsx @@ -5,6 +5,7 @@ import { useStore } from '../store' import cn from '@/utils/classnames' import { ChevronDown } from '@/app/components/base/icons/src/vender/solid/arrows' import { getUserColor } from '../collaboration/utils/user-color' +import Tooltip from '@/app/components/base/tooltip' const OnlineUsers = () => { const appId = useStore(s => s.appId) @@ -28,32 +29,56 @@ const OnlineUsers = () => { {visibleUsers.map((user, index) => { const userColor = getUserColor(user.user_id) return ( -
- -
+
+ +
+ ) })} {remainingCount > 0 && ( -
+ {onlineUsers.slice(maxVisible).map((user, index) => ( +
+ {user.username || 'User'} +
+ ))} +
+ } + position="bottom" + triggerMethod="hover" + needsDelay={false} + asChild > - +{remainingCount} -
+
+ +{remainingCount} +
+ )}
{remainingCount > 0 && ( From 850b05573eff189f0f2c0381f8d805dd8eca807f Mon Sep 17 00:00:00 2001 From: hjlarry Date: Sat, 6 Sep 2025 12:01:49 +0800 Subject: [PATCH 19/71] add dropdown users list --- .../workflow/header/online-users.tsx | 84 +++++++++++++------ 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx index 7f6792d860..f339a75055 100644 --- a/web/app/components/workflow/header/online-users.tsx +++ b/web/app/components/workflow/header/online-users.tsx @@ -1,4 +1,5 @@ 'use client' +import { useState } from 'react' import Avatar from '@/app/components/base/avatar' import { useCollaboration } from '../collaboration/hooks/use-collaboration' import { useStore } from '../store' @@ -6,10 +7,16 @@ import cn from '@/utils/classnames' import { ChevronDown } from '@/app/components/base/icons/src/vender/solid/arrows' import { getUserColor } from '../collaboration/utils/user-color' import Tooltip from '@/app/components/base/tooltip' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' const OnlineUsers = () => { const appId = useStore(s => s.appId) const { onlineUsers } = useCollaboration(appId) + const [dropdownOpen, setDropdownOpen] = useState(false) if (!onlineUsers || onlineUsers.length === 0) return null @@ -53,37 +60,60 @@ const OnlineUsers = () => { ) })} {remainingCount > 0 && ( - - {onlineUsers.slice(maxVisible).map((user, index) => ( -
- {user.username || 'User'} -
- ))} - - } - position="bottom" - triggerMethod="hover" - needsDelay={false} - asChild + -
setDropdownOpen(true)} + onMouseLeave={() => setDropdownOpen(false)} + asChild > - +{remainingCount} -
-
+
+
+ +{remainingCount} +
+ +
+ + setDropdownOpen(true)} + onMouseLeave={() => setDropdownOpen(false)} + className="z-[9999]" + > +
+ {onlineUsers.map((user) => { + const userColor = getUserColor(user.user_id) + return ( +
+ + + {user.username || 'User'} + +
+ ) + })} +
+
+ )} - {remainingCount > 0 && ( - - )} ) From 34eb4216498c38d58f57f9e8ac75198a2dc8e3d5 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Sat, 6 Sep 2025 12:27:54 +0800 Subject: [PATCH 20/71] add currentUserId is me --- .../workflow/header/online-users.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx index f339a75055..36556b2242 100644 --- a/web/app/components/workflow/header/online-users.tsx +++ b/web/app/components/workflow/header/online-users.tsx @@ -12,12 +12,16 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' +import { useAppContext } from '@/context/app-context' const OnlineUsers = () => { const appId = useStore(s => s.appId) const { onlineUsers } = useCollaboration(appId) + const { userProfile } = useAppContext() const [dropdownOpen, setDropdownOpen] = useState(false) + const currentUserId = userProfile?.id + if (!onlineUsers || onlineUsers.length === 0) return null @@ -34,11 +38,16 @@ const OnlineUsers = () => {
{visibleUsers.map((user, index) => { - const userColor = getUserColor(user.user_id) + const isCurrentUser = user.user_id === currentUserId + const userColor = isCurrentUser ? undefined : getUserColor(user.user_id) + const displayName = isCurrentUser + ? `${user.username || 'User'} (You)` + : (user.username || 'User') + return ( { >
{onlineUsers.map((user) => { - const userColor = getUserColor(user.user_id) + const isCurrentUser = user.user_id === currentUserId + const userColor = isCurrentUser ? undefined : getUserColor(user.user_id) + const displayName = isCurrentUser + ? `${user.username || 'User'} (You)` + : (user.username || 'User') + return (
{ backgroundColor={userColor} /> - {user.username || 'User'} + {displayName}
) From 9f97f4d79ee55557fb4653b1fbd6e213cfc758e5 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Sat, 6 Sep 2025 15:54:19 +0800 Subject: [PATCH 21/71] fix cursor style --- .../workflow/collaboration/components/user-cursors.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/app/components/workflow/collaboration/components/user-cursors.tsx b/web/app/components/workflow/collaboration/components/user-cursors.tsx index f0a827306e..e5ec93cf6b 100644 --- a/web/app/components/workflow/collaboration/components/user-cursors.tsx +++ b/web/app/components/workflow/collaboration/components/user-cursors.tsx @@ -26,7 +26,7 @@ const UserCursors: FC = ({ return (
= ({ className="drop-shadow-md" >
Date: Mon, 8 Sep 2025 09:00:20 +0800 Subject: [PATCH 22/71] add request leader to sync graph --- .../workflow-app/components/workflow-main.tsx | 36 ++++++++++++------- .../hooks/use-nodes-sync-draft.ts | 11 +++--- .../core/collaboration-manager.ts | 26 ++++++++++++++ 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index d310d0383c..6bc2fbcc31 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -109,6 +109,19 @@ const WorkflowMain = ({ } }, [featuresStore, workflowStore]) + const { + doSyncWorkflowDraft, + syncWorkflowDraftWhenPageClose, + } = useNodesSyncDraft() + const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() + const { + handleBackupDraft, + handleLoadBackupDraft, + handleRestoreFromPublishedWorkflow, + handleRun, + handleStopRun, + } = useWorkflowRun() + useEffect(() => { if (!appId) return @@ -125,18 +138,17 @@ const WorkflowMain = ({ return unsubscribe }, [appId, handleWorkflowDataUpdate]) - const { - doSyncWorkflowDraft, - syncWorkflowDraftWhenPageClose, - } = useNodesSyncDraft() - const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() - const { - handleBackupDraft, - handleLoadBackupDraft, - handleRestoreFromPublishedWorkflow, - handleRun, - handleStopRun, - } = useWorkflowRun() + // Listen for sync requests from other users (only processed by leader) + useEffect(() => { + if (!appId) return + + const unsubscribe = collaborationManager.onSyncRequest(() => { + console.log('Leader received sync request, performing sync') + doSyncWorkflowDraft() + }) + + return unsubscribe + }, [appId, doSyncWorkflowDraft]) const { handleStartWorkflowRun, handleWorkflowStartRunInChatflow, diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index c38e60cf97..09b0cf2e19 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -129,9 +129,10 @@ export const useNodesSyncDraft = () => { // Check leader status at sync time const currentIsLeader = collaborationManager.getIsLeader() - // Only allow leader to sync data + // If not leader, request the leader to sync if (!currentIsLeader) { - console.log('Not leader, skipping workflow draft sync') + console.log('Not leader, requesting leader to sync workflow draft') + collaborationManager.emitSyncRequest() callback?.onSettled?.() return } @@ -155,10 +156,10 @@ export const useNodesSyncDraft = () => { console.error('Leader failed to sync workflow draft:', error) if (error && error.json && !error.bodyUsed) { error.json().then((err: any) => { - if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError) - // TODO: hjlarry test collaboration - // handleRefreshWorkflowDraft() + if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError) { console.error('draft_workflow_not_sync', err) + handleRefreshWorkflowDraft() + } }) } callback?.onError && callback.onError() diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index a0075ecb1f..a36fa7ce16 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -144,6 +144,24 @@ export class CollaborationManager { } } + emitSyncRequest(): void { + if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return + + const socket = webSocketClient.getSocket(this.currentAppId) + if (socket) { + console.log('Emitting sync request to leader') + socket.emit('collaboration_event', { + type: 'syncRequest', + data: { timestamp: Date.now() }, + timestamp: Date.now(), + }) + } + } + + onSyncRequest(callback: () => void): () => void { + return this.eventEmitter.on('syncRequest', callback) + } + onStateChange(callback: (state: Partial) => void): () => void { return this.eventEmitter.on('stateChange', callback) } @@ -285,6 +303,14 @@ export class CollaborationManager { console.log('Processing varsAndFeaturesUpdate event:', update) this.eventEmitter.emit('varsAndFeaturesUpdate', update) } + else if (update.type === 'syncRequest') { + console.log('Received sync request from another user') + // Only process if we are the leader + if (this.isLeader) { + console.log('Leader received sync request, triggering sync') + this.eventEmitter.emit('syncRequest', {}) + } + } }) socket.on('online_users', (data: { users: OnlineUser[]; leader?: string }) => { From 684f7df1589413fd7cacfafb11746d16269c49c7 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Mon, 8 Sep 2025 14:46:28 +0800 Subject: [PATCH 23/71] node data use crdt data --- .../core/collaboration-manager.ts | 104 +++++++++++++++--- .../workflow/hooks/use-node-data-update.ts | 9 +- 2 files changed, 91 insertions(+), 22 deletions(-) diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index a36fa7ce16..ba9a2db07a 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -202,29 +202,107 @@ export class CollaborationManager { } private syncNodes(oldNodes: Node[], newNodes: Node[]): void { - if (!this.nodesMap) return + if (!this.nodesMap || !this.doc) return const oldNodesMap = new Map(oldNodes.map(node => [node.id, node])) const newNodesMap = new Map(newNodes.map(node => [node.id, node])) + // Delete removed nodes oldNodes.forEach((oldNode) => { if (!newNodesMap.has(oldNode.id)) this.nodesMap.delete(oldNode.id) }) + // Add or update nodes with fine-grained sync for data properties newNodes.forEach((newNode) => { const oldNode = oldNodesMap.get(newNode.id) + if (!oldNode) { - const persistentData = this.getPersistentNodeData(newNode) - const clonedData = JSON.parse(JSON.stringify(persistentData)) - this.nodesMap.set(newNode.id, clonedData) + // New node - create as nested structure + const nodeData: any = { + id: newNode.id, + type: newNode.type, + position: { ...newNode.position }, + width: newNode.width, + height: newNode.height, + sourcePosition: newNode.sourcePosition, + targetPosition: newNode.targetPosition, + data: {}, + } + + // Clone data properties, excluding private ones + Object.entries(newNode.data).forEach(([key, value]) => { + if (!key.startsWith('_') && value !== undefined) + nodeData.data[key] = JSON.parse(JSON.stringify(value)) + }) + + this.nodesMap.set(newNode.id, nodeData) } else { - const oldPersistentData = this.getPersistentNodeData(oldNode) - const newPersistentData = this.getPersistentNodeData(newNode) - if (!isEqual(oldPersistentData, newPersistentData)) { - const clonedData = JSON.parse(JSON.stringify(newPersistentData)) - this.nodesMap.set(newNode.id, clonedData) + // Get existing node from CRDT + const existingNode = this.nodesMap.get(newNode.id) + + if (existingNode) { + // Create a deep copy to modify + const updatedNode = JSON.parse(JSON.stringify(existingNode)) + + // Update position only if changed + if (oldNode.position.x !== newNode.position.x || oldNode.position.y !== newNode.position.y) + updatedNode.position = { ...newNode.position } + + // Update dimensions only if changed + if (oldNode.width !== newNode.width) + updatedNode.width = newNode.width + + if (oldNode.height !== newNode.height) + updatedNode.height = newNode.height + + // Ensure data object exists + if (!updatedNode.data) + updatedNode.data = {} + + // Fine-grained update of data properties + const oldData = oldNode.data || {} + const newData = newNode.data || {} + + // Only update changed properties in data + Object.entries(newData).forEach(([key, value]) => { + if (!key.startsWith('_')) { + const oldValue = (oldData as any)[key] + if (!isEqual(oldValue, value)) + updatedNode.data[key] = JSON.parse(JSON.stringify(value)) + } + }) + + // Remove deleted properties from data + Object.keys(oldData).forEach((key) => { + if (!key.startsWith('_') && !(key in newData)) + delete updatedNode.data[key] + }) + + // Only update in CRDT if something actually changed + if (!isEqual(existingNode, updatedNode)) + this.nodesMap.set(newNode.id, updatedNode) + } + else { + // Node exists locally but not in CRDT yet + const nodeData: any = { + id: newNode.id, + type: newNode.type, + position: { ...newNode.position }, + width: newNode.width, + height: newNode.height, + sourcePosition: newNode.sourcePosition, + targetPosition: newNode.targetPosition, + data: {}, + } + + Object.entries(newNode.data).forEach(([key, value]) => { + if (!key.startsWith('_') && value !== undefined) + nodeData.data[key] = JSON.parse(JSON.stringify(value)) + }) + + this.nodesMap.set(newNode.id, nodeData) } } }) @@ -254,14 +332,6 @@ export class CollaborationManager { }) } - private getPersistentNodeData(node: Node): any { - const { data, ...rest } = node - const filteredData = Object.fromEntries( - Object.entries(data).filter(([key]) => !key.startsWith('_')), - ) - return { ...rest, data: filteredData } - } - private setupSubscriptions(): void { this.nodesMap?.subscribe((event: any) => { if (event.by === 'import' && this.reactFlowStore) { diff --git a/web/app/components/workflow/hooks/use-node-data-update.ts b/web/app/components/workflow/hooks/use-node-data-update.ts index c59c858184..732a01ed05 100644 --- a/web/app/components/workflow/hooks/use-node-data-update.ts +++ b/web/app/components/workflow/hooks/use-node-data-update.ts @@ -3,6 +3,7 @@ import produce from 'immer' import { useStoreApi } from 'reactflow' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useNodesReadOnly } from './use-workflow' +import { useCollaborativeWorkflow } from './use-collaborative-workflow' type NodeDataUpdatePayload = { id: string @@ -13,13 +14,11 @@ export const useNodeDataUpdate = () => { const store = useStoreApi() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { getNodesReadOnly } = useNodesReadOnly() + const collaborativeWorkflow = useCollaborativeWorkflow() const handleNodeDataUpdate = useCallback(({ id, data }: NodeDataUpdatePayload) => { - const { - getNodes, - setNodes, - } = store.getState() - const newNodes = produce(getNodes(), (draft) => { + const { nodes, setNodes } = collaborativeWorkflow.getState() + const newNodes = produce(nodes, (draft) => { const currentNode = draft.find(node => node.id === id)! if (currentNode) From 294fc41aeced8f37c27c2f418a671fcc54887f49 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Tue, 9 Sep 2025 09:58:55 +0800 Subject: [PATCH 24/71] add redo undo manager of CRDT --- .../core/collaboration-manager.ts | 220 +++++++++++++++++- .../components/workflow/header/undo-redo.tsx | 32 ++- .../workflow/hooks/use-nodes-interactions.ts | 45 ++-- 3 files changed, 257 insertions(+), 40 deletions(-) diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index ba9a2db07a..38e47bba97 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -1,4 +1,4 @@ -import { LoroDoc } from 'loro-crdt' +import { LoroDoc, UndoManager } from 'loro-crdt' import { isEqual } from 'lodash-es' import { webSocketClient } from './websocket-manager' import { CRDTProvider } from './crdt-provider' @@ -8,6 +8,7 @@ import type { CollaborationState, CursorPosition, OnlineUser } from '../types/co export class CollaborationManager { private doc: LoroDoc | null = null + private undoManager: UndoManager | null = null private provider: CRDTProvider | null = null private nodesMap: any = null private edgesMap: any = null @@ -18,6 +19,7 @@ export class CollaborationManager { private isLeader = false private leaderId: string | null = null private activeConnections = new Set() + private isUndoRedoInProgress = false init = (appId: string, reactFlowStore: any): void => { if (!reactFlowStore) { @@ -28,15 +30,31 @@ export class CollaborationManager { } setNodes = (oldNodes: Node[], newNodes: Node[]): void => { + if (!this.doc) return + + // Don't track operations during undo/redo to prevent loops + if (this.isUndoRedoInProgress) { + console.log('Skipping setNodes during undo/redo') + return + } + + console.log('Setting nodes with tracking') this.syncNodes(oldNodes, newNodes) - if (this.doc) - this.doc.commit() + this.doc.commit() } setEdges = (oldEdges: Edge[], newEdges: Edge[]): void => { + if (!this.doc) return + + // Don't track operations during undo/redo to prevent loops + if (this.isUndoRedoInProgress) { + console.log('Skipping setEdges during undo/redo') + return + } + + console.log('Setting edges with tracking') this.syncEdges(oldEdges, newEdges) - if (this.doc) - this.doc.commit() + this.doc.commit() } destroy = (): void => { @@ -73,6 +91,54 @@ export class CollaborationManager { this.doc = new LoroDoc() this.nodesMap = this.doc.getMap('nodes') this.edgesMap = this.doc.getMap('edges') + + // Initialize UndoManager for collaborative undo/redo + this.undoManager = new UndoManager(this.doc, { + maxUndoSteps: 100, + mergeInterval: 500, // Merge operations within 500ms + excludeOriginPrefixes: [], // Don't exclude anything - let UndoManager track all local operations + onPush: (isUndo, range, event) => { + console.log('UndoManager onPush:', { isUndo, range, event }) + // Store current selection state when an operation is pushed + const selectedNode = this.reactFlowStore?.getState().getNodes().find((n: Node) => n.data.selected) + + // Emit event to update UI button states when new operation is pushed + setTimeout(() => { + this.eventEmitter.emit('undoRedoStateChange', { + canUndo: this.undoManager?.canUndo() || false, + canRedo: this.undoManager?.canRedo() || false, + }) + }, 0) + + return { + value: { + selectedNodeId: selectedNode?.id || null, + timestamp: Date.now(), + }, + cursors: [], + } + }, + onPop: (isUndo, value, counterRange) => { + console.log('UndoManager onPop:', { isUndo, value, counterRange }) + // Restore selection state when undoing/redoing + if (value?.value && typeof value.value === 'object' && 'selectedNodeId' in value.value && this.reactFlowStore) { + const selectedNodeId = (value.value as any).selectedNodeId + if (selectedNodeId) { + const { setNodes } = this.reactFlowStore.getState() + const nodes = this.reactFlowStore.getState().getNodes() + const newNodes = nodes.map((n: Node) => ({ + ...n, + data: { + ...n.data, + selected: n.id === selectedNodeId, + }, + })) + setNodes(newNodes) + } + } + }, + }) + this.provider = new CRDTProvider(socket, this.doc) this.setupSubscriptions() @@ -98,6 +164,7 @@ export class CollaborationManager { webSocketClient.disconnect(this.currentAppId) this.provider?.destroy() + this.undoManager = null this.doc = null this.provider = null this.nodesMap = null @@ -105,6 +172,7 @@ export class CollaborationManager { this.currentAppId = null this.reactFlowStore = null this.cursors = {} + this.isUndoRedoInProgress = false // Only reset leader status when actually disconnecting const wasLeader = this.isLeader @@ -182,6 +250,10 @@ export class CollaborationManager { return this.eventEmitter.on('leaderChange', callback) } + onUndoRedoStateChange(callback: (state: { canUndo: boolean; canRedo: boolean }) => void): () => void { + return this.eventEmitter.on('undoRedoStateChange', callback) + } + getLeaderId(): string | null { return this.leaderId } @@ -190,6 +262,114 @@ export class CollaborationManager { return this.isLeader } + // Collaborative undo/redo methods + undo(): boolean { + if (!this.undoManager) { + console.log('UndoManager not initialized') + return false + } + + const canUndo = this.undoManager.canUndo() + console.log('Can undo:', canUndo) + + if (canUndo) { + this.isUndoRedoInProgress = true + const result = this.undoManager.undo() + + // After undo, manually update React state from CRDT without triggering collaboration + if (result && this.reactFlowStore) { + requestAnimationFrame(() => { + // Get ReactFlow's native setters, not the collaborative ones + const state = this.reactFlowStore.getState() + const updatedNodes = Array.from(this.nodesMap.values()) + const updatedEdges = Array.from(this.edgesMap.values()) + console.log('Manually updating React state after undo') + + // Call ReactFlow's native setters directly to avoid triggering collaboration + state.setNodes(updatedNodes) + state.setEdges(updatedEdges) + + this.isUndoRedoInProgress = false + + // Emit event to update UI button states + this.eventEmitter.emit('undoRedoStateChange', { + canUndo: this.undoManager?.canUndo() || false, + canRedo: this.undoManager?.canRedo() || false, + }) + }) + } + else { + this.isUndoRedoInProgress = false + } + + console.log('Undo result:', result) + return result + } + + return false + } + + redo(): boolean { + if (!this.undoManager) { + console.log('RedoManager not initialized') + return false + } + + const canRedo = this.undoManager.canRedo() + console.log('Can redo:', canRedo) + + if (canRedo) { + this.isUndoRedoInProgress = true + const result = this.undoManager.redo() + + // After redo, manually update React state from CRDT without triggering collaboration + if (result && this.reactFlowStore) { + requestAnimationFrame(() => { + // Get ReactFlow's native setters, not the collaborative ones + const state = this.reactFlowStore.getState() + const updatedNodes = Array.from(this.nodesMap.values()) + const updatedEdges = Array.from(this.edgesMap.values()) + console.log('Manually updating React state after redo') + + // Call ReactFlow's native setters directly to avoid triggering collaboration + state.setNodes(updatedNodes) + state.setEdges(updatedEdges) + + this.isUndoRedoInProgress = false + + // Emit event to update UI button states + this.eventEmitter.emit('undoRedoStateChange', { + canUndo: this.undoManager?.canUndo() || false, + canRedo: this.undoManager?.canRedo() || false, + }) + }) + } + else { + this.isUndoRedoInProgress = false + } + + console.log('Redo result:', result) + return result + } + + return false + } + + canUndo(): boolean { + if (!this.undoManager) return false + return this.undoManager.canUndo() + } + + canRedo(): boolean { + if (!this.undoManager) return false + return this.undoManager.canRedo() + } + + clearUndoStack(): void { + if (!this.undoManager) return + this.undoManager.clear() + } + debugLeaderStatus(): void { console.log('=== Leader Status Debug ===') console.log('Current leader status:', this.isLeader) @@ -334,21 +514,43 @@ export class CollaborationManager { private setupSubscriptions(): void { this.nodesMap?.subscribe((event: any) => { + console.log('nodesMap subscription event:', event) if (event.by === 'import' && this.reactFlowStore) { + // Don't update React nodes during undo/redo to prevent loops + if (this.isUndoRedoInProgress) { + console.log('Skipping nodes subscription update during undo/redo') + return + } + requestAnimationFrame(() => { - const { setNodes } = this.reactFlowStore.getState() + // Get ReactFlow's native setters, not the collaborative ones + const state = this.reactFlowStore.getState() const updatedNodes = Array.from(this.nodesMap.values()) - setNodes(updatedNodes) + console.log('Updating React nodes from subscription') + + // Call ReactFlow's native setter directly to avoid triggering collaboration + state.setNodes(updatedNodes) }) } }) this.edgesMap?.subscribe((event: any) => { + console.log('edgesMap subscription event:', event) if (event.by === 'import' && this.reactFlowStore) { + // Don't update React edges during undo/redo to prevent loops + if (this.isUndoRedoInProgress) { + console.log('Skipping edges subscription update during undo/redo') + return + } + requestAnimationFrame(() => { - const { setEdges } = this.reactFlowStore.getState() + // Get ReactFlow's native setters, not the collaborative ones + const state = this.reactFlowStore.getState() const updatedEdges = Array.from(this.edgesMap.values()) - setEdges(updatedEdges) + console.log('Updating React edges from subscription') + + // Call ReactFlow's native setter directly to avoid triggering collaboration + state.setEdges(updatedEdges) }) } }) diff --git a/web/app/components/workflow/header/undo-redo.tsx b/web/app/components/workflow/header/undo-redo.tsx index 9beb655e28..2e3ea8bffd 100644 --- a/web/app/components/workflow/header/undo-redo.tsx +++ b/web/app/components/workflow/header/undo-redo.tsx @@ -6,27 +6,39 @@ import { RiArrowGoForwardFill, } from '@remixicon/react' import TipPopup from '../operator/tip-popup' -import { useWorkflowHistoryStore } from '../workflow-history-store' import Divider from '../../base/divider' import { useNodesReadOnly } from '@/app/components/workflow/hooks' import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history' +import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import classNames from '@/utils/classnames' export type UndoRedoProps = { handleUndo: () => void; handleRedo: () => void } const UndoRedo: FC = ({ handleUndo, handleRedo }) => { const { t } = useTranslation() - const { store } = useWorkflowHistoryStore() const [buttonsDisabled, setButtonsDisabled] = useState({ undo: true, redo: true }) useEffect(() => { - const unsubscribe = store.temporal.subscribe((state) => { + // Update button states based on Loro's UndoManager + const updateButtonStates = () => { setButtonsDisabled({ - undo: state.pastStates.length === 0, - redo: state.futureStates.length === 0, + undo: !collaborationManager.canUndo(), + redo: !collaborationManager.canRedo(), + }) + } + + // Initial state + updateButtonStates() + + // Listen for undo/redo state changes + const unsubscribe = collaborationManager.onUndoRedoStateChange((state) => { + setButtonsDisabled({ + undo: !state.canUndo, + redo: !state.canRedo, }) }) + return () => unsubscribe() - }, [store]) + }, []) const { nodesReadOnly } = useNodesReadOnly() @@ -36,9 +48,9 @@ const UndoRedo: FC = ({ handleUndo, handleRedo }) => {
!nodesReadOnly && !buttonsDisabled.undo && handleUndo()} > @@ -48,9 +60,9 @@ const UndoRedo: FC = ({ handleUndo, handleRedo }) => {
!nodesReadOnly && !buttonsDisabled.redo && handleRedo()} > diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 854d54b110..4925274e8d 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -50,7 +50,7 @@ import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants' import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types' import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions' import { useNodeLoopInteractions } from '../nodes/loop/use-interactions' -import { useWorkflowHistoryStore } from '../workflow-history-store' +import { collaborationManager } from '../collaboration/core/collaboration-manager' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useHelpline } from './use-helpline' import { @@ -67,7 +67,6 @@ export const useNodesInteractions = () => { const collaborativeWorkflow = useCollaborativeWorkflow() const workflowStore = useWorkflowStore() const reactflow = useReactFlow() - const { store: workflowHistoryStore } = useWorkflowHistoryStore() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { checkNestedParallelLimit, @@ -86,7 +85,7 @@ export const useNodesInteractions = () => { } = useNodeLoopInteractions() const dragNodeStartPosition = useRef({ x: 0, y: 0 } as { x: number; y: number }) - const { saveStateToHistory, undo, redo } = useWorkflowHistory() + const { saveStateToHistory } = useWorkflowHistory() const handleNodeDragStart = useCallback((_, node) => { workflowStore.setState({ nodeAnimation: false }) @@ -1427,31 +1426,35 @@ export const useNodesInteractions = () => { if (getNodesReadOnly() || getWorkflowReadOnly()) return - const { setNodes, setEdges } = collaborativeWorkflow.getState() - undo() + // Use collaborative undo from Loro + const undoResult = collaborationManager.undo() - const { edges, nodes } = workflowHistoryStore.getState() - if (edges.length === 0 && nodes.length === 0) - return - - setEdges(edges) - setNodes(nodes) - }, [collaborativeWorkflow, undo, workflowHistoryStore, getNodesReadOnly, getWorkflowReadOnly]) + if (undoResult) { + // The undo operation will automatically trigger subscriptions + // which will update the nodes and edges through setupSubscriptions + console.log('Collaborative undo performed') + } + else { + console.log('Nothing to undo') + } + }, [getNodesReadOnly, getWorkflowReadOnly]) const handleHistoryForward = useCallback(() => { if (getNodesReadOnly() || getWorkflowReadOnly()) return - const { setNodes, setEdges } = collaborativeWorkflow.getState() - redo() + // Use collaborative redo from Loro + const redoResult = collaborationManager.redo() - const { edges, nodes } = workflowHistoryStore.getState() - if (edges.length === 0 && nodes.length === 0) - return - - setEdges(edges) - setNodes(nodes) - }, [redo, collaborativeWorkflow, workflowHistoryStore, getNodesReadOnly, getWorkflowReadOnly]) + if (redoResult) { + // The redo operation will automatically trigger subscriptions + // which will update the nodes and edges through setupSubscriptions + console.log('Collaborative redo performed') + } + else { + console.log('Nothing to redo') + } + }, [getNodesReadOnly, getWorkflowReadOnly]) return { handleNodeDragStart, From 8ff062ec8bda505b51377896ce5f80a702ab29d8 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Tue, 9 Sep 2025 10:20:02 +0800 Subject: [PATCH 25/71] change user default color --- web/app/components/workflow/collaboration/utils/user-color.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/workflow/collaboration/utils/user-color.ts b/web/app/components/workflow/collaboration/utils/user-color.ts index ecdba509b5..51aee6a038 100644 --- a/web/app/components/workflow/collaboration/utils/user-color.ts +++ b/web/app/components/workflow/collaboration/utils/user-color.ts @@ -3,7 +3,7 @@ * Used for cursor colors and avatar backgrounds */ export const getUserColor = (id: string): string => { - const colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'] + 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 From 2b908d4fbeda6ed76ad06d5b76a01d2905964cf9 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Tue, 9 Sep 2025 14:24:37 +0800 Subject: [PATCH 26/71] add app state update --- api/controllers/console/app/online_user.py | 2 ++ .../[appId]/overview/cardView.tsx | 35 +++++++++++++++++-- .../core/collaboration-manager.ts | 8 +++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/api/controllers/console/app/online_user.py b/api/controllers/console/app/online_user.py index 34423f40da..4a95e777d9 100644 --- a/api/controllers/console/app/online_user.py +++ b/api/controllers/console/app/online_user.py @@ -207,6 +207,8 @@ def handle_collaboration_event(sid, data): Handle general collaboration events, include: 1. mouseMove 2. varsAndFeaturesUpdate + 3. syncRequest(ask leader to update graph) + 4. appStateUpdate """ mapping = redis_client.get(f"ws_sid_map:{sid}") diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx index 3d572b926a..2463490fc1 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import React from 'react' +import React, { useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import AppCard from '@/app/components/app/overview/appCard' @@ -19,6 +19,8 @@ import { asyncRunSafe } from '@/utils' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import type { IAppCardProps } from '@/app/components/app/overview/appCard' import { useStore as useAppStore } from '@/app/components/app/store' +import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' +import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' export type ICardViewProps = { appId: string @@ -47,15 +49,44 @@ const CardView: FC = ({ appId, isInPanel, className }) => { message ||= (type === 'success' ? 'modifiedSuccessfully' : 'modifiedUnsuccessfully') - if (type === 'success') + if (type === 'success') { updateAppDetail() + // Emit collaboration event to notify other clients of app state changes + const socket = webSocketClient.getSocket(appId) + if (socket) { + socket.emit('collaboration_event', { + type: 'appStateUpdate', + data: { timestamp: Date.now() }, + timestamp: Date.now(), + }) + } + } + notify({ type, message: t(`common.actionMsg.${message}`), }) } + // Listen for collaborative app state updates from other clients + useEffect(() => { + if (!appId) return + + const unsubscribe = collaborationManager.onAppStateUpdate(async (update: any) => { + try { + console.log('Received app state update from collaboration:', update) + // Update app detail when other clients modify app state + await updateAppDetail() + } + catch (error) { + console.error('app state update failed:', error) + } + }) + + return unsubscribe + }, [appId]) + const onChangeSiteStatus = async (value: boolean) => { const [err] = await asyncRunSafe( updateAppSiteStatus({ diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index 38e47bba97..98fb76c03b 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -246,6 +246,10 @@ export class CollaborationManager { return this.eventEmitter.on('varsAndFeaturesUpdate', callback) } + onAppStateUpdate(callback: (update: any) => void): () => void { + return this.eventEmitter.on('appStateUpdate', callback) + } + onLeaderChange(callback: (isLeader: boolean) => void): () => void { return this.eventEmitter.on('leaderChange', callback) } @@ -575,6 +579,10 @@ export class CollaborationManager { console.log('Processing varsAndFeaturesUpdate event:', update) this.eventEmitter.emit('varsAndFeaturesUpdate', update) } + else if (update.type === 'appStateUpdate') { + console.log('Processing appStateUpdate event:', update) + this.eventEmitter.emit('appStateUpdate', update) + } else if (update.type === 'syncRequest') { console.log('Received sync request from another user') // Only process if we are the leader From 3867fece4a9fc9611d856f769e3bcc113726b209 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Tue, 9 Sep 2025 15:01:38 +0800 Subject: [PATCH 27/71] mcp server update --- api/controllers/console/app/online_user.py | 1 + .../components/tools/mcp/mcp-service-card.tsx | 46 +++++++++++++++++++ .../core/collaboration-manager.ts | 8 ++++ 3 files changed, 55 insertions(+) diff --git a/api/controllers/console/app/online_user.py b/api/controllers/console/app/online_user.py index 4a95e777d9..c9c266692c 100644 --- a/api/controllers/console/app/online_user.py +++ b/api/controllers/console/app/online_user.py @@ -209,6 +209,7 @@ def handle_collaboration_event(sid, data): 2. varsAndFeaturesUpdate 3. syncRequest(ask leader to update graph) 4. appStateUpdate + 5. mcpServerUpdate """ mapping = redis_client.get(f"ws_sid_map:{sid}") diff --git a/web/app/components/tools/mcp/mcp-service-card.tsx b/web/app/components/tools/mcp/mcp-service-card.tsx index 301bb66897..f2c9f1a78a 100644 --- a/web/app/components/tools/mcp/mcp-service-card.tsx +++ b/web/app/components/tools/mcp/mcp-service-card.tsx @@ -26,6 +26,8 @@ import { import { BlockEnum } from '@/app/components/workflow/types' import cn from '@/utils/classnames' import { fetchAppDetail } from '@/service/apps' +import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' +import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' export type IAppCardProps = { appInfo: AppDetailResponse & Partial @@ -90,6 +92,19 @@ function MCPServiceCard({ const onGenCode = async () => { await refreshMCPServerCode(detail?.id || '') invalidateMCPServerDetail(appId) + + // Emit collaboration event to notify other clients of MCP server changes + const socket = webSocketClient.getSocket(appId) + if (socket) { + socket.emit('collaboration_event', { + type: 'mcpServerUpdate', + data: { + action: 'codeRegenerated', + timestamp: Date.now(), + }, + timestamp: Date.now(), + }) + } } const onChangeStatus = async (state: boolean) => { @@ -119,6 +134,20 @@ function MCPServiceCard({ }) invalidateMCPServerDetail(appId) } + + // Emit collaboration event to notify other clients of MCP server status change + const socket = webSocketClient.getSocket(appId) + if (socket) { + socket.emit('collaboration_event', { + type: 'mcpServerUpdate', + data: { + action: 'statusChanged', + status: state ? 'active' : 'inactive', + timestamp: Date.now(), + }, + timestamp: Date.now(), + }) + } } const handleServerModalHide = () => { @@ -131,6 +160,23 @@ function MCPServiceCard({ setActivated(serverActivated) }, [serverActivated]) + // Listen for collaborative MCP server updates from other clients + useEffect(() => { + if (!appId) return + + const unsubscribe = collaborationManager.onMcpServerUpdate(async (update: any) => { + try { + console.log('Received MCP server update from collaboration:', update) + invalidateMCPServerDetail(appId) + } + catch (error) { + console.error('MCP server update failed:', error) + } + }) + + return unsubscribe + }, [appId, invalidateMCPServerDetail]) + if (!currentWorkflow && isAdvancedApp) return null diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index 98fb76c03b..7cdc0aaf17 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -250,6 +250,10 @@ export class CollaborationManager { return this.eventEmitter.on('appStateUpdate', callback) } + onMcpServerUpdate(callback: (update: any) => void): () => void { + return this.eventEmitter.on('mcpServerUpdate', callback) + } + onLeaderChange(callback: (isLeader: boolean) => void): () => void { return this.eventEmitter.on('leaderChange', callback) } @@ -583,6 +587,10 @@ export class CollaborationManager { console.log('Processing appStateUpdate event:', update) this.eventEmitter.emit('appStateUpdate', update) } + else if (update.type === 'mcpServerUpdate') { + console.log('Processing mcpServerUpdate event:', update) + this.eventEmitter.emit('mcpServerUpdate', update) + } else if (update.type === 'syncRequest') { console.log('Received sync request from another user') // Only process if we are the leader From ab438b42da398b8e4939ed7fbed688696069e7f6 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Wed, 10 Sep 2025 09:07:36 +0800 Subject: [PATCH 28/71] use new env variables update api --- .../console/app/workflow_draft_variable.py | 32 +++++- api/services/workflow_service.py | 22 ++++ .../workflow/panel/env-panel/index.tsx | 103 +++++++++++++----- web/service/workflow.ts | 16 +++ 4 files changed, 144 insertions(+), 29 deletions(-) diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index ba93f82756..ef31a5a9bb 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -17,8 +17,8 @@ from core.variables.segment_group import SegmentGroup from core.variables.segments import ArrayFileSegment, FileSegment, Segment from core.variables.types import SegmentType from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID +from factories import variable_factory from factories.file_factory import build_from_mapping, build_from_mappings -from factories.variable_factory import build_segment_with_type from libs.login import current_user, login_required from models import App, AppMode, db from models.workflow import WorkflowDraftVariable @@ -295,7 +295,7 @@ class VariableApi(Resource): if len(raw_value) > 0 and not isinstance(raw_value[0], dict): raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") raw_value = build_from_mappings(mappings=raw_value, tenant_id=app_model.tenant_id) - new_value = build_segment_with_type(variable.value_type, raw_value) + new_value = variable_factory.build_segment_with_type(variable.value_type, raw_value) draft_var_srv.update_variable(variable, name=new_name, value=new_value) db.session.commit() return variable @@ -411,6 +411,34 @@ class EnvironmentVariableCollectionApi(Resource): ) return {"items": env_vars_list} + + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + def post(self, app_model: App): + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: + raise Forbidden() + + parser = reqparse.RequestParser() + parser.add_argument("environment_variables", type=list, required=True, location="json") + args = parser.parse_args() + + workflow_service = WorkflowService() + + environment_variables_list = args.get("environment_variables") or [] + environment_variables = [ + variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list + ] + + workflow_service.update_draft_workflow_environment_variables( + app_model=app_model, + account=current_user, + environment_variables=environment_variables, + ) + + return { "result": "success" } api.add_resource( diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 677bc74237..499b86360b 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -244,6 +244,28 @@ class WorkflowService: # return draft workflow return workflow + + def update_draft_workflow_environment_variables( + self, *, + app_model: App, + environment_variables: Sequence[Variable], + account: Account, + ): + """ + Update draft workflow environment variables + """ + # fetch draft workflow by app_model + workflow = self.get_draft_workflow(app_model=app_model) + + if not workflow: + raise ValueError("No draft workflow found.") + + workflow.environment_variables = environment_variables + workflow.updated_by = account.id + workflow.updated_at = datetime.now(UTC).replace(tzinfo=None) + + # commit db session changes + db.session.commit() def publish_workflow( self, diff --git a/web/app/components/workflow/panel/env-panel/index.tsx b/web/app/components/workflow/panel/env-panel/index.tsx index 052a80bd59..2f11174ef0 100644 --- a/web/app/components/workflow/panel/env-panel/index.tsx +++ b/web/app/components/workflow/panel/env-panel/index.tsx @@ -17,9 +17,9 @@ import type { import { findUsedVarNodes, updateNodeVars } from '@/app/components/workflow/nodes/_base/components/variable/utils' import RemoveEffectVarConfirm from '@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm' import cn from '@/utils/classnames' -import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { useStore as useWorkflowStore } from '@/app/components/workflow/store' +import { updateEnvironmentVariables } from '@/service/workflow' const EnvPanel = () => { const { t } = useTranslation() @@ -29,7 +29,6 @@ const EnvPanel = () => { const envSecrets = useStore(s => s.envSecrets) const updateEnvList = useStore(s => s.setEnvironmentVariables) const setEnvSecrets = useStore(s => s.setEnvSecrets) - const { doSyncWorkflowDraft } = useNodesSyncDraft() const appId = useWorkflowStore(s => s.appId) const [showVariableModal, setShowVariableModal] = useState(false) @@ -70,18 +69,31 @@ const EnvPanel = () => { 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) - await doSyncWorkflowDraft() - // Emit update event to other connected clients - const socket = webSocketClient.getSocket(appId) - if (socket?.connected) { - socket.emit('collaboration_event', { - type: 'varsAndFeaturesUpdate', - timestamp: Date.now(), + // 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: 'varsAndFeaturesUpdate', + 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') { @@ -89,7 +101,7 @@ const EnvPanel = () => { delete newMap[env.id] setEnvSecrets(newMap) } - }, [doSyncWorkflowDraft, envList, envSecrets, removeUsedVarInNodes, setEnvSecrets, updateEnvList, appId]) + }, [envList, envSecrets, removeUsedVarInNodes, setEnvSecrets, updateEnvList, appId]) const deleteCheck = useCallback((env: EnvironmentVariable) => { const effectedNodes = getEffectedNodes(env) @@ -105,26 +117,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() - const socket = webSocketClient.getSocket(appId) - if (socket) { - socket.emit('collaboration_event', { - type: 'varsAndFeaturesUpdate', + + // Use new dedicated environment variables API + try { + await updateEnvironmentVariables({ + appId, + environmentVariables: newList, }) + + const socket = webSocketClient.getSocket(appId) + if (socket) { + socket.emit('collaboration_event', { + type: 'varsAndFeaturesUpdate', + }) + } + + // 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) } - updateEnvList(newList.map(e => (e.id === env.id && env.value_type === 'secret') ? { ...e, value: '[__HIDDEN__]' } : e)) 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 @@ -147,8 +179,10 @@ 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() @@ -161,15 +195,30 @@ const EnvPanel = () => { }) setNodes(newNodes) } - await doSyncWorkflowDraft() - const socket = webSocketClient.getSocket(appId) - if (socket) { - socket.emit('collaboration_event', { - type: 'varsAndFeaturesUpdate', + + // Use new dedicated environment variables API + try { + await updateEnvironmentVariables({ + appId, + environmentVariables: newList, }) + + const socket = webSocketClient.getSocket(appId) + if (socket) { + socket.emit('collaboration_event', { + type: 'varsAndFeaturesUpdate', + }) + } + + // Hide secret values in UI + updateEnvList(newList.map(e => (e.id === env.id && env.value_type === 'secret') ? { ...e, value: '[__HIDDEN__]' } : e)) } - updateEnvList(newList.map(e => (e.id === env.id && env.value_type === 'secret') ? { ...e, value: '[__HIDDEN__]' } : e)) - }, [currentVar, doSyncWorkflowDraft, envList, envSecrets, getEffectedNodes, setEnvSecrets, store, updateEnvList, appId]) + catch (error) { + console.error('Failed to update environment variables:', error) + // Revert local state on error + updateEnvList(envList) + } + }, [currentVar, envList, envSecrets, getEffectedNodes, setEnvSecrets, store, updateEnvList, appId]) return (
{ return get(url, {}, { silent: true }) as Promise @@ -99,3 +100,18 @@ export const fetchNodeInspectVars = async (appId: string, nodeId: string): Promi const { items } = (await get(`apps/${appId}/workflows/draft/nodes/${nodeId}/variables`)) as { items: VarInInspect[] } return items } + +// Environment Variables API +export const fetchEnvironmentVariables = async (appId: string): Promise => { + const { items } = (await get(`apps/${appId}/workflows/draft/environment-variables`)) as { items: EnvironmentVariable[] } + return items +} + +export const updateEnvironmentVariables = ({ appId, environmentVariables }: { + appId: string + environmentVariables: EnvironmentVariable[] +}) => { + return post(`apps/${appId}/workflows/draft/environment-variables`, { + body: { environment_variables: environmentVariables }, + }) +} From d5fc3e7bed4f3d8853cc9e4f18347842aa77371b Mon Sep 17 00:00:00 2001 From: hjlarry Date: Wed, 10 Sep 2025 09:24:22 +0800 Subject: [PATCH 29/71] add new conversation vars update api --- .../console/app/workflow_draft_variable.py | 28 +++++ api/services/workflow_service.py | 22 ++++ .../panel/chat-variable-panel/index.tsx | 111 +++++++++++++----- web/service/workflow.ts | 11 +- 4 files changed, 142 insertions(+), 30 deletions(-) diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index ef31a5a9bb..961c1bfcc7 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -370,6 +370,34 @@ class ConversationVariableCollectionApi(Resource): draft_var_srv.prefill_conversation_variable_default_values(draft_workflow) db.session.commit() return _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID) + + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=AppMode.ADVANCED_CHAT) + def post(self, app_model: App): + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: + raise Forbidden() + + parser = reqparse.RequestParser() + parser.add_argument("conversation_variables", type=list, required=True, location="json") + args = parser.parse_args() + + workflow_service = WorkflowService() + + conversation_variables_list = args.get("conversation_variables") or [] + conversation_variables = [ + variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list + ] + + workflow_service.update_draft_workflow_conversation_variables( + app_model=app_model, + account=current_user, + conversation_variables=conversation_variables, + ) + + return { "result": "success" } class SystemVariableCollectionApi(Resource): diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 499b86360b..058e949a5a 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -267,6 +267,28 @@ class WorkflowService: # commit db session changes db.session.commit() + def update_draft_workflow_conversation_variables( + self, *, + app_model: App, + conversation_variables: Sequence[Variable], + account: Account, + ): + """ + Update draft workflow conversation variables + """ + # fetch draft workflow by app_model + workflow = self.get_draft_workflow(app_model=app_model) + + if not workflow: + raise ValueError("No draft workflow found.") + + workflow.conversation_variables = conversation_variables + workflow.updated_by = account.id + workflow.updated_at = datetime.now(UTC).replace(tzinfo=None) + + # commit db session changes + db.session.commit() + def publish_workflow( self, *, diff --git a/web/app/components/workflow/panel/chat-variable-panel/index.tsx b/web/app/components/workflow/panel/chat-variable-panel/index.tsx index e87db1eafe..090dd077fd 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/index.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/index.tsx @@ -19,12 +19,12 @@ import type { ConversationVariable, } from '@/app/components/workflow/types' import { findUsedVarNodes, updateNodeVars } from '@/app/components/workflow/nodes/_base/components/variable/utils' -import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft' import { BlockEnum } from '@/app/components/workflow/types' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { useDocLink } from '@/context/i18n' import cn from '@/utils/classnames' import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' +import { updateConversationVariables } from '@/service/workflow' const ChatVariablePanel = () => { const { t } = useTranslation() @@ -34,25 +34,9 @@ const ChatVariablePanel = () => { const varList = useStore(s => s.conversationVariables) as ConversationVariable[] const updateChatVarList = useStore(s => s.setConversationVariables) const appId = useStore(s => s.appId) - const { doSyncWorkflowDraft } = useNodesSyncDraft() const { invalidateConversationVarValues, } = useInspectVarsCrud() - const handleVarChanged = useCallback(() => { - doSyncWorkflowDraft(false, { - onSuccess() { - invalidateConversationVarValues() - if (appId) { - const socket = webSocketClient.getSocket(appId) - if (socket) { - socket.emit('collaboration_event', { - type: 'varsAndFeaturesUpdate', - }) - } - } - }, - }) - }, [doSyncWorkflowDraft, invalidateConversationVarValues, appId]) const [showTip, setShowTip] = useState(true) const [showVariableModal, setShowVariableModal] = useState(false) @@ -87,13 +71,36 @@ const ChatVariablePanel = () => { 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: 'varsAndFeaturesUpdate', + }) + } + + 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) @@ -107,17 +114,42 @@ 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: 'varsAndFeaturesUpdate', + }) + } + + 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 effectedNodes = getEffectedNodes(currentVar) @@ -129,8 +161,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: 'varsAndFeaturesUpdate', + }) + } + + invalidateConversationVarValues() + } + catch (error) { + console.error('Failed to update conversation variables:', error) + // Revert local state on error + updateChatVarList(varList) + } + }, [currentVar, getEffectedNodes, store, updateChatVarList, varList, appId, invalidateConversationVarValues]) return (
{ return get(url, {}, { silent: true }) as Promise @@ -115,3 +115,12 @@ export const updateEnvironmentVariables = ({ appId, environmentVariables }: { body: { environment_variables: environmentVariables }, }) } + +export const updateConversationVariables = ({ appId, conversationVariables }: { + appId: string + conversationVariables: ConversationVariable[] +}) => { + return post(`apps/${appId}/workflows/draft/conversation-variables`, { + body: { conversation_variables: conversationVariables }, + }) +} From 957a8253f8b4159486e2302e49eda01342ef059f Mon Sep 17 00:00:00 2001 From: hjlarry Date: Wed, 10 Sep 2025 09:26:38 +0800 Subject: [PATCH 30/71] change user list to conversation var panel left --- web/app/components/workflow/header/header-in-normal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/workflow/header/header-in-normal.tsx b/web/app/components/workflow/header/header-in-normal.tsx index bfce06aca9..fc4e2c44e1 100644 --- a/web/app/components/workflow/header/header-in-normal.tsx +++ b/web/app/components/workflow/header/header-in-normal.tsx @@ -60,8 +60,8 @@ const HeaderInNormal = ({
- {components?.left} + {components?.left} From 21fee59b22462e6b051088b222c1d7abbd5700a1 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Wed, 10 Sep 2025 14:24:38 +0800 Subject: [PATCH 31/71] use new features update api --- api/controllers/console/app/workflow.py | 25 +++++++++++ api/models/workflow.py | 2 +- api/services/workflow_service.py | 25 +++++++++++ web/app/components/workflow/features.tsx | 57 +++++++++++++++++------- web/service/workflow.ts | 10 +++++ 5 files changed, 102 insertions(+), 17 deletions(-) diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index e24deeb14e..f67c629d48 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -628,6 +628,27 @@ class WorkflowConfigApi(Resource): } +class WorkflowFeaturesApi(Resource): + """Update draft workflow features.""" + + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + def post(self, app_model: App): + parser = reqparse.RequestParser() + parser.add_argument("features", type=dict, required=True, location="json") + args = parser.parse_args() + + features = args.get("features") + + # Update draft workflow features + workflow_service = WorkflowService() + workflow_service.update_draft_workflow_features(app_model=app_model, features=features, account=current_user) + + return {"result": "success"} + + class PublishedAllWorkflowApi(Resource): @setup_required @login_required @@ -826,6 +847,10 @@ api.add_resource( WorkflowConfigApi, "/apps//workflows/draft/config", ) +api.add_resource( + WorkflowFeaturesApi, + "/apps//workflows/draft/features", +) api.add_resource( AdvancedChatDraftWorkflowRunApi, "/apps//advanced-chat/workflows/draft/run", diff --git a/api/models/workflow.py b/api/models/workflow.py index 9930859201..a828a05845 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -325,7 +325,7 @@ class Workflow(Base): :return: hash """ - entity = {"graph": self.graph_dict, "features": self.features_dict} + entity = {"graph": self.graph_dict} return helper.generate_text_hash(json.dumps(entity, sort_keys=True)) diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 058e949a5a..e40fd68429 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -289,6 +289,31 @@ class WorkflowService: # commit db session changes db.session.commit() + def update_draft_workflow_features( + self, *, + app_model: App, + features: dict, + account: Account, + ): + """ + Update draft workflow features + """ + # fetch draft workflow by app_model + workflow = self.get_draft_workflow(app_model=app_model) + + if not workflow: + raise ValueError("No draft workflow found.") + + # validate features structure + self.validate_features_structure(app_model=app_model, features=features) + + workflow.features = json.dumps(features) + workflow.updated_by = account.id + workflow.updated_at = datetime.now(UTC).replace(tzinfo=None) + + # commit db session changes + db.session.commit() + def publish_workflow( self, *, diff --git a/web/app/components/workflow/features.tsx b/web/app/components/workflow/features.tsx index f712fb9887..dcf2c93d2d 100644 --- a/web/app/components/workflow/features.tsx +++ b/web/app/components/workflow/features.tsx @@ -7,7 +7,6 @@ import { useStore } from './store' import { useIsChatMode, useNodesReadOnly, - useNodesSyncDraft, } from './hooks' import { type CommonNodeType, type InputVar, InputVarType, type Node } from './types' import useConfig from './nodes/start/use-config' @@ -15,13 +14,15 @@ import type { StartNodeType } from './nodes/start/types' import type { PromptVariable } from '@/models/debug' import NewFeaturePanel from '@/app/components/base/features/new-feature-panel' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' +import { useFeaturesStore } from '@/app/components/base/features/hooks' +import { updateFeatures } from '@/service/workflow' const Features = () => { const setShowFeaturesPanel = useStore(s => s.setShowFeaturesPanel) const appId = useStore(s => s.appId) const isChatMode = useIsChatMode() const { nodesReadOnly } = useNodesReadOnly() - const { doSyncWorkflowDraft } = useNodesSyncDraft() + const featuresStore = useFeaturesStore() const nodes = useNodes() const startNode = nodes.find(node => node.data.type === 'start') const { id, data } = startNode as Node @@ -40,21 +41,45 @@ const Features = () => { handleAddVariable(startNodeVariable) } - const handleFeaturesChange = useCallback(() => { - doSyncWorkflowDraft(false, { - onSuccess() { - if (appId) { - const socket = webSocketClient.getSocket(appId) - if (socket) { - socket.emit('collaboration_event', { - type: 'varsAndFeaturesUpdate', - }) - } - } - }, - }) + 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 = { + opening_statement: currentFeatures.opening?.enabled ? (currentFeatures.opening?.opening_statement || '') : '', + suggested_questions: currentFeatures.opening?.enabled ? (currentFeatures.opening?.suggested_questions || []) : [], + suggested_questions_after_answer: currentFeatures.suggested, + text_to_speech: currentFeatures.text2speech, + speech_to_text: currentFeatures.speech2text, + retriever_resource: currentFeatures.citation, + sensitive_word_avoidance: currentFeatures.moderation, + file_upload: currentFeatures.file, + } + + console.log('Sending features to server:', transformedFeatures) + + await updateFeatures({ + appId, + features: transformedFeatures, + }) + + // Emit update event to other connected clients + const socket = webSocketClient.getSocket(appId) + if (socket) { + socket.emit('collaboration_event', { + type: 'varsAndFeaturesUpdate', + }) + } + } + catch (error) { + console.error('Failed to update features:', error) + } + setShowFeaturesPanel(true) - }, [doSyncWorkflowDraft, setShowFeaturesPanel, appId]) + }, [appId, featuresStore, setShowFeaturesPanel]) return ( { return get(url, {}, { silent: true }) as Promise @@ -124,3 +125,12 @@ export const updateConversationVariables = ({ appId, conversationVariables }: { body: { conversation_variables: conversationVariables }, }) } + +export const updateFeatures = ({ appId, features }: { + appId: string + features: Features +}) => { + return post(`apps/${appId}/workflows/draft/features`, { + body: { features }, + }) +} From c8acc489766c4392e9d86e7f1d541b98c14288fd Mon Sep 17 00:00:00 2001 From: hjlarry Date: Wed, 10 Sep 2025 14:25:37 +0800 Subject: [PATCH 32/71] ruff format --- api/controllers/console/app/online_user.py | 20 +++--- .../console/app/workflow_comment.py | 13 ++-- .../console/app/workflow_draft_variable.py | 12 ++-- api/fields/workflow_comment_fields.py | 2 +- api/services/workflow_comment_service.py | 63 +++++++------------ api/services/workflow_service.py | 13 ++-- 6 files changed, 52 insertions(+), 71 deletions(-) diff --git a/api/controllers/console/app/online_user.py b/api/controllers/console/app/online_user.py index c9c266692c..f69ea32752 100644 --- a/api/controllers/console/app/online_user.py +++ b/api/controllers/console/app/online_user.py @@ -88,7 +88,7 @@ def handle_disconnect(sid): if mapping: data = json.loads(mapping) workflow_id = data["workflow_id"] - + # Remove this specific session redis_client.hdel(f"workflow_online_users:{workflow_id}", sid) redis_client.delete(f"ws_sid_map:{sid}") @@ -177,18 +177,20 @@ def broadcast_online_users(workflow_id): """ sessions_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") users = [] - + for sid, session_info_json in sessions_json.items(): try: session_info = json.loads(session_info_json) # Each session appears as a separate "user" in the UI - users.append({ - "user_id": session_info["user_id"], - "username": session_info["username"], - "avatar": session_info.get("avatar"), - "sid": session_info["sid"], - "connected_at": session_info.get("connected_at"), - }) + users.append( + { + "user_id": session_info["user_id"], + "username": session_info["username"], + "avatar": session_info.get("avatar"), + "sid": session_info["sid"], + "connected_at": session_info.get("connected_at"), + } + ) except Exception: continue diff --git a/api/controllers/console/app/workflow_comment.py b/api/controllers/console/app/workflow_comment.py index 2cd76e6bfa..f101824b18 100644 --- a/api/controllers/console/app/workflow_comment.py +++ b/api/controllers/console/app/workflow_comment.py @@ -164,10 +164,10 @@ class WorkflowCommentReplyApi(Resource): args = parser.parse_args() result = WorkflowCommentService.create_reply( - comment_id=comment_id, - content=args.content, + comment_id=comment_id, + content=args.content, created_by=current_user.id, - mentioned_user_ids=args.mentioned_user_ids + mentioned_user_ids=args.mentioned_user_ids, ) return result, 201 @@ -194,10 +194,7 @@ class WorkflowCommentReplyDetailApi(Resource): args = parser.parse_args() reply = WorkflowCommentService.update_reply( - reply_id=reply_id, - user_id=current_user.id, - content=args.content, - mentioned_user_ids=args.mentioned_user_ids + reply_id=reply_id, user_id=current_user.id, content=args.content, mentioned_user_ids=args.mentioned_user_ids ) return reply @@ -227,7 +224,7 @@ class WorkflowCommentMentionUsersApi(Resource): @get_app_model @marshal_with({"users": fields.List(fields.Nested(account_with_role_fields))}) def get(self, app_model: App): - """Get all users in current tenant for mentions.""" + """Get all users in current tenant for mentions.""" members = TenantService.get_tenant_members(current_user.current_tenant) return {"users": members} diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index 961c1bfcc7..6e769eca98 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -370,7 +370,7 @@ class ConversationVariableCollectionApi(Resource): draft_var_srv.prefill_conversation_variable_default_values(draft_workflow) db.session.commit() return _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID) - + @setup_required @login_required @account_initialization_required @@ -379,7 +379,7 @@ class ConversationVariableCollectionApi(Resource): # The role of the current user in the ta table must be admin, owner, or editor if not current_user.is_editor: raise Forbidden() - + parser = reqparse.RequestParser() parser.add_argument("conversation_variables", type=list, required=True, location="json") args = parser.parse_args() @@ -397,7 +397,7 @@ class ConversationVariableCollectionApi(Resource): conversation_variables=conversation_variables, ) - return { "result": "success" } + return {"result": "success"} class SystemVariableCollectionApi(Resource): @@ -439,7 +439,7 @@ class EnvironmentVariableCollectionApi(Resource): ) return {"items": env_vars_list} - + @setup_required @login_required @account_initialization_required @@ -448,7 +448,7 @@ class EnvironmentVariableCollectionApi(Resource): # The role of the current user in the ta table must be admin, owner, or editor if not current_user.is_editor: raise Forbidden() - + parser = reqparse.RequestParser() parser.add_argument("environment_variables", type=list, required=True, location="json") args = parser.parse_args() @@ -466,7 +466,7 @@ class EnvironmentVariableCollectionApi(Resource): environment_variables=environment_variables, ) - return { "result": "success" } + return {"result": "success"} api.add_resource( diff --git a/api/fields/workflow_comment_fields.py b/api/fields/workflow_comment_fields.py index 5f7bd6851a..1506147312 100644 --- a/api/fields/workflow_comment_fields.py +++ b/api/fields/workflow_comment_fields.py @@ -9,7 +9,7 @@ comment_account_fields = {"id": fields.String, "name": fields.String, "email": f workflow_comment_mention_fields = { "mentioned_user_id": fields.String, "mentioned_user_account": fields.Nested(comment_account_fields, allow_null=True), - "reply_id": fields.String + "reply_id": fields.String, } # Comment reply fields diff --git a/api/services/workflow_comment_service.py b/api/services/workflow_comment_service.py index f85420e258..4b5fbf7a05 100644 --- a/api/services/workflow_comment_service.py +++ b/api/services/workflow_comment_service.py @@ -42,6 +42,7 @@ class WorkflowCommentService: @staticmethod def get_comment(tenant_id: str, app_id: str, comment_id: str, session: Session = None) -> WorkflowComment: """Get a specific comment.""" + def _get_comment(session: Session) -> WorkflowComment: stmt = ( select(WorkflowComment) @@ -96,9 +97,9 @@ class WorkflowCommentService: for user_id in mentioned_user_ids: if isinstance(user_id, str) and uuid_value(user_id): mention = WorkflowCommentMention( - comment_id=comment.id, + comment_id=comment.id, reply_id=None, # This is a comment mention, not reply mention - mentioned_user_id=user_id + mentioned_user_id=user_id, ) session.add(mention) @@ -123,16 +124,13 @@ class WorkflowCommentService: with Session(db.engine, expire_on_commit=False) as session: # Get comment with validation - stmt = ( - select(WorkflowComment) - .where( - WorkflowComment.id == comment_id, - WorkflowComment.tenant_id == tenant_id, - WorkflowComment.app_id == app_id, - ) + stmt = select(WorkflowComment).where( + WorkflowComment.id == comment_id, + WorkflowComment.tenant_id == tenant_id, + WorkflowComment.app_id == app_id, ) comment = session.scalar(stmt) - + if not comment: raise NotFound("Comment not found") @@ -151,7 +149,7 @@ class WorkflowCommentService: existing_mentions = session.scalars( select(WorkflowCommentMention).where( WorkflowCommentMention.comment_id == comment.id, - WorkflowCommentMention.reply_id.is_(None) # Only comment mentions, not reply mentions + WorkflowCommentMention.reply_id.is_(None), # Only comment mentions, not reply mentions ) ).all() for mention in existing_mentions: @@ -162,18 +160,15 @@ class WorkflowCommentService: for user_id_str in mentioned_user_ids: if isinstance(user_id_str, str) and uuid_value(user_id_str): mention = WorkflowCommentMention( - comment_id=comment.id, + comment_id=comment.id, reply_id=None, # This is a comment mention - mentioned_user_id=user_id_str + mentioned_user_id=user_id_str, ) session.add(mention) session.commit() - - return { - "id": comment.id, - "updated_at": comment.updated_at - } + + return {"id": comment.id, "updated_at": comment.updated_at} @staticmethod def delete_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> None: @@ -219,10 +214,7 @@ class WorkflowCommentService: @staticmethod def create_reply( - comment_id: str, - content: str, - created_by: str, - mentioned_user_ids: Optional[list[str]] = None + comment_id: str, content: str, created_by: str, mentioned_user_ids: Optional[list[str]] = None ) -> dict: """Add a reply to a workflow comment.""" WorkflowCommentService._validate_content(content) @@ -244,29 +236,21 @@ class WorkflowCommentService: if isinstance(user_id, str) and uuid_value(user_id): # Create mention linking to specific reply mention = WorkflowCommentMention( - comment_id=comment_id, - reply_id=reply.id, - mentioned_user_id=user_id + comment_id=comment_id, reply_id=reply.id, mentioned_user_id=user_id ) session.add(mention) session.commit() - - return { - "id": reply.id, - "created_at": reply.created_at - } + + return {"id": reply.id, "created_at": reply.created_at} @staticmethod def update_reply( - reply_id: str, - user_id: str, - content: str, - mentioned_user_ids: Optional[list[str]] = None + reply_id: str, user_id: str, content: str, mentioned_user_ids: Optional[list[str]] = None ) -> WorkflowCommentReply: """Update a comment reply.""" WorkflowCommentService._validate_content(content) - + with Session(db.engine, expire_on_commit=False) as session: reply = session.get(WorkflowCommentReply, reply_id) if not reply: @@ -290,19 +274,14 @@ class WorkflowCommentService: for user_id_str in mentioned_user_ids: if isinstance(user_id_str, str) and uuid_value(user_id_str): mention = WorkflowCommentMention( - comment_id=reply.comment_id, - reply_id=reply.id, - mentioned_user_id=user_id_str + comment_id=reply.comment_id, reply_id=reply.id, mentioned_user_id=user_id_str ) session.add(mention) session.commit() session.refresh(reply) # Refresh to get updated timestamp - return { - "id": reply.id, - "updated_at": reply.updated_at - } + return {"id": reply.id, "updated_at": reply.updated_at} @staticmethod def delete_reply(reply_id: str, user_id: str) -> None: diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index e40fd68429..5a842a6612 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -244,9 +244,10 @@ class WorkflowService: # return draft workflow return workflow - + def update_draft_workflow_environment_variables( - self, *, + self, + *, app_model: App, environment_variables: Sequence[Variable], account: Account, @@ -268,7 +269,8 @@ class WorkflowService: db.session.commit() def update_draft_workflow_conversation_variables( - self, *, + self, + *, app_model: App, conversation_variables: Sequence[Variable], account: Account, @@ -290,7 +292,8 @@ class WorkflowService: db.session.commit() def update_draft_workflow_features( - self, *, + self, + *, app_model: App, features: dict, account: Account, @@ -303,7 +306,7 @@ class WorkflowService: if not workflow: raise ValueError("No draft workflow found.") - + # validate features structure self.validate_features_structure(app_model=app_model, features=features) From 89bedae0d3ea940b59784000834b796950d9dee8 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Wed, 10 Sep 2025 14:27:20 +0800 Subject: [PATCH 33/71] remove the test code for develop collaboration --- .../workflow-app/hooks/use-workflow-refresh-draft.ts | 4 ++-- .../workflow/store/workflow/workflow-draft-slice.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts b/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts index 6580763e8e..c944e10c4c 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import { useWorkflowStore } from '@/app/components/workflow/store' import { fetchWorkflowDraft } from '@/service/workflow' +import type { WorkflowDataUpdater } from '@/app/components/workflow/types' import { useWorkflowUpdate } from '@/app/components/workflow/hooks' export const useWorkflowRefreshDraft = () => { @@ -18,8 +19,7 @@ export const useWorkflowRefreshDraft = () => { } = workflowStore.getState() setIsSyncingWorkflowDraft(true) fetchWorkflowDraft(`/apps/${appId}/workflows/draft`).then((response) => { - // TODO: hjlarry test collaboration - // handleUpdateWorkflowCanvas(response.graph as WorkflowDataUpdater) + handleUpdateWorkflowCanvas(response.graph as WorkflowDataUpdater) setSyncWorkflowDraftHash(response.hash) setEnvSecrets((response.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => { acc[env.id] = env.value diff --git a/web/app/components/workflow/store/workflow/workflow-draft-slice.ts b/web/app/components/workflow/store/workflow/workflow-draft-slice.ts index 4a6c7b433e..ec28debee2 100644 --- a/web/app/components/workflow/store/workflow/workflow-draft-slice.ts +++ b/web/app/components/workflow/store/workflow/workflow-draft-slice.ts @@ -26,10 +26,9 @@ export type WorkflowDraftSliceShape = { export const createWorkflowDraftSlice: StateCreator = set => ({ backupDraft: undefined, setBackupDraft: backupDraft => set(() => ({ backupDraft })), - // TODO: hjlarry test collaboration debouncedSyncWorkflowDraft: debounce((syncWorkflowDraft) => { syncWorkflowDraft() - }, 500000), + }, 5000), syncWorkflowDraftHash: '', setSyncWorkflowDraftHash: syncWorkflowDraftHash => set(() => ({ syncWorkflowDraftHash })), isSyncingWorkflowDraft: false, From f091868b7c83f1ceb7b845d67ff3f3c6773b7a92 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Wed, 10 Sep 2025 15:15:31 +0800 Subject: [PATCH 34/71] use new get avatar api --- api/controllers/console/workspace/account.py | 12 +++++ .../workflow/header/online-users.tsx | 44 +++++++++++++++++-- web/service/common.ts | 4 ++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index 1f22e3fd01..17f917793b 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -33,6 +33,7 @@ from controllers.console.wraps import ( only_edition_cloud, setup_required, ) +from core.file import helpers as file_helpers from extensions.ext_database import db from fields.member_fields import account_fields from libs.helper import TimestampField, email, extract_remote_ip, timezone @@ -124,6 +125,17 @@ class AccountNameApi(Resource): class AccountAvatarApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self): + parser = reqparse.RequestParser() + parser.add_argument("avatar", type=str, required=True, location="args") + args = parser.parse_args() + + avatar_url = file_helpers.get_signed_file_url(args["avatar"]) + return {"avatar_url": avatar_url} + @setup_required @login_required @account_initialization_required diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx index 36556b2242..d75bc4887f 100644 --- a/web/app/components/workflow/header/online-users.tsx +++ b/web/app/components/workflow/header/online-users.tsx @@ -1,5 +1,5 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import Avatar from '@/app/components/base/avatar' import { useCollaboration } from '../collaboration/hooks/use-collaboration' import { useStore } from '../store' @@ -13,12 +13,46 @@ import { PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' import { useAppContext } from '@/context/app-context' +import { getAvatar } from '@/service/common' + +const useAvatarUrls = (users: any[]) => { + const [avatarUrls, setAvatarUrls] = useState>({}) + + useEffect(() => { + const fetchAvatars = async () => { + const newAvatarUrls: Record = {} + + 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 } = useCollaboration(appId) const { userProfile } = useAppContext() const [dropdownOpen, setDropdownOpen] = useState(false) + const avatarUrls = useAvatarUrls(onlineUsers || []) const currentUserId = userProfile?.id @@ -33,6 +67,10 @@ const OnlineUsers = () => { const visibleUsers = onlineUsers.slice(0, maxVisible) const remainingCount = onlineUsers.length - maxVisible + const getAvatarUrl = (user: any) => { + return avatarUrls[user.sid] || user.avatar + } + return (
@@ -59,7 +97,7 @@ const OnlineUsers = () => { > { > diff --git a/web/service/common.ts b/web/service/common.ts index 99eb58e2a0..90ec3d99b5 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -397,3 +397,7 @@ export const resetEmail = (body: { new_email: string; token: string }) => export const checkEmailExisted = (body: { email: string }) => post('/account/change-email/check-email-unique', { body }, { silent: true }) + +export const getAvatar: Fetcher<{ avatar_url: string }, { avatar: string }> = ({ avatar }) => { + return get<{ avatar_url: string }>(`/account/avatar?avatar=${avatar}`) +} From 53ba6aadff6bee969e0316b764d784122930ff01 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 11 Sep 2025 09:07:03 +0800 Subject: [PATCH 35/71] cursor pos transform to canvas --- .../workflow-app/components/workflow-main.tsx | 7 +-- .../collaboration/components/user-cursors.tsx | 15 +++++- .../collaboration/hooks/use-collaboration.ts | 5 +- .../collaboration/services/cursor-service.ts | 49 ++++++++++++------- 4 files changed, 51 insertions(+), 25 deletions(-) diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index 6bc2fbcc31..067e9ccf93 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -27,7 +27,7 @@ import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { useCollaboration } from '@/app/components/workflow/collaboration' import { collaborationManager } from '@/app/components/workflow/collaboration' import { fetchWorkflowDraft } from '@/service/workflow' -import { useStoreApi } from 'reactflow' +import { useReactFlow, useStoreApi } from 'reactflow' type WorkflowMainProps = Pick const WorkflowMain = ({ @@ -39,6 +39,7 @@ const WorkflowMain = ({ const workflowStore = useWorkflowStore() const appId = useStore(s => s.appId) const containerRef = useRef(null) + const reactFlow = useReactFlow() const store = useStoreApi() const { startCursorTracking, stopCursorTracking, onlineUsers, cursors, isConnected } = useCollaboration(appId, store) @@ -55,12 +56,12 @@ const WorkflowMain = ({ useEffect(() => { if (containerRef.current) - startCursorTracking(containerRef as React.RefObject) + startCursorTracking(containerRef as React.RefObject, reactFlow) return () => { stopCursorTracking() } - }, [startCursorTracking, stopCursorTracking]) + }, [startCursorTracking, stopCursorTracking, reactFlow]) const handleWorkflowDataUpdate = useCallback((payload: any) => { const { diff --git a/web/app/components/workflow/collaboration/components/user-cursors.tsx b/web/app/components/workflow/collaboration/components/user-cursors.tsx index e5ec93cf6b..bcc94960e3 100644 --- a/web/app/components/workflow/collaboration/components/user-cursors.tsx +++ b/web/app/components/workflow/collaboration/components/user-cursors.tsx @@ -1,4 +1,5 @@ import type { FC } from 'react' +import { useViewport } from 'reactflow' import type { CursorPosition, OnlineUser } from '@/app/components/workflow/collaboration/types' import { getUserColor } from '../utils/user-color' @@ -13,6 +14,15 @@ const UserCursors: FC = ({ 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]) => { @@ -22,14 +32,15 @@ const UserCursors: FC = ({ 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 (
) => { + const startCursorTracking = (containerRef: React.RefObject, reactFlowInstance?: ReactFlowInstance) => { if (cursorServiceRef.current) { cursorServiceRef.current.startTracking(containerRef, (position) => { collaborationManager.emitCursorMove(position) - }) + }, reactFlowInstance) } } diff --git a/web/app/components/workflow/collaboration/services/cursor-service.ts b/web/app/components/workflow/collaboration/services/cursor-service.ts index f33d595a4a..2a86e37697 100644 --- a/web/app/components/workflow/collaboration/services/cursor-service.ts +++ b/web/app/components/workflow/collaboration/services/cursor-service.ts @@ -1,5 +1,6 @@ import type { RefObject } from 'react' import type { CursorPosition } from '../types/collaboration' +import type { ReactFlowInstance } from 'reactflow' export type CursorServiceConfig = { minMoveDistance?: number @@ -8,6 +9,7 @@ export type CursorServiceConfig = { export class CursorService { private containerRef: RefObject | null = null + private reactFlowInstance: ReactFlowInstance | null = null private isTracking = false private onCursorUpdate: ((cursors: Record) => void) | null = null private onEmitPosition: ((position: CursorPosition) => void) | null = null @@ -25,11 +27,13 @@ export class CursorService { startTracking( containerRef: RefObject, 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) @@ -41,6 +45,7 @@ export class CursorService { this.containerRef.current.removeEventListener('mousemove', this.handleMouseMove) this.containerRef = null + this.reactFlowInstance = null this.onEmitPosition = null this.isTracking = false this.lastPosition = null @@ -59,26 +64,34 @@ export class CursorService { if (!this.containerRef?.current || !this.onEmitPosition) return const rect = this.containerRef.current.getBoundingClientRect() - const x = event.clientX - rect.left - const y = event.clientY - rect.top + let x = event.clientX - rect.left + let y = event.clientY - rect.top - if (x >= 0 && y >= 0 && x <= rect.width && y <= rect.height) { - const now = Date.now() - const timeThrottled = now - this.lastEmitTime > this.config.throttleMs - const distanceThrottled = !this.lastPosition - || (Math.abs(x - this.lastPosition.x) > this.config.minMoveDistance - || Math.abs(y - this.lastPosition.y) > this.config.minMoveDistance) + // 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 + } - if (timeThrottled && distanceThrottled) { - this.lastPosition = { x, y } - this.lastEmitTime = now - this.onEmitPosition({ - x, - y, - userId: '', - timestamp: now, - }) - } + // Always emit cursor position (remove boundary check since world coordinates can be negative) + const now = Date.now() + const timeThrottled = now - this.lastEmitTime > this.config.throttleMs + const distanceThrottled = !this.lastPosition + || (Math.abs(x - this.lastPosition.x) > this.config.minMoveDistance / (this.reactFlowInstance?.getZoom() || 1)) + || (Math.abs(y - this.lastPosition.y) > this.config.minMoveDistance / (this.reactFlowInstance?.getZoom() || 1)) + + if (timeThrottled && distanceThrottled) { + this.lastPosition = { x, y } + this.lastEmitTime = now + this.onEmitPosition({ + x, + y, + userId: '', + timestamp: now, + }) } } } From 2035186cd290c40de7187c7095b5c7e1c9badd1f Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 11 Sep 2025 09:26:05 +0800 Subject: [PATCH 36/71] click avatar to follow user cursor position --- .../workflow/header/online-users.tsx | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx index d75bc4887f..a09dfaa979 100644 --- a/web/app/components/workflow/header/online-users.tsx +++ b/web/app/components/workflow/header/online-users.tsx @@ -1,5 +1,6 @@ 'use client' import { useEffect, useState } from 'react' +import { useReactFlow } from 'reactflow' import Avatar from '@/app/components/base/avatar' import { useCollaboration } from '../collaboration/hooks/use-collaboration' import { useStore } from '../store' @@ -49,13 +50,23 @@ const useAvatarUrls = (users: any[]) => { const OnlineUsers = () => { const appId = useStore(s => s.appId) - const { onlineUsers } = useCollaboration(appId) + const { onlineUsers, cursors } = useCollaboration(appId) const { userProfile } = useAppContext() + const reactFlow = useReactFlow() const [dropdownOpen, setDropdownOpen] = useState(false) const avatarUrls = useAvatarUrls(onlineUsers || []) const currentUserId = userProfile?.id + // 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 (!onlineUsers || onlineUsers.length === 0) return null @@ -92,8 +103,12 @@ const OnlineUsers = () => { asChild >
!isCurrentUser && jumpToUserCursor(user.user_id)} > { return (
!isCurrentUser && jumpToUserCursor(user.user_id)} > - +
+ +
{displayName} From 58cd785da620c6bd0144e3782099fcbb36bff0c3 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 11 Sep 2025 09:36:22 +0800 Subject: [PATCH 37/71] use const for cursor move config --- .../collaboration/hooks/use-collaboration.ts | 8 ++----- .../collaboration/services/cursor-service.ts | 21 ++++++------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/web/app/components/workflow/collaboration/hooks/use-collaboration.ts b/web/app/components/workflow/collaboration/hooks/use-collaboration.ts index 6caeefb596..42cff00ebb 100644 --- a/web/app/components/workflow/collaboration/hooks/use-collaboration.ts +++ b/web/app/components/workflow/collaboration/hooks/use-collaboration.ts @@ -19,12 +19,8 @@ export function useCollaboration(appId: string, reactFlowStore?: any) { let connectionId: string | null = null - if (!cursorServiceRef.current) { - cursorServiceRef.current = new CursorService({ - minMoveDistance: 10, - throttleMs: 300, - }) - } + if (!cursorServiceRef.current) + cursorServiceRef.current = new CursorService() const initCollaboration = async () => { connectionId = await collaborationManager.connect(appId, reactFlowStore) diff --git a/web/app/components/workflow/collaboration/services/cursor-service.ts b/web/app/components/workflow/collaboration/services/cursor-service.ts index 2a86e37697..0d86992ca0 100644 --- a/web/app/components/workflow/collaboration/services/cursor-service.ts +++ b/web/app/components/workflow/collaboration/services/cursor-service.ts @@ -2,10 +2,8 @@ import type { RefObject } from 'react' import type { CursorPosition } from '../types/collaboration' import type { ReactFlowInstance } from 'reactflow' -export type CursorServiceConfig = { - minMoveDistance?: number - throttleMs?: number -} +const CURSOR_MIN_MOVE_DISTANCE = 10 +const CURSOR_THROTTLE_MS = 500 export class CursorService { private containerRef: RefObject | null = null @@ -15,14 +13,6 @@ export class CursorService { private onEmitPosition: ((position: CursorPosition) => void) | null = null private lastEmitTime = 0 private lastPosition: { x: number; y: number } | null = null - private config: Required - - constructor(config: CursorServiceConfig = {}) { - this.config = { - minMoveDistance: config.minMoveDistance ?? 5, - throttleMs: config.throttleMs ?? 300, - } - } startTracking( containerRef: RefObject, @@ -78,10 +68,11 @@ export class CursorService { // Always emit cursor position (remove boundary check since world coordinates can be negative) const now = Date.now() - const timeThrottled = now - this.lastEmitTime > this.config.throttleMs + 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) > this.config.minMoveDistance / (this.reactFlowInstance?.getZoom() || 1)) - || (Math.abs(y - this.lastPosition.y) > this.config.minMoveDistance / (this.reactFlowInstance?.getZoom() || 1)) + || (Math.abs(x - this.lastPosition.x) > minDistance) + || (Math.abs(y - this.lastPosition.y) > minDistance) if (timeThrottled && distanceThrottled) { this.lastPosition = { x, y } From b1140301a4b38f6d76153256ed237f835ad635fd Mon Sep 17 00:00:00 2001 From: hjlarry Date: Fri, 12 Sep 2025 14:46:40 +0800 Subject: [PATCH 38/71] sync import dsl --- .../workflow-app/components/workflow-main.tsx | 31 +++++++++++++++++++ .../core/collaboration-manager.ts | 22 +++++++++++++ .../components/workflow/update-dsl-modal.tsx | 3 ++ 3 files changed, 56 insertions(+) diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index 067e9ccf93..1abd4704a4 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -23,6 +23,7 @@ import { useWorkflowRun, useWorkflowStartRun, } from '../hooks' +import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { useCollaboration } from '@/app/components/workflow/collaboration' import { collaborationManager } from '@/app/components/workflow/collaboration' @@ -115,6 +116,7 @@ const WorkflowMain = ({ syncWorkflowDraftWhenPageClose, } = useNodesSyncDraft() const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() + const { handleUpdateWorkflowCanvas } = useWorkflowUpdate() const { handleBackupDraft, handleLoadBackupDraft, @@ -139,6 +141,35 @@ const WorkflowMain = ({ return unsubscribe }, [appId, handleWorkflowDataUpdate]) + // Listen for workflow updates from other users + useEffect(() => { + if (!appId) return + + const unsubscribe = collaborationManager.onWorkflowUpdate(async () => { + console.log('Received workflow update from collaborator, fetching latest workflow data') + try { + const response = await fetchWorkflowDraft(`/apps/${appId}/workflows/draft`) + + // Handle features, variables etc. + handleWorkflowDataUpdate(response) + + // Update workflow canvas (nodes, edges, viewport) + if (response.graph) { + handleUpdateWorkflowCanvas({ + nodes: response.graph.nodes || [], + edges: response.graph.edges || [], + viewport: response.graph.viewport || { x: 0, y: 0, zoom: 1 }, + }) + } + } + catch (error) { + console.error('Failed to fetch updated workflow:', error) + } + }) + + return unsubscribe + }, [appId, handleWorkflowDataUpdate, handleUpdateWorkflowCanvas]) + // Listen for sync requests from other users (only processed by leader) useEffect(() => { if (!appId) return diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index 7cdc0aaf17..54bc67f774 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -226,6 +226,20 @@ export class CollaborationManager { } } + emitWorkflowUpdate(appId: string): void { + if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return + + const socket = webSocketClient.getSocket(this.currentAppId) + if (socket) { + console.log('Emitting Workflow update event') + socket.emit('collaboration_event', { + type: 'workflowUpdate', + data: { appId, timestamp: Date.now() }, + timestamp: Date.now(), + }) + } + } + onSyncRequest(callback: () => void): () => void { return this.eventEmitter.on('syncRequest', callback) } @@ -242,6 +256,10 @@ export class CollaborationManager { return this.eventEmitter.on('onlineUsers', callback) } + onWorkflowUpdate(callback: (update: { appId: string; timestamp: number }) => void): () => void { + return this.eventEmitter.on('workflowUpdate', callback) + } + onVarsAndFeaturesUpdate(callback: (update: any) => void): () => void { return this.eventEmitter.on('varsAndFeaturesUpdate', callback) } @@ -591,6 +609,10 @@ export class CollaborationManager { console.log('Processing mcpServerUpdate event:', update) this.eventEmitter.emit('mcpServerUpdate', update) } + else if (update.type === 'workflowUpdate') { + console.log('Processing workflowUpdate event:', update) + this.eventEmitter.emit('workflowUpdate', update.data) + } else if (update.type === 'syncRequest') { console.log('Received sync request from another user') // Only process if we are the leader diff --git a/web/app/components/workflow/update-dsl-modal.tsx b/web/app/components/workflow/update-dsl-modal.tsx index 00c36cce90..bc52049ae8 100644 --- a/web/app/components/workflow/update-dsl-modal.tsx +++ b/web/app/components/workflow/update-dsl-modal.tsx @@ -39,6 +39,7 @@ import { useEventEmitterContextContext } from '@/context/event-emitter' import { useStore as useAppStore } from '@/app/components/app/store' import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' +import { collaborationManager } from './collaboration/core/collaboration-manager' type UpdateDSLModalProps = { onCancel: () => void @@ -201,6 +202,8 @@ const UpdateDSLModal = ({ return } handleWorkflowUpdate(app_id) + // Notify other collaboration clients about the workflow update + collaborationManager.emitWorkflowUpdate(app_id) await handleCheckPluginDependencies(app_id) if (onImport) onImport() From b4636ddf44e28d040fe13850157e7550c96d8b07 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Fri, 12 Sep 2025 15:34:41 +0800 Subject: [PATCH 39/71] add leader restore workflow --- web/app/components/workflow/header/header-in-restoring.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/app/components/workflow/header/header-in-restoring.tsx b/web/app/components/workflow/header/header-in-restoring.tsx index afa4e62099..f1a4194370 100644 --- a/web/app/components/workflow/header/header-in-restoring.tsx +++ b/web/app/components/workflow/header/header-in-restoring.tsx @@ -19,6 +19,7 @@ import RestoringTitle from './restoring-title' import Button from '@/app/components/base/button' import { useStore as useAppStore } from '@/app/components/app/store' import { useInvalidAllLastRun } from '@/service/use-workflow' +import { collaborationManager } from '../collaboration/core/collaboration-manager' export type HeaderInRestoringProps = { onRestoreSettled?: () => void @@ -58,6 +59,9 @@ const HeaderInRestoring = ({ type: 'success', message: t('workflow.versionHistory.action.restoreSuccess'), }) + // Notify other collaboration clients about the workflow restore + if (appDetail) + collaborationManager.emitWorkflowUpdate(appDetail.id) }, onError: () => { Toast.notify({ @@ -71,7 +75,7 @@ const HeaderInRestoring = ({ }) deleteAllInspectVars() invalidAllLastRun() - }, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled]) + }, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled, appDetail]) return ( <> From fc230bcc59dccde57fbdc0c3580038e5f27c89fc Mon Sep 17 00:00:00 2001 From: hjlarry Date: Fri, 12 Sep 2025 16:27:12 +0800 Subject: [PATCH 40/71] add force update workflow to support restore --- api/controllers/console/app/workflow.py | 3 +++ api/services/workflow_service.py | 4 +++- .../hooks/use-nodes-sync-draft.ts | 19 +++++++++++++++---- .../workflow/header/header-in-restoring.tsx | 2 +- .../components/workflow/hooks-store/store.ts | 3 ++- .../workflow/hooks/use-nodes-sync-draft.ts | 3 ++- 6 files changed, 26 insertions(+), 8 deletions(-) diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index f67c629d48..183351a830 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -107,6 +107,7 @@ class DraftWorkflowApi(Resource): parser.add_argument("hash", type=str, required=False, location="json") parser.add_argument("environment_variables", type=list, required=True, location="json") parser.add_argument("conversation_variables", type=list, required=False, location="json") + parser.add_argument("force_upload", type=bool, required=False, default=False, location="json") args = parser.parse_args() elif "text/plain" in content_type: try: @@ -123,6 +124,7 @@ class DraftWorkflowApi(Resource): "hash": data.get("hash"), "environment_variables": data.get("environment_variables"), "conversation_variables": data.get("conversation_variables"), + "force_upload": data.get("force_upload", False), } except json.JSONDecodeError: return {"message": "Invalid JSON data"}, 400 @@ -151,6 +153,7 @@ class DraftWorkflowApi(Resource): account=current_user, environment_variables=environment_variables, conversation_variables=conversation_variables, + force_upload=args.get("force_upload", False), ) except WorkflowHashNotEqualError: raise DraftWorkflowNotSync() diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 5a842a6612..cc637b5037 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -199,15 +199,17 @@ class WorkflowService: account: Account, environment_variables: Sequence[Variable], conversation_variables: Sequence[Variable], + force_upload: bool = False, ) -> Workflow: """ Sync draft workflow + :param force_upload: Skip hash validation when True (for restore operations) :raises WorkflowHashNotEqualError """ # fetch draft workflow by app_model workflow = self.get_draft_workflow(app_model=app_model) - if workflow and workflow.unique_hash != unique_hash: + if workflow and workflow.unique_hash != unique_hash and not force_upload: raise WorkflowHashNotEqualError() # validate features structure diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index 09b0cf2e19..b3b205940f 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -122,6 +122,7 @@ export const useNodesSyncDraft = () => { onError?: () => void onSettled?: () => void }, + forceUpload?: boolean, ) => { if (getNodesReadOnly()) return @@ -129,15 +130,15 @@ export const useNodesSyncDraft = () => { // Check leader status at sync time const currentIsLeader = collaborationManager.getIsLeader() - // If not leader, request the leader to sync - if (!currentIsLeader) { + // If not leader and not forcing upload, request the leader to sync + if (!currentIsLeader && !forceUpload) { console.log('Not leader, requesting leader to sync workflow draft') collaborationManager.emitSyncRequest() callback?.onSettled?.() return } - console.log('Leader performing workflow draft sync') + console.log(forceUpload ? 'Force uploading workflow draft' : 'Leader performing workflow draft sync') const postParams = getPostParams() if (postParams) { @@ -145,8 +146,18 @@ export const useNodesSyncDraft = () => { setSyncWorkflowDraftHash, setDraftUpdatedAt, } = workflowStore.getState() + + // Add force_upload parameter if needed + const finalParams = { + ...postParams.params, + ...(forceUpload && { force_upload: true }), + } + try { - const res = await syncWorkflowDraft(postParams) + const res = await syncWorkflowDraft({ + url: postParams.url, + params: finalParams, + }) setSyncWorkflowDraftHash(res.hash) setDraftUpdatedAt(res.updated_at) console.log('Leader successfully synced workflow draft') diff --git a/web/app/components/workflow/header/header-in-restoring.tsx b/web/app/components/workflow/header/header-in-restoring.tsx index f1a4194370..d035d2f189 100644 --- a/web/app/components/workflow/header/header-in-restoring.tsx +++ b/web/app/components/workflow/header/header-in-restoring.tsx @@ -72,7 +72,7 @@ const HeaderInRestoring = ({ onSettled: () => { onRestoreSettled?.() }, - }) + }, true) // Enable forceUpload for restore operation deleteAllInspectVars() invalidAllLastRun() }, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled, appDetail]) diff --git a/web/app/components/workflow/hooks-store/store.ts b/web/app/components/workflow/hooks-store/store.ts index 4e8f74c774..cf0d25ee15 100644 --- a/web/app/components/workflow/hooks-store/store.ts +++ b/web/app/components/workflow/hooks-store/store.ts @@ -21,7 +21,8 @@ type CommonHooksFnMap = { onSuccess?: () => void onError?: () => void onSettled?: () => void - } + }, + forceUpload?: boolean ) => Promise syncWorkflowDraftWhenPageClose: () => void handleRefreshWorkflowDraft: () => void diff --git a/web/app/components/workflow/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow/hooks/use-nodes-sync-draft.ts index e6cc3a97e3..b6d85fdac7 100644 --- a/web/app/components/workflow/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow/hooks/use-nodes-sync-draft.ts @@ -21,12 +21,13 @@ export const useNodesSyncDraft = () => { onError?: () => void onSettled?: () => void }, + forceUpload?: boolean, ) => { if (getNodesReadOnly()) return if (sync) - doSyncWorkflowDraft(notRefreshWhenSyncError, callback) + doSyncWorkflowDraft(notRefreshWhenSyncError, callback, forceUpload) else debouncedSyncWorkflowDraft(doSyncWorkflowDraft) }, [debouncedSyncWorkflowDraft, doSyncWorkflowDraft, getNodesReadOnly]) From 1721314c62fec3bb7176e8b17779431e4f13f6bd Mon Sep 17 00:00:00 2001 From: hjlarry Date: Sat, 13 Sep 2025 17:57:19 +0800 Subject: [PATCH 41/71] add frontend comment service --- web/service/workflow-comment.ts | 101 ++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 web/service/workflow-comment.ts diff --git a/web/service/workflow-comment.ts b/web/service/workflow-comment.ts new file mode 100644 index 0000000000..c4d530c63b --- /dev/null +++ b/web/service/workflow-comment.ts @@ -0,0 +1,101 @@ +import { del, get, post, put } from './base' +import type { CommonResponse } from '@/models/common' + +export type WorkflowComment = { + id: string + app_id: string + position_x: number + position_y: number + content: string + created_by: string + created_at: string + updated_at: string + resolved: boolean + resolved_by?: string + resolved_at?: string + mentioned_user_ids: string[] + replies_count: number + author: { + id: string + name: string + email: string + avatar?: string + } +} + +export type WorkflowCommentReply = { + id: string + comment_id: string + content: string + created_by: string + created_at: string + updated_at: string + mentioned_user_ids: string[] + author: { + id: string + name: string + email: string + avatar?: string + } +} + +export type CreateCommentParams = { + position_x: number + position_y: number + content: string + mentioned_user_ids?: string[] +} + +export type UpdateCommentParams = { + content: string + position_x?: number + position_y?: number + mentioned_user_ids?: string[] +} + +export type CreateReplyParams = { + content: string + mentioned_user_ids?: string[] +} + +export const fetchWorkflowComments = async (appId: string): Promise => { + const response = await get<{ data: WorkflowComment[] }>(`apps/${appId}/workflow/comments`) + return response.data +} + +export const createWorkflowComment = async (appId: string, params: CreateCommentParams): Promise => { + return post(`apps/${appId}/workflow/comments`, { body: params }) +} + +export const fetchWorkflowComment = async (appId: string, commentId: string): Promise => { + return get(`apps/${appId}/workflow/comments/${commentId}`) +} + +export const updateWorkflowComment = async (appId: string, commentId: string, params: UpdateCommentParams): Promise => { + return put(`apps/${appId}/workflow/comments/${commentId}`, { body: params }) +} + +export const deleteWorkflowComment = async (appId: string, commentId: string): Promise => { + return del(`apps/${appId}/workflow/comments/${commentId}`) +} + +export const resolveWorkflowComment = async (appId: string, commentId: string): Promise => { + return post(`apps/${appId}/workflow/comments/${commentId}/resolve`) +} + +export const createWorkflowCommentReply = async (appId: string, commentId: string, params: CreateReplyParams): Promise => { + return post(`apps/${appId}/workflow/comments/${commentId}/replies`, { body: params }) +} + +export const updateWorkflowCommentReply = async (appId: string, commentId: string, replyId: string, params: CreateReplyParams): Promise => { + return put(`apps/${appId}/workflow/comments/${commentId}/replies/${replyId}`, { body: params }) +} + +export const deleteWorkflowCommentReply = async (appId: string, commentId: string, replyId: string): Promise => { + return del(`apps/${appId}/workflow/comments/${commentId}/replies/${replyId}`) +} + +export const fetchMentionableUsers = async (appId: string) => { + const response = await get<{ users: Array<{ id: string; name: string; email: string; avatar?: string }> }>(`apps/${appId}/workflow/comments/mention-users`) + return response.users +} From 75257232c3b0e4f88adc924dd155ff387666a1ab Mon Sep 17 00:00:00 2001 From: hjlarry Date: Sun, 14 Sep 2025 12:10:37 +0800 Subject: [PATCH 42/71] add create comment frontend --- .../components/workflow/comment-manager.tsx | 21 +++ web/app/components/workflow/comment/index.tsx | 128 ++++++++++++++++++ web/app/components/workflow/hooks/index.ts | 1 + .../workflow/hooks/use-workflow-comment.ts | 86 ++++++++++++ web/app/components/workflow/index.tsx | 28 ++++ .../components/workflow/operator/control.tsx | 23 +++- web/app/components/workflow/operator/hooks.ts | 8 ++ .../workflow/store/workflow/workflow-slice.ts | 8 +- web/app/components/workflow/types.ts | 1 + web/i18n/en-US/workflow.ts | 1 + web/i18n/zh-Hans/workflow.ts | 1 + 11 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 web/app/components/workflow/comment-manager.tsx create mode 100644 web/app/components/workflow/comment/index.tsx create mode 100644 web/app/components/workflow/hooks/use-workflow-comment.ts diff --git a/web/app/components/workflow/comment-manager.tsx b/web/app/components/workflow/comment-manager.tsx new file mode 100644 index 0000000000..58fa93fa3d --- /dev/null +++ b/web/app/components/workflow/comment-manager.tsx @@ -0,0 +1,21 @@ +import { useEventListener } from 'ahooks' +import { useWorkflowStore } from './store' +import { useWorkflowComment } from './hooks/use-workflow-comment' + +const CommentManager = () => { + const workflowStore = useWorkflowStore() + const { handleCreateComment } = useWorkflowComment() + + useEventListener('click', (e) => { + const { controlMode, mousePosition } = workflowStore.getState() + + if (controlMode === 'comment') { + e.preventDefault() + handleCreateComment(mousePosition) + } + }) + + return null +} + +export default CommentManager diff --git a/web/app/components/workflow/comment/index.tsx b/web/app/components/workflow/comment/index.tsx new file mode 100644 index 0000000000..17b31c9ee6 --- /dev/null +++ b/web/app/components/workflow/comment/index.tsx @@ -0,0 +1,128 @@ +import type { FC } from 'react' +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { RiMessage3Line } from '@remixicon/react' +import { useStore } from '../store' +import { ControlMode } from '../types' +import type { WorkflowComment } from '@/service/workflow-comment' + +type CommentCursorProps = { + mousePosition: { elementX: number; elementY: number } +} + +export const CommentCursor: FC = memo(({ mousePosition }) => { + const controlMode = useStore(s => s.controlMode) + + if (controlMode !== ControlMode.Comment) + return null + + return ( +
+ +
+ ) +}) + +CommentCursor.displayName = 'CommentCursor' + +type CommentInputProps = { + position: { x: number; y: number } + onSubmit: (content: string) => void + onCancel: () => void +} + +export const CommentInput: FC = memo(({ position, onSubmit, onCancel }) => { + const { t } = useTranslation() + const [content, setContent] = useState('') + + const handleSubmit = useCallback(() => { + try { + if (content.trim()) { + onSubmit(content.trim()) + setContent('') + } + } + catch (error) { + console.error('Error in CommentInput handleSubmit:', error) + } + }, [content, onSubmit]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSubmit() + } + else if (e.key === 'Escape') { + onCancel() + } + }, [handleSubmit, onCancel]) + + return ( +
+