Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 53a22aa

Browse files
hjlarryautofix-ci[bot]gemini-code-assist[bot]lyzno1
authored
feat: collaboration (#30781)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: yyh <[email protected]> Co-authored-by: yyh <[email protected]>
1 parent cf4d7af commit 53a22aa

262 files changed

Lines changed: 21060 additions & 798 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.vscode/launch.json.template

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,10 @@
22
"version": "0.2.0",
33
"configurations": [
44
{
5-
"name": "Python: Flask API",
5+
"name": "Python: API (gevent)",
66
"type": "debugpy",
77
"request": "launch",
8-
"module": "flask",
9-
"env": {
10-
"FLASK_APP": "app.py",
11-
"FLASK_ENV": "development"
12-
},
13-
"args": [
14-
"run",
15-
"--host=0.0.0.0",
16-
"--port=5001",
17-
"--no-debugger",
18-
"--no-reload"
19-
],
8+
"program": "${workspaceFolder}/api/app.py",
209
"jinja": true,
2110
"justMyCode": true,
2211
"cwd": "${workspaceFolder}/api",

api/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ TRIGGER_URL=http://localhost:5001
3333
# The time in seconds after the signature is rejected
3434
FILES_ACCESS_TIMEOUT=300
3535

36+
# Collaboration mode toggle
37+
ENABLE_COLLABORATION_MODE=false
38+
3639
# Access token expiration time in minutes
3740
ACCESS_TOKEN_EXPIRE_MINUTES=60
3841

api/.vscode/launch.json.example

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,21 @@
33
"compounds": [
44
{
55
"name": "Launch Flask and Celery",
6-
"configurations": ["Python: Flask", "Python: Celery"]
6+
"configurations": ["Python: API (gevent)", "Python: Celery"]
77
}
88
],
99
"configurations": [
1010
{
11-
"name": "Python: Flask",
12-
"consoleName": "Flask",
11+
"name": "Python: API (gevent)",
12+
"consoleName": "API",
1313
"type": "debugpy",
1414
"request": "launch",
1515
"python": "${workspaceFolder}/.venv/bin/python",
1616
"cwd": "${workspaceFolder}",
1717
"envFile": ".env",
18-
"module": "flask",
18+
"program": "${workspaceFolder}/app.py",
1919
"justMyCode": true,
20-
"jinja": true,
21-
"env": {
22-
"FLASK_APP": "app.py",
23-
"GEVENT_SUPPORT": "True"
24-
},
25-
"args": [
26-
"run",
27-
"--port=5001"
28-
]
20+
"jinja": true
2921
},
3022
{
3123
"name": "Python: Celery",

api/app.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import logging
34
import sys
45
from typing import TYPE_CHECKING, cast
56

@@ -9,17 +10,35 @@
910
celery: Celery
1011

1112

13+
HOST = "0.0.0.0"
14+
PORT = 5001
15+
logger = logging.getLogger(__name__)
16+
17+
1218
def is_db_command() -> bool:
1319
if len(sys.argv) > 1 and sys.argv[0].endswith("flask") and sys.argv[1] == "db":
1420
return True
1521
return False
1622

1723

24+
def log_startup_banner(host: str, port: int) -> None:
25+
debugger_attached = sys.gettrace() is not None
26+
logger.info("Serving Dify API via gevent WebSocket server")
27+
logger.info("Bound to http://%s:%s", host, port)
28+
logger.info("Debugger attached: %s", "on" if debugger_attached else "off")
29+
logger.info("Press CTRL+C to quit")
30+
31+
1832
# create app
33+
flask_app = None
34+
socketio_app = None
35+
1936
if is_db_command():
2037
from app_factory import create_migrations_app
2138

2239
app = create_migrations_app()
40+
socketio_app = app
41+
flask_app = app
2342
else:
2443
# Gunicorn and Celery handle monkey patching automatically in production by
2544
# specifying the `gevent` worker class. Manual monkey patching is not required here.
@@ -30,8 +49,14 @@ def is_db_command() -> bool:
3049

3150
from app_factory import create_app
3251

33-
app = create_app()
52+
socketio_app, flask_app = create_app()
53+
app = flask_app
3454
celery = cast("Celery", app.extensions["celery"])
3555

3656
if __name__ == "__main__":
37-
app.run(host="0.0.0.0", port=5001)
57+
from gevent import pywsgi
58+
from geventwebsocket.handler import WebSocketHandler # type: ignore[reportMissingTypeStubs]
59+
60+
log_startup_banner(HOST, PORT)
61+
server = pywsgi.WSGIServer((HOST, PORT), socketio_app, handler_class=WebSocketHandler)
62+
server.serve_forever()

api/app_factory.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
import time
33

4+
import socketio # type: ignore[reportMissingTypeStubs]
45
from flask import request
56
from opentelemetry.trace import get_current_span
67
from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID
@@ -10,6 +11,7 @@
1011
from controllers.console.error import UnauthorizedAndForceLogout
1112
from core.logging.context import init_request_context
1213
from dify_app import DifyApp
14+
from extensions.ext_socketio import sio
1315
from services.enterprise.enterprise_service import EnterpriseService
1416
from services.feature_service import LicenseStatus
1517

@@ -122,14 +124,18 @@ def add_trace_headers(response):
122124
return dify_app
123125

124126

125-
def create_app() -> DifyApp:
127+
def create_app() -> tuple[socketio.WSGIApp, DifyApp]:
126128
start_time = time.perf_counter()
127129
app = create_flask_app_with_configs()
128130
initialize_extensions(app)
131+
132+
sio.app = app
133+
socketio_app = socketio.WSGIApp(sio, app)
134+
129135
end_time = time.perf_counter()
130136
if dify_config.DEBUG:
131137
logger.info("Finished create_app (%s ms)", round((end_time - start_time) * 1000, 2))
132-
return app
138+
return socketio_app, app
133139

134140

135141
def initialize_extensions(app: DifyApp):

api/configs/feature/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,13 @@ def POSITION_TOOL_EXCLUDES_SET(self) -> set[str]:
12741274
return {item.strip() for item in self.POSITION_TOOL_EXCLUDES.split(",") if item.strip() != ""}
12751275

12761276

1277+
class CollaborationConfig(BaseSettings):
1278+
ENABLE_COLLABORATION_MODE: bool = Field(
1279+
description="Whether to enable collaboration mode features across the workspace",
1280+
default=False,
1281+
)
1282+
1283+
12771284
class LoginConfig(BaseSettings):
12781285
ENABLE_EMAIL_CODE_LOGIN: bool = Field(
12791286
description="whether to enable email code login",
@@ -1399,6 +1406,7 @@ class FeatureConfig(
13991406
WorkflowConfig,
14001407
WorkflowNodeExecutionConfig,
14011408
WorkspaceConfig,
1409+
CollaborationConfig,
14021410
LoginConfig,
14031411
AccountConfig,
14041412
SwaggerUIConfig,

api/controllers/console/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
statistic,
6666
workflow,
6767
workflow_app_log,
68+
workflow_comment,
6869
workflow_draft_variable,
6970
workflow_run,
7071
workflow_statistic,
@@ -116,6 +117,7 @@
116117
saved_message,
117118
trial,
118119
)
120+
from .socketio import workflow as socketio_workflow # pyright: ignore[reportUnusedImport]
119121

120122
# Import tag controllers
121123
from .tag import tags
@@ -201,6 +203,7 @@
201203
"saved_message",
202204
"setup",
203205
"site",
206+
"socketio_workflow",
204207
"spec",
205208
"statistic",
206209
"tags",
@@ -211,6 +214,7 @@
211214
"website",
212215
"workflow",
213216
"workflow_app_log",
217+
"workflow_comment",
214218
"workflow_draft_variable",
215219
"workflow_run",
216220
"workflow_statistic",

api/controllers/console/app/workflow.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from flask_restx import Resource, fields, marshal, marshal_with
88
from graphon.enums import NodeType
99
from graphon.file import File
10+
from graphon.file import helpers as file_helpers
1011
from graphon.graph_engine.manager import GraphEngineManager
1112
from graphon.model_runtime.utils.encoders import jsonable_encoder
1213
from pydantic import BaseModel, Field, ValidationError, field_validator
@@ -39,6 +40,7 @@
3940
from extensions.ext_redis import redis_client
4041
from factories import file_factory, variable_factory
4142
from fields.member_fields import simple_account_fields
43+
from fields.online_user_fields import online_user_list_fields
4244
from fields.workflow_fields import workflow_fields, workflow_pagination_fields
4345
from libs import helper
4446
from libs.datetime_utils import naive_utc_now
@@ -47,6 +49,7 @@
4749
from models import App
4850
from models.model import AppMode
4951
from models.workflow import Workflow
52+
from repositories.workflow_collaboration_repository import WORKFLOW_ONLINE_USERS_PREFIX
5053
from services.app_generate_service import AppGenerateService
5154
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
5255
from services.errors.llm import InvokeRateLimitError
@@ -57,6 +60,7 @@
5760
LISTENING_RETRY_IN = 2000
5861
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
5962
RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE = "source workflow must be published"
63+
MAX_WORKFLOW_ONLINE_USERS_QUERY_IDS = 50
6064

6165
# Register models for flask_restx to avoid dict type issues in Swagger
6266
# Register in dependency order: base models first, then dependent models
@@ -150,6 +154,14 @@ class ConvertToWorkflowPayload(BaseModel):
150154
icon_background: str | None = None
151155

152156

157+
class WorkflowFeaturesPayload(BaseModel):
158+
features: dict[str, Any] = Field(..., description="Workflow feature configuration")
159+
160+
161+
class WorkflowOnlineUsersQuery(BaseModel):
162+
app_ids: str = Field(..., description="Comma-separated app IDs")
163+
164+
153165
class DraftWorkflowTriggerRunPayload(BaseModel):
154166
node_id: str
155167

@@ -173,6 +185,8 @@ def reg(cls: type[BaseModel]):
173185
reg(ConvertToWorkflowPayload)
174186
reg(WorkflowListQuery)
175187
reg(WorkflowUpdatePayload)
188+
reg(WorkflowFeaturesPayload)
189+
reg(WorkflowOnlineUsersQuery)
176190
reg(DraftWorkflowTriggerRunPayload)
177191
reg(DraftWorkflowTriggerRunAllPayload)
178192

@@ -931,6 +945,32 @@ def post(self, app_model: App):
931945
}
932946

933947

948+
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/features")
949+
class WorkflowFeaturesApi(Resource):
950+
"""Update draft workflow features."""
951+
952+
@console_ns.expect(console_ns.models[WorkflowFeaturesPayload.__name__])
953+
@console_ns.doc("update_workflow_features")
954+
@console_ns.doc(description="Update draft workflow features")
955+
@console_ns.doc(params={"app_id": "Application ID"})
956+
@console_ns.response(200, "Workflow features updated successfully")
957+
@setup_required
958+
@login_required
959+
@account_initialization_required
960+
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
961+
@edit_permission_required
962+
def post(self, app_model: App):
963+
current_user, _ = current_account_with_tenant()
964+
965+
args = WorkflowFeaturesPayload.model_validate(console_ns.payload or {})
966+
features = args.features
967+
968+
workflow_service = WorkflowService()
969+
workflow_service.update_draft_workflow_features(app_model=app_model, features=features, account=current_user)
970+
971+
return {"result": "success"}
972+
973+
934974
@console_ns.route("/apps/<uuid:app_id>/workflows")
935975
class PublishedAllWorkflowApi(Resource):
936976
@console_ns.expect(console_ns.models[WorkflowListQuery.__name__])
@@ -1340,3 +1380,62 @@ def post(self, app_model: App):
13401380
"status": "error",
13411381
}
13421382
), 400
1383+
1384+
1385+
@console_ns.route("/apps/workflows/online-users")
1386+
class WorkflowOnlineUsersApi(Resource):
1387+
@console_ns.expect(console_ns.models[WorkflowOnlineUsersQuery.__name__])
1388+
@console_ns.doc("get_workflow_online_users")
1389+
@console_ns.doc(description="Get workflow online users")
1390+
@setup_required
1391+
@login_required
1392+
@account_initialization_required
1393+
@marshal_with(online_user_list_fields)
1394+
def get(self):
1395+
args = WorkflowOnlineUsersQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
1396+
1397+
app_ids = list(dict.fromkeys(app_id.strip() for app_id in args.app_ids.split(",") if app_id.strip()))
1398+
if len(app_ids) > MAX_WORKFLOW_ONLINE_USERS_QUERY_IDS:
1399+
raise BadRequest(f"Maximum {MAX_WORKFLOW_ONLINE_USERS_QUERY_IDS} app_ids are allowed per request.")
1400+
1401+
if not app_ids:
1402+
return {"data": []}
1403+
1404+
_, current_tenant_id = current_account_with_tenant()
1405+
workflow_service = WorkflowService()
1406+
accessible_app_ids = workflow_service.get_accessible_app_ids(app_ids, current_tenant_id)
1407+
1408+
results = []
1409+
for app_id in app_ids:
1410+
if app_id not in accessible_app_ids:
1411+
continue
1412+
1413+
users_json = redis_client.hgetall(f"{WORKFLOW_ONLINE_USERS_PREFIX}{app_id}")
1414+
1415+
users = []
1416+
for _, user_info_json in users_json.items():
1417+
try:
1418+
user_info = json.loads(user_info_json)
1419+
except Exception:
1420+
continue
1421+
1422+
if not isinstance(user_info, dict):
1423+
continue
1424+
1425+
avatar = user_info.get("avatar")
1426+
if isinstance(avatar, str) and avatar and not avatar.startswith(("http://", "https://")):
1427+
try:
1428+
user_info["avatar"] = file_helpers.get_signed_file_url(avatar)
1429+
except Exception as exc:
1430+
logger.warning(
1431+
"Failed to sign workflow online user avatar; using original value. "
1432+
"app_id=%s avatar=%s error=%s",
1433+
app_id,
1434+
avatar,
1435+
exc,
1436+
)
1437+
1438+
users.append(user_info)
1439+
results.append({"app_id": app_id, "users": users})
1440+
1441+
return {"data": results}

0 commit comments

Comments
 (0)