mirror of
https://github.com/langgenius/dify.git
synced 2026-04-09 15:17:30 +08:00
Compare commits
5 Commits
feat/colla
...
test/workf
| Author | SHA1 | Date | |
|---|---|---|---|
| ca60bb5812 | |||
| c19a822e1b | |||
| 8782787a9e | |||
| 4c6b8f9229 | |||
| 51dcf4ce84 |
@ -33,9 +33,6 @@ TRIGGER_URL=http://localhost:5001
|
||||
# The time in seconds after the signature is rejected
|
||||
FILES_ACCESS_TIMEOUT=300
|
||||
|
||||
# Collaboration mode toggle
|
||||
ENABLE_COLLABORATION_MODE=false
|
||||
|
||||
# Access token expiration time in minutes
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
|
||||
|
||||
17
api/app.py
17
api/app.py
@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
@ -17,15 +16,10 @@ def is_db_command() -> bool:
|
||||
|
||||
|
||||
# create app
|
||||
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:
|
||||
# Gunicorn and Celery handle monkey patching automatically in production by
|
||||
# specifying the `gevent` worker class. Manual monkey patching is not required here.
|
||||
@ -36,15 +30,8 @@ else:
|
||||
|
||||
from app_factory import create_app
|
||||
|
||||
socketio_app, flask_app = create_app()
|
||||
app = flask_app
|
||||
app = create_app()
|
||||
celery = cast("Celery", app.extensions["celery"])
|
||||
|
||||
if __name__ == "__main__":
|
||||
from gevent import pywsgi
|
||||
from geventwebsocket.handler import WebSocketHandler # type: ignore[reportMissingTypeStubs]
|
||||
|
||||
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()
|
||||
app.run(host="0.0.0.0", port=5001)
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
import socketio # type: ignore[reportMissingTypeStubs]
|
||||
from flask import request
|
||||
from opentelemetry.trace import get_current_span
|
||||
from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID
|
||||
@ -11,7 +10,6 @@ from contexts.wrapper import RecyclableContextVar
|
||||
from controllers.console.error import UnauthorizedAndForceLogout
|
||||
from core.logging.context import init_request_context
|
||||
from dify_app import DifyApp
|
||||
from extensions.ext_socketio import sio
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.feature_service import LicenseStatus
|
||||
|
||||
@ -124,18 +122,14 @@ def create_flask_app_with_configs() -> DifyApp:
|
||||
return dify_app
|
||||
|
||||
|
||||
def create_app() -> tuple[socketio.WSGIApp, DifyApp]:
|
||||
def create_app() -> DifyApp:
|
||||
start_time = time.perf_counter()
|
||||
app = create_flask_app_with_configs()
|
||||
initialize_extensions(app)
|
||||
|
||||
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 socketio_app, app
|
||||
return app
|
||||
|
||||
|
||||
def initialize_extensions(app: DifyApp):
|
||||
|
||||
@ -1274,13 +1274,6 @@ class PositionConfig(BaseSettings):
|
||||
return {item.strip() for item in self.POSITION_TOOL_EXCLUDES.split(",") if item.strip() != ""}
|
||||
|
||||
|
||||
class CollaborationConfig(BaseSettings):
|
||||
ENABLE_COLLABORATION_MODE: bool = Field(
|
||||
description="Whether to enable collaboration mode features across the workspace",
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
class LoginConfig(BaseSettings):
|
||||
ENABLE_EMAIL_CODE_LOGIN: bool = Field(
|
||||
description="whether to enable email code login",
|
||||
@ -1406,7 +1399,6 @@ class FeatureConfig(
|
||||
WorkflowConfig,
|
||||
WorkflowNodeExecutionConfig,
|
||||
WorkspaceConfig,
|
||||
CollaborationConfig,
|
||||
LoginConfig,
|
||||
AccountConfig,
|
||||
SwaggerUIConfig,
|
||||
|
||||
@ -65,7 +65,6 @@ from .app import (
|
||||
statistic,
|
||||
workflow,
|
||||
workflow_app_log,
|
||||
workflow_comment,
|
||||
workflow_draft_variable,
|
||||
workflow_run,
|
||||
workflow_statistic,
|
||||
@ -117,7 +116,6 @@ from .explore import (
|
||||
saved_message,
|
||||
trial,
|
||||
)
|
||||
from .socketio import workflow as socketio_workflow # pyright: ignore[reportUnusedImport]
|
||||
|
||||
# Import tag controllers
|
||||
from .tag import tags
|
||||
@ -213,7 +211,6 @@ __all__ = [
|
||||
"website",
|
||||
"workflow",
|
||||
"workflow_app_log",
|
||||
"workflow_comment",
|
||||
"workflow_draft_variable",
|
||||
"workflow_run",
|
||||
"workflow_statistic",
|
||||
|
||||
@ -39,7 +39,6 @@ from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from factories import file_factory, variable_factory
|
||||
from fields.member_fields import simple_account_fields
|
||||
from fields.online_user_fields import online_user_list_fields
|
||||
from fields.workflow_fields import workflow_fields, workflow_pagination_fields
|
||||
from libs import helper
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
@ -48,7 +47,6 @@ from libs.login import current_account_with_tenant, login_required
|
||||
from models import App
|
||||
from models.model import AppMode
|
||||
from models.workflow import Workflow
|
||||
from repositories.workflow_collaboration_repository import WORKFLOW_ONLINE_USERS_PREFIX
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
@ -152,14 +150,6 @@ class ConvertToWorkflowPayload(BaseModel):
|
||||
icon_background: str | None = None
|
||||
|
||||
|
||||
class WorkflowFeaturesPayload(BaseModel):
|
||||
features: dict[str, Any] = Field(..., description="Workflow feature configuration")
|
||||
|
||||
|
||||
class WorkflowOnlineUsersQuery(BaseModel):
|
||||
workflow_ids: str = Field(..., description="Comma-separated workflow IDs")
|
||||
|
||||
|
||||
class DraftWorkflowTriggerRunPayload(BaseModel):
|
||||
node_id: str
|
||||
|
||||
@ -183,8 +173,6 @@ reg(DefaultBlockConfigQuery)
|
||||
reg(ConvertToWorkflowPayload)
|
||||
reg(WorkflowListQuery)
|
||||
reg(WorkflowUpdatePayload)
|
||||
reg(WorkflowFeaturesPayload)
|
||||
reg(WorkflowOnlineUsersQuery)
|
||||
reg(DraftWorkflowTriggerRunPayload)
|
||||
reg(DraftWorkflowTriggerRunAllPayload)
|
||||
|
||||
@ -943,31 +931,6 @@ class ConvertToWorkflowApi(Resource):
|
||||
}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/features")
|
||||
class WorkflowFeaturesApi(Resource):
|
||||
"""Update draft workflow features."""
|
||||
|
||||
@console_ns.expect(console_ns.models[WorkflowFeaturesPayload.__name__])
|
||||
@console_ns.doc("update_workflow_features")
|
||||
@console_ns.doc(description="Update draft workflow features")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(200, "Workflow features updated successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def post(self, app_model: App):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
args = WorkflowFeaturesPayload.model_validate(console_ns.payload or {})
|
||||
features = args.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):
|
||||
@console_ns.expect(console_ns.models[WorkflowListQuery.__name__])
|
||||
@ -1377,32 +1340,3 @@ class DraftWorkflowTriggerRunAllApi(Resource):
|
||||
"status": "error",
|
||||
}
|
||||
), 400
|
||||
|
||||
|
||||
@console_ns.route("/apps/workflows/online-users")
|
||||
class WorkflowOnlineUsersApi(Resource):
|
||||
@console_ns.expect(console_ns.models[WorkflowOnlineUsersQuery.__name__])
|
||||
@console_ns.doc("get_workflow_online_users")
|
||||
@console_ns.doc(description="Get workflow online users")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@marshal_with(online_user_list_fields)
|
||||
def get(self):
|
||||
args = WorkflowOnlineUsersQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||
|
||||
workflow_ids = [workflow_id.strip() for workflow_id in args.workflow_ids.split(",") if workflow_id.strip()]
|
||||
|
||||
results = []
|
||||
for workflow_id in workflow_ids:
|
||||
users_json = redis_client.hgetall(f"{WORKFLOW_ONLINE_USERS_PREFIX}{workflow_id}")
|
||||
|
||||
users = []
|
||||
for _, user_info_json in users_json.items():
|
||||
try:
|
||||
users.append(json.loads(user_info_json))
|
||||
except Exception:
|
||||
continue
|
||||
results.append({"workflow_id": workflow_id, "users": users})
|
||||
|
||||
return {"data": results}
|
||||
|
||||
@ -1,322 +0,0 @@
|
||||
import logging
|
||||
|
||||
from flask_restx import Resource, marshal_with
|
||||
from pydantic import BaseModel, Field, TypeAdapter
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from fields.member_fields import AccountWithRole
|
||||
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__)
|
||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
||||
|
||||
|
||||
class WorkflowCommentCreatePayload(BaseModel):
|
||||
position_x: float = Field(..., description="Comment X position")
|
||||
position_y: float = Field(..., description="Comment Y position")
|
||||
content: str = Field(..., description="Comment content")
|
||||
mentioned_user_ids: list[str] = Field(default_factory=list, description="Mentioned user IDs")
|
||||
|
||||
|
||||
class WorkflowCommentUpdatePayload(BaseModel):
|
||||
content: str = Field(..., description="Comment content")
|
||||
position_x: float | None = Field(default=None, description="Comment X position")
|
||||
position_y: float | None = Field(default=None, description="Comment Y position")
|
||||
mentioned_user_ids: list[str] = Field(default_factory=list, description="Mentioned user IDs")
|
||||
|
||||
|
||||
class WorkflowCommentReplyCreatePayload(BaseModel):
|
||||
content: str = Field(..., description="Reply content")
|
||||
mentioned_user_ids: list[str] = Field(default_factory=list, description="Mentioned user IDs")
|
||||
|
||||
|
||||
class WorkflowCommentReplyUpdatePayload(BaseModel):
|
||||
content: str = Field(..., description="Reply content")
|
||||
mentioned_user_ids: list[str] = Field(default_factory=list, description="Mentioned user IDs")
|
||||
|
||||
|
||||
class WorkflowCommentMentionUsersPayload(BaseModel):
|
||||
users: list[AccountWithRole]
|
||||
|
||||
|
||||
for model in (
|
||||
WorkflowCommentCreatePayload,
|
||||
WorkflowCommentUpdatePayload,
|
||||
WorkflowCommentReplyCreatePayload,
|
||||
WorkflowCommentReplyUpdatePayload,
|
||||
):
|
||||
console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
|
||||
register_schema_models(console_ns, AccountWithRole, WorkflowCommentMentionUsersPayload)
|
||||
|
||||
workflow_comment_basic_model = console_ns.model("WorkflowCommentBasic", workflow_comment_basic_fields)
|
||||
workflow_comment_detail_model = console_ns.model("WorkflowCommentDetail", workflow_comment_detail_fields)
|
||||
workflow_comment_create_model = console_ns.model("WorkflowCommentCreate", workflow_comment_create_fields)
|
||||
workflow_comment_update_model = console_ns.model("WorkflowCommentUpdate", workflow_comment_update_fields)
|
||||
workflow_comment_resolve_model = console_ns.model("WorkflowCommentResolve", workflow_comment_resolve_fields)
|
||||
workflow_comment_reply_create_model = console_ns.model(
|
||||
"WorkflowCommentReplyCreate", workflow_comment_reply_create_fields
|
||||
)
|
||||
workflow_comment_reply_update_model = console_ns.model(
|
||||
"WorkflowCommentReplyUpdate", workflow_comment_reply_update_fields
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflow/comments")
|
||||
class WorkflowCommentListApi(Resource):
|
||||
"""API for listing and creating workflow comments."""
|
||||
|
||||
@console_ns.doc("list_workflow_comments")
|
||||
@console_ns.doc(description="Get all comments for a workflow")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(200, "Comments retrieved successfully", workflow_comment_basic_model)
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@marshal_with(workflow_comment_basic_model, 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
|
||||
|
||||
@console_ns.doc("create_workflow_comment")
|
||||
@console_ns.doc(description="Create a new workflow comment")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.expect(console_ns.models[WorkflowCommentCreatePayload.__name__])
|
||||
@console_ns.response(201, "Comment created successfully", workflow_comment_create_model)
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@marshal_with(workflow_comment_create_model)
|
||||
def post(self, app_model: App):
|
||||
"""Create a new workflow comment."""
|
||||
payload = WorkflowCommentCreatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
result = WorkflowCommentService.create_comment(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
app_id=app_model.id,
|
||||
created_by=current_user.id,
|
||||
content=payload.content,
|
||||
position_x=payload.position_x,
|
||||
position_y=payload.position_y,
|
||||
mentioned_user_ids=payload.mentioned_user_ids,
|
||||
)
|
||||
|
||||
return result, 201
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>")
|
||||
class WorkflowCommentDetailApi(Resource):
|
||||
"""API for managing individual workflow comments."""
|
||||
|
||||
@console_ns.doc("get_workflow_comment")
|
||||
@console_ns.doc(description="Get a specific workflow comment")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
|
||||
@console_ns.response(200, "Comment retrieved successfully", workflow_comment_detail_model)
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@marshal_with(workflow_comment_detail_model)
|
||||
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
|
||||
|
||||
@console_ns.doc("update_workflow_comment")
|
||||
@console_ns.doc(description="Update a workflow comment")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
|
||||
@console_ns.expect(console_ns.models[WorkflowCommentUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Comment updated successfully", workflow_comment_update_model)
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@marshal_with(workflow_comment_update_model)
|
||||
def put(self, app_model: App, comment_id: str):
|
||||
"""Update a workflow comment."""
|
||||
payload = WorkflowCommentUpdatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
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=payload.content,
|
||||
position_x=payload.position_x,
|
||||
position_y=payload.position_y,
|
||||
mentioned_user_ids=payload.mentioned_user_ids,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@console_ns.doc("delete_workflow_comment")
|
||||
@console_ns.doc(description="Delete a workflow comment")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
|
||||
@console_ns.response(204, "Comment deleted successfully")
|
||||
@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
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/resolve")
|
||||
class WorkflowCommentResolveApi(Resource):
|
||||
"""API for resolving and reopening workflow comments."""
|
||||
|
||||
@console_ns.doc("resolve_workflow_comment")
|
||||
@console_ns.doc(description="Resolve a workflow comment")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
|
||||
@console_ns.response(200, "Comment resolved successfully", workflow_comment_resolve_model)
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@marshal_with(workflow_comment_resolve_model)
|
||||
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
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies")
|
||||
class WorkflowCommentReplyApi(Resource):
|
||||
"""API for managing comment replies."""
|
||||
|
||||
@console_ns.doc("create_workflow_comment_reply")
|
||||
@console_ns.doc(description="Add a reply to a workflow comment")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
|
||||
@console_ns.expect(console_ns.models[WorkflowCommentReplyCreatePayload.__name__])
|
||||
@console_ns.response(201, "Reply created successfully", workflow_comment_reply_create_model)
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@marshal_with(workflow_comment_reply_create_model)
|
||||
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
|
||||
)
|
||||
|
||||
payload = WorkflowCommentReplyCreatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
result = WorkflowCommentService.create_reply(
|
||||
comment_id=comment_id,
|
||||
content=payload.content,
|
||||
created_by=current_user.id,
|
||||
mentioned_user_ids=payload.mentioned_user_ids,
|
||||
)
|
||||
|
||||
return result, 201
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies/<string:reply_id>")
|
||||
class WorkflowCommentReplyDetailApi(Resource):
|
||||
"""API for managing individual comment replies."""
|
||||
|
||||
@console_ns.doc("update_workflow_comment_reply")
|
||||
@console_ns.doc(description="Update a comment reply")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID", "reply_id": "Reply ID"})
|
||||
@console_ns.expect(console_ns.models[WorkflowCommentReplyUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Reply updated successfully", workflow_comment_reply_update_model)
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@marshal_with(workflow_comment_reply_update_model)
|
||||
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
|
||||
)
|
||||
|
||||
payload = WorkflowCommentReplyUpdatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
reply = WorkflowCommentService.update_reply(
|
||||
reply_id=reply_id,
|
||||
user_id=current_user.id,
|
||||
content=payload.content,
|
||||
mentioned_user_ids=payload.mentioned_user_ids,
|
||||
)
|
||||
|
||||
return reply
|
||||
|
||||
@console_ns.doc("delete_workflow_comment_reply")
|
||||
@console_ns.doc(description="Delete a comment reply")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID", "reply_id": "Reply ID"})
|
||||
@console_ns.response(204, "Reply deleted successfully")
|
||||
@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
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/mention-users")
|
||||
class WorkflowCommentMentionUsersApi(Resource):
|
||||
"""API for getting mentionable users for workflow comments."""
|
||||
|
||||
@console_ns.doc("workflow_comment_mention_users")
|
||||
@console_ns.doc(description="Get all users in current tenant for mentions")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(
|
||||
200, "Mentionable users retrieved successfully", console_ns.models[WorkflowCommentMentionUsersPayload.__name__]
|
||||
)
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
def get(self, app_model: App):
|
||||
"""Get all users in current tenant for mentions."""
|
||||
members = TenantService.get_tenant_members(current_user.current_tenant)
|
||||
users = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
|
||||
response = WorkflowCommentMentionUsersPayload(users=users)
|
||||
return response.model_dump(mode="json"), 200
|
||||
@ -22,7 +22,6 @@ from controllers.web.error import InvalidArgumentError, NotFoundError
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.workflow.variable_prefixes 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
|
||||
@ -46,16 +45,6 @@ class WorkflowDraftVariableUpdatePayload(BaseModel):
|
||||
value: Any | None = Field(default=None, description="Variable value")
|
||||
|
||||
|
||||
class ConversationVariableUpdatePayload(BaseModel):
|
||||
conversation_variables: list[dict[str, Any]] = Field(
|
||||
..., description="Conversation variables for the draft workflow"
|
||||
)
|
||||
|
||||
|
||||
class EnvironmentVariableUpdatePayload(BaseModel):
|
||||
environment_variables: list[dict[str, Any]] = Field(..., description="Environment variables for the draft workflow")
|
||||
|
||||
|
||||
console_ns.schema_model(
|
||||
WorkflowDraftVariableListQuery.__name__,
|
||||
WorkflowDraftVariableListQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
||||
@ -64,14 +53,6 @@ console_ns.schema_model(
|
||||
WorkflowDraftVariableUpdatePayload.__name__,
|
||||
WorkflowDraftVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
||||
)
|
||||
console_ns.schema_model(
|
||||
ConversationVariableUpdatePayload.__name__,
|
||||
ConversationVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
||||
)
|
||||
console_ns.schema_model(
|
||||
EnvironmentVariableUpdatePayload.__name__,
|
||||
EnvironmentVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
||||
)
|
||||
|
||||
|
||||
def _convert_values_to_json_serializable_object(value: Segment):
|
||||
@ -521,34 +502,6 @@ class ConversationVariableCollectionApi(Resource):
|
||||
db.session.commit()
|
||||
return _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID)
|
||||
|
||||
@console_ns.expect(console_ns.models[ConversationVariableUpdatePayload.__name__])
|
||||
@console_ns.doc("update_conversation_variables")
|
||||
@console_ns.doc(description="Update conversation variables for workflow draft")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(200, "Conversation variables updated successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@get_app_model(mode=AppMode.ADVANCED_CHAT)
|
||||
def post(self, app_model: App):
|
||||
payload = ConversationVariableUpdatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
|
||||
conversation_variables_list = payload.conversation_variables
|
||||
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):
|
||||
@ -600,31 +553,3 @@ class EnvironmentVariableCollectionApi(Resource):
|
||||
)
|
||||
|
||||
return {"items": env_vars_list}
|
||||
|
||||
@console_ns.expect(console_ns.models[EnvironmentVariableUpdatePayload.__name__])
|
||||
@console_ns.doc("update_environment_variables")
|
||||
@console_ns.doc(description="Update environment variables for workflow draft")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(200, "Environment variables updated successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def post(self, app_model: App):
|
||||
payload = EnvironmentVariableUpdatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
|
||||
environment_variables_list = payload.environment_variables
|
||||
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"}
|
||||
|
||||
@ -1 +0,0 @@
|
||||
|
||||
@ -1,108 +0,0 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
|
||||
from flask import Request as FlaskRequest
|
||||
|
||||
from extensions.ext_socketio import sio
|
||||
from libs.passport import PassportService
|
||||
from libs.token import extract_access_token
|
||||
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository
|
||||
from services.account_service import AccountService
|
||||
from services.workflow_collaboration_service import WorkflowCollaborationService
|
||||
|
||||
repository = WorkflowCollaborationRepository()
|
||||
collaboration_service = WorkflowCollaborationService(repository, sio)
|
||||
|
||||
|
||||
def _sio_on(event: str) -> Callable[[Callable[..., object]], Callable[..., object]]:
|
||||
return cast(Callable[[Callable[..., object]], Callable[..., object]], sio.on(event))
|
||||
|
||||
|
||||
@_sio_on("connect")
|
||||
def socket_connect(sid, environ, auth):
|
||||
"""
|
||||
WebSocket connect event, do authentication here.
|
||||
"""
|
||||
try:
|
||||
request_environ = FlaskRequest(environ)
|
||||
token = extract_access_token(request_environ)
|
||||
except Exception:
|
||||
logging.exception("Failed to extract token")
|
||||
token = None
|
||||
|
||||
if not token:
|
||||
logging.warning("Socket connect rejected: missing token (sid=%s)", sid)
|
||||
return False
|
||||
|
||||
try:
|
||||
decoded = PassportService().verify(token)
|
||||
user_id = decoded.get("user_id")
|
||||
if not user_id:
|
||||
logging.warning("Socket connect rejected: missing user_id (sid=%s)", sid)
|
||||
return False
|
||||
|
||||
with sio.app.app_context():
|
||||
user = AccountService.load_logged_in_account(account_id=user_id)
|
||||
if not user:
|
||||
logging.warning("Socket connect rejected: user not found (user_id=%s, sid=%s)", user_id, sid)
|
||||
return False
|
||||
if not user.has_edit_permission:
|
||||
logging.warning("Socket connect rejected: no edit permission (user_id=%s, sid=%s)", user_id, sid)
|
||||
return False
|
||||
|
||||
collaboration_service.save_session(sid, user)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logging.exception("Socket authentication failed")
|
||||
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
|
||||
|
||||
result = collaboration_service.register_session(workflow_id, sid)
|
||||
if not result:
|
||||
return {"msg": "unauthorized"}, 401
|
||||
|
||||
user_id, is_leader = result
|
||||
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.
|
||||
"""
|
||||
collaboration_service.disconnect_session(sid)
|
||||
|
||||
|
||||
@_sio_on("collaboration_event")
|
||||
def handle_collaboration_event(sid, data):
|
||||
"""
|
||||
Handle general collaboration events, include:
|
||||
1. mouse_move
|
||||
2. vars_and_features_update
|
||||
3. sync_request (ask leader to update graph)
|
||||
4. app_state_update
|
||||
5. mcp_server_update
|
||||
6. workflow_update
|
||||
7. comments_update
|
||||
8. node_panel_presence
|
||||
"""
|
||||
return collaboration_service.relay_collaboration_event(sid, data)
|
||||
|
||||
|
||||
@_sio_on("graph_event")
|
||||
def handle_graph_event(sid, data):
|
||||
"""
|
||||
Handle graph events - simple broadcast relay.
|
||||
"""
|
||||
return collaboration_service.relay_graph_event(sid, data)
|
||||
@ -6,7 +6,6 @@ from typing import Literal
|
||||
import pytz
|
||||
from flask import request
|
||||
from flask_restx import Resource, fields, marshal_with
|
||||
from graphon.file import helpers as file_helpers
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@ -76,10 +75,6 @@ class AccountAvatarPayload(BaseModel):
|
||||
avatar: str
|
||||
|
||||
|
||||
class AccountAvatarQuery(BaseModel):
|
||||
avatar: str = Field(..., description="Avatar file ID")
|
||||
|
||||
|
||||
class AccountInterfaceLanguagePayload(BaseModel):
|
||||
interface_language: str
|
||||
|
||||
@ -165,7 +160,6 @@ def reg(cls: type[BaseModel]):
|
||||
reg(AccountInitPayload)
|
||||
reg(AccountNamePayload)
|
||||
reg(AccountAvatarPayload)
|
||||
reg(AccountAvatarQuery)
|
||||
reg(AccountInterfaceLanguagePayload)
|
||||
reg(AccountInterfaceThemePayload)
|
||||
reg(AccountTimezonePayload)
|
||||
@ -275,18 +269,6 @@ class AccountNameApi(Resource):
|
||||
|
||||
@console_ns.route("/account/avatar")
|
||||
class AccountAvatarApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AccountAvatarQuery.__name__])
|
||||
@console_ns.doc("get_account_avatar")
|
||||
@console_ns.doc(description="Get account avatar url")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
args = AccountAvatarQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||
|
||||
avatar_url = file_helpers.get_signed_file_url(args.avatar)
|
||||
return {"avatar_url": avatar_url}
|
||||
|
||||
@console_ns.expect(console_ns.models[AccountAvatarPayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
|
||||
@ -7,14 +7,13 @@ from sqlalchemy import select
|
||||
|
||||
from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler
|
||||
from core.model_manager import ModelManager
|
||||
from core.rag.datasource.retrieval_service import RetrievalService
|
||||
from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService
|
||||
from core.rag.entities import RetrievalSourceMetadata
|
||||
from core.rag.index_processor.constant.index_type import IndexTechniqueType
|
||||
from core.rag.models.document import Document as RagDocument
|
||||
from core.rag.rerank.rerank_model import RerankModelRunner
|
||||
from core.rag.retrieval.retrieval_methods import RetrievalMethod
|
||||
from core.tools.utils.dataset_retriever.dataset_retriever_base_tool import DatasetRetrieverBaseTool
|
||||
from core.tools.utils.dataset_retriever.dataset_retriever_tool import DefaultRetrievalModelDict
|
||||
from extensions.ext_database import db
|
||||
from models.dataset import Dataset, Document, DocumentSegment
|
||||
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
from typing import NotRequired, TypedDict, cast
|
||||
from typing import cast
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.app.app_config.entities import DatasetRetrieveConfigEntity, ModelConfig
|
||||
from core.rag.data_post_processor.data_post_processor import RerankingModelDict, WeightsDict
|
||||
from core.rag.datasource.retrieval_service import RetrievalService
|
||||
from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService
|
||||
from core.rag.entities import DocumentContext, RetrievalSourceMetadata
|
||||
from core.rag.index_processor.constant.index_type import IndexTechniqueType
|
||||
from core.rag.models.document import Document as RetrievalDocument
|
||||
@ -17,18 +16,6 @@ from models.dataset import Dataset
|
||||
from models.dataset import Document as DatasetDocument
|
||||
from services.external_knowledge_service import ExternalDatasetService
|
||||
|
||||
|
||||
class DefaultRetrievalModelDict(TypedDict):
|
||||
search_method: RetrievalMethod
|
||||
reranking_enable: bool
|
||||
reranking_model: RerankingModelDict
|
||||
reranking_mode: NotRequired[str]
|
||||
weights: NotRequired[WeightsDict | None]
|
||||
score_threshold: NotRequired[float]
|
||||
top_k: int
|
||||
score_threshold_enabled: bool
|
||||
|
||||
|
||||
default_retrieval_model: DefaultRetrievalModelDict = {
|
||||
"search_method": RetrievalMethod.SEMANTIC_SEARCH,
|
||||
"reranking_enable": False,
|
||||
|
||||
@ -119,16 +119,14 @@ elif [[ "${MODE}" == "job" ]]; then
|
||||
|
||||
else
|
||||
if [[ "${DEBUG}" == "true" ]]; then
|
||||
export HOST=${DIFY_BIND_ADDRESS:-0.0.0.0}
|
||||
export PORT=${DIFY_PORT:-5001}
|
||||
exec python -m app
|
||||
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:-geventwebsocket.gunicorn.workers.GeventWebSocketWorker} \
|
||||
--worker-class ${SERVER_WORKER_CLASS:-gevent} \
|
||||
--worker-connections ${SERVER_WORKER_CONNECTIONS:-10} \
|
||||
--timeout ${GUNICORN_TIMEOUT:-200} \
|
||||
app:socketio_app
|
||||
app:app
|
||||
fi
|
||||
fi
|
||||
|
||||
@ -68,46 +68,49 @@ class EnterpriseMetricHandler:
|
||||
|
||||
# Route to appropriate handler based on case
|
||||
case = envelope.case
|
||||
if case == TelemetryCase.APP_CREATED:
|
||||
self._on_app_created(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "app_created"})
|
||||
elif case == TelemetryCase.APP_UPDATED:
|
||||
self._on_app_updated(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "app_updated"})
|
||||
elif case == TelemetryCase.APP_DELETED:
|
||||
self._on_app_deleted(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "app_deleted"})
|
||||
elif case == TelemetryCase.FEEDBACK_CREATED:
|
||||
self._on_feedback_created(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "feedback_created"})
|
||||
elif case == TelemetryCase.MESSAGE_RUN:
|
||||
self._on_message_run(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "message_run"})
|
||||
elif case == TelemetryCase.TOOL_EXECUTION:
|
||||
self._on_tool_execution(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "tool_execution"})
|
||||
elif case == TelemetryCase.MODERATION_CHECK:
|
||||
self._on_moderation_check(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "moderation_check"})
|
||||
elif case == TelemetryCase.SUGGESTED_QUESTION:
|
||||
self._on_suggested_question(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "suggested_question"})
|
||||
elif case == TelemetryCase.DATASET_RETRIEVAL:
|
||||
self._on_dataset_retrieval(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "dataset_retrieval"})
|
||||
elif case == TelemetryCase.GENERATE_NAME:
|
||||
self._on_generate_name(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "generate_name"})
|
||||
elif case == TelemetryCase.PROMPT_GENERATION:
|
||||
self._on_prompt_generation(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "prompt_generation"})
|
||||
else:
|
||||
logger.warning(
|
||||
"Unknown telemetry case: %s (tenant_id=%s, event_id=%s)",
|
||||
case,
|
||||
envelope.tenant_id,
|
||||
envelope.event_id,
|
||||
)
|
||||
match case:
|
||||
case TelemetryCase.APP_CREATED:
|
||||
self._on_app_created(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "app_created"})
|
||||
case TelemetryCase.APP_UPDATED:
|
||||
self._on_app_updated(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "app_updated"})
|
||||
case TelemetryCase.APP_DELETED:
|
||||
self._on_app_deleted(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "app_deleted"})
|
||||
case TelemetryCase.FEEDBACK_CREATED:
|
||||
self._on_feedback_created(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "feedback_created"})
|
||||
case TelemetryCase.MESSAGE_RUN:
|
||||
self._on_message_run(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "message_run"})
|
||||
case TelemetryCase.TOOL_EXECUTION:
|
||||
self._on_tool_execution(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "tool_execution"})
|
||||
case TelemetryCase.MODERATION_CHECK:
|
||||
self._on_moderation_check(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "moderation_check"})
|
||||
case TelemetryCase.SUGGESTED_QUESTION:
|
||||
self._on_suggested_question(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "suggested_question"})
|
||||
case TelemetryCase.DATASET_RETRIEVAL:
|
||||
self._on_dataset_retrieval(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "dataset_retrieval"})
|
||||
case TelemetryCase.GENERATE_NAME:
|
||||
self._on_generate_name(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "generate_name"})
|
||||
case TelemetryCase.PROMPT_GENERATION:
|
||||
self._on_prompt_generation(envelope)
|
||||
self._increment_diagnostic_counter("processed_total", {"case": "prompt_generation"})
|
||||
case TelemetryCase.WORKFLOW_RUN | TelemetryCase.NODE_EXECUTION | TelemetryCase.DRAFT_NODE_EXECUTION:
|
||||
pass
|
||||
case _:
|
||||
logger.warning(
|
||||
"Unknown telemetry case: %s (tenant_id=%s, event_id=%s)",
|
||||
case,
|
||||
envelope.tenant_id,
|
||||
envelope.event_id,
|
||||
)
|
||||
|
||||
def _is_duplicate(self, envelope: TelemetryEnvelope) -> bool:
|
||||
"""Check if this event has already been processed.
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
import socketio # type: ignore[reportMissingTypeStubs]
|
||||
|
||||
from configs import dify_config
|
||||
|
||||
sio = socketio.Server(async_mode="gevent", cors_allowed_origins=dify_config.CONSOLE_CORS_ALLOW_ORIGINS)
|
||||
@ -1,17 +0,0 @@
|
||||
from flask_restx import fields
|
||||
|
||||
online_user_partial_fields = {
|
||||
"user_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)),
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
"""Add workflow comments table
|
||||
|
||||
Revision ID: 227822d22895
|
||||
Revises: 6b5f9f8b1a2c
|
||||
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 = '6b5f9f8b1a2c'
|
||||
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(), 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(), 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(), 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,11 +9,6 @@ from .account import (
|
||||
TenantStatus,
|
||||
)
|
||||
from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint
|
||||
from .comment import (
|
||||
WorkflowComment,
|
||||
WorkflowCommentMention,
|
||||
WorkflowCommentReply,
|
||||
)
|
||||
from .dataset import (
|
||||
AppDatasetJoin,
|
||||
Dataset,
|
||||
@ -213,9 +208,6 @@ __all__ = [
|
||||
"WorkflowAppLog",
|
||||
"WorkflowAppLogCreatedFrom",
|
||||
"WorkflowArchiveLog",
|
||||
"WorkflowComment",
|
||||
"WorkflowCommentMention",
|
||||
"WorkflowCommentReply",
|
||||
"WorkflowNodeExecutionModel",
|
||||
"WorkflowNodeExecutionOffload",
|
||||
"WorkflowNodeExecutionTriggeredFrom",
|
||||
|
||||
@ -1,210 +0,0 @@
|
||||
"""Workflow comment models."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import 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
|
||||
|
||||
|
||||
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("uuidv7()"))
|
||||
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[datetime | None] = mapped_column(db.DateTime)
|
||||
resolved_by: Mapped[str | None] = 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."""
|
||||
if hasattr(self, "_created_by_account_cache"):
|
||||
return self._created_by_account_cache
|
||||
return db.session.get(Account, self.created_by)
|
||||
|
||||
def cache_created_by_account(self, account: Account | None) -> None:
|
||||
"""Cache creator account to avoid extra queries."""
|
||||
self._created_by_account_cache = account
|
||||
|
||||
@property
|
||||
def resolved_by_account(self):
|
||||
"""Get resolver account."""
|
||||
if hasattr(self, "_resolved_by_account_cache"):
|
||||
return self._resolved_by_account_cache
|
||||
if self.resolved_by:
|
||||
return db.session.get(Account, self.resolved_by)
|
||||
return None
|
||||
|
||||
def cache_resolved_by_account(self, account: Account | None) -> None:
|
||||
"""Cache resolver account to avoid extra queries."""
|
||||
self._resolved_by_account_cache = account
|
||||
|
||||
@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("uuidv7()"))
|
||||
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."""
|
||||
if hasattr(self, "_created_by_account_cache"):
|
||||
return self._created_by_account_cache
|
||||
return db.session.get(Account, self.created_by)
|
||||
|
||||
def cache_created_by_account(self, account: Account | None) -> None:
|
||||
"""Cache creator account to avoid extra queries."""
|
||||
self._created_by_account_cache = account
|
||||
|
||||
|
||||
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("uuidv7()"))
|
||||
comment_id: Mapped[str] = mapped_column(
|
||||
StringUUID, db.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
reply_id: Mapped[str | None] = 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."""
|
||||
if hasattr(self, "_mentioned_user_account_cache"):
|
||||
return self._mentioned_user_account_cache
|
||||
return db.session.get(Account, self.mentioned_user_id)
|
||||
|
||||
def cache_mentioned_user_account(self, account: Account | None) -> None:
|
||||
"""Cache mentioned account to avoid extra queries."""
|
||||
self._mentioned_user_account_cache = account
|
||||
@ -490,7 +490,7 @@ class Workflow(Base): # bug
|
||||
|
||||
:return: hash
|
||||
"""
|
||||
entity = {"graph": self.graph_dict}
|
||||
entity = {"graph": self.graph_dict, "features": self.features_dict}
|
||||
|
||||
return helper.generate_text_hash(json.dumps(entity, sort_keys=True))
|
||||
|
||||
|
||||
@ -21,7 +21,6 @@ dependencies = [
|
||||
"flask-orjson~=2.0.0",
|
||||
"flask-sqlalchemy~=3.1.1",
|
||||
"gevent~=25.9.1",
|
||||
"gevent-websocket~=0.10.1",
|
||||
"gmpy2~=2.3.0",
|
||||
"google-api-core>=2.19.1",
|
||||
"google-api-python-client==2.193.0",
|
||||
@ -41,7 +40,7 @@ dependencies = [
|
||||
"numpy~=1.26.4",
|
||||
"openpyxl~=3.1.5",
|
||||
"opik~=1.10.37",
|
||||
"litellm==1.82.6", # Pinned to avoid madoka dependency issue
|
||||
"litellm==1.83.0", # Pinned to avoid madoka dependency issue
|
||||
"opentelemetry-api==1.40.0",
|
||||
"opentelemetry-distro==0.61b0",
|
||||
"opentelemetry-exporter-otlp==1.40.0",
|
||||
@ -69,7 +68,6 @@ dependencies = [
|
||||
"pypdfium2==5.6.0",
|
||||
"python-docx~=1.2.0",
|
||||
"python-dotenv==1.2.2",
|
||||
"python-socketio~=5.13.0",
|
||||
"pyyaml~=6.0.1",
|
||||
"readabilipy~=0.3.0",
|
||||
"redis[hiredis]~=7.4.0",
|
||||
|
||||
@ -1,147 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TypedDict
|
||||
|
||||
from extensions.ext_redis import redis_client
|
||||
|
||||
SESSION_STATE_TTL_SECONDS = 3600
|
||||
WORKFLOW_ONLINE_USERS_PREFIX = "workflow_online_users:"
|
||||
WORKFLOW_LEADER_PREFIX = "workflow_leader:"
|
||||
WS_SID_MAP_PREFIX = "ws_sid_map:"
|
||||
|
||||
|
||||
class WorkflowSessionInfo(TypedDict):
|
||||
user_id: str
|
||||
username: str
|
||||
avatar: str | None
|
||||
sid: str
|
||||
connected_at: int
|
||||
|
||||
|
||||
class SidMapping(TypedDict):
|
||||
workflow_id: str
|
||||
user_id: str
|
||||
|
||||
|
||||
class WorkflowCollaborationRepository:
|
||||
def __init__(self) -> None:
|
||||
self._redis = redis_client
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}(redis_client={self._redis})"
|
||||
|
||||
@staticmethod
|
||||
def workflow_key(workflow_id: str) -> str:
|
||||
return f"{WORKFLOW_ONLINE_USERS_PREFIX}{workflow_id}"
|
||||
|
||||
@staticmethod
|
||||
def leader_key(workflow_id: str) -> str:
|
||||
return f"{WORKFLOW_LEADER_PREFIX}{workflow_id}"
|
||||
|
||||
@staticmethod
|
||||
def sid_key(sid: str) -> str:
|
||||
return f"{WS_SID_MAP_PREFIX}{sid}"
|
||||
|
||||
@staticmethod
|
||||
def _decode(value: str | bytes | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8")
|
||||
return value
|
||||
|
||||
def refresh_session_state(self, workflow_id: str, sid: str) -> None:
|
||||
workflow_key = self.workflow_key(workflow_id)
|
||||
sid_key = self.sid_key(sid)
|
||||
if self._redis.exists(workflow_key):
|
||||
self._redis.expire(workflow_key, SESSION_STATE_TTL_SECONDS)
|
||||
if self._redis.exists(sid_key):
|
||||
self._redis.expire(sid_key, SESSION_STATE_TTL_SECONDS)
|
||||
|
||||
def set_session_info(self, workflow_id: str, session_info: WorkflowSessionInfo) -> None:
|
||||
workflow_key = self.workflow_key(workflow_id)
|
||||
self._redis.hset(workflow_key, session_info["sid"], json.dumps(session_info))
|
||||
self._redis.set(
|
||||
self.sid_key(session_info["sid"]),
|
||||
json.dumps({"workflow_id": workflow_id, "user_id": session_info["user_id"]}),
|
||||
ex=SESSION_STATE_TTL_SECONDS,
|
||||
)
|
||||
self.refresh_session_state(workflow_id, session_info["sid"])
|
||||
|
||||
def get_sid_mapping(self, sid: str) -> SidMapping | None:
|
||||
raw = self._redis.get(self.sid_key(sid))
|
||||
if not raw:
|
||||
return None
|
||||
value = self._decode(raw)
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
def delete_session(self, workflow_id: str, sid: str) -> None:
|
||||
self._redis.hdel(self.workflow_key(workflow_id), sid)
|
||||
self._redis.delete(self.sid_key(sid))
|
||||
|
||||
def session_exists(self, workflow_id: str, sid: str) -> bool:
|
||||
return bool(self._redis.hexists(self.workflow_key(workflow_id), sid))
|
||||
|
||||
def sid_mapping_exists(self, sid: str) -> bool:
|
||||
return bool(self._redis.exists(self.sid_key(sid)))
|
||||
|
||||
def get_session_sids(self, workflow_id: str) -> list[str]:
|
||||
raw_sids = self._redis.hkeys(self.workflow_key(workflow_id))
|
||||
decoded_sids: list[str] = []
|
||||
for sid in raw_sids:
|
||||
decoded = self._decode(sid)
|
||||
if decoded:
|
||||
decoded_sids.append(decoded)
|
||||
return decoded_sids
|
||||
|
||||
def list_sessions(self, workflow_id: str) -> list[WorkflowSessionInfo]:
|
||||
sessions_json = self._redis.hgetall(self.workflow_key(workflow_id))
|
||||
users: list[WorkflowSessionInfo] = []
|
||||
|
||||
for session_info_json in sessions_json.values():
|
||||
value = self._decode(session_info_json)
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
session_info = json.loads(value)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
if not isinstance(session_info, dict):
|
||||
continue
|
||||
if "user_id" not in session_info or "username" not in session_info or "sid" not in session_info:
|
||||
continue
|
||||
|
||||
users.append(
|
||||
{
|
||||
"user_id": str(session_info["user_id"]),
|
||||
"username": str(session_info["username"]),
|
||||
"avatar": session_info.get("avatar"),
|
||||
"sid": str(session_info["sid"]),
|
||||
"connected_at": int(session_info.get("connected_at") or 0),
|
||||
}
|
||||
)
|
||||
|
||||
return users
|
||||
|
||||
def get_current_leader(self, workflow_id: str) -> str | None:
|
||||
raw = self._redis.get(self.leader_key(workflow_id))
|
||||
return self._decode(raw)
|
||||
|
||||
def set_leader_if_absent(self, workflow_id: str, sid: str) -> bool:
|
||||
return bool(self._redis.set(self.leader_key(workflow_id), sid, nx=True, ex=SESSION_STATE_TTL_SECONDS))
|
||||
|
||||
def set_leader(self, workflow_id: str, sid: str) -> None:
|
||||
self._redis.set(self.leader_key(workflow_id), sid, ex=SESSION_STATE_TTL_SECONDS)
|
||||
|
||||
def delete_leader(self, workflow_id: str) -> None:
|
||||
self._redis.delete(self.leader_key(workflow_id))
|
||||
|
||||
def expire_leader(self, workflow_id: str) -> None:
|
||||
self._redis.expire(self.leader_key(workflow_id), SESSION_STATE_TTL_SECONDS)
|
||||
@ -164,7 +164,6 @@ class SystemFeatureModel(BaseModel):
|
||||
enable_email_code_login: bool = False
|
||||
enable_email_password_login: bool = True
|
||||
enable_social_oauth_login: bool = False
|
||||
enable_collaboration_mode: bool = False
|
||||
is_allow_register: bool = False
|
||||
is_allow_create_workspace: bool = False
|
||||
is_email_setup: bool = False
|
||||
@ -245,7 +244,6 @@ class FeatureService:
|
||||
system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN
|
||||
system_features.enable_email_password_login = dify_config.ENABLE_EMAIL_PASSWORD_LOGIN
|
||||
system_features.enable_social_oauth_login = dify_config.ENABLE_SOCIAL_OAUTH_LOGIN
|
||||
system_features.enable_collaboration_mode = dify_config.ENABLE_COLLABORATION_MODE
|
||||
system_features.is_allow_register = dify_config.ALLOW_REGISTER
|
||||
system_features.is_allow_create_workspace = dify_config.ALLOW_CREATE_WORKSPACE
|
||||
system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != ""
|
||||
|
||||
@ -1,196 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
|
||||
from models.account import Account
|
||||
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository, WorkflowSessionInfo
|
||||
|
||||
|
||||
class WorkflowCollaborationService:
|
||||
def __init__(self, repository: WorkflowCollaborationRepository, socketio) -> None:
|
||||
self._repository = repository
|
||||
self._socketio = socketio
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}(repository={self._repository})"
|
||||
|
||||
def save_session(self, sid: str, user: Account) -> None:
|
||||
self._socketio.save_session(
|
||||
sid,
|
||||
{
|
||||
"user_id": user.id,
|
||||
"username": user.name,
|
||||
"avatar": user.avatar,
|
||||
},
|
||||
)
|
||||
|
||||
def register_session(self, workflow_id: str, sid: str) -> tuple[str, bool] | None:
|
||||
session = self._socketio.get_session(sid)
|
||||
user_id = session.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
session_info: WorkflowSessionInfo = {
|
||||
"user_id": str(user_id),
|
||||
"username": str(session.get("username", "Unknown")),
|
||||
"avatar": session.get("avatar"),
|
||||
"sid": sid,
|
||||
"connected_at": int(time.time()),
|
||||
}
|
||||
|
||||
self._repository.set_session_info(workflow_id, session_info)
|
||||
|
||||
leader_sid = self.get_or_set_leader(workflow_id, sid)
|
||||
is_leader = leader_sid == sid
|
||||
|
||||
self._socketio.enter_room(sid, workflow_id)
|
||||
self.broadcast_online_users(workflow_id)
|
||||
|
||||
self._socketio.emit("status", {"isLeader": is_leader}, room=sid)
|
||||
|
||||
return str(user_id), is_leader
|
||||
|
||||
def disconnect_session(self, sid: str) -> None:
|
||||
mapping = self._repository.get_sid_mapping(sid)
|
||||
if not mapping:
|
||||
return
|
||||
|
||||
workflow_id = mapping["workflow_id"]
|
||||
self._repository.delete_session(workflow_id, sid)
|
||||
|
||||
self.handle_leader_disconnect(workflow_id, sid)
|
||||
self.broadcast_online_users(workflow_id)
|
||||
|
||||
def relay_collaboration_event(self, sid: str, data: Mapping[str, object]) -> tuple[dict[str, str], int]:
|
||||
mapping = self._repository.get_sid_mapping(sid)
|
||||
if not mapping:
|
||||
return {"msg": "unauthorized"}, 401
|
||||
|
||||
workflow_id = mapping["workflow_id"]
|
||||
user_id = mapping["user_id"]
|
||||
self.refresh_session_state(workflow_id, sid)
|
||||
|
||||
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
|
||||
|
||||
self._socketio.emit(
|
||||
"collaboration_update",
|
||||
{"type": event_type, "userId": user_id, "data": event_data, "timestamp": timestamp},
|
||||
room=workflow_id,
|
||||
skip_sid=sid,
|
||||
)
|
||||
|
||||
return {"msg": "event_broadcasted"}, 200
|
||||
|
||||
def relay_graph_event(self, sid: str, data: object) -> tuple[dict[str, str], int]:
|
||||
mapping = self._repository.get_sid_mapping(sid)
|
||||
if not mapping:
|
||||
return {"msg": "unauthorized"}, 401
|
||||
|
||||
workflow_id = mapping["workflow_id"]
|
||||
self.refresh_session_state(workflow_id, sid)
|
||||
|
||||
self._socketio.emit("graph_update", data, room=workflow_id, skip_sid=sid)
|
||||
|
||||
return {"msg": "graph_update_broadcasted"}, 200
|
||||
|
||||
def get_or_set_leader(self, workflow_id: str, sid: str) -> str:
|
||||
current_leader = self._repository.get_current_leader(workflow_id)
|
||||
|
||||
if current_leader:
|
||||
if self.is_session_active(workflow_id, current_leader):
|
||||
return current_leader
|
||||
self._repository.delete_session(workflow_id, current_leader)
|
||||
self._repository.delete_leader(workflow_id)
|
||||
|
||||
was_set = self._repository.set_leader_if_absent(workflow_id, sid)
|
||||
|
||||
if was_set:
|
||||
if current_leader:
|
||||
self.broadcast_leader_change(workflow_id, sid)
|
||||
return sid
|
||||
|
||||
current_leader = self._repository.get_current_leader(workflow_id)
|
||||
if current_leader:
|
||||
return current_leader
|
||||
|
||||
return sid
|
||||
|
||||
def handle_leader_disconnect(self, workflow_id: str, disconnected_sid: str) -> None:
|
||||
current_leader = self._repository.get_current_leader(workflow_id)
|
||||
if not current_leader:
|
||||
return
|
||||
|
||||
if current_leader != disconnected_sid:
|
||||
return
|
||||
|
||||
session_sids = self._repository.get_session_sids(workflow_id)
|
||||
if session_sids:
|
||||
new_leader_sid = session_sids[0]
|
||||
self._repository.set_leader(workflow_id, new_leader_sid)
|
||||
self.broadcast_leader_change(workflow_id, new_leader_sid)
|
||||
else:
|
||||
self._repository.delete_leader(workflow_id)
|
||||
|
||||
def broadcast_leader_change(self, workflow_id: str, new_leader_sid: str) -> None:
|
||||
for sid in self._repository.get_session_sids(workflow_id):
|
||||
try:
|
||||
is_leader = sid == new_leader_sid
|
||||
self._socketio.emit("status", {"isLeader": is_leader}, room=sid)
|
||||
except Exception:
|
||||
logging.exception("Failed to emit leader status to session %s", sid)
|
||||
|
||||
def get_current_leader(self, workflow_id: str) -> str | None:
|
||||
return self._repository.get_current_leader(workflow_id)
|
||||
|
||||
def broadcast_online_users(self, workflow_id: str) -> None:
|
||||
users = self._repository.list_sessions(workflow_id)
|
||||
users.sort(key=lambda x: x.get("connected_at") or 0)
|
||||
|
||||
leader_sid = self.get_current_leader(workflow_id)
|
||||
|
||||
self._socketio.emit(
|
||||
"online_users",
|
||||
{"workflow_id": workflow_id, "users": users, "leader": leader_sid},
|
||||
room=workflow_id,
|
||||
)
|
||||
|
||||
def refresh_session_state(self, workflow_id: str, sid: str) -> None:
|
||||
self._repository.refresh_session_state(workflow_id, sid)
|
||||
self._ensure_leader(workflow_id, sid)
|
||||
|
||||
def _ensure_leader(self, workflow_id: str, sid: str) -> None:
|
||||
current_leader = self._repository.get_current_leader(workflow_id)
|
||||
if current_leader and self.is_session_active(workflow_id, current_leader):
|
||||
self._repository.expire_leader(workflow_id)
|
||||
return
|
||||
|
||||
if current_leader:
|
||||
self._repository.delete_leader(workflow_id)
|
||||
|
||||
self._repository.set_leader(workflow_id, sid)
|
||||
self.broadcast_leader_change(workflow_id, sid)
|
||||
|
||||
def is_session_active(self, workflow_id: str, sid: str) -> bool:
|
||||
if not sid:
|
||||
return False
|
||||
|
||||
try:
|
||||
if not self._socketio.manager.is_connected(sid, "/"):
|
||||
return False
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
if not self._repository.session_exists(workflow_id, sid):
|
||||
return False
|
||||
|
||||
if not self._repository.sid_mapping_exists(sid):
|
||||
return False
|
||||
|
||||
return True
|
||||
@ -1,345 +0,0 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
|
||||
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
|
||||
from models.account import Account
|
||||
|
||||
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) -> Sequence[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()
|
||||
|
||||
# Batch preload all Account objects to avoid N+1 queries
|
||||
WorkflowCommentService._preload_accounts(session, comments)
|
||||
|
||||
return comments
|
||||
|
||||
@staticmethod
|
||||
def _preload_accounts(session: Session, comments: Sequence[WorkflowComment]) -> None:
|
||||
"""Batch preload Account objects for comments, replies, and mentions."""
|
||||
# Collect all user IDs
|
||||
user_ids: set[str] = set()
|
||||
for comment in comments:
|
||||
user_ids.add(comment.created_by)
|
||||
if comment.resolved_by:
|
||||
user_ids.add(comment.resolved_by)
|
||||
user_ids.update(reply.created_by for reply in comment.replies)
|
||||
user_ids.update(mention.mentioned_user_id for mention in comment.mentions)
|
||||
|
||||
if not user_ids:
|
||||
return
|
||||
|
||||
# Batch query all accounts
|
||||
accounts = session.scalars(select(Account).where(Account.id.in_(user_ids))).all()
|
||||
account_map = {str(account.id): account for account in accounts}
|
||||
|
||||
# Cache accounts on objects
|
||||
for comment in comments:
|
||||
comment.cache_created_by_account(account_map.get(comment.created_by))
|
||||
comment.cache_resolved_by_account(account_map.get(comment.resolved_by) if comment.resolved_by else None)
|
||||
for reply in comment.replies:
|
||||
reply.cache_created_by_account(account_map.get(reply.created_by))
|
||||
for mention in comment.mentions:
|
||||
mention.cache_mentioned_user_account(account_map.get(mention.mentioned_user_id))
|
||||
|
||||
@staticmethod
|
||||
def get_comment(tenant_id: str, app_id: str, comment_id: str, session: Session | None = 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")
|
||||
|
||||
# Preload accounts to avoid N+1 queries
|
||||
WorkflowCommentService._preload_accounts(session, [comment])
|
||||
|
||||
return comment
|
||||
|
||||
if session is not None:
|
||||
return _get_comment(session)
|
||||
else:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
return _get_comment(session)
|
||||
|
||||
@staticmethod
|
||||
def create_comment(
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
created_by: str,
|
||||
content: str,
|
||||
position_x: float,
|
||||
position_y: float,
|
||||
mentioned_user_ids: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""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: float | None = None,
|
||||
position_y: float | None = None,
|
||||
mentioned_user_ids: list[str] | None = 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: list[str] | None = 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: list[str] | None = None) -> dict:
|
||||
"""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)
|
||||
@ -291,78 +291,6 @@ class WorkflowService:
|
||||
# return draft workflow
|
||||
return workflow
|
||||
|
||||
def update_draft_workflow_environment_variables(
|
||||
self,
|
||||
*,
|
||||
app_model: App,
|
||||
environment_variables: Sequence[VariableBase],
|
||||
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 = naive_utc_now()
|
||||
|
||||
# commit db session changes
|
||||
db.session.commit()
|
||||
|
||||
def update_draft_workflow_conversation_variables(
|
||||
self,
|
||||
*,
|
||||
app_model: App,
|
||||
conversation_variables: Sequence[VariableBase],
|
||||
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 = naive_utc_now()
|
||||
|
||||
# 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 = naive_utc_now()
|
||||
|
||||
# commit db session changes
|
||||
db.session.commit()
|
||||
|
||||
def restore_published_workflow_to_draft(
|
||||
self,
|
||||
*,
|
||||
|
||||
@ -47,7 +47,7 @@ os.environ["OPENDAL_FS_ROOT"] = "/tmp/dify-storage"
|
||||
os.environ.setdefault("STORAGE_TYPE", "opendal")
|
||||
os.environ.setdefault("OPENDAL_SCHEME", "fs")
|
||||
|
||||
_SIO_APP, _CACHED_APP = create_app()
|
||||
_CACHED_APP = create_app()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
|
||||
@ -369,7 +369,7 @@ def _create_app_with_containers() -> Flask:
|
||||
|
||||
# Create and configure the Flask application
|
||||
logger.info("Initializing Flask application...")
|
||||
sio_app, app = create_app()
|
||||
app = create_app()
|
||||
logger.info("Flask application created successfully")
|
||||
|
||||
# Initialize database schema
|
||||
|
||||
@ -274,7 +274,6 @@ class TestFeatureService:
|
||||
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
|
||||
mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True
|
||||
mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = False
|
||||
mock_config.ENABLE_COLLABORATION_MODE = True
|
||||
mock_config.ALLOW_REGISTER = False
|
||||
mock_config.ALLOW_CREATE_WORKSPACE = False
|
||||
mock_config.MAIL_TYPE = "smtp"
|
||||
@ -299,7 +298,6 @@ class TestFeatureService:
|
||||
# Verify authentication settings
|
||||
assert result.enable_email_code_login is True
|
||||
assert result.enable_email_password_login is False
|
||||
assert result.enable_collaboration_mode is True
|
||||
assert result.is_allow_register is False
|
||||
assert result.is_allow_create_workspace is False
|
||||
|
||||
@ -403,7 +401,6 @@ class TestFeatureService:
|
||||
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
|
||||
mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True
|
||||
mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = False
|
||||
mock_config.ENABLE_COLLABORATION_MODE = False
|
||||
mock_config.ALLOW_REGISTER = True
|
||||
mock_config.ALLOW_CREATE_WORKSPACE = True
|
||||
mock_config.MAIL_TYPE = "smtp"
|
||||
@ -425,7 +422,6 @@ class TestFeatureService:
|
||||
assert result.enable_email_code_login is True
|
||||
assert result.enable_email_password_login is True
|
||||
assert result.enable_social_oauth_login is False
|
||||
assert result.enable_collaboration_mode is False
|
||||
assert result.is_allow_register is True
|
||||
assert result.is_allow_create_workspace is True
|
||||
assert result.is_email_setup is True
|
||||
|
||||
@ -1,121 +0,0 @@
|
||||
import json
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from repositories import workflow_collaboration_repository as repo_module
|
||||
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository
|
||||
|
||||
|
||||
class TestWorkflowCollaborationRepository:
|
||||
@pytest.fixture
|
||||
def mock_redis(self, monkeypatch: pytest.MonkeyPatch) -> Mock:
|
||||
mock_redis = Mock()
|
||||
monkeypatch.setattr(repo_module, "redis_client", mock_redis)
|
||||
return mock_redis
|
||||
|
||||
def test_get_sid_mapping_returns_mapping(self, mock_redis: Mock) -> None:
|
||||
# Arrange
|
||||
mock_redis.get.return_value = b'{"workflow_id":"wf-1","user_id":"u-1"}'
|
||||
repository = WorkflowCollaborationRepository()
|
||||
|
||||
# Act
|
||||
result = repository.get_sid_mapping("sid-1")
|
||||
|
||||
# Assert
|
||||
assert result == {"workflow_id": "wf-1", "user_id": "u-1"}
|
||||
|
||||
def test_list_sessions_filters_invalid_entries(self, mock_redis: Mock) -> None:
|
||||
# Arrange
|
||||
mock_redis.hgetall.return_value = {
|
||||
b"sid-1": b'{"user_id":"u-1","username":"Jane","sid":"sid-1","connected_at":2}',
|
||||
b"sid-2": b'{"username":"Missing","sid":"sid-2"}',
|
||||
b"sid-3": b"not-json",
|
||||
}
|
||||
repository = WorkflowCollaborationRepository()
|
||||
|
||||
# Act
|
||||
result = repository.list_sessions("wf-1")
|
||||
|
||||
# Assert
|
||||
assert result == [
|
||||
{
|
||||
"user_id": "u-1",
|
||||
"username": "Jane",
|
||||
"avatar": None,
|
||||
"sid": "sid-1",
|
||||
"connected_at": 2,
|
||||
}
|
||||
]
|
||||
|
||||
def test_set_session_info_persists_payload(self, mock_redis: Mock) -> None:
|
||||
# Arrange
|
||||
mock_redis.exists.return_value = True
|
||||
repository = WorkflowCollaborationRepository()
|
||||
payload = {
|
||||
"user_id": "u-1",
|
||||
"username": "Jane",
|
||||
"avatar": None,
|
||||
"sid": "sid-1",
|
||||
"connected_at": 1,
|
||||
}
|
||||
|
||||
# Act
|
||||
repository.set_session_info("wf-1", payload)
|
||||
|
||||
# Assert
|
||||
assert mock_redis.hset.called
|
||||
workflow_key, sid, session_json = mock_redis.hset.call_args.args
|
||||
assert workflow_key == "workflow_online_users:wf-1"
|
||||
assert sid == "sid-1"
|
||||
assert json.loads(session_json)["user_id"] == "u-1"
|
||||
assert mock_redis.set.called
|
||||
|
||||
def test_refresh_session_state_expires_keys(self, mock_redis: Mock) -> None:
|
||||
# Arrange
|
||||
mock_redis.exists.return_value = True
|
||||
repository = WorkflowCollaborationRepository()
|
||||
|
||||
# Act
|
||||
repository.refresh_session_state("wf-1", "sid-1")
|
||||
|
||||
# Assert
|
||||
assert mock_redis.expire.call_count == 2
|
||||
|
||||
def test_get_current_leader_decodes_bytes(self, mock_redis: Mock) -> None:
|
||||
# Arrange
|
||||
mock_redis.get.return_value = b"sid-1"
|
||||
repository = WorkflowCollaborationRepository()
|
||||
|
||||
# Act
|
||||
result = repository.get_current_leader("wf-1")
|
||||
|
||||
# Assert
|
||||
assert result == "sid-1"
|
||||
|
||||
def test_set_leader_if_absent_uses_nx(self, mock_redis: Mock) -> None:
|
||||
# Arrange
|
||||
mock_redis.set.return_value = True
|
||||
repository = WorkflowCollaborationRepository()
|
||||
|
||||
# Act
|
||||
result = repository.set_leader_if_absent("wf-1", "sid-1")
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
_key, _value = mock_redis.set.call_args.args
|
||||
assert _key == "workflow_leader:wf-1"
|
||||
assert _value == "sid-1"
|
||||
assert mock_redis.set.call_args.kwargs["nx"] is True
|
||||
assert "ex" in mock_redis.set.call_args.kwargs
|
||||
|
||||
def test_get_session_sids_decodes(self, mock_redis: Mock) -> None:
|
||||
# Arrange
|
||||
mock_redis.hkeys.return_value = [b"sid-1", "sid-2"]
|
||||
repository = WorkflowCollaborationRepository()
|
||||
|
||||
# Act
|
||||
result = repository.get_session_sids("wf-1")
|
||||
|
||||
# Assert
|
||||
assert result == ["sid-1", "sid-2"]
|
||||
@ -1,271 +0,0 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository
|
||||
from services.workflow_collaboration_service import WorkflowCollaborationService
|
||||
|
||||
|
||||
class TestWorkflowCollaborationService:
|
||||
@pytest.fixture
|
||||
def service(self) -> tuple[WorkflowCollaborationService, Mock, Mock]:
|
||||
repository = Mock(spec=WorkflowCollaborationRepository)
|
||||
socketio = Mock()
|
||||
return WorkflowCollaborationService(repository, socketio), repository, socketio
|
||||
|
||||
def test_register_session_returns_leader_status(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, socketio = service
|
||||
socketio.get_session.return_value = {"user_id": "u-1", "username": "Jane", "avatar": None}
|
||||
|
||||
with (
|
||||
patch.object(collaboration_service, "get_or_set_leader", return_value="sid-1"),
|
||||
patch.object(collaboration_service, "broadcast_online_users"),
|
||||
):
|
||||
# Act
|
||||
result = collaboration_service.register_session("wf-1", "sid-1")
|
||||
|
||||
# Assert
|
||||
assert result == ("u-1", True)
|
||||
repository.set_session_info.assert_called_once()
|
||||
socketio.enter_room.assert_called_once_with("sid-1", "wf-1")
|
||||
socketio.emit.assert_called_once_with("status", {"isLeader": True}, room="sid-1")
|
||||
|
||||
def test_register_session_returns_none_when_missing_user(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> None:
|
||||
# Arrange
|
||||
collaboration_service, _repository, socketio = service
|
||||
socketio.get_session.return_value = {}
|
||||
|
||||
# Act
|
||||
result = collaboration_service.register_session("wf-1", "sid-1")
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
def test_relay_collaboration_event_unauthorized(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, _socketio = service
|
||||
repository.get_sid_mapping.return_value = None
|
||||
|
||||
# Act
|
||||
result = collaboration_service.relay_collaboration_event("sid-1", {})
|
||||
|
||||
# Assert
|
||||
assert result == ({"msg": "unauthorized"}, 401)
|
||||
|
||||
def test_relay_collaboration_event_emits_update(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, socketio = service
|
||||
repository.get_sid_mapping.return_value = {"workflow_id": "wf-1", "user_id": "u-1"}
|
||||
payload = {"type": "mouse_move", "data": {"x": 1}, "timestamp": 123}
|
||||
|
||||
# Act
|
||||
result = collaboration_service.relay_collaboration_event("sid-1", payload)
|
||||
|
||||
# Assert
|
||||
assert result == ({"msg": "event_broadcasted"}, 200)
|
||||
socketio.emit.assert_called_once_with(
|
||||
"collaboration_update",
|
||||
{"type": "mouse_move", "userId": "u-1", "data": {"x": 1}, "timestamp": 123},
|
||||
room="wf-1",
|
||||
skip_sid="sid-1",
|
||||
)
|
||||
|
||||
def test_relay_graph_event_unauthorized(self, service: tuple[WorkflowCollaborationService, Mock, Mock]) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, _socketio = service
|
||||
repository.get_sid_mapping.return_value = None
|
||||
|
||||
# Act
|
||||
result = collaboration_service.relay_graph_event("sid-1", {"nodes": []})
|
||||
|
||||
# Assert
|
||||
assert result == ({"msg": "unauthorized"}, 401)
|
||||
|
||||
def test_disconnect_session_no_mapping(self, service: tuple[WorkflowCollaborationService, Mock, Mock]) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, _socketio = service
|
||||
repository.get_sid_mapping.return_value = None
|
||||
|
||||
# Act
|
||||
collaboration_service.disconnect_session("sid-1")
|
||||
|
||||
# Assert
|
||||
repository.delete_session.assert_not_called()
|
||||
|
||||
def test_disconnect_session_cleans_up(self, service: tuple[WorkflowCollaborationService, Mock, Mock]) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, _socketio = service
|
||||
repository.get_sid_mapping.return_value = {"workflow_id": "wf-1", "user_id": "u-1"}
|
||||
|
||||
with (
|
||||
patch.object(collaboration_service, "handle_leader_disconnect") as handle_leader_disconnect,
|
||||
patch.object(collaboration_service, "broadcast_online_users") as broadcast_online_users,
|
||||
):
|
||||
# Act
|
||||
collaboration_service.disconnect_session("sid-1")
|
||||
|
||||
# Assert
|
||||
repository.delete_session.assert_called_once_with("wf-1", "sid-1")
|
||||
handle_leader_disconnect.assert_called_once_with("wf-1", "sid-1")
|
||||
broadcast_online_users.assert_called_once_with("wf-1")
|
||||
|
||||
def test_get_or_set_leader_returns_active_leader(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, _socketio = service
|
||||
repository.get_current_leader.return_value = "sid-1"
|
||||
|
||||
with patch.object(collaboration_service, "is_session_active", return_value=True):
|
||||
# Act
|
||||
result = collaboration_service.get_or_set_leader("wf-1", "sid-2")
|
||||
|
||||
# Assert
|
||||
assert result == "sid-1"
|
||||
repository.set_leader_if_absent.assert_not_called()
|
||||
|
||||
def test_get_or_set_leader_replaces_dead_leader(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, _socketio = service
|
||||
repository.get_current_leader.return_value = "sid-1"
|
||||
repository.set_leader_if_absent.return_value = True
|
||||
|
||||
with (
|
||||
patch.object(collaboration_service, "is_session_active", return_value=False),
|
||||
patch.object(collaboration_service, "broadcast_leader_change") as broadcast_leader_change,
|
||||
):
|
||||
# Act
|
||||
result = collaboration_service.get_or_set_leader("wf-1", "sid-2")
|
||||
|
||||
# Assert
|
||||
assert result == "sid-2"
|
||||
repository.delete_session.assert_called_once_with("wf-1", "sid-1")
|
||||
repository.delete_leader.assert_called_once_with("wf-1")
|
||||
broadcast_leader_change.assert_called_once_with("wf-1", "sid-2")
|
||||
|
||||
def test_get_or_set_leader_falls_back_to_existing(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, _socketio = service
|
||||
repository.get_current_leader.side_effect = [None, "sid-3"]
|
||||
repository.set_leader_if_absent.return_value = False
|
||||
|
||||
# Act
|
||||
result = collaboration_service.get_or_set_leader("wf-1", "sid-2")
|
||||
|
||||
# Assert
|
||||
assert result == "sid-3"
|
||||
|
||||
def test_handle_leader_disconnect_elects_new(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, _socketio = service
|
||||
repository.get_current_leader.return_value = "sid-1"
|
||||
repository.get_session_sids.return_value = ["sid-2"]
|
||||
|
||||
with patch.object(collaboration_service, "broadcast_leader_change") as broadcast_leader_change:
|
||||
# Act
|
||||
collaboration_service.handle_leader_disconnect("wf-1", "sid-1")
|
||||
|
||||
# Assert
|
||||
repository.set_leader.assert_called_once_with("wf-1", "sid-2")
|
||||
broadcast_leader_change.assert_called_once_with("wf-1", "sid-2")
|
||||
|
||||
def test_handle_leader_disconnect_clears_when_empty(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, _socketio = service
|
||||
repository.get_current_leader.return_value = "sid-1"
|
||||
repository.get_session_sids.return_value = []
|
||||
|
||||
# Act
|
||||
collaboration_service.handle_leader_disconnect("wf-1", "sid-1")
|
||||
|
||||
# Assert
|
||||
repository.delete_leader.assert_called_once_with("wf-1")
|
||||
|
||||
def test_broadcast_online_users_sorts_and_emits(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, socketio = service
|
||||
repository.list_sessions.return_value = [
|
||||
{"user_id": "u-1", "username": "A", "avatar": None, "sid": "sid-1", "connected_at": 3},
|
||||
{"user_id": "u-2", "username": "B", "avatar": None, "sid": "sid-2", "connected_at": 1},
|
||||
]
|
||||
repository.get_current_leader.return_value = "sid-1"
|
||||
|
||||
# Act
|
||||
collaboration_service.broadcast_online_users("wf-1")
|
||||
|
||||
# Assert
|
||||
socketio.emit.assert_called_once_with(
|
||||
"online_users",
|
||||
{
|
||||
"workflow_id": "wf-1",
|
||||
"users": [
|
||||
{"user_id": "u-2", "username": "B", "avatar": None, "sid": "sid-2", "connected_at": 1},
|
||||
{"user_id": "u-1", "username": "A", "avatar": None, "sid": "sid-1", "connected_at": 3},
|
||||
],
|
||||
"leader": "sid-1",
|
||||
},
|
||||
room="wf-1",
|
||||
)
|
||||
|
||||
def test_refresh_session_state_expires_active_leader(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, _socketio = service
|
||||
repository.get_current_leader.return_value = "sid-1"
|
||||
|
||||
with patch.object(collaboration_service, "is_session_active", return_value=True):
|
||||
# Act
|
||||
collaboration_service.refresh_session_state("wf-1", "sid-1")
|
||||
|
||||
# Assert
|
||||
repository.refresh_session_state.assert_called_once_with("wf-1", "sid-1")
|
||||
repository.expire_leader.assert_called_once_with("wf-1")
|
||||
repository.set_leader.assert_not_called()
|
||||
|
||||
def test_refresh_session_state_sets_leader_when_missing(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, _socketio = service
|
||||
repository.get_current_leader.return_value = None
|
||||
|
||||
with patch.object(collaboration_service, "broadcast_leader_change") as broadcast_leader_change:
|
||||
# Act
|
||||
collaboration_service.refresh_session_state("wf-1", "sid-2")
|
||||
|
||||
# Assert
|
||||
repository.set_leader.assert_called_once_with("wf-1", "sid-2")
|
||||
broadcast_leader_change.assert_called_once_with("wf-1", "sid-2")
|
||||
|
||||
def test_relay_graph_event_emits_update(self, service: tuple[WorkflowCollaborationService, Mock, Mock]) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, socketio = service
|
||||
repository.get_sid_mapping.return_value = {"workflow_id": "wf-1", "user_id": "u-1"}
|
||||
|
||||
# Act
|
||||
result = collaboration_service.relay_graph_event("sid-1", {"nodes": []})
|
||||
|
||||
# Assert
|
||||
assert result == ({"msg": "graph_update_broadcasted"}, 200)
|
||||
repository.refresh_session_state.assert_called_once_with("wf-1", "sid-1")
|
||||
socketio.emit.assert_called_once_with("graph_update", {"nodes": []}, room="wf-1", skip_sid="sid-1")
|
||||
@ -1,245 +0,0 @@
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from services import workflow_comment_service as service_module
|
||||
from services.workflow_comment_service import WorkflowCommentService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session(monkeypatch: pytest.MonkeyPatch) -> Mock:
|
||||
session = Mock()
|
||||
context_manager = MagicMock()
|
||||
context_manager.__enter__.return_value = session
|
||||
context_manager.__exit__.return_value = False
|
||||
mock_db = MagicMock()
|
||||
mock_db.engine = Mock()
|
||||
monkeypatch.setattr(service_module, "Session", Mock(return_value=context_manager))
|
||||
monkeypatch.setattr(service_module, "db", mock_db)
|
||||
return session
|
||||
|
||||
|
||||
def _mock_scalars(result_list: list[object]) -> Mock:
|
||||
scalars = Mock()
|
||||
scalars.all.return_value = result_list
|
||||
return scalars
|
||||
|
||||
|
||||
class TestWorkflowCommentService:
|
||||
def test_validate_content_rejects_empty(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
WorkflowCommentService._validate_content(" ")
|
||||
|
||||
def test_validate_content_rejects_too_long(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
WorkflowCommentService._validate_content("a" * 1001)
|
||||
|
||||
def test_create_comment_creates_mentions(self, mock_session: Mock) -> None:
|
||||
comment = Mock()
|
||||
comment.id = "comment-1"
|
||||
comment.created_at = "ts"
|
||||
|
||||
with (
|
||||
patch.object(service_module, "WorkflowComment", return_value=comment),
|
||||
patch.object(service_module, "WorkflowCommentMention", return_value=Mock()),
|
||||
patch.object(service_module, "uuid_value", side_effect=[True, False]),
|
||||
):
|
||||
result = WorkflowCommentService.create_comment(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
created_by="user-1",
|
||||
content="hello",
|
||||
position_x=1.0,
|
||||
position_y=2.0,
|
||||
mentioned_user_ids=["user-2", "bad-id"],
|
||||
)
|
||||
|
||||
assert result == {"id": "comment-1", "created_at": "ts"}
|
||||
assert mock_session.add.call_args_list[0].args[0] is comment
|
||||
assert mock_session.add.call_count == 2
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_update_comment_raises_not_found(self, mock_session: Mock) -> None:
|
||||
mock_session.scalar.return_value = None
|
||||
|
||||
with pytest.raises(NotFound):
|
||||
WorkflowCommentService.update_comment(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
comment_id="comment-1",
|
||||
user_id="user-1",
|
||||
content="hello",
|
||||
)
|
||||
|
||||
def test_update_comment_raises_forbidden(self, mock_session: Mock) -> None:
|
||||
comment = Mock()
|
||||
comment.created_by = "owner"
|
||||
mock_session.scalar.return_value = comment
|
||||
|
||||
with pytest.raises(Forbidden):
|
||||
WorkflowCommentService.update_comment(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
comment_id="comment-1",
|
||||
user_id="intruder",
|
||||
content="hello",
|
||||
)
|
||||
|
||||
def test_update_comment_replaces_mentions(self, mock_session: Mock) -> None:
|
||||
comment = Mock()
|
||||
comment.id = "comment-1"
|
||||
comment.created_by = "owner"
|
||||
mock_session.scalar.return_value = comment
|
||||
|
||||
existing_mentions = [Mock(), Mock()]
|
||||
mock_session.scalars.return_value = _mock_scalars(existing_mentions)
|
||||
|
||||
with patch.object(service_module, "uuid_value", side_effect=[True, False]):
|
||||
result = WorkflowCommentService.update_comment(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
comment_id="comment-1",
|
||||
user_id="owner",
|
||||
content="updated",
|
||||
mentioned_user_ids=["user-2", "bad-id"],
|
||||
)
|
||||
|
||||
assert result == {"id": "comment-1", "updated_at": comment.updated_at}
|
||||
assert mock_session.delete.call_count == 2
|
||||
assert mock_session.add.call_count == 1
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_delete_comment_raises_forbidden(self, mock_session: Mock) -> None:
|
||||
comment = Mock()
|
||||
comment.created_by = "owner"
|
||||
|
||||
with patch.object(WorkflowCommentService, "get_comment", return_value=comment):
|
||||
with pytest.raises(Forbidden):
|
||||
WorkflowCommentService.delete_comment("tenant-1", "app-1", "comment-1", "intruder")
|
||||
|
||||
def test_delete_comment_removes_related_entities(self, mock_session: Mock) -> None:
|
||||
comment = Mock()
|
||||
comment.created_by = "owner"
|
||||
|
||||
mentions = [Mock(), Mock()]
|
||||
replies = [Mock()]
|
||||
mock_session.scalars.side_effect = [_mock_scalars(mentions), _mock_scalars(replies)]
|
||||
|
||||
with patch.object(WorkflowCommentService, "get_comment", return_value=comment):
|
||||
WorkflowCommentService.delete_comment("tenant-1", "app-1", "comment-1", "owner")
|
||||
|
||||
assert mock_session.delete.call_count == 4
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_resolve_comment_sets_fields(self, mock_session: Mock) -> None:
|
||||
comment = Mock()
|
||||
comment.resolved = False
|
||||
comment.resolved_at = None
|
||||
comment.resolved_by = None
|
||||
|
||||
with (
|
||||
patch.object(WorkflowCommentService, "get_comment", return_value=comment),
|
||||
patch.object(service_module, "naive_utc_now", return_value="now"),
|
||||
):
|
||||
result = WorkflowCommentService.resolve_comment("tenant-1", "app-1", "comment-1", "user-1")
|
||||
|
||||
assert result is comment
|
||||
assert comment.resolved is True
|
||||
assert comment.resolved_at == "now"
|
||||
assert comment.resolved_by == "user-1"
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_resolve_comment_noop_when_already_resolved(self, mock_session: Mock) -> None:
|
||||
comment = Mock()
|
||||
comment.resolved = True
|
||||
|
||||
with patch.object(WorkflowCommentService, "get_comment", return_value=comment):
|
||||
result = WorkflowCommentService.resolve_comment("tenant-1", "app-1", "comment-1", "user-1")
|
||||
|
||||
assert result is comment
|
||||
mock_session.commit.assert_not_called()
|
||||
|
||||
def test_create_reply_requires_comment(self, mock_session: Mock) -> None:
|
||||
mock_session.get.return_value = None
|
||||
|
||||
with pytest.raises(NotFound):
|
||||
WorkflowCommentService.create_reply("comment-1", "hello", "user-1")
|
||||
|
||||
def test_create_reply_creates_mentions(self, mock_session: Mock) -> None:
|
||||
mock_session.get.return_value = Mock()
|
||||
reply = Mock()
|
||||
reply.id = "reply-1"
|
||||
reply.created_at = "ts"
|
||||
|
||||
with (
|
||||
patch.object(service_module, "WorkflowCommentReply", return_value=reply),
|
||||
patch.object(service_module, "WorkflowCommentMention", return_value=Mock()),
|
||||
patch.object(service_module, "uuid_value", side_effect=[True, False]),
|
||||
):
|
||||
result = WorkflowCommentService.create_reply(
|
||||
comment_id="comment-1",
|
||||
content="hello",
|
||||
created_by="user-1",
|
||||
mentioned_user_ids=["user-2", "bad-id"],
|
||||
)
|
||||
|
||||
assert result == {"id": "reply-1", "created_at": "ts"}
|
||||
assert mock_session.add.call_count == 2
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_update_reply_raises_not_found(self, mock_session: Mock) -> None:
|
||||
mock_session.get.return_value = None
|
||||
|
||||
with pytest.raises(NotFound):
|
||||
WorkflowCommentService.update_reply("reply-1", "user-1", "hello")
|
||||
|
||||
def test_update_reply_raises_forbidden(self, mock_session: Mock) -> None:
|
||||
reply = Mock()
|
||||
reply.created_by = "owner"
|
||||
mock_session.get.return_value = reply
|
||||
|
||||
with pytest.raises(Forbidden):
|
||||
WorkflowCommentService.update_reply("reply-1", "intruder", "hello")
|
||||
|
||||
def test_update_reply_replaces_mentions(self, mock_session: Mock) -> None:
|
||||
reply = Mock()
|
||||
reply.id = "reply-1"
|
||||
reply.comment_id = "comment-1"
|
||||
reply.created_by = "owner"
|
||||
reply.updated_at = "updated"
|
||||
mock_session.get.return_value = reply
|
||||
mock_session.scalars.return_value = _mock_scalars([Mock()])
|
||||
|
||||
with patch.object(service_module, "uuid_value", side_effect=[True, False]):
|
||||
result = WorkflowCommentService.update_reply(
|
||||
reply_id="reply-1",
|
||||
user_id="owner",
|
||||
content="new",
|
||||
mentioned_user_ids=["user-2", "bad-id"],
|
||||
)
|
||||
|
||||
assert result == {"id": "reply-1", "updated_at": "updated"}
|
||||
assert mock_session.delete.call_count == 1
|
||||
assert mock_session.add.call_count == 1
|
||||
mock_session.commit.assert_called_once()
|
||||
mock_session.refresh.assert_called_once_with(reply)
|
||||
|
||||
def test_delete_reply_raises_forbidden(self, mock_session: Mock) -> None:
|
||||
reply = Mock()
|
||||
reply.created_by = "owner"
|
||||
mock_session.get.return_value = reply
|
||||
|
||||
with pytest.raises(Forbidden):
|
||||
WorkflowCommentService.delete_reply("reply-1", "intruder")
|
||||
|
||||
def test_delete_reply_removes_mentions(self, mock_session: Mock) -> None:
|
||||
reply = Mock()
|
||||
reply.created_by = "owner"
|
||||
mock_session.get.return_value = reply
|
||||
mock_session.scalars.return_value = _mock_scalars([Mock(), Mock()])
|
||||
|
||||
WorkflowCommentService.delete_reply("reply-1", "owner")
|
||||
|
||||
assert mock_session.delete.call_count == 3
|
||||
mock_session.commit.assert_called_once()
|
||||
@ -12,7 +12,7 @@ This test suite covers:
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, cast
|
||||
from unittest.mock import ANY, MagicMock, Mock, patch
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from graphon.entities import WorkflowNodeExecution
|
||||
@ -713,79 +713,6 @@ class TestWorkflowService:
|
||||
with pytest.raises(ValueError, match="Invalid app mode"):
|
||||
workflow_service.validate_features_structure(app, features)
|
||||
|
||||
# ==================== Draft Workflow Variable Update Tests ====================
|
||||
# These tests verify updating draft workflow environment/conversation variables
|
||||
|
||||
def test_update_draft_workflow_environment_variables_updates_workflow(self, workflow_service, mock_db_session):
|
||||
"""Test update_draft_workflow_environment_variables updates draft fields."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
account = TestWorkflowAssociatedDataFactory.create_account_mock()
|
||||
workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock()
|
||||
variables = [Mock()]
|
||||
|
||||
with (
|
||||
patch.object(workflow_service, "get_draft_workflow", return_value=workflow),
|
||||
patch("services.workflow_service.naive_utc_now", return_value="now"),
|
||||
):
|
||||
workflow_service.update_draft_workflow_environment_variables(
|
||||
app_model=app,
|
||||
environment_variables=variables,
|
||||
account=account,
|
||||
)
|
||||
|
||||
assert workflow.environment_variables == variables
|
||||
assert workflow.updated_by == account.id
|
||||
assert workflow.updated_at == "now"
|
||||
mock_db_session.session.commit.assert_called_once()
|
||||
|
||||
def test_update_draft_workflow_environment_variables_raises_when_missing(self, workflow_service):
|
||||
"""Test update_draft_workflow_environment_variables raises when draft missing."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
account = TestWorkflowAssociatedDataFactory.create_account_mock()
|
||||
|
||||
with patch.object(workflow_service, "get_draft_workflow", return_value=None):
|
||||
with pytest.raises(ValueError, match="No draft workflow found."):
|
||||
workflow_service.update_draft_workflow_environment_variables(
|
||||
app_model=app,
|
||||
environment_variables=[],
|
||||
account=account,
|
||||
)
|
||||
|
||||
def test_update_draft_workflow_conversation_variables_updates_workflow(self, workflow_service, mock_db_session):
|
||||
"""Test update_draft_workflow_conversation_variables updates draft fields."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
account = TestWorkflowAssociatedDataFactory.create_account_mock()
|
||||
workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock()
|
||||
variables = [Mock()]
|
||||
|
||||
with (
|
||||
patch.object(workflow_service, "get_draft_workflow", return_value=workflow),
|
||||
patch("services.workflow_service.naive_utc_now", return_value="now"),
|
||||
):
|
||||
workflow_service.update_draft_workflow_conversation_variables(
|
||||
app_model=app,
|
||||
conversation_variables=variables,
|
||||
account=account,
|
||||
)
|
||||
|
||||
assert workflow.conversation_variables == variables
|
||||
assert workflow.updated_by == account.id
|
||||
assert workflow.updated_at == "now"
|
||||
mock_db_session.session.commit.assert_called_once()
|
||||
|
||||
def test_update_draft_workflow_conversation_variables_raises_when_missing(self, workflow_service):
|
||||
"""Test update_draft_workflow_conversation_variables raises when draft missing."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
account = TestWorkflowAssociatedDataFactory.create_account_mock()
|
||||
|
||||
with patch.object(workflow_service, "get_draft_workflow", return_value=None):
|
||||
with pytest.raises(ValueError, match="No draft workflow found."):
|
||||
workflow_service.update_draft_workflow_conversation_variables(
|
||||
app_model=app,
|
||||
conversation_variables=[],
|
||||
account=account,
|
||||
)
|
||||
|
||||
# ==================== Publish Workflow Tests ====================
|
||||
# These tests verify creating published versions from draft workflows
|
||||
|
||||
|
||||
82
api/uv.lock
generated
82
api/uv.lock
generated
@ -501,15 +501,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bidict"
|
||||
version = "0.23.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "billiard"
|
||||
version = "4.2.3"
|
||||
@ -1301,7 +1292,6 @@ dependencies = [
|
||||
{ name = "flask-restx" },
|
||||
{ name = "flask-sqlalchemy" },
|
||||
{ name = "gevent" },
|
||||
{ name = "gevent-websocket" },
|
||||
{ name = "gmpy2" },
|
||||
{ name = "google-api-core" },
|
||||
{ name = "google-api-python-client" },
|
||||
@ -1352,7 +1342,6 @@ dependencies = [
|
||||
{ name = "pypdfium2" },
|
||||
{ name = "python-docx" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-socketio" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "readabilipy" },
|
||||
{ name = "redis", extra = ["hiredis"] },
|
||||
@ -1503,7 +1492,6 @@ requires-dist = [
|
||||
{ name = "flask-restx", specifier = "~=1.3.2" },
|
||||
{ name = "flask-sqlalchemy", specifier = "~=3.1.1" },
|
||||
{ name = "gevent", specifier = "~=25.9.1" },
|
||||
{ name = "gevent-websocket", specifier = "~=0.10.1" },
|
||||
{ name = "gmpy2", specifier = "~=2.3.0" },
|
||||
{ name = "google-api-core", specifier = ">=2.19.1" },
|
||||
{ name = "google-api-python-client", specifier = "==2.193.0" },
|
||||
@ -1519,7 +1507,7 @@ requires-dist = [
|
||||
{ name = "json-repair", specifier = ">=0.55.1" },
|
||||
{ name = "langfuse", specifier = ">=3.0.0,<5.0.0" },
|
||||
{ name = "langsmith", specifier = "~=0.7.16" },
|
||||
{ name = "litellm", specifier = "==1.82.6" },
|
||||
{ name = "litellm", specifier = "==1.83.0" },
|
||||
{ name = "markdown", specifier = "~=3.10.2" },
|
||||
{ name = "mlflow-skinny", specifier = ">=3.0.0" },
|
||||
{ name = "numpy", specifier = "~=1.26.4" },
|
||||
@ -1554,7 +1542,6 @@ requires-dist = [
|
||||
{ name = "pypdfium2", specifier = "==5.6.0" },
|
||||
{ name = "python-docx", specifier = "~=1.2.0" },
|
||||
{ name = "python-dotenv", specifier = "==1.2.2" },
|
||||
{ name = "python-socketio", specifier = "~=5.13.0" },
|
||||
{ name = "pyyaml", specifier = "~=6.0.1" },
|
||||
{ name = "readabilipy", specifier = "~=0.3.0" },
|
||||
{ name = "redis", extras = ["hiredis"], specifier = "~=7.4.0" },
|
||||
@ -2119,18 +2106,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/98/caf06d5d22a7c129c1fb2fc1477306902a2c8ddfd399cd26bbbd4caf2141/gevent-25.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4acd6bcd5feabf22c7c5174bd3b9535ee9f088d2bbce789f740ad8d6554b18f3", size = 1682837, upload-time = "2025-09-17T19:48:47.318Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gevent-websocket"
|
||||
version = "0.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "gevent" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/d2/6fa19239ff1ab072af40ebf339acd91fb97f34617c2ee625b8e34bf42393/gevent-websocket-0.10.1.tar.gz", hash = "sha256:7eaef32968290c9121f7c35b973e2cc302ffb076d018c9068d2f5ca8b2d85fb0", size = 18366, upload-time = "2017-03-12T22:46:05.68Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/84/2dc373eb6493e00c884cc11e6c059ec97abae2678d42f06bf780570b0193/gevent_websocket-0.10.1-py3-none-any.whl", hash = "sha256:17b67d91282f8f4c973eba0551183fc84f56f1c90c8f6b6b30256f31f66f5242", size = 22987, upload-time = "2017-03-12T22:46:03.611Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gitdb"
|
||||
version = "4.0.12"
|
||||
@ -3146,7 +3121,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.82.6"
|
||||
version = "1.83.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@ -3162,9 +3137,9 @@ dependencies = [
|
||||
{ name = "tiktoken" },
|
||||
{ name = "tokenizers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/75/1c537aa458426a9127a92bc2273787b2f987f4e5044e21f01f2eed5244fd/litellm-1.82.6.tar.gz", hash = "sha256:2aa1c2da21fe940c33613aa447119674a3ad4d2ad5eb064e4d5ce5ee42420136", size = 17414147, upload-time = "2026-03-22T06:36:00.452Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/92/6ce9737554994ca8e536e5f4f6a87cc7c4774b656c9eb9add071caf7d54b/litellm-1.83.0.tar.gz", hash = "sha256:860bebc76c4bb27b4cf90b4a77acd66dba25aced37e3db98750de8a1766bfb7a", size = 17333062, upload-time = "2026-03-31T05:08:25.331Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/6c/5327667e6dbe9e98cbfbd4261c8e91386a52e38f41419575854248bbab6a/litellm-1.82.6-py3-none-any.whl", hash = "sha256:164a3ef3e19f309e3cabc199bef3d2045212712fefdfa25fc7f75884a5b5b205", size = 15591595, upload-time = "2026-03-22T06:35:56.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2c/a670cc050fcd6f45c6199eb99e259c73aea92edba8d5c2fc1b3686d36217/litellm-1.83.0-py3-none-any.whl", hash = "sha256:88c536d339248f3987571493015784671ba3f193a328e1ea6780dbebaa2094a8", size = 15610306, upload-time = "2026-03-31T05:08:21.987Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5013,18 +4988,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-engineio"
|
||||
version = "4.12.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "simple-websocket" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/d8/63e5535ab21dc4998ba1cfe13690ccf122883a38f025dca24d6e56c05eba/python_engineio-4.12.3.tar.gz", hash = "sha256:35633e55ec30915e7fc8f7e34ca8d73ee0c080cec8a8cd04faf2d7396f0a7a7a", size = 91910, upload-time = "2025-09-28T06:31:36.765Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/f0/c5aa0a69fd9326f013110653543f36ece4913c17921f3e1dbd78e1b423ee/python_engineio-4.12.3-py3-none-any.whl", hash = "sha256:7c099abb2a27ea7ab429c04da86ab2d82698cdd6c52406cb73766fe454feb7e1", size = 59637, upload-time = "2025-09-28T06:31:35.354Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-http-client"
|
||||
version = "3.3.7"
|
||||
@ -5081,19 +5044,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-socketio"
|
||||
version = "5.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bidict" },
|
||||
{ name = "python-engineio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/1a/396d50ccf06ee539fa758ce5623b59a9cb27637fc4b2dc07ed08bf495e77/python_socketio-5.13.0.tar.gz", hash = "sha256:ac4e19a0302ae812e23b712ec8b6427ca0521f7c582d6abb096e36e24a263029", size = 121125, upload-time = "2025-04-12T15:46:59.933Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/32/b4fb8585d1be0f68bde7e110dffbcf354915f77ad8c778563f0ad9655c02/python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf", size = 77800, upload-time = "2025-04-12T15:46:58.412Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2025.2"
|
||||
@ -5481,18 +5431,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simple-websocket"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wsproto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
@ -6939,18 +6877,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/21/abdedb4cdf6ff41ebf01a74087740a709e2edb146490e4d9beea054b0b7a/wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", size = 23362, upload-time = "2023-11-09T06:33:28.271Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wsproto"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425, upload-time = "2022-08-23T19:58:21.447Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226, upload-time = "2022-08-23T19:58:19.96Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xinference-client"
|
||||
version = "2.4.0"
|
||||
|
||||
@ -132,10 +132,6 @@ MIGRATION_ENABLED=true
|
||||
# The default value is 300 seconds.
|
||||
FILES_ACCESS_TIMEOUT=300
|
||||
|
||||
# Collaboration mode toggle
|
||||
# To open collaboration features, you also need to set SERVER_WORKER_CLASS=geventwebsocket.gunicorn.workers.GeventWebSocketWorker
|
||||
ENABLE_COLLABORATION_MODE=false
|
||||
|
||||
# Access token expiration time in minutes
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
|
||||
@ -171,7 +167,6 @@ SERVER_WORKER_AMOUNT=1
|
||||
# Modifying it may also decrease throughput.
|
||||
#
|
||||
# It is strongly discouraged to change this parameter.
|
||||
# If enable collaboration mode, it must be set to geventwebsocket.gunicorn.workers.GeventWebSocketWorker
|
||||
SERVER_WORKER_CLASS=gevent
|
||||
|
||||
# Default number of worker connections, the default is 10.
|
||||
@ -430,8 +425,6 @@ CONSOLE_CORS_ALLOW_ORIGINS=*
|
||||
COOKIE_DOMAIN=
|
||||
# When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1.
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=
|
||||
# WebSocket server URL.
|
||||
NEXT_PUBLIC_SOCKET_URL=ws://localhost
|
||||
NEXT_PUBLIC_BATCH_CONCURRENCY=5
|
||||
|
||||
# ------------------------------
|
||||
|
||||
@ -159,7 +159,6 @@ services:
|
||||
APP_API_URL: ${APP_API_URL:-}
|
||||
AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-}
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-}
|
||||
NEXT_PUBLIC_SOCKET_URL: ${NEXT_PUBLIC_SOCKET_URL:-ws://localhost}
|
||||
SENTRY_DSN: ${WEB_SENTRY_DSN:-}
|
||||
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0}
|
||||
EXPERIMENTAL_ENABLE_VINEXT: ${EXPERIMENTAL_ENABLE_VINEXT:-false}
|
||||
|
||||
@ -34,7 +34,6 @@ x-shared-env: &shared-api-worker-env
|
||||
OPENAI_API_BASE: ${OPENAI_API_BASE:-https://api.openai.com/v1}
|
||||
MIGRATION_ENABLED: ${MIGRATION_ENABLED:-true}
|
||||
FILES_ACCESS_TIMEOUT: ${FILES_ACCESS_TIMEOUT:-300}
|
||||
ENABLE_COLLABORATION_MODE: ${ENABLE_COLLABORATION_MODE:-false}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30}
|
||||
APP_DEFAULT_ACTIVE_REQUESTS: ${APP_DEFAULT_ACTIVE_REQUESTS:-0}
|
||||
@ -119,7 +118,6 @@ x-shared-env: &shared-api-worker-env
|
||||
CONSOLE_CORS_ALLOW_ORIGINS: ${CONSOLE_CORS_ALLOW_ORIGINS:-*}
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-}
|
||||
NEXT_PUBLIC_SOCKET_URL: ${NEXT_PUBLIC_SOCKET_URL:-ws://localhost}
|
||||
NEXT_PUBLIC_BATCH_CONCURRENCY: ${NEXT_PUBLIC_BATCH_CONCURRENCY:-5}
|
||||
STORAGE_TYPE: ${STORAGE_TYPE:-opendal}
|
||||
OPENDAL_SCHEME: ${OPENDAL_SCHEME:-fs}
|
||||
@ -878,7 +876,6 @@ services:
|
||||
APP_API_URL: ${APP_API_URL:-}
|
||||
AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-}
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-}
|
||||
NEXT_PUBLIC_SOCKET_URL: ${NEXT_PUBLIC_SOCKET_URL:-ws://localhost}
|
||||
SENTRY_DSN: ${WEB_SENTRY_DSN:-}
|
||||
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0}
|
||||
EXPERIMENTAL_ENABLE_VINEXT: ${EXPERIMENTAL_ENABLE_VINEXT:-false}
|
||||
|
||||
@ -14,14 +14,6 @@ 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};
|
||||
|
||||
11
e2e/features/apps/create-chatbot-app.feature
Normal file
11
e2e/features/apps/create-chatbot-app.feature
Normal file
@ -0,0 +1,11 @@
|
||||
@apps @authenticated
|
||||
Feature: Create Chatbot app
|
||||
Scenario: Create a new Chatbot app and redirect to the configuration page
|
||||
Given I am signed in as the default E2E admin
|
||||
When I open the apps console
|
||||
And I start creating a blank app
|
||||
And I expand the beginner app types
|
||||
And I select the "Chatbot" app type
|
||||
And I enter a unique E2E app name
|
||||
And I confirm app creation
|
||||
Then I should land on the app configuration page
|
||||
10
e2e/features/apps/create-workflow-app.feature
Normal file
10
e2e/features/apps/create-workflow-app.feature
Normal file
@ -0,0 +1,10 @@
|
||||
@apps @authenticated
|
||||
Feature: Create Workflow app
|
||||
Scenario: Create a new Workflow app and redirect to the workflow editor
|
||||
Given I am signed in as the default E2E admin
|
||||
When I open the apps console
|
||||
And I start creating a blank app
|
||||
And I select the "Workflow" app type
|
||||
And I enter a unique E2E app name
|
||||
And I confirm app creation
|
||||
Then I should land on the workflow editor
|
||||
8
e2e/features/auth/sign-out.feature
Normal file
8
e2e/features/auth/sign-out.feature
Normal file
@ -0,0 +1,8 @@
|
||||
@auth @authenticated
|
||||
Feature: Sign out
|
||||
Scenario: Sign out from the apps console
|
||||
Given I am signed in as the default E2E admin
|
||||
When I open the apps console
|
||||
And I open the account menu
|
||||
And I sign out
|
||||
Then I should be on the sign-in page
|
||||
@ -24,6 +24,30 @@ When('I confirm app creation', async function (this: DifyWorld) {
|
||||
await createButton.click()
|
||||
})
|
||||
|
||||
When('I select the {string} app type', async function (this: DifyWorld, appType: string) {
|
||||
const dialog = this.getPage().getByRole('dialog')
|
||||
const appTypeTitle = dialog.getByText(appType, { exact: true })
|
||||
|
||||
await expect(appTypeTitle).toBeVisible()
|
||||
await appTypeTitle.click()
|
||||
})
|
||||
|
||||
When('I expand the beginner app types', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const toggle = page.getByRole('button', { name: 'More basic app types' })
|
||||
|
||||
await expect(toggle).toBeVisible()
|
||||
await toggle.click()
|
||||
})
|
||||
|
||||
Then('I should land on the app editor', async function (this: DifyWorld) {
|
||||
await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/(workflow|configuration)(?:\?.*)?$/)
|
||||
})
|
||||
|
||||
Then('I should land on the workflow editor', async function (this: DifyWorld) {
|
||||
await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/workflow(?:\?.*)?$/)
|
||||
})
|
||||
|
||||
Then('I should land on the app configuration page', async function (this: DifyWorld) {
|
||||
await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/configuration(?:\?.*)?$/)
|
||||
})
|
||||
|
||||
25
e2e/features/step-definitions/auth/sign-out.steps.ts
Normal file
25
e2e/features/step-definitions/auth/sign-out.steps.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
|
||||
When('I open the account menu', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const trigger = page.getByRole('button', { name: 'Account' })
|
||||
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
})
|
||||
|
||||
When('I sign out', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
|
||||
await expect(page.getByText('Log out')).toBeVisible()
|
||||
await page.getByText('Log out').click()
|
||||
})
|
||||
|
||||
Then('I should be on the sign-in page', async function (this: DifyWorld) {
|
||||
await expect(this.getPage()).toHaveURL(/\/signin/)
|
||||
await expect(this.getPage().getByRole('button', { name: /^Sign in$/i })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
})
|
||||
@ -1,4 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 4C0 1.79086 1.79086 0 4 0H12C14.2091 0 16 1.79086 16 4V12C16 14.2091 14.2091 16 12 16H4C1.79086 16 0 14.2091 0 12V4Z" fill="white" fill-opacity="0.12"/>
|
||||
<path d="M3.42756 8.7358V7.62784H10.8764C11.2003 7.62784 11.4957 7.5483 11.7628 7.3892C12.0298 7.23011 12.2415 7.01705 12.3977 6.75C12.5568 6.48295 12.6364 6.1875 12.6364 5.86364C12.6364 5.53977 12.5568 5.24574 12.3977 4.98153C12.2386 4.71449 12.0256 4.50142 11.7585 4.34233C11.4943 4.18324 11.2003 4.10369 10.8764 4.10369H10.3991V3H10.8764C11.4048 3 11.8849 3.12926 12.3168 3.38778C12.7486 3.64631 13.0938 3.99148 13.3523 4.4233C13.6108 4.85511 13.7401 5.33523 13.7401 5.86364C13.7401 6.25852 13.6648 6.62926 13.5142 6.97585C13.3665 7.32244 13.1619 7.62784 12.9006 7.89205C12.6392 8.15625 12.3352 8.36364 11.9886 8.5142C11.642 8.66193 11.2713 8.7358 10.8764 8.7358H3.42756ZM6.16761 12.0554L2.29403 8.18182L6.16761 4.30824L6.9304 5.07102L3.81534 8.18182L6.9304 11.2926L6.16761 12.0554Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="12" viewBox="0 0 14 12" fill="none">
|
||||
<path d="M12.3334 4C12.3334 2.52725 11.1395 1.33333 9.66671 1.33333H4.33337C2.86062 1.33333 1.66671 2.52724 1.66671 4V10.6667H9.66671C11.1395 10.6667 12.3334 9.47274 12.3334 8V4ZM7.66671 6.66667V8H4.33337V6.66667H7.66671ZM9.66671 4V5.33333H4.33337V4H9.66671ZM13.6667 8C13.6667 10.2091 11.8758 12 9.66671 12H0.333374V4C0.333374 1.79086 2.12424 0 4.33337 0H9.66671C11.8758 0 13.6667 1.79086 13.6667 4V8Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 528 B |
89
pnpm-lock.yaml
generated
89
pnpm-lock.yaml
generated
@ -384,9 +384,6 @@ catalogs:
|
||||
lexical:
|
||||
specifier: 0.42.0
|
||||
version: 0.42.0
|
||||
loro-crdt:
|
||||
specifier: 1.10.8
|
||||
version: 1.10.8
|
||||
mermaid:
|
||||
specifier: 11.14.0
|
||||
version: 11.14.0
|
||||
@ -474,9 +471,6 @@ catalogs:
|
||||
shiki:
|
||||
specifier: 4.0.2
|
||||
version: 4.0.2
|
||||
socket.io-client:
|
||||
specifier: 4.8.3
|
||||
version: 4.8.3
|
||||
sortablejs:
|
||||
specifier: 1.15.7
|
||||
version: 1.15.7
|
||||
@ -835,9 +829,6 @@ importers:
|
||||
lexical:
|
||||
specifier: 'catalog:'
|
||||
version: 0.42.0
|
||||
loro-crdt:
|
||||
specifier: 'catalog:'
|
||||
version: 1.10.8
|
||||
mermaid:
|
||||
specifier: 'catalog:'
|
||||
version: 11.14.0
|
||||
@ -919,9 +910,6 @@ importers:
|
||||
shiki:
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.2
|
||||
socket.io-client:
|
||||
specifier: 'catalog:'
|
||||
version: 4.8.3
|
||||
sortablejs:
|
||||
specifier: 'catalog:'
|
||||
version: 1.15.7
|
||||
@ -3534,9 +3522,6 @@ packages:
|
||||
resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@socket.io/component-emitter@3.1.2':
|
||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||
|
||||
'@solid-primitives/event-listener@2.4.5':
|
||||
resolution: {integrity: sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA==}
|
||||
peerDependencies:
|
||||
@ -5490,13 +5475,6 @@ packages:
|
||||
end-of-stream@1.4.5:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
engine.io-client@6.6.4:
|
||||
resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==}
|
||||
|
||||
engine.io-parser@5.2.3:
|
||||
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
enhanced-resolve@5.20.1:
|
||||
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@ -6615,9 +6593,6 @@ packages:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
loro-crdt@1.10.8:
|
||||
resolution: {integrity: sha512-GvH8fSJST1VDHRGzlQml80pBYoFbIP4ULeV1S8fD4ffmA8m+icoPORyVUW2AkJBY3dxKIcMMn0WqaJmpCmnbkQ==}
|
||||
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
@ -7757,14 +7732,6 @@ packages:
|
||||
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
socket.io-client@4.8.3:
|
||||
resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
socket.io-parser@4.2.6:
|
||||
resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
solid-js@1.9.11:
|
||||
resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==}
|
||||
|
||||
@ -8488,18 +8455,6 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
ws@8.18.3:
|
||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
||||
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.20.0:
|
||||
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@ -8528,10 +8483,6 @@ packages:
|
||||
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
xmlhttprequest-ssl@2.1.2:
|
||||
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
@ -10900,8 +10851,6 @@ snapshots:
|
||||
|
||||
'@sindresorhus/base62@1.0.0': {}
|
||||
|
||||
'@socket.io/component-emitter@3.1.2': {}
|
||||
|
||||
'@solid-primitives/event-listener@2.4.5(solid-js@1.9.11)':
|
||||
dependencies:
|
||||
'@solid-primitives/utils': 6.4.0(solid-js@1.9.11)
|
||||
@ -12964,20 +12913,6 @@ snapshots:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
|
||||
engine.io-client@6.6.4:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
engine.io-parser: 5.2.3
|
||||
ws: 8.18.3
|
||||
xmlhttprequest-ssl: 2.1.2
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
engine.io-parser@5.2.3: {}
|
||||
|
||||
enhanced-resolve@5.20.1:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@ -14320,8 +14255,6 @@ snapshots:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
loro-crdt@1.10.8: {}
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
lower-case@2.0.2:
|
||||
@ -16014,24 +15947,6 @@ snapshots:
|
||||
|
||||
smol-toml@1.6.1: {}
|
||||
|
||||
socket.io-client@4.8.3:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
engine.io-client: 6.6.4
|
||||
socket.io-parser: 4.2.6
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
socket.io-parser@4.2.6:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
solid-js@1.9.11:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
@ -16801,8 +16716,6 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
||||
ws@8.20.0: {}
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
@ -16818,8 +16731,6 @@ snapshots:
|
||||
|
||||
xmlbuilder@15.1.1: {}
|
||||
|
||||
xmlhttprequest-ssl@2.1.2: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yallist@5.0.0: {}
|
||||
|
||||
@ -174,7 +174,6 @@ catalog:
|
||||
ky: 2.0.0
|
||||
lamejs: 1.2.1
|
||||
lexical: 0.42.0
|
||||
loro-crdt: 1.10.8
|
||||
mermaid: 11.14.0
|
||||
mime: 4.1.0
|
||||
mitt: 3.0.1
|
||||
@ -205,7 +204,6 @@ catalog:
|
||||
scheduler: 0.27.0
|
||||
sharp: 0.34.5
|
||||
shiki: 4.0.2
|
||||
socket.io-client: 4.8.3
|
||||
sortablejs: 1.15.7
|
||||
std-semver: 1.0.8
|
||||
storybook: 10.3.5
|
||||
|
||||
@ -14,8 +14,6 @@ NEXT_PUBLIC_API_PREFIX=http://localhost:5001/console/api
|
||||
NEXT_PUBLIC_PUBLIC_API_PREFIX=http://localhost:5001/api
|
||||
# When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1.
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=
|
||||
# WebSocket server URL.
|
||||
NEXT_PUBLIC_SOCKET_URL=ws://localhost:5001
|
||||
|
||||
# Dev-only Hono proxy targets.
|
||||
# The frontend keeps requesting http://localhost:5001 directly,
|
||||
|
||||
@ -5,8 +5,7 @@ import type { BlockEnum } from '@/app/components/workflow/types'
|
||||
import type { UpdateAppSiteCodeResponse } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppCard from '@/app/components/app/overview/app-card'
|
||||
import TriggerCard from '@/app/components/app/overview/trigger-card'
|
||||
@ -14,8 +13,6 @@ import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { isTriggerNode } from '@/app/components/workflow/types'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import {
|
||||
@ -75,56 +72,25 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
|
||||
? buildTriggerModeMessage(t('mcp.server.title', { ns: 'tools' }))
|
||||
: null
|
||||
|
||||
const updateAppDetail = useCallback(async () => {
|
||||
const updateAppDetail = async () => {
|
||||
try {
|
||||
const res = await fetchAppDetail({ url: '/apps', id: appId })
|
||||
setAppDetail({ ...res })
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}, [appId, setAppDetail])
|
||||
catch (error) { console.error(error) }
|
||||
}
|
||||
|
||||
const handleCallbackResult = (err: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => {
|
||||
const type = err ? 'error' : 'success'
|
||||
|
||||
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: 'app_state_update',
|
||||
data: { timestamp: Date.now() },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
toast(t(`actionMsg.${message}`, { ns: 'common' }) as string, { type })
|
||||
}
|
||||
|
||||
// Listen for collaborative app state updates from other clients
|
||||
useEffect(() => {
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
const unsubscribe = collaborationManager.onAppStateUpdate(async () => {
|
||||
try {
|
||||
// Update app detail when other clients modify app state
|
||||
await updateAppDetail()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('app state update failed:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appId, updateAppDetail])
|
||||
|
||||
const onChangeSiteStatus = async (value: boolean) => {
|
||||
const [err] = await asyncRunSafe<App>(
|
||||
updateAppSiteStatus({
|
||||
|
||||
@ -20,11 +20,8 @@ const mockUpdateAppInfo = vi.fn()
|
||||
const mockCopyApp = vi.fn()
|
||||
const mockExportAppConfig = vi.fn()
|
||||
const mockDeleteApp = vi.fn()
|
||||
const mockFetchAppDetail = vi.fn()
|
||||
const mockFetchWorkflowDraft = vi.fn()
|
||||
const mockDownloadBlob = vi.fn()
|
||||
const mockGetSocket = vi.fn()
|
||||
const mockOnAppMetaUpdate = vi.fn()
|
||||
|
||||
let mockAppDetail: Record<string, unknown> | undefined = {
|
||||
id: 'app-1',
|
||||
@ -71,7 +68,6 @@ vi.mock('@/service/apps', () => ({
|
||||
copyApp: (...args: unknown[]) => mockCopyApp(...args),
|
||||
exportAppConfig: (...args: unknown[]) => mockExportAppConfig(...args),
|
||||
deleteApp: (...args: unknown[]) => mockDeleteApp(...args),
|
||||
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
@ -86,18 +82,6 @@ vi.mock('@/utils/app-redirection', () => ({
|
||||
getRedirection: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({
|
||||
webSocketClient: {
|
||||
getSocket: (...args: unknown[]) => mockGetSocket(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
|
||||
collaborationManager: {
|
||||
onAppMetaUpdate: (...args: unknown[]) => mockOnAppMetaUpdate(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
NEED_REFRESH_APP_LIST_KEY: 'test-refresh-key',
|
||||
}))
|
||||
@ -105,8 +89,6 @@ vi.mock('@/config', () => ({
|
||||
describe('useAppInfoActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockOnAppMetaUpdate.mockReturnValue(() => {})
|
||||
mockGetSocket.mockReturnValue(null)
|
||||
mockAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
@ -209,35 +191,6 @@ describe('useAppInfoActions', () => {
|
||||
expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'app.editDone' })
|
||||
})
|
||||
|
||||
it('should emit app_meta_update after successful edit when collaboration socket exists', async () => {
|
||||
const updatedApp = { ...mockAppDetail, name: 'Updated' }
|
||||
const socket = { emit: vi.fn() }
|
||||
mockUpdateAppInfo.mockResolvedValue(updatedApp)
|
||||
mockGetSocket.mockReturnValue(socket)
|
||||
|
||||
const { result } = renderHook(() => useAppInfoActions({}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEdit({
|
||||
name: 'Updated',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
description: '',
|
||||
use_icon_as_answer_icon: false,
|
||||
})
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(mockGetSocket).toHaveBeenCalledWith('app-1')
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'collaboration_event',
|
||||
expect.objectContaining({
|
||||
type: 'app_meta_update',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should notify error on edit failure', async () => {
|
||||
mockUpdateAppInfo.mockRejectedValue(new Error('fail'))
|
||||
|
||||
@ -549,31 +502,4 @@ describe('useAppInfoActions', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('collaboration app meta updates', () => {
|
||||
it('should refresh app detail when receiving app_meta_update', async () => {
|
||||
const updated = { ...mockAppDetail, name: 'Remote Updated' }
|
||||
const unsubscribe = vi.fn()
|
||||
let onUpdate: (() => Promise<void>) | undefined
|
||||
|
||||
mockOnAppMetaUpdate.mockImplementation((callback: () => Promise<void>) => {
|
||||
onUpdate = callback
|
||||
return unsubscribe
|
||||
})
|
||||
mockFetchAppDetail.mockResolvedValue(updated)
|
||||
|
||||
const { unmount } = renderHook(() => useAppInfoActions({}))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
await act(async () => {
|
||||
await onUpdate?.()
|
||||
})
|
||||
|
||||
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
|
||||
expect(mockSetAppDetail).toHaveBeenCalledWith(updated)
|
||||
|
||||
unmount()
|
||||
expect(unsubscribe).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { EnvironmentVariable } from '@/app/components/workflow/types'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { copyApp, deleteApp, exportAppConfig, fetchAppDetail, updateAppInfo } from '@/service/apps'
|
||||
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
import { useInvalidateAppList } from '@/service/use-apps'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@ -47,56 +47,6 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
|
||||
setActiveModal(null)
|
||||
}, [])
|
||||
|
||||
const emitAppMetaUpdate = useCallback(() => {
|
||||
if (!appDetail?.id)
|
||||
return
|
||||
|
||||
void import('@/app/components/workflow/collaboration/core/websocket-manager')
|
||||
.then(({ webSocketClient }) => {
|
||||
const socket = webSocketClient.getSocket(appDetail.id)
|
||||
if (!socket)
|
||||
return
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'app_meta_update',
|
||||
data: { timestamp: Date.now() },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [appDetail?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!appDetail?.id)
|
||||
return
|
||||
|
||||
let unsubscribe: (() => void) | null = null
|
||||
let disposed = false
|
||||
|
||||
void import('@/app/components/workflow/collaboration/core/collaboration-manager')
|
||||
.then(({ collaborationManager }) => {
|
||||
if (disposed)
|
||||
return
|
||||
|
||||
unsubscribe = collaborationManager.onAppMetaUpdate(async () => {
|
||||
try {
|
||||
const res = await fetchAppDetail({ url: '/apps', id: appDetail.id })
|
||||
if (disposed)
|
||||
return
|
||||
setAppDetail({ ...res })
|
||||
}
|
||||
catch (error) {
|
||||
console.error('failed to refresh app detail from collaboration update:', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [appDetail?.id, setAppDetail])
|
||||
|
||||
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
||||
name,
|
||||
icon_type,
|
||||
@ -122,12 +72,11 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
|
||||
closeModal()
|
||||
toast(t('editDone', { ns: 'app' }), { type: 'success' })
|
||||
setAppDetail(app)
|
||||
emitAppMetaUpdate()
|
||||
}
|
||||
catch {
|
||||
toast(t('editFailed', { ns: 'app' }), { type: 'error' })
|
||||
}
|
||||
}, [appDetail, closeModal, setAppDetail, t, emitAppMetaUpdate])
|
||||
}, [appDetail, closeModal, setAppDetail, t])
|
||||
|
||||
const onCopy: DuplicateAppModalProps['onConfirm'] = useCallback(async ({
|
||||
name,
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
|
||||
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
@ -14,7 +13,7 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { Resolution } from '@/types/app'
|
||||
|
||||
type Props = Omit<AppPublisherProps, 'onPublish'> & {
|
||||
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise<any> | any
|
||||
onPublish?: (modelAndParameter?: ModelAndParameter, features?: any) => Promise<any> | any
|
||||
publishedConfig?: any
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
@ -63,8 +62,8 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
|
||||
setRestoreConfirmOpen(false)
|
||||
}, [featuresStore, props])
|
||||
|
||||
const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => {
|
||||
return props.onPublish?.(params, features)
|
||||
const handlePublish = useCallback((modelAndParameter?: ModelAndParameter) => {
|
||||
return props.onPublish?.(modelAndParameter, features)
|
||||
}, [features, props])
|
||||
|
||||
return (
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
|
||||
import type { InputVar, Variable } from '@/app/components/workflow/types'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
@ -21,9 +19,6 @@ import {
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
@ -31,8 +26,6 @@ import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { fetchAppDetailDirect } from '@/service/apps'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { useInvalidateAppWorkflow } from '@/service/use-workflow'
|
||||
import { fetchPublishedWorkflow } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { toast } from '../../base/ui/toast'
|
||||
@ -58,8 +51,8 @@ export type AppPublisherProps = {
|
||||
debugWithMultipleModel?: boolean
|
||||
multipleModelConfigs?: ModelAndParameter[]
|
||||
/** modelAndParameter is passed when debugWithMultipleModel is true */
|
||||
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void> | void
|
||||
onRestore?: () => Promise<void> | void
|
||||
onPublish?: (params?: any) => Promise<any> | any
|
||||
onRestore?: () => Promise<any> | any
|
||||
onToggle?: (state: boolean) => void
|
||||
crossAxisOffset?: number
|
||||
toolPublished?: boolean
|
||||
@ -104,7 +97,6 @@ const AppPublisher = ({
|
||||
|
||||
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
|
||||
|
||||
const workflowStore = useContext(WorkflowContext)
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const setAppDetail = useAppStore(s => s.setAppDetail)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
@ -116,7 +108,6 @@ const AppPublisher = ({
|
||||
|
||||
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
|
||||
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
const invalidateAppWorkflow = useInvalidateAppWorkflow()
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const isAppAccessSet = useMemo(() => isPublisherAccessConfigured(appDetail, appAccessSubjects), [appAccessSubjects, appDetail])
|
||||
@ -144,39 +135,12 @@ const AppPublisher = ({
|
||||
try {
|
||||
await onPublish?.(params)
|
||||
setPublished(true)
|
||||
|
||||
const appId = appDetail?.id
|
||||
const socket = appId ? webSocketClient.getSocket(appId) : null
|
||||
console.warn('[app-publisher] publish success', {
|
||||
appId,
|
||||
hasSocket: Boolean(socket),
|
||||
})
|
||||
if (appId)
|
||||
invalidateAppWorkflow(appId)
|
||||
else
|
||||
console.warn('[app-publisher] missing appId, skip workflow invalidate and socket emit')
|
||||
if (socket) {
|
||||
const timestamp = Date.now()
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'app_publish_update',
|
||||
data: {
|
||||
action: 'published',
|
||||
timestamp,
|
||||
},
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
else if (appId) {
|
||||
console.warn('[app-publisher] socket not ready, skip collaboration_event emit', { appId })
|
||||
}
|
||||
|
||||
trackEvent('app_published_time', { action_mode: 'app', app_id: appDetail?.id, app_name: appDetail?.name })
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('[app-publisher] publish failed', error)
|
||||
catch {
|
||||
setPublished(false)
|
||||
}
|
||||
}, [appDetail, onPublish, invalidateAppWorkflow])
|
||||
}, [appDetail, onPublish])
|
||||
|
||||
const handleRestore = useCallback(async () => {
|
||||
try {
|
||||
@ -235,29 +199,6 @@ const AppPublisher = ({
|
||||
handlePublish()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useEffect(() => {
|
||||
const appId = appDetail?.id
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
const unsubscribe = collaborationManager.onAppPublishUpdate((update: CollaborationUpdate) => {
|
||||
const action = typeof update.data.action === 'string' ? update.data.action : undefined
|
||||
if (action === 'published') {
|
||||
invalidateAppWorkflow(appId)
|
||||
fetchPublishedWorkflow(`/apps/${appId}/workflows/publish`)
|
||||
.then((publishedWorkflow) => {
|
||||
if (publishedWorkflow?.created_at)
|
||||
workflowStore?.getState().setPublishedAt(publishedWorkflow.created_at)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[app-publisher] refresh published workflow failed', error)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appDetail?.id, invalidateAppWorkflow, workflowStore])
|
||||
|
||||
const hasPublishedVersion = !!publishedAt
|
||||
const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable
|
||||
? t('common.workflowAsToolDisabledHint', { ns: 'workflow' })
|
||||
|
||||
@ -389,8 +389,7 @@ describe('List', () => {
|
||||
const { rerender } = renderWithNuqs(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
renderList()
|
||||
rerender(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ import type { HtmlContentProps } from '@/app/components/base/popover'
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { EnvironmentVariable } from '@/app/components/workflow/types'
|
||||
import type { WorkflowOnlineUser } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
@ -27,7 +26,6 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@/app/components/base/ui/alert-dialog'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@ -68,10 +66,9 @@ const AccessControl = dynamic(() => import('@/app/components/app/app-access-cont
|
||||
type AppCardProps = {
|
||||
app: App
|
||||
onRefresh?: () => void
|
||||
onlineUsers?: WorkflowOnlineUser[]
|
||||
}
|
||||
|
||||
const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
|
||||
const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const deleteAppNameInputId = useId()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
@ -363,19 +360,6 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
|
||||
return `${t('segment.editedAt', { ns: 'datasetDocuments' })} ${timeText}`
|
||||
}, [app.updated_at, app.created_at, t])
|
||||
|
||||
const onlineUserAvatars = useMemo(() => {
|
||||
if (!onlineUsers.length)
|
||||
return []
|
||||
|
||||
return onlineUsers
|
||||
.map(user => ({
|
||||
id: user.user_id || user.sid || '',
|
||||
name: user.username || 'User',
|
||||
avatar_url: user.avatar || undefined,
|
||||
}))
|
||||
.filter(user => !!user.id)
|
||||
}, [onlineUsers])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@ -428,11 +412,6 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{onlineUserAvatars.length > 0 && (
|
||||
<UserAvatarList users={onlineUserAvatars} maxVisible={3} size={20} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary">
|
||||
<div
|
||||
|
||||
@ -19,78 +19,27 @@ export type AvatarSize = keyof typeof avatarSizeClasses
|
||||
export type AvatarProps = {
|
||||
name: string
|
||||
avatar: string | null
|
||||
size?: AvatarSize | number
|
||||
size?: AvatarSize
|
||||
className?: string
|
||||
textClassName?: string
|
||||
onError?: (hasError: boolean) => void
|
||||
backgroundColor?: string
|
||||
onLoadingStatusChange?: (status: ImageLoadingStatus) => void
|
||||
}
|
||||
|
||||
type AvatarRootProps = React.ComponentPropsWithRef<typeof BaseAvatar.Root> & {
|
||||
size?: AvatarSize | number
|
||||
hasAvatar?: boolean
|
||||
backgroundColor?: string
|
||||
size?: AvatarSize
|
||||
}
|
||||
|
||||
const isAvatarPresetSize = (size: AvatarSize | number): size is AvatarSize =>
|
||||
typeof size === 'string'
|
||||
|
||||
function AvatarRoot({
|
||||
size = 'md',
|
||||
className,
|
||||
hasAvatar = false,
|
||||
backgroundColor,
|
||||
style,
|
||||
...props
|
||||
}: AvatarRootProps) {
|
||||
const resolvedStyle: React.CSSProperties = {
|
||||
...(typeof size === 'number' ? { width: `${size}px`, height: `${size}px` } : {}),
|
||||
...(backgroundColor && !hasAvatar ? { backgroundColor } : {}),
|
||||
...style,
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseAvatar.Root
|
||||
className={cn(
|
||||
'relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full bg-primary-600',
|
||||
isAvatarPresetSize(size) && avatarSizeClasses[size].root,
|
||||
avatarSizeClasses[size].root,
|
||||
className,
|
||||
)}
|
||||
style={resolvedStyle}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type AvatarFallbackProps = React.ComponentPropsWithRef<typeof BaseAvatar.Fallback> & {
|
||||
size?: AvatarSize | number
|
||||
textClassName?: string
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
size = 'md',
|
||||
textClassName,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: AvatarFallbackProps) {
|
||||
const resolvedStyle: React.CSSProperties = {
|
||||
...(typeof size === 'number'
|
||||
? { fontSize: `${Math.round(size * 0.4)}px`, lineHeight: 1 }
|
||||
: {}),
|
||||
...style,
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseAvatar.Fallback
|
||||
className={cn(
|
||||
'flex size-full items-center justify-center font-medium text-white',
|
||||
isAvatarPresetSize(size) && avatarSizeClasses[size].text,
|
||||
textClassName,
|
||||
className,
|
||||
)}
|
||||
style={resolvedStyle}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@ -110,43 +59,46 @@ function AvatarImage({
|
||||
)
|
||||
}
|
||||
|
||||
type AvatarFallbackProps = React.ComponentPropsWithRef<typeof BaseAvatar.Fallback> & {
|
||||
size?: AvatarSize
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
size = 'md',
|
||||
className,
|
||||
...props
|
||||
}: AvatarFallbackProps) {
|
||||
return (
|
||||
<BaseAvatar.Fallback
|
||||
className={cn(
|
||||
'flex size-full items-center justify-center font-medium text-white',
|
||||
avatarSizeClasses[size].text,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const Avatar = ({
|
||||
name,
|
||||
avatar,
|
||||
size = 'md',
|
||||
className,
|
||||
textClassName,
|
||||
onError,
|
||||
backgroundColor,
|
||||
onLoadingStatusChange,
|
||||
}: AvatarProps) => {
|
||||
const handleLoadingStatusChange = (status: ImageLoadingStatus) => {
|
||||
onLoadingStatusChange?.(status)
|
||||
if (status === 'error')
|
||||
onError?.(true)
|
||||
if (status === 'loaded')
|
||||
onError?.(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AvatarRoot
|
||||
size={size}
|
||||
className={className}
|
||||
backgroundColor={backgroundColor}
|
||||
hasAvatar={Boolean(avatar)}
|
||||
>
|
||||
<AvatarRoot size={size} className={className}>
|
||||
{avatar && (
|
||||
<AvatarImage
|
||||
src={avatar}
|
||||
alt={name}
|
||||
onLoadingStatusChange={handleLoadingStatusChange}
|
||||
onLoadingStatusChange={onLoadingStatusChange}
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback size={size} textClassName={textClassName}>
|
||||
<AvatarFallback size={size}>
|
||||
{name?.[0]?.toLocaleUpperCase()}
|
||||
</AvatarFallback>
|
||||
</AvatarRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export default Avatar
|
||||
|
||||
@ -19,7 +19,7 @@ const ContentDialog = ({
|
||||
<Transition
|
||||
show={show}
|
||||
as="div"
|
||||
className="absolute left-0 top-0 z-[70] box-border h-full w-full p-2"
|
||||
className="absolute left-0 top-0 z-30 box-border h-full w-full p-2"
|
||||
>
|
||||
<TransitionChild>
|
||||
<div
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "16",
|
||||
"height": "16",
|
||||
"viewBox": "0 0 16 16",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M0 4C0 1.79086 1.79086 0 4 0H12C14.2091 0 16 1.79086 16 4V12C16 14.2091 14.2091 16 12 16H4C1.79086 16 0 14.2091 0 12V4Z",
|
||||
"fill": "white",
|
||||
"fill-opacity": "0.12"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M3.42756 8.7358V7.62784H10.8764C11.2003 7.62784 11.4957 7.5483 11.7628 7.3892C12.0298 7.23011 12.2415 7.01705 12.3977 6.75C12.5568 6.48295 12.6364 6.1875 12.6364 5.86364C12.6364 5.53977 12.5568 5.24574 12.3977 4.98153C12.2386 4.71449 12.0256 4.50142 11.7585 4.34233C11.4943 4.18324 11.2003 4.10369 10.8764 4.10369H10.3991V3H10.8764C11.4048 3 11.8849 3.12926 12.3168 3.38778C12.7486 3.64631 13.0938 3.99148 13.3523 4.4233C13.6108 4.85511 13.7401 5.33523 13.7401 5.86364C13.7401 6.25852 13.6648 6.62926 13.5142 6.97585C13.3665 7.32244 13.1619 7.62784 12.9006 7.89205C12.6392 8.15625 12.3352 8.36364 11.9886 8.5142C11.642 8.66193 11.2713 8.7358 10.8764 8.7358H3.42756ZM6.16761 12.0554L2.29403 8.18182L6.16761 4.30824L6.9304 5.07102L3.81534 8.18182L6.9304 11.2926L6.16761 12.0554Z",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "EnterKey"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './EnterKey.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'EnterKey'
|
||||
|
||||
export default Icon
|
||||
@ -1,26 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"xmlns": "http://www.w3.org/2000/svg",
|
||||
"width": "14",
|
||||
"height": "12",
|
||||
"viewBox": "0 0 14 12",
|
||||
"fill": "none"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M12.3334 4C12.3334 2.52725 11.1395 1.33333 9.66671 1.33333H4.33337C2.86062 1.33333 1.66671 2.52724 1.66671 4V10.6667H9.66671C11.1395 10.6667 12.3334 9.47274 12.3334 8V4ZM7.66671 6.66667V8H4.33337V6.66667H7.66671ZM9.66671 4V5.33333H4.33337V4H9.66671ZM13.6667 8C13.6667 10.2091 11.8758 12 9.66671 12H0.333374V4C0.333374 1.79086 2.12424 0 4.33337 0H9.66671C11.8758 0 13.6667 1.79086 13.6667 4V8Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Comment"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Comment.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Comment'
|
||||
|
||||
export default Icon
|
||||
@ -1,4 +1,3 @@
|
||||
export { default as Comment } from './Comment'
|
||||
export { default as DefaultToolIcon } from './DefaultToolIcon'
|
||||
|
||||
export { default as Message3Fill } from './Message3Fill'
|
||||
|
||||
@ -22,7 +22,6 @@ import type {
|
||||
} from './types'
|
||||
import { CodeNode } from '@lexical/code'
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
|
||||
@ -99,29 +98,6 @@ import {
|
||||
} from './plugins/workflow-variable-block'
|
||||
import { textToEditorState } from './utils'
|
||||
|
||||
const ValueSyncPlugin: FC<{ value?: string }> = ({ value }) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (value === undefined)
|
||||
return
|
||||
|
||||
const incomingValue = value ?? ''
|
||||
const shouldUpdate = editor.getEditorState().read(() => {
|
||||
const currentText = $getRoot().getChildren().map(node => node.getTextContent()).join('\n')
|
||||
return currentText !== incomingValue
|
||||
})
|
||||
|
||||
if (!shouldUpdate)
|
||||
return
|
||||
|
||||
const editorState = editor.parseEditorState(textToEditorState(incomingValue))
|
||||
editor.setEditorState(editorState)
|
||||
}, [editor, value])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export type PromptEditorProps = {
|
||||
instanceId?: string
|
||||
compact?: boolean
|
||||
@ -376,7 +352,6 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
<VariableValueBlock />
|
||||
)
|
||||
}
|
||||
<ValueSyncPlugin value={value} />
|
||||
<OnChangePlugin onChange={handleEditorChange} />
|
||||
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
|
||||
<UpdateBlock instanceId={instanceId} />
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
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-components-panel-bg"
|
||||
backgroundColor={userColor}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
)}
|
||||
{shouldShowCount && remainingCount > 0 && (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-full bg-gray-500 text-[10px] leading-none text-white ring-2 ring-components-panel-bg"
|
||||
style={{
|
||||
zIndex: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
>
|
||||
+
|
||||
{remainingCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
UserAvatarList.displayName = 'UserAvatarList'
|
||||
@ -71,7 +71,7 @@ const ActionList = ({
|
||||
className='w-full'
|
||||
onClick={() => setShowSettingAuth(true)}
|
||||
disabled={!isCurrentWorkspaceManager}
|
||||
>{t('nodes.tool.authorize', { ns: 'workflow' })}</Button>
|
||||
>{t('workflow.nodes.tool.authorize')}</Button>
|
||||
)} */}
|
||||
</div>
|
||||
{/* <div className='flex flex-col gap-2'>
|
||||
|
||||
@ -24,7 +24,7 @@ export const useAvailableNodesMetaData = () => {
|
||||
},
|
||||
knowledgeBaseDefault,
|
||||
dataSourceEmptyDefault,
|
||||
] as AvailableNodesMetaData['nodes'], [])
|
||||
], [])
|
||||
|
||||
const helpLinkUri = useMemo(() => docLink(
|
||||
'/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration',
|
||||
|
||||
@ -3,14 +3,8 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
|
||||
export const useGetRunAndTraceUrl = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const getWorkflowRunAndTraceUrl = useCallback((runId?: string) => {
|
||||
const getWorkflowRunAndTraceUrl = useCallback((runId: string) => {
|
||||
const { pipelineId } = workflowStore.getState()
|
||||
if (!pipelineId || !runId) {
|
||||
return {
|
||||
runUrl: '',
|
||||
traceUrl: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runUrl: `/rag/pipelines/${pipelineId}/workflow-runs/${runId}`,
|
||||
|
||||
@ -10,7 +10,6 @@ import Divider from '@/app/components/base/divider'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import MCPServerParamItem from '@/app/components/tools/mcp/mcp-server-param-item'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import {
|
||||
useCreateMCPServer,
|
||||
useInvalidateMCPServerDetail,
|
||||
@ -60,22 +59,6 @@ const MCPServerModal = ({
|
||||
return res
|
||||
}
|
||||
|
||||
const emitMcpServerUpdate = (action: 'created' | 'updated') => {
|
||||
const socket = webSocketClient.getSocket(appID)
|
||||
if (!socket)
|
||||
return
|
||||
|
||||
const timestamp = Date.now()
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'mcp_server_update',
|
||||
data: {
|
||||
action,
|
||||
timestamp,
|
||||
},
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!data) {
|
||||
const payload: any = {
|
||||
@ -88,7 +71,6 @@ const MCPServerModal = ({
|
||||
|
||||
await createMCPServer(payload)
|
||||
invalidateMCPServerDetail(appID)
|
||||
emitMcpServerUpdate('created')
|
||||
onHide()
|
||||
}
|
||||
else {
|
||||
@ -101,7 +83,6 @@ const MCPServerModal = ({
|
||||
payload.description = description
|
||||
await updateMCPServer(payload)
|
||||
invalidateMCPServerDetail(appID)
|
||||
emitMcpServerUpdate('updated')
|
||||
onHide()
|
||||
}
|
||||
}
|
||||
@ -111,7 +92,6 @@ const MCPServerModal = ({
|
||||
isShow={show}
|
||||
onClose={onHide}
|
||||
className={cn('relative max-w-[520px]! p-0!')}
|
||||
highPriority
|
||||
>
|
||||
<div className="absolute right-5 top-5 z-10 cursor-pointer p-1.5" onClick={onHide}>
|
||||
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
'use client'
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import { RiEditLine, RiLoopLeftLine } from '@remixicon/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
@ -17,7 +16,6 @@ import Tooltip from '@/app/components/base/tooltip'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useInvalidateMCPServerDetail } from '@/service/use-tools'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useMCPServiceCardState } from './hooks/use-mcp-service-card'
|
||||
|
||||
@ -165,7 +163,6 @@ const MCPServiceCard: FC<IAppCardProps> = ({
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const appId = appInfo.id
|
||||
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
|
||||
|
||||
const {
|
||||
genLoading,
|
||||
@ -194,28 +191,6 @@ const MCPServiceCard: FC<IAppCardProps> = ({
|
||||
const [pendingStatus, setPendingStatus] = useState<boolean | null>(null)
|
||||
const activated = pendingStatus ?? serverActivated
|
||||
|
||||
const emitMcpServerUpdate = async (data: Record<string, unknown>) => {
|
||||
try {
|
||||
const { webSocketClient } = await import('@/app/components/workflow/collaboration/core/websocket-manager')
|
||||
const socket = webSocketClient.getSocket(appId)
|
||||
if (!socket)
|
||||
return
|
||||
|
||||
const timestamp = Date.now()
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'mcp_server_update',
|
||||
data: {
|
||||
...data,
|
||||
timestamp,
|
||||
},
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('MCP collaboration event emit failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onChangeStatus = async (state: boolean) => {
|
||||
setPendingStatus(state)
|
||||
const result = await handleStatusChange(state)
|
||||
@ -223,15 +198,6 @@ const MCPServiceCard: FC<IAppCardProps> = ({
|
||||
// Server modal was opened instead, clear pending status
|
||||
setPendingStatus(null)
|
||||
}
|
||||
|
||||
if (result.activated !== state)
|
||||
return
|
||||
|
||||
// Emit collaboration event to notify other clients of MCP server status change
|
||||
void emitMcpServerUpdate({
|
||||
action: 'statusChanged',
|
||||
status: state ? 'active' : 'inactive',
|
||||
})
|
||||
}
|
||||
|
||||
const onServerModalHide = () => {
|
||||
@ -241,52 +207,10 @@ const MCPServiceCard: FC<IAppCardProps> = ({
|
||||
}
|
||||
|
||||
const onConfirmRegenerate = () => {
|
||||
handleGenCode()
|
||||
closeConfirmDelete()
|
||||
|
||||
void (async () => {
|
||||
await handleGenCode()
|
||||
|
||||
// Emit collaboration event to notify other clients of MCP server code changes
|
||||
await emitMcpServerUpdate({
|
||||
action: 'codeRegenerated',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
// Listen for collaborative MCP server updates from other clients
|
||||
useEffect(() => {
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
let unsubscribe: (() => void) | undefined
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { collaborationManager } = await import('@/app/components/workflow/collaboration/core/collaboration-manager')
|
||||
if (cancelled)
|
||||
return
|
||||
|
||||
unsubscribe = collaborationManager.onMcpServerUpdate((_update: CollaborationUpdate) => {
|
||||
try {
|
||||
invalidateMCPServerDetail(appId)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('MCP server update failed:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('MCP collaboration subscription failed:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [appId, invalidateMCPServerDetail])
|
||||
|
||||
if (isLoading)
|
||||
return null
|
||||
|
||||
|
||||
@ -124,7 +124,7 @@ vi.mock('@/app/components/app/app-publisher', () => ({
|
||||
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.()).catch(() => undefined) }}>
|
||||
publisher-publish
|
||||
</button>
|
||||
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ url: '/apps/app-id/workflows/publish', title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
|
||||
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
|
||||
publisher-publish-with-params
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
|
||||
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
|
||||
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
|
||||
import type {
|
||||
@ -144,8 +143,7 @@ const FeaturesTrigger = () => {
|
||||
const needWarningNodes = useChecklist(nodes, edges)
|
||||
|
||||
const updatePublishedWorkflow = useInvalidateAppWorkflow()
|
||||
const onPublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
|
||||
const publishParams = params && 'title' in params ? params : undefined
|
||||
const onPublish = useCallback(async (params?: PublishWorkflowParams) => {
|
||||
// First check if there are any items in the checklist
|
||||
// if (!validateBeforeRun())
|
||||
// throw new Error('Checklist has unresolved items')
|
||||
@ -157,21 +155,12 @@ const FeaturesTrigger = () => {
|
||||
|
||||
// Then perform the detailed validation
|
||||
if (await handleCheckBeforePublish()) {
|
||||
console.warn('[workflow-header] publish start', {
|
||||
appId: appID,
|
||||
title: publishParams?.title ?? '',
|
||||
})
|
||||
const res = await publishWorkflow({
|
||||
url: `/apps/${appID}/workflows/publish`,
|
||||
title: publishParams?.title || '',
|
||||
releaseNotes: publishParams?.releaseNotes || '',
|
||||
title: params?.title || '',
|
||||
releaseNotes: params?.releaseNotes || '',
|
||||
})
|
||||
|
||||
console.warn('[workflow-header] publish response', {
|
||||
appId: appID,
|
||||
hasResponse: Boolean(res),
|
||||
createdAt: res?.created_at,
|
||||
})
|
||||
if (res) {
|
||||
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
||||
updatePublishedWorkflow(appID!)
|
||||
|
||||
@ -1,24 +1,11 @@
|
||||
import type { Features as FeaturesData } from '@/app/components/base/features/types'
|
||||
import type { WorkflowProps } from '@/app/components/workflow'
|
||||
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
|
||||
import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store/store'
|
||||
import type { Edge, Node } from '@/app/components/workflow/types'
|
||||
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import { WorkflowWithInnerContext } from '@/app/components/workflow'
|
||||
import { collaborationManager, useCollaboration } from '@/app/components/workflow/collaboration'
|
||||
import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
useAvailableNodesMetaData,
|
||||
useConfigsMap,
|
||||
@ -34,7 +21,6 @@ import {
|
||||
import WorkflowChildren from './workflow-children'
|
||||
|
||||
type WorkflowMainProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
|
||||
type WorkflowDataUpdatePayload = Pick<FetchWorkflowDraftResponse, 'features' | 'conversation_variables' | 'environment_variables'>
|
||||
const WorkflowMain = ({
|
||||
nodes,
|
||||
edges,
|
||||
@ -42,48 +28,8 @@ const WorkflowMain = ({
|
||||
}: WorkflowMainProps) => {
|
||||
const featuresStore = useFeaturesStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const appId = useStore(s => s.appId)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const reactFlow = useReactFlow()
|
||||
|
||||
const reactFlowStore = useMemo(() => ({
|
||||
getState: () => ({
|
||||
getNodes: () => reactFlow.getNodes(),
|
||||
setNodes: (nodesToSet: Node[]) => reactFlow.setNodes(nodesToSet),
|
||||
getEdges: () => reactFlow.getEdges(),
|
||||
setEdges: (edgesToSet: Edge[]) => reactFlow.setEdges(edgesToSet),
|
||||
}),
|
||||
}), [reactFlow])
|
||||
const {
|
||||
startCursorTracking,
|
||||
stopCursorTracking,
|
||||
onlineUsers,
|
||||
cursors,
|
||||
isConnected,
|
||||
isEnabled: isCollaborationEnabled,
|
||||
} = useCollaboration(appId || '', reactFlowStore)
|
||||
const myUserId = useMemo(
|
||||
() => (isCollaborationEnabled && isConnected ? 'current-user' : null),
|
||||
[isCollaborationEnabled, isConnected],
|
||||
)
|
||||
|
||||
const filteredCursors = Object.fromEntries(
|
||||
Object.entries(cursors).filter(([userId]) => userId !== myUserId),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCollaborationEnabled)
|
||||
return
|
||||
|
||||
if (containerRef.current)
|
||||
startCursorTracking(containerRef as React.RefObject<HTMLElement>, reactFlow)
|
||||
|
||||
return () => {
|
||||
stopCursorTracking()
|
||||
}
|
||||
}, [startCursorTracking, stopCursorTracking, reactFlow, isCollaborationEnabled])
|
||||
|
||||
const handleWorkflowDataUpdate = useCallback((payload: WorkflowDataUpdatePayload) => {
|
||||
const handleWorkflowDataUpdate = useCallback((payload: any) => {
|
||||
const {
|
||||
features,
|
||||
conversation_variables,
|
||||
@ -92,33 +38,7 @@ const WorkflowMain = ({
|
||||
if (features && featuresStore) {
|
||||
const { setFeatures } = featuresStore.getState()
|
||||
|
||||
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)
|
||||
setFeatures(features)
|
||||
}
|
||||
if (conversation_variables) {
|
||||
const { setConversationVariables } = workflowStore.getState()
|
||||
@ -135,7 +55,6 @@ const WorkflowMain = ({
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
} = useNodesSyncDraft()
|
||||
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
|
||||
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
|
||||
const {
|
||||
handleBackupDraft,
|
||||
handleLoadBackupDraft,
|
||||
@ -143,64 +62,6 @@ const WorkflowMain = ({
|
||||
handleRun,
|
||||
handleStopRun,
|
||||
} = useWorkflowRun()
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId || !isCollaborationEnabled)
|
||||
return
|
||||
|
||||
const unsubscribe = collaborationManager.onVarsAndFeaturesUpdate(async (_update: CollaborationUpdate) => {
|
||||
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, isCollaborationEnabled])
|
||||
|
||||
// Listen for workflow updates from other users
|
||||
useEffect(() => {
|
||||
if (!appId || !isCollaborationEnabled)
|
||||
return
|
||||
|
||||
const unsubscribe = collaborationManager.onWorkflowUpdate(async () => {
|
||||
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, isCollaborationEnabled])
|
||||
|
||||
// Listen for sync requests from other users (only processed by leader)
|
||||
useEffect(() => {
|
||||
if (!appId || !isCollaborationEnabled)
|
||||
return
|
||||
|
||||
const unsubscribe = collaborationManager.onSyncRequest(() => {
|
||||
doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appId, doSyncWorkflowDraft, isCollaborationEnabled])
|
||||
const {
|
||||
handleStartWorkflowRun,
|
||||
handleWorkflowStartRunInChatflow,
|
||||
@ -218,7 +79,6 @@ const WorkflowMain = ({
|
||||
} = useDSL()
|
||||
|
||||
const configsMap = useConfigsMap()
|
||||
|
||||
const { fetchInspectVars } = useSetWorkflowVarsWithValue({
|
||||
...configsMap,
|
||||
})
|
||||
@ -239,7 +99,7 @@ const WorkflowMain = ({
|
||||
invalidateConversationVarValues,
|
||||
} = useInspectVarsCrud()
|
||||
|
||||
const hooksStore = useMemo<Partial<HooksStoreShape>>(() => {
|
||||
const hooksStore = useMemo(() => {
|
||||
return {
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
doSyncWorkflowDraft,
|
||||
@ -316,23 +176,15 @@ const WorkflowMain = ({
|
||||
])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative h-full w-full"
|
||||
<WorkflowWithInnerContext
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
viewport={viewport}
|
||||
onWorkflowDataUpdate={handleWorkflowDataUpdate}
|
||||
hooksStore={hooksStore as any}
|
||||
>
|
||||
<WorkflowWithInnerContext
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
viewport={viewport}
|
||||
onWorkflowDataUpdate={handleWorkflowDataUpdate}
|
||||
hooksStore={hooksStore}
|
||||
cursors={filteredCursors}
|
||||
myUserId={myUserId}
|
||||
onlineUsers={onlineUsers}
|
||||
>
|
||||
<WorkflowChildren />
|
||||
</WorkflowWithInnerContext>
|
||||
</div>
|
||||
<WorkflowChildren />
|
||||
</WorkflowWithInnerContext>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Panel from '@/app/components/workflow/panel'
|
||||
import CommentsPanel from '@/app/components/workflow/panel/comments-panel'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import {
|
||||
@ -68,7 +67,6 @@ const WorkflowPanelOnRight = () => {
|
||||
const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel)
|
||||
const showChatVariablePanel = useStore(s => s.showChatVariablePanel)
|
||||
const showGlobalVariablePanel = useStore(s => s.showGlobalVariablePanel)
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -102,7 +100,6 @@ const WorkflowPanelOnRight = () => {
|
||||
<GlobalVariablePanel />
|
||||
)
|
||||
}
|
||||
{controlMode === 'comment' && <CommentsPanel />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -8,10 +8,6 @@ const mockPostWithKeepalive = vi.fn()
|
||||
const mockSetSyncWorkflowDraftHash = vi.fn()
|
||||
const mockSetDraftUpdatedAt = vi.fn()
|
||||
const mockGetNodesReadOnly = vi.fn()
|
||||
const mockCollaborationIsConnected = vi.fn()
|
||||
const mockCollaborationGetIsLeader = vi.fn()
|
||||
const mockCollaborationEmitSyncRequest = vi.fn()
|
||||
let isCollaborationEnabled = false
|
||||
|
||||
let reactFlowState: {
|
||||
getNodes: typeof mockGetNodes
|
||||
@ -61,23 +57,6 @@ vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({
|
||||
useNodesReadOnly: () => ({ getNodesReadOnly: mockGetNodesReadOnly }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
|
||||
collaborationManager: {
|
||||
isConnected: (...args: unknown[]) => mockCollaborationIsConnected(...args),
|
||||
getIsLeader: (...args: unknown[]) => mockCollaborationGetIsLeader(...args),
|
||||
emitSyncRequest: (...args: unknown[]) => mockCollaborationEmitSyncRequest(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => unknown) =>
|
||||
selector({
|
||||
systemFeatures: {
|
||||
enable_collaboration_mode: isCollaborationEnabled,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({
|
||||
useSerialAsyncCallback: (fn: (...args: unknown[]) => Promise<void>, checkFn: () => boolean) =>
|
||||
(...args: unknown[]) => {
|
||||
@ -130,9 +109,6 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
|
||||
mockGetNodesReadOnly.mockReturnValue(false)
|
||||
mockGetNodes.mockReturnValue([{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start' } }])
|
||||
mockSyncWorkflowDraft.mockResolvedValue({ hash: 'new', updated_at: 1 })
|
||||
mockCollaborationIsConnected.mockReturnValue(false)
|
||||
mockCollaborationGetIsLeader.mockReturnValue(true)
|
||||
isCollaborationEnabled = false
|
||||
})
|
||||
|
||||
it('should call handleRefreshWorkflowDraft(true) — not updating canvas — on draft_workflow_not_sync', async () => {
|
||||
@ -285,41 +261,4 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
|
||||
hash: 'hash-123',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should emit sync request instead of syncing when current user is collaboration follower', async () => {
|
||||
isCollaborationEnabled = true
|
||||
mockCollaborationIsConnected.mockReturnValue(true)
|
||||
mockCollaborationGetIsLeader.mockReturnValue(false)
|
||||
const callbacks = {
|
||||
onSuccess: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onSettled: vi.fn(),
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, callbacks)
|
||||
})
|
||||
|
||||
expect(mockCollaborationEmitSyncRequest).toHaveBeenCalled()
|
||||
expect(mockSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
expect(callbacks.onSuccess).not.toHaveBeenCalled()
|
||||
expect(callbacks.onError).not.toHaveBeenCalled()
|
||||
expect(callbacks.onSettled).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip keepalive sync on page close when current user is collaboration follower', () => {
|
||||
isCollaborationEnabled = true
|
||||
mockCollaborationIsConnected.mockReturnValue(true)
|
||||
mockCollaborationGetIsLeader.mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
expect(mockPostWithKeepalive).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -39,7 +39,7 @@ export const useAvailableNodesMetaData = () => {
|
||||
TriggerPluginDefault,
|
||||
]
|
||||
),
|
||||
] as AvailableNodesMetaData['nodes'], [isChatMode, startNodeMetaData])
|
||||
], [isChatMode, startNodeMetaData])
|
||||
|
||||
const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => {
|
||||
const { metaData } = node
|
||||
@ -60,7 +60,7 @@ export const useAvailableNodesMetaData = () => {
|
||||
title,
|
||||
},
|
||||
}
|
||||
}) as AvailableNodesMetaData['nodes'], [mergedNodesMetaData, t, docLink])
|
||||
}), [mergedNodesMetaData, t, docLink])
|
||||
|
||||
const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => {
|
||||
acc![node.metaData.type] = node
|
||||
|
||||
@ -3,14 +3,8 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
|
||||
export const useGetRunAndTraceUrl = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const getWorkflowRunAndTraceUrl = useCallback((runId?: string) => {
|
||||
const getWorkflowRunAndTraceUrl = useCallback((runId: string) => {
|
||||
const { appId } = workflowStore.getState()
|
||||
if (!appId || !runId) {
|
||||
return {
|
||||
runUrl: '',
|
||||
traceUrl: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runUrl: `/apps/${appId}/workflow-runs/${runId}`,
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store'
|
||||
import type { WorkflowDraftFeaturesPayload } from '@/service/workflow'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback'
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks/use-workflow'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { postWithKeepalive } from '@/service/fetch'
|
||||
import { syncWorkflowDraft } from '@/service/workflow'
|
||||
import { useWorkflowRefreshDraft } from '.'
|
||||
@ -20,7 +17,6 @@ export const useNodesSyncDraft = () => {
|
||||
const featuresStore = useFeaturesStore()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
|
||||
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
|
||||
|
||||
const getPostParams = useCallback(() => {
|
||||
const {
|
||||
@ -58,16 +54,7 @@ export const useNodesSyncDraft = () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
const featuresPayload: WorkflowDraftFeaturesPayload = {
|
||||
opening_statement: features.opening?.enabled ? (features.opening?.opening_statement || '') : '',
|
||||
suggested_questions: features.opening?.enabled ? (features.opening?.suggested_questions || []) : [],
|
||||
suggested_questions_after_answer: features.suggested,
|
||||
text_to_speech: features.text2speech,
|
||||
speech_to_text: features.speech2text,
|
||||
retriever_resource: features.citation,
|
||||
sensitive_word_avoidance: features.moderation,
|
||||
file_upload: features.file,
|
||||
}
|
||||
const viewport = { x, y, zoom }
|
||||
|
||||
return {
|
||||
url: `/apps/${appId}/workflows/draft`,
|
||||
@ -75,37 +62,33 @@ export const useNodesSyncDraft = () => {
|
||||
graph: {
|
||||
nodes: producedNodes,
|
||||
edges: producedEdges,
|
||||
viewport: {
|
||||
x,
|
||||
y,
|
||||
zoom,
|
||||
},
|
||||
viewport,
|
||||
},
|
||||
features: {
|
||||
opening_statement: features.opening?.enabled ? (features.opening?.opening_statement || '') : '',
|
||||
suggested_questions: features.opening?.enabled ? (features.opening?.suggested_questions || []) : [],
|
||||
suggested_questions_after_answer: features.suggested,
|
||||
text_to_speech: features.text2speech,
|
||||
speech_to_text: features.speech2text,
|
||||
retriever_resource: features.citation,
|
||||
sensitive_word_avoidance: features.moderation,
|
||||
file_upload: features.file,
|
||||
},
|
||||
features: featuresPayload,
|
||||
environment_variables: environmentVariables,
|
||||
conversation_variables: conversationVariables,
|
||||
hash: syncWorkflowDraftHash,
|
||||
...(isCollaborationEnabled ? { _is_collaborative: true } : {}),
|
||||
},
|
||||
}
|
||||
}, [store, featuresStore, workflowStore, isCollaborationEnabled])
|
||||
}, [store, featuresStore, workflowStore])
|
||||
|
||||
const syncWorkflowDraftWhenPageClose = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const isFollower = isCollaborationEnabled
|
||||
&& collaborationManager.isConnected()
|
||||
&& !collaborationManager.getIsLeader()
|
||||
|
||||
if (isFollower)
|
||||
return
|
||||
|
||||
const postParams = getPostParams()
|
||||
|
||||
if (postParams)
|
||||
postWithKeepalive(`${API_PREFIX}${postParams.url}`, postParams.params)
|
||||
}, [getPostParams, getNodesReadOnly, isCollaborationEnabled])
|
||||
}, [getPostParams, getNodesReadOnly])
|
||||
|
||||
const performSync = useCallback(async (
|
||||
notRefreshWhenSyncError?: boolean,
|
||||
@ -114,16 +97,7 @@ export const useNodesSyncDraft = () => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const isFollower = isCollaborationEnabled
|
||||
&& collaborationManager.isConnected()
|
||||
&& !collaborationManager.getIsLeader()
|
||||
|
||||
if (isFollower) {
|
||||
collaborationManager.emitSyncRequest()
|
||||
callback?.onSettled?.()
|
||||
return
|
||||
}
|
||||
|
||||
// Get base params without hash
|
||||
const baseParams = getPostParams()
|
||||
if (!baseParams)
|
||||
return
|
||||
@ -134,13 +108,15 @@ export const useNodesSyncDraft = () => {
|
||||
} = workflowStore.getState()
|
||||
|
||||
try {
|
||||
// IMPORTANT: Get the LATEST hash right before sending the request
|
||||
// This ensures that even if queued, each request uses the most recent hash
|
||||
const latestHash = workflowStore.getState().syncWorkflowDraftHash
|
||||
|
||||
const postParams = {
|
||||
...baseParams,
|
||||
params: {
|
||||
...baseParams.params,
|
||||
hash: latestHash || null,
|
||||
hash: latestHash || null, // null for first-time, otherwise use latest hash
|
||||
},
|
||||
}
|
||||
|
||||
@ -161,7 +137,7 @@ export const useNodesSyncDraft = () => {
|
||||
finally {
|
||||
callback?.onSettled?.()
|
||||
}
|
||||
}, [workflowStore, getPostParams, getNodesReadOnly, handleRefreshWorkflowDraft, isCollaborationEnabled])
|
||||
}, [workflowStore, getPostParams, getNodesReadOnly, handleRefreshWorkflowDraft])
|
||||
|
||||
const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly)
|
||||
|
||||
|
||||
@ -10,7 +10,6 @@ import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { FeaturesProvider } from '@/app/components/base/features'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import WorkflowWithDefaultContext from '@/app/components/workflow'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import {
|
||||
WorkflowContextProvider,
|
||||
} from '@/app/components/workflow/context'
|
||||
@ -79,20 +78,15 @@ const WorkflowAppWithAdditionalContext = () => {
|
||||
}, [workflowStore])
|
||||
|
||||
const nodesData = useMemo(() => {
|
||||
if (data) {
|
||||
const processedNodes = initialNodes(data.graph.nodes, data.graph.edges)
|
||||
collaborationManager.setNodes([], processedNodes)
|
||||
return processedNodes
|
||||
}
|
||||
if (data)
|
||||
return initialNodes(data.graph.nodes, data.graph.edges)
|
||||
|
||||
return []
|
||||
}, [data])
|
||||
|
||||
const edgesData = useMemo(() => {
|
||||
if (data) {
|
||||
const processedEdges = initialEdges(data.graph.edges, data.graph.nodes)
|
||||
collaborationManager.setEdges([], processedEdges)
|
||||
return processedEdges
|
||||
}
|
||||
if (data)
|
||||
return initialEdges(data.graph.edges, data.graph.nodes)
|
||||
|
||||
return []
|
||||
}, [data])
|
||||
|
||||
|
||||
46
web/app/components/workflow/__tests__/block-icon.spec.tsx
Normal file
46
web/app/components/workflow/__tests__/block-icon.spec.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import BlockIcon, { VarBlockIcon } from '../block-icon'
|
||||
import { BlockEnum } from '../types'
|
||||
|
||||
describe('BlockIcon', () => {
|
||||
it('renders the default workflow icon container for regular nodes', () => {
|
||||
const { container } = render(<BlockIcon type={BlockEnum.Start} size="xs" className="extra-class" />)
|
||||
|
||||
const iconContainer = container.firstElementChild
|
||||
expect(iconContainer).toHaveClass('w-4', 'h-4', 'bg-util-colors-blue-brand-blue-brand-500', 'extra-class')
|
||||
expect(iconContainer?.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('normalizes protected plugin icon urls for tool-like nodes', () => {
|
||||
const { container } = render(
|
||||
<BlockIcon
|
||||
type={BlockEnum.Tool}
|
||||
toolIcon="/foo/workspaces/current/plugin/icon/plugin-tool.png"
|
||||
/>,
|
||||
)
|
||||
|
||||
const iconContainer = container.firstElementChild as HTMLElement
|
||||
const backgroundIcon = iconContainer.querySelector('div') as HTMLElement
|
||||
|
||||
expect(iconContainer).not.toHaveClass('bg-util-colors-blue-blue-500')
|
||||
expect(backgroundIcon.style.backgroundImage).toContain(
|
||||
`${API_PREFIX}/workspaces/current/plugin/icon/plugin-tool.png`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('VarBlockIcon', () => {
|
||||
it('renders the compact icon variant without the default container wrapper', () => {
|
||||
const { container } = render(
|
||||
<VarBlockIcon
|
||||
type={BlockEnum.Answer}
|
||||
className="custom-var-icon"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.custom-var-icon')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
expect(container.querySelector('.bg-util-colors-warning-warning-500')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
39
web/app/components/workflow/__tests__/context.spec.tsx
Normal file
39
web/app/components/workflow/__tests__/context.spec.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { WorkflowContextProvider } from '../context'
|
||||
import { useStore, useWorkflowStore } from '../store'
|
||||
|
||||
const StoreConsumer = () => {
|
||||
const showSingleRunPanel = useStore(s => s.showSingleRunPanel)
|
||||
const store = useWorkflowStore()
|
||||
|
||||
return (
|
||||
<button onClick={() => store.getState().setShowSingleRunPanel(!showSingleRunPanel)}>
|
||||
{showSingleRunPanel ? 'open' : 'closed'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkflowContextProvider', () => {
|
||||
it('provides the workflow store to descendants and keeps the same store across rerenders', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(
|
||||
<WorkflowContextProvider>
|
||||
<StoreConsumer />
|
||||
</WorkflowContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'closed' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'closed' }))
|
||||
expect(screen.getByRole('button', { name: 'open' })).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<WorkflowContextProvider>
|
||||
<StoreConsumer />
|
||||
</WorkflowContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'open' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
67
web/app/components/workflow/__tests__/index.spec.tsx
Normal file
67
web/app/components/workflow/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import type { Edge, Node } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useDatasetsDetailStore } from '../datasets-detail-store/store'
|
||||
import WorkflowWithDefaultContext from '../index'
|
||||
import { BlockEnum } from '../types'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
|
||||
const nodes: Node[] = [
|
||||
{
|
||||
id: 'node-start',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
type: BlockEnum.Start,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const edges: Edge[] = [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'node-start',
|
||||
target: 'node-end',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
type: 'custom',
|
||||
data: {
|
||||
sourceType: BlockEnum.Start,
|
||||
targetType: BlockEnum.End,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const ContextConsumer = () => {
|
||||
const { store, shortcutsEnabled } = useWorkflowHistoryStore()
|
||||
const datasetCount = useDatasetsDetailStore(state => Object.keys(state.datasetsDetail).length)
|
||||
const reactFlowStore = useStoreApi()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{`history:${store.getState().nodes.length}`}
|
||||
{` shortcuts:${String(shortcutsEnabled)}`}
|
||||
{` datasets:${datasetCount}`}
|
||||
{` reactflow:${String(!!reactFlowStore)}`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkflowWithDefaultContext', () => {
|
||||
it('wires the ReactFlow, workflow history, and datasets detail providers around its children', () => {
|
||||
render(
|
||||
<WorkflowWithDefaultContext
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
<ContextConsumer />
|
||||
</WorkflowWithDefaultContext>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText('history:1 shortcuts:true datasets:0 reactflow:true'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
|
||||
describe('ShortcutsName', () => {
|
||||
const originalNavigator = globalThis.navigator
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders mac-friendly key labels and style variants', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Macintosh' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<ShortcutsName
|
||||
keys={['ctrl', 'shift', 's']}
|
||||
bgColor="white"
|
||||
textColor="secondary"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('⌘')).toBeInTheDocument()
|
||||
expect(screen.getByText('⇧')).toBeInTheDocument()
|
||||
expect(screen.getByText('s')).toBeInTheDocument()
|
||||
expect(container.querySelector('.system-kbd')).toHaveClass(
|
||||
'bg-components-kbd-bg-white',
|
||||
'text-text-tertiary',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps raw key names on non-mac systems', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Windows NT' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
render(<ShortcutsName keys={['ctrl', 'alt']} />)
|
||||
|
||||
expect(screen.getByText('ctrl')).toBeInTheDocument()
|
||||
expect(screen.getByText('alt')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,97 @@
|
||||
import type { Edge, Node } from '../types'
|
||||
import type { WorkflowHistoryState } from '../workflow-history-store'
|
||||
import { render, renderHook, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BlockEnum } from '../types'
|
||||
import { useWorkflowHistoryStore, WorkflowHistoryProvider } from '../workflow-history-store'
|
||||
|
||||
const nodes: Node[] = [
|
||||
{
|
||||
id: 'node-1',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
type: BlockEnum.Start,
|
||||
selected: true,
|
||||
},
|
||||
selected: true,
|
||||
},
|
||||
]
|
||||
|
||||
const edges: Edge[] = [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'node-1',
|
||||
target: 'node-2',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
type: 'custom',
|
||||
selected: true,
|
||||
data: {
|
||||
sourceType: BlockEnum.Start,
|
||||
targetType: BlockEnum.End,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const HistoryConsumer = () => {
|
||||
const { store, shortcutsEnabled, setShortcutsEnabled } = useWorkflowHistoryStore()
|
||||
|
||||
return (
|
||||
<button onClick={() => setShortcutsEnabled(!shortcutsEnabled)}>
|
||||
{`nodes:${store.getState().nodes.length} shortcuts:${String(shortcutsEnabled)}`}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkflowHistoryProvider', () => {
|
||||
it('provides workflow history state and shortcut toggles', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<WorkflowHistoryProvider
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
<HistoryConsumer />
|
||||
</WorkflowHistoryProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'nodes:1 shortcuts:true' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'nodes:1 shortcuts:true' }))
|
||||
expect(screen.getByRole('button', { name: 'nodes:1 shortcuts:false' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sanitizes selected flags when history state is replaced through the exposed store api', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<WorkflowHistoryProvider
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
{children}
|
||||
</WorkflowHistoryProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useWorkflowHistoryStore(), { wrapper })
|
||||
const nextState: WorkflowHistoryState = {
|
||||
workflowHistoryEvent: undefined,
|
||||
workflowHistoryEventMeta: undefined,
|
||||
nodes,
|
||||
edges,
|
||||
}
|
||||
|
||||
result.current.store.setState(nextState)
|
||||
|
||||
expect(result.current.store.getState().nodes[0].data.selected).toBe(false)
|
||||
expect(result.current.store.getState().edges[0].selected).toBe(false)
|
||||
})
|
||||
|
||||
it('throws when consumed outside the provider', () => {
|
||||
expect(() => renderHook(() => useWorkflowHistoryStore())).toThrow(
|
||||
'useWorkflowHistoryStoreApi must be used within a WorkflowHistoryProvider',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -35,7 +35,7 @@ const NodeSelectorWrapper = (props: NodeSelectorProps) => {
|
||||
|
||||
return true
|
||||
})
|
||||
}, [availableNodesMetaData?.nodes]) as NodeSelectorProps['blocks']
|
||||
}, [availableNodesMetaData?.nodes])
|
||||
|
||||
return (
|
||||
<NodeSelector
|
||||
|
||||
@ -11,9 +11,9 @@ import {
|
||||
} from 'react'
|
||||
import {
|
||||
useReactFlow,
|
||||
useStoreApi,
|
||||
useViewport,
|
||||
} from 'reactflow'
|
||||
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
|
||||
import { CUSTOM_NODE } from './constants'
|
||||
import { useAutoGenerateWebhookUrl, useNodesInteractions, useNodesSyncDraft, useWorkflowHistory, WorkflowHistoryEvent } from './hooks'
|
||||
import CustomNode from './nodes'
|
||||
@ -32,6 +32,7 @@ type Props = {
|
||||
const CandidateNodeMain: FC<Props> = ({
|
||||
candidateNode,
|
||||
}) => {
|
||||
const store = useStoreApi()
|
||||
const reactflow = useReactFlow()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const mousePosition = useStore(s => s.mousePosition)
|
||||
@ -40,12 +41,15 @@ const CandidateNodeMain: FC<Props> = ({
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl()
|
||||
const collaborativeWorkflow = useCollaborativeWorkflow()
|
||||
|
||||
useEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
const { screenToFlowPosition } = reactflow
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
const nodes = getNodes()
|
||||
const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.push({
|
||||
|
||||
@ -1,78 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import type { CursorPosition, OnlineUser } from '@/app/components/workflow/collaboration/types'
|
||||
import { useViewport } from 'reactflow'
|
||||
import { getUserColor } from '../utils/user-color'
|
||||
|
||||
type UserCursorsProps = {
|
||||
cursors: Record<string, CursorPosition>
|
||||
myUserId: string | null
|
||||
onlineUsers: OnlineUser[]
|
||||
}
|
||||
|
||||
const UserCursors: FC<UserCursorsProps> = ({
|
||||
cursors,
|
||||
myUserId,
|
||||
onlineUsers,
|
||||
}) => {
|
||||
const viewport = useViewport()
|
||||
|
||||
const convertToScreenCoordinates = (cursor: CursorPosition) => {
|
||||
// Convert world coordinates to screen coordinates using current viewport
|
||||
const screenX = cursor.x * viewport.zoom + viewport.x
|
||||
const screenY = cursor.y * viewport.zoom + viewport.y
|
||||
|
||||
return { x: screenX, y: screenY }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{Object.entries(cursors || {}).map(([userId, cursor]) => {
|
||||
if (userId === myUserId)
|
||||
return null
|
||||
|
||||
const userInfo = onlineUsers.find(user => user.user_id === userId)
|
||||
const userName = userInfo?.username || `User ${userId.slice(-4)}`
|
||||
const userColor = getUserColor(userId)
|
||||
const screenPos = convertToScreenCoordinates(cursor)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={userId}
|
||||
className="pointer-events-none absolute z-[8] transition-all duration-150 ease-out"
|
||||
style={{
|
||||
left: screenPos.x,
|
||||
top: screenPos.y,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="drop-shadow-md"
|
||||
>
|
||||
<path
|
||||
d="M5 3L5 15L8 11.5L11 16L13 15L10 10.5L14 10.5L5 3Z"
|
||||
fill={userColor}
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="absolute left-4 top-4 max-w-[120px] overflow-hidden text-ellipsis whitespace-nowrap rounded px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm"
|
||||
style={{
|
||||
backgroundColor: userColor,
|
||||
}}
|
||||
>
|
||||
{userName}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserCursors
|
||||
@ -1,331 +0,0 @@
|
||||
import type { LoroMap } from 'loro-crdt'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { LoroDoc } from 'loro-crdt'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { CollaborationManager } from '../collaboration-manager'
|
||||
|
||||
const NODE_ID = 'node-1'
|
||||
const LLM_NODE_ID = 'llm-node'
|
||||
const PARAM_NODE_ID = 'parameter-node'
|
||||
|
||||
type WorkflowVariable = {
|
||||
variable: string
|
||||
label: string
|
||||
type: string
|
||||
required: boolean
|
||||
default: string
|
||||
max_length: number
|
||||
placeholder: string
|
||||
options: string[]
|
||||
hint: string
|
||||
}
|
||||
|
||||
type PromptTemplateItem = {
|
||||
id: string
|
||||
role: string
|
||||
text: string
|
||||
}
|
||||
|
||||
type ParameterItem = {
|
||||
description: string
|
||||
name: string
|
||||
required: boolean
|
||||
type: string
|
||||
}
|
||||
|
||||
type StartNodeData = {
|
||||
variables: WorkflowVariable[]
|
||||
}
|
||||
|
||||
type LLMNodeData = {
|
||||
model: {
|
||||
mode: string
|
||||
name: string
|
||||
provider: string
|
||||
completion_params: {
|
||||
temperature: number
|
||||
}
|
||||
}
|
||||
context: {
|
||||
enabled: boolean
|
||||
variable_selector: string[]
|
||||
}
|
||||
vision: {
|
||||
enabled: boolean
|
||||
}
|
||||
prompt_template: PromptTemplateItem[]
|
||||
}
|
||||
|
||||
type ParameterExtractorNodeData = {
|
||||
model: {
|
||||
mode: string
|
||||
name: string
|
||||
provider: string
|
||||
completion_params: {
|
||||
temperature: number
|
||||
}
|
||||
}
|
||||
parameters: ParameterItem[]
|
||||
query: unknown[]
|
||||
reasoning_mode: string
|
||||
vision: {
|
||||
enabled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type CollaborationManagerInternals = {
|
||||
doc: LoroDoc
|
||||
nodesMap: LoroMap
|
||||
edgesMap: LoroMap
|
||||
syncNodes: (oldNodes: Node[], newNodes: Node[]) => void
|
||||
}
|
||||
|
||||
const createNode = (variables: string[]): Node<StartNodeData> => ({
|
||||
id: NODE_ID,
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
variables: variables.map(name => ({
|
||||
variable: name,
|
||||
label: name,
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
default: '',
|
||||
max_length: 48,
|
||||
placeholder: '',
|
||||
options: [],
|
||||
hint: '',
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
const createLLMNode = (templates: PromptTemplateItem[]): Node<LLMNodeData> => ({
|
||||
id: LLM_NODE_ID,
|
||||
type: 'custom',
|
||||
position: { x: 200, y: 200 },
|
||||
data: {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM',
|
||||
desc: '',
|
||||
selected: false,
|
||||
model: {
|
||||
mode: 'chat',
|
||||
name: 'gemini-2.5-pro',
|
||||
provider: 'langgenius/gemini/google',
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
enabled: false,
|
||||
variable_selector: [],
|
||||
},
|
||||
vision: {
|
||||
enabled: false,
|
||||
},
|
||||
prompt_template: templates,
|
||||
},
|
||||
})
|
||||
|
||||
const createParameterExtractorNode = (parameters: ParameterItem[]): Node<ParameterExtractorNodeData> => ({
|
||||
id: PARAM_NODE_ID,
|
||||
type: 'custom',
|
||||
position: { x: 400, y: 120 },
|
||||
data: {
|
||||
type: BlockEnum.ParameterExtractor,
|
||||
title: 'ParameterExtractor',
|
||||
desc: '',
|
||||
selected: true,
|
||||
model: {
|
||||
mode: 'chat',
|
||||
name: '',
|
||||
provider: '',
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
},
|
||||
query: [],
|
||||
reasoning_mode: 'prompt',
|
||||
parameters,
|
||||
vision: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals =>
|
||||
manager as unknown as CollaborationManagerInternals
|
||||
|
||||
const getManager = (doc: LoroDoc) => {
|
||||
const manager = new CollaborationManager()
|
||||
const internals = getManagerInternals(manager)
|
||||
internals.doc = doc
|
||||
internals.nodesMap = doc.getMap('nodes')
|
||||
internals.edgesMap = doc.getMap('edges')
|
||||
return manager
|
||||
}
|
||||
|
||||
const deepClone = <T>(value: T): T => JSON.parse(JSON.stringify(value))
|
||||
|
||||
const syncNodes = (manager: CollaborationManager, previous: Node[], next: Node[]) => {
|
||||
const internals = getManagerInternals(manager)
|
||||
internals.syncNodes(previous, next)
|
||||
}
|
||||
|
||||
const exportNodes = (manager: CollaborationManager) => manager.getNodes()
|
||||
|
||||
describe('Loro merge behavior smoke test', () => {
|
||||
it('inspects concurrent edits after merge', () => {
|
||||
const docA = new LoroDoc()
|
||||
const managerA = getManager(docA)
|
||||
syncNodes(managerA, [], [createNode(['a'])])
|
||||
|
||||
const snapshot = docA.export({ mode: 'snapshot' })
|
||||
|
||||
const docB = LoroDoc.fromSnapshot(snapshot)
|
||||
const managerB = getManager(docB)
|
||||
|
||||
syncNodes(managerA, [createNode(['a'])], [createNode(['a', 'b'])])
|
||||
syncNodes(managerB, [createNode(['a'])], [createNode(['a', 'c'])])
|
||||
|
||||
const updateForA = docB.export({ mode: 'update', from: docA.version() })
|
||||
docA.import(updateForA)
|
||||
|
||||
const updateForB = docA.export({ mode: 'update', from: docB.version() })
|
||||
docB.import(updateForB)
|
||||
|
||||
const finalA = exportNodes(managerA)
|
||||
const finalB = exportNodes(managerB)
|
||||
expect(finalA.length).toBe(1)
|
||||
expect(finalB.length).toBe(1)
|
||||
})
|
||||
|
||||
it('merges prompt template insertions and edits across replicas', () => {
|
||||
const baseTemplate = [
|
||||
{
|
||||
id: 'system-1',
|
||||
role: 'system',
|
||||
text: 'base instruction',
|
||||
},
|
||||
]
|
||||
|
||||
const docA = new LoroDoc()
|
||||
const managerA = getManager(docA)
|
||||
syncNodes(managerA, [], [createLLMNode(deepClone(baseTemplate))])
|
||||
|
||||
const snapshot = docA.export({ mode: 'snapshot' })
|
||||
const docB = LoroDoc.fromSnapshot(snapshot)
|
||||
const managerB = getManager(docB)
|
||||
|
||||
const additionTemplate = [
|
||||
...baseTemplate,
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
text: 'hello from docA',
|
||||
},
|
||||
]
|
||||
syncNodes(managerA, [createLLMNode(deepClone(baseTemplate))], [createLLMNode(deepClone(additionTemplate))])
|
||||
|
||||
const editedTemplate = [
|
||||
{
|
||||
id: 'system-1',
|
||||
role: 'system',
|
||||
text: 'updated by docB',
|
||||
},
|
||||
]
|
||||
syncNodes(managerB, [createLLMNode(deepClone(baseTemplate))], [createLLMNode(deepClone(editedTemplate))])
|
||||
|
||||
const updateForA = docB.export({ mode: 'update', from: docA.version() })
|
||||
docA.import(updateForA)
|
||||
|
||||
const updateForB = docA.export({ mode: 'update', from: docB.version() })
|
||||
docB.import(updateForB)
|
||||
|
||||
const finalA = exportNodes(managerA).find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | undefined
|
||||
const finalB = exportNodes(managerB).find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | undefined
|
||||
|
||||
expect(finalA).toBeDefined()
|
||||
expect(finalB).toBeDefined()
|
||||
|
||||
const expectedTemplates = [
|
||||
{
|
||||
id: 'system-1',
|
||||
role: 'system',
|
||||
text: 'updated by docB',
|
||||
},
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
text: 'hello from docA',
|
||||
},
|
||||
]
|
||||
|
||||
expect(finalA!.data.prompt_template).toEqual(expectedTemplates)
|
||||
expect(finalB!.data.prompt_template).toEqual(expectedTemplates)
|
||||
})
|
||||
|
||||
it('converges when parameter lists are edited concurrently', () => {
|
||||
const baseParameters = [
|
||||
{ description: 'bb', name: 'aa', required: false, type: 'string' },
|
||||
{ description: 'dd', name: 'cc', required: false, type: 'string' },
|
||||
]
|
||||
|
||||
const docA = new LoroDoc()
|
||||
const managerA = getManager(docA)
|
||||
syncNodes(managerA, [], [createParameterExtractorNode(deepClone(baseParameters))])
|
||||
|
||||
const snapshot = docA.export({ mode: 'snapshot' })
|
||||
const docB = LoroDoc.fromSnapshot(snapshot)
|
||||
const managerB = getManager(docB)
|
||||
|
||||
const docAUpdate = [
|
||||
{ description: 'bb updated by A', name: 'aa', required: true, type: 'string' },
|
||||
{ description: 'dd', name: 'cc', required: false, type: 'string' },
|
||||
{ description: 'new from A', name: 'ee', required: false, type: 'number' },
|
||||
]
|
||||
syncNodes(
|
||||
managerA,
|
||||
[createParameterExtractorNode(deepClone(baseParameters))],
|
||||
[createParameterExtractorNode(deepClone(docAUpdate))],
|
||||
)
|
||||
|
||||
const docBUpdate = [
|
||||
{ description: 'bb', name: 'aa', required: false, type: 'string' },
|
||||
{ description: 'dd updated by B', name: 'cc', required: true, type: 'string' },
|
||||
]
|
||||
syncNodes(
|
||||
managerB,
|
||||
[createParameterExtractorNode(deepClone(baseParameters))],
|
||||
[createParameterExtractorNode(deepClone(docBUpdate))],
|
||||
)
|
||||
|
||||
const updateForA = docB.export({ mode: 'update', from: docA.version() })
|
||||
docA.import(updateForA)
|
||||
|
||||
const updateForB = docA.export({ mode: 'update', from: docB.version() })
|
||||
docB.import(updateForB)
|
||||
|
||||
const finalA = exportNodes(managerA).find(node => node.id === PARAM_NODE_ID) as
|
||||
| Node<ParameterExtractorNodeData>
|
||||
| undefined
|
||||
const finalB = exportNodes(managerB).find(node => node.id === PARAM_NODE_ID) as
|
||||
| Node<ParameterExtractorNodeData>
|
||||
| undefined
|
||||
|
||||
expect(finalA).toBeDefined()
|
||||
expect(finalB).toBeDefined()
|
||||
|
||||
const expectedParameters = [
|
||||
{ description: 'bb updated by A', name: 'aa', required: true, type: 'string' },
|
||||
{ description: 'dd updated by B', name: 'cc', required: true, type: 'string' },
|
||||
{ description: 'new from A', name: 'ee', required: false, type: 'number' },
|
||||
]
|
||||
|
||||
expect(finalA!.data.parameters).toEqual(expectedParameters)
|
||||
expect(finalB!.data.parameters).toEqual(expectedParameters)
|
||||
})
|
||||
})
|
||||
@ -1,763 +0,0 @@
|
||||
import type { LoroMap } from 'loro-crdt'
|
||||
import type {
|
||||
NodePanelPresenceMap,
|
||||
NodePanelPresenceUser,
|
||||
} from '@/app/components/workflow/collaboration/types/collaboration'
|
||||
import type { CommonNodeType, Edge, Node } from '@/app/components/workflow/types'
|
||||
import { LoroDoc } from 'loro-crdt'
|
||||
import { Position } from 'reactflow'
|
||||
import { CollaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
const NODE_ID = '1760342909316'
|
||||
|
||||
type WorkflowVariable = {
|
||||
default: string
|
||||
hint: string
|
||||
label: string
|
||||
max_length: number
|
||||
options: string[]
|
||||
placeholder: string
|
||||
required: boolean
|
||||
type: string
|
||||
variable: string
|
||||
}
|
||||
|
||||
type PromptTemplateItem = {
|
||||
id: string
|
||||
role: string
|
||||
text: string
|
||||
}
|
||||
|
||||
type ParameterItem = {
|
||||
description: string
|
||||
name: string
|
||||
required: boolean
|
||||
type: string
|
||||
}
|
||||
|
||||
type NodePanelPresenceEventData = {
|
||||
nodeId: string
|
||||
action: 'open' | 'close'
|
||||
user: NodePanelPresenceUser
|
||||
clientId: string
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
type StartNodeData = {
|
||||
variables: WorkflowVariable[]
|
||||
}
|
||||
|
||||
type LLMNodeData = {
|
||||
context: {
|
||||
enabled: boolean
|
||||
variable_selector: string[]
|
||||
}
|
||||
model: {
|
||||
mode: string
|
||||
name: string
|
||||
provider: string
|
||||
completion_params: {
|
||||
temperature: number
|
||||
}
|
||||
}
|
||||
prompt_template: PromptTemplateItem[]
|
||||
vision: {
|
||||
enabled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type ParameterExtractorNodeData = {
|
||||
model: {
|
||||
mode: string
|
||||
name: string
|
||||
provider: string
|
||||
completion_params: {
|
||||
temperature: number
|
||||
}
|
||||
}
|
||||
parameters: ParameterItem[]
|
||||
query: unknown[]
|
||||
reasoning_mode: string
|
||||
vision: {
|
||||
enabled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type LLMNodeDataWithUnknownTemplate = Omit<LLMNodeData, 'prompt_template'> & {
|
||||
prompt_template: unknown
|
||||
}
|
||||
|
||||
type ManagerDoc = LoroDoc | { commit: () => void }
|
||||
|
||||
type CollaborationManagerInternals = {
|
||||
doc: ManagerDoc
|
||||
nodesMap: LoroMap
|
||||
edgesMap: LoroMap
|
||||
syncNodes: (oldNodes: Node[], newNodes: Node[]) => void
|
||||
syncEdges: (oldEdges: Edge[], newEdges: Edge[]) => void
|
||||
applyNodePanelPresenceUpdate: (update: NodePanelPresenceEventData) => void
|
||||
forceDisconnect: () => void
|
||||
activeConnections: Set<string>
|
||||
isUndoRedoInProgress: boolean
|
||||
}
|
||||
|
||||
const createVariable = (name: string, overrides: Partial<WorkflowVariable> = {}): WorkflowVariable => ({
|
||||
default: '',
|
||||
hint: '',
|
||||
label: name,
|
||||
max_length: 48,
|
||||
options: [],
|
||||
placeholder: '',
|
||||
required: true,
|
||||
type: 'text-input',
|
||||
variable: name,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const deepClone = <T>(value: T): T => JSON.parse(JSON.stringify(value))
|
||||
|
||||
const createNodeSnapshot = (variableNames: string[]): Node<StartNodeData> => ({
|
||||
id: NODE_ID,
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 24 },
|
||||
positionAbsolute: { x: 0, y: 24 },
|
||||
height: 88,
|
||||
width: 242,
|
||||
selected: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
data: {
|
||||
selected: true,
|
||||
title: '开始',
|
||||
desc: '',
|
||||
type: BlockEnum.Start,
|
||||
variables: variableNames.map(name => createVariable(name)),
|
||||
},
|
||||
})
|
||||
|
||||
const LLM_NODE_ID = 'llm-node'
|
||||
const PARAM_NODE_ID = 'param-extractor-node'
|
||||
|
||||
const createLLMNodeSnapshot = (promptTemplates: PromptTemplateItem[]): Node<LLMNodeData> => ({
|
||||
id: LLM_NODE_ID,
|
||||
type: 'custom',
|
||||
position: { x: 200, y: 120 },
|
||||
positionAbsolute: { x: 200, y: 120 },
|
||||
height: 320,
|
||||
width: 460,
|
||||
selected: false,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
data: {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM',
|
||||
desc: '',
|
||||
selected: false,
|
||||
context: {
|
||||
enabled: false,
|
||||
variable_selector: [],
|
||||
},
|
||||
model: {
|
||||
mode: 'chat',
|
||||
name: 'gemini-2.5-pro',
|
||||
provider: 'langgenius/gemini/google',
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
},
|
||||
vision: {
|
||||
enabled: false,
|
||||
},
|
||||
prompt_template: promptTemplates,
|
||||
},
|
||||
})
|
||||
|
||||
const createParameterExtractorNodeSnapshot = (parameters: ParameterItem[]): Node<ParameterExtractorNodeData> => ({
|
||||
id: PARAM_NODE_ID,
|
||||
type: 'custom',
|
||||
position: { x: 420, y: 220 },
|
||||
positionAbsolute: { x: 420, y: 220 },
|
||||
height: 260,
|
||||
width: 420,
|
||||
selected: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
data: {
|
||||
type: BlockEnum.ParameterExtractor,
|
||||
title: '参数提取器',
|
||||
desc: '',
|
||||
selected: true,
|
||||
model: {
|
||||
mode: 'chat',
|
||||
name: '',
|
||||
provider: '',
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
},
|
||||
reasoning_mode: 'prompt',
|
||||
parameters,
|
||||
query: [],
|
||||
vision: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const getVariables = (node: Node): string[] => {
|
||||
const data = node.data as CommonNodeType<{ variables?: WorkflowVariable[] }>
|
||||
const variables = data.variables ?? []
|
||||
return variables.map(item => item.variable)
|
||||
}
|
||||
|
||||
const getVariableObject = (node: Node, name: string): WorkflowVariable | undefined => {
|
||||
const data = node.data as CommonNodeType<{ variables?: WorkflowVariable[] }>
|
||||
const variables = data.variables ?? []
|
||||
return variables.find(item => item.variable === name)
|
||||
}
|
||||
|
||||
const getPromptTemplates = (node: Node): PromptTemplateItem[] => {
|
||||
const data = node.data as CommonNodeType<{ prompt_template?: PromptTemplateItem[] }>
|
||||
return data.prompt_template ?? []
|
||||
}
|
||||
|
||||
const getParameters = (node: Node): ParameterItem[] => {
|
||||
const data = node.data as CommonNodeType<{ parameters?: ParameterItem[] }>
|
||||
return data.parameters ?? []
|
||||
}
|
||||
|
||||
const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals =>
|
||||
manager as unknown as CollaborationManagerInternals
|
||||
|
||||
const setupManager = (): { manager: CollaborationManager, internals: CollaborationManagerInternals } => {
|
||||
const manager = new CollaborationManager()
|
||||
const doc = new LoroDoc()
|
||||
const internals = getManagerInternals(manager)
|
||||
internals.doc = doc
|
||||
internals.nodesMap = doc.getMap('nodes')
|
||||
internals.edgesMap = doc.getMap('edges')
|
||||
return { manager, internals }
|
||||
}
|
||||
|
||||
describe('CollaborationManager syncNodes', () => {
|
||||
let manager: CollaborationManager
|
||||
let internals: CollaborationManagerInternals
|
||||
|
||||
beforeEach(() => {
|
||||
const setup = setupManager()
|
||||
manager = setup.manager
|
||||
internals = setup.internals
|
||||
|
||||
const initialNode = createNodeSnapshot(['a'])
|
||||
internals.syncNodes([], [deepClone(initialNode)])
|
||||
})
|
||||
|
||||
it('updates collaborators map when a single client adds a variable', () => {
|
||||
const base = [createNodeSnapshot(['a'])]
|
||||
const next = [createNodeSnapshot(['a', 'b'])]
|
||||
|
||||
internals.syncNodes(base, next)
|
||||
|
||||
const stored = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
|
||||
expect(stored).toBeDefined()
|
||||
expect(getVariables(stored!)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('applies the latest parallel additions derived from the same base snapshot', () => {
|
||||
const base = [createNodeSnapshot(['a'])]
|
||||
const userA = [createNodeSnapshot(['a', 'b'])]
|
||||
const userB = [createNodeSnapshot(['a', 'c'])]
|
||||
|
||||
internals.syncNodes(base, userA)
|
||||
|
||||
const afterUserA = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
|
||||
expect(getVariables(afterUserA!)).toEqual(['a', 'b'])
|
||||
|
||||
internals.syncNodes(base, userB)
|
||||
|
||||
const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
|
||||
const finalVariables = getVariables(finalNode!)
|
||||
|
||||
expect(finalVariables).toEqual(['a', 'c'])
|
||||
})
|
||||
|
||||
it('prefers the incoming mutation when the same variable is edited concurrently', () => {
|
||||
const base = [createNodeSnapshot(['a'])]
|
||||
const userA = [
|
||||
{
|
||||
...createNodeSnapshot(['a']),
|
||||
data: {
|
||||
...createNodeSnapshot(['a']).data,
|
||||
variables: [
|
||||
createVariable('a', { label: 'A from userA', hint: 'hintA' }),
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
const userB = [
|
||||
{
|
||||
...createNodeSnapshot(['a']),
|
||||
data: {
|
||||
...createNodeSnapshot(['a']).data,
|
||||
variables: [
|
||||
createVariable('a', { label: 'A from userB', hint: 'hintB' }),
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
internals.syncNodes(base, userA)
|
||||
internals.syncNodes(base, userB)
|
||||
|
||||
const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
|
||||
const finalVariable = getVariableObject(finalNode!, 'a')
|
||||
|
||||
expect(finalVariable?.label).toBe('A from userB')
|
||||
expect(finalVariable?.hint).toBe('hintB')
|
||||
})
|
||||
|
||||
it('reflects the last writer when concurrent removal and edits happen', () => {
|
||||
const base = [createNodeSnapshot(['a', 'b'])]
|
||||
internals.syncNodes([], [deepClone(base[0])])
|
||||
const userA = [
|
||||
{
|
||||
...createNodeSnapshot(['a']),
|
||||
data: {
|
||||
...createNodeSnapshot(['a']).data,
|
||||
variables: [
|
||||
createVariable('a', { label: 'A after deletion' }),
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
const userB = [
|
||||
{
|
||||
...createNodeSnapshot(['a', 'b']),
|
||||
data: {
|
||||
...createNodeSnapshot(['a']).data,
|
||||
variables: [
|
||||
createVariable('a'),
|
||||
createVariable('b', { label: 'B edited but should vanish' }),
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
internals.syncNodes(base, userA)
|
||||
internals.syncNodes(base, userB)
|
||||
|
||||
const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
|
||||
const finalVariables = getVariables(finalNode!)
|
||||
expect(finalVariables).toEqual(['a', 'b'])
|
||||
expect(getVariableObject(finalNode!, 'b')).toBeDefined()
|
||||
})
|
||||
|
||||
it('synchronizes prompt_template list updates across collaborators', () => {
|
||||
const { manager: promptManager, internals: promptInternals } = setupManager()
|
||||
|
||||
const baseTemplate = [
|
||||
{
|
||||
id: 'abcfa5f9-3c44-4252-aeba-4b6eaf0acfc4',
|
||||
role: 'system',
|
||||
text: 'avc',
|
||||
},
|
||||
]
|
||||
|
||||
const baseNode = createLLMNodeSnapshot(baseTemplate)
|
||||
promptInternals.syncNodes([], [deepClone(baseNode)])
|
||||
|
||||
const updatedTemplates = [
|
||||
...baseTemplate,
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
text: 'hello world',
|
||||
},
|
||||
]
|
||||
|
||||
const updatedNode = createLLMNodeSnapshot(updatedTemplates)
|
||||
promptInternals.syncNodes([deepClone(baseNode)], [deepClone(updatedNode)])
|
||||
|
||||
const stored = (promptManager.getNodes() as Node[]).find(node => node.id === LLM_NODE_ID)
|
||||
expect(stored).toBeDefined()
|
||||
|
||||
const storedTemplates = getPromptTemplates(stored!)
|
||||
expect(storedTemplates).toHaveLength(2)
|
||||
expect(storedTemplates[0]).toEqual(baseTemplate[0])
|
||||
expect(storedTemplates[1]).toEqual(updatedTemplates[1])
|
||||
|
||||
const editedTemplates = [
|
||||
{
|
||||
id: 'abcfa5f9-3c44-4252-aeba-4b6eaf0acfc4',
|
||||
role: 'system',
|
||||
text: 'updated system prompt',
|
||||
},
|
||||
]
|
||||
const editedNode = createLLMNodeSnapshot(editedTemplates)
|
||||
|
||||
promptInternals.syncNodes([deepClone(updatedNode)], [deepClone(editedNode)])
|
||||
|
||||
const final = (promptManager.getNodes() as Node[]).find(node => node.id === LLM_NODE_ID)
|
||||
const finalTemplates = getPromptTemplates(final!)
|
||||
expect(finalTemplates).toHaveLength(1)
|
||||
expect(finalTemplates[0].text).toBe('updated system prompt')
|
||||
})
|
||||
|
||||
it('keeps parameter list in sync when nodes add, edit, or remove parameters', () => {
|
||||
const { manager: parameterManager, internals: parameterInternals } = setupManager()
|
||||
|
||||
const baseParameters: ParameterItem[] = [
|
||||
{ description: 'bb', name: 'aa', required: false, type: 'string' },
|
||||
{ description: 'dd', name: 'cc', required: false, type: 'string' },
|
||||
]
|
||||
|
||||
const baseNode = createParameterExtractorNodeSnapshot(baseParameters)
|
||||
parameterInternals.syncNodes([], [deepClone(baseNode)])
|
||||
|
||||
const updatedParameters: ParameterItem[] = [
|
||||
...baseParameters,
|
||||
{ description: 'ff', name: 'ee', required: true, type: 'number' },
|
||||
]
|
||||
|
||||
const updatedNode = createParameterExtractorNodeSnapshot(updatedParameters)
|
||||
parameterInternals.syncNodes([deepClone(baseNode)], [deepClone(updatedNode)])
|
||||
|
||||
const stored = (parameterManager.getNodes() as Node[]).find(node => node.id === PARAM_NODE_ID)
|
||||
expect(stored).toBeDefined()
|
||||
expect(getParameters(stored!)).toEqual(updatedParameters)
|
||||
|
||||
const editedParameters: ParameterItem[] = [
|
||||
{ description: 'bb edited', name: 'aa', required: true, type: 'string' },
|
||||
]
|
||||
const editedNode = createParameterExtractorNodeSnapshot(editedParameters)
|
||||
|
||||
parameterInternals.syncNodes([deepClone(updatedNode)], [deepClone(editedNode)])
|
||||
|
||||
const final = (parameterManager.getNodes() as Node[]).find(node => node.id === PARAM_NODE_ID)
|
||||
expect(getParameters(final!)).toEqual(editedParameters)
|
||||
})
|
||||
|
||||
it('handles nodes without data gracefully', () => {
|
||||
const emptyNode: Node = {
|
||||
id: 'empty-node',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: undefined as unknown as CommonNodeType<Record<string, never>>,
|
||||
}
|
||||
|
||||
internals.syncNodes([], [deepClone(emptyNode)])
|
||||
|
||||
const stored = (manager.getNodes() as Node[]).find(node => node.id === 'empty-node')
|
||||
expect(stored).toBeDefined()
|
||||
expect(stored?.data).toEqual({})
|
||||
})
|
||||
|
||||
it('preserves CRDT list instances when synchronizing parsed state back into the manager', () => {
|
||||
const { manager: promptManager, internals: promptInternals } = setupManager()
|
||||
|
||||
const base = createLLMNodeSnapshot([
|
||||
{ id: 'system', role: 'system', text: 'base' },
|
||||
])
|
||||
promptInternals.syncNodes([], [deepClone(base)])
|
||||
|
||||
const storedBefore = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | undefined
|
||||
expect(storedBefore).toBeDefined()
|
||||
const firstTemplate = storedBefore?.data.prompt_template?.[0]
|
||||
expect(firstTemplate?.text).toBe('base')
|
||||
|
||||
// simulate consumer mutating the plain JSON array and syncing back
|
||||
const baseNode = storedBefore!
|
||||
const mutatedNode = deepClone(baseNode)
|
||||
mutatedNode.data.prompt_template.push({
|
||||
id: 'user',
|
||||
role: 'user',
|
||||
text: 'mutated',
|
||||
})
|
||||
|
||||
promptInternals.syncNodes([baseNode], [mutatedNode])
|
||||
|
||||
const storedAfter = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | undefined
|
||||
const templatesAfter = storedAfter?.data.prompt_template
|
||||
expect(Array.isArray(templatesAfter)).toBe(true)
|
||||
expect(templatesAfter).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reuses CRDT list when syncing parameters repeatedly', () => {
|
||||
const { manager: parameterManager, internals: parameterInternals } = setupManager()
|
||||
|
||||
const initialParameters: ParameterItem[] = [
|
||||
{ description: 'desc', name: 'param', required: false, type: 'string' },
|
||||
]
|
||||
const node = createParameterExtractorNodeSnapshot(initialParameters)
|
||||
parameterInternals.syncNodes([], [deepClone(node)])
|
||||
|
||||
const stored = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID) as Node<ParameterExtractorNodeData>
|
||||
const mutatedNode = deepClone(stored)
|
||||
mutatedNode.data.parameters[0].description = 'updated'
|
||||
|
||||
parameterInternals.syncNodes([stored], [mutatedNode])
|
||||
|
||||
const storedAfter = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID) as
|
||||
| Node<ParameterExtractorNodeData>
|
||||
| undefined
|
||||
const params = storedAfter?.data.parameters ?? []
|
||||
expect(params).toHaveLength(1)
|
||||
expect(params[0].description).toBe('updated')
|
||||
})
|
||||
|
||||
it('filters out transient/private data keys while keeping allowlisted ones', () => {
|
||||
const nodeWithPrivate: Node<{ _foo: string, variables: WorkflowVariable[] }> = {
|
||||
id: 'private-node',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: 'private',
|
||||
desc: '',
|
||||
_foo: 'should disappear',
|
||||
_children: [{ nodeId: 'child-a', nodeType: BlockEnum.Start }],
|
||||
selected: true,
|
||||
variables: [],
|
||||
},
|
||||
}
|
||||
|
||||
internals.syncNodes([], [deepClone(nodeWithPrivate)])
|
||||
|
||||
const stored = (manager.getNodes() as Node[]).find(node => node.id === 'private-node')!
|
||||
const storedData = stored.data as CommonNodeType<{ _foo?: string }>
|
||||
expect(storedData._foo).toBeUndefined()
|
||||
expect(storedData._children).toEqual([{ nodeId: 'child-a', nodeType: BlockEnum.Start }])
|
||||
expect(storedData.selected).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes list fields when they are omitted in the update snapshot', () => {
|
||||
const baseNode = createNodeSnapshot(['alpha'])
|
||||
internals.syncNodes([], [deepClone(baseNode)])
|
||||
|
||||
const withoutVariables: Node<StartNodeData> = {
|
||||
...deepClone(baseNode),
|
||||
data: {
|
||||
...deepClone(baseNode).data,
|
||||
},
|
||||
}
|
||||
delete (withoutVariables.data as CommonNodeType<{ variables?: WorkflowVariable[] }>).variables
|
||||
|
||||
internals.syncNodes([deepClone(baseNode)], [withoutVariables])
|
||||
|
||||
const stored = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)!
|
||||
const storedData = stored.data as CommonNodeType<{ variables?: WorkflowVariable[] }>
|
||||
expect(storedData.variables).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats non-array list inputs as empty lists during synchronization', () => {
|
||||
const { manager: promptManager, internals: promptInternals } = setupManager()
|
||||
|
||||
const nodeWithInvalidTemplate = createLLMNodeSnapshot([])
|
||||
promptInternals.syncNodes([], [deepClone(nodeWithInvalidTemplate)])
|
||||
|
||||
const mutated = deepClone(nodeWithInvalidTemplate) as Node<LLMNodeDataWithUnknownTemplate>
|
||||
mutated.data.prompt_template = 'not-an-array'
|
||||
|
||||
promptInternals.syncNodes([deepClone(nodeWithInvalidTemplate)], [mutated])
|
||||
|
||||
const stored = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData>
|
||||
expect(Array.isArray(stored.data.prompt_template)).toBe(true)
|
||||
expect(stored.data.prompt_template).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('updates edges map when edges are added, modified, and removed', () => {
|
||||
const { manager: edgeManager } = setupManager()
|
||||
|
||||
const edge: Edge = {
|
||||
id: 'edge-1',
|
||||
source: 'node-a',
|
||||
target: 'node-b',
|
||||
type: 'default',
|
||||
data: {
|
||||
sourceType: BlockEnum.Start,
|
||||
targetType: BlockEnum.LLM,
|
||||
_waitingRun: false,
|
||||
},
|
||||
}
|
||||
|
||||
edgeManager.setEdges([], [edge])
|
||||
expect(edgeManager.getEdges()).toHaveLength(1)
|
||||
const storedEdge = edgeManager.getEdges()[0]!
|
||||
expect(storedEdge.data).toBeDefined()
|
||||
expect(storedEdge.data!._waitingRun).toBe(false)
|
||||
|
||||
const updatedEdge: Edge = {
|
||||
...edge,
|
||||
data: {
|
||||
sourceType: BlockEnum.Start,
|
||||
targetType: BlockEnum.LLM,
|
||||
_waitingRun: true,
|
||||
},
|
||||
}
|
||||
edgeManager.setEdges([edge], [updatedEdge])
|
||||
expect(edgeManager.getEdges()).toHaveLength(1)
|
||||
const updatedStoredEdge = edgeManager.getEdges()[0]!
|
||||
expect(updatedStoredEdge.data).toBeDefined()
|
||||
expect(updatedStoredEdge.data!._waitingRun).toBe(true)
|
||||
|
||||
edgeManager.setEdges([updatedEdge], [])
|
||||
expect(edgeManager.getEdges()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CollaborationManager public API wrappers', () => {
|
||||
let manager: CollaborationManager
|
||||
let internals: CollaborationManagerInternals
|
||||
const baseNodes: Node[] = []
|
||||
const updatedNodes: Node[] = [
|
||||
{
|
||||
id: 'new-node',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: 'New node',
|
||||
desc: '',
|
||||
},
|
||||
},
|
||||
]
|
||||
const baseEdges: Edge[] = []
|
||||
const updatedEdges: Edge[] = [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'source',
|
||||
target: 'target',
|
||||
type: 'default',
|
||||
data: {
|
||||
sourceType: BlockEnum.Start,
|
||||
targetType: BlockEnum.End,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new CollaborationManager()
|
||||
internals = getManagerInternals(manager)
|
||||
})
|
||||
|
||||
it('setNodes delegates to syncNodes and commits the CRDT document', () => {
|
||||
const commit = vi.fn()
|
||||
internals.doc = { commit }
|
||||
const syncSpy = vi.spyOn(internals, 'syncNodes').mockImplementation(() => undefined)
|
||||
|
||||
manager.setNodes(baseNodes, updatedNodes)
|
||||
|
||||
expect(syncSpy).toHaveBeenCalledWith(baseNodes, updatedNodes)
|
||||
expect(commit).toHaveBeenCalled()
|
||||
syncSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('setNodes skips syncing when undo/redo replay is running', () => {
|
||||
const commit = vi.fn()
|
||||
internals.doc = { commit }
|
||||
internals.isUndoRedoInProgress = true
|
||||
const syncSpy = vi.spyOn(internals, 'syncNodes').mockImplementation(() => undefined)
|
||||
|
||||
manager.setNodes(baseNodes, updatedNodes)
|
||||
|
||||
expect(syncSpy).not.toHaveBeenCalled()
|
||||
expect(commit).not.toHaveBeenCalled()
|
||||
syncSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('setEdges delegates to syncEdges and commits the CRDT document', () => {
|
||||
const commit = vi.fn()
|
||||
internals.doc = { commit }
|
||||
const syncSpy = vi.spyOn(internals, 'syncEdges').mockImplementation(() => undefined)
|
||||
|
||||
manager.setEdges(baseEdges, updatedEdges)
|
||||
|
||||
expect(syncSpy).toHaveBeenCalledWith(baseEdges, updatedEdges)
|
||||
expect(commit).toHaveBeenCalled()
|
||||
syncSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('disconnect tears down the collaboration state only when last connection closes', () => {
|
||||
const forceSpy = vi.spyOn(internals, 'forceDisconnect').mockImplementation(() => undefined)
|
||||
internals.activeConnections.add('conn-a')
|
||||
internals.activeConnections.add('conn-b')
|
||||
|
||||
manager.disconnect('conn-a')
|
||||
expect(forceSpy).not.toHaveBeenCalled()
|
||||
|
||||
manager.disconnect('conn-b')
|
||||
expect(forceSpy).toHaveBeenCalledTimes(1)
|
||||
forceSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('applyNodePanelPresenceUpdate keeps a client visible on a single node at a time', () => {
|
||||
const updates: NodePanelPresenceMap[] = []
|
||||
manager.onNodePanelPresenceUpdate((presence) => {
|
||||
updates.push(presence)
|
||||
})
|
||||
|
||||
const user: NodePanelPresenceUser = { userId: 'user-1', username: 'Dana' }
|
||||
|
||||
internals.applyNodePanelPresenceUpdate({
|
||||
nodeId: 'node-a',
|
||||
action: 'open',
|
||||
user,
|
||||
clientId: 'client-1',
|
||||
timestamp: 100,
|
||||
})
|
||||
|
||||
internals.applyNodePanelPresenceUpdate({
|
||||
nodeId: 'node-b',
|
||||
action: 'open',
|
||||
user,
|
||||
clientId: 'client-1',
|
||||
timestamp: 200,
|
||||
})
|
||||
|
||||
const finalSnapshot = updates[updates.length - 1]!
|
||||
expect(finalSnapshot).toEqual({
|
||||
'node-b': {
|
||||
'client-1': {
|
||||
userId: 'user-1',
|
||||
username: 'Dana',
|
||||
clientId: 'client-1',
|
||||
timestamp: 200,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('applyNodePanelPresenceUpdate clears node entries when last viewer closes the panel', () => {
|
||||
const updates: NodePanelPresenceMap[] = []
|
||||
manager.onNodePanelPresenceUpdate((presence) => {
|
||||
updates.push(presence)
|
||||
})
|
||||
|
||||
const user: NodePanelPresenceUser = { userId: 'user-2', username: 'Kai' }
|
||||
|
||||
internals.applyNodePanelPresenceUpdate({
|
||||
nodeId: 'node-a',
|
||||
action: 'open',
|
||||
user,
|
||||
clientId: 'client-9',
|
||||
timestamp: 300,
|
||||
})
|
||||
|
||||
internals.applyNodePanelPresenceUpdate({
|
||||
nodeId: 'node-a',
|
||||
action: 'close',
|
||||
user,
|
||||
clientId: 'client-9',
|
||||
timestamp: 301,
|
||||
})
|
||||
|
||||
expect(updates[updates.length - 1]).toEqual({})
|
||||
})
|
||||
})
|
||||
@ -1,138 +0,0 @@
|
||||
import type { LoroDoc } from 'loro-crdt'
|
||||
import type { Socket } from 'socket.io-client'
|
||||
import { CRDTProvider } from '../crdt-provider'
|
||||
|
||||
type FakeDocEvent = {
|
||||
by: string
|
||||
}
|
||||
|
||||
type FakeDoc = {
|
||||
export: ReturnType<typeof vi.fn>
|
||||
import: ReturnType<typeof vi.fn>
|
||||
subscribe: ReturnType<typeof vi.fn>
|
||||
trigger: (event: FakeDocEvent) => void
|
||||
}
|
||||
|
||||
const createFakeDoc = (): FakeDoc => {
|
||||
let handler: ((payload: FakeDocEvent) => void) | null = null
|
||||
|
||||
const exportFn = vi.fn(() => new Uint8Array([1, 2, 3]))
|
||||
const importFn = vi.fn()
|
||||
const subscribeFn = vi.fn((cb: (payload: FakeDocEvent) => void) => {
|
||||
handler = cb
|
||||
})
|
||||
|
||||
return {
|
||||
export: exportFn,
|
||||
import: importFn,
|
||||
subscribe: subscribeFn,
|
||||
trigger: (event: FakeDocEvent) => {
|
||||
handler?.(event)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type MockSocket = {
|
||||
trigger: (event: string, ...args: unknown[]) => void
|
||||
emit: ReturnType<typeof vi.fn>
|
||||
on: ReturnType<typeof vi.fn>
|
||||
off: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const createMockSocket = (): MockSocket => {
|
||||
const handlers = new Map<string, (...args: unknown[]) => void>()
|
||||
|
||||
const socket: MockSocket = {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, handler)
|
||||
}),
|
||||
off: vi.fn((event: string) => {
|
||||
handlers.delete(event)
|
||||
}),
|
||||
trigger: (event: string, ...args: unknown[]) => {
|
||||
const handler = handlers.get(event)
|
||||
if (handler)
|
||||
handler(...args)
|
||||
},
|
||||
}
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
describe('CRDTProvider', () => {
|
||||
it('emits graph_event when local changes happen', () => {
|
||||
const doc = createFakeDoc()
|
||||
const socket = createMockSocket()
|
||||
|
||||
const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc)
|
||||
expect(provider).toBeInstanceOf(CRDTProvider)
|
||||
|
||||
doc.trigger({ by: 'local' })
|
||||
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'graph_event',
|
||||
expect.any(Uint8Array),
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(doc.export).toHaveBeenCalledWith({ mode: 'update' })
|
||||
})
|
||||
|
||||
it('ignores non-local events', () => {
|
||||
const doc = createFakeDoc()
|
||||
const socket = createMockSocket()
|
||||
|
||||
const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc)
|
||||
|
||||
doc.trigger({ by: 'remote' })
|
||||
|
||||
expect(socket.emit).not.toHaveBeenCalled()
|
||||
provider.destroy()
|
||||
})
|
||||
|
||||
it('imports remote updates on graph_update', () => {
|
||||
const doc = createFakeDoc()
|
||||
const socket = createMockSocket()
|
||||
|
||||
const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc)
|
||||
|
||||
const payload = new Uint8Array([9, 9, 9])
|
||||
socket.trigger('graph_update', payload)
|
||||
|
||||
expect(doc.import).toHaveBeenCalledWith(expect.any(Uint8Array))
|
||||
expect(Array.from(doc.import.mock.calls[0][0])).toEqual([9, 9, 9])
|
||||
provider.destroy()
|
||||
})
|
||||
|
||||
it('removes graph_update listener on destroy', () => {
|
||||
const doc = createFakeDoc()
|
||||
const socket = createMockSocket()
|
||||
|
||||
const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc)
|
||||
provider.destroy()
|
||||
|
||||
expect(socket.off).toHaveBeenCalledWith('graph_update')
|
||||
})
|
||||
|
||||
it('logs an error when graph_update import fails but continues operating', () => {
|
||||
const doc = createFakeDoc()
|
||||
const socket = createMockSocket()
|
||||
doc.import.mockImplementation(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
|
||||
const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc)
|
||||
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
socket.trigger('graph_update', new Uint8Array([1]))
|
||||
expect(errorSpy).toHaveBeenCalledWith('Error importing graph update:', expect.any(Error))
|
||||
|
||||
doc.import.mockReset()
|
||||
socket.trigger('graph_update', new Uint8Array([2, 3]))
|
||||
expect(doc.import).toHaveBeenCalled()
|
||||
|
||||
provider.destroy()
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@ -1,93 +0,0 @@
|
||||
import { EventEmitter } from '../event-emitter'
|
||||
|
||||
describe('EventEmitter', () => {
|
||||
it('registers and invokes handlers via on/emit', () => {
|
||||
const emitter = new EventEmitter()
|
||||
const handler = vi.fn()
|
||||
|
||||
emitter.on('test', handler)
|
||||
emitter.emit('test', { value: 42 })
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ value: 42 })
|
||||
})
|
||||
|
||||
it('removes specific handler with off', () => {
|
||||
const emitter = new EventEmitter()
|
||||
const handlerA = vi.fn()
|
||||
const handlerB = vi.fn()
|
||||
|
||||
emitter.on('test', handlerA)
|
||||
emitter.on('test', handlerB)
|
||||
|
||||
emitter.off('test', handlerA)
|
||||
emitter.emit('test', 'payload')
|
||||
|
||||
expect(handlerA).not.toHaveBeenCalled()
|
||||
expect(handlerB).toHaveBeenCalledWith('payload')
|
||||
})
|
||||
|
||||
it('clears all listeners when off is called without handler', () => {
|
||||
const emitter = new EventEmitter()
|
||||
const handlerA = vi.fn()
|
||||
const handlerB = vi.fn()
|
||||
|
||||
emitter.on('trigger', handlerA)
|
||||
emitter.on('trigger', handlerB)
|
||||
|
||||
emitter.off('trigger')
|
||||
emitter.emit('trigger', 'payload')
|
||||
|
||||
expect(handlerA).not.toHaveBeenCalled()
|
||||
expect(handlerB).not.toHaveBeenCalled()
|
||||
expect(emitter.getListenerCount('trigger')).toBe(0)
|
||||
})
|
||||
|
||||
it('removeAllListeners clears every registered event', () => {
|
||||
const emitter = new EventEmitter()
|
||||
emitter.on('one', vi.fn())
|
||||
emitter.on('two', vi.fn())
|
||||
|
||||
emitter.removeAllListeners()
|
||||
|
||||
expect(emitter.getListenerCount('one')).toBe(0)
|
||||
expect(emitter.getListenerCount('two')).toBe(0)
|
||||
})
|
||||
|
||||
it('returns an unsubscribe function from on', () => {
|
||||
const emitter = new EventEmitter()
|
||||
const handler = vi.fn()
|
||||
|
||||
const unsubscribe = emitter.on('detach', handler)
|
||||
unsubscribe()
|
||||
|
||||
emitter.emit('detach', 'value')
|
||||
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues emitting when a handler throws', () => {
|
||||
const emitter = new EventEmitter()
|
||||
const errorHandler = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const failingHandler = vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
const succeedingHandler = vi.fn()
|
||||
|
||||
emitter.on('safe', failingHandler)
|
||||
emitter.on('safe', succeedingHandler)
|
||||
|
||||
emitter.emit('safe', 7)
|
||||
|
||||
expect(failingHandler).toHaveBeenCalledWith(7)
|
||||
expect(succeedingHandler).toHaveBeenCalledWith(7)
|
||||
expect(errorHandler).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Error in event handler for safe:'),
|
||||
expect.any(Error),
|
||||
)
|
||||
|
||||
errorHandler.mockRestore()
|
||||
})
|
||||
})
|
||||
@ -1,161 +0,0 @@
|
||||
type MockSocket = {
|
||||
trigger: (event: string, ...args: unknown[]) => void
|
||||
emit: ReturnType<typeof vi.fn>
|
||||
on: ReturnType<typeof vi.fn>
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
type IoOptions = {
|
||||
auth?: unknown
|
||||
path?: string
|
||||
transports?: string[]
|
||||
withCredentials?: boolean
|
||||
}
|
||||
|
||||
const ioMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('socket.io-client', () => ({
|
||||
io: (...args: Parameters<typeof ioMock>) => ioMock(...args),
|
||||
}))
|
||||
|
||||
const createMockSocket = (id: string): MockSocket => {
|
||||
const handlers = new Map<string, (...args: unknown[]) => void>()
|
||||
|
||||
const socket: MockSocket & { id: string } = {
|
||||
id,
|
||||
connected: true,
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(() => {
|
||||
socket.connected = false
|
||||
}),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, handler)
|
||||
}),
|
||||
trigger: (event: string, ...args: unknown[]) => {
|
||||
const handler = handlers.get(event)
|
||||
if (handler)
|
||||
handler(...args)
|
||||
},
|
||||
}
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
describe('WebSocketClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
ioMock.mockReset()
|
||||
})
|
||||
|
||||
it('connects with default url and registers base listeners', async () => {
|
||||
const mockSocket = createMockSocket('socket-fallback')
|
||||
ioMock.mockImplementation(() => mockSocket)
|
||||
|
||||
const { WebSocketClient } = await import('../websocket-manager')
|
||||
const client = new WebSocketClient()
|
||||
const socket = client.connect('app-1')
|
||||
|
||||
expect(ioMock).toHaveBeenCalledWith(
|
||||
'ws://localhost:5001',
|
||||
expect.objectContaining({
|
||||
path: '/socket.io',
|
||||
transports: ['websocket'],
|
||||
withCredentials: true,
|
||||
}),
|
||||
)
|
||||
expect(socket).toBe(mockSocket)
|
||||
expect(mockSocket.on).toHaveBeenCalledWith('connect', expect.any(Function))
|
||||
expect(mockSocket.on).toHaveBeenCalledWith('disconnect', expect.any(Function))
|
||||
expect(mockSocket.on).toHaveBeenCalledWith('connect_error', expect.any(Function))
|
||||
})
|
||||
|
||||
it('reuses existing connected socket and avoids duplicate connections', async () => {
|
||||
const mockSocket = createMockSocket('socket-reuse')
|
||||
ioMock.mockImplementation(() => mockSocket)
|
||||
|
||||
const { WebSocketClient } = await import('../websocket-manager')
|
||||
const client = new WebSocketClient()
|
||||
|
||||
const first = client.connect('app-reuse')
|
||||
const second = client.connect('app-reuse')
|
||||
|
||||
expect(ioMock).toHaveBeenCalledTimes(1)
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('emits user_connect on connect without auth payload', async () => {
|
||||
const mockSocket = createMockSocket('socket-auth')
|
||||
ioMock.mockImplementation((url: string, options: IoOptions) => {
|
||||
expect(options.auth).toBeUndefined()
|
||||
return mockSocket
|
||||
})
|
||||
|
||||
const { WebSocketClient } = await import('../websocket-manager')
|
||||
const client = new WebSocketClient()
|
||||
client.connect('app-auth')
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1] as () => void
|
||||
expect(connectHandler).toBeDefined()
|
||||
connectHandler()
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith(
|
||||
'user_connect',
|
||||
{ workflow_id: 'app-auth' },
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
|
||||
it('disconnects a specific app and clears internal maps', async () => {
|
||||
const mockSocket = createMockSocket('socket-disconnect-one')
|
||||
ioMock.mockImplementation(() => mockSocket)
|
||||
|
||||
const { WebSocketClient } = await import('../websocket-manager')
|
||||
const client = new WebSocketClient()
|
||||
client.connect('app-disconnect')
|
||||
|
||||
expect(client.isConnected('app-disconnect')).toBe(true)
|
||||
client.disconnect('app-disconnect')
|
||||
|
||||
expect(mockSocket.disconnect).toHaveBeenCalled()
|
||||
expect(client.getSocket('app-disconnect')).toBeNull()
|
||||
expect(client.isConnected('app-disconnect')).toBe(false)
|
||||
})
|
||||
|
||||
it('disconnects all apps when no id is provided', async () => {
|
||||
const socketA = createMockSocket('socket-a')
|
||||
const socketB = createMockSocket('socket-b')
|
||||
ioMock.mockImplementationOnce(() => socketA).mockImplementationOnce(() => socketB)
|
||||
|
||||
const { WebSocketClient } = await import('../websocket-manager')
|
||||
const client = new WebSocketClient()
|
||||
client.connect('app-a')
|
||||
client.connect('app-b')
|
||||
|
||||
client.disconnect()
|
||||
|
||||
expect(socketA.disconnect).toHaveBeenCalled()
|
||||
expect(socketB.disconnect).toHaveBeenCalled()
|
||||
expect(client.getConnectedApps()).toEqual([])
|
||||
})
|
||||
|
||||
it('reports connected apps, sockets, and debug info correctly', async () => {
|
||||
const socketA = createMockSocket('socket-debug-a')
|
||||
const socketB = createMockSocket('socket-debug-b')
|
||||
socketB.connected = false
|
||||
ioMock.mockImplementationOnce(() => socketA).mockImplementationOnce(() => socketB)
|
||||
|
||||
const { WebSocketClient } = await import('../websocket-manager')
|
||||
const client = new WebSocketClient()
|
||||
client.connect('app-a')
|
||||
client.connect('app-b')
|
||||
|
||||
expect(client.getConnectedApps()).toEqual(['app-a'])
|
||||
|
||||
const debugInfo = client.getDebugInfo()
|
||||
expect(debugInfo).toMatchObject({
|
||||
'app-a': { connected: true, socketId: 'socket-debug-a' },
|
||||
'app-b': { connected: false, socketId: 'socket-debug-b' },
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,39 +0,0 @@
|
||||
import type { LoroDoc } from 'loro-crdt'
|
||||
import type { Socket } from 'socket.io-client'
|
||||
import { emitWithAuthGuard } from './websocket-manager'
|
||||
|
||||
export class CRDTProvider {
|
||||
private doc: LoroDoc
|
||||
private socket: Socket
|
||||
private onUnauthorized?: () => void
|
||||
|
||||
constructor(socket: Socket, doc: LoroDoc, onUnauthorized?: () => void) {
|
||||
this.socket = socket
|
||||
this.doc = doc
|
||||
this.onUnauthorized = onUnauthorized
|
||||
this.setupEventListeners()
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
this.doc.subscribe((event: { by?: string }) => {
|
||||
if (event.by === 'local') {
|
||||
const update = this.doc.export({ mode: 'update' })
|
||||
emitWithAuthGuard(this.socket, 'graph_event', update, { onUnauthorized: this.onUnauthorized })
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('graph_update', (updateData: Uint8Array) => {
|
||||
try {
|
||||
const data = new Uint8Array(updateData)
|
||||
this.doc.import(data)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error importing graph update:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.socket.off('graph_update')
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
export type EventHandler<T = unknown> = (data: T) => void
|
||||
|
||||
export class EventEmitter {
|
||||
private events: Map<string, Set<EventHandler<unknown>>> = new Map()
|
||||
|
||||
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
||||
if (!this.events.has(event))
|
||||
this.events.set(event, new Set())
|
||||
|
||||
this.events.get(event)!.add(handler as EventHandler<unknown>)
|
||||
|
||||
return () => this.off(event, handler)
|
||||
}
|
||||
|
||||
off<T = unknown>(event: string, handler?: EventHandler<T>): void {
|
||||
if (!this.events.has(event))
|
||||
return
|
||||
|
||||
const handlers = this.events.get(event)!
|
||||
if (handler)
|
||||
handlers.delete(handler as EventHandler<unknown>)
|
||||
else
|
||||
handlers.clear()
|
||||
|
||||
if (handlers.size === 0)
|
||||
this.events.delete(event)
|
||||
}
|
||||
|
||||
emit<T = unknown>(event: string, data: T): void {
|
||||
if (!this.events.has(event))
|
||||
return
|
||||
|
||||
const handlers = this.events.get(event)!
|
||||
handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(data)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`Error in event handler for ${event}:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
removeAllListeners(): void {
|
||||
this.events.clear()
|
||||
}
|
||||
|
||||
getListenerCount(event: string): number {
|
||||
return this.events.get(event)?.size || 0
|
||||
}
|
||||
}
|
||||
@ -1,157 +0,0 @@
|
||||
import type { Socket } from 'socket.io-client'
|
||||
import type { DebugInfo, WebSocketConfig } from '../types/websocket'
|
||||
import { io } from 'socket.io-client'
|
||||
import { SOCKET_URL } from '@/config'
|
||||
|
||||
type AckArgs = unknown[]
|
||||
|
||||
const isUnauthorizedAck = (...ackArgs: AckArgs): boolean => {
|
||||
const [first, second] = ackArgs
|
||||
|
||||
if (second === 401 || first === 401)
|
||||
return true
|
||||
|
||||
if (first && typeof first === 'object' && 'msg' in first) {
|
||||
const message = (first as { msg?: unknown }).msg
|
||||
return message === 'unauthorized'
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export type EmitAckOptions = {
|
||||
onAck?: (...ackArgs: AckArgs) => void
|
||||
onUnauthorized?: (...ackArgs: AckArgs) => void
|
||||
}
|
||||
|
||||
export const emitWithAuthGuard = (
|
||||
socket: Socket | null | undefined,
|
||||
event: string,
|
||||
payload: unknown,
|
||||
options?: EmitAckOptions,
|
||||
): void => {
|
||||
if (!socket)
|
||||
return
|
||||
|
||||
socket.emit(
|
||||
event,
|
||||
payload,
|
||||
(...ackArgs: AckArgs) => {
|
||||
options?.onAck?.(...ackArgs)
|
||||
if (isUnauthorizedAck(...ackArgs))
|
||||
options?.onUnauthorized?.(...ackArgs)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export class WebSocketClient {
|
||||
private connections: Map<string, Socket> = new Map()
|
||||
private connecting: Set<string> = new Set()
|
||||
private readonly url: string
|
||||
private readonly transports: WebSocketConfig['transports']
|
||||
private readonly withCredentials?: boolean
|
||||
|
||||
constructor(config: WebSocketConfig = {}) {
|
||||
this.url = SOCKET_URL
|
||||
this.transports = config.transports || ['websocket']
|
||||
this.withCredentials = config.withCredentials !== false
|
||||
}
|
||||
|
||||
connect(appId: string): Socket {
|
||||
const existingSocket = this.connections.get(appId)
|
||||
if (existingSocket?.connected)
|
||||
return existingSocket
|
||||
|
||||
if (this.connecting.has(appId)) {
|
||||
const pendingSocket = this.connections.get(appId)
|
||||
if (pendingSocket)
|
||||
return pendingSocket
|
||||
}
|
||||
|
||||
if (existingSocket && !existingSocket.connected) {
|
||||
existingSocket.disconnect()
|
||||
this.connections.delete(appId)
|
||||
}
|
||||
|
||||
this.connecting.add(appId)
|
||||
|
||||
const socketOptions: {
|
||||
path: string
|
||||
transports: WebSocketConfig['transports']
|
||||
withCredentials?: boolean
|
||||
} = {
|
||||
path: '/socket.io',
|
||||
transports: this.transports,
|
||||
withCredentials: this.withCredentials,
|
||||
}
|
||||
|
||||
const socket = io(this.url, socketOptions)
|
||||
|
||||
this.connections.set(appId, socket)
|
||||
this.setupBaseEventListeners(socket, appId)
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
disconnect(appId?: string): void {
|
||||
if (appId) {
|
||||
const socket = this.connections.get(appId)
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
this.connections.delete(appId)
|
||||
this.connecting.delete(appId)
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.connections.forEach(socket => socket.disconnect())
|
||||
this.connections.clear()
|
||||
this.connecting.clear()
|
||||
}
|
||||
}
|
||||
|
||||
getSocket(appId: string): Socket | null {
|
||||
return this.connections.get(appId) || null
|
||||
}
|
||||
|
||||
isConnected(appId: string): boolean {
|
||||
return this.connections.get(appId)?.connected || false
|
||||
}
|
||||
|
||||
getConnectedApps(): string[] {
|
||||
const connectedApps: string[] = []
|
||||
this.connections.forEach((socket, appId) => {
|
||||
if (socket.connected)
|
||||
connectedApps.push(appId)
|
||||
})
|
||||
return connectedApps
|
||||
}
|
||||
|
||||
getDebugInfo(): DebugInfo {
|
||||
const info: DebugInfo = {}
|
||||
this.connections.forEach((socket, appId) => {
|
||||
info[appId] = {
|
||||
connected: socket.connected,
|
||||
connecting: this.connecting.has(appId),
|
||||
socketId: socket.id,
|
||||
}
|
||||
})
|
||||
return info
|
||||
}
|
||||
|
||||
private setupBaseEventListeners(socket: Socket, appId: string): void {
|
||||
socket.on('connect', () => {
|
||||
this.connecting.delete(appId)
|
||||
emitWithAuthGuard(socket, 'user_connect', { workflow_id: appId })
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
this.connecting.delete(appId)
|
||||
})
|
||||
|
||||
socket.on('connect_error', () => {
|
||||
this.connecting.delete(appId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const webSocketClient = new WebSocketClient()
|
||||
@ -1,141 +0,0 @@
|
||||
import type { ReactFlowInstance } from 'reactflow'
|
||||
import type {
|
||||
CollaborationState,
|
||||
CursorPosition,
|
||||
NodePanelPresenceMap,
|
||||
OnlineUser,
|
||||
} from '../types/collaboration'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { collaborationManager } from '../core/collaboration-manager'
|
||||
import { CursorService } from '../services/cursor-service'
|
||||
|
||||
type CollaborationViewState = {
|
||||
isConnected: boolean
|
||||
onlineUsers: OnlineUser[]
|
||||
cursors: Record<string, CursorPosition>
|
||||
nodePanelPresence: NodePanelPresenceMap
|
||||
isLeader: boolean
|
||||
}
|
||||
|
||||
type ReactFlowStore = NonNullable<Parameters<typeof collaborationManager.connect>[1]>
|
||||
|
||||
const initialState: CollaborationViewState = {
|
||||
isConnected: false,
|
||||
onlineUsers: [],
|
||||
cursors: {},
|
||||
nodePanelPresence: {},
|
||||
isLeader: false,
|
||||
}
|
||||
|
||||
export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) {
|
||||
const [state, setState] = useState<CollaborationViewState>(initialState)
|
||||
|
||||
const cursorServiceRef = useRef<CursorService | null>(null)
|
||||
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId || !isCollaborationEnabled) {
|
||||
Promise.resolve().then(() => {
|
||||
setState(initialState)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let connectionId: string | null = null
|
||||
let isUnmounted = false
|
||||
|
||||
if (!cursorServiceRef.current)
|
||||
cursorServiceRef.current = new CursorService()
|
||||
|
||||
const initCollaboration = async () => {
|
||||
try {
|
||||
const id = await collaborationManager.connect(appId, reactFlowStore)
|
||||
if (isUnmounted) {
|
||||
collaborationManager.disconnect(id)
|
||||
return
|
||||
}
|
||||
connectionId = id
|
||||
setState(prev => ({ ...prev, isConnected: collaborationManager.isConnected() }))
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to initialize collaboration:', error)
|
||||
}
|
||||
}
|
||||
|
||||
initCollaboration()
|
||||
|
||||
const unsubscribeStateChange = collaborationManager.onStateChange((newState: Partial<CollaborationState>) => {
|
||||
if (newState.isConnected === undefined)
|
||||
return
|
||||
|
||||
setState(prev => ({ ...prev, isConnected: newState.isConnected ?? prev.isConnected }))
|
||||
})
|
||||
|
||||
const unsubscribeCursors = collaborationManager.onCursorUpdate((cursors: Record<string, CursorPosition>) => {
|
||||
setState(prev => ({ ...prev, cursors }))
|
||||
})
|
||||
|
||||
const unsubscribeUsers = collaborationManager.onOnlineUsersUpdate((users: OnlineUser[]) => {
|
||||
setState(prev => ({ ...prev, onlineUsers: users }))
|
||||
})
|
||||
|
||||
const unsubscribeNodePanelPresence = collaborationManager.onNodePanelPresenceUpdate((presence: NodePanelPresenceMap) => {
|
||||
setState(prev => ({ ...prev, nodePanelPresence: presence }))
|
||||
})
|
||||
|
||||
const unsubscribeLeaderChange = collaborationManager.onLeaderChange((isLeader: boolean) => {
|
||||
setState(prev => ({ ...prev, isLeader }))
|
||||
})
|
||||
|
||||
return () => {
|
||||
isUnmounted = true
|
||||
unsubscribeStateChange()
|
||||
unsubscribeCursors()
|
||||
unsubscribeUsers()
|
||||
unsubscribeNodePanelPresence()
|
||||
unsubscribeLeaderChange()
|
||||
cursorServiceRef.current?.stopTracking()
|
||||
if (connectionId)
|
||||
collaborationManager.disconnect(connectionId)
|
||||
}
|
||||
}, [appId, reactFlowStore, isCollaborationEnabled])
|
||||
|
||||
const prevIsConnected = useRef(false)
|
||||
useEffect(() => {
|
||||
if (prevIsConnected.current && !state.isConnected) {
|
||||
toast.error('Network connection lost. Please check your network.')
|
||||
}
|
||||
prevIsConnected.current = state.isConnected || false
|
||||
}, [state.isConnected])
|
||||
|
||||
const startCursorTracking = (containerRef: React.RefObject<HTMLElement>, reactFlowInstance?: ReactFlowInstance) => {
|
||||
if (!isCollaborationEnabled || !cursorServiceRef.current)
|
||||
return
|
||||
|
||||
if (cursorServiceRef.current) {
|
||||
cursorServiceRef.current.startTracking(containerRef, (position) => {
|
||||
collaborationManager.emitCursorMove(position)
|
||||
}, reactFlowInstance)
|
||||
}
|
||||
}
|
||||
|
||||
const stopCursorTracking = () => {
|
||||
cursorServiceRef.current?.stopTracking()
|
||||
}
|
||||
|
||||
const result = {
|
||||
isConnected: state.isConnected || false,
|
||||
onlineUsers: state.onlineUsers || [],
|
||||
cursors: state.cursors || {},
|
||||
nodePanelPresence: state.nodePanelPresence || {},
|
||||
isLeader: state.isLeader || false,
|
||||
leaderId: collaborationManager.getLeaderId(),
|
||||
isEnabled: isCollaborationEnabled,
|
||||
startCursorTracking,
|
||||
stopCursorTracking,
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export { collaborationManager } from './core/collaboration-manager'
|
||||
export { webSocketClient } from './core/websocket-manager'
|
||||
export { useCollaboration } from './hooks/use-collaboration'
|
||||
export { CursorService } from './services/cursor-service'
|
||||
export * from './types'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user