mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 17:49:23 +08:00
Merge branch 'feat/collaboration2' into feat/support-agent-sandbox
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
171
api/README.md
171
api/README.md
@@ -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 site’s 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 site’s 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
|
||||
```
|
||||
|
||||
19
api/app.py
19
api/app.py
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}
|
||||
|
||||
317
api/controllers/console/app/workflow_comment.py
Normal file
317
api/controllers/console/app/workflow_comment.py
Normal 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}
|
||||
@@ -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"}
|
||||
|
||||
1
api/controllers/console/socketio/__init__.py
Normal file
1
api/controllers/console/socketio/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
108
api/controllers/console/socketio/workflow.py
Normal file
108
api/controllers/console/socketio/workflow.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -301,7 +301,7 @@ class DatasourceNode(Node[DatasourceNodeData]):
|
||||
|
||||
text = ""
|
||||
files: list[File] = []
|
||||
json: list[dict] = []
|
||||
json: list[dict | list] = []
|
||||
|
||||
variables: dict[str, Any] = {}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
5
api/extensions/ext_socketio.py
Normal file
5
api/extensions/ext_socketio.py
Normal 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)
|
||||
17
api/fields/online_user_fields.py
Normal file
17
api/fields/online_user_fields.py
Normal 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)),
|
||||
}
|
||||
96
api/fields/workflow_comment_fields.py
Normal file
96
api/fields/workflow_comment_fields.py
Normal 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,
|
||||
}
|
||||
@@ -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 ###
|
||||
@@ -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
210
api/models/comment.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
147
api/repositories/workflow_collaboration_repository.py
Normal file
147
api/repositories/workflow_collaboration_repository.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 != ""
|
||||
|
||||
@@ -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")
|
||||
|
||||
196
api/services/workflow_collaboration_service.py
Normal file
196
api/services/workflow_collaboration_service.py
Normal 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
|
||||
345
api/services/workflow_comment_service.py
Normal file
345
api/services/workflow_comment_service.py
Normal 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)
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
245
api/tests/unit_tests/services/test_workflow_comment_service.py
Normal file
245
api/tests/unit_tests/services/test_workflow_comment_service.py
Normal 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()
|
||||
@@ -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
671
api/uv.lock
generated
File diff suppressed because it is too large
Load Diff
28
dev/setup
Executable file
28
dev/setup
Executable 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
|
||||
@@ -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
8
dev/start-docker-compose
Executable 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
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
# ------------------------------
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -14,6 +14,14 @@ server {
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /socket.io/ {
|
||||
proxy_pass http://api:5001;
|
||||
include proxy.conf;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location /v1 {
|
||||
proxy_pass http://api:5001;
|
||||
include proxy.conf;
|
||||
|
||||
@@ -5,7 +5,7 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
# proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout ${NGINX_PROXY_READ_TIMEOUT};
|
||||
proxy_send_timeout ${NGINX_PROXY_SEND_TIMEOUT};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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] ?? []} />
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
20
web/app/components/base/icons/src/public/common/EnterKey.tsx
Normal file
20
web/app/components/base/icons/src/public/common/EnterKey.tsx
Normal 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
|
||||
@@ -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'
|
||||
|
||||
26
web/app/components/base/icons/src/public/other/Comment.json
Normal file
26
web/app/components/base/icons/src/public/other/Comment.json
Normal 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"
|
||||
}
|
||||
20
web/app/components/base/icons/src/public/other/Comment.tsx
Normal file
20
web/app/components/base/icons/src/public/other/Comment.tsx
Normal 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
|
||||
@@ -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'
|
||||
|
||||
@@ -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} />
|
||||
|
||||
79
web/app/components/base/user-avatar-list/index.tsx
Normal file
79
web/app/components/base/user-avatar-list/index.tsx
Normal 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'
|
||||
@@ -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">
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const useAvailableNodesMetaData = () => {
|
||||
},
|
||||
knowledgeBaseDefault,
|
||||
dataSourceEmptyDefault,
|
||||
], [])
|
||||
] as AvailableNodesMetaData['nodes'], [])
|
||||
|
||||
const helpLinkUri = useMemo(() => docLink(
|
||||
'/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration',
|
||||
|
||||
@@ -3,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}`,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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!)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export const useAvailableNodesMetaData = () => {
|
||||
TriggerPluginDefault,
|
||||
]
|
||||
),
|
||||
], [isChatMode, startNodeMetaData])
|
||||
] as AvailableNodesMetaData['nodes'], [isChatMode, startNodeMetaData])
|
||||
|
||||
const availableNodesMetaData = useMemo<NodeDefaultBase[]>(() => {
|
||||
const toNodeDefaultBase = (
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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((
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ const NodeSelectorWrapper = (props: NodeSelectorProps) => {
|
||||
|
||||
return true
|
||||
})
|
||||
}, [availableNodesMetaData?.nodes])
|
||||
}, [availableNodesMetaData?.nodes]) as NodeSelectorProps['blocks']
|
||||
|
||||
return (
|
||||
<NodeSelector
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
} from 'react'
|
||||
import {
|
||||
useReactFlow,
|
||||
useStoreApi,
|
||||
useViewport,
|
||||
} from 'reactflow'
|
||||
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
|
||||
import { CUSTOM_NODE } from './constants'
|
||||
import { useAutoGenerateWebhookUrl, useNodesInteractions, useNodesSyncDraft, useWorkflowHistory, WorkflowHistoryEvent } from './hooks'
|
||||
import CustomNode from './nodes'
|
||||
@@ -32,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({
|
||||
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user