Merge branch 'feat/collaboration2' into feat/support-agent-sandbox

This commit is contained in:
hjlarry
2026-01-25 00:00:03 +08:00
221 changed files with 13878 additions and 1226 deletions

View File

@@ -33,6 +33,9 @@ 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

View File

@@ -1,6 +1,6 @@
# Dify Backend API
## Usage
## Setup and Run
> [!IMPORTANT]
>
@@ -8,48 +8,77 @@
> [`uv`](https://docs.astral.sh/uv/) as the package manager
> for Dify API backend service.
1. Start the docker-compose stack
`uv` and `pnpm` are required to run the setup and development commands below.
The backend require some middleware, including PostgreSQL, Redis, and Weaviate, which can be started together using `docker-compose`.
### Using scripts (recommended)
The scripts resolve paths relative to their location, so you can run them from anywhere.
1. Run setup (copies env files and installs dependencies).
```bash
cd ../docker
cp middleware.env.example middleware.env
# change the profile to mysql if you are not using postgres,change the profile to other vector database if you are not using weaviate
docker compose -f docker-compose.middleware.yaml --profile postgresql --profile weaviate -p dify up -d
cd ../api
./dev/setup
```
1. Copy `.env.example` to `.env`
1. Review `api/.env`, `web/.env.local`, and `docker/middleware.env` values (see the `SECRET_KEY` note below).
```cli
cp .env.example .env
1. Start middleware (PostgreSQL/Redis/Weaviate).
```bash
./dev/start-docker-compose
```
> [!IMPORTANT]
>
> When the frontend and backend run on different subdomains, set COOKIE_DOMAIN to the sites top-level domain (e.g., `example.com`). The frontend and backend must be under the same top-level domain in order to share authentication cookies.
1. Start backend (runs migrations first).
1. Generate a `SECRET_KEY` in the `.env` file.
bash for Linux
```bash for Linux
sed -i "/^SECRET_KEY=/c\SECRET_KEY=$(openssl rand -base64 42)" .env
```bash
./dev/start-api
```
bash for Mac
1. Start Dify [web](../web) service.
```bash for Mac
secret_key=$(openssl rand -base64 42)
sed -i '' "/^SECRET_KEY=/c\\
SECRET_KEY=${secret_key}" .env
```bash
./dev/start-web
```
1. Create environment.
1. Set up your application by visiting `http://localhost:3000`.
Dify API service uses [UV](https://docs.astral.sh/uv/) to manage dependencies.
First, you need to add the uv package manager, if you don't have it already.
1. Optional: start the worker service (async tasks, runs from `api`).
```bash
./dev/start-worker
```
1. Optional: start Celery Beat (scheduled tasks).
```bash
./dev/start-beat
```
### Manual commands
<details>
<summary>Show manual setup and run steps</summary>
These commands assume you start from the repository root.
1. Start the docker-compose stack.
The backend requires middleware, including PostgreSQL, Redis, and Weaviate, which can be started together using `docker-compose`.
```bash
cp docker/middleware.env.example docker/middleware.env
# Use mysql or another vector database profile if you are not using postgres/weaviate.
docker compose -f docker/docker-compose.middleware.yaml --profile postgresql --profile weaviate -p dify up -d
```
1. Copy env files.
```bash
cp api/.env.example api/.env
cp web/.env.example web/.env.local
```
1. Install UV if needed.
```bash
pip install uv
@@ -57,60 +86,96 @@
brew install uv
```
1. Install dependencies
1. Install API dependencies.
```bash
uv sync --dev
cd api
uv sync --group dev
```
1. Run migrate
Before the first launch, migrate the database to the latest version.
1. Install web dependencies.
```bash
cd web
pnpm install
cd ..
```
1. Start backend (runs migrations first, in a new terminal).
```bash
cd api
uv run flask db upgrade
```
1. Start backend
```bash
uv run flask run --host 0.0.0.0 --port=5001 --debug
```
1. Start Dify [web](../web) service.
1. Start Dify [web](../web) service (in a new terminal).
1. Setup your application by visiting `http://localhost:3000`.
```bash
cd web
pnpm dev:inspect
```
1. If you need to handle and debug the async tasks (e.g. dataset importing and documents indexing), please start the worker service.
1. Set up your application by visiting `http://localhost:3000`.
```bash
uv run celery -A app.celery worker -P threads -c 2 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention
```
1. Optional: start the worker service (async tasks, in a new terminal).
Additionally, if you want to debug the celery scheduled tasks, you can run the following command in another terminal to start the beat service:
```bash
cd api
uv run celery -A app.celery worker -P threads -c 2 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention
```
```bash
uv run celery -A app.celery beat
```
1. Optional: start Celery Beat (scheduled tasks, in a new terminal).
```bash
cd api
uv run celery -A app.celery beat
```
</details>
### Environment notes
> [!IMPORTANT]
>
> When the frontend and backend run on different subdomains, set COOKIE_DOMAIN to the sites top-level domain (e.g., `example.com`). The frontend and backend must be under the same top-level domain in order to share authentication cookies.
- Generate a `SECRET_KEY` in the `.env` file.
bash for Linux
```bash
sed -i "/^SECRET_KEY=/c\\SECRET_KEY=$(openssl rand -base64 42)" .env
```
bash for Mac
```bash
secret_key=$(openssl rand -base64 42)
sed -i '' "/^SECRET_KEY=/c\\
SECRET_KEY=${secret_key}" .env
```
## Testing
1. Install dependencies for both the backend and the test environment
```bash
uv sync --dev
cd api
uv sync --group dev
```
1. Run the tests locally with mocked system environment variables in `tool.pytest_env` section in `pyproject.toml`, more can check [Claude.md](../CLAUDE.md)
```bash
cd api
uv run pytest # Run all tests
uv run pytest tests/unit_tests/ # Unit tests only
uv run pytest tests/integration_tests/ # Integration tests
# Code quality
../dev/reformat # Run all formatters and linters
uv run ruff check --fix ./ # Fix linting issues
uv run ruff format ./ # Format code
uv run basedpyright . # Type checking
./dev/reformat # Run all formatters and linters
uv run ruff check --fix ./ # Fix linting issues
uv run ruff format ./ # Format code
uv run basedpyright . # Type checking
```

View File

@@ -1,3 +1,4 @@
import os
import sys
@@ -8,10 +9,15 @@ 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.
@@ -22,8 +28,15 @@ else:
from app_factory import create_app
app = create_app()
celery = app.extensions["celery"]
socketio_app, flask_app = create_app()
app = flask_app
celery = flask_app.extensions["celery"]
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5001)
from gevent import pywsgi
from geventwebsocket.handler import WebSocketHandler # 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()

View File

@@ -1,6 +1,7 @@
import logging
import time
import socketio # type: ignore[reportMissingTypeStubs]
from opentelemetry.trace import get_current_span
from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID
@@ -8,6 +9,7 @@ from configs import dify_config
from contexts.wrapper import RecyclableContextVar
from core.logging.context import init_request_context
from dify_app import DifyApp
from extensions.ext_socketio import sio
logger = logging.getLogger(__name__)
@@ -60,14 +62,18 @@ def create_flask_app_with_configs() -> DifyApp:
return dify_app
def create_app() -> DifyApp:
def create_app() -> tuple[socketio.WSGIApp, 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 app
return socketio_app, app
def initialize_extensions(app: DifyApp):

View File

@@ -1240,6 +1240,13 @@ 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",
@@ -1359,6 +1366,7 @@ class FeatureConfig(
WorkflowConfig,
WorkflowNodeExecutionConfig,
WorkspaceConfig,
CollaborationConfig,
LoginConfig,
AccountConfig,
SwaggerUIConfig,

View File

@@ -64,6 +64,7 @@ from .app import (
statistic,
workflow,
workflow_app_log,
workflow_comment,
workflow_draft_variable,
workflow_run,
workflow_statistic,
@@ -115,6 +116,7 @@ from .explore import (
saved_message,
trial,
)
from .socketio import workflow as socketio_workflow # pyright: ignore[reportUnusedImport]
# Import tag controllers
from .tag import tags
@@ -211,6 +213,7 @@ __all__ = [
"website",
"workflow",
"workflow_app_log",
"workflow_comment",
"workflow_draft_variable",
"workflow_run",
"workflow_statistic",

View File

@@ -82,13 +82,13 @@ class ProviderNotSupportSpeechToTextError(BaseHTTPException):
class DraftWorkflowNotExist(BaseHTTPException):
error_code = "draft_workflow_not_exist"
description = "Draft workflow need to be initialized."
code = 400
code = 404
class DraftWorkflowNotSync(BaseHTTPException):
error_code = "draft_workflow_not_sync"
description = "Workflow graph might have been modified, please refresh and resubmit."
code = 400
code = 409
class TracingConfigNotExist(BaseHTTPException):

View File

@@ -32,8 +32,10 @@ from core.trigger.debug.event_selectors import (
from core.workflow.enums import NodeType
from core.workflow.graph_engine.manager import GraphEngineManager
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from factories import file_factory, variable_factory
from fields.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 fields.workflow_run_fields import workflow_run_node_execution_fields
from libs import helper
@@ -43,6 +45,7 @@ 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 WorkflowHashNotEqualError
from services.errors.llm import InvokeRateLimitError
@@ -182,6 +185,14 @@ class WorkflowUpdatePayload(BaseModel):
marked_comment: str | None = Field(default=None, max_length=100)
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
@@ -214,6 +225,8 @@ reg(DefaultBlockConfigQuery)
reg(ConvertToWorkflowPayload)
reg(WorkflowListQuery)
reg(WorkflowUpdatePayload)
reg(WorkflowFeaturesPayload)
reg(WorkflowOnlineUsersQuery)
reg(DraftWorkflowTriggerRunPayload)
reg(DraftWorkflowTriggerRunAllPayload)
reg(NestedNodeGraphPayload)
@@ -804,6 +817,31 @@ 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__])
@@ -1230,3 +1268,32 @@ class NestedNodeGraphApi(Resource):
response = service.generate_nested_node_graph(tenant_id=app_model.tenant_id, request=request)
return response.model_dump()
@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}

View File

@@ -0,0 +1,317 @@
import logging
from flask_restx import Resource, fields, marshal_with
from pydantic import BaseModel, Field
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 account_with_role_fields
from fields.workflow_comment_fields import (
workflow_comment_basic_fields,
workflow_comment_create_fields,
workflow_comment_detail_fields,
workflow_comment_reply_create_fields,
workflow_comment_reply_update_fields,
workflow_comment_resolve_fields,
workflow_comment_update_fields,
)
from libs.login import current_user, login_required
from models import App
from services.account_service import TenantService
from services.workflow_comment_service import WorkflowCommentService
logger = logging.getLogger(__name__)
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")
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))
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
)
workflow_comment_mention_users_model = console_ns.model(
"WorkflowCommentMentionUsers",
{"users": fields.List(fields.Nested(account_with_role_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", workflow_comment_mention_users_model)
@login_required
@setup_required
@account_initialization_required
@get_app_model()
@marshal_with(workflow_comment_mention_users_model)
def get(self, app_model: App):
"""Get all users in current tenant for mentions."""
members = TenantService.get_tenant_members(current_user.current_tenant)
return {"users": members}

View File

@@ -22,8 +22,8 @@ from core.variables.segments import ArrayFileSegment, ArrayPromptMessageSegment,
from core.variables.types import SegmentType
from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from extensions.ext_database import db
from factories import variable_factory
from factories.file_factory import build_from_mapping, build_from_mappings
from factories.variable_factory import build_segment_with_type
from libs.login import current_account_with_tenant, login_required
from models import App, AppMode
from models.workflow import WorkflowDraftVariable
@@ -44,6 +44,16 @@ 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),
@@ -52,6 +62,14 @@ 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):
@@ -389,7 +407,7 @@ class VariableApi(Resource):
if len(raw_value) > 0 and not isinstance(raw_value[0], dict):
raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}")
raw_value = build_from_mappings(mappings=raw_value, tenant_id=app_model.tenant_id)
new_value = build_segment_with_type(variable.value_type, raw_value)
new_value = variable_factory.build_segment_with_type(variable.value_type, raw_value)
draft_var_srv.update_variable(variable, name=new_name, value=new_value)
db.session.commit()
return variable
@@ -482,6 +500,34 @@ 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):
@@ -533,3 +579,31 @@ 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"}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,108 @@
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)

View File

@@ -36,6 +36,7 @@ from controllers.console.wraps import (
only_edition_cloud,
setup_required,
)
from core.file import helpers as file_helpers
from extensions.ext_database import db
from fields.member_fields import account_fields
from libs.datetime_utils import naive_utc_now
@@ -73,6 +74,10 @@ class AccountAvatarPayload(BaseModel):
avatar: str
class AccountAvatarQuery(BaseModel):
avatar: str = Field(..., description="Avatar file ID")
class AccountInterfaceLanguagePayload(BaseModel):
interface_language: str
@@ -158,6 +163,7 @@ def reg(cls: type[BaseModel]):
reg(AccountInitPayload)
reg(AccountNamePayload)
reg(AccountAvatarPayload)
reg(AccountAvatarQuery)
reg(AccountInterfaceLanguagePayload)
reg(AccountInterfaceThemePayload)
reg(AccountTimezonePayload)
@@ -248,6 +254,18 @@ 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

View File

@@ -166,18 +166,22 @@ class WorkflowBasedAppRunner:
# Determine which type of single node execution and get graph/variable_pool
if single_iteration_run:
graph, variable_pool = self._get_graph_and_variable_pool_of_single_iteration(
graph, variable_pool = self._get_graph_and_variable_pool_for_single_node_run(
workflow=workflow,
node_id=single_iteration_run.node_id,
user_inputs=dict(single_iteration_run.inputs),
graph_runtime_state=graph_runtime_state,
node_type_filter_key="iteration_id",
node_type_label="iteration",
)
elif single_loop_run:
graph, variable_pool = self._get_graph_and_variable_pool_of_single_loop(
graph, variable_pool = self._get_graph_and_variable_pool_for_single_node_run(
workflow=workflow,
node_id=single_loop_run.node_id,
user_inputs=dict(single_loop_run.inputs),
graph_runtime_state=graph_runtime_state,
node_type_filter_key="loop_id",
node_type_label="loop",
)
else:
raise ValueError("Neither single_iteration_run nor single_loop_run is specified")
@@ -314,44 +318,6 @@ class WorkflowBasedAppRunner:
return graph, variable_pool
def _get_graph_and_variable_pool_of_single_iteration(
self,
workflow: Workflow,
node_id: str,
user_inputs: dict[str, Any],
graph_runtime_state: GraphRuntimeState,
) -> tuple[Graph, VariablePool]:
"""
Get variable pool of single iteration
"""
return self._get_graph_and_variable_pool_for_single_node_run(
workflow=workflow,
node_id=node_id,
user_inputs=user_inputs,
graph_runtime_state=graph_runtime_state,
node_type_filter_key="iteration_id",
node_type_label="iteration",
)
def _get_graph_and_variable_pool_of_single_loop(
self,
workflow: Workflow,
node_id: str,
user_inputs: dict[str, Any],
graph_runtime_state: GraphRuntimeState,
) -> tuple[Graph, VariablePool]:
"""
Get variable pool of single loop
"""
return self._get_graph_and_variable_pool_for_single_node_run(
workflow=workflow,
node_id=node_id,
user_inputs=user_inputs,
graph_runtime_state=graph_runtime_state,
node_type_filter_key="loop_id",
node_type_label="loop",
)
def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent):
"""
Handle event

View File

@@ -154,7 +154,7 @@ class IrisConnectionPool:
# Add to cache to skip future checks
self._schemas_initialized.add(schema)
except Exception as e:
except Exception:
conn.rollback()
logger.exception("Failed to ensure schema %s exists", schema)
raise
@@ -177,6 +177,9 @@ class IrisConnectionPool:
class IrisVector(BaseVector):
"""IRIS vector database implementation using native VECTOR type and HNSW indexing."""
# Fallback score for full-text search when Rank function unavailable or TEXT_INDEX disabled
_FULL_TEXT_FALLBACK_SCORE = 0.5
def __init__(self, collection_name: str, config: IrisVectorConfig) -> None:
super().__init__(collection_name)
self.config = config
@@ -272,41 +275,131 @@ class IrisVector(BaseVector):
return docs
def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]:
"""Search documents by full-text using iFind index or fallback to LIKE search."""
"""Search documents by full-text using iFind index with BM25 relevance scoring.
When IRIS_TEXT_INDEX is enabled, this method uses the auto-generated Rank
function from %iFind.Index.Basic to calculate BM25 relevance scores. The Rank
function is automatically created with naming: {schema}.{table_name}_{index}Rank
Args:
query: Search query string
**kwargs: Optional parameters including top_k, document_ids_filter
Returns:
List of Document objects with relevance scores in metadata["score"]
"""
top_k = kwargs.get("top_k", 5)
document_ids_filter = kwargs.get("document_ids_filter")
with self._get_cursor() as cursor:
if self.config.IRIS_TEXT_INDEX:
# Use iFind full-text search with index
# Use iFind full-text search with auto-generated Rank function
text_index_name = f"idx_{self.table_name}_text"
# IRIS removes underscores from function names
table_no_underscore = self.table_name.replace("_", "")
index_no_underscore = text_index_name.replace("_", "")
rank_function = f"{self.schema}.{table_no_underscore}_{index_no_underscore}Rank"
# Build WHERE clause with document ID filter if provided
where_clause = f"WHERE %ID %FIND search_index({text_index_name}, ?)"
# First param for Rank function, second for FIND
params = [query, query]
if document_ids_filter:
# Add document ID filter
placeholders = ",".join("?" * len(document_ids_filter))
where_clause += f" AND JSON_VALUE(meta, '$.document_id') IN ({placeholders})"
params.extend(document_ids_filter)
sql = f"""
SELECT TOP {top_k} id, text, meta
SELECT TOP {top_k}
id,
text,
meta,
{rank_function}(%ID, ?) AS score
FROM {self.schema}.{self.table_name}
WHERE %ID %FIND search_index({text_index_name}, ?)
{where_clause}
ORDER BY score DESC
"""
cursor.execute(sql, (query,))
logger.debug(
"iFind search: query='%s', index='%s', rank='%s'",
query,
text_index_name,
rank_function,
)
try:
cursor.execute(sql, params)
except Exception: # pylint: disable=broad-exception-caught
# Fallback to query without Rank function if it fails
logger.warning(
"Rank function '%s' failed, using fixed score",
rank_function,
exc_info=True,
)
sql_fallback = f"""
SELECT TOP {top_k} id, text, meta, {self._FULL_TEXT_FALLBACK_SCORE} AS score
FROM {self.schema}.{self.table_name}
{where_clause}
"""
# Skip first param (for Rank function)
cursor.execute(sql_fallback, params[1:])
else:
# Fallback to LIKE search (inefficient for large datasets)
# Escape special characters for LIKE clause to prevent SQL injection
from libs.helper import escape_like_pattern
# Fallback to LIKE search (IRIS_TEXT_INDEX disabled)
from libs.helper import ( # pylint: disable=import-outside-toplevel
escape_like_pattern,
)
escaped_query = escape_like_pattern(query)
query_pattern = f"%{escaped_query}%"
# Build WHERE clause with document ID filter if provided
where_clause = "WHERE text LIKE ? ESCAPE '\\\\'"
params = [query_pattern]
if document_ids_filter:
placeholders = ",".join("?" * len(document_ids_filter))
where_clause += f" AND JSON_VALUE(meta, '$.document_id') IN ({placeholders})"
params.extend(document_ids_filter)
sql = f"""
SELECT TOP {top_k} id, text, meta
SELECT TOP {top_k} id, text, meta, {self._FULL_TEXT_FALLBACK_SCORE} AS score
FROM {self.schema}.{self.table_name}
WHERE text LIKE ? ESCAPE '\\'
{where_clause}
ORDER BY LENGTH(text) ASC
"""
cursor.execute(sql, (query_pattern,))
logger.debug(
"LIKE fallback (TEXT_INDEX disabled): query='%s'",
query_pattern,
)
cursor.execute(sql, params)
docs = []
for row in cursor.fetchall():
if len(row) >= 3:
metadata = json.loads(row[2]) if row[2] else {}
docs.append(Document(page_content=row[1], metadata=metadata))
# Expecting 4 columns: id, text, meta, score
if len(row) >= 4:
text_content = row[1]
meta_str = row[2]
score_value = row[3]
metadata = json.loads(meta_str) if meta_str else {}
# Add score to metadata for hybrid search compatibility
score = float(score_value) if score_value is not None else 0.0
metadata["score"] = score
docs.append(Document(page_content=text_content, metadata=metadata))
logger.info(
"Full-text search completed: query='%s', results=%d/%d",
query,
len(docs),
top_k,
)
if not docs:
logger.info("Full-text search for '%s' returned no results", query)
logger.warning("Full-text search for '%s' returned no results", query)
return docs
@@ -370,7 +463,11 @@ class IrisVector(BaseVector):
AS %iFind.Index.Basic
(LANGUAGE = '{language}', LOWER = 1, INDEXOPTION = 0)
"""
logger.info("Creating text index: %s with language: %s", text_index_name, language)
logger.info(
"Creating text index: %s with language: %s",
text_index_name,
language,
)
logger.info("SQL for text index: %s", sql_text_index)
cursor.execute(sql_text_index)
logger.info("Text index created successfully: %s", text_index_name)

View File

@@ -130,7 +130,7 @@ class ToolInvokeMessage(BaseModel):
text: str
class JsonMessage(BaseModel):
json_object: dict
json_object: dict | list
suppress_output: bool = Field(default=False, description="Whether to suppress JSON output in result string")
class BlobMessage(BaseModel):
@@ -144,7 +144,14 @@ class ToolInvokeMessage(BaseModel):
end: bool = Field(..., description="Whether the chunk is the last chunk")
class FileMessage(BaseModel):
pass
file_marker: str = Field(default="file_marker")
@model_validator(mode="before")
@classmethod
def validate_file_message(cls, values):
if isinstance(values, dict) and "file_marker" not in values:
raise ValueError("Invalid FileMessage: missing file_marker")
return values
class VariableMessage(BaseModel):
variable_name: str = Field(..., description="The name of the variable")
@@ -234,10 +241,22 @@ class ToolInvokeMessage(BaseModel):
@field_validator("message", mode="before")
@classmethod
def decode_blob_message(cls, v):
def decode_blob_message(cls, v, info: ValidationInfo):
# 处理 blob 解码
if isinstance(v, dict) and "blob" in v:
with contextlib.suppress(Exception):
v["blob"] = base64.b64decode(v["blob"])
# Force correct message type based on type field
# Only wrap dict types to avoid wrapping already parsed Pydantic model objects
if info.data and isinstance(info.data, dict) and isinstance(v, dict):
msg_type = info.data.get("type")
if msg_type == cls.MessageType.JSON:
if "json_object" not in v:
v = {"json_object": v}
elif msg_type == cls.MessageType.FILE:
v = {"file_marker": "file_marker"}
return v
@field_serializer("message")

View File

@@ -660,7 +660,7 @@ class AgentNode(Node[AgentNodeData]):
text = ""
files: list[File] = []
json_list: list[dict] = []
json_list: list[dict | list] = []
agent_logs: list[AgentLogEvent] = []
agent_execution_metadata: Mapping[WorkflowNodeExecutionMetadataKey, Any] = {}
@@ -734,13 +734,18 @@ class AgentNode(Node[AgentNodeData]):
elif message.type == ToolInvokeMessage.MessageType.JSON:
assert isinstance(message.message, ToolInvokeMessage.JsonMessage)
if node_type == NodeType.AGENT:
msg_metadata: dict[str, Any] = message.message.json_object.pop("execution_metadata", {})
llm_usage = LLMUsage.from_metadata(cast(LLMUsageMetadata, msg_metadata))
agent_execution_metadata = {
WorkflowNodeExecutionMetadataKey(key): value
for key, value in msg_metadata.items()
if key in WorkflowNodeExecutionMetadataKey.__members__.values()
}
if isinstance(message.message.json_object, dict):
msg_metadata: dict[str, Any] = message.message.json_object.pop("execution_metadata", {})
llm_usage = LLMUsage.from_metadata(cast(LLMUsageMetadata, msg_metadata))
agent_execution_metadata = {
WorkflowNodeExecutionMetadataKey(key): value
for key, value in msg_metadata.items()
if key in WorkflowNodeExecutionMetadataKey.__members__.values()
}
else:
msg_metadata = {}
llm_usage = LLMUsage.empty_usage()
agent_execution_metadata = {}
if message.message.json_object:
json_list.append(message.message.json_object)
elif message.type == ToolInvokeMessage.MessageType.LINK:
@@ -849,7 +854,7 @@ class AgentNode(Node[AgentNodeData]):
yield agent_log
# Add agent_logs to outputs['json'] to ensure frontend can access thinking process
json_output: list[dict[str, Any]] = []
json_output: list[dict[str, Any] | list[Any]] = []
# Step 1: append each agent log as its own dict.
if agent_logs:

View File

@@ -301,7 +301,7 @@ class DatasourceNode(Node[DatasourceNodeData]):
text = ""
files: list[File] = []
json: list[dict] = []
json: list[dict | list] = []
variables: dict[str, Any] = {}

View File

@@ -269,7 +269,7 @@ class ToolNode(Node[ToolNodeData]):
text = ""
files: list[File] = []
json: list[dict] = []
json: list[dict | list] = []
variables: dict[str, Any] = {}
@@ -425,7 +425,7 @@ class ToolNode(Node[ToolNodeData]):
message.message.metadata = dict_metadata
# Add agent_logs to outputs['json'] to ensure frontend can access thinking process
json_output: list[dict[str, Any]] = []
json_output: list[dict[str, Any] | list[Any]] = []
# Step 2: normalize JSON into {"data": [...]}.change json to list[dict]
if json:

View File

@@ -119,14 +119,16 @@ elif [[ "${MODE}" == "job" ]]; then
else
if [[ "${DEBUG}" == "true" ]]; then
exec flask run --host=${DIFY_BIND_ADDRESS:-0.0.0.0} --port=${DIFY_PORT:-5001} --debug
export HOST=${DIFY_BIND_ADDRESS:-0.0.0.0}
export PORT=${DIFY_PORT:-5001}
exec python -m app
else
exec gunicorn \
--bind "${DIFY_BIND_ADDRESS:-0.0.0.0}:${DIFY_PORT:-5001}" \
--workers ${SERVER_WORKER_AMOUNT:-1} \
--worker-class ${SERVER_WORKER_CLASS:-gevent} \
--worker-class ${SERVER_WORKER_CLASS:-geventwebsocket.gunicorn.workers.GeventWebSocketWorker} \
--worker-connections ${SERVER_WORKER_CONNECTIONS:-10} \
--timeout ${GUNICORN_TIMEOUT:-200} \
app:app
app:socketio_app
fi
fi

View File

@@ -0,0 +1,5 @@
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)

View File

@@ -0,0 +1,17 @@
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)),
}

View File

@@ -0,0 +1,96 @@
from flask_restx import fields
from libs.helper import AvatarUrlField, TimestampField
# basic account fields for comments
account_fields = {
"id": fields.String,
"name": fields.String,
"email": fields.String,
"avatar_url": AvatarUrlField,
}
# Comment mention fields
workflow_comment_mention_fields = {
"mentioned_user_id": fields.String,
"mentioned_user_account": fields.Nested(account_fields, allow_null=True),
"reply_id": fields.String,
}
# Comment reply fields
workflow_comment_reply_fields = {
"id": fields.String,
"content": fields.String,
"created_by": fields.String,
"created_by_account": fields.Nested(account_fields, allow_null=True),
"created_at": TimestampField,
}
# Basic comment fields (for list views)
workflow_comment_basic_fields = {
"id": fields.String,
"position_x": fields.Float,
"position_y": fields.Float,
"content": fields.String,
"created_by": fields.String,
"created_by_account": fields.Nested(account_fields, allow_null=True),
"created_at": TimestampField,
"updated_at": TimestampField,
"resolved": fields.Boolean,
"resolved_at": TimestampField,
"resolved_by": fields.String,
"resolved_by_account": fields.Nested(account_fields, allow_null=True),
"reply_count": fields.Integer,
"mention_count": fields.Integer,
"participants": fields.List(fields.Nested(account_fields)),
}
# Detailed comment fields (for single comment view)
workflow_comment_detail_fields = {
"id": fields.String,
"position_x": fields.Float,
"position_y": fields.Float,
"content": fields.String,
"created_by": fields.String,
"created_by_account": fields.Nested(account_fields, allow_null=True),
"created_at": TimestampField,
"updated_at": TimestampField,
"resolved": fields.Boolean,
"resolved_at": TimestampField,
"resolved_by": fields.String,
"resolved_by_account": fields.Nested(account_fields, allow_null=True),
"replies": fields.List(fields.Nested(workflow_comment_reply_fields)),
"mentions": fields.List(fields.Nested(workflow_comment_mention_fields)),
}
# Comment creation response fields (simplified)
workflow_comment_create_fields = {
"id": fields.String,
"created_at": TimestampField,
}
# Comment update response fields (simplified)
workflow_comment_update_fields = {
"id": fields.String,
"updated_at": TimestampField,
}
# Comment resolve response fields
workflow_comment_resolve_fields = {
"id": fields.String,
"resolved": fields.Boolean,
"resolved_at": TimestampField,
"resolved_by": fields.String,
}
# Reply creation response fields (simplified)
workflow_comment_reply_create_fields = {
"id": fields.String,
"created_at": TimestampField,
}
# Reply update response fields
workflow_comment_reply_update_fields = {
"id": fields.String,
"updated_at": TimestampField,
}

View File

@@ -0,0 +1,90 @@
"""Add workflow comments table
Revision ID: 227822d22895
Revises: 9d77545f524e
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 = '9d77545f524e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('workflow_comments',
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False),
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
sa.Column('app_id', models.types.StringUUID(), nullable=False),
sa.Column('position_x', sa.Float(), nullable=False),
sa.Column('position_y', sa.Float(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('created_by', models.types.StringUUID(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('resolved', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('resolved_at', sa.DateTime(), nullable=True),
sa.Column('resolved_by', models.types.StringUUID(), nullable=True),
sa.PrimaryKeyConstraint('id', name='workflow_comments_pkey')
)
with op.batch_alter_table('workflow_comments', schema=None) as batch_op:
batch_op.create_index('workflow_comments_app_idx', ['tenant_id', 'app_id'], unique=False)
batch_op.create_index('workflow_comments_created_at_idx', ['created_at'], unique=False)
op.create_table('workflow_comment_replies',
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False),
sa.Column('comment_id', models.types.StringUUID(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('created_by', models.types.StringUUID(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name=op.f('workflow_comment_replies_comment_id_fkey'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='workflow_comment_replies_pkey')
)
with op.batch_alter_table('workflow_comment_replies', schema=None) as batch_op:
batch_op.create_index('comment_replies_comment_idx', ['comment_id'], unique=False)
batch_op.create_index('comment_replies_created_at_idx', ['created_at'], unique=False)
op.create_table('workflow_comment_mentions',
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), 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 ###

View File

@@ -10,6 +10,11 @@ from .account import (
)
from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint
from .app_asset import AppAssets
from .comment import (
WorkflowComment,
WorkflowCommentMention,
WorkflowCommentReply,
)
from .dataset import (
AppDatasetJoin,
Dataset,
@@ -213,6 +218,9 @@ __all__ = [
"WorkflowAppLog",
"WorkflowAppLogCreatedFrom",
"WorkflowArchiveLog",
"WorkflowComment",
"WorkflowCommentMention",
"WorkflowCommentReply",
"WorkflowFeature",
"WorkflowFeatures",
"WorkflowNodeExecutionModel",

210
api/models/comment.py Normal file
View File

@@ -0,0 +1,210 @@
"""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

View File

@@ -317,40 +317,48 @@ class App(Base):
return None
class AppModelConfig(Base):
class AppModelConfig(TypeBase):
__tablename__ = "app_model_configs"
__table_args__ = (sa.PrimaryKeyConstraint("id", name="app_model_config_pkey"), sa.Index("app_app_id_idx", "app_id"))
id = mapped_column(StringUUID, default=lambda: str(uuid4()))
app_id = mapped_column(StringUUID, nullable=False)
provider = mapped_column(String(255), nullable=True)
model_id = mapped_column(String(255), nullable=True)
configs = mapped_column(sa.JSON, nullable=True)
created_by = mapped_column(StringUUID, nullable=True)
created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
updated_by = mapped_column(StringUUID, nullable=True)
updated_at = mapped_column(
sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()), init=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
provider: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
model_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
configs: Mapped[Any | None] = mapped_column(sa.JSON, nullable=True, default=None)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
opening_statement = mapped_column(LongText)
suggested_questions = mapped_column(LongText)
suggested_questions_after_answer = mapped_column(LongText)
speech_to_text = mapped_column(LongText)
text_to_speech = mapped_column(LongText)
more_like_this = mapped_column(LongText)
model = mapped_column(LongText)
user_input_form = mapped_column(LongText)
dataset_query_variable = mapped_column(String(255))
pre_prompt = mapped_column(LongText)
agent_mode = mapped_column(LongText)
sensitive_word_avoidance = mapped_column(LongText)
retriever_resource = mapped_column(LongText)
prompt_type = mapped_column(String(255), nullable=False, server_default=sa.text("'simple'"))
chat_prompt_config = mapped_column(LongText)
completion_prompt_config = mapped_column(LongText)
dataset_configs = mapped_column(LongText)
external_data_tools = mapped_column(LongText)
file_upload = mapped_column(LongText)
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
updated_at: Mapped[datetime] = mapped_column(
sa.DateTime,
nullable=False,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
init=False,
)
opening_statement: Mapped[str | None] = mapped_column(LongText, default=None)
suggested_questions: Mapped[str | None] = mapped_column(LongText, default=None)
suggested_questions_after_answer: Mapped[str | None] = mapped_column(LongText, default=None)
speech_to_text: Mapped[str | None] = mapped_column(LongText, default=None)
text_to_speech: Mapped[str | None] = mapped_column(LongText, default=None)
more_like_this: Mapped[str | None] = mapped_column(LongText, default=None)
model: Mapped[str | None] = mapped_column(LongText, default=None)
user_input_form: Mapped[str | None] = mapped_column(LongText, default=None)
dataset_query_variable: Mapped[str | None] = mapped_column(String(255), default=None)
pre_prompt: Mapped[str | None] = mapped_column(LongText, default=None)
agent_mode: Mapped[str | None] = mapped_column(LongText, default=None)
sensitive_word_avoidance: Mapped[str | None] = mapped_column(LongText, default=None)
retriever_resource: Mapped[str | None] = mapped_column(LongText, default=None)
prompt_type: Mapped[str] = mapped_column(
String(255), nullable=False, server_default=sa.text("'simple'"), default="simple"
)
chat_prompt_config: Mapped[str | None] = mapped_column(LongText, default=None)
completion_prompt_config: Mapped[str | None] = mapped_column(LongText, default=None)
dataset_configs: Mapped[str | None] = mapped_column(LongText, default=None)
external_data_tools: Mapped[str | None] = mapped_column(LongText, default=None)
file_upload: Mapped[str | None] = mapped_column(LongText, default=None)
@property
def app(self) -> App | None:
@@ -812,8 +820,8 @@ class Conversation(Base):
override_model_configs = json.loads(self.override_model_configs)
if "model" in override_model_configs:
app_model_config = AppModelConfig()
app_model_config = app_model_config.from_model_config_dict(override_model_configs)
# where is app_id?
app_model_config = AppModelConfig(app_id=self.app_id).from_model_config_dict(override_model_configs)
model_config = app_model_config.to_dict()
else:
model_config["configs"] = override_model_configs

View File

@@ -257,8 +257,7 @@ class Workflow(Base): # bug
#
# Currently, the following functions / methods would mutate the returned dict:
#
# - `_get_graph_and_variable_pool_of_single_iteration`.
# - `_get_graph_and_variable_pool_of_single_loop`.
# - `_get_graph_and_variable_pool_for_single_node_run`.
return json.loads(self.graph) if self.graph else {}
def get_node_config_by_id(self, node_id: str) -> Mapping[str, Any]:
@@ -435,7 +434,7 @@ class Workflow(Base): # bug
:return: hash
"""
entity = {"graph": self.graph_dict, "features": self.features_dict}
entity = {"graph": self.graph_dict}
return helper.generate_text_hash(json.dumps(entity, sort_keys=True))

View File

@@ -22,6 +22,7 @@ dependencies = [
"flask-orjson~=2.0.0",
"flask-sqlalchemy~=3.1.1",
"gevent~=25.9.1",
"gevent-websocket~=0.10.1",
"gmpy2~=2.2.1",
"google-api-core==2.18.0",
"google-api-python-client==2.90.0",
@@ -33,7 +34,7 @@ dependencies = [
"httpx~=0.28.1",
"python-socks>=2.4.4",
"jieba==0.42.1",
"json-repair>=0.41.1",
"json-repair>=0.55.1",
"jsonschema>=4.25.1",
"langfuse~=2.51.3",
"langsmith~=0.1.77",
@@ -74,6 +75,7 @@ dependencies = [
"pypdfium2==5.2.0",
"python-docx~=1.1.0",
"python-dotenv==1.0.1",
"python-socketio~=5.13.0",
"pyyaml~=6.0.1",
"readabilipy~=0.3.0",
"redis[hiredis]~=6.1.0",

View File

@@ -0,0 +1,147 @@
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)

View File

@@ -521,12 +521,10 @@ class AppDslService:
raise ValueError("Missing model_config for chat/agent-chat/completion app")
# Initialize or update model config
if not app.app_model_config:
app_model_config = AppModelConfig().from_model_config_dict(model_config)
app_model_config = AppModelConfig(
app_id=app.id, created_by=account.id, updated_by=account.id
).from_model_config_dict(model_config)
app_model_config.id = str(uuid4())
app_model_config.app_id = app.id
app_model_config.created_by = account.id
app_model_config.updated_by = account.id
app.app_model_config_id = app_model_config.id
self._session.add(app_model_config)

View File

@@ -150,10 +150,9 @@ class AppService:
db.session.flush()
if default_model_config:
app_model_config = AppModelConfig(**default_model_config)
app_model_config.app_id = app.id
app_model_config.created_by = account.id
app_model_config.updated_by = account.id
app_model_config = AppModelConfig(
**default_model_config, app_id=app.id, created_by=account.id, updated_by=account.id
)
db.session.add(app_model_config)
db.session.flush()

View File

@@ -161,6 +161,7 @@ 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
@@ -224,6 +225,7 @@ 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 != ""

View File

@@ -261,10 +261,9 @@ class MessageService:
else:
conversation_override_model_configs = json.loads(conversation.override_model_configs)
app_model_config = AppModelConfig(
id=conversation.app_model_config_id,
app_id=app_model.id,
)
app_model_config.id = conversation.app_model_config_id
app_model_config = app_model_config.from_model_config_dict(conversation_override_model_configs)
if not app_model_config:
raise ValueError("did not find app model config")

View File

@@ -0,0 +1,196 @@
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

View File

@@ -0,0 +1,345 @@
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)

View File

@@ -254,6 +254,78 @@ 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 publish_workflow(
self,
*,

View File

@@ -38,7 +38,7 @@ os.environ["OPENDAL_FS_ROOT"] = "/tmp/dify-storage"
os.environ.setdefault("STORAGE_TYPE", "opendal")
os.environ.setdefault("OPENDAL_SCHEME", "fs")
_CACHED_APP = create_app()
_SIO_APP, _CACHED_APP = create_app()
@pytest.fixture

View File

@@ -364,7 +364,7 @@ def _create_app_with_containers() -> Flask:
# Create and configure the Flask application
logger.info("Initializing Flask application...")
app = create_app()
sio_app, app = create_app()
logger.info("Flask application created successfully")
# Initialize database schema

View File

@@ -172,7 +172,6 @@ class TestAgentService:
# Create app model config
app_model_config = AppModelConfig(
id=fake.uuid4(),
app_id=app.id,
provider="openai",
model_id="gpt-3.5-turbo",
@@ -180,6 +179,7 @@ class TestAgentService:
model="gpt-3.5-turbo",
agent_mode=json.dumps({"enabled": True, "strategy": "react", "tools": []}),
)
app_model_config.id = fake.uuid4()
db.session.add(app_model_config)
db.session.commit()
@@ -413,7 +413,6 @@ class TestAgentService:
# Create app model config
app_model_config = AppModelConfig(
id=fake.uuid4(),
app_id=app.id,
provider="openai",
model_id="gpt-3.5-turbo",
@@ -421,6 +420,7 @@ class TestAgentService:
model="gpt-3.5-turbo",
agent_mode=json.dumps({"enabled": True, "strategy": "react", "tools": []}),
)
app_model_config.id = fake.uuid4()
db.session.add(app_model_config)
db.session.commit()
@@ -485,7 +485,6 @@ class TestAgentService:
# Create app model config
app_model_config = AppModelConfig(
id=fake.uuid4(),
app_id=app.id,
provider="openai",
model_id="gpt-3.5-turbo",
@@ -493,6 +492,7 @@ class TestAgentService:
model="gpt-3.5-turbo",
agent_mode=json.dumps({"enabled": True, "strategy": "react", "tools": []}),
)
app_model_config.id = fake.uuid4()
db.session.add(app_model_config)
db.session.commit()

View File

@@ -226,26 +226,27 @@ class TestAppDslService:
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Create model config for the app
model_config = AppModelConfig()
model_config.id = fake.uuid4()
model_config.app_id = app.id
model_config.provider = "openai"
model_config.model_id = "gpt-3.5-turbo"
model_config.model = json.dumps(
{
"provider": "openai",
"name": "gpt-3.5-turbo",
"mode": "chat",
"completion_params": {
"max_tokens": 1000,
"temperature": 0.7,
},
}
model_config = AppModelConfig(
app_id=app.id,
provider="openai",
model_id="gpt-3.5-turbo",
model=json.dumps(
{
"provider": "openai",
"name": "gpt-3.5-turbo",
"mode": "chat",
"completion_params": {
"max_tokens": 1000,
"temperature": 0.7,
},
}
),
pre_prompt="You are a helpful assistant.",
prompt_type="simple",
created_by=account.id,
updated_by=account.id,
)
model_config.pre_prompt = "You are a helpful assistant."
model_config.prompt_type = "simple"
model_config.created_by = account.id
model_config.updated_by = account.id
model_config.id = fake.uuid4()
# Set the app_model_config_id to link the config
app.app_model_config_id = model_config.id

View File

@@ -274,6 +274,7 @@ 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"
@@ -298,6 +299,7 @@ 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
@@ -402,6 +404,7 @@ 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"
@@ -423,6 +426,7 @@ 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

View File

@@ -925,24 +925,24 @@ class TestWorkflowService:
# Create app model config (required for conversion)
from models.model import AppModelConfig
app_model_config = AppModelConfig()
app_model_config.id = fake.uuid4()
app_model_config.app_id = app.id
app_model_config.tenant_id = app.tenant_id
app_model_config.provider = "openai"
app_model_config.model_id = "gpt-3.5-turbo"
# Set the model field directly - this is what model_dict property returns
app_model_config.model = json.dumps(
{
"provider": "openai",
"name": "gpt-3.5-turbo",
"completion_params": {"max_tokens": 1000, "temperature": 0.7},
}
app_model_config = AppModelConfig(
app_id=app.id,
provider="openai",
model_id="gpt-3.5-turbo",
# Set the model field directly - this is what model_dict property returns
model=json.dumps(
{
"provider": "openai",
"name": "gpt-3.5-turbo",
"completion_params": {"max_tokens": 1000, "temperature": 0.7},
}
),
# Set pre_prompt for PromptTemplateConfigManager
pre_prompt="You are a helpful assistant.",
created_by=account.id,
updated_by=account.id,
)
# Set pre_prompt for PromptTemplateConfigManager
app_model_config.pre_prompt = "You are a helpful assistant."
app_model_config.created_by = account.id
app_model_config.updated_by = account.id
app_model_config.id = fake.uuid4()
from extensions.ext_database import db
@@ -987,24 +987,24 @@ class TestWorkflowService:
# Create app model config (required for conversion)
from models.model import AppModelConfig
app_model_config = AppModelConfig()
app_model_config.id = fake.uuid4()
app_model_config.app_id = app.id
app_model_config.tenant_id = app.tenant_id
app_model_config.provider = "openai"
app_model_config.model_id = "gpt-3.5-turbo"
# Set the model field directly - this is what model_dict property returns
app_model_config.model = json.dumps(
{
"provider": "openai",
"name": "gpt-3.5-turbo",
"completion_params": {"max_tokens": 1000, "temperature": 0.7},
}
app_model_config = AppModelConfig(
app_id=app.id,
provider="openai",
model_id="gpt-3.5-turbo",
# Set the model field directly - this is what model_dict property returns
model=json.dumps(
{
"provider": "openai",
"name": "gpt-3.5-turbo",
"completion_params": {"max_tokens": 1000, "temperature": 0.7},
}
),
# Set pre_prompt for PromptTemplateConfigManager
pre_prompt="Complete the following text:",
created_by=account.id,
updated_by=account.id,
)
# Set pre_prompt for PromptTemplateConfigManager
app_model_config.pre_prompt = "Complete the following text:"
app_model_config.created_by = account.id
app_model_config.updated_by = account.id
app_model_config.id = fake.uuid4()
from extensions.ext_database import db

View File

@@ -475,3 +475,130 @@ def test_valid_api_key_works():
headers = executor._assembling_headers()
assert "Authorization" in headers
assert headers["Authorization"] == "Bearer valid-api-key-123"
def test_executor_with_json_body_and_unquoted_uuid_variable():
"""Test that unquoted UUID variables are correctly handled in JSON body.
This test verifies the fix for issue #31436 where json_repair would truncate
certain UUID patterns (like 57eeeeb1-...) when they appeared as unquoted values.
"""
# UUID that triggers the json_repair truncation bug
test_uuid = "57eeeeb1-450b-482c-81b9-4be77e95dee2"
variable_pool = VariablePool(
system_variables=SystemVariable.empty(),
user_inputs={},
)
variable_pool.add(["pre_node_id", "uuid"], test_uuid)
node_data = HttpRequestNodeData(
title="Test JSON Body with Unquoted UUID Variable",
method="post",
url="https://api.example.com/data",
authorization=HttpRequestNodeAuthorization(type="no-auth"),
headers="Content-Type: application/json",
params="",
body=HttpRequestNodeBody(
type="json",
data=[
BodyData(
key="",
type="text",
# UUID variable without quotes - this is the problematic case
value='{"rowId": {{#pre_node_id.uuid#}}}',
)
],
),
)
executor = Executor(
node_data=node_data,
timeout=HttpRequestNodeTimeout(connect=10, read=30, write=30),
variable_pool=variable_pool,
)
# The UUID should be preserved in full, not truncated
assert executor.json == {"rowId": test_uuid}
assert len(executor.json["rowId"]) == len(test_uuid)
def test_executor_with_json_body_and_unquoted_uuid_with_newlines():
"""Test that unquoted UUID variables with newlines in JSON are handled correctly.
This is a specific case from issue #31436 where the JSON body contains newlines.
"""
test_uuid = "57eeeeb1-450b-482c-81b9-4be77e95dee2"
variable_pool = VariablePool(
system_variables=SystemVariable.empty(),
user_inputs={},
)
variable_pool.add(["pre_node_id", "uuid"], test_uuid)
node_data = HttpRequestNodeData(
title="Test JSON Body with Unquoted UUID and Newlines",
method="post",
url="https://api.example.com/data",
authorization=HttpRequestNodeAuthorization(type="no-auth"),
headers="Content-Type: application/json",
params="",
body=HttpRequestNodeBody(
type="json",
data=[
BodyData(
key="",
type="text",
# JSON with newlines and unquoted UUID variable
value='{\n"rowId": {{#pre_node_id.uuid#}}\n}',
)
],
),
)
executor = Executor(
node_data=node_data,
timeout=HttpRequestNodeTimeout(connect=10, read=30, write=30),
variable_pool=variable_pool,
)
# The UUID should be preserved in full
assert executor.json == {"rowId": test_uuid}
def test_executor_with_json_body_preserves_numbers_and_strings():
"""Test that numbers are preserved and string values are properly quoted."""
variable_pool = VariablePool(
system_variables=SystemVariable.empty(),
user_inputs={},
)
variable_pool.add(["node", "count"], 42)
variable_pool.add(["node", "id"], "abc-123")
node_data = HttpRequestNodeData(
title="Test JSON Body with mixed types",
method="post",
url="https://api.example.com/data",
authorization=HttpRequestNodeAuthorization(type="no-auth"),
headers="",
params="",
body=HttpRequestNodeBody(
type="json",
data=[
BodyData(
key="",
type="text",
value='{"count": {{#node.count#}}, "id": {{#node.id#}}}',
)
],
),
)
executor = Executor(
node_data=node_data,
timeout=HttpRequestNodeTimeout(connect=10, read=30, write=30),
variable_pool=variable_pool,
)
assert executor.json["count"] == 42
assert executor.json["id"] == "abc-123"

View File

@@ -0,0 +1,121 @@
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"]

View File

@@ -0,0 +1,271 @@
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")

View File

@@ -0,0 +1,245 @@
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()

View File

@@ -10,7 +10,7 @@ This test suite covers:
"""
import json
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, Mock, patch
import pytest
@@ -630,6 +630,79 @@ 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

671
api/uv.lock generated

File diff suppressed because it is too large Load Diff

28
dev/setup Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
ROOT="$(dirname "$SCRIPT_DIR")"
API_ENV_EXAMPLE="$ROOT/api/.env.example"
API_ENV="$ROOT/api/.env"
WEB_ENV_EXAMPLE="$ROOT/web/.env.example"
WEB_ENV="$ROOT/web/.env.local"
MIDDLEWARE_ENV_EXAMPLE="$ROOT/docker/middleware.env.example"
MIDDLEWARE_ENV="$ROOT/docker/middleware.env"
# 1) Copy api/.env.example -> api/.env
cp "$API_ENV_EXAMPLE" "$API_ENV"
# 2) Copy web/.env.example -> web/.env.local
cp "$WEB_ENV_EXAMPLE" "$WEB_ENV"
# 3) Copy docker/middleware.env.example -> docker/middleware.env
cp "$MIDDLEWARE_ENV_EXAMPLE" "$MIDDLEWARE_ENV"
# 4) Install deps
cd "$ROOT/api"
uv sync --group dev
cd "$ROOT/web"
pnpm install

View File

@@ -3,8 +3,9 @@
set -x
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
cd "$SCRIPT_DIR/.."
cd "$SCRIPT_DIR/../api"
uv run flask db upgrade
uv --directory api run \
uv run \
flask run --host 0.0.0.0 --port=5001 --debug

8
dev/start-docker-compose Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
ROOT="$(dirname "$SCRIPT_DIR")"
cd "$ROOT/docker"
docker compose -f docker-compose.middleware.yaml --profile postgresql --profile weaviate -p dify up -d

View File

@@ -83,7 +83,7 @@ while [[ $# -gt 0 ]]; do
done
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
cd "$SCRIPT_DIR/.."
cd "$SCRIPT_DIR/../api"
if [[ -n "${ENV_FILE}" ]]; then
if [[ ! -f "${ENV_FILE}" ]]; then
@@ -123,6 +123,6 @@ echo " Concurrency: ${CONCURRENCY}"
echo " Pool: ${POOL}"
echo " Log Level: ${LOGLEVEL}"
uv --directory api run \
uv run \
celery -A app.celery worker \
-P ${POOL} -c ${CONCURRENCY} --loglevel ${LOGLEVEL} -Q ${QUEUES}

View File

@@ -4,7 +4,7 @@
set -e
set -o pipefail
SCRIPT_DIR="$(dirname "$0")"
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
REPO_ROOT="$(dirname "${SCRIPT_DIR}")"
# rely on `poetry` in path

View File

@@ -129,6 +129,10 @@ 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
@@ -164,6 +168,7 @@ 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.
@@ -401,6 +406,8 @@ 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
# ------------------------------

View File

@@ -139,6 +139,7 @@ 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}
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}

View File

@@ -33,6 +33,7 @@ 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}
@@ -109,6 +110,7 @@ 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}
@@ -824,6 +826,7 @@ 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}
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}

View File

@@ -14,6 +14,14 @@ server {
include proxy.conf;
}
location /socket.io/ {
proxy_pass http://api:5001;
include proxy.conf;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
}
location /v1 {
proxy_pass http://api:5001;
include proxy.conf;

View File

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

View File

@@ -14,6 +14,8 @@ 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
# The API PREFIX for MARKETPLACE
NEXT_PUBLIC_MARKETPLACE_API_PREFIX=https://marketplace.dify.ai/api/v1

View File

@@ -43,6 +43,8 @@ NEXT_PUBLIC_EDITION=SELF_HOSTED
# example: http://cloud.dify.ai/console/api
NEXT_PUBLIC_API_PREFIX=http://localhost:5001/console/api
NEXT_PUBLIC_COOKIE_DOMAIN=
# WebSocket server URL.
NEXT_PUBLIC_SOCKET_URL=ws://localhost:5001
# The URL for Web APP, refers to the Web App base URL of WEB service if web app domain is different from
# console or api domain.
# example: http://udify.app/api

View File

@@ -5,7 +5,8 @@ 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 { useCallback, useMemo } from 'react'
import * as React from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppCard from '@/app/components/app/overview/app-card'
@@ -14,6 +15,8 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { ToastContext } from '@/app/components/base/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 {
@@ -74,28 +77,59 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
? buildTriggerModeMessage(t('mcp.server.title', { ns: 'tools' }))
: null
const updateAppDetail = async () => {
const updateAppDetail = useCallback(async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appId })
setAppDetail({ ...res })
}
catch (error) { console.error(error) }
}
catch (error) {
console.error(error)
}
}, [appId, setAppDetail])
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(),
})
}
}
notify({
type,
message: t(`actionMsg.${message}`, { ns: 'common' }) as string,
})
}
// 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({

View File

@@ -14,7 +14,7 @@ import {
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
@@ -22,10 +22,12 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import ContentDialog from '@/app/components/base/content-dialog'
import { ToastContext } from '@/app/components/base/toast'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { copyApp, deleteApp, exportAppConfig, fetchAppDetail, updateAppInfo } from '@/service/apps'
import { useInvalidateAppList } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
@@ -77,6 +79,19 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const [showExportWarning, setShowExportWarning] = useState(false)
const emitAppMetaUpdate = useCallback(() => {
if (!appDetail?.id)
return
const socket = webSocketClient.getSocket(appDetail.id)
if (socket) {
socket.emit('collaboration_event', {
type: 'app_meta_update',
data: { timestamp: Date.now() },
timestamp: Date.now(),
})
}
}, [appDetail])
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
icon_type,
@@ -105,11 +120,12 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
message: t('editDone', { ns: 'app' }),
})
setAppDetail(app)
emitAppMetaUpdate()
}
catch {
notify({ type: 'error', message: t('editFailed', { ns: 'app' }) })
}
}, [appDetail, notify, setAppDetail, t])
}, [appDetail, notify, setAppDetail, t, emitAppMetaUpdate])
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
if (!appDetail)
@@ -207,6 +223,23 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
setShowConfirmDelete(false)
}, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t])
useEffect(() => {
if (!appDetail?.id)
return
const unsubscribe = collaborationManager.onAppMetaUpdate(async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appDetail.id })
setAppDetail({ ...res })
}
catch (error) {
console.error('failed to refresh app detail from collaboration update:', error)
}
})
return unsubscribe
}, [appDetail?.id, setAppDetail])
const { isCurrentWorkspaceEditor } = useAppContext()
if (!appDetail)

View File

@@ -1,6 +1,7 @@
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'
@@ -13,7 +14,7 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { Resolution } from '@/types/app'
type Props = Omit<AppPublisherProps, 'onPublish'> & {
onPublish?: (modelAndParameter?: ModelAndParameter, features?: any) => Promise<any> | any
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise<any> | any
publishedConfig?: any
resetAppConfig?: () => void
}
@@ -62,8 +63,8 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
setRestoreConfirmOpen(false)
}, [featuresStore, props])
const handlePublish = useCallback((modelAndParameter?: ModelAndParameter) => {
return props.onPublish?.(modelAndParameter, features)
const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => {
return props.onPublish?.(params, features)
}, [features, props])
return (

View File

@@ -1,5 +1,7 @@
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 { InstalledApp } from '@/models/explore'
import type { I18nKeysByPrefix } from '@/types/i18n'
import type { PublishWorkflowParams } from '@/types/workflow'
import {
@@ -18,6 +20,7 @@ import { useKeyPress } from 'ahooks'
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
@@ -35,6 +38,9 @@ import {
} from '@/app/components/base/portal-to-follow-elem'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
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 { appDefaultIconBackground } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
@@ -43,6 +49,8 @@ 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 Divider from '../../base/divider'
@@ -56,6 +64,10 @@ import SuggestedAction from './suggested-action'
type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'>
type InstalledAppsResponse = {
installed_apps?: InstalledApp[]
}
const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: React.ElementType }> = {
[AccessMode.ORGANIZATION]: {
label: 'organization',
@@ -102,8 +114,8 @@ export type AppPublisherProps = {
debugWithMultipleModel?: boolean
multipleModelConfigs?: ModelAndParameter[]
/** modelAndParameter is passed when debugWithMultipleModel is true */
onPublish?: (params?: any) => Promise<any> | any
onRestore?: () => Promise<any> | any
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void> | void
onRestore?: () => Promise<void> | void
onToggle?: (state: boolean) => void
crossAxisOffset?: number
toolPublished?: boolean
@@ -146,6 +158,7 @@ const AppPublisher = ({
const [isAppAccessSet, setIsAppAccessSet] = useState(true)
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)
@@ -158,6 +171,7 @@ 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 noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp])
@@ -193,12 +207,39 @@ 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 {
catch (error) {
console.warn('[app-publisher] publish failed', error)
setPublished(false)
}
}, [appDetail, onPublish])
}, [appDetail, onPublish, invalidateAppWorkflow])
const handleRestore = useCallback(async () => {
try {
@@ -227,9 +268,10 @@ const AppPublisher = ({
await openAsyncWindow(async () => {
if (!appDetail?.id)
throw new Error('App not found')
const { installed_apps }: any = await fetchInstalledAppList(appDetail?.id) || {}
if (installed_apps?.length > 0)
return `${basePath}/explore/installed/${installed_apps[0].id}`
const response = (await fetchInstalledAppList(appDetail?.id)) as InstalledAppsResponse
const installedApps = response?.installed_apps
if (installedApps?.length)
return `${basePath}/explore/installed/${installedApps[0].id}`
throw new Error('No app found in Explore')
}, {
onError: (err) => {
@@ -257,6 +299,29 @@ 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 workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable
const workflowToolMessage = workflowToolDisabled ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined

View File

@@ -18,6 +18,7 @@ import type {
TextToSpeechConfig,
} from '@/models/debug'
import type { ModelConfig as BackendModelConfig, UserInputFormItem, VisionSettings } from '@/types/app'
import type { PublishWorkflowParams } from '@/types/workflow'
import { CodeBracketIcon } from '@heroicons/react/20/solid'
import { useBoolean, useGetState } from 'ahooks'
import { clone } from 'es-toolkit/object'
@@ -760,7 +761,8 @@ const Configuration: FC = () => {
else { return promptEmpty }
})()
const contextVarEmpty = mode === AppModeEnum.COMPLETION && dataSets.length > 0 && !hasSetContextVar
const onPublish = async (modelAndParameter?: ModelAndParameter, features?: FeaturesData) => {
const onPublish = async (params?: ModelAndParameter | PublishWorkflowParams, features?: FeaturesData) => {
const modelAndParameter = params && 'model' in params ? params : undefined
const modelId = modelAndParameter?.model || modelConfig.model_id
const promptTemplate = modelConfig.configs.prompt_template
const promptVariables = modelConfig.configs.prompt_variables

View File

@@ -5,6 +5,7 @@ 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 dynamic from 'next/dynamic'
@@ -20,6 +21,7 @@ import CustomPopover from '@/app/components/base/popover'
import TagSelector from '@/app/components/base/tag-management/selector'
import Toast, { ToastContext } from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
@@ -58,9 +60,10 @@ const AccessControl = dynamic(() => import('@/app/components/app/app-access-cont
export type AppCardProps = {
app: App
onRefresh?: () => void
onlineUsers?: WorkflowOnlineUser[]
}
const AppCard = ({ app, onRefresh }: AppCardProps) => {
const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
@@ -362,6 +365,19 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
return `${t('segment.editedAt', { ns: 'datasetDocuments' })} ${timeText}`
}, [app.updated_at, app.created_at])
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
@@ -414,6 +430,11 @@ const AppCard = ({ app, onRefresh }: 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

View File

@@ -1,3 +1,4 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
@@ -141,9 +142,13 @@ vi.mock('@/app/components/base/tag-management/filter', () => ({
}))
// Mock config
vi.mock('@/config', () => ({
NEED_REFRESH_APP_LIST_KEY: 'needRefreshAppList',
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
NEED_REFRESH_APP_LIST_KEY: 'needRefreshAppList',
}
})
// Mock pay hook
vi.mock('@/hooks/use-pay', () => ({
@@ -234,6 +239,21 @@ beforeAll(() => {
} as unknown as typeof IntersectionObserver
})
const renderList = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
<List />
</QueryClientProvider>,
)
}
describe('List', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -260,13 +280,13 @@ describe('List', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<List />)
renderList()
// Tab slider renders app type tabs
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render tab slider with all app types', () => {
render(<List />)
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
@@ -277,48 +297,48 @@ describe('List', () => {
})
it('should render search input', () => {
render(<List />)
renderList()
// Input component renders a searchbox
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render tag filter', () => {
render(<List />)
renderList()
// Tag filter renders with placeholder text
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should render created by me checkbox', () => {
render(<List />)
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
render(<List />)
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should render new app card for editors', () => {
render(<List />)
renderList()
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
it('should render footer when branding is disabled', () => {
render(<List />)
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
it('should render drop DSL hint for editors', () => {
render(<List />)
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should call setActiveTab when tab is clicked', () => {
render(<List />)
renderList()
fireEvent.click(screen.getByText('app.types.workflow'))
@@ -326,7 +346,7 @@ describe('List', () => {
})
it('should call setActiveTab for all tab', () => {
render(<List />)
renderList()
fireEvent.click(screen.getByText('app.types.all'))
@@ -336,12 +356,12 @@ describe('List', () => {
describe('Search Functionality', () => {
it('should render search input field', () => {
render(<List />)
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should handle search input change', () => {
render(<List />)
renderList()
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
@@ -350,7 +370,7 @@ describe('List', () => {
})
it('should handle search input interaction', () => {
render(<List />)
renderList()
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
@@ -360,7 +380,7 @@ describe('List', () => {
// Set initial keywords to make clear button visible
mockQueryState.keywords = 'existing search'
render(<List />)
renderList()
// Find and click clear button (Input component uses .group class for clear icon container)
const clearButton = document.querySelector('.group')
@@ -375,12 +395,12 @@ describe('List', () => {
describe('Tag Filter', () => {
it('should render tag filter component', () => {
render(<List />)
renderList()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should render tag filter with placeholder', () => {
render(<List />)
renderList()
// Tag filter is rendered
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
@@ -389,12 +409,12 @@ describe('List', () => {
describe('Created By Me Filter', () => {
it('should render checkbox with correct label', () => {
render(<List />)
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should handle checkbox change', () => {
render(<List />)
renderList()
// Checkbox component uses data-testid="checkbox-{id}"
// CheckboxWithLabel doesn't pass testId, so id is undefined
@@ -409,7 +429,7 @@ describe('List', () => {
it('should not render new app card for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
render(<List />)
renderList()
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
})
@@ -417,7 +437,7 @@ describe('List', () => {
it('should not render drop DSL hint for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
render(<List />)
renderList()
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
})
@@ -427,7 +447,7 @@ describe('List', () => {
it('should redirect dataset operators to datasets page', () => {
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
render(<List />)
renderList()
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})
@@ -437,7 +457,7 @@ describe('List', () => {
it('should call refetch when refresh key is set in localStorage', () => {
localStorage.setItem('needRefreshAppList', '1')
render(<List />)
renderList()
expect(mockRefetch).toHaveBeenCalled()
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
@@ -446,22 +466,23 @@ describe('List', () => {
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = render(<List />)
const { unmount } = renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
rerender(<List />)
unmount()
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render app cards correctly', () => {
render(<List />)
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
it('should render with all filter options visible', () => {
render(<List />)
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
@@ -471,14 +492,14 @@ describe('List', () => {
describe('Dragging State', () => {
it('should show drop hint when DSL feature is enabled for editors', () => {
render(<List />)
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
})
describe('App Type Tabs', () => {
it('should render all app type tabs', () => {
render(<List />)
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
@@ -489,7 +510,7 @@ describe('List', () => {
})
it('should call setActiveTab for each app type', () => {
render(<List />)
renderList()
const appTypeTexts = [
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
@@ -508,7 +529,7 @@ describe('List', () => {
describe('Search and Filter Integration', () => {
it('should display search input with correct attributes', () => {
render(<List />)
renderList()
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
@@ -516,13 +537,13 @@ describe('List', () => {
})
it('should have tag filter component', () => {
render(<List />)
renderList()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should display created by me label', () => {
render(<List />)
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
@@ -530,14 +551,14 @@ describe('List', () => {
describe('App List Display', () => {
it('should display all app cards from data', () => {
render(<List />)
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should display app names correctly', () => {
render(<List />)
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
@@ -546,7 +567,7 @@ describe('List', () => {
describe('Footer Visibility', () => {
it('should render footer when branding is disabled', () => {
render(<List />)
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
@@ -558,14 +579,14 @@ describe('List', () => {
describe('Additional Coverage', () => {
it('should render dragging state overlay when dragging', () => {
mockDragging = true
const { container } = render(<List />)
const { container } = renderList()
// Component should render successfully with dragging state
expect(container).toBeInTheDocument()
})
it('should handle app mode filter in query params', () => {
render(<List />)
renderList()
const workflowTab = screen.getByText('app.types.workflow')
fireEvent.click(workflowTab)
@@ -574,7 +595,7 @@ describe('List', () => {
})
it('should render new app card for editors', () => {
render(<List />)
renderList()
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
@@ -582,7 +603,7 @@ describe('List', () => {
describe('DSL File Drop', () => {
it('should handle DSL file drop and show modal', () => {
render(<List />)
renderList()
// Simulate DSL file drop via the callback
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
@@ -596,7 +617,7 @@ describe('List', () => {
})
it('should close DSL modal when onClose is called', () => {
render(<List />)
renderList()
// Open modal via DSL file drop
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
@@ -614,7 +635,7 @@ describe('List', () => {
})
it('should close DSL modal and refetch when onSuccess is called', () => {
render(<List />)
renderList()
// Open modal via DSL file drop
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
@@ -637,7 +658,7 @@ describe('List', () => {
describe('Tag Filter Change', () => {
it('should handle tag filter value change', () => {
vi.useFakeTimers()
render(<List />)
renderList()
// TagFilter component is rendered
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
@@ -661,7 +682,7 @@ describe('List', () => {
it('should handle empty tag filter selection', () => {
vi.useFakeTimers()
render(<List />)
renderList()
// Trigger tag filter change with empty array
act(() => {
@@ -683,7 +704,7 @@ describe('List', () => {
describe('Infinite Scroll', () => {
it('should call fetchNextPage when intersection observer triggers', () => {
mockServiceState.hasNextPage = true
render(<List />)
renderList()
// Simulate intersection
if (intersectionCallback) {
@@ -700,7 +721,7 @@ describe('List', () => {
it('should not call fetchNextPage when not intersecting', () => {
mockServiceState.hasNextPage = true
render(<List />)
renderList()
// Simulate non-intersection
if (intersectionCallback) {
@@ -718,7 +739,7 @@ describe('List', () => {
it('should not call fetchNextPage when loading', () => {
mockServiceState.hasNextPage = true
mockServiceState.isLoading = true
render(<List />)
renderList()
if (intersectionCallback) {
act(() => {
@@ -736,7 +757,7 @@ describe('List', () => {
describe('Error State', () => {
it('should handle error state in useEffect', () => {
mockServiceState.error = new Error('Test error')
const { container } = render(<List />)
const { container } = renderList()
// Component should still render
expect(container).toBeInTheDocument()

View File

@@ -9,13 +9,14 @@ import {
RiMessage3Line,
RiRobot3Line,
} from '@remixicon/react'
import { useQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import dynamic from 'next/dynamic'
import {
useRouter,
} from 'next/navigation'
import { parseAsString, useQueryState } from 'nuqs'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Input from '@/app/components/base/input'
@@ -29,7 +30,7 @@ import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { CheckModal } from '@/hooks/use-pay'
import { DSLImportStatus } from '@/models/app'
import { importAppBundle } from '@/service/apps'
import { fetchWorkflowOnlineUsers, importAppBundle } from '@/service/apps'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
@@ -156,6 +157,37 @@ const List: FC<Props> = ({
refetch,
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
const apps = useMemo(() => data?.pages?.flatMap(page => page.data) ?? [], [data])
const workflowIds = useMemo(() => {
const ids = new Set<string>()
apps.forEach((appItem) => {
const workflowId = appItem.id
if (!workflowId)
return
if (appItem.mode === 'workflow' || appItem.mode === 'advanced-chat')
ids.add(workflowId)
})
return Array.from(ids)
}, [apps])
const { data: onlineUsersByWorkflow = {}, refetch: refreshOnlineUsers } = useQuery({
queryKey: ['apps', 'workflow-online-users', workflowIds],
queryFn: () => fetchWorkflowOnlineUsers({ workflowIds }),
enabled: workflowIds.length > 0,
})
useEffect(() => {
const timer = window.setInterval(() => {
refetch()
if (workflowIds.length)
refreshOnlineUsers()
}, 10000)
return () => window.clearInterval(timer)
}, [workflowIds, refetch, refreshOnlineUsers])
useEffect(() => {
if (controlRefreshList > 0) {
refetch()
@@ -294,7 +326,7 @@ const List: FC<Props> = ({
if (hasAnyApp) {
return pages.flatMap(({ data: apps }) => apps).map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} />
<AppCard key={app.id} app={app} onRefresh={refetch} onlineUsers={onlineUsersByWorkflow?.[app.id] ?? []} />
))
}

View File

@@ -35,12 +35,14 @@ describe('Avatar', () => {
it.each([
{ size: undefined, expected: '30px', label: 'default (30px)' },
{ size: 50, expected: '50px', label: 'custom (50px)' },
])('should apply $label size to img element', ({ size, expected }) => {
])('should apply $label size to avatar container', ({ size, expected }) => {
const props = { name: 'Test', avatar: 'https://example.com/avatar.jpg', size }
render(<Avatar {...props} />)
expect(screen.getByRole('img')).toHaveStyle({
const img = screen.getByRole('img')
const wrapper = img.parentElement as HTMLElement
expect(wrapper).toHaveStyle({
width: expected,
height: expected,
fontSize: expected,
@@ -60,7 +62,7 @@ describe('Avatar', () => {
})
describe('className prop', () => {
it('should merge className with default avatar classes on img', () => {
it('should merge className with default avatar classes on container', () => {
const props = {
name: 'Test',
avatar: 'https://example.com/avatar.jpg',
@@ -70,8 +72,9 @@ describe('Avatar', () => {
render(<Avatar {...props} />)
const img = screen.getByRole('img')
expect(img).toHaveClass('custom-class')
expect(img).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600')
const wrapper = img.parentElement as HTMLElement
expect(wrapper).toHaveClass('custom-class')
expect(wrapper).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600')
})
it('should merge className with default avatar classes on fallback div', () => {
@@ -277,10 +280,11 @@ describe('Avatar', () => {
render(<Avatar {...props} />)
const img = screen.getByRole('img')
const wrapper = img.parentElement as HTMLElement
expect(img).toHaveAttribute('alt', 'Test User')
expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg')
expect(img).toHaveStyle({ width: '64px', height: '64px' })
expect(img).toHaveClass('custom-avatar')
expect(wrapper).toHaveStyle({ width: '64px', height: '64px' })
expect(wrapper).toHaveClass('custom-avatar')
// Trigger load to verify onError callback
fireEvent.load(img)

View File

@@ -9,6 +9,7 @@ export type AvatarProps = {
className?: string
textClassName?: string
onError?: (x: boolean) => void
backgroundColor?: string
}
const Avatar = ({
name,
@@ -17,9 +18,18 @@ const Avatar = ({
className,
textClassName,
onError,
backgroundColor,
}: AvatarProps) => {
const avatarClassName = 'shrink-0 flex items-center rounded-full bg-primary-600'
const style = { width: `${size}px`, height: `${size}px`, fontSize: `${size}px`, lineHeight: `${size}px` }
const avatarClassName = backgroundColor
? 'shrink-0 flex items-center rounded-full'
: 'shrink-0 flex items-center rounded-full bg-primary-600'
const style = {
width: `${size}px`,
height: `${size}px`,
fontSize: `${size}px`,
lineHeight: `${size}px`,
...(backgroundColor && !avatar ? { backgroundColor } : {}),
}
const [imgError, setImgError] = useState(false)
const handleError = () => {
@@ -35,14 +45,18 @@ const Avatar = ({
if (avatar && !imgError) {
return (
<img
<span
className={cn(avatarClassName, className)}
style={style}
alt={name}
src={avatar}
onError={handleError}
onLoad={() => onError?.(false)}
/>
>
<img
className="h-full w-full rounded-full object-cover"
alt={name}
src={avatar}
onError={handleError}
onLoad={() => onError?.(false)}
/>
</span>
)
}

View File

@@ -19,7 +19,7 @@ const ContentDialog = ({
<Transition
show={show}
as="div"
className="absolute left-0 top-0 z-30 box-border h-full w-full p-2"
className="absolute left-0 top-0 z-[70] box-border h-full w-full p-2"
>
<TransitionChild>
<div

View File

@@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 527 B

View File

@@ -0,0 +1,36 @@
{
"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"
}

View File

@@ -0,0 +1,20 @@
// 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

View File

@@ -1,6 +1,7 @@
export { default as D } from './D'
export { default as DiagonalDividingLine } from './DiagonalDividingLine'
export { default as Dify } from './Dify'
export { default as EnterKey } from './EnterKey'
export { default as Gdpr } from './Gdpr'
export { default as Github } from './Github'
export { default as Highlight } from './Highlight'

View File

@@ -0,0 +1,26 @@
{
"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"
}

View File

@@ -0,0 +1,20 @@
// 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

View File

@@ -1,3 +1,4 @@
export { default as Comment } from './Comment'
export { default as DefaultToolIcon } from './DefaultToolIcon'
export { default as Icon3Dots } from './Icon3Dots'
export { default as Message3Fill } from './Message3Fill'

View File

@@ -18,6 +18,7 @@ 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'
@@ -93,6 +94,29 @@ 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
@@ -357,6 +381,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
<VariableValueBlock />
)
}
<ValueSyncPlugin value={value} />
<OnChangePlugin onChange={handleEditorChange} />
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
<UpdateBlock instanceId={instanceId} />

View File

@@ -0,0 +1,79 @@
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'

View File

@@ -144,7 +144,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
{t('modelProvider.systemModelSettings', { ns: 'common' })}
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[60]">
<PortalToFollowElemContent className="z-[75]">
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg pt-4 shadow-xl">
<div className="px-6 py-1">
<div className="flex h-8 items-center text-[13px] font-medium text-text-primary">

View File

@@ -76,7 +76,7 @@ const ActionList = ({
className='w-full'
onClick={() => setShowSettingAuth(true)}
disabled={!isCurrentWorkspaceManager}
>{t('workflow.nodes.tool.authorize')}</Button>
>{t('nodes.tool.authorize', { ns: 'workflow' })}</Button>
)} */}
</div>
{/* <div className='flex flex-col gap-2'>

View File

@@ -24,7 +24,7 @@ export const useAvailableNodesMetaData = () => {
},
knowledgeBaseDefault,
dataSourceEmptyDefault,
], [])
] as AvailableNodesMetaData['nodes'], [])
const helpLinkUri = useMemo(() => docLink(
'/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration',

View File

@@ -3,8 +3,14 @@ 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}`,

View File

@@ -10,6 +10,7 @@ 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,
@@ -59,6 +60,22 @@ 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 = {
@@ -71,6 +88,7 @@ const MCPServerModal = ({
await createMCPServer(payload)
invalidateMCPServerDetail(appID)
emitMcpServerUpdate('created')
onHide()
}
else {
@@ -83,6 +101,7 @@ const MCPServerModal = ({
payload.description = description
await updateMCPServer(payload)
invalidateMCPServerDetail(appID)
emitMcpServerUpdate('updated')
onHide()
}
}
@@ -92,6 +111,7 @@ 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" />

View File

@@ -1,6 +1,8 @@
'use client'
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
import type { InputVar } from '@/app/components/workflow/types'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import type { AppSSO, ModelConfig, UserInputFormItem } from '@/types/app'
import { RiEditLine, RiLoopLeftLine } from '@remixicon/react'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
@@ -16,6 +18,8 @@ import Switch from '@/app/components/base/switch'
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 { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
@@ -36,6 +40,16 @@ export type IAppCardProps = {
triggerModeMessage?: React.ReactNode // display-only message explaining the trigger restriction
}
type BasicAppConfig = Partial<ModelConfig> & {
updated_at?: number
}
type McpServerParam = {
label: string
variable: string
type: string
}
function MCPServiceCard({
appInfo,
triggerModeDisabled = false,
@@ -54,16 +68,16 @@ function MCPServiceCard({
const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW
const isBasicApp = !isAdvancedApp
const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '')
const [basicAppConfig, setBasicAppConfig] = useState<any>({})
const basicAppInputForm = useMemo(() => {
if (!isBasicApp || !basicAppConfig?.user_input_form)
const [basicAppConfig, setBasicAppConfig] = useState<BasicAppConfig>({})
const basicAppInputForm = useMemo<McpServerParam[]>(() => {
if (!isBasicApp || !basicAppConfig.user_input_form)
return []
return basicAppConfig.user_input_form.map((item: any) => {
const type = Object.keys(item)[0]
return {
...item[type],
type: type || 'text-input',
}
return basicAppConfig.user_input_form.map((item: UserInputFormItem) => {
if ('text-input' in item)
return { label: item['text-input'].label, variable: item['text-input'].variable, type: 'text-input' }
if ('select' in item)
return { label: item.select.label, variable: item.select.variable, type: 'select' }
return { label: item.paragraph.label, variable: item.paragraph.variable, type: 'paragraph' }
})
}, [basicAppConfig.user_input_form, isBasicApp])
useEffect(() => {
@@ -90,12 +104,22 @@ function MCPServiceCard({
const [activated, setActivated] = useState(serverActivated)
const latestParams = useMemo(() => {
const latestParams = useMemo<McpServerParam[]>(() => {
if (isAdvancedApp) {
if (!currentWorkflow?.graph)
return []
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as any
return startNode?.data.variables as any[] || []
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start)
const variables = (startNode?.data as { variables?: InputVar[] } | undefined)?.variables || []
return variables.map((variable) => {
const label = typeof variable.label === 'string'
? variable.label
: (variable.label.variable || variable.label.nodeName)
return {
label,
variable: variable.variable,
type: variable.type,
}
})
}
return basicAppInputForm
}, [currentWorkflow, basicAppInputForm, isAdvancedApp])
@@ -103,6 +127,19 @@ function MCPServiceCard({
const onGenCode = async () => {
await refreshMCPServerCode(detail?.id || '')
invalidateMCPServerDetail(appId)
// Emit collaboration event to notify other clients of MCP server changes
const socket = webSocketClient.getSocket(appId)
if (socket) {
socket.emit('collaboration_event', {
type: 'mcp_server_update',
data: {
action: 'codeRegenerated',
timestamp: Date.now(),
},
timestamp: Date.now(),
})
}
}
const onChangeStatus = async (state: boolean) => {
@@ -132,6 +169,20 @@ function MCPServiceCard({
})
invalidateMCPServerDetail(appId)
}
// Emit collaboration event to notify other clients of MCP server status change
const socket = webSocketClient.getSocket(appId)
if (socket) {
socket.emit('collaboration_event', {
type: 'mcp_server_update',
data: {
action: 'statusChanged',
status: state ? 'active' : 'inactive',
timestamp: Date.now(),
},
timestamp: Date.now(),
})
}
}
const handleServerModalHide = () => {
@@ -144,6 +195,23 @@ function MCPServiceCard({
setActivated(serverActivated)
}, [serverActivated])
// Listen for collaborative MCP server updates from other clients
useEffect(() => {
if (!appId)
return
const unsubscribe = collaborationManager.onMcpServerUpdate(async (_update: CollaborationUpdate) => {
try {
invalidateMCPServerDetail(appId)
}
catch (error) {
console.error('MCP server update failed:', error)
}
})
return unsubscribe
}, [appId, invalidateMCPServerDetail])
if (!currentWorkflow && isAdvancedApp)
return null

View File

@@ -108,7 +108,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?.({ title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ url: '/apps/app-id/workflows/publish', title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
publisher-publish-with-params
</button>
</div>

View File

@@ -1,3 +1,4 @@
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 {
@@ -140,24 +141,38 @@ const FeaturesTrigger = () => {
const needWarningNodes = useChecklist(nodes, edges)
const updatePublishedWorkflow = useInvalidateAppWorkflow()
const onPublish = useCallback(async (params?: PublishWorkflowParams) => {
const onPublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
const publishParams = params && 'title' in params ? params : undefined
// First check if there are any items in the checklist
// if (!validateBeforeRun())
// throw new Error('Checklist has unresolved items')
if (needWarningNodes.length > 0) {
console.warn('[workflow-header] publish blocked by checklist', {
appId: appID,
warningCount: needWarningNodes.length,
})
notify({ type: 'error', message: t('panel.checklistTip', { ns: 'workflow' }) })
throw new Error('Checklist has unresolved items')
}
// 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: params?.title || '',
releaseNotes: params?.releaseNotes || '',
title: publishParams?.title || '',
releaseNotes: publishParams?.releaseNotes || '',
})
console.warn('[workflow-header] publish response', {
appId: appID,
hasResponse: Boolean(res),
createdAt: res?.created_at,
})
if (res) {
notify({ type: 'success', message: t('api.actionSuccess', { ns: 'common' }) })
updatePublishedWorkflow(appID!)

View File

@@ -1,13 +1,26 @@
import type { ReactNode } from 'react'
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 { useFeatures, 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 { MCPToolAvailabilityProvider } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { fetchWorkflowDraft } from '@/service/workflow'
import {
useAvailableNodesMetaData,
useConfigsMap,
@@ -25,6 +38,7 @@ import WorkflowChildren from './workflow-children'
type WorkflowMainProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'> & {
headerLeftSlot?: ReactNode
}
type WorkflowDataUpdatePayload = Pick<FetchWorkflowDraftResponse, 'features' | 'conversation_variables' | 'environment_variables'>
const WorkflowMain = ({
nodes,
edges,
@@ -34,8 +48,48 @@ const WorkflowMain = ({
const sandboxEnabled = useFeatures(state => state.features.sandbox?.enabled) ?? false
const featuresStore = useFeaturesStore()
const workflowStore = useWorkflowStore()
const appId = useStore(s => s.appId)
const containerRef = useRef<HTMLDivElement>(null)
const reactFlow = useReactFlow()
const handleWorkflowDataUpdate = useCallback((payload: any) => {
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 {
features,
conversation_variables,
@@ -44,7 +98,33 @@ const WorkflowMain = ({
if (features && featuresStore) {
const { setFeatures } = featuresStore.getState()
setFeatures(features)
const transformedFeatures: FeaturesData = {
file: {
image: {
enabled: !!features.file_upload?.image?.enabled,
number_limits: features.file_upload?.image?.number_limits || 3,
transfer_methods: features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
},
enabled: !!(features.file_upload?.enabled || features.file_upload?.image?.enabled),
allowed_file_types: features.file_upload?.allowed_file_types || [SupportUploadFileTypes.image],
allowed_file_extensions: features.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
allowed_file_upload_methods: features.file_upload?.allowed_file_upload_methods || features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
number_limits: features.file_upload?.number_limits || features.file_upload?.image?.number_limits || 3,
},
opening: {
enabled: !!features.opening_statement,
opening_statement: features.opening_statement,
suggested_questions: features.suggested_questions,
},
suggested: features.suggested_questions_after_answer || { enabled: false },
speech2text: features.speech_to_text || { enabled: false },
text2speech: features.text_to_speech || { enabled: false },
citation: features.retriever_resource || { enabled: false },
moderation: features.sensitive_word_avoidance || { enabled: false },
annotationReply: features.annotation_reply || { enabled: false },
}
setFeatures(transformedFeatures)
}
if (conversation_variables) {
const { setConversationVariables } = workflowStore.getState()
@@ -61,6 +141,7 @@ const WorkflowMain = ({
syncWorkflowDraftWhenPageClose,
} = useNodesSyncDraft()
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
const {
handleBackupDraft,
handleLoadBackupDraft,
@@ -68,6 +149,64 @@ 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,
@@ -85,6 +224,7 @@ const WorkflowMain = ({
} = useDSL()
const configsMap = useConfigsMap()
const { fetchInspectVars } = useSetWorkflowVarsWithValue({
...configsMap,
})
@@ -105,7 +245,7 @@ const WorkflowMain = ({
invalidateConversationVarValues,
} = useInspectVarsCrud()
const hooksStore = useMemo(() => {
const hooksStore = useMemo<Partial<HooksStoreShape>>(() => {
return {
syncWorkflowDraftWhenPageClose,
doSyncWorkflowDraft,
@@ -182,17 +322,25 @@ const WorkflowMain = ({
])
return (
<WorkflowWithInnerContext
nodes={nodes}
edges={edges}
viewport={viewport}
onWorkflowDataUpdate={handleWorkflowDataUpdate}
hooksStore={hooksStore as any}
<div
ref={containerRef}
className="relative h-full w-full"
>
<MCPToolAvailabilityProvider sandboxEnabled={sandboxEnabled}>
<WorkflowChildren headerLeftSlot={headerLeftSlot} />
</MCPToolAvailabilityProvider>
</WorkflowWithInnerContext>
<WorkflowWithInnerContext
nodes={nodes}
edges={edges}
viewport={viewport}
onWorkflowDataUpdate={handleWorkflowDataUpdate}
hooksStore={hooksStore}
cursors={filteredCursors}
myUserId={myUserId}
onlineUsers={onlineUsers}
>
<MCPToolAvailabilityProvider sandboxEnabled={sandboxEnabled}>
<WorkflowChildren headerLeftSlot={headerLeftSlot} />
</MCPToolAvailabilityProvider>
</WorkflowWithInnerContext>
</div>
)
}

View File

@@ -7,6 +7,7 @@ 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 {
useIsChatMode,
@@ -67,6 +68,7 @@ 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 (
<>
@@ -100,6 +102,7 @@ const WorkflowPanelOnRight = () => {
<GlobalVariablePanel />
)
}
{controlMode === 'comment' && <CommentsPanel />}
</>
)
}

View File

@@ -40,7 +40,7 @@ export const useAvailableNodesMetaData = () => {
TriggerPluginDefault,
]
),
], [isChatMode, startNodeMetaData])
] as AvailableNodesMetaData['nodes'], [isChatMode, startNodeMetaData])
const availableNodesMetaData = useMemo<NodeDefaultBase[]>(() => {
const toNodeDefaultBase = (

View File

@@ -3,8 +3,14 @@ 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}`,

View File

@@ -1,11 +1,15 @@
import type { WorkflowDraftFeaturesPayload } from '@/service/workflow'
import { produce } from 'immer'
import { useParams } from 'next/navigation'
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 { syncWorkflowDraft } from '@/service/workflow'
import { useWorkflowRefreshDraft } from '.'
@@ -15,6 +19,8 @@ export const useNodesSyncDraft = () => {
const featuresStore = useFeaturesStore()
const { getNodesReadOnly } = useNodesReadOnly()
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const params = useParams()
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
const getPostParams = useCallback(() => {
const {
@@ -52,7 +58,16 @@ export const useNodesSyncDraft = () => {
})
})
})
const viewport = { x, y, zoom }
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,
}
return {
url: `/apps/${appId}/workflows/draft`,
@@ -60,34 +75,41 @@ export const useNodesSyncDraft = () => {
graph: {
nodes: producedNodes,
edges: producedEdges,
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,
sandbox: features.sandbox,
viewport: {
x,
y,
zoom,
},
},
features: featuresPayload,
environment_variables: environmentVariables,
conversation_variables: conversationVariables,
hash: syncWorkflowDraftHash,
_is_collaborative: isCollaborationEnabled,
},
}
}, [store, featuresStore, workflowStore])
}, [store, featuresStore, workflowStore, isCollaborationEnabled])
const syncWorkflowDraftWhenPageClose = useCallback(() => {
if (getNodesReadOnly())
return
// Check leader status at sync time
const currentIsLeader = isCollaborationEnabled ? collaborationManager.getIsLeader() : true
// Only allow leader to sync data
if (isCollaborationEnabled && !currentIsLeader)
return
const postParams = getPostParams()
if (postParams)
navigator.sendBeacon(`${API_PREFIX}${postParams.url}`, JSON.stringify(postParams.params))
}, [getPostParams, getNodesReadOnly])
if (postParams) {
navigator.sendBeacon(
`${API_PREFIX}/apps/${params.appId}/workflows/draft`,
JSON.stringify(postParams.params),
)
}
}, [getPostParams, params.appId, getNodesReadOnly, isCollaborationEnabled])
const performSync = useCallback(async (
notRefreshWhenSyncError?: boolean,
@@ -99,6 +121,17 @@ export const useNodesSyncDraft = () => {
) => {
if (getNodesReadOnly())
return
// Check leader status at sync time
const currentIsLeader = isCollaborationEnabled ? collaborationManager.getIsLeader() : true
// If not leader, request the leader to sync
if (isCollaborationEnabled && !currentIsLeader) {
if (isCollaborationEnabled)
collaborationManager.emitSyncRequest()
callback?.onSettled?.()
return
}
const postParams = getPostParams()
if (postParams) {
@@ -106,6 +139,7 @@ export const useNodesSyncDraft = () => {
setSyncWorkflowDraftHash,
setDraftUpdatedAt,
} = workflowStore.getState()
try {
const res = await syncWorkflowDraft({
...postParams,
@@ -128,7 +162,7 @@ export const useNodesSyncDraft = () => {
callback?.onSettled?.()
}
}
}, [workflowStore, getPostParams, getNodesReadOnly, handleRefreshWorkflowDraft])
}, [workflowStore, getPostParams, getNodesReadOnly, handleRefreshWorkflowDraft, isCollaborationEnabled])
const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly)
const syncWorkflowDraftImmediately = useCallback((

View File

@@ -18,6 +18,7 @@ import { FeaturesProvider } from '@/app/components/base/features'
import Loading from '@/app/components/base/loading'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import WorkflowWithDefaultContext from '@/app/components/workflow'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import {
WorkflowContextProvider,
} from '@/app/components/workflow/context'
@@ -185,15 +186,20 @@ const WorkflowAppWithAdditionalContext = () => {
}, [workflowStore])
const nodesData = useMemo(() => {
if (data)
return initialNodes(data.graph.nodes, data.graph.edges)
if (data) {
const processedNodes = initialNodes(data.graph.nodes, data.graph.edges)
collaborationManager.setNodes([], processedNodes)
return processedNodes
}
return []
}, [data])
const edgesData = useMemo(() => {
if (data)
return initialEdges(data.graph.edges, data.graph.nodes)
const edgesData = useMemo(() => {
if (data) {
const processedEdges = initialEdges(data.graph.edges, data.graph.nodes)
collaborationManager.setEdges([], processedEdges)
return processedEdges
}
return []
}, [data])

View File

@@ -35,7 +35,7 @@ const NodeSelectorWrapper = (props: NodeSelectorProps) => {
return true
})
}, [availableNodesMetaData?.nodes])
}, [availableNodesMetaData?.nodes]) as NodeSelectorProps['blocks']
return (
<NodeSelector

View File

@@ -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,7 +32,6 @@ type Props = {
const CandidateNodeMain: FC<Props> = ({
candidateNode,
}) => {
const store = useStoreApi()
const reactflow = useReactFlow()
const workflowStore = useWorkflowStore()
const mousePosition = useStore(s => s.mousePosition)
@@ -41,15 +40,12 @@ 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 = getNodes()
const { nodes, setNodes } = collaborativeWorkflow.getState()
const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
const newNodes = produce(nodes, (draft) => {
draft.push({

View File

@@ -0,0 +1,78 @@
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

View File

@@ -0,0 +1,331 @@
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)
})
})

Some files were not shown because too many files have changed in this diff Show More