diff --git a/api/controllers/console/app/online_user.py b/api/controllers/console/app/online_user.py index 905e4622df..b0b4dc5013 100644 --- a/api/controllers/console/app/online_user.py +++ b/api/controllers/console/app/online_user.py @@ -1,9 +1,14 @@ import json from flask import request +from flask_restful import Resource, marshal_with, reqparse +from controllers.console import api +from controllers.console.wraps import account_initialization_required, setup_required from extensions.ext_redis import redis_client from extensions.ext_socketio import ext_socketio +from fields.online_user_fields import online_user_list_fields +from libs.login import login_required @ext_socketio.on("user_connect") @@ -25,8 +30,8 @@ def handle_user_connect(data): user_info = { "user_id": current_user.id, - "username": getattr(current_user, "username", ""), - "avatar": getattr(current_user, "avatar", ""), + "username": current_user.name, + "avatar": current_user.avatar, "sid": sid, } @@ -49,3 +54,32 @@ def handle_disconnect(): user_id = data["user_id"] redis_client.hdel(f"workflow_online_users:{workflow_id}", user_id) redis_client.delete(f"ws_sid_map:{sid}") + + +class OnlineUserApi(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(online_user_list_fields) + def get(self): + parser = reqparse.RequestParser() + parser.add_argument("workflow_ids", type=str, required=True, location="args") + args = parser.parse_args() + + workflow_ids = [id.strip() for id in args["workflow_ids"].split(",")] + + results = {} + for workflow_id in workflow_ids: + users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}") + + users = [] + for _, user_info_json in users_json.items(): + try: + users.append(json.loads(user_info_json)) + except Exception: + continue + results[workflow_id] = users + + return {"data": results} + +api.add_resource(OnlineUserApi, "/online-users") diff --git a/api/fields/online_user_fields.py b/api/fields/online_user_fields.py new file mode 100644 index 0000000000..be41fd19a5 --- /dev/null +++ b/api/fields/online_user_fields.py @@ -0,0 +1,17 @@ +from flask_restful import fields + +online_user_partial_fields = { + "id": fields.String, + "username": fields.String, + "avatar": fields.String, + "sid": fields.String, +} + +workflow_online_users_fields = { + "workflow_id": fields.String, + "users": fields.List(fields.Nested(online_user_partial_fields)) +} + +online_user_list_fields = { + "data": fields.List(fields.Nested(workflow_online_users_fields)), +} \ No newline at end of file diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index d425e6f595..162062f293 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -16,6 +16,7 @@ import { useWorkflowStartRun, } from '../hooks' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' +import { useCollaborativeCursors } from '../hooks' type WorkflowMainProps = Pick const WorkflowMain = ({ @@ -65,6 +66,9 @@ const WorkflowMain = ({ handleWorkflowStartRunInWorkflow, } = useWorkflowStartRun() const appId = useStore(s => s.appId) + + const { cursors, myUserId } = useCollaborativeCursors(appId) + const { fetchInspectVars } = useSetWorkflowVarsWithValue({ flowId: appId, ...useConfigsMap(), @@ -148,15 +152,53 @@ const WorkflowMain = ({ ]) return ( - - - +
+ + + + + {/* Render other users' cursors on top */} + {Object.entries(cursors || {}).map(([userId, cursor]) => { + if (userId === myUserId) + return null + + return ( +
+ {/* You can replace this with your own cursor SVG or component */} + + + + + {cursor.name || userId} + +
+ ) + })} +
) } diff --git a/web/app/components/workflow-app/hooks/index.ts b/web/app/components/workflow-app/hooks/index.ts index 9e4f94965b..99acd3e2c3 100644 --- a/web/app/components/workflow-app/hooks/index.ts +++ b/web/app/components/workflow-app/hooks/index.ts @@ -8,3 +8,4 @@ export * from './use-workflow-refresh-draft' export * from '../../workflow/hooks/use-fetch-workflow-inspect-vars' export * from './use-inspect-vars-crud' export * from './use-configs-map' +export * from './use-workflow-websocket' diff --git a/web/app/components/workflow-app/hooks/use-workflow-websocket.ts b/web/app/components/workflow-app/hooks/use-workflow-websocket.ts new file mode 100644 index 0000000000..c7864b37ab --- /dev/null +++ b/web/app/components/workflow-app/hooks/use-workflow-websocket.ts @@ -0,0 +1,50 @@ +import { + useEffect, + useRef, + useState, +} from 'react' + +import { connectOnlineUserWebSocket, disconnectOnlineUserWebSocket } from '@/service/demo/online-user' + +type Cursor = { + x: number + y: number + userId: string + name?: string + color?: string +} + +export function useCollaborativeCursors(appId: string) { + const [cursors, setCursors] = useState>({}) + const socketRef = useRef | null>(null) + const lastSent = useRef(0) + + useEffect(() => { + // Connect websocket + const socket = connectOnlineUserWebSocket(appId) + socketRef.current = socket + + // Listen for other users' cursor updates + socket.on('users_mouse_positions', (positions: Record) => { + setCursors(positions) + }) + + // Mouse move handler with throttle (e.g. 30ms) + const handleMouseMove = (e: MouseEvent) => { + const now = Date.now() + if (now - lastSent.current > 30) { + socket.emit('mouse_move', { x: e.clientX, y: e.clientY }) + lastSent.current = now + } + } + window.addEventListener('mousemove', handleMouseMove) + + return () => { + window.removeEventListener('mousemove', handleMouseMove) + socket.off('users_mouse_positions') + disconnectOnlineUserWebSocket() + } + }, [appId]) + + return cursors +} diff --git a/web/service/demo/online-user.ts b/web/service/demo/online-user.ts index f2e2db87b7..889a78a9ef 100644 --- a/web/service/demo/online-user.ts +++ b/web/service/demo/online-user.ts @@ -3,6 +3,7 @@ import type { Socket } from 'socket.io-client' import { io } from 'socket.io-client' let socket: Socket | null = null +let lastAppId: string | null = null /** * Connect to the online user websocket server. @@ -10,7 +11,8 @@ let socket: Socket | null = null * @returns The socket instance. */ export function connectOnlineUserWebSocket(appId: string): Socket { - // If already connected, disconnect first + if (socket && lastAppId === appId) + return socket if (socket) socket.disconnect() @@ -24,6 +26,8 @@ export function connectOnlineUserWebSocket(appId: string): Socket { withCredentials: true, }) + lastAppId = appId + // Add your event listeners here socket.on('connect', () => { socket?.emit('user_connect', { workflow_id: appId })