mirror of https://github.com/langgenius/dify.git
Merge branch 'p284' into deploy/dev
This commit is contained in:
commit
c662b95b80
20
api/app.py
20
api/app.py
|
|
@ -1,3 +1,4 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
|
||||
|
|
@ -8,10 +9,16 @@ def is_db_command():
|
|||
|
||||
|
||||
# create app
|
||||
celery = None
|
||||
flask_app = None
|
||||
socketio_app = None
|
||||
|
||||
if is_db_command():
|
||||
from app_factory import create_migrations_app
|
||||
|
||||
app = create_migrations_app()
|
||||
socketio_app = app
|
||||
flask_app = app
|
||||
else:
|
||||
# It seems that JetBrains Python debugger does not work well with gevent,
|
||||
# so we need to disable gevent in debug mode.
|
||||
|
|
@ -33,8 +40,15 @@ else:
|
|||
|
||||
from app_factory import create_app
|
||||
|
||||
app = create_app()
|
||||
celery = app.extensions["celery"]
|
||||
socketio_app, flask_app = create_app()
|
||||
app = flask_app
|
||||
celery = flask_app.extensions["celery"]
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5001)
|
||||
from gevent import pywsgi
|
||||
from geventwebsocket.handler import WebSocketHandler
|
||||
|
||||
host = os.environ.get("HOST", "0.0.0.0")
|
||||
port = int(os.environ.get("PORT", 5001))
|
||||
server = pywsgi.WSGIServer((host, port), socketio_app, handler_class=WebSocketHandler)
|
||||
server.serve_forever()
|
||||
|
|
|
|||
|
|
@ -31,14 +31,22 @@ def create_flask_app_with_configs() -> DifyApp:
|
|||
return dify_app
|
||||
|
||||
|
||||
def create_app() -> DifyApp:
|
||||
def create_app() -> tuple[any, DifyApp]:
|
||||
start_time = time.perf_counter()
|
||||
app = create_flask_app_with_configs()
|
||||
initialize_extensions(app)
|
||||
|
||||
import socketio
|
||||
|
||||
from extensions.ext_socketio import sio
|
||||
|
||||
sio.app = app
|
||||
socketio_app = socketio.WSGIApp(sio, app)
|
||||
|
||||
end_time = time.perf_counter()
|
||||
if dify_config.DEBUG:
|
||||
logger.info("Finished create_app (%s ms)", round((end_time - start_time) * 1000, 2))
|
||||
return app
|
||||
return socketio_app, app
|
||||
|
||||
|
||||
def initialize_extensions(app: DifyApp):
|
||||
|
|
|
|||
|
|
@ -89,11 +89,13 @@ from .app import (
|
|||
mcp_server,
|
||||
message,
|
||||
model_config,
|
||||
online_user,
|
||||
ops_trace,
|
||||
site,
|
||||
statistic,
|
||||
workflow,
|
||||
workflow_app_log,
|
||||
workflow_comment,
|
||||
workflow_draft_variable,
|
||||
workflow_run,
|
||||
workflow_statistic,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,258 @@
|
|||
import json
|
||||
import time
|
||||
|
||||
from extensions.ext_redis import redis_client
|
||||
from extensions.ext_socketio import sio
|
||||
from libs.passport import PassportService
|
||||
from services.account_service import AccountService
|
||||
|
||||
|
||||
@sio.on("connect")
|
||||
def socket_connect(sid, environ, auth):
|
||||
"""
|
||||
WebSocket connect event, do authentication here.
|
||||
"""
|
||||
token = None
|
||||
if auth and isinstance(auth, dict):
|
||||
token = auth.get("token")
|
||||
if not token:
|
||||
return False
|
||||
|
||||
try:
|
||||
decoded = PassportService().verify(token)
|
||||
user_id = decoded.get("user_id")
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
with sio.app.app_context():
|
||||
user = AccountService.load_logged_in_account(account_id=user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
sio.save_session(sid, {"user_id": user.id, "username": user.name, "avatar": user.avatar})
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@sio.on("user_connect")
|
||||
def handle_user_connect(sid, data):
|
||||
"""
|
||||
Handle user connect event. Each session (tab) is treated as an independent collaborator.
|
||||
"""
|
||||
|
||||
workflow_id = data.get("workflow_id")
|
||||
if not workflow_id:
|
||||
return {"msg": "workflow_id is required"}, 400
|
||||
|
||||
session = sio.get_session(sid)
|
||||
user_id = session.get("user_id")
|
||||
|
||||
if not user_id:
|
||||
return {"msg": "unauthorized"}, 401
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
# 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 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 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}
|
||||
|
||||
|
||||
@sio.on("disconnect")
|
||||
def handle_disconnect(sid):
|
||||
"""
|
||||
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"]
|
||||
|
||||
# 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 session disconnected
|
||||
handle_leader_disconnect(workflow_id, sid)
|
||||
|
||||
broadcast_online_users(workflow_id)
|
||||
|
||||
|
||||
def get_or_set_leader(workflow_id, sid):
|
||||
"""
|
||||
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 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_sid):
|
||||
"""
|
||||
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)
|
||||
|
||||
if current_leader:
|
||||
current_leader = current_leader.decode("utf-8") if isinstance(current_leader, bytes) else current_leader
|
||||
|
||||
if current_leader == disconnected_sid:
|
||||
# Leader session disconnected, elect a new leader
|
||||
sessions_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}")
|
||||
|
||||
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_sid, ex=3600)
|
||||
|
||||
# Notify all sessions about the new leader
|
||||
broadcast_leader_change(workflow_id, new_leader_sid)
|
||||
else:
|
||||
# No sessions left, remove leader
|
||||
redis_client.delete(leader_key)
|
||||
|
||||
|
||||
def broadcast_leader_change(workflow_id, new_leader_sid):
|
||||
"""
|
||||
Broadcast leader change to all sessions in the workflow.
|
||||
"""
|
||||
sessions_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}")
|
||||
|
||||
for sid, session_info_json in sessions_json.items():
|
||||
try:
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
Each session is shown as a separate user (even if same person has multiple tabs).
|
||||
"""
|
||||
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"),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Sort by connection time to maintain consistent order
|
||||
users.sort(key=lambda x: x.get("connected_at") or 0)
|
||||
|
||||
# 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")
|
||||
def handle_collaboration_event(sid, data):
|
||||
"""
|
||||
Handle general collaboration events, include:
|
||||
1. mouseMove
|
||||
2. varsAndFeaturesUpdate
|
||||
3. syncRequest(ask leader to update graph)
|
||||
4. appStateUpdate
|
||||
5. mcpServerUpdate
|
||||
|
||||
"""
|
||||
mapping = redis_client.get(f"ws_sid_map:{sid}")
|
||||
|
||||
if not mapping:
|
||||
return {"msg": "unauthorized"}, 401
|
||||
|
||||
mapping_data = json.loads(mapping)
|
||||
workflow_id = mapping_data["workflow_id"]
|
||||
user_id = mapping_data["user_id"]
|
||||
|
||||
event_type = data.get("type")
|
||||
event_data = data.get("data")
|
||||
timestamp = data.get("timestamp", int(time.time()))
|
||||
|
||||
if not event_type:
|
||||
return {"msg": "invalid event type"}, 400
|
||||
|
||||
sio.emit(
|
||||
"collaboration_update",
|
||||
{"type": event_type, "userId": user_id, "data": event_data, "timestamp": timestamp},
|
||||
room=workflow_id,
|
||||
skip_sid=sid,
|
||||
)
|
||||
|
||||
return {"msg": "event_broadcasted"}
|
||||
|
||||
|
||||
@sio.on("graph_event")
|
||||
def handle_graph_event(sid, data):
|
||||
"""
|
||||
Handle graph events - simple broadcast relay.
|
||||
"""
|
||||
mapping = redis_client.get(f"ws_sid_map:{sid}")
|
||||
|
||||
if not mapping:
|
||||
return {"msg": "unauthorized"}, 401
|
||||
|
||||
mapping_data = json.loads(mapping)
|
||||
workflow_id = mapping_data["workflow_id"]
|
||||
|
||||
sio.emit("graph_update", data, room=workflow_id, skip_sid=sid)
|
||||
|
||||
return {"msg": "graph_update_broadcasted"}
|
||||
|
|
@ -22,7 +22,9 @@ from core.file.models import File
|
|||
from core.helper.trace_id_helper import get_external_trace_id
|
||||
from core.workflow.graph_engine.manager import GraphEngineManager
|
||||
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 fields.workflow_run_fields import workflow_run_node_execution_fields
|
||||
from libs import helper
|
||||
|
|
@ -128,6 +130,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:
|
||||
|
|
@ -144,6 +147,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
|
||||
|
|
@ -172,6 +176,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()
|
||||
|
|
@ -815,6 +820,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"}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows")
|
||||
class PublishedAllWorkflowApi(Resource):
|
||||
@api.doc("get_all_published_workflows")
|
||||
|
|
@ -1004,3 +1030,105 @@ class DraftWorkflowNodeLastRunApi(Resource):
|
|||
if node_exec is None:
|
||||
raise NotFound("last run not found")
|
||||
return node_exec
|
||||
|
||||
|
||||
class WorkflowOnlineUsersApi(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(
|
||||
DraftWorkflowApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft",
|
||||
)
|
||||
api.add_resource(
|
||||
WorkflowConfigApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft/config",
|
||||
)
|
||||
api.add_resource(
|
||||
WorkflowFeaturesApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft/features",
|
||||
)
|
||||
api.add_resource(
|
||||
AdvancedChatDraftWorkflowRunApi,
|
||||
"/apps/<uuid:app_id>/advanced-chat/workflows/draft/run",
|
||||
)
|
||||
api.add_resource(
|
||||
DraftWorkflowRunApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft/run",
|
||||
)
|
||||
api.add_resource(
|
||||
WorkflowTaskStopApi,
|
||||
"/apps/<uuid:app_id>/workflow-runs/tasks/<string:task_id>/stop",
|
||||
)
|
||||
api.add_resource(
|
||||
DraftWorkflowNodeRunApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/run",
|
||||
)
|
||||
api.add_resource(
|
||||
AdvancedChatDraftRunIterationNodeApi,
|
||||
"/apps/<uuid:app_id>/advanced-chat/workflows/draft/iteration/nodes/<string:node_id>/run",
|
||||
)
|
||||
api.add_resource(
|
||||
WorkflowDraftRunIterationNodeApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft/iteration/nodes/<string:node_id>/run",
|
||||
)
|
||||
api.add_resource(
|
||||
AdvancedChatDraftRunLoopNodeApi,
|
||||
"/apps/<uuid:app_id>/advanced-chat/workflows/draft/loop/nodes/<string:node_id>/run",
|
||||
)
|
||||
api.add_resource(
|
||||
WorkflowDraftRunLoopNodeApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft/loop/nodes/<string:node_id>/run",
|
||||
)
|
||||
api.add_resource(
|
||||
PublishedWorkflowApi,
|
||||
"/apps/<uuid:app_id>/workflows/publish",
|
||||
)
|
||||
api.add_resource(
|
||||
PublishedAllWorkflowApi,
|
||||
"/apps/<uuid:app_id>/workflows",
|
||||
)
|
||||
api.add_resource(
|
||||
DefaultBlockConfigsApi,
|
||||
"/apps/<uuid:app_id>/workflows/default-workflow-block-configs",
|
||||
)
|
||||
api.add_resource(
|
||||
DefaultBlockConfigApi,
|
||||
"/apps/<uuid:app_id>/workflows/default-workflow-block-configs/<string:block_type>",
|
||||
)
|
||||
api.add_resource(
|
||||
ConvertToWorkflowApi,
|
||||
"/apps/<uuid:app_id>/convert-to-workflow",
|
||||
)
|
||||
api.add_resource(
|
||||
WorkflowByIdApi,
|
||||
"/apps/<uuid:app_id>/workflows/<string:workflow_id>",
|
||||
)
|
||||
api.add_resource(
|
||||
DraftWorkflowNodeLastRunApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/last-run",
|
||||
)
|
||||
api.add_resource(WorkflowOnlineUsersApi, "/apps/workflows/online-users")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
import logging
|
||||
|
||||
from flask_restx 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,
|
||||
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.account_service import TenantService
|
||||
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("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()
|
||||
|
||||
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 result
|
||||
|
||||
@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 {"result": "success"}, 204
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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")
|
||||
parser.add_argument("mentioned_user_ids", type=list, location="json", default=[])
|
||||
args = parser.parse_args()
|
||||
|
||||
result = WorkflowCommentService.create_reply(
|
||||
comment_id=comment_id,
|
||||
content=args.content,
|
||||
created_by=current_user.id,
|
||||
mentioned_user_ids=args.mentioned_user_ids,
|
||||
)
|
||||
|
||||
return result, 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")
|
||||
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, mentioned_user_ids=args.mentioned_user_ids
|
||||
)
|
||||
|
||||
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 {"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/<uuid:app_id>/workflow/comments")
|
||||
api.add_resource(WorkflowCommentDetailApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>")
|
||||
api.add_resource(WorkflowCommentResolveApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/resolve")
|
||||
api.add_resource(WorkflowCommentReplyApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies")
|
||||
api.add_resource(
|
||||
WorkflowCommentReplyDetailApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies/<string:reply_id>"
|
||||
)
|
||||
api.add_resource(WorkflowCommentMentionUsersApi, "/apps/<uuid:app_id>/workflow/comments/mention-users")
|
||||
|
|
@ -19,8 +19,8 @@ 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 extensions.ext_database import db
|
||||
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
|
||||
from models.account import Account
|
||||
|
|
@ -353,7 +353,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
|
||||
|
|
@ -446,8 +446,35 @@ class ConversationVariableCollectionApi(Resource):
|
|||
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"}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/system-variables")
|
||||
class SystemVariableCollectionApi(Resource):
|
||||
@api.doc("get_system_variables")
|
||||
@api.doc(description="Get system variables for workflow")
|
||||
|
|
@ -497,3 +524,44 @@ 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(
|
||||
WorkflowVariableCollectionApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft/variables",
|
||||
)
|
||||
api.add_resource(NodeVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/variables")
|
||||
api.add_resource(VariableApi, "/apps/<uuid:app_id>/workflows/draft/variables/<uuid:variable_id>")
|
||||
api.add_resource(VariableResetApi, "/apps/<uuid:app_id>/workflows/draft/variables/<uuid:variable_id>/reset")
|
||||
|
||||
api.add_resource(ConversationVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/conversation-variables")
|
||||
api.add_resource(SystemVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/system-variables")
|
||||
api.add_resource(EnvironmentVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/environment-variables")
|
||||
|
|
|
|||
|
|
@ -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.datetime_utils import naive_utc_now
|
||||
|
|
@ -131,6 +132,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
|
||||
|
|
|
|||
|
|
@ -38,14 +38,15 @@ elif [[ "${MODE}" == "beat" ]]; then
|
|||
exec celery -A app.celery beat --loglevel ${LOG_LEVEL:-INFO}
|
||||
else
|
||||
if [[ "${DEBUG}" == "true" ]]; then
|
||||
# TODO: add socketio support
|
||||
exec flask run --host=${DIFY_BIND_ADDRESS:-0.0.0.0} --port=${DIFY_PORT:-5001} --debug
|
||||
else
|
||||
exec gunicorn \
|
||||
--bind "${DIFY_BIND_ADDRESS:-0.0.0.0}:${DIFY_PORT:-5001}" \
|
||||
--workers ${SERVER_WORKER_AMOUNT:-1} \
|
||||
--worker-class ${SERVER_WORKER_CLASS:-gevent} \
|
||||
--worker-class ${SERVER_WORKER_CLASS:-geventwebsocket.gunicorn.workers.GeventWebSocketWorker} \
|
||||
--worker-connections ${SERVER_WORKER_CONNECTIONS:-10} \
|
||||
--timeout ${GUNICORN_TIMEOUT:-200} \
|
||||
app:app
|
||||
app:socketio_app
|
||||
fi
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
import socketio
|
||||
|
||||
sio = socketio.Server(async_mode="gevent", cors_allowed_origins="*")
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
from flask_restx 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)),
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
from flask_restx import fields
|
||||
|
||||
from libs.helper import AvatarUrlField, TimestampField
|
||||
|
||||
# basic account fields for comments
|
||||
account_fields = {
|
||||
"id": fields.String,
|
||||
"name": fields.String,
|
||||
"email": fields.String,
|
||||
"avatar_url": AvatarUrlField,
|
||||
}
|
||||
|
||||
# Comment mention fields
|
||||
workflow_comment_mention_fields = {
|
||||
"mentioned_user_id": fields.String,
|
||||
"mentioned_user_account": fields.Nested(account_fields, allow_null=True),
|
||||
"reply_id": fields.String,
|
||||
}
|
||||
|
||||
# Comment reply fields
|
||||
workflow_comment_reply_fields = {
|
||||
"id": fields.String,
|
||||
"content": fields.String,
|
||||
"created_by": fields.String,
|
||||
"created_by_account": fields.Nested(account_fields, allow_null=True),
|
||||
"created_at": TimestampField,
|
||||
}
|
||||
|
||||
# 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(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(account_fields, allow_null=True),
|
||||
"reply_count": fields.Integer,
|
||||
"mention_count": fields.Integer,
|
||||
"participants": fields.List(fields.Nested(account_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(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(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 (simplified)
|
||||
workflow_comment_update_fields = {
|
||||
"id": fields.String,
|
||||
"updated_at": TimestampField,
|
||||
}
|
||||
|
||||
# Comment resolve response fields
|
||||
workflow_comment_resolve_fields = {
|
||||
"id": fields.String,
|
||||
"resolved": fields.Boolean,
|
||||
"resolved_at": TimestampField,
|
||||
"resolved_by": fields.String,
|
||||
}
|
||||
|
||||
# Reply creation response fields (simplified)
|
||||
workflow_comment_reply_create_fields = {
|
||||
"id": fields.String,
|
||||
"created_at": TimestampField,
|
||||
}
|
||||
|
||||
# Reply update response fields
|
||||
workflow_comment_reply_update_fields = {
|
||||
"id": fields.String,
|
||||
"updated_at": TimestampField,
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
"""Add workflow comments table
|
||||
|
||||
Revision ID: 227822d22895
|
||||
Revises: 68519ad5cd18
|
||||
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 = '68519ad5cd18'
|
||||
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.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')
|
||||
)
|
||||
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 ###
|
||||
|
|
@ -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,
|
||||
|
|
@ -180,6 +185,9 @@ __all__ = [
|
|||
"Workflow",
|
||||
"WorkflowAppLog",
|
||||
"WorkflowAppLogCreatedFrom",
|
||||
"WorkflowComment",
|
||||
"WorkflowCommentMention",
|
||||
"WorkflowCommentReply",
|
||||
"WorkflowNodeExecutionModel",
|
||||
"WorkflowNodeExecutionOffload",
|
||||
"WorkflowNodeExecutionTriggeredFrom",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
"""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)
|
||||
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_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)
|
||||
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())
|
||||
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
|
||||
|
||||
@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
|
||||
participant_ids.update(reply.created_by for reply in self.replies)
|
||||
|
||||
# Add mentioned users
|
||||
participant_ids.update(mention.mentioned_user_id for mention in self.mentions)
|
||||
|
||||
# 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
|
||||
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, 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())
|
||||
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")
|
||||
|
||||
@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_reply_idx", "reply_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, 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):
|
||||
"""Get mentioned account."""
|
||||
return db.session.get(Account, self.mentioned_user_id)
|
||||
|
|
@ -335,7 +335,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))
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ dependencies = [
|
|||
"flask-orjson~=2.0.0",
|
||||
"flask-sqlalchemy~=3.1.1",
|
||||
"gevent~=25.9.1",
|
||||
"gevent-websocket~=0.10.1",
|
||||
"gmpy2~=2.2.1",
|
||||
"google-api-core==2.18.0",
|
||||
"google-api-python-client==2.90.0",
|
||||
|
|
@ -71,6 +72,7 @@ dependencies = [
|
|||
"pypdfium2==4.30.0",
|
||||
"python-docx~=1.1.0",
|
||||
"python-dotenv==1.0.1",
|
||||
"python-socketio~=5.13.0",
|
||||
"pyyaml~=6.0.1",
|
||||
"readabilipy~=0.3.0",
|
||||
"redis[hiredis]~=6.1.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,311 @@
|
|||
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.datetime_utils import naive_utc_now
|
||||
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 _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."""
|
||||
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, session: Session = None) -> WorkflowComment:
|
||||
"""Get a specific comment."""
|
||||
|
||||
def _get_comment(session: Session) -> WorkflowComment:
|
||||
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
|
||||
|
||||
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(
|
||||
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."""
|
||||
WorkflowCommentService._validate_content(content)
|
||||
|
||||
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,
|
||||
reply_id=None, # This is a comment mention, not reply mention
|
||||
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,
|
||||
position_x: Optional[float] = None,
|
||||
position_y: Optional[float] = None,
|
||||
mentioned_user_ids: Optional[list[str]] = None,
|
||||
) -> dict:
|
||||
"""Update a workflow comment."""
|
||||
WorkflowCommentService._validate_content(content)
|
||||
|
||||
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,
|
||||
)
|
||||
comment = session.scalar(stmt)
|
||||
|
||||
if not comment:
|
||||
raise NotFound("Comment not found")
|
||||
|
||||
# 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:
|
||||
"""Delete a workflow comment."""
|
||||
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")
|
||||
|
||||
# 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:
|
||||
"""Resolve a workflow comment."""
|
||||
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
|
||||
|
||||
comment.resolved = True
|
||||
comment.resolved_at = naive_utc_now()
|
||||
comment.resolved_by = user_id
|
||||
session.commit()
|
||||
|
||||
return comment
|
||||
|
||||
@staticmethod
|
||||
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."""
|
||||
WorkflowCommentService._validate_content(content)
|
||||
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
# Check if comment exists
|
||||
comment = session.get(WorkflowComment, comment_id)
|
||||
if not comment:
|
||||
raise NotFound("Comment not found")
|
||||
|
||||
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, mentioned_user_id=user_id
|
||||
)
|
||||
session.add(mention)
|
||||
|
||||
session.commit()
|
||||
|
||||
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
|
||||
) -> 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:
|
||||
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")
|
||||
|
||||
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, 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}
|
||||
|
||||
@staticmethod
|
||||
def delete_reply(reply_id: str, user_id: str) -> None:
|
||||
"""Delete a comment reply."""
|
||||
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")
|
||||
|
||||
# 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:
|
||||
"""Validate that a comment belongs to the specified tenant and app."""
|
||||
return WorkflowCommentService.get_comment(tenant_id, app_id, comment_id)
|
||||
|
|
@ -196,15 +196,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
|
||||
|
|
@ -242,6 +244,78 @@ 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 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 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,
|
||||
*,
|
||||
|
|
|
|||
4667
api/uv.lock
4667
api/uv.lock
File diff suppressed because it is too large
Load Diff
|
|
@ -14,6 +14,14 @@ server {
|
|||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /socket.io/ {
|
||||
proxy_pass http://api:5001;
|
||||
include proxy.conf;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location /v1 {
|
||||
proxy_pass http://api:5001;
|
||||
include proxy.conf;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
# proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout ${NGINX_PROXY_READ_TIMEOUT};
|
||||
proxy_send_timeout ${NGINX_PROXY_SEND_TIMEOUT};
|
||||
|
|
|
|||
|
|
@ -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/app-card'
|
||||
|
|
@ -19,6 +19,8 @@ import { asyncRunSafe } from '@/utils'
|
|||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import type { IAppCardProps } from '@/app/components/app/overview/app-card'
|
||||
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<ICardViewProps> = ({ 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<App>(
|
||||
updateAppSiteStatus({
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type AvatarProps = {
|
|||
className?: string
|
||||
textClassName?: string
|
||||
onError?: (x: boolean) => void
|
||||
backgroundColor?: string
|
||||
}
|
||||
const Avatar = ({
|
||||
name,
|
||||
|
|
@ -17,9 +18,18 @@ const Avatar = ({
|
|||
className,
|
||||
textClassName,
|
||||
onError,
|
||||
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 = () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
|
||||
type User = {
|
||||
id: string
|
||||
name: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
type UserAvatarListProps = {
|
||||
users: User[]
|
||||
maxVisible?: number
|
||||
size?: number
|
||||
className?: string
|
||||
showCount?: boolean
|
||||
}
|
||||
|
||||
export const UserAvatarList: FC<UserAvatarListProps> = memo(({
|
||||
users,
|
||||
maxVisible = 3,
|
||||
size = 24,
|
||||
className = '',
|
||||
showCount = true,
|
||||
}) => {
|
||||
const { userProfile } = useAppContext()
|
||||
if (!users.length) return null
|
||||
|
||||
const shouldShowCount = showCount && users.length > maxVisible
|
||||
const actualMaxVisible = shouldShowCount ? Math.max(1, maxVisible - 1) : maxVisible
|
||||
const visibleUsers = users.slice(0, actualMaxVisible)
|
||||
const remainingCount = users.length - actualMaxVisible
|
||||
|
||||
const currentUserId = userProfile?.id
|
||||
|
||||
return (
|
||||
<div className={`flex items-center -space-x-1 ${className}`}>
|
||||
{visibleUsers.map((user, index) => {
|
||||
const isCurrentUser = user.id === currentUserId
|
||||
const userColor = isCurrentUser ? undefined : getUserColor(user.id)
|
||||
return (
|
||||
<div
|
||||
key={`${user.id}-${index}`}
|
||||
className='relative'
|
||||
style={{ zIndex: visibleUsers.length - index }}
|
||||
>
|
||||
<Avatar
|
||||
name={user.name}
|
||||
avatar={user.avatar_url || null}
|
||||
size={size}
|
||||
className='ring-2 ring-white'
|
||||
backgroundColor={userColor}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
)}
|
||||
{shouldShowCount && remainingCount > 0 && (
|
||||
<div
|
||||
className={'flex items-center justify-center rounded-full bg-components-panel-on-panel-item-bg text-[10px] leading-none text-text-secondary ring-2 ring-white'}
|
||||
style={{
|
||||
zIndex: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
>
|
||||
+{remainingCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
UserAvatarList.displayName = 'UserAvatarList'
|
||||
|
|
@ -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<AppSSO>
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { Features as FeaturesData } from '@/app/components/base/features/types'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import { WorkflowWithInnerContext } from '@/app/components/workflow'
|
||||
import type { WorkflowProps } from '@/app/components/workflow'
|
||||
import WorkflowChildren from './workflow-children'
|
||||
import UserCursors from '@/app/components/workflow/collaboration/components/user-cursors'
|
||||
|
||||
import {
|
||||
useAvailableNodesMetaData,
|
||||
useConfigsMap,
|
||||
|
|
@ -18,7 +26,12 @@ import {
|
|||
useWorkflowRun,
|
||||
useWorkflowStartRun,
|
||||
} from '../hooks'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
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'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { useReactFlow, useStoreApi } from 'reactflow'
|
||||
|
||||
type WorkflowMainProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
|
||||
const WorkflowMain = ({
|
||||
|
|
@ -28,6 +41,31 @@ const WorkflowMain = ({
|
|||
}: WorkflowMainProps) => {
|
||||
const featuresStore = useFeaturesStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const appId = useStore(s => s.appId)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const reactFlow = useReactFlow()
|
||||
|
||||
const store = useStoreApi()
|
||||
const { startCursorTracking, stopCursorTracking, onlineUsers, cursors, isConnected } = useCollaboration(appId, store)
|
||||
const [myUserId, setMyUserId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected)
|
||||
setMyUserId('current-user')
|
||||
}, [isConnected])
|
||||
|
||||
const filteredCursors = Object.fromEntries(
|
||||
Object.entries(cursors).filter(([userId]) => userId !== myUserId),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current)
|
||||
startCursorTracking(containerRef as React.RefObject<HTMLElement>, reactFlow)
|
||||
|
||||
return () => {
|
||||
stopCursorTracking()
|
||||
}
|
||||
}, [startCursorTracking, stopCursorTracking, reactFlow])
|
||||
|
||||
const handleWorkflowDataUpdate = useCallback((payload: any) => {
|
||||
const {
|
||||
|
|
@ -38,7 +76,33 @@ const WorkflowMain = ({
|
|||
if (features && featuresStore) {
|
||||
const { setFeatures } = featuresStore.getState()
|
||||
|
||||
setFeatures(features)
|
||||
const transformedFeatures: FeaturesData = {
|
||||
file: {
|
||||
image: {
|
||||
enabled: !!features.file_upload?.image?.enabled,
|
||||
number_limits: features.file_upload?.image?.number_limits || 3,
|
||||
transfer_methods: features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
|
||||
},
|
||||
enabled: !!(features.file_upload?.enabled || features.file_upload?.image?.enabled),
|
||||
allowed_file_types: features.file_upload?.allowed_file_types || [SupportUploadFileTypes.image],
|
||||
allowed_file_extensions: features.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
|
||||
allowed_file_upload_methods: features.file_upload?.allowed_file_upload_methods || features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
|
||||
number_limits: features.file_upload?.number_limits || features.file_upload?.image?.number_limits || 3,
|
||||
},
|
||||
opening: {
|
||||
enabled: !!features.opening_statement,
|
||||
opening_statement: features.opening_statement,
|
||||
suggested_questions: features.suggested_questions,
|
||||
},
|
||||
suggested: features.suggested_questions_after_answer || { enabled: false },
|
||||
speech2text: features.speech_to_text || { enabled: false },
|
||||
text2speech: features.text_to_speech || { enabled: false },
|
||||
citation: features.retriever_resource || { enabled: false },
|
||||
moderation: features.sensitive_word_avoidance || { enabled: false },
|
||||
annotationReply: features.annotation_reply || { enabled: false },
|
||||
}
|
||||
|
||||
setFeatures(transformedFeatures)
|
||||
}
|
||||
if (conversation_variables) {
|
||||
const { setConversationVariables } = workflowStore.getState()
|
||||
|
|
@ -55,6 +119,7 @@ const WorkflowMain = ({
|
|||
syncWorkflowDraftWhenPageClose,
|
||||
} = useNodesSyncDraft()
|
||||
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
|
||||
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
|
||||
const {
|
||||
handleBackupDraft,
|
||||
handleLoadBackupDraft,
|
||||
|
|
@ -62,6 +127,63 @@ const WorkflowMain = ({
|
|||
handleRun,
|
||||
handleStopRun,
|
||||
} = useWorkflowRun()
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId) return
|
||||
|
||||
const unsubscribe = collaborationManager.onVarsAndFeaturesUpdate(async (update: any) => {
|
||||
try {
|
||||
const response = await fetchWorkflowDraft(`/apps/${appId}/workflows/draft`)
|
||||
handleWorkflowDataUpdate(response)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('workflow vars and features update failed:', error)
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
const unsubscribe = collaborationManager.onSyncRequest(() => {
|
||||
console.log('Leader received sync request, performing sync')
|
||||
doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appId, doSyncWorkflowDraft])
|
||||
const {
|
||||
handleStartWorkflowRun,
|
||||
handleWorkflowStartRunInChatflow,
|
||||
|
|
@ -75,6 +197,7 @@ const WorkflowMain = ({
|
|||
} = useDSL()
|
||||
|
||||
const configsMap = useConfigsMap()
|
||||
|
||||
const { fetchInspectVars } = useSetWorkflowVarsWithValue({
|
||||
...configsMap,
|
||||
})
|
||||
|
|
@ -164,15 +287,21 @@ const WorkflowMain = ({
|
|||
])
|
||||
|
||||
return (
|
||||
<WorkflowWithInnerContext
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
viewport={viewport}
|
||||
onWorkflowDataUpdate={handleWorkflowDataUpdate}
|
||||
hooksStore={hooksStore as any}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative h-full w-full"
|
||||
>
|
||||
<WorkflowChildren />
|
||||
</WorkflowWithInnerContext>
|
||||
<WorkflowWithInnerContext
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
viewport={viewport}
|
||||
onWorkflowDataUpdate={handleWorkflowDataUpdate}
|
||||
hooksStore={hooksStore as any}
|
||||
>
|
||||
<WorkflowChildren />
|
||||
</WorkflowWithInnerContext>
|
||||
<UserCursors cursors={filteredCursors} myUserId={myUserId} onlineUsers={onlineUsers} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { useStore } from '@/app/components/workflow/store'
|
|||
import {
|
||||
useIsChatMode,
|
||||
} from '../hooks'
|
||||
import VersionHistoryPanel from '@/app/components/workflow/panel/version-history-panel'
|
||||
import CommentsPanel from '@/app/components/workflow/panel/comments-panel'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import type { PanelProps } from '@/app/components/workflow/panel'
|
||||
import Panel from '@/app/components/workflow/panel'
|
||||
|
|
@ -67,6 +69,8 @@ const WorkflowPanelOnRight = () => {
|
|||
const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel)
|
||||
const showChatVariablePanel = useStore(s => s.showChatVariablePanel)
|
||||
const showGlobalVariablePanel = useStore(s => s.showGlobalVariablePanel)
|
||||
const showWorkflowVersionHistoryPanel = useStore(s => s.showWorkflowVersionHistoryPanel)
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -100,6 +104,12 @@ const WorkflowPanelOnRight = () => {
|
|||
<GlobalVariablePanel />
|
||||
)
|
||||
}
|
||||
{
|
||||
showWorkflowVersionHistoryPanel && (
|
||||
<VersionHistoryPanel/>
|
||||
)
|
||||
}
|
||||
{controlMode === 'comment' && <CommentsPanel />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -85,6 +86,7 @@ export const useNodesSyncDraft = () => {
|
|||
environment_variables: environmentVariables,
|
||||
conversation_variables: conversationVariables,
|
||||
hash: syncWorkflowDraftHash,
|
||||
_is_collaborative: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -93,9 +95,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),
|
||||
|
|
@ -110,9 +123,23 @@ export const useNodesSyncDraft = () => {
|
|||
onError?: () => void
|
||||
onSettled?: () => void
|
||||
},
|
||||
forceUpload?: boolean,
|
||||
) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
// Check leader status at sync time
|
||||
const currentIsLeader = collaborationManager.getIsLeader()
|
||||
|
||||
// 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(forceUpload ? 'Force uploading workflow draft' : 'Leader performing workflow draft sync')
|
||||
const postParams = getPostParams()
|
||||
|
||||
if (postParams) {
|
||||
|
|
@ -120,17 +147,31 @@ 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')
|
||||
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)
|
||||
if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError) {
|
||||
console.error('draft_workflow_not_sync', err)
|
||||
handleRefreshWorkflowDraft()
|
||||
}
|
||||
})
|
||||
}
|
||||
callback?.onError && callback.onError()
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store'
|
||||
import { createWorkflowSlice } from './store/workflow/workflow-slice'
|
||||
import WorkflowAppMain from './components/workflow-main'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
|
||||
const WorkflowAppWithAdditionalContext = () => {
|
||||
const {
|
||||
|
|
@ -35,15 +36,20 @@ const WorkflowAppWithAdditionalContext = () => {
|
|||
const { isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
|
||||
|
||||
const nodesData = useMemo(() => {
|
||||
if (data)
|
||||
return initialNodes(data.graph.nodes, data.graph.edges)
|
||||
|
||||
if (data) {
|
||||
const processedNodes = initialNodes(data.graph.nodes, data.graph.edges)
|
||||
collaborationManager.setNodes([], processedNodes)
|
||||
return processedNodes
|
||||
}
|
||||
return []
|
||||
}, [data])
|
||||
const edgesData = useMemo(() => {
|
||||
if (data)
|
||||
return initialEdges(data.graph.edges, data.graph.nodes)
|
||||
|
||||
const edgesData = useMemo(() => {
|
||||
if (data) {
|
||||
const processedEdges = initialEdges(data.graph.edges, data.graph.nodes)
|
||||
collaborationManager.setEdges([], processedEdges)
|
||||
return processedEdges
|
||||
}
|
||||
return []
|
||||
}, [data])
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import type { FC } from 'react'
|
||||
import { useViewport } from 'reactflow'
|
||||
import type { CursorPosition, OnlineUser } from '@/app/components/workflow/collaboration/types'
|
||||
import { getUserColor } from '../utils/user-color'
|
||||
|
||||
type UserCursorsProps = {
|
||||
cursors: Record<string, CursorPosition>
|
||||
myUserId: string | null
|
||||
onlineUsers: OnlineUser[]
|
||||
}
|
||||
|
||||
const UserCursors: FC<UserCursorsProps> = ({
|
||||
cursors,
|
||||
myUserId,
|
||||
onlineUsers,
|
||||
}) => {
|
||||
const viewport = useViewport()
|
||||
|
||||
const convertToScreenCoordinates = (cursor: CursorPosition) => {
|
||||
// Convert world coordinates to screen coordinates using current viewport
|
||||
const screenX = cursor.x * viewport.zoom + viewport.x
|
||||
const screenY = cursor.y * viewport.zoom + viewport.y
|
||||
|
||||
return { x: screenX, y: screenY }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{Object.entries(cursors || {}).map(([userId, cursor]) => {
|
||||
if (userId === myUserId)
|
||||
return null
|
||||
|
||||
const userInfo = onlineUsers.find(user => user.user_id === userId)
|
||||
const userName = userInfo?.username || `User ${userId.slice(-4)}`
|
||||
const userColor = getUserColor(userId)
|
||||
const screenPos = convertToScreenCoordinates(cursor)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={userId}
|
||||
className="pointer-events-none absolute z-[10000] transition-all duration-150 ease-out"
|
||||
style={{
|
||||
left: screenPos.x,
|
||||
top: screenPos.y,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="drop-shadow-md"
|
||||
>
|
||||
<path
|
||||
d="M5 3L5 15L8 11.5L11 16L13 15L10 10.5L14 10.5L5 3Z"
|
||||
fill={userColor}
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="absolute left-4 top-4 max-w-[120px] overflow-hidden text-ellipsis whitespace-nowrap rounded px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm"
|
||||
style={{
|
||||
backgroundColor: userColor,
|
||||
}}
|
||||
>
|
||||
{userName}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserCursors
|
||||
|
|
@ -0,0 +1,856 @@
|
|||
import { LoroDoc, UndoManager } from 'loro-crdt'
|
||||
import { isEqual } from 'lodash-es'
|
||||
import { webSocketClient } from './websocket-manager'
|
||||
import { CRDTProvider } from './crdt-provider'
|
||||
import { EventEmitter } from './event-emitter'
|
||||
import type { Edge, Node } from '../../types'
|
||||
import type {
|
||||
CollaborationState,
|
||||
CursorPosition,
|
||||
NodePanelPresenceMap,
|
||||
NodePanelPresenceUser,
|
||||
OnlineUser,
|
||||
} from '../types/collaboration'
|
||||
|
||||
type NodePanelPresenceEventData = {
|
||||
nodeId: string
|
||||
action: 'open' | 'close'
|
||||
user: NodePanelPresenceUser
|
||||
clientId: string
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
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
|
||||
private eventEmitter = new EventEmitter()
|
||||
private currentAppId: string | null = null
|
||||
private reactFlowStore: any = null
|
||||
private isLeader = false
|
||||
private leaderId: string | null = null
|
||||
private cursors: Record<string, CursorPosition> = {}
|
||||
private nodePanelPresence: NodePanelPresenceMap = {}
|
||||
private activeConnections = new Set<string>()
|
||||
private isUndoRedoInProgress = false
|
||||
|
||||
private getNodePanelPresenceSnapshot(): NodePanelPresenceMap {
|
||||
const snapshot: NodePanelPresenceMap = {}
|
||||
Object.entries(this.nodePanelPresence).forEach(([nodeId, viewers]) => {
|
||||
snapshot[nodeId] = { ...viewers }
|
||||
})
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private applyNodePanelPresenceUpdate(update: NodePanelPresenceEventData): void {
|
||||
const { nodeId, action, clientId, user, timestamp } = update
|
||||
|
||||
if (action === 'open') {
|
||||
// ensure a client only appears on a single node at a time
|
||||
Object.entries(this.nodePanelPresence).forEach(([id, viewers]) => {
|
||||
if (viewers[clientId]) {
|
||||
delete viewers[clientId]
|
||||
if (Object.keys(viewers).length === 0)
|
||||
delete this.nodePanelPresence[id]
|
||||
}
|
||||
})
|
||||
|
||||
if (!this.nodePanelPresence[nodeId])
|
||||
this.nodePanelPresence[nodeId] = {}
|
||||
|
||||
this.nodePanelPresence[nodeId][clientId] = {
|
||||
...user,
|
||||
clientId,
|
||||
timestamp: timestamp || Date.now(),
|
||||
}
|
||||
}
|
||||
else {
|
||||
const viewers = this.nodePanelPresence[nodeId]
|
||||
if (viewers) {
|
||||
delete viewers[clientId]
|
||||
if (Object.keys(viewers).length === 0)
|
||||
delete this.nodePanelPresence[nodeId]
|
||||
}
|
||||
}
|
||||
|
||||
this.eventEmitter.emit('nodePanelPresence', this.getNodePanelPresenceSnapshot())
|
||||
}
|
||||
|
||||
private cleanupNodePanelPresence(activeClientIds: Set<string>, activeUserIds: Set<string>): void {
|
||||
let hasChanges = false
|
||||
|
||||
Object.entries(this.nodePanelPresence).forEach(([nodeId, viewers]) => {
|
||||
Object.keys(viewers).forEach((clientId) => {
|
||||
const viewer = viewers[clientId]
|
||||
const clientActive = activeClientIds.has(clientId)
|
||||
const userActive = viewer?.userId ? activeUserIds.has(viewer.userId) : false
|
||||
|
||||
if (!clientActive && !userActive) {
|
||||
delete viewers[clientId]
|
||||
hasChanges = true
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(viewers).length === 0)
|
||||
delete this.nodePanelPresence[nodeId]
|
||||
})
|
||||
|
||||
if (hasChanges)
|
||||
this.eventEmitter.emit('nodePanelPresence', this.getNodePanelPresenceSnapshot())
|
||||
}
|
||||
|
||||
init = (appId: string, reactFlowStore: any): void => {
|
||||
if (!reactFlowStore) {
|
||||
console.warn('CollaborationManager.init called without reactFlowStore, deferring to connect()')
|
||||
return
|
||||
}
|
||||
this.connect(appId, reactFlowStore)
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
this.doc.commit()
|
||||
}
|
||||
|
||||
destroy = (): void => {
|
||||
this.disconnect()
|
||||
}
|
||||
|
||||
async connect(appId: string, reactFlowStore?: any): Promise<string> {
|
||||
const connectionId = Math.random().toString(36).substring(2, 11)
|
||||
|
||||
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
|
||||
// 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')
|
||||
|
||||
// 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()
|
||||
|
||||
// Force user_connect if already connected
|
||||
if (socket.connected)
|
||||
socket.emit('user_connect', { workflow_id: appId })
|
||||
|
||||
return connectionId
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
this.provider?.destroy()
|
||||
this.undoManager = null
|
||||
this.doc = null
|
||||
this.provider = null
|
||||
this.nodesMap = null
|
||||
this.edgesMap = null
|
||||
this.currentAppId = null
|
||||
this.reactFlowStore = null
|
||||
this.cursors = {}
|
||||
this.nodePanelPresence = {}
|
||||
this.isUndoRedoInProgress = false
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.currentAppId ? webSocketClient.isConnected(this.currentAppId) : false
|
||||
}
|
||||
|
||||
getNodes(): Node[] {
|
||||
return this.nodesMap ? Array.from(this.nodesMap.values()) : []
|
||||
}
|
||||
|
||||
getEdges(): Edge[] {
|
||||
return this.edgesMap ? Array.from(this.edgesMap.values()) : []
|
||||
}
|
||||
|
||||
emitCursorMove(position: CursorPosition): void {
|
||||
if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return
|
||||
|
||||
const socket = webSocketClient.getSocket(this.currentAppId)
|
||||
if (socket) {
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'mouseMove',
|
||||
userId: socket.id,
|
||||
data: { x: position.x, y: position.y },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emitNodePanelPresence(nodeId: string, isOpen: boolean, user: NodePanelPresenceUser): void {
|
||||
if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return
|
||||
|
||||
const socket = webSocketClient.getSocket(this.currentAppId)
|
||||
if (!socket || !nodeId || !user?.userId) return
|
||||
|
||||
const payload: NodePanelPresenceEventData = {
|
||||
nodeId,
|
||||
action: isOpen ? 'open' : 'close',
|
||||
user,
|
||||
clientId: socket.id as string,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'nodePanelPresence',
|
||||
data: payload,
|
||||
timestamp: payload.timestamp,
|
||||
})
|
||||
|
||||
this.applyNodePanelPresenceUpdate(payload)
|
||||
}
|
||||
|
||||
onSyncRequest(callback: () => void): () => void {
|
||||
return this.eventEmitter.on('syncRequest', callback)
|
||||
}
|
||||
|
||||
onStateChange(callback: (state: Partial<CollaborationState>) => void): () => void {
|
||||
return this.eventEmitter.on('stateChange', callback)
|
||||
}
|
||||
|
||||
onCursorUpdate(callback: (cursors: Record<string, CursorPosition>) => void): () => void {
|
||||
return this.eventEmitter.on('cursors', callback)
|
||||
}
|
||||
|
||||
onOnlineUsersUpdate(callback: (users: OnlineUser[]) => void): () => void {
|
||||
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)
|
||||
}
|
||||
|
||||
onAppStateUpdate(callback: (update: any) => void): () => void {
|
||||
return this.eventEmitter.on('appStateUpdate', callback)
|
||||
}
|
||||
|
||||
onMcpServerUpdate(callback: (update: any) => void): () => void {
|
||||
return this.eventEmitter.on('mcpServerUpdate', callback)
|
||||
}
|
||||
|
||||
onNodePanelPresenceUpdate(callback: (presence: NodePanelPresenceMap) => void): () => void {
|
||||
const off = this.eventEmitter.on('nodePanelPresence', callback)
|
||||
callback(this.getNodePanelPresenceSnapshot())
|
||||
return off
|
||||
}
|
||||
|
||||
onLeaderChange(callback: (isLeader: boolean) => void): () => void {
|
||||
return this.eventEmitter.on('leaderChange', callback)
|
||||
}
|
||||
|
||||
onCommentsUpdate(callback: (update: { appId: string; timestamp: number }) => void): () => void {
|
||||
return this.eventEmitter.on('commentsUpdate', callback)
|
||||
}
|
||||
|
||||
emitCommentsUpdate(appId: string): void {
|
||||
if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return
|
||||
|
||||
const socket = webSocketClient.getSocket(this.currentAppId)
|
||||
if (socket) {
|
||||
console.log('Emitting Comments update event')
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'commentsUpdate',
|
||||
data: { appId, timestamp: Date.now() },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onUndoRedoStateChange(callback: (state: { canUndo: boolean; canRedo: boolean }) => void): () => void {
|
||||
return this.eventEmitter.on('undoRedoStateChange', callback)
|
||||
}
|
||||
|
||||
getLeaderId(): string | null {
|
||||
return this.leaderId
|
||||
}
|
||||
|
||||
getIsLeader(): boolean {
|
||||
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)
|
||||
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 || !this.doc) return
|
||||
|
||||
const oldNodesMap = new Map(oldNodes.map(node => [node.id, node]))
|
||||
const newNodesMap = new Map(newNodes.map(node => [node.id, node]))
|
||||
const shouldSyncDataKey = (key: string) => !key.startsWith('_') && key !== 'selected'
|
||||
|
||||
// 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) {
|
||||
// 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 (shouldSyncDataKey(key) && value !== undefined)
|
||||
nodeData.data[key] = JSON.parse(JSON.stringify(value))
|
||||
})
|
||||
|
||||
this.nodesMap.set(newNode.id, nodeData)
|
||||
}
|
||||
else {
|
||||
// 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 (shouldSyncDataKey(key)) {
|
||||
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 (shouldSyncDataKey(key) && !(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 (shouldSyncDataKey(key) && value !== undefined)
|
||||
nodeData.data[key] = JSON.parse(JSON.stringify(value))
|
||||
})
|
||||
|
||||
this.nodesMap.set(newNode.id, nodeData)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private syncEdges(oldEdges: Edge[], newEdges: Edge[]): void {
|
||||
if (!this.edgesMap) return
|
||||
|
||||
const oldEdgesMap = new Map(oldEdges.map(edge => [edge.id, edge]))
|
||||
const newEdgesMap = new Map(newEdges.map(edge => [edge.id, edge]))
|
||||
|
||||
oldEdges.forEach((oldEdge) => {
|
||||
if (!newEdgesMap.has(oldEdge.id))
|
||||
this.edgesMap.delete(oldEdge.id)
|
||||
})
|
||||
|
||||
newEdges.forEach((newEdge) => {
|
||||
const oldEdge = oldEdgesMap.get(newEdge.id)
|
||||
if (!oldEdge) {
|
||||
const clonedEdge = JSON.parse(JSON.stringify(newEdge))
|
||||
this.edgesMap.set(newEdge.id, clonedEdge)
|
||||
}
|
||||
else if (!isEqual(oldEdge, newEdge)) {
|
||||
const clonedEdge = JSON.parse(JSON.stringify(newEdge))
|
||||
this.edgesMap.set(newEdge.id, clonedEdge)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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(() => {
|
||||
// Get ReactFlow's native setters, not the collaborative ones
|
||||
const state = this.reactFlowStore.getState()
|
||||
const updatedNodes = Array.from(this.nodesMap.values())
|
||||
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(() => {
|
||||
// Get ReactFlow's native setters, not the collaborative ones
|
||||
const state = this.reactFlowStore.getState()
|
||||
const updatedEdges = Array.from(this.edgesMap.values())
|
||||
console.log('Updating React edges from subscription')
|
||||
|
||||
// Call ReactFlow's native setter directly to avoid triggering collaboration
|
||||
state.setEdges(updatedEdges)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private setupSocketEventListeners(socket: any): void {
|
||||
console.log('Setting up socket event listeners for collaboration')
|
||||
|
||||
socket.on('collaboration_update', (update: any) => {
|
||||
if (update.type === 'mouseMove') {
|
||||
// Update cursor state for this user
|
||||
this.cursors[update.userId] = {
|
||||
x: update.data.x,
|
||||
y: update.data.y,
|
||||
userId: update.userId,
|
||||
timestamp: update.timestamp,
|
||||
}
|
||||
|
||||
this.eventEmitter.emit('cursors', { ...this.cursors })
|
||||
}
|
||||
else if (update.type === 'varsAndFeaturesUpdate') {
|
||||
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 === 'mcpServerUpdate') {
|
||||
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 === 'commentsUpdate') {
|
||||
console.log('Processing commentsUpdate event:', update)
|
||||
this.eventEmitter.emit('commentsUpdate', update.data)
|
||||
}
|
||||
else if (update.type === 'nodePanelPresence') {
|
||||
console.log('Processing nodePanelPresence event:', update)
|
||||
this.applyNodePanelPresenceUpdate(update.data as NodePanelPresenceEventData)
|
||||
}
|
||||
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 }) => {
|
||||
try {
|
||||
if (!data || !Array.isArray(data.users)) {
|
||||
console.warn('Invalid online_users data structure:', data)
|
||||
return
|
||||
}
|
||||
|
||||
const onlineUserIds = new Set(data.users.map((user: OnlineUser) => user.user_id))
|
||||
const onlineClientIds = new Set(
|
||||
data.users
|
||||
.map((user: OnlineUser) => user.sid)
|
||||
.filter((sid): sid is string => typeof sid === 'string' && sid.length > 0),
|
||||
)
|
||||
|
||||
// Remove cursors for offline users
|
||||
Object.keys(this.cursors).forEach((userId) => {
|
||||
if (!onlineUserIds.has(userId))
|
||||
delete this.cursors[userId]
|
||||
})
|
||||
|
||||
this.cleanupNodePanelPresence(onlineClientIds, onlineUserIds)
|
||||
|
||||
// Update leader information
|
||||
if (data.leader && typeof data.leader === 'string')
|
||||
this.leaderId = data.leader
|
||||
|
||||
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
|
||||
|
||||
if (wasLeader !== this.isLeader)
|
||||
this.eventEmitter.emit('leaderChange', this.isLeader)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error processing status update:', error)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('status', (data: { isLeader: boolean }) => {
|
||||
if (this.isLeader !== data.isLeader) {
|
||||
this.isLeader = data.isLeader
|
||||
console.log(`Collaboration: I am now the ${this.isLeader ? 'Leader' : 'Follower'}.`)
|
||||
this.eventEmitter.emit('leaderChange', this.isLeader)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('status', (data: { isLeader: boolean }) => {
|
||||
if (this.isLeader !== data.isLeader) {
|
||||
this.isLeader = data.isLeader
|
||||
console.log(`Collaboration: I am now the ${this.isLeader ? 'Leader' : 'Follower'}.`)
|
||||
this.eventEmitter.emit('leaderChange', this.isLeader)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('WebSocket connected successfully')
|
||||
this.eventEmitter.emit('stateChange', { isConnected: true })
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const collaborationManager = new CollaborationManager()
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { LoroDoc } from 'loro-crdt'
|
||||
import type { Socket } from 'socket.io-client'
|
||||
|
||||
export class CRDTProvider {
|
||||
private doc: LoroDoc
|
||||
private socket: Socket
|
||||
|
||||
constructor(socket: Socket, doc: LoroDoc) {
|
||||
this.socket = socket
|
||||
this.doc = doc
|
||||
this.setupEventListeners()
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
this.doc.subscribe((event: any) => {
|
||||
if (event.by === 'local') {
|
||||
const update = this.doc.export({ mode: 'update' })
|
||||
this.socket.emit('graph_event', update)
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('graph_update', (updateData: Uint8Array) => {
|
||||
try {
|
||||
const data = new Uint8Array(updateData)
|
||||
this.doc.import(data)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error importing graph update:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.socket.off('graph_update')
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
export type EventHandler<T = any> = (data: T) => void
|
||||
|
||||
export class EventEmitter {
|
||||
private events: Map<string, Set<EventHandler>> = new Map()
|
||||
|
||||
on<T = any>(event: string, handler: EventHandler<T>): () => void {
|
||||
if (!this.events.has(event))
|
||||
this.events.set(event, new Set())
|
||||
|
||||
this.events.get(event)!.add(handler)
|
||||
|
||||
return () => this.off(event, handler)
|
||||
}
|
||||
|
||||
off<T = any>(event: string, handler?: EventHandler<T>): void {
|
||||
if (!this.events.has(event)) return
|
||||
|
||||
const handlers = this.events.get(event)!
|
||||
if (handler)
|
||||
handlers.delete(handler)
|
||||
else
|
||||
handlers.clear()
|
||||
|
||||
if (handlers.size === 0)
|
||||
this.events.delete(event)
|
||||
}
|
||||
|
||||
emit<T = any>(event: string, data: T): void {
|
||||
if (!this.events.has(event)) return
|
||||
|
||||
const handlers = this.events.get(event)!
|
||||
handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(data)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`Error in event handler for ${event}:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
removeAllListeners(): void {
|
||||
this.events.clear()
|
||||
}
|
||||
|
||||
getListenerCount(event: string): number {
|
||||
return this.events.get(event)?.size || 0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import type { Socket } from 'socket.io-client'
|
||||
import { io } from 'socket.io-client'
|
||||
import type { DebugInfo, WebSocketConfig } from '../types/websocket'
|
||||
|
||||
export class WebSocketClient {
|
||||
private connections: Map<string, Socket> = new Map()
|
||||
private connecting: Set<string> = new Set()
|
||||
private config: WebSocketConfig
|
||||
|
||||
constructor(config: WebSocketConfig = {}) {
|
||||
const inferUrl = () => {
|
||||
if (typeof window === 'undefined')
|
||||
return 'ws://localhost:5001'
|
||||
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${scheme}//${window.location.host}`
|
||||
}
|
||||
this.config = {
|
||||
url: config.url || process.env.NEXT_PUBLIC_SOCKET_URL || inferUrl(),
|
||||
transports: config.transports || ['websocket'],
|
||||
withCredentials: config.withCredentials !== false,
|
||||
...config,
|
||||
}
|
||||
}
|
||||
|
||||
connect(appId: string): Socket {
|
||||
const existingSocket = this.connections.get(appId)
|
||||
if (existingSocket?.connected)
|
||||
return existingSocket
|
||||
|
||||
if (this.connecting.has(appId)) {
|
||||
const pendingSocket = this.connections.get(appId)
|
||||
if (pendingSocket)
|
||||
return pendingSocket
|
||||
}
|
||||
|
||||
if (existingSocket && !existingSocket.connected) {
|
||||
existingSocket.disconnect()
|
||||
this.connections.delete(appId)
|
||||
}
|
||||
|
||||
this.connecting.add(appId)
|
||||
|
||||
const authToken = localStorage.getItem('console_token')
|
||||
const socket = io(this.config.url!, {
|
||||
path: '/socket.io',
|
||||
transports: this.config.transports,
|
||||
auth: { token: authToken },
|
||||
withCredentials: this.config.withCredentials,
|
||||
})
|
||||
|
||||
this.connections.set(appId, socket)
|
||||
this.setupBaseEventListeners(socket, appId)
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
disconnect(appId?: string): void {
|
||||
if (appId) {
|
||||
const socket = this.connections.get(appId)
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
this.connections.delete(appId)
|
||||
this.connecting.delete(appId)
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.connections.forEach(socket => socket.disconnect())
|
||||
this.connections.clear()
|
||||
this.connecting.clear()
|
||||
}
|
||||
}
|
||||
|
||||
getSocket(appId: string): Socket | null {
|
||||
return this.connections.get(appId) || null
|
||||
}
|
||||
|
||||
isConnected(appId: string): boolean {
|
||||
return this.connections.get(appId)?.connected || false
|
||||
}
|
||||
|
||||
getConnectedApps(): string[] {
|
||||
const connectedApps: string[] = []
|
||||
this.connections.forEach((socket, appId) => {
|
||||
if (socket.connected)
|
||||
connectedApps.push(appId)
|
||||
})
|
||||
return connectedApps
|
||||
}
|
||||
|
||||
getDebugInfo(): DebugInfo {
|
||||
const info: DebugInfo = {}
|
||||
this.connections.forEach((socket, appId) => {
|
||||
info[appId] = {
|
||||
connected: socket.connected,
|
||||
connecting: this.connecting.has(appId),
|
||||
socketId: socket.id,
|
||||
}
|
||||
})
|
||||
return info
|
||||
}
|
||||
|
||||
private setupBaseEventListeners(socket: Socket, appId: string): void {
|
||||
socket.on('connect', () => {
|
||||
this.connecting.delete(appId)
|
||||
socket.emit('user_connect', { workflow_id: appId })
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
this.connecting.delete(appId)
|
||||
})
|
||||
|
||||
socket.on('connect_error', () => {
|
||||
this.connecting.delete(appId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const webSocketClient = new WebSocketClient()
|
||||
|
||||
export const fetchAppsOnlineUsers = async (appIds: string[]) => {
|
||||
const response = await fetch(`/api/online-users?${new URLSearchParams({
|
||||
app_ids: appIds.join(','),
|
||||
})}`)
|
||||
return response.json()
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ReactFlowInstance } from 'reactflow'
|
||||
import { collaborationManager } from '../core/collaboration-manager'
|
||||
import { CursorService } from '../services/cursor-service'
|
||||
import type { CollaborationState } from '../types/collaboration'
|
||||
|
||||
export function useCollaboration(appId: string, reactFlowStore?: any) {
|
||||
const [state, setState] = useState<Partial<CollaborationState & { isLeader: boolean }>>({
|
||||
isConnected: false,
|
||||
onlineUsers: [],
|
||||
cursors: {},
|
||||
nodePanelPresence: {},
|
||||
isLeader: false,
|
||||
})
|
||||
|
||||
const cursorServiceRef = useRef<CursorService | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId) return
|
||||
|
||||
let connectionId: string | null = null
|
||||
|
||||
if (!cursorServiceRef.current)
|
||||
cursorServiceRef.current = new CursorService()
|
||||
|
||||
const initCollaboration = async () => {
|
||||
connectionId = await collaborationManager.connect(appId, reactFlowStore)
|
||||
setState((prev: any) => ({ ...prev, appId, isConnected: collaborationManager.isConnected() }))
|
||||
}
|
||||
|
||||
initCollaboration()
|
||||
|
||||
const unsubscribeStateChange = collaborationManager.onStateChange((newState: any) => {
|
||||
console.log('Collaboration state change:', newState)
|
||||
setState((prev: any) => ({ ...prev, ...newState }))
|
||||
})
|
||||
|
||||
const unsubscribeCursors = collaborationManager.onCursorUpdate((cursors: any) => {
|
||||
setState((prev: any) => ({ ...prev, cursors }))
|
||||
})
|
||||
|
||||
const unsubscribeUsers = collaborationManager.onOnlineUsersUpdate((users: any) => {
|
||||
console.log('Online users update:', users)
|
||||
setState((prev: any) => ({ ...prev, onlineUsers: users }))
|
||||
})
|
||||
|
||||
const unsubscribeNodePanelPresence = collaborationManager.onNodePanelPresenceUpdate((presence) => {
|
||||
setState((prev: any) => ({ ...prev, nodePanelPresence: presence }))
|
||||
})
|
||||
|
||||
const unsubscribeLeaderChange = collaborationManager.onLeaderChange((isLeader: boolean) => {
|
||||
console.log('Leader status changed:', isLeader)
|
||||
setState((prev: any) => ({ ...prev, isLeader }))
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribeStateChange()
|
||||
unsubscribeCursors()
|
||||
unsubscribeUsers()
|
||||
unsubscribeNodePanelPresence()
|
||||
unsubscribeLeaderChange()
|
||||
cursorServiceRef.current?.stopTracking()
|
||||
if (connectionId)
|
||||
collaborationManager.disconnect(connectionId)
|
||||
}
|
||||
}, [appId, reactFlowStore])
|
||||
|
||||
const startCursorTracking = (containerRef: React.RefObject<HTMLElement>, reactFlowInstance?: ReactFlowInstance) => {
|
||||
if (cursorServiceRef.current) {
|
||||
cursorServiceRef.current.startTracking(containerRef, (position) => {
|
||||
collaborationManager.emitCursorMove(position)
|
||||
}, reactFlowInstance)
|
||||
}
|
||||
}
|
||||
|
||||
const stopCursorTracking = () => {
|
||||
cursorServiceRef.current?.stopTracking()
|
||||
}
|
||||
|
||||
const result = {
|
||||
isConnected: state.isConnected || false,
|
||||
onlineUsers: state.onlineUsers || [],
|
||||
cursors: state.cursors || {},
|
||||
nodePanelPresence: state.nodePanelPresence || {},
|
||||
isLeader: state.isLeader || false,
|
||||
leaderId: collaborationManager.getLeaderId(),
|
||||
startCursorTracking,
|
||||
stopCursorTracking,
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export { collaborationManager } from './core/collaboration-manager'
|
||||
export { webSocketClient, fetchAppsOnlineUsers } from './core/websocket-manager'
|
||||
export { CursorService } from './services/cursor-service'
|
||||
export { useCollaboration } from './hooks/use-collaboration'
|
||||
export * from './types'
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import type { RefObject } from 'react'
|
||||
import type { CursorPosition } from '../types/collaboration'
|
||||
import type { ReactFlowInstance } from 'reactflow'
|
||||
|
||||
const CURSOR_MIN_MOVE_DISTANCE = 10
|
||||
const CURSOR_THROTTLE_MS = 500
|
||||
|
||||
export class CursorService {
|
||||
private containerRef: RefObject<HTMLElement> | null = null
|
||||
private reactFlowInstance: ReactFlowInstance | null = null
|
||||
private isTracking = false
|
||||
private onCursorUpdate: ((cursors: Record<string, CursorPosition>) => void) | null = null
|
||||
private onEmitPosition: ((position: CursorPosition) => void) | null = null
|
||||
private lastEmitTime = 0
|
||||
private lastPosition: { x: number; y: number } | null = null
|
||||
|
||||
startTracking(
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
onEmitPosition: (position: CursorPosition) => void,
|
||||
reactFlowInstance?: ReactFlowInstance,
|
||||
): void {
|
||||
if (this.isTracking) this.stopTracking()
|
||||
|
||||
this.containerRef = containerRef
|
||||
this.onEmitPosition = onEmitPosition
|
||||
this.reactFlowInstance = reactFlowInstance || null
|
||||
this.isTracking = true
|
||||
|
||||
if (containerRef.current)
|
||||
containerRef.current.addEventListener('mousemove', this.handleMouseMove)
|
||||
}
|
||||
|
||||
stopTracking(): void {
|
||||
if (this.containerRef?.current)
|
||||
this.containerRef.current.removeEventListener('mousemove', this.handleMouseMove)
|
||||
|
||||
this.containerRef = null
|
||||
this.reactFlowInstance = null
|
||||
this.onEmitPosition = null
|
||||
this.isTracking = false
|
||||
this.lastPosition = null
|
||||
}
|
||||
|
||||
setCursorUpdateHandler(handler: (cursors: Record<string, CursorPosition>) => void): void {
|
||||
this.onCursorUpdate = handler
|
||||
}
|
||||
|
||||
updateCursors(cursors: Record<string, CursorPosition>): void {
|
||||
if (this.onCursorUpdate)
|
||||
this.onCursorUpdate(cursors)
|
||||
}
|
||||
|
||||
private handleMouseMove = (event: MouseEvent): void => {
|
||||
if (!this.containerRef?.current || !this.onEmitPosition) return
|
||||
|
||||
const rect = this.containerRef.current.getBoundingClientRect()
|
||||
let x = event.clientX - rect.left
|
||||
let y = event.clientY - rect.top
|
||||
|
||||
// Transform coordinates to ReactFlow world coordinates if ReactFlow instance is available
|
||||
if (this.reactFlowInstance) {
|
||||
const viewport = this.reactFlowInstance.getViewport()
|
||||
// Convert screen coordinates to world coordinates
|
||||
// World coordinates = (screen coordinates - viewport translation) / zoom
|
||||
x = (x - viewport.x) / viewport.zoom
|
||||
y = (y - viewport.y) / viewport.zoom
|
||||
}
|
||||
|
||||
// Always emit cursor position (remove boundary check since world coordinates can be negative)
|
||||
const now = Date.now()
|
||||
const timeThrottled = now - this.lastEmitTime > CURSOR_THROTTLE_MS
|
||||
const minDistance = CURSOR_MIN_MOVE_DISTANCE / (this.reactFlowInstance?.getZoom() || 1)
|
||||
const distanceThrottled = !this.lastPosition
|
||||
|| (Math.abs(x - this.lastPosition.x) > minDistance)
|
||||
|| (Math.abs(y - this.lastPosition.y) > minDistance)
|
||||
|
||||
if (timeThrottled && distanceThrottled) {
|
||||
this.lastPosition = { x, y }
|
||||
this.lastEmitTime = now
|
||||
this.onEmitPosition({
|
||||
x,
|
||||
y,
|
||||
userId: '',
|
||||
timestamp: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import type { Edge, Node } from '../../types'
|
||||
|
||||
export type OnlineUser = {
|
||||
user_id: string
|
||||
username: string
|
||||
avatar: string
|
||||
sid: string
|
||||
}
|
||||
|
||||
export type WorkflowOnlineUsers = {
|
||||
workflow_id: string
|
||||
users: OnlineUser[]
|
||||
}
|
||||
|
||||
export type OnlineUserListResponse = {
|
||||
data: WorkflowOnlineUsers[]
|
||||
}
|
||||
|
||||
export type CursorPosition = {
|
||||
x: number
|
||||
y: number
|
||||
userId: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type NodePanelPresenceUser = {
|
||||
userId: string
|
||||
username: string
|
||||
avatar?: string | null
|
||||
}
|
||||
|
||||
export type NodePanelPresenceInfo = NodePanelPresenceUser & {
|
||||
clientId: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type NodePanelPresenceMap = Record<string, Record<string, NodePanelPresenceInfo>>
|
||||
|
||||
export type CollaborationState = {
|
||||
appId: string
|
||||
isConnected: boolean
|
||||
onlineUsers: OnlineUser[]
|
||||
cursors: Record<string, CursorPosition>
|
||||
nodePanelPresence: NodePanelPresenceMap
|
||||
}
|
||||
|
||||
export type GraphSyncData = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
}
|
||||
|
||||
export type CollaborationUpdate = {
|
||||
type: 'mouseMove' | 'graphUpdate' | 'userJoin' | 'userLeave'
|
||||
userId: string
|
||||
data: any
|
||||
timestamp: number
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
export type CollaborationEvent = {
|
||||
type: string
|
||||
data: any
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type GraphUpdateEvent = {
|
||||
type: 'graph_update'
|
||||
data: Uint8Array
|
||||
} & CollaborationEvent
|
||||
|
||||
export type CursorMoveEvent = {
|
||||
type: 'cursor_move'
|
||||
data: {
|
||||
x: number
|
||||
y: number
|
||||
userId: string
|
||||
}
|
||||
} & CollaborationEvent
|
||||
|
||||
export type UserConnectEvent = {
|
||||
type: 'user_connect'
|
||||
data: {
|
||||
workflow_id: string
|
||||
}
|
||||
} & CollaborationEvent
|
||||
|
||||
export type OnlineUsersEvent = {
|
||||
type: 'online_users'
|
||||
data: {
|
||||
users: Array<{
|
||||
user_id: string
|
||||
username: string
|
||||
avatar: string
|
||||
sid: string
|
||||
}>
|
||||
}
|
||||
} & CollaborationEvent
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './websocket'
|
||||
export * from './collaboration'
|
||||
export * from './events'
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
export type WebSocketConfig = {
|
||||
url?: string
|
||||
token?: string
|
||||
transports?: string[]
|
||||
withCredentials?: boolean
|
||||
}
|
||||
|
||||
export type ConnectionInfo = {
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
socketId?: string
|
||||
}
|
||||
|
||||
export type DebugInfo = {
|
||||
[appId: string]: ConnectionInfo
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Generate a consistent color for a user based on their ID
|
||||
* Used for cursor colors and avatar backgrounds
|
||||
*/
|
||||
export const getUserColor = (id: string): string => {
|
||||
const colors = ['#155AEF', '#0BA5EC', '#444CE7', '#7839EE', '#4CA30D', '#0E9384', '#DD2590', '#FF4405', '#D92D20', '#F79009', '#828DAD']
|
||||
const hash = id.split('').reduce((a, b) => {
|
||||
a = ((a << 5) - a) + b.charCodeAt(0)
|
||||
return a & a
|
||||
}, 0)
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
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') {
|
||||
const target = e.target as HTMLElement
|
||||
const isInDropdown = target.closest('[data-mention-dropdown]')
|
||||
const isInCommentInput = target.closest('[data-comment-input]')
|
||||
const isOnCanvasPane = target.closest('.react-flow__pane')
|
||||
|
||||
// Only when clicking on the React Flow canvas pane (background),
|
||||
// and not inside comment input or its dropdown
|
||||
if (!isInDropdown && !isInCommentInput && isOnCanvasPane) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleCreateComment(mousePosition)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default CommentManager
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { useReactFlow, useViewport } from 'reactflow'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import type { WorkflowCommentList } from '@/service/workflow-comment'
|
||||
|
||||
type CommentIconProps = {
|
||||
comment: WorkflowCommentList
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export const CommentIcon: FC<CommentIconProps> = memo(({ comment, onClick }) => {
|
||||
const { flowToScreenPosition } = useReactFlow()
|
||||
const viewport = useViewport()
|
||||
|
||||
const screenPosition = useMemo(() => {
|
||||
return flowToScreenPosition({
|
||||
x: comment.position_x,
|
||||
y: comment.position_y,
|
||||
})
|
||||
}, [comment.position_x, comment.position_y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition])
|
||||
|
||||
// Calculate dynamic width based on number of participants
|
||||
const participantCount = comment.participants?.length || 0
|
||||
const maxVisible = Math.min(3, participantCount)
|
||||
const showCount = participantCount > 3
|
||||
const avatarSize = 24
|
||||
const avatarSpacing = 4 // -space-x-1 is about 4px overlap
|
||||
|
||||
// Width calculation: first avatar + (additional avatars * (size - spacing)) + padding
|
||||
const dynamicWidth = Math.max(40, // minimum width
|
||||
8 + avatarSize + Math.max(0, (showCount ? 2 : maxVisible - 1)) * (avatarSize - avatarSpacing) + 8,
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-10 cursor-pointer"
|
||||
style={{
|
||||
left: screenPosition.x,
|
||||
top: screenPosition.y,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={'relative h-10 overflow-hidden rounded-br-full rounded-tl-full rounded-tr-full'}
|
||||
style={{ width: dynamicWidth }}
|
||||
>
|
||||
<div className="absolute inset-1 overflow-hidden rounded-br-full rounded-tl-full rounded-tr-full bg-white">
|
||||
<div className="flex h-full w-full items-center justify-center px-1">
|
||||
<UserAvatarList
|
||||
users={comment.participants}
|
||||
maxVisible={3}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.comment.id === nextProps.comment.id
|
||||
&& prevProps.comment.position_x === nextProps.comment.position_x
|
||||
&& prevProps.comment.position_y === nextProps.comment.position_y
|
||||
&& prevProps.onClick === nextProps.onClick
|
||||
)
|
||||
})
|
||||
|
||||
CommentIcon.displayName = 'CommentIcon'
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import type { FC } from 'react'
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { MentionInput } from './mention-input'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type CommentInputProps = {
|
||||
position: { x: number; y: number }
|
||||
onSubmit: (content: string, mentionedUserIds: string[]) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const CommentInput: FC<CommentInputProps> = memo(({ position, onSubmit, onCancel }) => {
|
||||
const [content, setContent] = useState('')
|
||||
const { userProfile } = useAppContext()
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleGlobalKeyDown, true)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleGlobalKeyDown, true)
|
||||
}
|
||||
}, [onCancel])
|
||||
|
||||
const handleMentionSubmit = useCallback((content: string, mentionedUserIds: string[]) => {
|
||||
onSubmit(content, mentionedUserIds)
|
||||
setContent('')
|
||||
}, [onSubmit])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-50 w-96"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
}}
|
||||
data-comment-input
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative shrink-0">
|
||||
<div className="relative h-10 w-10 overflow-hidden rounded-br-full rounded-tl-full rounded-tr-full bg-primary-500">
|
||||
<div className="absolute inset-1 overflow-hidden rounded-br-full rounded-tl-full rounded-tr-full bg-white">
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-6 w-6 overflow-hidden rounded-full">
|
||||
<Avatar
|
||||
avatar={userProfile.avatar_url}
|
||||
name={userProfile.name}
|
||||
size={24}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex-1 rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[9px] shadow-md',
|
||||
)}
|
||||
>
|
||||
<div className='relative px-[9px] pt-[9px]'>
|
||||
<MentionInput
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
onSubmit={handleMentionSubmit}
|
||||
placeholder="Add a comment"
|
||||
autoFocus
|
||||
className="relative"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
CommentInput.displayName = 'CommentInput'
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { useStore } from '../store'
|
||||
import { ControlMode } from '../types'
|
||||
|
||||
type CommentCursorProps = {
|
||||
mousePosition: { elementX: number; elementY: number }
|
||||
}
|
||||
|
||||
export const CommentCursor: FC<CommentCursorProps> = memo(({ mousePosition }) => {
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
|
||||
if (controlMode !== ControlMode.Comment)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute z-50 flex h-6 w-6 items-center justify-center"
|
||||
style={{
|
||||
left: mousePosition.elementX,
|
||||
top: mousePosition.elementY,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M10.5 6.33325H5.5H10.5ZM8 9.66658H5.5H8ZM0.5 14.6666H11.3333C13.6345 14.6666 15.5 12.8011 15.5 10.4999V5.49992C15.5 3.19874 13.6345 1.33325 11.3333 1.33325H4.66667C2.36548 1.33325 0.5 3.19874 0.5 5.49992V14.6666Z" fill="white"/>
|
||||
<path d="M10.5 6.33325H5.5M8 9.66658H5.5M0.5 14.6666H11.3333C13.6345 14.6666 15.5 12.8011 15.5 10.4999V5.49992C15.5 3.19874 13.6345 1.33325 11.3333 1.33325H4.66667C2.36548 1.33325 0.5 3.19874 0.5 5.49992V14.6666Z" stroke="black"/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
CommentCursor.displayName = 'CommentCursor'
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export { CommentCursor } from './cursor'
|
||||
export { CommentInput } from './comment-input'
|
||||
export { CommentIcon } from './comment-icon'
|
||||
export { CommentThread } from './thread'
|
||||
export { MentionInput } from './mention-input'
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { RiArrowUpLine, RiAtLine } from '@remixicon/react'
|
||||
import Textarea from 'react-textarea-autosize'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import cn from '@/utils/classnames'
|
||||
import { type UserProfile, fetchMentionableUsers } from '@/service/workflow-comment'
|
||||
|
||||
type MentionInputProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: (content: string, mentionedUserIds: string[]) => void
|
||||
onCancel?: () => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
className?: string
|
||||
isEditing?: boolean
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
export const MentionInput: FC<MentionInputProps> = memo(({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
placeholder = 'Add a comment',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className,
|
||||
isEditing = false,
|
||||
autoFocus = false,
|
||||
}) => {
|
||||
const params = useParams()
|
||||
const appId = params.appId as string
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const [mentionUsers, setMentionUsers] = useState<UserProfile[]>([])
|
||||
const [showMentionDropdown, setShowMentionDropdown] = useState(false)
|
||||
const [mentionQuery, setMentionQuery] = useState('')
|
||||
const [mentionPosition, setMentionPosition] = useState(0)
|
||||
const [selectedMentionIndex, setSelectedMentionIndex] = useState(0)
|
||||
const [mentionedUserIds, setMentionedUserIds] = useState<string[]>([])
|
||||
|
||||
const loadMentionableUsers = useCallback(async () => {
|
||||
if (!appId) return
|
||||
try {
|
||||
const users = await fetchMentionableUsers(appId)
|
||||
setMentionUsers(users)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to load mentionable users:', error)
|
||||
}
|
||||
}, [appId])
|
||||
|
||||
useEffect(() => {
|
||||
loadMentionableUsers()
|
||||
}, [loadMentionableUsers])
|
||||
|
||||
const filteredMentionUsers = useMemo(() => {
|
||||
if (!mentionQuery) return mentionUsers
|
||||
return mentionUsers.filter(user =>
|
||||
user.name.toLowerCase().includes(mentionQuery.toLowerCase())
|
||||
|| user.email.toLowerCase().includes(mentionQuery.toLowerCase()),
|
||||
)
|
||||
}, [mentionUsers, mentionQuery])
|
||||
|
||||
const dropdownPosition = useMemo(() => {
|
||||
if (!showMentionDropdown || !textareaRef.current)
|
||||
return { x: 0, y: 0 }
|
||||
|
||||
const textareaRect = textareaRef.current.getBoundingClientRect()
|
||||
return {
|
||||
x: textareaRect.left,
|
||||
y: textareaRect.bottom + 4,
|
||||
}
|
||||
}, [showMentionDropdown])
|
||||
|
||||
const handleContentChange = useCallback((newValue: string) => {
|
||||
onChange(newValue)
|
||||
|
||||
setTimeout(() => {
|
||||
const cursorPosition = textareaRef.current?.selectionStart || 0
|
||||
const textBeforeCursor = newValue.slice(0, cursorPosition)
|
||||
const mentionMatch = textBeforeCursor.match(/@(\w*)$/)
|
||||
|
||||
if (mentionMatch) {
|
||||
setMentionQuery(mentionMatch[1])
|
||||
setMentionPosition(cursorPosition - mentionMatch[0].length)
|
||||
setShowMentionDropdown(true)
|
||||
setSelectedMentionIndex(0)
|
||||
}
|
||||
else {
|
||||
setShowMentionDropdown(false)
|
||||
}
|
||||
}, 0)
|
||||
}, [onChange])
|
||||
|
||||
const handleMentionButtonClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const cursorPosition = textarea.selectionStart || 0
|
||||
const newContent = `${value.slice(0, cursorPosition)}@${value.slice(cursorPosition)}`
|
||||
|
||||
onChange(newContent)
|
||||
|
||||
setTimeout(() => {
|
||||
const newCursorPos = cursorPosition + 1
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
|
||||
setMentionQuery('')
|
||||
setMentionPosition(cursorPosition)
|
||||
setShowMentionDropdown(true)
|
||||
setSelectedMentionIndex(0)
|
||||
}, 0)
|
||||
}, [value, onChange])
|
||||
|
||||
const insertMention = useCallback((user: UserProfile) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const beforeMention = value.slice(0, mentionPosition)
|
||||
const afterMention = value.slice(textarea.selectionStart || 0)
|
||||
const newContent = `${beforeMention}@${user.name} ${afterMention}`
|
||||
|
||||
onChange(newContent)
|
||||
setShowMentionDropdown(false)
|
||||
|
||||
const newMentionedUserIds = [...mentionedUserIds, user.id]
|
||||
setMentionedUserIds(newMentionedUserIds)
|
||||
|
||||
setTimeout(() => {
|
||||
const newCursorPos = mentionPosition + user.name.length + 2 // @ + name + space
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
}, [value, mentionPosition, onChange, mentionedUserIds])
|
||||
|
||||
const handleSubmit = useCallback((e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
if (value.trim()) {
|
||||
onSubmit(value.trim(), mentionedUserIds)
|
||||
setMentionedUserIds([])
|
||||
setShowMentionDropdown(false)
|
||||
}
|
||||
}, [value, mentionedUserIds, onSubmit])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (showMentionDropdown) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedMentionIndex(prev =>
|
||||
prev < filteredMentionUsers.length - 1 ? prev + 1 : 0,
|
||||
)
|
||||
}
|
||||
else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedMentionIndex(prev =>
|
||||
prev > 0 ? prev - 1 : filteredMentionUsers.length - 1,
|
||||
)
|
||||
}
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (filteredMentionUsers[selectedMentionIndex])
|
||||
insertMention(filteredMentionUsers[selectedMentionIndex])
|
||||
|
||||
return
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setShowMentionDropdown(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey && !showMentionDropdown) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}, [showMentionDropdown, filteredMentionUsers, selectedMentionIndex, insertMention, handleSubmit])
|
||||
|
||||
const resetMentionState = useCallback(() => {
|
||||
setMentionedUserIds([])
|
||||
setShowMentionDropdown(false)
|
||||
setMentionQuery('')
|
||||
setMentionPosition(0)
|
||||
setSelectedMentionIndex(0)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!value)
|
||||
resetMentionState()
|
||||
}, [value, resetMentionState])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && textareaRef.current) {
|
||||
const textarea = textareaRef.current
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
const length = textarea.value.length
|
||||
textarea.setSelectionRange(length, length)
|
||||
}, 0)
|
||||
}
|
||||
}, [autoFocus])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('relative flex items-center', className)}>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
'body-lg-regular w-full resize-none bg-transparent p-1 leading-6 text-text-primary caret-primary-500 outline-none',
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
minRows={isEditing ? 4 : 1}
|
||||
maxRows={4}
|
||||
value={value}
|
||||
disabled={disabled || loading}
|
||||
onChange={e => handleContentChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="absolute bottom-0 right-1 z-20 flex items-end gap-1">
|
||||
<div
|
||||
className="z-20 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-components-button-secondary-bg hover:bg-state-base-hover"
|
||||
onClick={handleMentionButtonClick}
|
||||
>
|
||||
<RiAtLine className="h-4 w-4" />
|
||||
</div>
|
||||
<Button
|
||||
className='z-20 ml-2 w-8 px-0'
|
||||
variant='primary'
|
||||
disabled={!value.trim() || disabled || loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<RiArrowUpLine className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div className="absolute bottom-0 left-1 right-1 z-20 flex items-end justify-between">
|
||||
<div
|
||||
className="z-20 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-components-button-secondary-bg hover:bg-state-base-hover"
|
||||
onClick={handleMentionButtonClick}
|
||||
>
|
||||
<RiAtLine className="h-4 w-4" />
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button variant='secondary' size='small' onClick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
size='small'
|
||||
disabled={loading || !value.trim()}
|
||||
onClick={() => handleSubmit()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showMentionDropdown && filteredMentionUsers.length > 0 && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] max-h-40 w-64 overflow-y-auto rounded-lg border border-components-panel-border bg-white shadow-lg"
|
||||
style={{
|
||||
left: dropdownPosition.x,
|
||||
top: dropdownPosition.y,
|
||||
}}
|
||||
data-mention-dropdown
|
||||
>
|
||||
{filteredMentionUsers.map((user, index) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 p-2 hover:bg-state-base-hover',
|
||||
index === selectedMentionIndex && 'bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => insertMention(user)}
|
||||
>
|
||||
<Avatar
|
||||
avatar={user.avatar_url || null}
|
||||
name={user.name}
|
||||
size={24}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-text-primary">
|
||||
{user.name}
|
||||
</div>
|
||||
<div className="truncate text-xs text-text-tertiary">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
MentionInput.displayName = 'MentionInput'
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useReactFlow, useViewport } from 'reactflow'
|
||||
import { RiArrowDownSLine, RiArrowUpSLine, RiCheckboxCircleFill, RiCheckboxCircleLine, RiCloseLine, RiDeleteBinLine, RiMoreFill } from '@remixicon/react'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useFormatTimeFromNow } from '@/app/components/workflow/hooks'
|
||||
import type { WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/service/workflow-comment'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { MentionInput } from './mention-input'
|
||||
|
||||
type CommentThreadProps = {
|
||||
comment: WorkflowCommentDetail
|
||||
loading?: boolean
|
||||
onClose: () => void
|
||||
onDelete?: () => void
|
||||
onResolve?: () => void
|
||||
onPrev?: () => void
|
||||
onNext?: () => void
|
||||
canGoPrev?: boolean
|
||||
canGoNext?: boolean
|
||||
onReply?: (content: string, mentionedUserIds?: string[]) => Promise<void> | void
|
||||
onReplyEdit?: (replyId: string, content: string, mentionedUserIds?: string[]) => Promise<void> | void
|
||||
onReplyDelete?: (replyId: string) => void
|
||||
}
|
||||
|
||||
const ThreadMessage: FC<{
|
||||
authorName: string
|
||||
avatarUrl?: string | null
|
||||
createdAt: number
|
||||
content: string
|
||||
}> = ({ authorName, avatarUrl, createdAt, content }) => {
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-3 pt-1')}>
|
||||
<div className='shrink-0'>
|
||||
<Avatar
|
||||
name={authorName}
|
||||
avatar={avatarUrl || null}
|
||||
size={24}
|
||||
className={cn('h-8 w-8 rounded-full')}
|
||||
/>
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 pb-4 text-text-primary last:pb-0'>
|
||||
<div className='flex flex-wrap items-center gap-x-2 gap-y-1'>
|
||||
<span className='system-sm-medium text-text-primary'>{authorName}</span>
|
||||
<span className='system-2xs-regular text-text-tertiary'>{formatTimeFromNow(createdAt * 1000)}</span>
|
||||
</div>
|
||||
<div className='system-sm-regular mt-1 whitespace-pre-wrap break-words text-text-secondary'>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CommentThread: FC<CommentThreadProps> = memo(({
|
||||
comment,
|
||||
loading = false,
|
||||
onClose,
|
||||
onDelete,
|
||||
onResolve,
|
||||
onPrev,
|
||||
onNext,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
onReply,
|
||||
onReplyEdit,
|
||||
onReplyDelete,
|
||||
}) => {
|
||||
const { flowToScreenPosition } = useReactFlow()
|
||||
const viewport = useViewport()
|
||||
const { userProfile } = useAppContext()
|
||||
const [replyContent, setReplyContent] = useState('')
|
||||
const [activeReplyMenuId, setActiveReplyMenuId] = useState<string | null>(null)
|
||||
const [editingReply, setEditingReply] = useState<{ id: string; content: string }>({ id: '', content: '' })
|
||||
|
||||
useEffect(() => {
|
||||
setReplyContent('')
|
||||
}, [comment.id])
|
||||
|
||||
const handleReplySubmit = useCallback(async (content: string, mentionedUserIds: string[]) => {
|
||||
if (!onReply || loading) return
|
||||
|
||||
try {
|
||||
await onReply(content, mentionedUserIds)
|
||||
setReplyContent('')
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to send reply', error)
|
||||
}
|
||||
}, [onReply, loading])
|
||||
|
||||
const screenPosition = useMemo(() => {
|
||||
return flowToScreenPosition({
|
||||
x: comment.position_x,
|
||||
y: comment.position_y,
|
||||
})
|
||||
}, [comment.position_x, comment.position_y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition])
|
||||
|
||||
const handleStartEdit = useCallback((reply: WorkflowCommentDetailReply) => {
|
||||
setEditingReply({ id: reply.id, content: reply.content })
|
||||
setActiveReplyMenuId(null)
|
||||
}, [])
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingReply({ id: '', content: '' })
|
||||
}, [])
|
||||
|
||||
const handleEditSubmit = useCallback(async (content: string, mentionedUserIds: string[]) => {
|
||||
if (!onReplyEdit || !editingReply) return
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed) return
|
||||
await onReplyEdit(editingReply.id, trimmed, mentionedUserIds)
|
||||
setEditingReply({ id: '', content: '' })
|
||||
}, [editingReply, onReplyEdit])
|
||||
|
||||
const replies = comment.replies || []
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute z-50 w-[360px] max-w-[360px]'
|
||||
style={{
|
||||
left: screenPosition.x,
|
||||
top: screenPosition.y,
|
||||
transform: 'translate(-50%, -100%) translateY(-24px)',
|
||||
}}
|
||||
>
|
||||
<div className='relative flex h-[360px] flex-col overflow-hidden rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl'>
|
||||
<div className='flex items-center justify-between rounded-t-2xl border-b border-components-panel-border bg-components-panel-bg-blur px-4 py-3'>
|
||||
<div className=' font-semibold uppercase text-text-primary'>Comment</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<button
|
||||
type='button'
|
||||
disabled={loading}
|
||||
className={cn('flex h-6 w-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')}
|
||||
onClick={onDelete}
|
||||
aria-label='Delete comment'
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
disabled={comment.resolved || loading}
|
||||
className={cn('flex h-6 w-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')}
|
||||
onClick={onResolve}
|
||||
aria-label='Resolve comment'
|
||||
>
|
||||
{comment.resolved ? <RiCheckboxCircleFill className='h-4 w-4' /> : <RiCheckboxCircleLine className='h-4 w-4' />}
|
||||
</button>
|
||||
<Divider type='vertical' className='h-3.5' />
|
||||
<button
|
||||
type='button'
|
||||
disabled={!canGoPrev || loading}
|
||||
className={cn('flex h-6 w-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')}
|
||||
onClick={onPrev}
|
||||
aria-label='Previous comment'
|
||||
>
|
||||
<RiArrowUpSLine className='h-4 w-4' />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
disabled={!canGoNext || loading}
|
||||
className={cn('flex h-6 w-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')}
|
||||
onClick={onNext}
|
||||
aria-label='Next comment'
|
||||
>
|
||||
<RiArrowDownSLine className='h-4 w-4' />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-6 w-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary'
|
||||
onClick={onClose}
|
||||
aria-label='Close comment'
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='relative mt-2 flex-1 overflow-y-auto px-4'>
|
||||
<ThreadMessage
|
||||
authorName={comment.created_by_account?.name || 'User'}
|
||||
avatarUrl={comment.created_by_account?.avatar_url || null}
|
||||
createdAt={comment.created_at}
|
||||
content={comment.content}
|
||||
/>
|
||||
{replies.length > 0 && (
|
||||
<div className='mt-2 space-y-3 pt-3'>
|
||||
{replies.map((reply) => {
|
||||
const isReplyEditing = editingReply?.id === reply.id
|
||||
return (
|
||||
<div
|
||||
key={reply.id}
|
||||
className='group relative rounded-lg py-2 transition-colors hover:bg-components-panel-on-panel-item-bg'
|
||||
>
|
||||
{!isReplyEditing && (
|
||||
<div className='absolute right-1 top-1 hidden gap-1 group-hover:flex'>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-6 w-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setActiveReplyMenuId(prev => prev === reply.id ? null : reply.id)
|
||||
}}
|
||||
aria-label='Reply actions'
|
||||
>
|
||||
<RiMoreFill className='h-4 w-4' />
|
||||
</button>
|
||||
{activeReplyMenuId === reply.id && (
|
||||
<div className='absolute right-0 top-7 z-40 w-36 rounded-lg border border-components-panel-border bg-components-panel-bg shadow-lg'>
|
||||
<button
|
||||
className='flex w-full items-center justify-start px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover'
|
||||
onClick={() => handleStartEdit(reply)}
|
||||
>
|
||||
Edit reply
|
||||
</button>
|
||||
<button
|
||||
className='text-negative flex w-full items-center justify-start px-3 py-2 text-left text-sm hover:bg-state-base-hover'
|
||||
onClick={() => {
|
||||
setActiveReplyMenuId(null)
|
||||
onReplyDelete?.(reply.id)
|
||||
}}
|
||||
>
|
||||
Delete reply
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isReplyEditing ? (
|
||||
<div className='rounded-lg border border-components-chat-input-border bg-components-panel-bg-blur px-3 py-2 shadow-sm'>
|
||||
<MentionInput
|
||||
value={editingReply?.content ?? ''}
|
||||
onChange={newContent => setEditingReply(prev => prev ? { ...prev, content: newContent } : prev)}
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={handleCancelEdit}
|
||||
placeholder="Edit reply"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
isEditing={true}
|
||||
className="system-sm-regular"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ThreadMessage
|
||||
authorName={reply.created_by_account?.name || 'User'}
|
||||
avatarUrl={reply.created_by_account?.avatar_url || null}
|
||||
createdAt={reply.created_at}
|
||||
content={reply.content}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className='bg-components-panel-bg/70 absolute inset-0 z-30 flex items-center justify-center text-sm text-text-tertiary'>
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
{onReply && (
|
||||
<div className='border-t border-components-panel-border px-4 py-3'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Avatar
|
||||
avatar={userProfile?.avatar_url || null}
|
||||
name={userProfile?.name || 'You'}
|
||||
size={24}
|
||||
className='h-8 w-8'
|
||||
/>
|
||||
<div className='flex-1 rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-[2px] shadow-sm'>
|
||||
<MentionInput
|
||||
value={replyContent}
|
||||
onChange={setReplyContent}
|
||||
onSubmit={handleReplySubmit}
|
||||
placeholder='Reply'
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
className='px-2'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
CommentThread.displayName = 'CommentThread'
|
||||
|
|
@ -7,21 +7,23 @@ import { useStore } from './store'
|
|||
import {
|
||||
useIsChatMode,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from './hooks'
|
||||
import { type CommonNodeType, type InputVar, InputVarType, type Node } from './types'
|
||||
import useConfig from './nodes/start/use-config'
|
||||
import type { StartNodeType } from './nodes/start/types'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import NewFeaturePanel from '@/app/components/base/features/new-feature-panel'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { updateFeatures } from '@/service/workflow'
|
||||
|
||||
const Features = () => {
|
||||
const setShowFeaturesPanel = useStore(s => s.setShowFeaturesPanel)
|
||||
const appId = useStore(s => s.appId)
|
||||
const isChatMode = useIsChatMode()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const featuresStore = useFeaturesStore()
|
||||
const nodes = useNodes<CommonNodeType>()
|
||||
|
||||
const startNode = nodes.find(node => node.data.type === 'start')
|
||||
const { id, data } = startNode as Node<StartNodeType>
|
||||
const { handleAddVariable } = useConfig(id, data)
|
||||
|
|
@ -39,10 +41,45 @@ const Features = () => {
|
|||
handleAddVariable(startNodeVariable)
|
||||
}
|
||||
|
||||
const handleFeaturesChange = useCallback(() => {
|
||||
handleSyncWorkflowDraft()
|
||||
const handleFeaturesChange = useCallback(async () => {
|
||||
if (!appId || !featuresStore) return
|
||||
|
||||
try {
|
||||
const currentFeatures = featuresStore.getState().features
|
||||
|
||||
// Transform features to match the expected server format (same as doSyncWorkflowDraft)
|
||||
const transformedFeatures = {
|
||||
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)
|
||||
}, [handleSyncWorkflowDraft, setShowFeaturesPanel])
|
||||
}, [appId, featuresStore, setShowFeaturesPanel])
|
||||
|
||||
return (
|
||||
<NewFeaturePanel
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import RunAndHistory from './run-and-history'
|
|||
import EditingTitle from './editing-title'
|
||||
import EnvButton from './env-button'
|
||||
import VersionHistoryButton from './version-history-button'
|
||||
import OnlineUsers from './online-users'
|
||||
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
|
||||
|
||||
export type HeaderInNormalProps = {
|
||||
|
|
@ -64,7 +65,9 @@ const HeaderInNormal = ({
|
|||
<EditingTitle />
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<OnlineUsers />
|
||||
{components?.left}
|
||||
<Divider type='vertical' className='mx-auto h-3.5' />
|
||||
<EnvButton disabled={nodesReadOnly} />
|
||||
<Divider type='vertical' className='mx-auto h-3.5' />
|
||||
<RunAndHistory {...runAndHistoryProps} />
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { useInvalidAllLastRun } from '@/service/use-workflow'
|
|||
import { useHooksStore } from '../hooks-store'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import cn from '@/utils/classnames'
|
||||
import { collaborationManager } from '../collaboration/core/collaboration-manager'
|
||||
|
||||
export type HeaderInRestoringProps = {
|
||||
onRestoreSettled?: () => void
|
||||
|
|
@ -60,6 +61,9 @@ const HeaderInRestoring = ({
|
|||
type: 'success',
|
||||
message: t('workflow.versionHistory.action.restoreSuccess'),
|
||||
})
|
||||
// Notify other collaboration clients about the workflow restore
|
||||
if (appDetail)
|
||||
collaborationManager.emitWorkflowUpdate(appDetail.id)
|
||||
},
|
||||
onError: () => {
|
||||
Toast.notify({
|
||||
|
|
@ -70,10 +74,10 @@ const HeaderInRestoring = ({
|
|||
onSettled: () => {
|
||||
onRestoreSettled?.()
|
||||
},
|
||||
})
|
||||
}, true) // Enable forceUpload for restore operation
|
||||
deleteAllInspectVars()
|
||||
invalidAllLastRun()
|
||||
}, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
|
||||
}, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled, appDetail])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,195 @@
|
|||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import { useCollaboration } from '../collaboration/hooks/use-collaboration'
|
||||
import { useStore } from '../store'
|
||||
import cn from '@/utils/classnames'
|
||||
import { 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'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { getAvatar } from '@/service/common'
|
||||
|
||||
const useAvatarUrls = (users: any[]) => {
|
||||
const [avatarUrls, setAvatarUrls] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAvatars = async () => {
|
||||
const newAvatarUrls: Record<string, string> = {}
|
||||
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
if (user.avatar) {
|
||||
try {
|
||||
const response = await getAvatar({ avatar: user.avatar })
|
||||
newAvatarUrls[user.sid] = response.avatar_url
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to fetch avatar:', error)
|
||||
newAvatarUrls[user.sid] = user.avatar
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
setAvatarUrls(newAvatarUrls)
|
||||
}
|
||||
|
||||
if (users.length > 0)
|
||||
fetchAvatars()
|
||||
}, [users])
|
||||
|
||||
return avatarUrls
|
||||
}
|
||||
|
||||
const OnlineUsers = () => {
|
||||
const appId = useStore(s => s.appId)
|
||||
const { onlineUsers, cursors } = useCollaboration(appId as string)
|
||||
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
|
||||
|
||||
// Display logic:
|
||||
// 1-3 users: show all avatars
|
||||
// 4+ users: show 2 avatars + count + arrow
|
||||
const shouldShowCount = onlineUsers.length >= 4
|
||||
const maxVisible = shouldShowCount ? 2 : 3
|
||||
const visibleUsers = onlineUsers.slice(0, maxVisible)
|
||||
const remainingCount = onlineUsers.length - maxVisible
|
||||
|
||||
const getAvatarUrl = (user: any) => {
|
||||
return avatarUrls[user.sid] || user.avatar
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center rounded-full bg-white px-1 py-1">
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center -space-x-2">
|
||||
{visibleUsers.map((user, index) => {
|
||||
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 (
|
||||
<Tooltip
|
||||
key={`${user.sid}-${index}`}
|
||||
popupContent={displayName}
|
||||
position="bottom"
|
||||
triggerMethod="hover"
|
||||
needsDelay={false}
|
||||
asChild
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative',
|
||||
!isCurrentUser && 'cursor-pointer transition-transform hover:scale-110',
|
||||
)}
|
||||
style={{ zIndex: visibleUsers.length - index }}
|
||||
onClick={() => !isCurrentUser && jumpToUserCursor(user.user_id)}
|
||||
>
|
||||
<Avatar
|
||||
name={user.username || 'User'}
|
||||
avatar={getAvatarUrl(user)}
|
||||
size={28}
|
||||
className="ring-2 ring-white"
|
||||
backgroundColor={userColor}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{remainingCount > 0 && (
|
||||
<PortalToFollowElem
|
||||
open={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
placement="bottom-start"
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onMouseEnter={() => setDropdownOpen(true)}
|
||||
onMouseLeave={() => setDropdownOpen(false)}
|
||||
asChild
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center',
|
||||
'cursor-pointer rounded-full bg-gray-300',
|
||||
'text-xs font-medium text-gray-700',
|
||||
'ring-2 ring-white',
|
||||
)}
|
||||
>
|
||||
+{remainingCount}
|
||||
</div>
|
||||
<ChevronDown className="ml-1 h-3 w-3 text-gray-500" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent
|
||||
onMouseEnter={() => setDropdownOpen(true)}
|
||||
onMouseLeave={() => setDropdownOpen(false)}
|
||||
className="z-[9999]"
|
||||
>
|
||||
<div className="mt-2 min-w-[200px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg">
|
||||
{onlineUsers.map((user) => {
|
||||
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 (
|
||||
<div
|
||||
key={user.sid}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2',
|
||||
!isCurrentUser && 'cursor-pointer hover:bg-components-panel-on-panel-item-bg-hover',
|
||||
)}
|
||||
onClick={() => !isCurrentUser && jumpToUserCursor(user.user_id)}
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
name={user.username || 'User'}
|
||||
avatar={getAvatarUrl(user)}
|
||||
size={24}
|
||||
backgroundColor={userColor}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-text-secondary">
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OnlineUsers
|
||||
|
|
@ -6,27 +6,39 @@ import {
|
|||
RiArrowGoForwardFill,
|
||||
} from '@remixicon/react'
|
||||
import TipPopup from '../operator/tip-popup'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
import Divider from '../../base/divider'
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
||||
import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
export type UndoRedoProps = { handleUndo: () => void; handleRedo: () => void }
|
||||
const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
|
||||
const { t } = useTranslation()
|
||||
const { store } = useWorkflowHistoryStore()
|
||||
const [buttonsDisabled, setButtonsDisabled] = useState({ undo: true, redo: true })
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = store.temporal.subscribe((state) => {
|
||||
// Update button states based on Loro's UndoManager
|
||||
const updateButtonStates = () => {
|
||||
setButtonsDisabled({
|
||||
undo: state.pastStates.length === 0,
|
||||
redo: state.futureStates.length === 0,
|
||||
undo: !collaborationManager.canUndo(),
|
||||
redo: !collaborationManager.canRedo(),
|
||||
})
|
||||
}
|
||||
|
||||
// Initial state
|
||||
updateButtonStates()
|
||||
|
||||
// Listen for undo/redo state changes
|
||||
const unsubscribe = collaborationManager.onUndoRedoStateChange((state) => {
|
||||
setButtonsDisabled({
|
||||
undo: !state.canUndo,
|
||||
redo: !state.canRedo,
|
||||
})
|
||||
})
|
||||
|
||||
return () => unsubscribe()
|
||||
}, [store])
|
||||
}, [])
|
||||
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ export type CommonHooksFnMap = {
|
|||
onSuccess?: () => void
|
||||
onError?: () => void
|
||||
onSettled?: () => void
|
||||
}
|
||||
},
|
||||
forceUpload?: boolean
|
||||
) => Promise<void>
|
||||
syncWorkflowDraftWhenPageClose: () => void
|
||||
handleRefreshWorkflowDraft: () => void
|
||||
|
|
|
|||
|
|
@ -23,3 +23,4 @@ export * from './use-inspect-vars-crud'
|
|||
export * from './use-set-workflow-vars-with-value'
|
||||
export * from './use-workflow-search'
|
||||
export * from './use-format-time-from-now'
|
||||
export * from './use-workflow-comment'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import type { Edge, Node } from '../types'
|
||||
import { collaborationManager } from '../collaboration/core/collaboration-manager'
|
||||
|
||||
const sanitizeNodeForBroadcast = (node: Node): Node => {
|
||||
if (!node.data)
|
||||
return node
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(node.data, 'selected'))
|
||||
return node
|
||||
|
||||
const sanitizedData = { ...node.data }
|
||||
delete (sanitizedData as Record<string, unknown>).selected
|
||||
|
||||
return {
|
||||
...node,
|
||||
data: sanitizedData,
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeEdgeForBroadcast = (edge: Edge): Edge => {
|
||||
if (!edge.data)
|
||||
return edge
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(edge.data, '_connectedNodeIsSelected'))
|
||||
return edge
|
||||
|
||||
const sanitizedData = { ...edge.data }
|
||||
delete (sanitizedData as Record<string, unknown>)._connectedNodeIsSelected
|
||||
|
||||
return {
|
||||
...edge,
|
||||
data: sanitizedData,
|
||||
}
|
||||
}
|
||||
|
||||
export const useCollaborativeWorkflow = () => {
|
||||
const store = useStoreApi()
|
||||
const { setNodes: collabSetNodes, setEdges: collabSetEdges } = collaborationManager
|
||||
|
||||
const setNodes = useCallback((newNodes: Node[], shouldBroadcast: boolean = true) => {
|
||||
const { getNodes, setNodes: reactFlowSetNodes } = store.getState()
|
||||
if (shouldBroadcast) {
|
||||
const oldNodes = getNodes()
|
||||
collabSetNodes(
|
||||
oldNodes.map(sanitizeNodeForBroadcast),
|
||||
newNodes.map(sanitizeNodeForBroadcast),
|
||||
)
|
||||
}
|
||||
reactFlowSetNodes(newNodes)
|
||||
}, [store, collabSetNodes])
|
||||
|
||||
const setEdges = useCallback((newEdges: Edge[], shouldBroadcast: boolean = true) => {
|
||||
const { edges, setEdges: reactFlowSetEdges } = store.getState()
|
||||
if (shouldBroadcast) {
|
||||
collabSetEdges(
|
||||
edges.map(sanitizeEdgeForBroadcast),
|
||||
newEdges.map(sanitizeEdgeForBroadcast),
|
||||
)
|
||||
}
|
||||
|
||||
reactFlowSetEdges(newEdges)
|
||||
}, [store, collabSetEdges])
|
||||
|
||||
const collaborativeStore = useCallback(() => {
|
||||
const state = store.getState()
|
||||
return {
|
||||
|
||||
nodes: state.getNodes(),
|
||||
edges: state.edges,
|
||||
|
||||
setNodes,
|
||||
setEdges,
|
||||
|
||||
}
|
||||
}, [store, setNodes, setEdges])
|
||||
|
||||
return {
|
||||
getState: collaborativeStore,
|
||||
setNodes,
|
||||
setEdges,
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,7 @@ import type {
|
|||
EdgeMouseHandler,
|
||||
OnEdgesChange,
|
||||
} from 'reactflow'
|
||||
import {
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
|
||||
import type {
|
||||
Node,
|
||||
} from '../types'
|
||||
|
|
@ -14,61 +12,55 @@ import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils'
|
|||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
import { useNodesReadOnly } from './use-workflow'
|
||||
import { WorkflowHistoryEvent, useWorkflowHistory } from './use-workflow-history'
|
||||
import { useCollaborativeWorkflow } from './use-collaborative-workflow'
|
||||
|
||||
export const useEdgesInteractions = () => {
|
||||
const store = useStoreApi()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
const collaborativeWorkflow = useCollaborativeWorkflow()
|
||||
|
||||
const handleEdgeEnter = useCallback<EdgeMouseHandler>((_, edge) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
const { edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
const currentEdge = draft.find(e => e.id === edge.id)!
|
||||
|
||||
currentEdge.data._hovering = true
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}, [store, getNodesReadOnly])
|
||||
setEdges(newEdges, false)
|
||||
}, [collaborativeWorkflow, getNodesReadOnly])
|
||||
|
||||
const handleEdgeLeave = useCallback<EdgeMouseHandler>((_, edge) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
const { edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
const currentEdge = draft.find(e => e.id === edge.id)!
|
||||
|
||||
currentEdge.data._hovering = false
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}, [store, getNodesReadOnly])
|
||||
setEdges(newEdges, false)
|
||||
}, [collaborativeWorkflow, getNodesReadOnly])
|
||||
|
||||
const handleEdgeDeleteByDeleteBranch = useCallback((nodeId: string, branchId: string) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
getNodes,
|
||||
nodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
} = collaborativeWorkflow.getState()
|
||||
const edgeWillBeDeleted = edges.filter(edge => edge.source === nodeId && edge.sourceHandle === branchId)
|
||||
|
||||
if (!edgeWillBeDeleted.length)
|
||||
return
|
||||
|
||||
const nodes = getNodes()
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
edgeWillBeDeleted.map(edge => ({ type: 'remove', edge })),
|
||||
nodes,
|
||||
|
|
@ -90,24 +82,23 @@ export const useEdgesInteractions = () => {
|
|||
setEdges(newEdges)
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch)
|
||||
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
}, [getNodesReadOnly, collaborativeWorkflow, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
|
||||
const handleEdgeDelete = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
getNodes,
|
||||
nodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
} = collaborativeWorkflow.getState()
|
||||
const currentEdgeIndex = edges.findIndex(edge => edge.selected)
|
||||
|
||||
if (currentEdgeIndex < 0)
|
||||
return
|
||||
const currentEdge = edges[currentEdgeIndex]
|
||||
const nodes = getNodes()
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[
|
||||
{ type: 'remove', edge: currentEdge },
|
||||
|
|
@ -131,7 +122,7 @@ export const useEdgesInteractions = () => {
|
|||
setEdges(newEdges)
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.EdgeDelete)
|
||||
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
}, [getNodesReadOnly, collaborativeWorkflow, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
|
||||
const handleEdgesChange = useCallback<OnEdgesChange>((changes) => {
|
||||
if (getNodesReadOnly())
|
||||
|
|
@ -140,7 +131,7 @@ export const useEdgesInteractions = () => {
|
|||
const {
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
} = collaborativeWorkflow.getState()
|
||||
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
changes.forEach((change) => {
|
||||
|
|
@ -149,7 +140,7 @@ export const useEdgesInteractions = () => {
|
|||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}, [store, getNodesReadOnly])
|
||||
}, [collaborativeWorkflow, getNodesReadOnly])
|
||||
|
||||
return {
|
||||
handleEdgeEnter,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
getConnectedEdges,
|
||||
getOutgoers,
|
||||
useReactFlow,
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import type { ToolDefaultValue } from '../block-selector/types'
|
||||
import type { Edge, Node, OnNodeAdd } from '../types'
|
||||
|
|
@ -46,7 +45,7 @@ import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
|
|||
import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types'
|
||||
import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions'
|
||||
import { useNodeLoopInteractions } from '../nodes/loop/use-interactions'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
import { collaborationManager } from '../collaboration/core/collaboration-manager'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
import { useHelpline } from './use-helpline'
|
||||
import {
|
||||
|
|
@ -62,13 +61,13 @@ import { useNodesMetaData } from './use-nodes-meta-data'
|
|||
import type { RAGPipelineVariables } from '@/models/pipeline'
|
||||
import useInspectVarsCrud from './use-inspect-vars-crud'
|
||||
import { getNodeUsedVars } from '../nodes/_base/components/variable/utils'
|
||||
import { useCollaborativeWorkflow } from './use-collaborative-workflow'
|
||||
|
||||
export const useNodesInteractions = () => {
|
||||
const { t } = useTranslation()
|
||||
const store = useStoreApi()
|
||||
const collaborativeWorkflow = useCollaborativeWorkflow()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const reactflow = useReactFlow()
|
||||
const { store: workflowHistoryStore } = useWorkflowHistoryStore()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { checkNestedParallelLimit, getAfterNodesInSameBranch } = useWorkflow()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
|
|
@ -84,7 +83,7 @@ export const useNodesInteractions = () => {
|
|||
})
|
||||
const { nodesMap: nodesMetaDataMap } = useNodesMetaData()
|
||||
|
||||
const { saveStateToHistory, undo, redo } = useWorkflowHistory()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
|
||||
const handleNodeDragStart = useCallback<NodeDragHandler>(
|
||||
(_, node) => {
|
||||
|
|
@ -120,19 +119,18 @@ export const useNodesInteractions = () => {
|
|||
|
||||
if (node.type === CUSTOM_LOOP_START_NODE) return
|
||||
|
||||
const { getNodes, setNodes } = store.getState()
|
||||
e.stopPropagation()
|
||||
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
|
||||
const { restrictPosition } = handleNodeIterationChildDrag(node)
|
||||
const { restrictPosition: restrictLoopPosition }
|
||||
= handleNodeLoopChildDrag(node)
|
||||
= handleNodeLoopChildDrag(node)
|
||||
|
||||
const { showHorizontalHelpLineNodes, showVerticalHelpLineNodes }
|
||||
= handleSetHelpline(node)
|
||||
= handleSetHelpline(node)
|
||||
const showHorizontalHelpLineNodesLength
|
||||
= showHorizontalHelpLineNodes.length
|
||||
= showHorizontalHelpLineNodes.length
|
||||
const showVerticalHelpLineNodesLength = showVerticalHelpLineNodes.length
|
||||
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
|
|
@ -152,18 +150,11 @@ export const useNodesInteractions = () => {
|
|||
currentNode.position.y = restrictPosition.y
|
||||
else if (restrictLoopPosition.y !== undefined)
|
||||
currentNode.position.y = restrictLoopPosition.y
|
||||
else currentNode.position.y = node.position.y
|
||||
else
|
||||
currentNode.position.y = node.position.y
|
||||
})
|
||||
setNodes(newNodes)
|
||||
},
|
||||
[
|
||||
getNodesReadOnly,
|
||||
store,
|
||||
handleNodeIterationChildDrag,
|
||||
handleNodeLoopChildDrag,
|
||||
handleSetHelpline,
|
||||
],
|
||||
)
|
||||
}, [getNodesReadOnly, collaborativeWorkflow, handleNodeIterationChildDrag, handleNodeLoopChildDrag, handleSetHelpline])
|
||||
|
||||
const handleNodeDragStop = useCallback<NodeDragHandler>(
|
||||
(_, node) => {
|
||||
|
|
@ -210,11 +201,11 @@ export const useNodesInteractions = () => {
|
|||
)
|
||||
return
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { connectingNodePayload, setEnteringNodePayload }
|
||||
= workflowStore.getState()
|
||||
|
||||
const { nodes, edges, setNodes, setEdges } = collaborativeWorkflow.getState()
|
||||
const {
|
||||
connectingNodePayload,
|
||||
setEnteringNodePayload,
|
||||
} = workflowStore.getState()
|
||||
if (connectingNodePayload) {
|
||||
if (connectingNodePayload.nodeId === node.id) return
|
||||
const connectingNode: Node = nodes.find(
|
||||
|
|
@ -233,25 +224,25 @@ export const useNodesInteractions = () => {
|
|||
draft.forEach((n) => {
|
||||
if (
|
||||
n.id === node.id
|
||||
&& fromType === 'source'
|
||||
&& (node.data.type === BlockEnum.VariableAssigner
|
||||
|| node.data.type === BlockEnum.VariableAggregator)
|
||||
&& fromType === 'source'
|
||||
&& (node.data.type === BlockEnum.VariableAssigner
|
||||
|| node.data.type === BlockEnum.VariableAggregator)
|
||||
) {
|
||||
if (!node.data.advanced_settings?.group_enabled)
|
||||
n.data._isEntering = true
|
||||
}
|
||||
if (
|
||||
n.id === node.id
|
||||
&& fromType === 'target'
|
||||
&& (connectingNode.data.type === BlockEnum.VariableAssigner
|
||||
|| connectingNode.data.type === BlockEnum.VariableAggregator)
|
||||
&& node.data.type !== BlockEnum.IfElse
|
||||
&& node.data.type !== BlockEnum.QuestionClassifier
|
||||
&& fromType === 'target'
|
||||
&& (connectingNode.data.type === BlockEnum.VariableAssigner
|
||||
|| connectingNode.data.type === BlockEnum.VariableAggregator)
|
||||
&& node.data.type !== BlockEnum.IfElse
|
||||
&& node.data.type !== BlockEnum.QuestionClassifier
|
||||
)
|
||||
n.data._isEntering = true
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
setNodes(newNodes, false)
|
||||
}
|
||||
}
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
|
|
@ -262,9 +253,9 @@ export const useNodesInteractions = () => {
|
|||
if (currentEdge) currentEdge.data._connectedNodeIsHovering = true
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
setEdges(newEdges, false)
|
||||
},
|
||||
[store, workflowStore, getNodesReadOnly],
|
||||
[collaborativeWorkflow, workflowStore, getNodesReadOnly],
|
||||
)
|
||||
|
||||
const handleNodeLeave = useCallback<NodeMouseHandler>(
|
||||
|
|
@ -285,21 +276,21 @@ export const useNodesInteractions = () => {
|
|||
|
||||
const { setEnteringNodePayload } = workflowStore.getState()
|
||||
setEnteringNodePayload(undefined)
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const newNodes = produce(getNodes(), (draft) => {
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
node.data._isEntering = false
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
setNodes(newNodes, false)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.forEach((edge) => {
|
||||
edge.data._connectedNodeIsHovering = false
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
setEdges(newEdges, false)
|
||||
},
|
||||
[store, workflowStore, getNodesReadOnly],
|
||||
[collaborativeWorkflow, workflowStore, getNodesReadOnly],
|
||||
)
|
||||
|
||||
const handleNodeSelect = useCallback(
|
||||
|
|
@ -310,9 +301,7 @@ export const useNodesInteractions = () => {
|
|||
) => {
|
||||
if (initShowLastRunTab)
|
||||
workflowStore.setState({ initShowLastRunTab: true })
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const selectedNode = nodes.find(node => node.data.selected)
|
||||
|
||||
if (!cancelSelection && selectedNode?.id === nodeId) return
|
||||
|
|
@ -323,7 +312,7 @@ export const useNodesInteractions = () => {
|
|||
else node.data.selected = false
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
setNodes(newNodes, false)
|
||||
|
||||
const connectedEdges = getConnectedEdges(
|
||||
[{ id: nodeId } as Node],
|
||||
|
|
@ -345,12 +334,8 @@ export const useNodesInteractions = () => {
|
|||
}
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
|
||||
handleSyncWorkflowDraft()
|
||||
},
|
||||
[store, handleSyncWorkflowDraft],
|
||||
)
|
||||
setEdges(newEdges, false)
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const handleNodeClick = useCallback<NodeMouseHandler>(
|
||||
(_, node) => {
|
||||
|
|
@ -367,8 +352,7 @@ export const useNodesInteractions = () => {
|
|||
if (source === target) return
|
||||
if (getNodesReadOnly()) return
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, edges, setNodes, setEdges } = collaborativeWorkflow.getState()
|
||||
const targetNode = nodes.find(node => node.id === target!)
|
||||
const sourceNode = nodes.find(node => node.id === source!)
|
||||
|
||||
|
|
@ -454,7 +438,7 @@ export const useNodesInteractions = () => {
|
|||
},
|
||||
[
|
||||
getNodesReadOnly,
|
||||
store,
|
||||
collaborativeWorkflow,
|
||||
workflowStore,
|
||||
handleSyncWorkflowDraft,
|
||||
saveStateToHistory,
|
||||
|
|
@ -468,8 +452,8 @@ export const useNodesInteractions = () => {
|
|||
|
||||
if (nodeId && handleType) {
|
||||
const { setConnectingNodePayload } = workflowStore.getState()
|
||||
const { getNodes } = store.getState()
|
||||
const node = getNodes().find(n => n.id === nodeId)!
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
const node = nodes.find(n => n.id === nodeId)!
|
||||
|
||||
if (node.type === CUSTOM_NOTE_NODE) return
|
||||
|
||||
|
|
@ -486,9 +470,7 @@ export const useNodesInteractions = () => {
|
|||
handleId,
|
||||
})
|
||||
}
|
||||
},
|
||||
[store, workflowStore, getNodesReadOnly],
|
||||
)
|
||||
}, [collaborativeWorkflow, workflowStore, getNodesReadOnly])
|
||||
|
||||
const handleNodeConnectEnd = useCallback<OnConnectEnd>(
|
||||
(e: any) => {
|
||||
|
|
@ -504,8 +486,7 @@ export const useNodesInteractions = () => {
|
|||
const { setShowAssignVariablePopup, hoveringAssignVariableGroupId }
|
||||
= workflowStore.getState()
|
||||
const { screenToFlowPosition } = reactflow
|
||||
const { getNodes, setNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
const fromHandleType = connectingNodePayload.handleType
|
||||
const fromHandleId = connectingNodePayload.handleId
|
||||
const fromNode = nodes.find(
|
||||
|
|
@ -562,7 +543,7 @@ export const useNodesInteractions = () => {
|
|||
setConnectingNodePayload(undefined)
|
||||
setEnteringNodePayload(undefined)
|
||||
},
|
||||
[store, handleNodeConnect, getNodesReadOnly, workflowStore, reactflow],
|
||||
[collaborativeWorkflow, handleNodeConnect, getNodesReadOnly, workflowStore, reactflow],
|
||||
)
|
||||
|
||||
const { deleteNodeInspectorVars } = useInspectVarsCrud()
|
||||
|
|
@ -571,9 +552,7 @@ export const useNodesInteractions = () => {
|
|||
(nodeId: string) => {
|
||||
if (getNodesReadOnly()) return
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const currentNodeIndex = nodes.findIndex(node => node.id === nodeId)
|
||||
const currentNode = nodes[currentNodeIndex]
|
||||
|
||||
|
|
@ -728,7 +707,7 @@ export const useNodesInteractions = () => {
|
|||
},
|
||||
[
|
||||
getNodesReadOnly,
|
||||
store,
|
||||
collaborativeWorkflow,
|
||||
handleSyncWorkflowDraft,
|
||||
saveStateToHistory,
|
||||
workflowStore,
|
||||
|
|
@ -750,8 +729,7 @@ export const useNodesInteractions = () => {
|
|||
) => {
|
||||
if (getNodesReadOnly()) return
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const nodesWithSameType = nodes.filter(
|
||||
node => node.data.type === nodeType,
|
||||
)
|
||||
|
|
@ -1292,7 +1270,7 @@ export const useNodesInteractions = () => {
|
|||
},
|
||||
[
|
||||
getNodesReadOnly,
|
||||
store,
|
||||
collaborativeWorkflow,
|
||||
handleSyncWorkflowDraft,
|
||||
saveStateToHistory,
|
||||
workflowStore,
|
||||
|
|
@ -1311,8 +1289,7 @@ export const useNodesInteractions = () => {
|
|||
) => {
|
||||
if (getNodesReadOnly()) return
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const currentNode = nodes.find(node => node.id === currentNodeId)!
|
||||
const connectedEdges = getConnectedEdges([currentNode], edges)
|
||||
const nodesWithSameType = nodes.filter(
|
||||
|
|
@ -1390,7 +1367,7 @@ export const useNodesInteractions = () => {
|
|||
},
|
||||
[
|
||||
getNodesReadOnly,
|
||||
store,
|
||||
collaborativeWorkflow,
|
||||
handleSyncWorkflowDraft,
|
||||
saveStateToHistory,
|
||||
nodesMetaDataMap,
|
||||
|
|
@ -1398,16 +1375,14 @@ export const useNodesInteractions = () => {
|
|||
)
|
||||
|
||||
const handleNodesCancelSelected = useCallback(() => {
|
||||
const { getNodes, setNodes } = store.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
node.data.selected = false
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const handleNodeContextMenu = useCallback(
|
||||
(e: MouseEvent, node: Node) => {
|
||||
|
|
@ -1444,9 +1419,7 @@ export const useNodesInteractions = () => {
|
|||
|
||||
const { setClipboardElements } = workflowStore.getState()
|
||||
|
||||
const { getNodes } = store.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
|
||||
if (nodeId) {
|
||||
// If nodeId is provided, copy that specific node
|
||||
|
|
@ -1485,7 +1458,7 @@ export const useNodesInteractions = () => {
|
|||
if (selectedNode) setClipboardElements([selectedNode])
|
||||
}
|
||||
},
|
||||
[getNodesReadOnly, store, workflowStore],
|
||||
[getNodesReadOnly, collaborativeWorkflow, workflowStore],
|
||||
)
|
||||
|
||||
const handleNodesPaste = useCallback(() => {
|
||||
|
|
@ -1493,11 +1466,10 @@ export const useNodesInteractions = () => {
|
|||
|
||||
const { clipboardElements, mousePosition } = workflowStore.getState()
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
|
||||
const nodesToPaste: Node[] = []
|
||||
const edgesToPaste: Edge[] = []
|
||||
const nodes = getNodes()
|
||||
|
||||
if (clipboardElements.length) {
|
||||
const { x, y } = getTopLeftNodePosition(clipboardElements)
|
||||
|
|
@ -1649,7 +1621,7 @@ export const useNodesInteractions = () => {
|
|||
}, [
|
||||
getNodesReadOnly,
|
||||
workflowStore,
|
||||
store,
|
||||
collaborativeWorkflow,
|
||||
reactflow,
|
||||
saveStateToHistory,
|
||||
handleSyncWorkflowDraft,
|
||||
|
|
@ -1671,9 +1643,8 @@ export const useNodesInteractions = () => {
|
|||
const handleNodesDelete = useCallback(() => {
|
||||
if (getNodesReadOnly()) return
|
||||
|
||||
const { getNodes, edges } = store.getState()
|
||||
const { nodes, edges } = collaborativeWorkflow.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const bundledNodes = nodes.filter(
|
||||
node => node.data._isBundled && node.data.type !== BlockEnum.Start,
|
||||
)
|
||||
|
|
@ -1692,16 +1663,15 @@ export const useNodesInteractions = () => {
|
|||
)
|
||||
|
||||
if (selectedNode) handleNodeDelete(selectedNode.id)
|
||||
}, [store, getNodesReadOnly, handleNodeDelete])
|
||||
}, [collaborativeWorkflow, getNodesReadOnly, handleNodeDelete])
|
||||
|
||||
const handleNodeResize = useCallback(
|
||||
(nodeId: string, params: ResizeParamsWithDirection) => {
|
||||
if (getNodesReadOnly()) return
|
||||
|
||||
const { getNodes, setNodes } = store.getState()
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
const { x, y, width, height } = params
|
||||
|
||||
const nodes = getNodes()
|
||||
const currentNode = nodes.find(n => n.id === nodeId)!
|
||||
const childrenNodes = nodes.filter(n =>
|
||||
currentNode.data._children?.find((c: any) => c.nodeId === n.id),
|
||||
|
|
@ -1760,15 +1730,14 @@ export const useNodesInteractions = () => {
|
|||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeResize, { nodeId })
|
||||
},
|
||||
[getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory],
|
||||
[getNodesReadOnly, collaborativeWorkflow, handleSyncWorkflowDraft, saveStateToHistory],
|
||||
)
|
||||
|
||||
const handleNodeDisconnect = useCallback(
|
||||
(nodeId: string) => {
|
||||
if (getNodesReadOnly()) return
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const currentNode = nodes.find(node => node.id === nodeId)!
|
||||
const connectedEdges = getConnectedEdges([currentNode], edges)
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap
|
||||
|
|
@ -1799,24 +1768,24 @@ export const useNodesInteractions = () => {
|
|||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.EdgeDelete)
|
||||
},
|
||||
[store, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory],
|
||||
[collaborativeWorkflow, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory],
|
||||
)
|
||||
|
||||
const handleHistoryBack = useCallback(() => {
|
||||
if (getNodesReadOnly() || getWorkflowReadOnly()) return
|
||||
|
||||
const { setEdges, setNodes } = store.getState()
|
||||
undo()
|
||||
// Use collaborative undo from Loro
|
||||
const undoResult = collaborationManager.undo()
|
||||
|
||||
const { edges, nodes } = workflowHistoryStore.getState()
|
||||
if (edges.length === 0 && nodes.length === 0) return
|
||||
|
||||
setEdges(edges)
|
||||
setNodes(nodes)
|
||||
if (undoResult) {
|
||||
// The undo operation will automatically trigger subscriptions
|
||||
// which will update the nodes and edges through setupSubscriptions
|
||||
console.log('Collaborative undo performed')
|
||||
}
|
||||
else {
|
||||
console.log('Nothing to undo')
|
||||
}
|
||||
}, [
|
||||
store,
|
||||
undo,
|
||||
workflowHistoryStore,
|
||||
getNodesReadOnly,
|
||||
getWorkflowReadOnly,
|
||||
])
|
||||
|
|
@ -1824,18 +1793,18 @@ export const useNodesInteractions = () => {
|
|||
const handleHistoryForward = useCallback(() => {
|
||||
if (getNodesReadOnly() || getWorkflowReadOnly()) return
|
||||
|
||||
const { setEdges, setNodes } = store.getState()
|
||||
redo()
|
||||
// Use collaborative redo from Loro
|
||||
const redoResult = collaborationManager.redo()
|
||||
|
||||
const { edges, nodes } = workflowHistoryStore.getState()
|
||||
if (edges.length === 0 && nodes.length === 0) return
|
||||
|
||||
setEdges(edges)
|
||||
setNodes(nodes)
|
||||
if (redoResult) {
|
||||
// The redo operation will automatically trigger subscriptions
|
||||
// which will update the nodes and edges through setupSubscriptions
|
||||
console.log('Collaborative redo performed')
|
||||
}
|
||||
else {
|
||||
console.log('Nothing to redo')
|
||||
}
|
||||
}, [
|
||||
redo,
|
||||
store,
|
||||
workflowHistoryStore,
|
||||
getNodesReadOnly,
|
||||
getWorkflowReadOnly,
|
||||
])
|
||||
|
|
@ -1844,8 +1813,7 @@ export const useNodesInteractions = () => {
|
|||
/** Add opacity-30 to all nodes except the nodeId */
|
||||
const dimOtherNodes = useCallback(() => {
|
||||
if (isDimming) return
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
|
||||
const selectedNode = nodes.find(n => n.data.selected)
|
||||
if (!selectedNode) return
|
||||
|
|
@ -1938,12 +1906,11 @@ export const useNodesInteractions = () => {
|
|||
draft.push(...tempEdges)
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}, [isDimming, store])
|
||||
}, [isDimming, collaborativeWorkflow])
|
||||
|
||||
/** Restore all nodes to full opacity */
|
||||
const undimAllNodes = useCallback(() => {
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
setIsDimming(false)
|
||||
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
|
|
@ -1963,7 +1930,7 @@ export const useNodesInteractions = () => {
|
|||
},
|
||||
)
|
||||
setEdges(newEdges)
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
return {
|
||||
handleNodeDragStart,
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -0,0 +1,339 @@
|
|||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { useStore } from '../store'
|
||||
import { ControlMode } from '../types'
|
||||
import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
|
||||
import { createWorkflowComment, createWorkflowCommentReply, deleteWorkflowComment, deleteWorkflowCommentReply, fetchWorkflowComment, fetchWorkflowComments, resolveWorkflowComment, updateWorkflowCommentReply } from '@/service/workflow-comment'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration'
|
||||
|
||||
export const useWorkflowComment = () => {
|
||||
const params = useParams()
|
||||
const appId = params.appId as string
|
||||
const reactflow = useReactFlow()
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
const setControlMode = useStore(s => s.setControlMode)
|
||||
const pendingComment = useStore(s => s.pendingComment)
|
||||
const setPendingComment = useStore(s => s.setPendingComment)
|
||||
const setActiveCommentId = useStore(s => s.setActiveCommentId)
|
||||
const activeCommentId = useStore(s => s.activeCommentId)
|
||||
const comments = useStore(s => s.comments)
|
||||
const setComments = useStore(s => s.setComments)
|
||||
const loading = useStore(s => s.commentsLoading)
|
||||
const setCommentsLoading = useStore(s => s.setCommentsLoading)
|
||||
const activeComment = useStore(s => s.activeCommentDetail)
|
||||
const setActiveComment = useStore(s => s.setActiveCommentDetail)
|
||||
const activeCommentLoading = useStore(s => s.activeCommentDetailLoading)
|
||||
const setActiveCommentLoading = useStore(s => s.setActiveCommentDetailLoading)
|
||||
const commentDetailCache = useStore(s => s.commentDetailCache)
|
||||
const setCommentDetailCache = useStore(s => s.setCommentDetailCache)
|
||||
const commentDetailCacheRef = useRef<Record<string, WorkflowCommentDetail>>(commentDetailCache)
|
||||
const activeCommentIdRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
activeCommentIdRef.current = activeCommentId ?? null
|
||||
}, [activeCommentId])
|
||||
|
||||
useEffect(() => {
|
||||
commentDetailCacheRef.current = commentDetailCache
|
||||
}, [commentDetailCache])
|
||||
|
||||
const refreshActiveComment = useCallback(async (commentId: string) => {
|
||||
if (!appId) return
|
||||
|
||||
const detailResponse = await fetchWorkflowComment(appId, commentId)
|
||||
const detail = (detailResponse as any)?.data ?? detailResponse
|
||||
|
||||
commentDetailCacheRef.current = {
|
||||
...commentDetailCacheRef.current,
|
||||
[commentId]: detail,
|
||||
}
|
||||
setCommentDetailCache(commentDetailCacheRef.current)
|
||||
setActiveComment(detail)
|
||||
}, [appId, setActiveComment, setCommentDetailCache])
|
||||
|
||||
const loadComments = useCallback(async () => {
|
||||
if (!appId) return
|
||||
|
||||
setCommentsLoading(true)
|
||||
try {
|
||||
const commentsData = await fetchWorkflowComments(appId)
|
||||
setComments(commentsData)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to fetch comments:', error)
|
||||
}
|
||||
finally {
|
||||
setCommentsLoading(false)
|
||||
}
|
||||
}, [appId, setComments, setCommentsLoading])
|
||||
|
||||
// Setup collaboration
|
||||
useEffect(() => {
|
||||
if (!appId) return
|
||||
|
||||
const unsubscribe = collaborationManager.onCommentsUpdate(() => {
|
||||
loadComments()
|
||||
if (activeCommentIdRef.current)
|
||||
refreshActiveComment(activeCommentIdRef.current)
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appId, loadComments, refreshActiveComment])
|
||||
|
||||
useEffect(() => {
|
||||
loadComments()
|
||||
}, [loadComments])
|
||||
|
||||
const handleCommentSubmit = useCallback(async (content: string, mentionedUserIds: string[] = []) => {
|
||||
if (!pendingComment) return
|
||||
|
||||
console.log('Submitting comment:', { appId, pendingComment, content, mentionedUserIds })
|
||||
|
||||
if (!appId) {
|
||||
console.error('AppId is missing')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert screen position to flow position when submitting
|
||||
const { screenToFlowPosition } = reactflow
|
||||
const flowPosition = screenToFlowPosition({ x: pendingComment.x, y: pendingComment.y })
|
||||
|
||||
const newComment = await createWorkflowComment(appId, {
|
||||
position_x: flowPosition.x,
|
||||
position_y: flowPosition.y,
|
||||
content,
|
||||
mentioned_user_ids: mentionedUserIds,
|
||||
})
|
||||
|
||||
console.log('Comment created successfully:', newComment)
|
||||
|
||||
collaborationManager.emitCommentsUpdate(appId)
|
||||
|
||||
await loadComments()
|
||||
setPendingComment(null)
|
||||
setControlMode(ControlMode.Pointer)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to create comment:', error)
|
||||
setPendingComment(null)
|
||||
setControlMode(ControlMode.Pointer)
|
||||
}
|
||||
}, [appId, pendingComment, setControlMode, setPendingComment, loadComments, reactflow])
|
||||
|
||||
const handleCommentCancel = useCallback(() => {
|
||||
setPendingComment(null)
|
||||
setControlMode(ControlMode.Pointer)
|
||||
}, [setControlMode, setPendingComment])
|
||||
|
||||
const handleCommentIconClick = useCallback(async (comment: WorkflowCommentList) => {
|
||||
setPendingComment(null)
|
||||
|
||||
activeCommentIdRef.current = comment.id
|
||||
setControlMode(ControlMode.Comment)
|
||||
setActiveCommentId(comment.id)
|
||||
|
||||
const cachedDetail = commentDetailCacheRef.current[comment.id]
|
||||
setActiveComment(cachedDetail || comment)
|
||||
|
||||
reactflow.setCenter(comment.position_x, comment.position_y, { zoom: 1, duration: 600 })
|
||||
|
||||
if (!appId) return
|
||||
|
||||
setActiveCommentLoading(!cachedDetail)
|
||||
|
||||
try {
|
||||
const detailResponse = await fetchWorkflowComment(appId, comment.id)
|
||||
const detail = (detailResponse as any)?.data ?? detailResponse
|
||||
|
||||
commentDetailCacheRef.current = {
|
||||
...commentDetailCacheRef.current,
|
||||
[comment.id]: detail,
|
||||
}
|
||||
setCommentDetailCache(commentDetailCacheRef.current)
|
||||
|
||||
if (activeCommentIdRef.current === comment.id)
|
||||
setActiveComment(detail)
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('Failed to load workflow comment detail', e)
|
||||
}
|
||||
finally {
|
||||
setActiveCommentLoading(false)
|
||||
}
|
||||
}, [appId, reactflow, setActiveComment, setActiveCommentId, setActiveCommentLoading, setCommentDetailCache, setControlMode, setPendingComment])
|
||||
|
||||
const handleCommentResolve = useCallback(async (commentId: string) => {
|
||||
if (!appId) return
|
||||
|
||||
setActiveCommentLoading(true)
|
||||
try {
|
||||
await resolveWorkflowComment(appId, commentId)
|
||||
|
||||
collaborationManager.emitCommentsUpdate(appId)
|
||||
|
||||
await refreshActiveComment(commentId)
|
||||
await loadComments()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to resolve comment:', error)
|
||||
}
|
||||
finally {
|
||||
setActiveCommentLoading(false)
|
||||
}
|
||||
}, [appId, loadComments, refreshActiveComment, setActiveCommentLoading])
|
||||
|
||||
const handleCommentDelete = useCallback(async (commentId: string) => {
|
||||
if (!appId) return
|
||||
|
||||
setActiveCommentLoading(true)
|
||||
try {
|
||||
await deleteWorkflowComment(appId, commentId)
|
||||
|
||||
collaborationManager.emitCommentsUpdate(appId)
|
||||
|
||||
const updatedCache = { ...commentDetailCacheRef.current }
|
||||
delete updatedCache[commentId]
|
||||
commentDetailCacheRef.current = updatedCache
|
||||
setCommentDetailCache(updatedCache)
|
||||
|
||||
const currentComments = comments.filter(c => c.id !== commentId)
|
||||
const commentIndex = comments.findIndex(c => c.id === commentId)
|
||||
const fallbackTarget = commentIndex >= 0 ? comments[commentIndex + 1] ?? comments[commentIndex - 1] : undefined
|
||||
|
||||
await loadComments()
|
||||
|
||||
if (fallbackTarget) {
|
||||
handleCommentIconClick(fallbackTarget)
|
||||
}
|
||||
else if (currentComments.length > 0) {
|
||||
const nextComment = currentComments[0]
|
||||
handleCommentIconClick(nextComment)
|
||||
}
|
||||
else {
|
||||
setActiveComment(null)
|
||||
setActiveCommentId(null)
|
||||
activeCommentIdRef.current = null
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to delete comment:', error)
|
||||
}
|
||||
finally {
|
||||
setActiveCommentLoading(false)
|
||||
}
|
||||
}, [appId, comments, handleCommentIconClick, loadComments, setActiveComment, setActiveCommentId, setActiveCommentLoading, setCommentDetailCache])
|
||||
|
||||
const handleCommentReply = useCallback(async (commentId: string, content: string, mentionedUserIds: string[] = []) => {
|
||||
if (!appId) return
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
setActiveCommentLoading(true)
|
||||
try {
|
||||
await createWorkflowCommentReply(appId, commentId, { content: trimmed, mentioned_user_ids: mentionedUserIds })
|
||||
|
||||
collaborationManager.emitCommentsUpdate(appId)
|
||||
|
||||
await refreshActiveComment(commentId)
|
||||
await loadComments()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to create reply:', error)
|
||||
}
|
||||
finally {
|
||||
setActiveCommentLoading(false)
|
||||
}
|
||||
}, [appId, loadComments, refreshActiveComment, setActiveCommentLoading])
|
||||
|
||||
const handleCommentReplyUpdate = useCallback(async (commentId: string, replyId: string, content: string, mentionedUserIds: string[] = []) => {
|
||||
if (!appId) return
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
setActiveCommentLoading(true)
|
||||
try {
|
||||
await updateWorkflowCommentReply(appId, commentId, replyId, { content: trimmed, mentioned_user_ids: mentionedUserIds })
|
||||
|
||||
collaborationManager.emitCommentsUpdate(appId)
|
||||
|
||||
await refreshActiveComment(commentId)
|
||||
await loadComments()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to update reply:', error)
|
||||
}
|
||||
finally {
|
||||
setActiveCommentLoading(false)
|
||||
}
|
||||
}, [appId, loadComments, refreshActiveComment, setActiveCommentLoading])
|
||||
|
||||
const handleCommentReplyDelete = useCallback(async (commentId: string, replyId: string) => {
|
||||
if (!appId) return
|
||||
|
||||
setActiveCommentLoading(true)
|
||||
try {
|
||||
await deleteWorkflowCommentReply(appId, commentId, replyId)
|
||||
|
||||
collaborationManager.emitCommentsUpdate(appId)
|
||||
|
||||
await refreshActiveComment(commentId)
|
||||
await loadComments()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to delete reply:', error)
|
||||
}
|
||||
finally {
|
||||
setActiveCommentLoading(false)
|
||||
}
|
||||
}, [appId, loadComments, refreshActiveComment, setActiveCommentLoading])
|
||||
|
||||
const handleCommentNavigate = useCallback((direction: 'prev' | 'next') => {
|
||||
const currentId = activeCommentIdRef.current
|
||||
if (!currentId) return
|
||||
const idx = comments.findIndex(c => c.id === currentId)
|
||||
if (idx === -1) return
|
||||
const target = direction === 'prev' ? comments[idx - 1] : comments[idx + 1]
|
||||
if (target)
|
||||
handleCommentIconClick(target)
|
||||
}, [comments, handleCommentIconClick])
|
||||
|
||||
const handleActiveCommentClose = useCallback(() => {
|
||||
setActiveComment(null)
|
||||
setActiveCommentLoading(false)
|
||||
setActiveCommentId(null)
|
||||
activeCommentIdRef.current = null
|
||||
}, [setActiveComment, setActiveCommentId, setActiveCommentLoading])
|
||||
|
||||
const handleCreateComment = useCallback((mousePosition: { elementX: number; elementY: number }) => {
|
||||
if (controlMode === ControlMode.Comment) {
|
||||
console.log('Setting pending comment at screen position:', mousePosition)
|
||||
setPendingComment({ x: mousePosition.elementX, y: mousePosition.elementY })
|
||||
}
|
||||
else {
|
||||
console.log('Control mode is not Comment:', controlMode)
|
||||
}
|
||||
}, [controlMode, setPendingComment])
|
||||
|
||||
return {
|
||||
comments,
|
||||
loading,
|
||||
pendingComment,
|
||||
activeComment,
|
||||
activeCommentLoading,
|
||||
handleCommentSubmit,
|
||||
handleCommentCancel,
|
||||
handleCommentIconClick,
|
||||
handleActiveCommentClose,
|
||||
handleCommentResolve,
|
||||
handleCommentDelete,
|
||||
handleCommentNavigate,
|
||||
handleCommentReply,
|
||||
handleCommentReplyUpdate,
|
||||
handleCommentReplyDelete,
|
||||
refreshActiveComment,
|
||||
handleCreateComment,
|
||||
loadComments,
|
||||
}
|
||||
}
|
||||
|
|
@ -67,12 +67,15 @@ import CustomEdge from './custom-edge'
|
|||
import CustomConnectionLine from './custom-connection-line'
|
||||
import HelpLine from './help-line'
|
||||
import CandidateNode from './candidate-node'
|
||||
import CommentManager from './comment-manager'
|
||||
import PanelContextmenu from './panel-contextmenu'
|
||||
import NodeContextmenu from './node-contextmenu'
|
||||
import SelectionContextmenu from './selection-contextmenu'
|
||||
import SyncingDataModal from './syncing-data-modal'
|
||||
import LimitTips from './limit-tips'
|
||||
import { setupScrollToNodeListener } from './utils/node-navigation'
|
||||
import { CommentCursor, CommentIcon, CommentInput, CommentThread } from './comment'
|
||||
import { useWorkflowComment } from './hooks/use-workflow-comment'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
|
|
@ -127,6 +130,9 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||
const workflowContainerRef = useRef<HTMLDivElement>(null)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const reactflow = useReactFlow()
|
||||
const [isMouseOverCanvas, setIsMouseOverCanvas] = useState(false)
|
||||
const [pendingDeleteCommentId, setPendingDeleteCommentId] = useState<string | null>(null)
|
||||
const [pendingDeleteReply, setPendingDeleteReply] = useState<{ commentId: string; replyId: string } | null>(null)
|
||||
const [nodes, setNodes] = useNodesState(originalNodes)
|
||||
const [edges, setEdges] = useEdgesState(originalEdges)
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
|
|
@ -171,6 +177,23 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||
const { workflowReadOnly } = useWorkflowReadOnly()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const {
|
||||
comments,
|
||||
pendingComment,
|
||||
activeComment,
|
||||
activeCommentLoading,
|
||||
handleCommentSubmit,
|
||||
handleCommentCancel,
|
||||
handleCommentIconClick,
|
||||
handleActiveCommentClose,
|
||||
handleCommentResolve,
|
||||
handleCommentDelete,
|
||||
handleCommentNavigate,
|
||||
handleCommentReply,
|
||||
handleCommentReplyUpdate,
|
||||
handleCommentReplyDelete,
|
||||
} = useWorkflowComment()
|
||||
const mousePosition = useStore(s => s.mousePosition)
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v.type === WORKFLOW_DATA_UPDATE) {
|
||||
|
|
@ -241,6 +264,9 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||
elementY: e.clientY - containerClientRect.top,
|
||||
},
|
||||
})
|
||||
const target = e.target as HTMLElement
|
||||
const onPane = !!target?.closest('.react-flow__pane')
|
||||
setIsMouseOverCanvas(onPane)
|
||||
}
|
||||
})
|
||||
const { handleFetchAllTools } = useFetchToolsData()
|
||||
|
|
@ -356,6 +382,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||
>
|
||||
<SyncingDataModal />
|
||||
<CandidateNode />
|
||||
<CommentManager />
|
||||
<div
|
||||
className='pointer-events-none absolute left-0 top-0 z-10 flex w-12 items-center justify-center p-1 pl-2'
|
||||
style={{ height: controlHeight }}
|
||||
|
|
@ -367,24 +394,90 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||
<NodeContextmenu />
|
||||
<SelectionContextmenu />
|
||||
<HelpLine />
|
||||
{
|
||||
!!showConfirm && (
|
||||
<Confirm
|
||||
isShow
|
||||
onCancel={() => setShowConfirm(undefined)}
|
||||
onConfirm={showConfirm.onConfirm}
|
||||
title={showConfirm.title}
|
||||
content={showConfirm.desc}
|
||||
{!!showConfirm && (
|
||||
<Confirm
|
||||
isShow
|
||||
onCancel={() => setShowConfirm(undefined)}
|
||||
onConfirm={showConfirm.onConfirm}
|
||||
title={showConfirm.title}
|
||||
content={showConfirm.desc}
|
||||
/>
|
||||
)}
|
||||
{pendingDeleteCommentId && (
|
||||
<Confirm
|
||||
isShow
|
||||
title='Delete this thread?'
|
||||
content='This action will permanently delete the thread and all its replies. This cannot be undone.'
|
||||
onCancel={() => setPendingDeleteCommentId(null)}
|
||||
onConfirm={async () => {
|
||||
await handleCommentDelete(pendingDeleteCommentId)
|
||||
setPendingDeleteCommentId(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{pendingDeleteReply && (
|
||||
<Confirm
|
||||
isShow
|
||||
title='Delete this reply?'
|
||||
content='This reply will be removed permanently.'
|
||||
onCancel={() => setPendingDeleteReply(null)}
|
||||
onConfirm={async () => {
|
||||
await handleCommentReplyDelete(pendingDeleteReply.commentId, pendingDeleteReply.replyId)
|
||||
setPendingDeleteReply(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<LimitTips />
|
||||
{controlMode === ControlMode.Comment && isMouseOverCanvas && (
|
||||
<CommentCursor mousePosition={mousePosition} />
|
||||
)}
|
||||
{pendingComment && (
|
||||
<CommentInput
|
||||
position={pendingComment}
|
||||
onSubmit={handleCommentSubmit}
|
||||
onCancel={handleCommentCancel}
|
||||
/>
|
||||
)}
|
||||
{comments.map((comment, index) => {
|
||||
const isActive = activeComment?.id === comment.id
|
||||
|
||||
if (isActive && activeComment) {
|
||||
const canGoPrev = index > 0
|
||||
const canGoNext = index < comments.length - 1
|
||||
return (
|
||||
<CommentThread
|
||||
key={comment.id}
|
||||
comment={activeComment}
|
||||
loading={activeCommentLoading}
|
||||
onClose={handleActiveCommentClose}
|
||||
onResolve={() => handleCommentResolve(comment.id)}
|
||||
onDelete={() => setPendingDeleteCommentId(comment.id)}
|
||||
onPrev={canGoPrev ? () => handleCommentNavigate('prev') : undefined}
|
||||
onNext={canGoNext ? () => handleCommentNavigate('next') : undefined}
|
||||
onReply={(content, ids) => handleCommentReply(comment.id, content, ids ?? [])}
|
||||
onReplyEdit={(replyId, content, ids) => handleCommentReplyUpdate(comment.id, replyId, content, ids ?? [])}
|
||||
onReplyDelete={replyId => setPendingDeleteReply({ commentId: comment.id, replyId })}
|
||||
canGoPrev={canGoPrev}
|
||||
canGoNext={canGoNext}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CommentIcon
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
onClick={() => handleCommentIconClick(comment)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<LimitTips />
|
||||
})}
|
||||
{children}
|
||||
<ReactFlow
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
className={controlMode === ControlMode.Comment ? 'comment-mode-flow' : ''}
|
||||
onNodeDragStart={handleNodeDragStart}
|
||||
onNodeDrag={handleNodeDrag}
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,10 @@ import { DataSourceClassification } from '@/app/components/workflow/nodes/data-s
|
|||
import { useModalContext } from '@/context/modal-context'
|
||||
import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form'
|
||||
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
||||
import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
|
||||
const getCustomRunForm = (params: CustomRunFormProps): React.JSX.Element => {
|
||||
const nodeType = params.payload.type
|
||||
|
|
@ -97,11 +101,51 @@ const BasePanel: FC<BasePanelProps> = ({
|
|||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const appId = useStore(s => s.appId)
|
||||
const { userProfile } = useAppContext()
|
||||
const { isConnected, nodePanelPresence } = useCollaboration(appId as string)
|
||||
const { showMessageLogModal } = useAppStore(useShallow(state => ({
|
||||
showMessageLogModal: state.showMessageLogModal,
|
||||
})))
|
||||
const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running
|
||||
|
||||
const currentUserPresence = useMemo(() => {
|
||||
const userId = userProfile?.id || ''
|
||||
const username = userProfile?.name || userProfile?.email || 'User'
|
||||
const avatar = userProfile?.avatar_url || userProfile?.avatar || null
|
||||
|
||||
return {
|
||||
userId,
|
||||
username,
|
||||
avatar,
|
||||
}
|
||||
}, [userProfile?.avatar, userProfile?.avatar_url, userProfile?.email, userProfile?.id, userProfile?.name])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !currentUserPresence.userId)
|
||||
return
|
||||
|
||||
collaborationManager.emitNodePanelPresence(id, true, currentUserPresence)
|
||||
|
||||
return () => {
|
||||
collaborationManager.emitNodePanelPresence(id, false, currentUserPresence)
|
||||
}
|
||||
}, [id, isConnected, currentUserPresence])
|
||||
|
||||
const viewingUsers = useMemo(() => {
|
||||
const presence = nodePanelPresence?.[id]
|
||||
if (!presence)
|
||||
return []
|
||||
|
||||
return Object.values(presence)
|
||||
.filter(viewer => viewer.userId && viewer.userId !== currentUserPresence.userId)
|
||||
.map(viewer => ({
|
||||
id: viewer.userId,
|
||||
name: viewer.username,
|
||||
avatar_url: viewer.avatar || null,
|
||||
}))
|
||||
}, [currentUserPresence.userId, id, nodePanelPresence])
|
||||
|
||||
const showSingleRunPanel = useStore(s => s.showSingleRunPanel)
|
||||
const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth)
|
||||
const nodePanelWidth = useStore(s => s.nodePanelWidth)
|
||||
|
|
@ -393,6 +437,15 @@ const BasePanel: FC<BasePanelProps> = ({
|
|||
value={data.title || ''}
|
||||
onBlur={handleTitleBlur}
|
||||
/>
|
||||
{viewingUsers.length > 0 && (
|
||||
<div className='ml-3 shrink-0'>
|
||||
<UserAvatarList
|
||||
users={viewingUsers}
|
||||
maxVisible={3}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex shrink-0 items-center text-text-tertiary'>
|
||||
{
|
||||
isSupportSingleRun && !nodesReadOnly && (
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ import BlockIcon from '@/app/components/workflow/block-icon'
|
|||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud'
|
||||
import { ToolTypeEnum } from '../../block-selector/types'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration'
|
||||
|
||||
type BaseNodeProps = {
|
||||
children: ReactElement
|
||||
|
|
@ -65,6 +69,35 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||
const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions()
|
||||
const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions()
|
||||
const toolIcon = useToolIcon(data)
|
||||
const { userProfile } = useAppContext()
|
||||
const appId = useStore(s => s.appId)
|
||||
const { nodePanelPresence } = useCollaboration(appId as string)
|
||||
|
||||
const currentUserPresence = useMemo(() => {
|
||||
const userId = userProfile?.id || ''
|
||||
const username = userProfile?.name || userProfile?.email || 'User'
|
||||
const avatar = userProfile?.avatar_url || userProfile?.avatar || null
|
||||
|
||||
return {
|
||||
userId,
|
||||
username,
|
||||
avatar,
|
||||
}
|
||||
}, [userProfile?.avatar, userProfile?.avatar_url, userProfile?.email, userProfile?.id, userProfile?.name])
|
||||
|
||||
const viewingUsers = useMemo(() => {
|
||||
const presence = nodePanelPresence?.[id]
|
||||
if (!presence)
|
||||
return []
|
||||
|
||||
return Object.values(presence)
|
||||
.filter(viewer => viewer.userId && viewer.userId !== currentUserPresence.userId)
|
||||
.map(viewer => ({
|
||||
id: viewer.userId,
|
||||
name: viewer.username,
|
||||
avatar_url: viewer.avatar || null,
|
||||
}))
|
||||
}, [currentUserPresence.userId, id, nodePanelPresence])
|
||||
|
||||
useEffect(() => {
|
||||
if (nodeRef.current && data.selected && data.isInIteration) {
|
||||
|
|
@ -237,7 +270,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||
/>
|
||||
<div
|
||||
title={data.title}
|
||||
className='system-sm-semibold-uppercase mr-1 flex grow items-center truncate text-text-primary'
|
||||
className='system-sm-semibold-uppercase mr-1 flex grow items-center justify-between truncate text-text-primary'
|
||||
>
|
||||
<div>
|
||||
{data.title}
|
||||
|
|
@ -258,6 +291,15 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{viewingUsers.length > 0 && (
|
||||
<div className='ml-3 shrink-0'>
|
||||
<UserAvatarList
|
||||
users={viewingUsers}
|
||||
maxVisible={3}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
data._iterationLength && data._iterationIndex && data._runningStatus === NodeRunningStatus.Running && (
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
RiCursorLine,
|
||||
RiFunctionAddLine,
|
||||
RiHand,
|
||||
RiMessage3Line,
|
||||
RiStickyNoteAddLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
|
|
@ -34,7 +35,7 @@ const Control = () => {
|
|||
const maximizeCanvas = useStore(s => s.maximizeCanvas)
|
||||
const { handleModePointer, handleModeHand } = useWorkflowMoveMode()
|
||||
const { handleLayout } = useWorkflowOrganize()
|
||||
const { handleAddNote } = useOperator()
|
||||
const { handleAddNote, handleAddComment } = useOperator()
|
||||
const {
|
||||
nodesReadOnly,
|
||||
getNodesReadOnly,
|
||||
|
|
@ -49,6 +50,14 @@ const Control = () => {
|
|||
handleAddNote()
|
||||
}
|
||||
|
||||
const addComment = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
e.stopPropagation()
|
||||
handleAddComment()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='pointer-events-auto flex flex-col items-center rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 text-text-tertiary shadow-lg'>
|
||||
<AddBlock />
|
||||
|
|
@ -88,6 +97,18 @@ const Control = () => {
|
|||
<RiHand className='h-4 w-4' />
|
||||
</div>
|
||||
</TipPopup>
|
||||
<TipPopup title={t('workflow.common.commentMode')} shortcuts={['c']}>
|
||||
<div
|
||||
className={cn(
|
||||
'ml-[1px] flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg',
|
||||
controlMode === ControlMode.Comment ? 'bg-state-accent-active text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary',
|
||||
`${nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`,
|
||||
)}
|
||||
onClick={addComment}
|
||||
>
|
||||
<RiMessage3Line className='h-4 w-4' />
|
||||
</div>
|
||||
</TipPopup>
|
||||
<Divider className='my-1 w-3.5' />
|
||||
<ExportImage />
|
||||
<TipPopup title={t('workflow.panel.organizeBlocks')} shortcuts={['ctrl', 'o']}>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { NoteNodeType } from '../note-node/types'
|
|||
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
|
||||
import { NoteTheme } from '../note-node/types'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { ControlMode } from '../types'
|
||||
|
||||
export const useOperator = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
|
@ -35,7 +36,14 @@ export const useOperator = () => {
|
|||
})
|
||||
}, [workflowStore, userProfile])
|
||||
|
||||
const handleAddComment = useCallback(() => {
|
||||
workflowStore.setState({
|
||||
controlMode: ControlMode.Comment,
|
||||
})
|
||||
}, [workflowStore])
|
||||
|
||||
return {
|
||||
handleAddNote,
|
||||
handleAddComment,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,11 +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()
|
||||
|
|
@ -32,17 +33,10 @@ const ChatVariablePanel = () => {
|
|||
const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel)
|
||||
const varList = useStore(s => s.conversationVariables) as ConversationVariable[]
|
||||
const updateChatVarList = useStore(s => s.setConversationVariables)
|
||||
const { doSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const appId = useStore(s => s.appId)
|
||||
const {
|
||||
invalidateConversationVarValues,
|
||||
} = useInspectVarsCrud()
|
||||
const handleVarChanged = useCallback(() => {
|
||||
doSyncWorkflowDraft(false, {
|
||||
onSuccess() {
|
||||
invalidateConversationVarValues()
|
||||
},
|
||||
})
|
||||
}, [doSyncWorkflowDraft, invalidateConversationVarValues])
|
||||
|
||||
const [showTip, setShowTip] = useState(true)
|
||||
const [showVariableModal, setShowVariableModal] = useState(false)
|
||||
|
|
@ -77,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)
|
||||
|
|
@ -97,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)
|
||||
|
|
@ -119,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 (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { RiCheckLine, RiCheckboxCircleFill, RiCheckboxCircleLine, RiCloseLine, RiFilter3Line } from '@remixicon/react'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import type { WorkflowCommentList } from '@/service/workflow-comment'
|
||||
import { useWorkflowComment } from '@/app/components/workflow/hooks/use-workflow-comment'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import cn from '@/utils/classnames'
|
||||
import { ControlMode } from '@/app/components/workflow/types'
|
||||
import { resolveWorkflowComment } from '@/service/workflow-comment'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useFormatTimeFromNow } from '@/app/components/workflow/hooks'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration'
|
||||
|
||||
const CommentsPanel = () => {
|
||||
const activeCommentId = useStore(s => s.activeCommentId)
|
||||
const setActiveCommentId = useStore(s => s.setActiveCommentId)
|
||||
const setControlMode = useStore(s => s.setControlMode)
|
||||
const { comments, loading, loadComments, handleCommentIconClick } = useWorkflowComment()
|
||||
const params = useParams()
|
||||
const appId = params.appId as string
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
|
||||
const [filter, setFilter] = useState<'all' | 'unresolved' | 'mine'>('all')
|
||||
const [showFilter, setShowFilter] = useState(false)
|
||||
|
||||
const handleSelect = useCallback((comment: WorkflowCommentList) => {
|
||||
handleCommentIconClick(comment)
|
||||
}, [handleCommentIconClick])
|
||||
|
||||
const { userProfile } = useAppContext()
|
||||
|
||||
const filteredSorted = useMemo(() => {
|
||||
let data = comments
|
||||
if (filter === 'unresolved')
|
||||
data = data.filter(c => !c.resolved)
|
||||
else if (filter === 'mine')
|
||||
data = data.filter(c => c.created_by === userProfile?.id)
|
||||
return data
|
||||
}, [comments, filter, userProfile?.id])
|
||||
|
||||
const handleResolve = useCallback(async (comment: WorkflowCommentList) => {
|
||||
if (comment.resolved) return
|
||||
if (!appId) return
|
||||
try {
|
||||
await resolveWorkflowComment(appId, comment.id)
|
||||
|
||||
collaborationManager.emitCommentsUpdate(appId)
|
||||
|
||||
await loadComments()
|
||||
setActiveCommentId(comment.id)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Resolve comment failed', e)
|
||||
}
|
||||
}, [appId, loadComments, setActiveCommentId])
|
||||
|
||||
const handleFilterChange = useCallback((value: 'all' | 'unresolved' | 'mine') => {
|
||||
setFilter(value)
|
||||
setShowFilter(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex h-full w-[420px] flex-col rounded-l-2xl border border-components-panel-border bg-components-panel-bg')}>
|
||||
<div className='flex items-center justify-between p-4 pb-2'>
|
||||
<div className='system-xl-semibold text-text-primary'>Comments</div>
|
||||
<div className='relative flex items-center gap-2'>
|
||||
<button
|
||||
className='flex h-8 w-8 items-center justify-center rounded-md bg-white hover:bg-state-base-hover'
|
||||
aria-label='Filter comments'
|
||||
onClick={() => setShowFilter(v => !v)}
|
||||
>
|
||||
<RiFilter3Line className='h-4 w-4 text-text-secondary' />
|
||||
</button>
|
||||
{showFilter && (
|
||||
<div className='absolute right-10 top-9 z-50 w-40 rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg'>
|
||||
<button
|
||||
className={cn('flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm hover:bg-state-base-hover', filter === 'all' && 'bg-components-panel-on-panel-item-bg')}
|
||||
onClick={() => handleFilterChange('all')}
|
||||
>
|
||||
<span>All</span>
|
||||
{filter === 'all' && <RiCheckLine className='h-4 w-4 text-text-secondary' />}
|
||||
</button>
|
||||
<button
|
||||
className={cn('mt-1 flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm hover:bg-state-base-hover', filter === 'unresolved' && 'bg-components-panel-on-panel-item-bg')}
|
||||
onClick={() => handleFilterChange('unresolved')}
|
||||
>
|
||||
<span>Unresolved</span>
|
||||
{filter === 'unresolved' && <RiCheckLine className='h-4 w-4 text-text-secondary' />}
|
||||
</button>
|
||||
<button
|
||||
className={cn('mt-1 flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm hover:bg-state-base-hover', filter === 'mine' && 'bg-components-panel-on-panel-item-bg')}
|
||||
onClick={() => handleFilterChange('mine')}
|
||||
>
|
||||
<span>Only your threads</span>
|
||||
{filter === 'mine' && <RiCheckLine className='h-4 w-4 text-text-secondary' />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className='flex h-6 w-6 cursor-pointer items-center justify-center'
|
||||
onClick={() => {
|
||||
setControlMode(ControlMode.Pointer)
|
||||
setActiveCommentId(null)
|
||||
}}
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grow overflow-y-auto px-1'>
|
||||
{filteredSorted.map((c) => {
|
||||
const isActive = activeCommentId === c.id
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={cn('group mb-2 cursor-pointer rounded-xl bg-components-panel-bg p-3 transition-colors hover:bg-components-panel-on-panel-item-bg-hover', isActive && 'bg-components-panel-on-panel-item-bg-hover')}
|
||||
onClick={() => handleSelect(c)}
|
||||
>
|
||||
<div className='min-w-0'>
|
||||
<div className='mb-1 flex items-center justify-between'>
|
||||
<UserAvatarList
|
||||
users={c.participants}
|
||||
maxVisible={3}
|
||||
size={24}
|
||||
/>
|
||||
<div className='ml-2 flex items-center'>
|
||||
{c.resolved ? (
|
||||
<RiCheckboxCircleFill className='h-4 w-4 text-text-secondary'/>
|
||||
) : (
|
||||
<RiCheckboxCircleLine
|
||||
className='h-4 w-4 cursor-pointer text-text-tertiary hover:text-text-secondary'
|
||||
onClick={() => handleResolve(c)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Header row: creator + time */}
|
||||
<div className='flex items-start'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<div className='system-sm-medium truncate text-text-primary'>{c.created_by_account.name}</div>
|
||||
<div className='system-2xs-regular shrink-0 text-text-tertiary'>
|
||||
{formatTimeFromNow(c.updated_at * 1000)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className='system-sm-regular mt-1 line-clamp-3 break-words text-text-secondary'>{c.content}</div>
|
||||
{/* Footer */}
|
||||
<div className='mt-2 flex items-center justify-between'>
|
||||
<div className='system-2xs-regular text-text-tertiary'>
|
||||
{c.reply_count} replies
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!loading && filteredSorted.length === 0 && (
|
||||
<div className='system-sm-regular mt-6 text-center text-text-tertiary'>No comments yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CommentsPanel)
|
||||
|
|
@ -17,7 +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()
|
||||
|
|
@ -27,7 +29,7 @@ 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)
|
||||
const [currentVar, setCurrentVar] = useState<EnvironmentVariable>()
|
||||
|
|
@ -65,18 +67,41 @@ const EnvPanel = () => {
|
|||
setShowVariableModal(true)
|
||||
}
|
||||
|
||||
const handleDelete = useCallback((env: EnvironmentVariable) => {
|
||||
const handleDelete = useCallback(async (env: EnvironmentVariable) => {
|
||||
removeUsedVarInNodes(env)
|
||||
updateEnvList(envList.filter(e => e.id !== env.id))
|
||||
const newEnvList = envList.filter(e => e.id !== env.id)
|
||||
updateEnvList(newEnvList)
|
||||
setCacheForDelete(undefined)
|
||||
setShowRemoveConfirm(false)
|
||||
doSyncWorkflowDraft()
|
||||
|
||||
// Use new dedicated environment variables API instead of workflow draft sync
|
||||
try {
|
||||
await updateEnvironmentVariables({
|
||||
appId,
|
||||
environmentVariables: newEnvList,
|
||||
})
|
||||
|
||||
// Emit update event to other connected clients
|
||||
const socket = webSocketClient.getSocket(appId)
|
||||
if (socket?.connected) {
|
||||
socket.emit('collaboration_event', {
|
||||
type: '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') {
|
||||
const newMap = { ...envSecrets }
|
||||
delete newMap[env.id]
|
||||
setEnvSecrets(newMap)
|
||||
}
|
||||
}, [doSyncWorkflowDraft, envList, envSecrets, removeUsedVarInNodes, setEnvSecrets, updateEnvList])
|
||||
}, [envList, envSecrets, removeUsedVarInNodes, setEnvSecrets, updateEnvList, appId])
|
||||
|
||||
const deleteCheck = useCallback((env: EnvironmentVariable) => {
|
||||
const effectedNodes = getEffectedNodes(env)
|
||||
|
|
@ -92,20 +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()
|
||||
updateEnvList(newList.map(e => (e.id === env.id && env.value_type === 'secret') ? { ...e, value: '[__HIDDEN__]' } : e))
|
||||
|
||||
// Use new dedicated environment variables API
|
||||
try {
|
||||
await updateEnvironmentVariables({
|
||||
appId,
|
||||
environmentVariables: newList,
|
||||
})
|
||||
|
||||
const socket = webSocketClient.getSocket(appId)
|
||||
if (socket) {
|
||||
socket.emit('collaboration_event', {
|
||||
type: '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)
|
||||
}
|
||||
return
|
||||
}
|
||||
else if (currentVar.value_type === 'secret') {
|
||||
|
||||
// Updating existing environment variable
|
||||
if (currentVar.value_type === 'secret') {
|
||||
if (env.value_type === 'secret') {
|
||||
if (envSecrets[currentVar.id] !== env.value) {
|
||||
newEnv = env
|
||||
|
|
@ -128,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()
|
||||
|
|
@ -142,9 +195,30 @@ const EnvPanel = () => {
|
|||
})
|
||||
setNodes(newNodes)
|
||||
}
|
||||
await doSyncWorkflowDraft()
|
||||
updateEnvList(newList.map(e => (e.id === env.id && env.value_type === 'secret') ? { ...e, value: '[__HIDDEN__]' } : e))
|
||||
}, [currentVar, doSyncWorkflowDraft, envList, envSecrets, getEffectedNodes, setEnvSecrets, store, updateEnvList])
|
||||
|
||||
// Use new dedicated environment variables API
|
||||
try {
|
||||
await updateEnvironmentVariables({
|
||||
appId,
|
||||
environmentVariables: newList,
|
||||
})
|
||||
|
||||
const socket = webSocketClient.getSocket(appId)
|
||||
if (socket) {
|
||||
socket.emit('collaboration_event', {
|
||||
type: '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)
|
||||
}
|
||||
}, [currentVar, envList, envSecrets, getEffectedNodes, setEnvSecrets, store, updateEnvList, appId])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import type { StateCreator } from 'zustand'
|
||||
import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
|
||||
|
||||
export type CommentSliceShape = {
|
||||
comments: WorkflowCommentList[]
|
||||
setComments: (comments: WorkflowCommentList[]) => void
|
||||
commentsLoading: boolean
|
||||
setCommentsLoading: (loading: boolean) => void
|
||||
activeCommentDetail: WorkflowCommentDetail | null
|
||||
setActiveCommentDetail: (comment: WorkflowCommentDetail | null) => void
|
||||
activeCommentDetailLoading: boolean
|
||||
setActiveCommentDetailLoading: (loading: boolean) => void
|
||||
commentDetailCache: Record<string, WorkflowCommentDetail>
|
||||
setCommentDetailCache: (cache: Record<string, WorkflowCommentDetail>) => void
|
||||
}
|
||||
|
||||
export const createCommentSlice: StateCreator<CommentSliceShape> = set => ({
|
||||
comments: [],
|
||||
setComments: comments => set({ comments }),
|
||||
commentsLoading: false,
|
||||
setCommentsLoading: commentsLoading => set({ commentsLoading }),
|
||||
activeCommentDetail: null,
|
||||
setActiveCommentDetail: activeCommentDetail => set({ activeCommentDetail }),
|
||||
activeCommentDetailLoading: false,
|
||||
setActiveCommentDetailLoading: activeCommentDetailLoading => set({ activeCommentDetailLoading }),
|
||||
commentDetailCache: {},
|
||||
setCommentDetailCache: commentDetailCache => set({ commentDetailCache }),
|
||||
})
|
||||
|
|
@ -20,6 +20,8 @@ import type { NodeSliceShape } from './node-slice'
|
|||
import { createNodeSlice } from './node-slice'
|
||||
import type { PanelSliceShape } from './panel-slice'
|
||||
import { createPanelSlice } from './panel-slice'
|
||||
import type { CommentSliceShape } from './comment-slice'
|
||||
import { createCommentSlice } from './comment-slice'
|
||||
import type { ToolSliceShape } from './tool-slice'
|
||||
import { createToolSlice } from './tool-slice'
|
||||
import type { VersionSliceShape } from './version-slice'
|
||||
|
|
@ -49,6 +51,7 @@ export type Shape
|
|||
& HistorySliceShape
|
||||
& NodeSliceShape
|
||||
& PanelSliceShape
|
||||
& CommentSliceShape
|
||||
& ToolSliceShape
|
||||
& VersionSliceShape
|
||||
& WorkflowDraftSliceShape
|
||||
|
|
@ -74,6 +77,7 @@ export const createWorkflowStore = (params: CreateWorkflowStoreParams) => {
|
|||
...createHistorySlice(...args),
|
||||
...createNodeSlice(...args),
|
||||
...createPanelSlice(...args),
|
||||
...createCommentSlice(...args),
|
||||
...createToolSlice(...args),
|
||||
...createVersionSlice(...args),
|
||||
...createWorkflowDraftSlice(...args),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export type PanelSliceShape = {
|
|||
setShowInputsPanel: (showInputsPanel: boolean) => void
|
||||
showDebugAndPreviewPanel: boolean
|
||||
setShowDebugAndPreviewPanel: (showDebugAndPreviewPanel: boolean) => void
|
||||
showCommentsPanel: boolean
|
||||
setShowCommentsPanel: (showCommentsPanel: boolean) => void
|
||||
panelMenu?: {
|
||||
top: number
|
||||
left: number
|
||||
|
|
@ -24,6 +26,8 @@ export type PanelSliceShape = {
|
|||
setShowVariableInspectPanel: (showVariableInspectPanel: boolean) => void
|
||||
initShowLastRunTab: boolean
|
||||
setInitShowLastRunTab: (initShowLastRunTab: boolean) => void
|
||||
activeCommentId?: string | null
|
||||
setActiveCommentId: (commentId: string | null) => void
|
||||
}
|
||||
|
||||
export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
|
||||
|
|
@ -36,6 +40,8 @@ export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
|
|||
setShowInputsPanel: showInputsPanel => set(() => ({ showInputsPanel })),
|
||||
showDebugAndPreviewPanel: false,
|
||||
setShowDebugAndPreviewPanel: showDebugAndPreviewPanel => set(() => ({ showDebugAndPreviewPanel })),
|
||||
showCommentsPanel: false,
|
||||
setShowCommentsPanel: showCommentsPanel => set(() => ({ showCommentsPanel })),
|
||||
panelMenu: undefined,
|
||||
setPanelMenu: panelMenu => set(() => ({ panelMenu })),
|
||||
selectionMenu: undefined,
|
||||
|
|
@ -44,4 +50,6 @@ export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
|
|||
setShowVariableInspectPanel: showVariableInspectPanel => set(() => ({ showVariableInspectPanel })),
|
||||
initShowLastRunTab: false,
|
||||
setInitShowLastRunTab: initShowLastRunTab => set(() => ({ initShowLastRunTab })),
|
||||
activeCommentId: null,
|
||||
setActiveCommentId: (commentId: string | null) => set(() => ({ activeCommentId: commentId })),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@ export type WorkflowSliceShape = {
|
|||
setSelection: (selection: WorkflowSliceShape['selection']) => void
|
||||
bundleNodeSize: { width: number; height: number } | null
|
||||
setBundleNodeSize: (bundleNodeSize: WorkflowSliceShape['bundleNodeSize']) => void
|
||||
controlMode: 'pointer' | 'hand'
|
||||
controlMode: 'pointer' | 'hand' | 'comment'
|
||||
setControlMode: (controlMode: WorkflowSliceShape['controlMode']) => void
|
||||
pendingComment: { x: number; y: number } | null
|
||||
setPendingComment: (pendingComment: WorkflowSliceShape['pendingComment']) => void
|
||||
mousePosition: { pageX: number; pageY: number; elementX: number; elementY: number }
|
||||
setMousePosition: (mousePosition: WorkflowSliceShape['mousePosition']) => void
|
||||
showConfirm?: { title: string; desc?: string; onConfirm: () => void }
|
||||
|
|
@ -46,11 +48,13 @@ export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({
|
|||
setSelection: selection => set(() => ({ selection })),
|
||||
bundleNodeSize: null,
|
||||
setBundleNodeSize: bundleNodeSize => set(() => ({ bundleNodeSize })),
|
||||
controlMode: localStorage.getItem('workflow-operation-mode') === 'pointer' ? 'pointer' : 'hand',
|
||||
controlMode: localStorage.getItem('workflow-operation-mode') === 'pointer' ? 'pointer' : localStorage.getItem('workflow-operation-mode') === 'hand' ? 'hand' : 'comment',
|
||||
setControlMode: (controlMode) => {
|
||||
set(() => ({ controlMode }))
|
||||
localStorage.setItem('workflow-operation-mode', controlMode)
|
||||
},
|
||||
pendingComment: null,
|
||||
setPendingComment: pendingComment => set(() => ({ pendingComment })),
|
||||
mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 },
|
||||
setMousePosition: mousePosition => set(() => ({ mousePosition })),
|
||||
showConfirm: undefined,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@
|
|||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Comment mode cursor override */
|
||||
.comment-mode-flow .react-flow__pane,
|
||||
.comment-mode-flow .react-flow__viewport {
|
||||
cursor: none !important;
|
||||
}
|
||||
|
||||
#workflow-container .react-flow__nodesselection-rect {
|
||||
border: 1px solid #528BFF;
|
||||
background: rgba(21, 94, 239, 0.05);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export enum BlockEnum {
|
|||
export enum ControlMode {
|
||||
Pointer = 'pointer',
|
||||
Hand = 'hand',
|
||||
Comment = 'comment',
|
||||
}
|
||||
export enum ErrorHandleMode {
|
||||
Terminated = 'terminated',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export NEXT_PUBLIC_API_PREFIX=${CONSOLE_API_URL}/console/api
|
|||
export NEXT_PUBLIC_PUBLIC_API_PREFIX=${APP_API_URL}/api
|
||||
export NEXT_PUBLIC_MARKETPLACE_API_PREFIX=${MARKETPLACE_API_URL}/api/v1
|
||||
export NEXT_PUBLIC_MARKETPLACE_URL_PREFIX=${MARKETPLACE_URL}
|
||||
export NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL}
|
||||
|
||||
export NEXT_PUBLIC_SENTRY_DSN=${SENTRY_DSN}
|
||||
export NEXT_PUBLIC_SITE_ABOUT=${SITE_ABOUT}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ const translation = {
|
|||
pasteHere: 'Paste Here',
|
||||
pointerMode: 'Pointer Mode',
|
||||
handMode: 'Hand Mode',
|
||||
commentMode: 'Comment Mode',
|
||||
exportImage: 'Export Image',
|
||||
exportPNG: 'Export as PNG',
|
||||
exportJPEG: 'Export as JPEG',
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ const translation = {
|
|||
pasteHere: '粘贴到这里',
|
||||
pointerMode: '指针模式',
|
||||
handMode: '手模式',
|
||||
commentMode: '评论模式',
|
||||
exportImage: '导出图片',
|
||||
exportPNG: '导出为 PNG',
|
||||
exportJPEG: '导出为 JPEG',
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export function middleware(request: NextRequest) {
|
|||
const cspHeader = `
|
||||
default-src 'self' ${scheme_source} ${csp} ${whiteList};
|
||||
connect-src 'self' ${scheme_source} ${csp} ${whiteList};
|
||||
script-src 'self' ${scheme_source} ${csp} ${whiteList};
|
||||
script-src 'self' 'wasm-unsafe-eval' ${scheme_source} ${csp} ${whiteList};
|
||||
style-src 'self' 'unsafe-inline' ${scheme_source} ${whiteList};
|
||||
worker-src 'self' ${scheme_source} ${csp} ${whiteList};
|
||||
media-src 'self' ${scheme_source} ${csp} ${whiteList};
|
||||
|
|
|
|||
|
|
@ -92,8 +92,14 @@ const remoteImageURLs = [hasSetWebPrefix ? new URL(`${process.env.NEXT_PUBLIC_WE
|
|||
const nextConfig = {
|
||||
basePath: process.env.NEXT_PUBLIC_BASE_PATH || '',
|
||||
webpack: (config, { dev, isServer }) => {
|
||||
if (dev) {
|
||||
config.plugins.push(codeInspectorPlugin({ bundler: 'webpack' }))
|
||||
config.plugins.push(codeInspectorPlugin({ bundler: 'webpack' }))
|
||||
|
||||
config.experiments = {
|
||||
asyncWebAssembly: true,
|
||||
layers: true,
|
||||
}
|
||||
config.output.environment = {
|
||||
asyncFunction: true,
|
||||
}
|
||||
|
||||
return config
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@
|
|||
"lexical": "^0.30.0",
|
||||
"line-clamp": "^1.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loro-crdt": "^1.5.9",
|
||||
"mermaid": "11.10.0",
|
||||
"mime": "^4.0.4",
|
||||
"mitt": "^3.0.1",
|
||||
|
|
@ -144,6 +145,7 @@
|
|||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.33.2",
|
||||
"shave": "^5.0.4",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sortablejs": "^1.15.0",
|
||||
"swr": "^2.3.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
|
|
|
|||
|
|
@ -217,6 +217,9 @@ importers:
|
|||
lodash-es:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
loro-crdt:
|
||||
specifier: ^1.5.9
|
||||
version: 1.7.3
|
||||
mermaid:
|
||||
specifier: 11.10.0
|
||||
version: 11.10.0
|
||||
|
|
@ -346,6 +349,9 @@ importers:
|
|||
shave:
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4
|
||||
socket.io-client:
|
||||
specifier: ^4.8.1
|
||||
version: 4.8.1
|
||||
sortablejs:
|
||||
specifier: ^1.15.0
|
||||
version: 1.15.6
|
||||
|
|
@ -1792,144 +1798,170 @@ packages:
|
|||
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.0':
|
||||
resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.0.5':
|
||||
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.0':
|
||||
resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.0':
|
||||
resolution: {integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.0.4':
|
||||
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.0':
|
||||
resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.0.4':
|
||||
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.0':
|
||||
resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
|
||||
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.0':
|
||||
resolution: {integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
|
||||
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.0':
|
||||
resolution: {integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.33.5':
|
||||
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.3':
|
||||
resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.33.5':
|
||||
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.3':
|
||||
resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.3':
|
||||
resolution: {integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.33.5':
|
||||
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.3':
|
||||
resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.33.5':
|
||||
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.3':
|
||||
resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.33.5':
|
||||
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.3':
|
||||
resolution: {integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.33.5':
|
||||
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.3':
|
||||
resolution: {integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.33.5':
|
||||
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
|
||||
|
|
@ -2227,24 +2259,28 @@ packages:
|
|||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.5.0':
|
||||
resolution: {integrity: sha512-biWqIOE17OW/6S34t1X8K/3vb1+svp5ji5QQT/IKR+VfM3B7GvlCwmz5XtlEan2ukOUf9tj2vJJBffaGH4fGRw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.5.0':
|
||||
resolution: {integrity: sha512-zPisT+obYypM/l6EZ0yRkK3LEuoZqHaSoYKj+5jiD9ESHwdr6QhnabnNxYkdy34uCigNlWIaCbjFmQ8FY5AlxA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@15.5.0':
|
||||
resolution: {integrity: sha512-+t3+7GoU9IYmk+N+FHKBNFdahaReoAktdOpXHFIPOU1ixxtdge26NgQEEkJkCw2dHT9UwwK5zw4mAsURw4E8jA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.5.0':
|
||||
resolution: {integrity: sha512-d8MrXKh0A+c9DLiy1BUFwtg3Hu90Lucj3k6iKTUdPOv42Ve2UiIG8HYi3UAb8kFVluXxEfdpCoPPCSODk5fDcw==}
|
||||
|
|
@ -2466,36 +2502,42 @@ packages:
|
|||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
||||
|
|
@ -2890,6 +2932,9 @@ packages:
|
|||
'@sinonjs/fake-timers@10.3.0':
|
||||
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
|
||||
|
||||
'@socket.io/component-emitter@3.1.2':
|
||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||
|
||||
'@storybook/addon-actions@8.5.0':
|
||||
resolution: {integrity: sha512-6CW9+17rk5eNx6I8EKqCxRKtsJFTR/lHL+xiJ6/iBWApIm8sg63vhXvUTJ58UixmIkT5oLh0+ESNPh+x10D8fw==}
|
||||
peerDependencies:
|
||||
|
|
@ -3694,41 +3739,49 @@ packages:
|
|||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
|
|
@ -5017,8 +5070,15 @@ packages:
|
|||
endent@2.1.0:
|
||||
resolution: {integrity: sha512-r8VyPX7XL8U01Xgnb1CjZ3XV+z90cXIJ9JPE/R9SEC9vpw2P6CfsRPJmp20DppC5N7ZAMCmjYkJIa744Iyg96w==}
|
||||
|
||||
enhanced-resolve@5.18.2:
|
||||
resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==}
|
||||
engine.io-client@6.6.3:
|
||||
resolution: {integrity: sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==}
|
||||
|
||||
engine.io-parser@5.2.3:
|
||||
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
enhanced-resolve@5.18.1:
|
||||
resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
entities@2.2.0:
|
||||
|
|
@ -6524,8 +6584,11 @@ packages:
|
|||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
loupe@3.1.4:
|
||||
resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==}
|
||||
loro-crdt@1.7.3:
|
||||
resolution: {integrity: sha512-bP/Su2mVDf0ieIdOH8RyQ4Dg8hIJUQCzwTfzpuPtQl7XoAk2tzYe5Dt77kIAmioN6DyCI0Frbt2Nr9lHBPf9Mw==}
|
||||
|
||||
loupe@3.1.3:
|
||||
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
|
||||
|
||||
lower-case@2.0.2:
|
||||
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
|
||||
|
|
@ -8061,6 +8124,14 @@ packages:
|
|||
resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
socket.io-client@4.8.1:
|
||||
resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
socket.io-parser@4.2.4:
|
||||
resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
sortablejs@1.15.6:
|
||||
resolution: {integrity: sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==}
|
||||
|
||||
|
|
@ -8899,8 +8970,20 @@ packages:
|
|||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.18.3:
|
||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
||||
ws@8.17.1:
|
||||
resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.18.1:
|
||||
resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
|
|
@ -8915,6 +8998,10 @@ packages:
|
|||
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
xmlhttprequest-ssl@2.1.2:
|
||||
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
|
@ -9095,7 +9182,7 @@ snapshots:
|
|||
'@babel/traverse': 7.28.3
|
||||
'@babel/types': 7.28.2
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.1
|
||||
debug: 4.4.0
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
|
|
@ -9168,7 +9255,7 @@ snapshots:
|
|||
'@babel/core': 7.28.3
|
||||
'@babel/helper-compilation-targets': 7.27.2
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
lodash.debounce: 4.0.8
|
||||
resolve: 1.22.10
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -9917,7 +10004,7 @@ snapshots:
|
|||
'@babel/parser': 7.28.0
|
||||
'@babel/template': 7.27.2
|
||||
'@babel/types': 7.28.1
|
||||
debug: 4.4.1
|
||||
debug: 4.4.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -9929,7 +10016,7 @@ snapshots:
|
|||
'@babel/parser': 7.28.3
|
||||
'@babel/template': 7.27.2
|
||||
'@babel/types': 7.28.2
|
||||
debug: 4.4.1
|
||||
debug: 4.4.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -10231,7 +10318,7 @@ snapshots:
|
|||
'@eslint/config-array@0.21.0':
|
||||
dependencies:
|
||||
'@eslint/object-schema': 2.1.6
|
||||
debug: 4.4.1
|
||||
debug: 4.4.0
|
||||
minimatch: 3.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -10249,7 +10336,7 @@ snapshots:
|
|||
'@eslint/eslintrc@3.3.1':
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
debug: 4.4.1
|
||||
debug: 4.4.0
|
||||
espree: 10.4.0
|
||||
globals: 14.0.0
|
||||
ignore: 5.3.2
|
||||
|
|
@ -10370,7 +10457,7 @@ snapshots:
|
|||
'@antfu/install-pkg': 1.1.0
|
||||
'@antfu/utils': 8.1.1
|
||||
'@iconify/types': 2.0.0
|
||||
debug: 4.4.1
|
||||
debug: 4.4.0
|
||||
globals: 15.15.0
|
||||
kolorist: 1.8.0
|
||||
local-pkg: 1.1.2
|
||||
|
|
@ -11654,6 +11741,8 @@ snapshots:
|
|||
dependencies:
|
||||
'@sinonjs/commons': 3.0.1
|
||||
|
||||
'@socket.io/component-emitter@3.1.2': {}
|
||||
|
||||
'@storybook/addon-actions@8.5.0(storybook@8.5.0)':
|
||||
dependencies:
|
||||
'@storybook/global': 5.0.0
|
||||
|
|
@ -11836,7 +11925,7 @@ snapshots:
|
|||
recast: 0.23.11
|
||||
semver: 7.7.2
|
||||
util: 0.12.5
|
||||
ws: 8.18.3
|
||||
ws: 8.18.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
|
|
@ -11974,7 +12063,7 @@ snapshots:
|
|||
|
||||
'@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@5.8.3)(webpack@5.100.2(esbuild@0.25.0)(uglify-js@3.19.3))':
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
debug: 4.4.0
|
||||
endent: 2.1.0
|
||||
find-cache-dir: 3.3.2
|
||||
flat-cache: 3.2.0
|
||||
|
|
@ -12605,7 +12694,7 @@ snapshots:
|
|||
'@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3)
|
||||
'@typescript-eslint/types': 8.37.0
|
||||
'@typescript-eslint/visitor-keys': 8.37.0
|
||||
debug: 4.4.1
|
||||
debug: 4.4.0
|
||||
fast-glob: 3.3.3
|
||||
is-glob: 4.0.3
|
||||
minimatch: 9.0.5
|
||||
|
|
@ -12621,7 +12710,7 @@ snapshots:
|
|||
'@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3)
|
||||
'@typescript-eslint/types': 8.38.0
|
||||
'@typescript-eslint/visitor-keys': 8.38.0
|
||||
debug: 4.4.1
|
||||
debug: 4.4.0
|
||||
fast-glob: 3.3.3
|
||||
is-glob: 4.0.3
|
||||
minimatch: 9.0.5
|
||||
|
|
@ -12788,13 +12877,13 @@ snapshots:
|
|||
dependencies:
|
||||
'@vitest/pretty-format': 2.0.5
|
||||
estree-walker: 3.0.3
|
||||
loupe: 3.1.4
|
||||
loupe: 3.1.3
|
||||
tinyrainbow: 1.2.0
|
||||
|
||||
'@vitest/utils@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 2.1.9
|
||||
loupe: 3.1.4
|
||||
loupe: 3.1.3
|
||||
tinyrainbow: 1.2.0
|
||||
|
||||
'@vue/compiler-core@3.5.17':
|
||||
|
|
@ -13353,7 +13442,7 @@ snapshots:
|
|||
assertion-error: 2.0.1
|
||||
check-error: 2.1.1
|
||||
deep-eql: 5.0.2
|
||||
loupe: 3.1.4
|
||||
loupe: 3.1.3
|
||||
pathval: 2.0.1
|
||||
|
||||
chalk@3.0.0:
|
||||
|
|
@ -14121,7 +14210,21 @@ snapshots:
|
|||
fast-json-parse: 1.0.3
|
||||
objectorarray: 1.0.5
|
||||
|
||||
enhanced-resolve@5.18.2:
|
||||
engine.io-client@6.6.3:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.3.7
|
||||
engine.io-parser: 5.2.3
|
||||
ws: 8.17.1
|
||||
xmlhttprequest-ssl: 2.1.2
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
engine.io-parser@5.2.3: {}
|
||||
|
||||
enhanced-resolve@5.18.1:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.2.2
|
||||
|
|
@ -14168,7 +14271,7 @@ snapshots:
|
|||
|
||||
esbuild-register@3.6.0(esbuild@0.25.0):
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
debug: 4.4.0
|
||||
esbuild: 0.25.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -14352,7 +14455,7 @@ snapshots:
|
|||
'@es-joy/jsdoccomment': 0.52.0
|
||||
are-docs-informative: 0.0.2
|
||||
comment-parser: 1.4.1
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint: 9.35.0(jiti@1.21.7)
|
||||
espree: 10.4.0
|
||||
|
|
@ -14683,7 +14786,7 @@ snapshots:
|
|||
|
||||
eslint-plugin-yml@1.18.0(eslint@9.35.0(jiti@1.21.7)):
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
debug: 4.4.0
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint: 9.35.0(jiti@1.21.7)
|
||||
eslint-compat-utils: 0.6.5(eslint@9.35.0(jiti@1.21.7))
|
||||
|
|
@ -14729,7 +14832,7 @@ snapshots:
|
|||
ajv: 6.12.6
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.1
|
||||
debug: 4.4.0
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
|
@ -16054,7 +16157,7 @@ snapshots:
|
|||
dependencies:
|
||||
chalk: 5.4.1
|
||||
commander: 13.1.0
|
||||
debug: 4.4.1
|
||||
debug: 4.4.0
|
||||
execa: 8.0.1
|
||||
lilconfig: 3.1.3
|
||||
listr2: 8.3.3
|
||||
|
|
@ -16136,7 +16239,9 @@ snapshots:
|
|||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
loupe@3.1.4: {}
|
||||
loro-crdt@1.7.3: {}
|
||||
|
||||
loupe@3.1.3: {}
|
||||
|
||||
lower-case@2.0.2:
|
||||
dependencies:
|
||||
|
|
@ -16695,7 +16800,7 @@ snapshots:
|
|||
micromark@4.0.2:
|
||||
dependencies:
|
||||
'@types/debug': 4.1.12
|
||||
debug: 4.4.1
|
||||
debug: 4.4.0
|
||||
decode-named-character-reference: 1.2.0
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.3
|
||||
|
|
@ -17222,7 +17327,7 @@ snapshots:
|
|||
portfinder@1.0.37:
|
||||
dependencies:
|
||||
async: 3.2.6
|
||||
debug: 4.4.1
|
||||
debug: 4.4.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -18149,6 +18254,24 @@ snapshots:
|
|||
ansi-styles: 6.2.1
|
||||
is-fullwidth-code-point: 5.0.0
|
||||
|
||||
socket.io-client@4.8.1:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.3.7
|
||||
engine.io-client: 6.6.3
|
||||
socket.io-parser: 4.2.4
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
socket.io-parser@4.2.4:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.3.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
sortablejs@1.15.6: {}
|
||||
|
||||
source-list-map@2.0.1: {}
|
||||
|
|
@ -18525,7 +18648,7 @@ snapshots:
|
|||
tsconfig-paths-webpack-plugin@4.2.0:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
enhanced-resolve: 5.18.2
|
||||
enhanced-resolve: 5.18.1
|
||||
tapable: 2.2.2
|
||||
tsconfig-paths: 4.2.0
|
||||
|
||||
|
|
@ -18898,7 +19021,7 @@ snapshots:
|
|||
acorn-import-phases: 1.0.4(acorn@8.15.0)
|
||||
browserslist: 4.25.1
|
||||
chrome-trace-event: 1.0.4
|
||||
enhanced-resolve: 5.18.2
|
||||
enhanced-resolve: 5.18.1
|
||||
es-module-lexer: 1.7.0
|
||||
eslint-scope: 5.1.1
|
||||
events: 3.3.0
|
||||
|
|
@ -19095,10 +19218,14 @@ snapshots:
|
|||
|
||||
ws@7.5.10: {}
|
||||
|
||||
ws@8.18.3: {}
|
||||
ws@8.17.1: {}
|
||||
|
||||
ws@8.18.1: {}
|
||||
|
||||
xml-name-validator@4.0.0: {}
|
||||
|
||||
xmlhttprequest-ssl@2.1.2: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
|
|
|||
|
|
@ -397,3 +397,7 @@ export const resetEmail = (body: { new_email: string; token: string }) =>
|
|||
|
||||
export const checkEmailExisted = (body: { email: string }) =>
|
||||
post<CommonResponse>('/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}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
import { del, get, post, put } from './base'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
|
||||
export type UserProfile = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
avatar_url?: string
|
||||
}
|
||||
|
||||
export type WorkflowCommentList = {
|
||||
id: string
|
||||
position_x: number
|
||||
position_y: number
|
||||
content: string
|
||||
created_by: string
|
||||
created_by_account: UserProfile
|
||||
created_at: number
|
||||
updated_at: number
|
||||
resolved: boolean
|
||||
resolved_by?: string
|
||||
resolved_by_account?: UserProfile
|
||||
resolved_at?: number
|
||||
mention_count: number
|
||||
reply_count: number
|
||||
participants: UserProfile[]
|
||||
}
|
||||
|
||||
export type WorkflowCommentDetailMention = {
|
||||
mentioned_user_id: string
|
||||
mentioned_user_account?: UserProfile | null
|
||||
reply_id: string | null
|
||||
}
|
||||
|
||||
export type WorkflowCommentDetailReply = {
|
||||
id: string
|
||||
content: string
|
||||
created_by: string
|
||||
created_by_account?: UserProfile | null
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export type WorkflowCommentDetail = {
|
||||
id: string
|
||||
position_x: number
|
||||
position_y: number
|
||||
content: string
|
||||
created_by: string
|
||||
created_by_account: UserProfile
|
||||
created_at: number
|
||||
updated_at: number
|
||||
resolved: boolean
|
||||
resolved_by?: string
|
||||
resolved_by_account?: UserProfile
|
||||
resolved_at?: number
|
||||
replies: WorkflowCommentDetailReply[]
|
||||
mentions: WorkflowCommentDetailMention[]
|
||||
}
|
||||
|
||||
export type WorkflowCommentCreateRes = {
|
||||
id: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type WorkflowCommentUpdateRes = {
|
||||
id: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type WorkflowCommentResolveRes = {
|
||||
id: string
|
||||
resolved: boolean
|
||||
resolved_by: string
|
||||
resolved_at: number
|
||||
}
|
||||
|
||||
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<WorkflowCommentList[]> => {
|
||||
const response = await get<{ data: WorkflowCommentList[] }>(`apps/${appId}/workflow/comments`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const createWorkflowComment = async (appId: string, params: CreateCommentParams): Promise<WorkflowCommentCreateRes> => {
|
||||
return post<WorkflowCommentCreateRes>(`apps/${appId}/workflow/comments`, { body: params })
|
||||
}
|
||||
|
||||
export const fetchWorkflowComment = async (appId: string, commentId: string): Promise<WorkflowCommentDetail> => {
|
||||
return get<WorkflowCommentDetail>(`apps/${appId}/workflow/comments/${commentId}`)
|
||||
}
|
||||
|
||||
export const updateWorkflowComment = async (appId: string, commentId: string, params: UpdateCommentParams): Promise<WorkflowCommentUpdateRes> => {
|
||||
return put<WorkflowCommentUpdateRes>(`apps/${appId}/workflow/comments/${commentId}`, { body: params })
|
||||
}
|
||||
|
||||
export const deleteWorkflowComment = async (appId: string, commentId: string): Promise<CommonResponse> => {
|
||||
return del<CommonResponse>(`apps/${appId}/workflow/comments/${commentId}`)
|
||||
}
|
||||
|
||||
export const resolveWorkflowComment = async (appId: string, commentId: string): Promise<WorkflowCommentResolveRes> => {
|
||||
return post<WorkflowCommentResolveRes>(`apps/${appId}/workflow/comments/${commentId}/resolve`)
|
||||
}
|
||||
|
||||
export const createWorkflowCommentReply = async (appId: string, commentId: string, params: CreateReplyParams): Promise<WorkflowCommentReply> => {
|
||||
return post<WorkflowCommentReply>(`apps/${appId}/workflow/comments/${commentId}/replies`, { body: params })
|
||||
}
|
||||
|
||||
export const updateWorkflowCommentReply = async (appId: string, commentId: string, replyId: string, params: CreateReplyParams): Promise<WorkflowCommentReply> => {
|
||||
return put<WorkflowCommentReply>(`apps/${appId}/workflow/comments/${commentId}/replies/${replyId}`, { body: params })
|
||||
}
|
||||
|
||||
export const deleteWorkflowCommentReply = async (appId: string, commentId: string, replyId: string): Promise<CommonResponse> => {
|
||||
return del<CommonResponse>(`apps/${appId}/workflow/comments/${commentId}/replies/${replyId}`)
|
||||
}
|
||||
|
||||
export const fetchMentionableUsers = async (appId: string) => {
|
||||
const response = await get<{ users: Array<UserProfile> }>(`apps/${appId}/workflow/comments/mention-users`)
|
||||
return response.users
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ import type { BlockEnum } from '@/app/components/workflow/types'
|
|||
import type { VarInInspect } from '@/types/workflow'
|
||||
import type { FlowType } from '@/types/common'
|
||||
import { getFlowPrefix } from './utils'
|
||||
import type { ConversationVariable, EnvironmentVariable } from '@/app/components/workflow/types'
|
||||
import type { Features } from '@/app/components/base/features/types'
|
||||
|
||||
export const fetchWorkflowDraft = (url: string) => {
|
||||
return get(url, {}, { silent: true }) as Promise<FetchWorkflowDraftResponse>
|
||||
|
|
@ -107,3 +109,36 @@ export const fetchNodeInspectVars = async (flowType: FlowType, flowId: string, n
|
|||
const { items } = (await get(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/nodes/${nodeId}/variables`)) as { items: VarInInspect[] }
|
||||
return items
|
||||
}
|
||||
|
||||
// Environment Variables API
|
||||
export const fetchEnvironmentVariables = async (appId: string): Promise<EnvironmentVariable[]> => {
|
||||
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<CommonResponse>(`apps/${appId}/workflows/draft/environment-variables`, {
|
||||
body: { environment_variables: environmentVariables },
|
||||
})
|
||||
}
|
||||
|
||||
export const updateConversationVariables = ({ appId, conversationVariables }: {
|
||||
appId: string
|
||||
conversationVariables: ConversationVariable[]
|
||||
}) => {
|
||||
return post<CommonResponse>(`apps/${appId}/workflows/draft/conversation-variables`, {
|
||||
body: { conversation_variables: conversationVariables },
|
||||
})
|
||||
}
|
||||
|
||||
export const updateFeatures = ({ appId, features }: {
|
||||
appId: string
|
||||
features: Features
|
||||
}) => {
|
||||
return post<CommonResponse>(`apps/${appId}/workflows/draft/features`, {
|
||||
body: { features },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue