add online users backend api and frontend submit cursor pos

This commit is contained in:
hjlarry
2025-07-18 10:54:06 +08:00
parent 2f966d8c38
commit 2f35cc9188
6 changed files with 160 additions and 12 deletions

View File

@@ -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")

View File

@@ -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)),
}

View File

@@ -16,6 +16,7 @@ import {
useWorkflowStartRun,
} from '../hooks'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { useCollaborativeCursors } from '../hooks'
type WorkflowMainProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
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 (
<WorkflowWithInnerContext
nodes={nodes}
edges={edges}
viewport={viewport}
onWorkflowDataUpdate={handleWorkflowDataUpdate}
hooksStore={hooksStore}
>
<WorkflowChildren />
</WorkflowWithInnerContext>
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<WorkflowWithInnerContext
nodes={nodes}
edges={edges}
viewport={viewport}
onWorkflowDataUpdate={handleWorkflowDataUpdate}
hooksStore={hooksStore}
>
<WorkflowChildren />
</WorkflowWithInnerContext>
{/* Render other users' cursors on top */}
{Object.entries(cursors || {}).map(([userId, cursor]) => {
if (userId === myUserId)
return null
return (
<div
key={userId}
style={{
position: 'absolute',
left: cursor.x,
top: cursor.y,
pointerEvents: 'none', // Important: allows clicking through the cursor
zIndex: 9999, // Ensure cursors are on top of other elements
transition: 'left 0.1s linear, top 0.1s linear', // Optional: for smoother movement
}}
>
{/* You can replace this with your own cursor SVG or component */}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.5 3.75L10.5 18.25L12.5 11.25L19.5 9.25L5.5 3.75Z" fill={cursor.color || 'black'} stroke="white" strokeWidth="1.5" strokeLinejoin="round"/>
</svg>
<span style={{
backgroundColor: cursor.color || 'black',
color: 'white',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '12px',
whiteSpace: 'nowrap',
marginLeft: '4px',
}}>
{cursor.name || userId}
</span>
</div>
)
})}
</div>
)
}

View File

@@ -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'

View File

@@ -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<Record<string, Cursor>>({})
const socketRef = useRef<ReturnType<typeof connectOnlineUserWebSocket> | null>(null)
const lastSent = useRef<number>(0)
useEffect(() => {
// Connect websocket
const socket = connectOnlineUserWebSocket(appId)
socketRef.current = socket
// Listen for other users' cursor updates
socket.on('users_mouse_positions', (positions: Record<string, Cursor>) => {
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
}

View File

@@ -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 })