mirror of
https://github.com/langgenius/dify.git
synced 2026-02-09 23:20:12 -05:00
Compare commits
300 Commits
hotfix/1.1
...
feat/hitl-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f815310fda | ||
|
|
c56ad8e323 | ||
|
|
365f749ed5 | ||
|
|
f686197589 | ||
|
|
f584be9cf0 | ||
|
|
3bd228ddb7 | ||
|
|
0dfa59b1db | ||
|
|
1e344f773b | ||
|
|
bba2040a05 | ||
|
|
ad3be1e4d0 | ||
|
|
297dd832aa | ||
|
|
cc5705cb71 | ||
|
|
74b027c41a | ||
|
|
5f69470ebf | ||
|
|
ec7ccd800c | ||
|
|
0d74ac634b | ||
|
|
468990cc39 | ||
|
|
64e769f96e | ||
|
|
778aabb485 | ||
|
|
2ca03d80f9 | ||
|
|
52b9299e43 | ||
|
|
757317f965 | ||
|
|
e119d1a16c | ||
|
|
0187838a54 | ||
|
|
68c36655a7 | ||
|
|
0357530468 | ||
|
|
966e308e8b | ||
|
|
e405e801ce | ||
|
|
3170e12b0a | ||
|
|
8bbf3ef261 | ||
|
|
e09bd3e977 | ||
|
|
b1f7602dc8 | ||
|
|
2ac20ced26 | ||
|
|
4e34fa3d70 | ||
|
|
bf161d01a8 | ||
|
|
fe5a65b21c | ||
|
|
b3be035c64 | ||
|
|
91f118c82c | ||
|
|
3eaafccff2 | ||
|
|
9d3b4749ae | ||
|
|
67bbfd972b | ||
|
|
1968589fdf | ||
|
|
fc2acc2c53 | ||
|
|
e945f3d439 | ||
|
|
1b0abdf642 | ||
|
|
441104ac3c | ||
|
|
8897633e42 | ||
|
|
8a20a59d72 | ||
|
|
7cd4a7c1de | ||
|
|
b503da0942 | ||
|
|
94671d42c1 | ||
|
|
8cf817a5fd | ||
|
|
3e0b3fae37 | ||
|
|
19664daeef | ||
|
|
fe5b5bc4c3 | ||
|
|
07d8eaae82 | ||
|
|
e8130fc509 | ||
|
|
4ea3cf46fa | ||
|
|
824f139b46 | ||
|
|
f2d98e832f | ||
|
|
5f73fed1e7 | ||
|
|
2ccef3ac84 | ||
|
|
9341227cf1 | ||
|
|
d9982b8dc4 | ||
|
|
d3d42e3a8e | ||
|
|
f73db70cec | ||
|
|
bcd6e22735 | ||
|
|
9aa674a04f | ||
|
|
a4b87be5f4 | ||
|
|
c8c98519ad | ||
|
|
6aa452d4e3 | ||
|
|
45c2167e0f | ||
|
|
b242578b86 | ||
|
|
d65523aa0b | ||
|
|
ac46cf499f | ||
|
|
8f780cad4c | ||
|
|
0389dd459d | ||
|
|
a19ae24adb | ||
|
|
42e80659b6 | ||
|
|
59cb447e05 | ||
|
|
e63f7b2249 | ||
|
|
307f0d5827 | ||
|
|
757b1c7190 | ||
|
|
c42b78f2d2 | ||
|
|
645793c48c | ||
|
|
528e3400da | ||
|
|
c69a26b1ca | ||
|
|
663f320d86 | ||
|
|
6e9facd9b5 | ||
|
|
52e9342af5 | ||
|
|
0138dc45b6 | ||
|
|
aaf6e8f978 | ||
|
|
55be933342 | ||
|
|
711cca01b8 | ||
|
|
2b28074f4c | ||
|
|
c5721184e9 | ||
|
|
f3ec6ad53c | ||
|
|
1014852ebd | ||
|
|
5e644315e4 | ||
|
|
e3a22e5027 | ||
|
|
977cef5a22 | ||
|
|
1353eec9ca | ||
|
|
73343f03c1 | ||
|
|
d401a29bd9 | ||
|
|
3bf8f19874 | ||
|
|
51a7ddba81 | ||
|
|
bd634b165d | ||
|
|
a298140d8f | ||
|
|
3db3a18eff | ||
|
|
91c35c2f0a | ||
|
|
61c7fdc614 | ||
|
|
88c2483192 | ||
|
|
ca58055a39 | ||
|
|
368e38d593 | ||
|
|
7463bc9199 | ||
|
|
35a707199f | ||
|
|
dfb25df5ec | ||
|
|
cdc24696e4 | ||
|
|
8f6a0e2d8e | ||
|
|
20ba0de47d | ||
|
|
f13ace9dd9 | ||
|
|
23f5427349 | ||
|
|
7deaab116a | ||
|
|
b6db6d9305 | ||
|
|
2fa688cefd | ||
|
|
b24fafe901 | ||
|
|
c837def205 | ||
|
|
1a7eac192c | ||
|
|
d21cf87bb6 | ||
|
|
ef41325ad1 | ||
|
|
57c33d5869 | ||
|
|
4484261023 | ||
|
|
72bc396646 | ||
|
|
68885afac6 | ||
|
|
18e57096d2 | ||
|
|
b6c6d52725 | ||
|
|
bd104557ef | ||
|
|
55b18bb79e | ||
|
|
3f8f158b04 | ||
|
|
471d14f882 | ||
|
|
a280df2c07 | ||
|
|
b479a36273 | ||
|
|
9136bf48f5 | ||
|
|
ac70069847 | ||
|
|
27da1e72eb | ||
|
|
467119d186 | ||
|
|
1cdbfa2539 | ||
|
|
d3299db915 | ||
|
|
ebb816b90b | ||
|
|
0b794ad9cc | ||
|
|
a19c0023f9 | ||
|
|
c7fa7009b1 | ||
|
|
3e1a96ad64 | ||
|
|
d500a631f0 | ||
|
|
607e77e0a7 | ||
|
|
46ec24cf8a | ||
|
|
f654a7f704 | ||
|
|
cf9b72d574 | ||
|
|
5a92bbdab5 | ||
|
|
d0a713e117 | ||
|
|
9ec127ea8f | ||
|
|
d478822fe1 | ||
|
|
4eac271adc | ||
|
|
0f13b6b26d | ||
|
|
52ce46c364 | ||
|
|
f0f1ae0b49 | ||
|
|
0c69466b0f | ||
|
|
44a688cb81 | ||
|
|
0e2b59d661 | ||
|
|
501c3bcc94 | ||
|
|
bf6a2c22eb | ||
|
|
8c0365c71e | ||
|
|
c0a4f3b715 | ||
|
|
d308c34842 | ||
|
|
68615eec04 | ||
|
|
eca3e23af0 | ||
|
|
c716c4ccbe | ||
|
|
32366774a1 | ||
|
|
640661983a | ||
|
|
f528f2eafc | ||
|
|
0994953728 | ||
|
|
d80167d9ec | ||
|
|
2969a77b15 | ||
|
|
062896cb9c | ||
|
|
5a1e6269d5 | ||
|
|
e744d4de80 | ||
|
|
be0f493e61 | ||
|
|
b8d4f60782 | ||
|
|
8b65b689f7 | ||
|
|
8b9846f52b | ||
|
|
1c4c1b5cb1 | ||
|
|
ec0c144eb2 | ||
|
|
afddc56bb4 | ||
|
|
57dde4a4d8 | ||
|
|
3c0fd213bf | ||
|
|
ddfd1cb1f5 | ||
|
|
138922f3f4 | ||
|
|
aa9d0bd655 | ||
|
|
ccef15aafa | ||
|
|
202f924d99 | ||
|
|
882d2f5b4c | ||
|
|
93ec42344c | ||
|
|
330585ec9b | ||
|
|
8f2660cea8 | ||
|
|
87f6ac78c5 | ||
|
|
6c7d58aa11 | ||
|
|
8bca22a94b | ||
|
|
17aa11da79 | ||
|
|
63bbcff496 | ||
|
|
f71a632cdc | ||
|
|
4e3bded902 | ||
|
|
e75bbdbc0d | ||
|
|
331a5edbf9 | ||
|
|
6afc99a5ad | ||
|
|
a4e2ef6b0c | ||
|
|
3632b473df | ||
|
|
e35dd14c59 | ||
|
|
495f901798 | ||
|
|
8703515153 | ||
|
|
be3c6da654 | ||
|
|
d51db3afb3 | ||
|
|
22683fba3f | ||
|
|
ed16265eee | ||
|
|
463ea14d44 | ||
|
|
527736b8e4 | ||
|
|
f22dcee6d9 | ||
|
|
9bdd7e5465 | ||
|
|
ad0e79372f | ||
|
|
a362114486 | ||
|
|
79ab253c26 | ||
|
|
783b78cc0a | ||
|
|
b1e123c3aa | ||
|
|
84709a7941 | ||
|
|
ec07636ce9 | ||
|
|
4d4c8b21ac | ||
|
|
ec8754173f | ||
|
|
7920b89714 | ||
|
|
19e152fd0c | ||
|
|
076a8ecff4 | ||
|
|
40591b2196 | ||
|
|
5156b8f9c9 | ||
|
|
286ab0d468 | ||
|
|
71a511a470 | ||
|
|
6b11973151 | ||
|
|
949a894f03 | ||
|
|
305b5da764 | ||
|
|
fda19d3f0e | ||
|
|
e5a2172a85 | ||
|
|
2d89d59d74 | ||
|
|
4f56acd432 | ||
|
|
c1b7412465 | ||
|
|
62b9a20115 | ||
|
|
5392401e60 | ||
|
|
baa77d3cda | ||
|
|
a41176b66d | ||
|
|
465e978209 | ||
|
|
a9ea8cfd1c | ||
|
|
c771f4dbc7 | ||
|
|
ebbed8f863 | ||
|
|
bdf1e9ed3b | ||
|
|
36acd0b9dd | ||
|
|
a4049e1ea7 | ||
|
|
114dfe038c | ||
|
|
81f6344aaa | ||
|
|
89963ecf59 | ||
|
|
a18bcf3957 | ||
|
|
c28720529e | ||
|
|
242826013e | ||
|
|
05453cb22f | ||
|
|
da211d3009 | ||
|
|
f8a249de03 | ||
|
|
e2e5dedceb | ||
|
|
bd7ba85471 | ||
|
|
7d5f6bc255 | ||
|
|
fcc8789cc3 | ||
|
|
6da1a48cad | ||
|
|
465ff7838a | ||
|
|
a4bf493343 | ||
|
|
c27f20b4b7 | ||
|
|
a9e6140dc6 | ||
|
|
792f28451c | ||
|
|
82530df38f | ||
|
|
ce8325c83c | ||
|
|
3ed561d943 | ||
|
|
bb8d54c48b | ||
|
|
922aeb7c21 | ||
|
|
177be06d09 | ||
|
|
736ec55f86 | ||
|
|
ab373197f9 | ||
|
|
a6d2392c6c | ||
|
|
3371989572 | ||
|
|
f04daf056d | ||
|
|
fb6c8fa01f | ||
|
|
b02199145e | ||
|
|
e257455c9c | ||
|
|
63af5305e6 | ||
|
|
95b88a0621 | ||
|
|
1ab02c6e9a | ||
|
|
1099ab5d91 | ||
|
|
6485adae35 |
7
.github/CODEOWNERS
vendored
7
.github/CODEOWNERS
vendored
@@ -24,6 +24,10 @@
|
||||
/api/services/tools/mcp_tools_manage_service.py @Nov1c444
|
||||
/api/controllers/mcp/ @Nov1c444
|
||||
/api/controllers/console/app/mcp_server.py @Nov1c444
|
||||
|
||||
# Backend - Tests
|
||||
/api/tests/ @laipz8200 @QuantumGhost
|
||||
|
||||
/api/tests/**/*mcp* @Nov1c444
|
||||
|
||||
# Backend - Workflow - Engine (Core graph execution engine)
|
||||
@@ -234,6 +238,9 @@
|
||||
# Frontend - Base Components
|
||||
/web/app/components/base/ @iamjoel @zxhlyh
|
||||
|
||||
# Frontend - Base Components Tests
|
||||
/web/app/components/base/**/*.spec.tsx @hyoban @CodingOnStar
|
||||
|
||||
# Frontend - Utils and Hooks
|
||||
/web/utils/classnames.ts @iamjoel @zxhlyh
|
||||
/web/utils/time.ts @iamjoel @zxhlyh
|
||||
|
||||
1
.github/workflows/build-push.yml
vendored
1
.github/workflows/build-push.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
- "build/**"
|
||||
- "release/e-*"
|
||||
- "hotfix/**"
|
||||
- "feat/hitl-frontend"
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource, fields, marshal, marshal_with, reqparse
|
||||
from flask_restx import Resource, fields, marshal, marshal_with
|
||||
from pydantic import BaseModel
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
from controllers.common.fields import Parameters as ParametersResponse
|
||||
from controllers.common.fields import Site as SiteResponse
|
||||
from controllers.common.schema import get_or_create_model
|
||||
from controllers.console import api
|
||||
from controllers.console import api, console_ns
|
||||
from controllers.console.app.error import (
|
||||
AppUnavailableError,
|
||||
AudioTooLargeError,
|
||||
@@ -117,7 +118,56 @@ workflow_fields_copy["rag_pipeline_variables"] = fields.List(fields.Nested(pipel
|
||||
workflow_model = get_or_create_model("TrialWorkflow", workflow_fields_copy)
|
||||
|
||||
|
||||
# Pydantic models for request validation
|
||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
||||
|
||||
|
||||
class WorkflowRunRequest(BaseModel):
|
||||
inputs: dict
|
||||
files: list | None = None
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
inputs: dict
|
||||
query: str
|
||||
files: list | None = None
|
||||
conversation_id: str | None = None
|
||||
parent_message_id: str | None = None
|
||||
retriever_from: str = "explore_app"
|
||||
|
||||
|
||||
class TextToSpeechRequest(BaseModel):
|
||||
message_id: str | None = None
|
||||
voice: str | None = None
|
||||
text: str | None = None
|
||||
streaming: bool | None = None
|
||||
|
||||
|
||||
class CompletionRequest(BaseModel):
|
||||
inputs: dict
|
||||
query: str = ""
|
||||
files: list | None = None
|
||||
response_mode: Literal["blocking", "streaming"] | None = None
|
||||
retriever_from: str = "explore_app"
|
||||
|
||||
|
||||
# Register schemas for Swagger documentation
|
||||
console_ns.schema_model(
|
||||
WorkflowRunRequest.__name__, WorkflowRunRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
||||
)
|
||||
console_ns.schema_model(
|
||||
ChatRequest.__name__, ChatRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
||||
)
|
||||
console_ns.schema_model(
|
||||
TextToSpeechRequest.__name__, TextToSpeechRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
||||
)
|
||||
console_ns.schema_model(
|
||||
CompletionRequest.__name__, CompletionRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
||||
)
|
||||
|
||||
|
||||
class TrialAppWorkflowRunApi(TrialAppResource):
|
||||
@console_ns.expect(console_ns.models[WorkflowRunRequest.__name__])
|
||||
def post(self, trial_app):
|
||||
"""
|
||||
Run workflow
|
||||
@@ -129,10 +179,8 @@ class TrialAppWorkflowRunApi(TrialAppResource):
|
||||
if app_mode != AppMode.WORKFLOW:
|
||||
raise NotWorkflowAppError()
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("inputs", type=dict, required=True, nullable=False, location="json")
|
||||
parser.add_argument("files", type=list, required=False, location="json")
|
||||
args = parser.parse_args()
|
||||
request_data = WorkflowRunRequest.model_validate(console_ns.payload)
|
||||
args = request_data.model_dump()
|
||||
assert current_user is not None
|
||||
try:
|
||||
app_id = app_model.id
|
||||
@@ -183,6 +231,7 @@ class TrialAppWorkflowTaskStopApi(TrialAppResource):
|
||||
|
||||
|
||||
class TrialChatApi(TrialAppResource):
|
||||
@console_ns.expect(console_ns.models[ChatRequest.__name__])
|
||||
@trial_feature_enable
|
||||
def post(self, trial_app):
|
||||
app_model = trial_app
|
||||
@@ -190,14 +239,14 @@ class TrialChatApi(TrialAppResource):
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("inputs", type=dict, required=True, location="json")
|
||||
parser.add_argument("query", type=str, required=True, location="json")
|
||||
parser.add_argument("files", type=list, required=False, location="json")
|
||||
parser.add_argument("conversation_id", type=uuid_value, location="json")
|
||||
parser.add_argument("parent_message_id", type=uuid_value, required=False, location="json")
|
||||
parser.add_argument("retriever_from", type=str, required=False, default="explore_app", location="json")
|
||||
args = parser.parse_args()
|
||||
request_data = ChatRequest.model_validate(console_ns.payload)
|
||||
args = request_data.model_dump()
|
||||
|
||||
# Validate UUID values if provided
|
||||
if args.get("conversation_id"):
|
||||
args["conversation_id"] = uuid_value(args["conversation_id"])
|
||||
if args.get("parent_message_id"):
|
||||
args["parent_message_id"] = uuid_value(args["parent_message_id"])
|
||||
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
@@ -320,20 +369,16 @@ class TrialChatAudioApi(TrialAppResource):
|
||||
|
||||
|
||||
class TrialChatTextApi(TrialAppResource):
|
||||
@console_ns.expect(console_ns.models[TextToSpeechRequest.__name__])
|
||||
@trial_feature_enable
|
||||
def post(self, trial_app):
|
||||
app_model = trial_app
|
||||
try:
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("message_id", type=str, required=False, location="json")
|
||||
parser.add_argument("voice", type=str, location="json")
|
||||
parser.add_argument("text", type=str, location="json")
|
||||
parser.add_argument("streaming", type=bool, location="json")
|
||||
args = parser.parse_args()
|
||||
request_data = TextToSpeechRequest.model_validate(console_ns.payload)
|
||||
|
||||
message_id = args.get("message_id", None)
|
||||
text = args.get("text", None)
|
||||
voice = args.get("voice", None)
|
||||
message_id = request_data.message_id
|
||||
text = request_data.text
|
||||
voice = request_data.voice
|
||||
if not isinstance(current_user, Account):
|
||||
raise ValueError("current_user must be an Account instance")
|
||||
|
||||
@@ -371,19 +416,15 @@ class TrialChatTextApi(TrialAppResource):
|
||||
|
||||
|
||||
class TrialCompletionApi(TrialAppResource):
|
||||
@console_ns.expect(console_ns.models[CompletionRequest.__name__])
|
||||
@trial_feature_enable
|
||||
def post(self, trial_app):
|
||||
app_model = trial_app
|
||||
if app_model.mode != "completion":
|
||||
raise NotCompletionAppError()
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("inputs", type=dict, required=True, location="json")
|
||||
parser.add_argument("query", type=str, location="json", default="")
|
||||
parser.add_argument("files", type=list, required=False, location="json")
|
||||
parser.add_argument("response_mode", type=str, choices=["blocking", "streaming"], location="json")
|
||||
parser.add_argument("retriever_from", type=str, required=False, default="explore_app", location="json")
|
||||
args = parser.parse_args()
|
||||
request_data = CompletionRequest.model_validate(console_ns.payload)
|
||||
args = request_data.model_dump()
|
||||
|
||||
streaming = args["response_mode"] == "streaming"
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.12.0"
|
||||
version = "1.12.1"
|
||||
requires-python = ">=3.11,<3.13"
|
||||
|
||||
dependencies = [
|
||||
|
||||
@@ -327,6 +327,17 @@ class AccountService:
|
||||
@staticmethod
|
||||
def delete_account(account: Account):
|
||||
"""Delete account. This method only adds a task to the queue for deletion."""
|
||||
# Queue account deletion sync tasks for all workspaces BEFORE account deletion (enterprise only)
|
||||
from services.enterprise.account_deletion_sync import sync_account_deletion
|
||||
|
||||
sync_success = sync_account_deletion(account_id=account.id, source="account_deleted")
|
||||
if not sync_success:
|
||||
logger.warning(
|
||||
"Enterprise account deletion sync failed for account %s; proceeding with local deletion.",
|
||||
account.id,
|
||||
)
|
||||
|
||||
# Now proceed with async account deletion
|
||||
delete_account_task.delay(account.id)
|
||||
|
||||
@staticmethod
|
||||
@@ -1230,6 +1241,19 @@ class TenantService:
|
||||
if dify_config.BILLING_ENABLED:
|
||||
BillingService.clean_billing_info_cache(tenant.id)
|
||||
|
||||
# Queue account deletion sync task for enterprise backend to reassign resources (enterprise only)
|
||||
from services.enterprise.account_deletion_sync import sync_workspace_member_removal
|
||||
|
||||
sync_success = sync_workspace_member_removal(
|
||||
workspace_id=tenant.id, member_id=account.id, source="workspace_member_removed"
|
||||
)
|
||||
if not sync_success:
|
||||
logger.warning(
|
||||
"Enterprise workspace member removal sync failed: workspace_id=%s, member_id=%s",
|
||||
tenant.id,
|
||||
account.id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_member_role(tenant: Tenant, member: Account, new_role: str, operator: Account):
|
||||
"""Update member role"""
|
||||
|
||||
115
api/services/enterprise/account_deletion_sync.py
Normal file
115
api/services/enterprise/account_deletion_sync.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from redis import RedisError
|
||||
|
||||
from configs import dify_config
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.account import TenantAccountJoin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACCOUNT_DELETION_SYNC_QUEUE = "enterprise:member:sync:queue"
|
||||
ACCOUNT_DELETION_SYNC_TASK_TYPE = "sync_member_deletion_from_workspace"
|
||||
|
||||
|
||||
def _queue_task(workspace_id: str, member_id: str, *, source: str) -> bool:
|
||||
"""
|
||||
Queue an account deletion sync task to Redis.
|
||||
|
||||
Internal helper function. Do not call directly - use the public functions instead.
|
||||
|
||||
Args:
|
||||
workspace_id: The workspace/tenant ID to sync
|
||||
member_id: The member/account ID that was removed
|
||||
source: Source of the sync request (for debugging/tracking)
|
||||
|
||||
Returns:
|
||||
bool: True if task was queued successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
task = {
|
||||
"task_id": str(uuid.uuid4()),
|
||||
"workspace_id": workspace_id,
|
||||
"member_id": member_id,
|
||||
"retry_count": 0,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"source": source,
|
||||
"type": ACCOUNT_DELETION_SYNC_TASK_TYPE,
|
||||
}
|
||||
|
||||
# Push to Redis list (queue) - LPUSH adds to the head, worker consumes from tail with RPOP
|
||||
redis_client.lpush(ACCOUNT_DELETION_SYNC_QUEUE, json.dumps(task))
|
||||
|
||||
logger.info(
|
||||
"Queued account deletion sync task for workspace %s, member %s, task_id: %s, source: %s",
|
||||
workspace_id,
|
||||
member_id,
|
||||
task["task_id"],
|
||||
source,
|
||||
)
|
||||
return True
|
||||
|
||||
except (RedisError, TypeError) as e:
|
||||
logger.error(
|
||||
"Failed to queue account deletion sync for workspace %s, member %s: %s",
|
||||
workspace_id,
|
||||
member_id,
|
||||
str(e),
|
||||
exc_info=True,
|
||||
)
|
||||
# Don't raise - we don't want to fail member deletion if queueing fails
|
||||
return False
|
||||
|
||||
|
||||
def sync_workspace_member_removal(workspace_id: str, member_id: str, *, source: str) -> bool:
|
||||
"""
|
||||
Sync a single workspace member removal (enterprise only).
|
||||
|
||||
Queues a task for the enterprise backend to reassign resources from the removed member.
|
||||
Handles enterprise edition check internally. Safe to call in community edition (no-op).
|
||||
|
||||
Args:
|
||||
workspace_id: The workspace/tenant ID
|
||||
member_id: The member/account ID that was removed
|
||||
source: Source of the sync request (e.g., "workspace_member_removed")
|
||||
|
||||
Returns:
|
||||
bool: True if task was queued (or skipped in community), False if queueing failed
|
||||
"""
|
||||
if not dify_config.ENTERPRISE_ENABLED:
|
||||
return True
|
||||
|
||||
return _queue_task(workspace_id=workspace_id, member_id=member_id, source=source)
|
||||
|
||||
|
||||
def sync_account_deletion(account_id: str, *, source: str) -> bool:
|
||||
"""
|
||||
Sync full account deletion across all workspaces (enterprise only).
|
||||
|
||||
Fetches all workspace memberships for the account and queues a sync task for each.
|
||||
Handles enterprise edition check internally. Safe to call in community edition (no-op).
|
||||
|
||||
Args:
|
||||
account_id: The account ID being deleted
|
||||
source: Source of the sync request (e.g., "account_deleted")
|
||||
|
||||
Returns:
|
||||
bool: True if all tasks were queued (or skipped in community), False if any queueing failed
|
||||
"""
|
||||
if not dify_config.ENTERPRISE_ENABLED:
|
||||
return True
|
||||
|
||||
# Fetch all workspaces the account belongs to
|
||||
workspace_joins = db.session.query(TenantAccountJoin).filter_by(account_id=account_id).all()
|
||||
|
||||
# Queue sync task for each workspace
|
||||
success = True
|
||||
for join in workspace_joins:
|
||||
if not _queue_task(workspace_id=join.tenant_id, member_id=account_id, source=source):
|
||||
success = False
|
||||
|
||||
return success
|
||||
@@ -259,8 +259,8 @@ def _delete_app_workflow_app_logs(tenant_id: str, app_id: str):
|
||||
|
||||
|
||||
def _delete_app_workflow_archive_logs(tenant_id: str, app_id: str):
|
||||
def del_workflow_archive_log(workflow_archive_log_id: str):
|
||||
db.session.query(WorkflowArchiveLog).where(WorkflowArchiveLog.id == workflow_archive_log_id).delete(
|
||||
def del_workflow_archive_log(session, workflow_archive_log_id: str):
|
||||
session.query(WorkflowArchiveLog).where(WorkflowArchiveLog.id == workflow_archive_log_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
@@ -420,7 +420,7 @@ def delete_draft_variables_batch(app_id: str, batch_size: int = 1000) -> int:
|
||||
total_files_deleted = 0
|
||||
|
||||
while True:
|
||||
with session_factory.create_session() as session:
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
# Get a batch of draft variable IDs along with their file_ids
|
||||
query_sql = """
|
||||
SELECT id, file_id FROM workflow_draft_variables
|
||||
|
||||
@@ -10,7 +10,10 @@ from models import Tenant
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import App, UploadFile
|
||||
from models.workflow import WorkflowDraftVariable, WorkflowDraftVariableFile
|
||||
from tasks.remove_app_and_related_data_task import _delete_draft_variables, delete_draft_variables_batch
|
||||
from tasks.remove_app_and_related_data_task import (
|
||||
_delete_draft_variables,
|
||||
delete_draft_variables_batch,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -297,12 +300,18 @@ class TestDeleteDraftVariablesWithOffloadIntegration:
|
||||
def test_delete_draft_variables_with_offload_data(self, mock_storage, setup_offload_test_data):
|
||||
data = setup_offload_test_data
|
||||
app_id = data["app"].id
|
||||
upload_file_ids = [uf.id for uf in data["upload_files"]]
|
||||
variable_file_ids = [vf.id for vf in data["variable_files"]]
|
||||
mock_storage.delete.return_value = None
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
draft_vars_before = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count()
|
||||
var_files_before = session.query(WorkflowDraftVariableFile).count()
|
||||
upload_files_before = session.query(UploadFile).count()
|
||||
var_files_before = (
|
||||
session.query(WorkflowDraftVariableFile)
|
||||
.where(WorkflowDraftVariableFile.id.in_(variable_file_ids))
|
||||
.count()
|
||||
)
|
||||
upload_files_before = session.query(UploadFile).where(UploadFile.id.in_(upload_file_ids)).count()
|
||||
assert draft_vars_before == 3
|
||||
assert var_files_before == 2
|
||||
assert upload_files_before == 2
|
||||
@@ -315,8 +324,12 @@ class TestDeleteDraftVariablesWithOffloadIntegration:
|
||||
assert draft_vars_after == 0
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
var_files_after = session.query(WorkflowDraftVariableFile).count()
|
||||
upload_files_after = session.query(UploadFile).count()
|
||||
var_files_after = (
|
||||
session.query(WorkflowDraftVariableFile)
|
||||
.where(WorkflowDraftVariableFile.id.in_(variable_file_ids))
|
||||
.count()
|
||||
)
|
||||
upload_files_after = session.query(UploadFile).where(UploadFile.id.in_(upload_file_ids)).count()
|
||||
assert var_files_after == 0
|
||||
assert upload_files_after == 0
|
||||
|
||||
@@ -329,6 +342,8 @@ class TestDeleteDraftVariablesWithOffloadIntegration:
|
||||
def test_delete_draft_variables_storage_failure_continues_cleanup(self, mock_storage, setup_offload_test_data):
|
||||
data = setup_offload_test_data
|
||||
app_id = data["app"].id
|
||||
upload_file_ids = [uf.id for uf in data["upload_files"]]
|
||||
variable_file_ids = [vf.id for vf in data["variable_files"]]
|
||||
mock_storage.delete.side_effect = [Exception("Storage error"), None]
|
||||
|
||||
deleted_count = delete_draft_variables_batch(app_id, batch_size=10)
|
||||
@@ -339,8 +354,12 @@ class TestDeleteDraftVariablesWithOffloadIntegration:
|
||||
assert draft_vars_after == 0
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
var_files_after = session.query(WorkflowDraftVariableFile).count()
|
||||
upload_files_after = session.query(UploadFile).count()
|
||||
var_files_after = (
|
||||
session.query(WorkflowDraftVariableFile)
|
||||
.where(WorkflowDraftVariableFile.id.in_(variable_file_ids))
|
||||
.count()
|
||||
)
|
||||
upload_files_after = session.query(UploadFile).where(UploadFile.id.in_(upload_file_ids)).count()
|
||||
assert var_files_after == 0
|
||||
assert upload_files_after == 0
|
||||
|
||||
@@ -395,3 +414,275 @@ class TestDeleteDraftVariablesWithOffloadIntegration:
|
||||
if app2_obj:
|
||||
session.delete(app2_obj)
|
||||
session.commit()
|
||||
|
||||
|
||||
class TestDeleteDraftVariablesSessionCommit:
|
||||
"""Test suite to verify session commit behavior in delete_draft_variables_batch."""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_offload_test_data(self, app_and_tenant):
|
||||
"""Create test data with offload files for session commit tests."""
|
||||
from core.variables.types import SegmentType
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
|
||||
tenant, app = app_and_tenant
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
upload_file1 = UploadFile(
|
||||
tenant_id=tenant.id,
|
||||
storage_type="local",
|
||||
key="test/file1.json",
|
||||
name="file1.json",
|
||||
size=1024,
|
||||
extension="json",
|
||||
mime_type="application/json",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=str(uuid.uuid4()),
|
||||
created_at=naive_utc_now(),
|
||||
used=False,
|
||||
)
|
||||
upload_file2 = UploadFile(
|
||||
tenant_id=tenant.id,
|
||||
storage_type="local",
|
||||
key="test/file2.json",
|
||||
name="file2.json",
|
||||
size=2048,
|
||||
extension="json",
|
||||
mime_type="application/json",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=str(uuid.uuid4()),
|
||||
created_at=naive_utc_now(),
|
||||
used=False,
|
||||
)
|
||||
session.add(upload_file1)
|
||||
session.add(upload_file2)
|
||||
session.flush()
|
||||
|
||||
var_file1 = WorkflowDraftVariableFile(
|
||||
tenant_id=tenant.id,
|
||||
app_id=app.id,
|
||||
user_id=str(uuid.uuid4()),
|
||||
upload_file_id=upload_file1.id,
|
||||
size=1024,
|
||||
length=10,
|
||||
value_type=SegmentType.STRING,
|
||||
)
|
||||
var_file2 = WorkflowDraftVariableFile(
|
||||
tenant_id=tenant.id,
|
||||
app_id=app.id,
|
||||
user_id=str(uuid.uuid4()),
|
||||
upload_file_id=upload_file2.id,
|
||||
size=2048,
|
||||
length=20,
|
||||
value_type=SegmentType.OBJECT,
|
||||
)
|
||||
session.add(var_file1)
|
||||
session.add(var_file2)
|
||||
session.flush()
|
||||
|
||||
draft_var1 = WorkflowDraftVariable.new_node_variable(
|
||||
app_id=app.id,
|
||||
node_id="node_1",
|
||||
name="large_var_1",
|
||||
value=StringSegment(value="truncated..."),
|
||||
node_execution_id=str(uuid.uuid4()),
|
||||
file_id=var_file1.id,
|
||||
)
|
||||
draft_var2 = WorkflowDraftVariable.new_node_variable(
|
||||
app_id=app.id,
|
||||
node_id="node_2",
|
||||
name="large_var_2",
|
||||
value=StringSegment(value="truncated..."),
|
||||
node_execution_id=str(uuid.uuid4()),
|
||||
file_id=var_file2.id,
|
||||
)
|
||||
draft_var3 = WorkflowDraftVariable.new_node_variable(
|
||||
app_id=app.id,
|
||||
node_id="node_3",
|
||||
name="regular_var",
|
||||
value=StringSegment(value="regular_value"),
|
||||
node_execution_id=str(uuid.uuid4()),
|
||||
)
|
||||
session.add(draft_var1)
|
||||
session.add(draft_var2)
|
||||
session.add(draft_var3)
|
||||
session.commit()
|
||||
|
||||
data = {
|
||||
"app": app,
|
||||
"tenant": tenant,
|
||||
"upload_files": [upload_file1, upload_file2],
|
||||
"variable_files": [var_file1, var_file2],
|
||||
"draft_variables": [draft_var1, draft_var2, draft_var3],
|
||||
}
|
||||
|
||||
yield data
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
for table, ids in [
|
||||
(WorkflowDraftVariable, [v.id for v in data["draft_variables"]]),
|
||||
(WorkflowDraftVariableFile, [vf.id for vf in data["variable_files"]]),
|
||||
(UploadFile, [uf.id for uf in data["upload_files"]]),
|
||||
]:
|
||||
cleanup_query = delete(table).where(table.id.in_(ids)).execution_options(synchronize_session=False)
|
||||
session.execute(cleanup_query)
|
||||
session.commit()
|
||||
|
||||
@pytest.fixture
|
||||
def setup_commit_test_data(self, app_and_tenant):
|
||||
"""Create test data for session commit tests."""
|
||||
tenant, app = app_and_tenant
|
||||
variable_ids: list[str] = []
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
variables = []
|
||||
for i in range(10):
|
||||
var = WorkflowDraftVariable.new_node_variable(
|
||||
app_id=app.id,
|
||||
node_id=f"node_{i}",
|
||||
name=f"var_{i}",
|
||||
value=StringSegment(value="test_value"),
|
||||
node_execution_id=str(uuid.uuid4()),
|
||||
)
|
||||
session.add(var)
|
||||
variables.append(var)
|
||||
session.commit()
|
||||
variable_ids = [v.id for v in variables]
|
||||
|
||||
yield {
|
||||
"app": app,
|
||||
"tenant": tenant,
|
||||
"variable_ids": variable_ids,
|
||||
}
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
cleanup_query = (
|
||||
delete(WorkflowDraftVariable)
|
||||
.where(WorkflowDraftVariable.id.in_(variable_ids))
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
session.execute(cleanup_query)
|
||||
session.commit()
|
||||
|
||||
def test_session_commit_is_called_after_each_batch(self, setup_commit_test_data):
|
||||
"""Test that session.begin() is used for automatic transaction management."""
|
||||
data = setup_commit_test_data
|
||||
app_id = data["app"].id
|
||||
|
||||
# Since session.begin() is used, the transaction is automatically committed
|
||||
# when the with block exits successfully. We verify this by checking that
|
||||
# data is actually persisted.
|
||||
deleted_count = delete_draft_variables_batch(app_id, batch_size=3)
|
||||
|
||||
# Verify all data was deleted (proves transaction was committed)
|
||||
with session_factory.create_session() as session:
|
||||
remaining_count = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count()
|
||||
|
||||
assert deleted_count == 10
|
||||
assert remaining_count == 0
|
||||
|
||||
def test_data_persisted_after_batch_deletion(self, setup_commit_test_data):
|
||||
"""Test that data is actually persisted to database after batch deletion with commits."""
|
||||
data = setup_commit_test_data
|
||||
app_id = data["app"].id
|
||||
variable_ids = data["variable_ids"]
|
||||
|
||||
# Verify initial state
|
||||
with session_factory.create_session() as session:
|
||||
initial_count = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count()
|
||||
assert initial_count == 10
|
||||
|
||||
# Perform deletion with small batch size to force multiple commits
|
||||
deleted_count = delete_draft_variables_batch(app_id, batch_size=3)
|
||||
|
||||
assert deleted_count == 10
|
||||
|
||||
# Verify all data is deleted in a new session (proves commits worked)
|
||||
with session_factory.create_session() as session:
|
||||
final_count = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count()
|
||||
assert final_count == 0
|
||||
|
||||
# Verify specific IDs are deleted
|
||||
with session_factory.create_session() as session:
|
||||
remaining_vars = (
|
||||
session.query(WorkflowDraftVariable).where(WorkflowDraftVariable.id.in_(variable_ids)).count()
|
||||
)
|
||||
assert remaining_vars == 0
|
||||
|
||||
def test_session_commit_with_empty_dataset(self, setup_commit_test_data):
|
||||
"""Test session behavior when deleting from an empty dataset."""
|
||||
nonexistent_app_id = str(uuid.uuid4())
|
||||
|
||||
# Should not raise any errors and should return 0
|
||||
deleted_count = delete_draft_variables_batch(nonexistent_app_id, batch_size=10)
|
||||
assert deleted_count == 0
|
||||
|
||||
def test_session_commit_with_single_batch(self, setup_commit_test_data):
|
||||
"""Test that commit happens correctly when all data fits in a single batch."""
|
||||
data = setup_commit_test_data
|
||||
app_id = data["app"].id
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
initial_count = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count()
|
||||
assert initial_count == 10
|
||||
|
||||
# Delete all in a single batch
|
||||
deleted_count = delete_draft_variables_batch(app_id, batch_size=100)
|
||||
assert deleted_count == 10
|
||||
|
||||
# Verify data is persisted
|
||||
with session_factory.create_session() as session:
|
||||
final_count = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count()
|
||||
assert final_count == 0
|
||||
|
||||
def test_invalid_batch_size_raises_error(self, setup_commit_test_data):
|
||||
"""Test that invalid batch size raises ValueError."""
|
||||
data = setup_commit_test_data
|
||||
app_id = data["app"].id
|
||||
|
||||
with pytest.raises(ValueError, match="batch_size must be positive"):
|
||||
delete_draft_variables_batch(app_id, batch_size=0)
|
||||
|
||||
with pytest.raises(ValueError, match="batch_size must be positive"):
|
||||
delete_draft_variables_batch(app_id, batch_size=-1)
|
||||
|
||||
@patch("extensions.ext_storage.storage")
|
||||
def test_session_commit_with_offload_data_cleanup(self, mock_storage, setup_offload_test_data):
|
||||
"""Test that session commits correctly when cleaning up offload data."""
|
||||
data = setup_offload_test_data
|
||||
app_id = data["app"].id
|
||||
upload_file_ids = [uf.id for uf in data["upload_files"]]
|
||||
mock_storage.delete.return_value = None
|
||||
|
||||
# Verify initial state
|
||||
with session_factory.create_session() as session:
|
||||
draft_vars_before = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count()
|
||||
var_files_before = (
|
||||
session.query(WorkflowDraftVariableFile)
|
||||
.where(WorkflowDraftVariableFile.id.in_([vf.id for vf in data["variable_files"]]))
|
||||
.count()
|
||||
)
|
||||
upload_files_before = session.query(UploadFile).where(UploadFile.id.in_(upload_file_ids)).count()
|
||||
assert draft_vars_before == 3
|
||||
assert var_files_before == 2
|
||||
assert upload_files_before == 2
|
||||
|
||||
# Delete variables with offload data
|
||||
deleted_count = delete_draft_variables_batch(app_id, batch_size=10)
|
||||
assert deleted_count == 3
|
||||
|
||||
# Verify all data is persisted (deleted) in new session
|
||||
with session_factory.create_session() as session:
|
||||
draft_vars_after = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count()
|
||||
var_files_after = (
|
||||
session.query(WorkflowDraftVariableFile)
|
||||
.where(WorkflowDraftVariableFile.id.in_([vf.id for vf in data["variable_files"]]))
|
||||
.count()
|
||||
)
|
||||
upload_files_after = session.query(UploadFile).where(UploadFile.id.in_(upload_file_ids)).count()
|
||||
assert draft_vars_after == 0
|
||||
assert var_files_after == 0
|
||||
assert upload_files_after == 0
|
||||
|
||||
# Verify storage cleanup was called
|
||||
assert mock_storage.delete.call_count == 2
|
||||
|
||||
@@ -1016,7 +1016,7 @@ class TestAccountService:
|
||||
|
||||
def test_delete_account(self, db_session_with_containers, mock_external_service_dependencies):
|
||||
"""
|
||||
Test account deletion (should add task to queue).
|
||||
Test account deletion (should add task to queue and sync to enterprise).
|
||||
"""
|
||||
fake = Faker()
|
||||
email = fake.email()
|
||||
@@ -1034,10 +1034,18 @@ class TestAccountService:
|
||||
password=password,
|
||||
)
|
||||
|
||||
with patch("services.account_service.delete_account_task") as mock_delete_task:
|
||||
with (
|
||||
patch("services.account_service.delete_account_task") as mock_delete_task,
|
||||
patch("services.enterprise.account_deletion_sync.sync_account_deletion") as mock_sync,
|
||||
):
|
||||
mock_sync.return_value = True
|
||||
|
||||
# Delete account
|
||||
AccountService.delete_account(account)
|
||||
|
||||
# Verify sync was called
|
||||
mock_sync.assert_called_once_with(account_id=account.id, source="account_deleted")
|
||||
|
||||
# Verify task was added to queue
|
||||
mock_delete_task.delay.assert_called_once_with(account.id)
|
||||
|
||||
@@ -1716,7 +1724,7 @@ class TestTenantService:
|
||||
|
||||
def test_remove_member_from_tenant_success(self, db_session_with_containers, mock_external_service_dependencies):
|
||||
"""
|
||||
Test successful member removal from tenant.
|
||||
Test successful member removal from tenant (should sync to enterprise).
|
||||
"""
|
||||
fake = Faker()
|
||||
tenant_name = fake.company()
|
||||
@@ -1751,7 +1759,15 @@ class TestTenantService:
|
||||
TenantService.create_tenant_member(tenant, member_account, role="normal")
|
||||
|
||||
# Remove member
|
||||
TenantService.remove_member_from_tenant(tenant, member_account, owner_account)
|
||||
with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync:
|
||||
mock_sync.return_value = True
|
||||
|
||||
TenantService.remove_member_from_tenant(tenant, member_account, owner_account)
|
||||
|
||||
# Verify sync was called
|
||||
mock_sync.assert_called_once_with(
|
||||
workspace_id=tenant.id, member_id=member_account.id, source="workspace_member_removed"
|
||||
)
|
||||
|
||||
# Verify member was removed
|
||||
from extensions.ext_database import db
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Unit tests for account deletion synchronization.
|
||||
|
||||
This test module verifies the enterprise account deletion sync functionality,
|
||||
including Redis queuing, error handling, and community vs enterprise behavior.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from redis import RedisError
|
||||
|
||||
from services.enterprise.account_deletion_sync import (
|
||||
_queue_task,
|
||||
sync_account_deletion,
|
||||
sync_workspace_member_removal,
|
||||
)
|
||||
|
||||
|
||||
class TestQueueTask:
|
||||
"""Unit tests for the _queue_task helper function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis_client(self):
|
||||
"""Mock redis_client for testing."""
|
||||
with patch("services.enterprise.account_deletion_sync.redis_client") as mock_redis:
|
||||
yield mock_redis
|
||||
|
||||
@pytest.fixture
|
||||
def mock_uuid(self):
|
||||
"""Mock UUID generation for predictable task IDs."""
|
||||
with patch("services.enterprise.account_deletion_sync.uuid.uuid4") as mock_uuid_gen:
|
||||
mock_uuid_gen.return_value = MagicMock(hex="test-task-id-1234")
|
||||
yield mock_uuid_gen
|
||||
|
||||
def test_queue_task_success(self, mock_redis_client, mock_uuid):
|
||||
"""Test successful task queueing to Redis."""
|
||||
# Arrange
|
||||
workspace_id = "ws-123"
|
||||
member_id = "member-456"
|
||||
source = "test_source"
|
||||
|
||||
# Act
|
||||
result = _queue_task(workspace_id=workspace_id, member_id=member_id, source=source)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_redis_client.lpush.assert_called_once()
|
||||
|
||||
# Verify the task payload structure
|
||||
call_args = mock_redis_client.lpush.call_args[0]
|
||||
assert call_args[0] == "enterprise:member:sync:queue"
|
||||
|
||||
import json
|
||||
|
||||
task_data = json.loads(call_args[1])
|
||||
assert task_data["workspace_id"] == workspace_id
|
||||
assert task_data["member_id"] == member_id
|
||||
assert task_data["source"] == source
|
||||
assert task_data["type"] == "sync_member_deletion_from_workspace"
|
||||
assert task_data["retry_count"] == 0
|
||||
assert "task_id" in task_data
|
||||
assert "created_at" in task_data
|
||||
|
||||
def test_queue_task_redis_error(self, mock_redis_client, caplog):
|
||||
"""Test handling of Redis connection errors."""
|
||||
# Arrange
|
||||
mock_redis_client.lpush.side_effect = RedisError("Connection failed")
|
||||
|
||||
# Act
|
||||
result = _queue_task(workspace_id="ws-123", member_id="member-456", source="test_source")
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
assert "Failed to queue account deletion sync" in caplog.text
|
||||
|
||||
def test_queue_task_type_error(self, mock_redis_client, caplog):
|
||||
"""Test handling of JSON serialization errors."""
|
||||
# Arrange
|
||||
mock_redis_client.lpush.side_effect = TypeError("Cannot serialize")
|
||||
|
||||
# Act
|
||||
result = _queue_task(workspace_id="ws-123", member_id="member-456", source="test_source")
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
assert "Failed to queue account deletion sync" in caplog.text
|
||||
|
||||
|
||||
class TestSyncWorkspaceMemberRemoval:
|
||||
"""Unit tests for sync_workspace_member_removal function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_queue_task(self):
|
||||
"""Mock _queue_task for testing."""
|
||||
with patch("services.enterprise.account_deletion_sync._queue_task") as mock_queue:
|
||||
mock_queue.return_value = True
|
||||
yield mock_queue
|
||||
|
||||
def test_sync_workspace_member_removal_enterprise_enabled(self, mock_queue_task):
|
||||
"""Test sync when ENTERPRISE_ENABLED is True."""
|
||||
# Arrange
|
||||
workspace_id = "ws-123"
|
||||
member_id = "member-456"
|
||||
source = "workspace_member_removed"
|
||||
|
||||
with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config:
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
|
||||
# Act
|
||||
result = sync_workspace_member_removal(workspace_id=workspace_id, member_id=member_id, source=source)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_queue_task.assert_called_once_with(workspace_id=workspace_id, member_id=member_id, source=source)
|
||||
|
||||
def test_sync_workspace_member_removal_enterprise_disabled(self, mock_queue_task):
|
||||
"""Test sync when ENTERPRISE_ENABLED is False (community edition)."""
|
||||
# Arrange
|
||||
with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config:
|
||||
mock_config.ENTERPRISE_ENABLED = False
|
||||
|
||||
# Act
|
||||
result = sync_workspace_member_removal(workspace_id="ws-123", member_id="member-456", source="test_source")
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_queue_task.assert_not_called()
|
||||
|
||||
def test_sync_workspace_member_removal_queue_failure(self, mock_queue_task):
|
||||
"""Test handling of queue task failures."""
|
||||
# Arrange
|
||||
mock_queue_task.return_value = False
|
||||
|
||||
with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config:
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
|
||||
# Act
|
||||
result = sync_workspace_member_removal(workspace_id="ws-123", member_id="member-456", source="test_source")
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestSyncAccountDeletion:
|
||||
"""Unit tests for sync_account_deletion function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_session(self):
|
||||
"""Mock database session for testing."""
|
||||
with patch("services.enterprise.account_deletion_sync.db.session") as mock_session:
|
||||
yield mock_session
|
||||
|
||||
@pytest.fixture
|
||||
def mock_queue_task(self):
|
||||
"""Mock _queue_task for testing."""
|
||||
with patch("services.enterprise.account_deletion_sync._queue_task") as mock_queue:
|
||||
mock_queue.return_value = True
|
||||
yield mock_queue
|
||||
|
||||
def test_sync_account_deletion_enterprise_disabled(self, mock_db_session, mock_queue_task):
|
||||
"""Test sync when ENTERPRISE_ENABLED is False (community edition)."""
|
||||
# Arrange
|
||||
with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config:
|
||||
mock_config.ENTERPRISE_ENABLED = False
|
||||
|
||||
# Act
|
||||
result = sync_account_deletion(account_id="acc-123", source="account_deleted")
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_db_session.query.assert_not_called()
|
||||
mock_queue_task.assert_not_called()
|
||||
|
||||
def test_sync_account_deletion_multiple_workspaces(self, mock_db_session, mock_queue_task):
|
||||
"""Test sync for account with multiple workspace memberships."""
|
||||
# Arrange
|
||||
account_id = "acc-123"
|
||||
|
||||
# Mock workspace joins
|
||||
mock_join1 = MagicMock()
|
||||
mock_join1.tenant_id = "tenant-1"
|
||||
mock_join2 = MagicMock()
|
||||
mock_join2.tenant_id = "tenant-2"
|
||||
mock_join3 = MagicMock()
|
||||
mock_join3.tenant_id = "tenant-3"
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter_by.return_value.all.return_value = [mock_join1, mock_join2, mock_join3]
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config:
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
|
||||
# Act
|
||||
result = sync_account_deletion(account_id=account_id, source="account_deleted")
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
assert mock_queue_task.call_count == 3
|
||||
|
||||
# Verify each workspace was queued
|
||||
mock_queue_task.assert_any_call(workspace_id="tenant-1", member_id=account_id, source="account_deleted")
|
||||
mock_queue_task.assert_any_call(workspace_id="tenant-2", member_id=account_id, source="account_deleted")
|
||||
mock_queue_task.assert_any_call(workspace_id="tenant-3", member_id=account_id, source="account_deleted")
|
||||
|
||||
def test_sync_account_deletion_no_workspaces(self, mock_db_session, mock_queue_task):
|
||||
"""Test sync for account with no workspace memberships."""
|
||||
# Arrange
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter_by.return_value.all.return_value = []
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config:
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
|
||||
# Act
|
||||
result = sync_account_deletion(account_id="acc-123", source="account_deleted")
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_queue_task.assert_not_called()
|
||||
|
||||
def test_sync_account_deletion_partial_failure(self, mock_db_session, mock_queue_task):
|
||||
"""Test sync when some tasks fail to queue."""
|
||||
# Arrange
|
||||
account_id = "acc-123"
|
||||
|
||||
# Mock workspace joins
|
||||
mock_join1 = MagicMock()
|
||||
mock_join1.tenant_id = "tenant-1"
|
||||
mock_join2 = MagicMock()
|
||||
mock_join2.tenant_id = "tenant-2"
|
||||
mock_join3 = MagicMock()
|
||||
mock_join3.tenant_id = "tenant-3"
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter_by.return_value.all.return_value = [mock_join1, mock_join2, mock_join3]
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
# Mock queue_task to fail for second workspace
|
||||
def queue_side_effect(workspace_id, member_id, source):
|
||||
return workspace_id != "tenant-2"
|
||||
|
||||
mock_queue_task.side_effect = queue_side_effect
|
||||
|
||||
with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config:
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
|
||||
# Act
|
||||
result = sync_account_deletion(account_id=account_id, source="account_deleted")
|
||||
|
||||
# Assert
|
||||
assert result is False # Should return False if any task fails
|
||||
assert mock_queue_task.call_count == 3
|
||||
|
||||
def test_sync_account_deletion_all_failures(self, mock_db_session, mock_queue_task):
|
||||
"""Test sync when all tasks fail to queue."""
|
||||
# Arrange
|
||||
mock_join = MagicMock()
|
||||
mock_join.tenant_id = "tenant-1"
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter_by.return_value.all.return_value = [mock_join]
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
mock_queue_task.return_value = False
|
||||
|
||||
with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config:
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
|
||||
# Act
|
||||
result = sync_account_deletion(account_id="acc-123", source="account_deleted")
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
mock_queue_task.assert_called_once()
|
||||
@@ -350,7 +350,7 @@ class TestDeleteWorkflowArchiveLogs:
|
||||
mock_query.where.return_value = mock_delete_query
|
||||
mock_db.session.query.return_value = mock_query
|
||||
|
||||
delete_func("log-1")
|
||||
delete_func(mock_db.session, "log-1")
|
||||
|
||||
mock_db.session.query.assert_called_once_with(WorkflowArchiveLog)
|
||||
mock_query.where.assert_called_once()
|
||||
|
||||
2
api/uv.lock
generated
2
api/uv.lock
generated
@@ -1368,7 +1368,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.12.0"
|
||||
version = "1.12.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
|
||||
@@ -21,7 +21,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.12.0
|
||||
image: langgenius/dify-api:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -63,7 +63,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.12.0
|
||||
image: langgenius/dify-api:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -102,7 +102,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.12.0
|
||||
image: langgenius/dify-api:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.12.0
|
||||
image: langgenius/dify-web:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
||||
@@ -707,7 +707,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.12.0
|
||||
image: langgenius/dify-api:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -749,7 +749,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.12.0
|
||||
image: langgenius/dify-api:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -788,7 +788,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.12.0
|
||||
image: langgenius/dify-api:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -818,7 +818,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.12.0
|
||||
image: langgenius/dify-web:1.12.1
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
||||
@@ -35,6 +35,7 @@ export const baseProviderContextValue: ProviderContextState = {
|
||||
refreshLicenseLimit: noop,
|
||||
isAllowTransferWorkspace: false,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
|
||||
humanInputEmailDeliveryEnabled: false,
|
||||
}
|
||||
|
||||
export const createMockProviderContextValue = (overrides: Partial<ProviderContextState> = {}): ProviderContextState => {
|
||||
|
||||
@@ -8,7 +8,6 @@ describe('SVG Attribute Error Reproduction', () => {
|
||||
// Capture console errors
|
||||
const originalError = console.error
|
||||
let errorMessages: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
errorMessages = []
|
||||
console.error = vi.fn((message) => {
|
||||
|
||||
289
web/app/(humanInputLayout)/form/[token]/form.tsx
Normal file
289
web/app/(humanInputLayout)/form/[token]/form.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
'use client'
|
||||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type { HumanInputFormError } from '@/service/use-share'
|
||||
import {
|
||||
RiCheckboxCircleFill,
|
||||
RiErrorWarningFill,
|
||||
RiInformation2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { produce } from 'immer'
|
||||
import { useParams } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
|
||||
import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time'
|
||||
import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export type FormData = {
|
||||
site: { site: SiteInfo }
|
||||
form_content: string
|
||||
inputs: FormInputItem[]
|
||||
resolved_default_values: Record<string, string>
|
||||
user_actions: UserAction[]
|
||||
expiration_time: number
|
||||
}
|
||||
|
||||
const FormContent = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { token } = useParams<{ token: string }>()
|
||||
useDocumentTitle('')
|
||||
|
||||
const [inputs, setInputs] = useState<Record<string, string>>({})
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm()
|
||||
|
||||
const { data: formData, isLoading, error } = useGetHumanInputForm(token)
|
||||
|
||||
const expired = (error as HumanInputFormError | null)?.code === 'human_input_form_expired'
|
||||
const submitted = (error as HumanInputFormError | null)?.code === 'human_input_form_submitted'
|
||||
const rateLimitExceeded = (error as HumanInputFormError | null)?.code === 'web_form_rate_limit_exceeded'
|
||||
|
||||
const splitByOutputVar = (content: string): string[] => {
|
||||
const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g
|
||||
const parts = content.split(outputVarRegex)
|
||||
return parts.filter(part => part.length > 0)
|
||||
}
|
||||
|
||||
const contentList = useMemo(() => {
|
||||
if (!formData?.form_content)
|
||||
return []
|
||||
return splitByOutputVar(formData.form_content)
|
||||
}, [formData?.form_content])
|
||||
|
||||
useEffect(() => {
|
||||
if (!formData?.inputs)
|
||||
return
|
||||
const initialInputs: Record<string, string> = {}
|
||||
formData.inputs.forEach((item) => {
|
||||
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? formData.resolved_default_values[item.output_variable_name] || '' : item.default.value
|
||||
})
|
||||
setInputs(initialInputs)
|
||||
}, [formData?.inputs, formData?.resolved_default_values])
|
||||
|
||||
// use immer
|
||||
const handleInputsChange = (name: string, value: string) => {
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft[name] = value
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}
|
||||
|
||||
const submit = (actionID: string) => {
|
||||
submitForm(
|
||||
{ token, data: { inputs, action: actionID } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccess(true)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Loading type="app" />
|
||||
)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="min-w-[480px] max-w-[640px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
|
||||
<div className="h-[56px] w-[56px] shrink-0 rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiCheckboxCircleFill className="h-8 w-8 text-text-success" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.thanks', { ns: 'share' })}</div>
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.recorded', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="system-2xs-regular-uppercase shrink-0 text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (expired) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="min-w-[480px] max-w-[640px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiInformation2Fill className="h-8 w-8 text-text-accent" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.expired', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="system-2xs-regular-uppercase shrink-0 text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="min-w-[480px] max-w-[640px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiInformation2Fill className="h-8 w-8 text-text-accent" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.completed', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="system-2xs-regular-uppercase shrink-0 text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (rateLimitExceeded) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="min-w-[480px] max-w-[640px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiErrorWarningFill className="h-8 w-8 text-text-destructive" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.rateLimitExceeded', { ns: 'share' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!formData) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="min-w-[480px] max-w-[640px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiErrorWarningFill className="h-8 w-8 text-text-destructive" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.formNotFound', { ns: 'share' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const site = formData.site.site
|
||||
|
||||
return (
|
||||
<div className={cn('mx-auto flex h-full w-full max-w-[720px] flex-col items-center')}>
|
||||
<div className="mt-4 flex w-full shrink-0 items-center gap-3 py-3">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={site.icon_type}
|
||||
icon={site.icon}
|
||||
background={site.icon_background}
|
||||
imageUrl={site.icon_url}
|
||||
/>
|
||||
<div className="system-xl-semibold grow text-text-primary">{site.title}</div>
|
||||
</div>
|
||||
<div className="h-0 w-full grow overflow-y-auto">
|
||||
<div className="border-components-divider-subtle rounded-[20px] border bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-sm">
|
||||
{contentList.map((content, index) => (
|
||||
<ContentItem
|
||||
key={index}
|
||||
content={content}
|
||||
formInputFields={formData.inputs}
|
||||
inputs={inputs}
|
||||
onInputChange={handleInputsChange}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-1 py-1">
|
||||
{formData.user_actions.map((action: UserAction) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
disabled={isSubmitting}
|
||||
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
|
||||
onClick={() => submit(action.id)}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<ExpirationTime expirationTime={formData.expiration_time * 1000} />
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FormContent)
|
||||
13
web/app/(humanInputLayout)/form/[token]/page.tsx
Normal file
13
web/app/(humanInputLayout)/form/[token]/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
import FormContent from './form'
|
||||
|
||||
const FormPage = () => {
|
||||
return (
|
||||
<div className="h-full min-w-[300px] bg-chatbot-bg pb-[env(safe-area-inset-bottom)]">
|
||||
<FormContent />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FormPage)
|
||||
@@ -47,7 +47,7 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
await webAppLogout(shareCode!)
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router, webAppLogout, shareCode])
|
||||
}, [getSigninUrl, router, shareCode])
|
||||
|
||||
if (appInfoError) {
|
||||
return (
|
||||
|
||||
@@ -31,7 +31,7 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
|
||||
await webAppLogout(shareCode!)
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router, webAppLogout, shareCode])
|
||||
}, [getSigninUrl, router, shareCode])
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -115,6 +115,7 @@ export type AppPublisherProps = {
|
||||
missingStartNode?: boolean
|
||||
hasTriggerNode?: boolean // Whether workflow currently contains any trigger nodes (used to hide missing-start CTA when triggers exist).
|
||||
startNodeLimitExceeded?: boolean
|
||||
hasHumanInputNode?: boolean
|
||||
}
|
||||
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
@@ -138,13 +139,14 @@ const AppPublisher = ({
|
||||
missingStartNode = false,
|
||||
hasTriggerNode = false,
|
||||
startNodeLimitExceeded = false,
|
||||
hasHumanInputNode = false,
|
||||
}: AppPublisherProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [published, setPublished] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
|
||||
const [isAppAccessSet, setIsAppAccessSet] = useState(true)
|
||||
|
||||
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
|
||||
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
@@ -161,6 +163,13 @@ const AppPublisher = ({
|
||||
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const isAppAccessSet = useMemo(() => {
|
||||
if (appDetail && appAccessSubjects) {
|
||||
return !(appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
|
||||
}
|
||||
return true
|
||||
}, [appAccessSubjects, appDetail])
|
||||
|
||||
const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp])
|
||||
const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission])
|
||||
|
||||
@@ -171,25 +180,13 @@ const AppPublisher = ({
|
||||
return t('noUserInputNode', { ns: 'app' })
|
||||
if (noAccessPermission)
|
||||
return t('noAccessPermission', { ns: 'app' })
|
||||
}, [missingStartNode, noAccessPermission, publishedAt])
|
||||
}, [missingStartNode, noAccessPermission, publishedAt, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (systemFeatures.webapp_auth.enabled && open && appDetail)
|
||||
refetch()
|
||||
}, [open, appDetail, refetch, systemFeatures])
|
||||
|
||||
useEffect(() => {
|
||||
if (appDetail && appAccessSubjects) {
|
||||
if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
|
||||
setIsAppAccessSet(false)
|
||||
else
|
||||
setIsAppAccessSet(true)
|
||||
}
|
||||
else {
|
||||
setIsAppAccessSet(true)
|
||||
}
|
||||
}, [appAccessSubjects, appDetail])
|
||||
|
||||
const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
|
||||
try {
|
||||
await onPublish?.(params)
|
||||
@@ -461,7 +458,7 @@ const AppPublisher = ({
|
||||
{t('common.accessAPIReference', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW && (
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={workflowToolDisabled}
|
||||
published={!!toolPublished}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -13,8 +14,8 @@ import { getRedirection } from '@/utils/app-redirection'
|
||||
import CreateAppModal from './index'
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounceFn: (fn: (...args: any[]) => any) => {
|
||||
const run = (...args: any[]) => fn(...args)
|
||||
useDebounceFn: <T extends (...args: unknown[]) => unknown>(fn: T) => {
|
||||
const run = (...args: Parameters<T>) => fn(...args)
|
||||
const cancel = vi.fn()
|
||||
const flush = vi.fn()
|
||||
return { run, cancel, flush }
|
||||
@@ -83,7 +84,7 @@ describe('CreateAppModal', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseRouter.mockReturnValue({ push: mockPush } as any)
|
||||
mockUseRouter.mockReturnValue({ push: mockPush } as unknown as ReturnType<typeof useRouter>)
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
type: AppModeEnum.ADVANCED_CHAT,
|
||||
@@ -92,10 +93,10 @@ describe('CreateAppModal', () => {
|
||||
reset: {},
|
||||
},
|
||||
enableBilling: true,
|
||||
} as any)
|
||||
} as unknown as ReturnType<typeof useProviderContext>)
|
||||
mockUseAppContext.mockReturnValue({
|
||||
isCurrentWorkspaceEditor: true,
|
||||
} as any)
|
||||
} as unknown as ReturnType<typeof useAppContext>)
|
||||
mockSetItem.mockClear()
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
@@ -118,8 +119,8 @@ describe('CreateAppModal', () => {
|
||||
})
|
||||
|
||||
it('creates an app, notifies success, and fires callbacks', async () => {
|
||||
const mockApp = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }
|
||||
mockCreateApp.mockResolvedValue(mockApp as any)
|
||||
const mockApp: Partial<App> = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }
|
||||
mockCreateApp.mockResolvedValue(mockApp as App)
|
||||
const { onClose, onSuccess } = renderModal()
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
|
||||
|
||||
@@ -68,6 +68,7 @@ type IDrawerContext = {
|
||||
}
|
||||
|
||||
type StatusCount = {
|
||||
paused: number
|
||||
success: number
|
||||
failed: number
|
||||
partial_success: number
|
||||
@@ -93,7 +94,15 @@ const statusTdRender = (statusCount: StatusCount) => {
|
||||
if (!statusCount)
|
||||
return null
|
||||
|
||||
if (statusCount.partial_success + statusCount.failed === 0) {
|
||||
if (statusCount.paused > 0) {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
<Indicator color="yellow" />
|
||||
<span className="text-util-colors-warning-warning-600">Pending</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
else if (statusCount.partial_success + statusCount.failed === 0) {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
<Indicator color="green" />
|
||||
@@ -296,7 +305,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
if (abortControllerRef.current === controller)
|
||||
abortControllerRef.current = null
|
||||
}
|
||||
}, [detail.id, hasMore, timezone, t, appDetail, detail?.model_config?.configs?.introduction])
|
||||
}, [detail.id, hasMore, timezone, t, appDetail])
|
||||
|
||||
// Derive chatItemTree, threadChatItems, and oldestAnswerIdRef from allChatItems
|
||||
useEffect(() => {
|
||||
@@ -411,7 +420,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
|
||||
return false
|
||||
}
|
||||
}, [allChatItems, appDetail?.id, t])
|
||||
}, [allChatItems, appDetail?.id, notify, t])
|
||||
|
||||
const fetchInitiated = useRef(false)
|
||||
|
||||
@@ -504,7 +513,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [detail.id, hasMore, isLoading, timezone, t, appDetail, detail?.model_config?.configs?.introduction])
|
||||
}, [detail.id, hasMore, isLoading, timezone, t, appDetail])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const scrollableDiv = document.getElementById('scrollableDiv')
|
||||
|
||||
@@ -53,6 +53,7 @@ const defaultProviderContext = {
|
||||
refreshLicenseLimit: noop,
|
||||
isAllowTransferWorkspace: false,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
|
||||
humanInputEmailDeliveryEnabled: false,
|
||||
}
|
||||
|
||||
const defaultModalContext: ModalContextState = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
RiClipboardLine,
|
||||
RiFileList3Line,
|
||||
RiPlayList2Line,
|
||||
RiReplay15Line,
|
||||
RiResetLeftLine,
|
||||
RiSparklingFill,
|
||||
RiSparklingLine,
|
||||
RiThumbDownLine,
|
||||
@@ -18,10 +18,12 @@ import { useBoolean } from 'ahooks'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { useParams } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import HumanInputFilledFormList from '@/app/components/base/chat/chat/answer/human-input-filled-form-list'
|
||||
import HumanInputFormList from '@/app/components/base/chat/chat/answer/human-input-form-list'
|
||||
import WorkflowProcessItem from '@/app/components/base/chat/chat/answer/workflow-process'
|
||||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
@@ -29,7 +31,8 @@ import { Markdown } from '@/app/components/base/markdown'
|
||||
import NewAudioButton from '@/app/components/base/new-audio-button'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { fetchTextGenerationMessage } from '@/service/debug'
|
||||
import { AppSourceType, fetchMoreLikeThis, updateFeedback } from '@/service/share'
|
||||
import { AppSourceType, fetchMoreLikeThis, submitHumanInputForm, updateFeedback } from '@/service/share'
|
||||
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ResultTab from './result-tab'
|
||||
|
||||
@@ -121,7 +124,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false)
|
||||
|
||||
const childProps = {
|
||||
isInWebApp: true,
|
||||
isInWebApp,
|
||||
content: completionRes,
|
||||
messageId: childMessageId,
|
||||
depth: depth + 1,
|
||||
@@ -202,16 +205,22 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
}
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<string>('DETAIL')
|
||||
const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length
|
||||
const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length || (workflowProcessData?.humanInputFormDataList && workflowProcessData?.humanInputFormDataList.length > 0) || (workflowProcessData?.humanInputFilledFormDataList && workflowProcessData?.humanInputFilledFormDataList.length > 0)
|
||||
const switchTab = async (tab: string) => {
|
||||
setCurrentTab(tab)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length)
|
||||
if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length || (workflowProcessData?.humanInputFormDataList && workflowProcessData?.humanInputFormDataList.length > 0) || (workflowProcessData?.humanInputFilledFormDataList && workflowProcessData?.humanInputFilledFormDataList.length > 0))
|
||||
switchTab('RESULT')
|
||||
else
|
||||
switchTab('DETAIL')
|
||||
}, [workflowProcessData?.files?.length, workflowProcessData?.resultText])
|
||||
}, [workflowProcessData?.files?.length, workflowProcessData?.resultText, workflowProcessData?.humanInputFormDataList, workflowProcessData?.humanInputFilledFormDataList])
|
||||
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record<string, string>, action: string }) => {
|
||||
if (appSourceType === AppSourceType.installedApp)
|
||||
await submitHumanInputFormService(formToken, formData)
|
||||
else
|
||||
await submitHumanInputForm(formToken, formData)
|
||||
}, [appSourceType])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -275,7 +284,24 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
)}
|
||||
</div>
|
||||
{!isError && (
|
||||
<ResultTab data={workflowProcessData} content={content} currentTab={currentTab} />
|
||||
<>
|
||||
{currentTab === 'RESULT' && workflowProcessData.humanInputFormDataList && workflowProcessData.humanInputFormDataList.length > 0 && (
|
||||
<div className="px-4 pt-4">
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={workflowProcessData.humanInputFormDataList}
|
||||
onHumanInputFormSubmit={handleSubmitHumanInputForm}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{currentTab === 'RESULT' && workflowProcessData.humanInputFilledFormDataList && workflowProcessData.humanInputFilledFormDataList.length > 0 && (
|
||||
<div className="px-4 pt-4">
|
||||
<HumanInputFilledFormList
|
||||
humanInputFilledFormDataList={workflowProcessData.humanInputFilledFormDataList}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ResultTab data={workflowProcessData} content={content} currentTab={currentTab} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -348,7 +374,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
)}
|
||||
{isInWebApp && isError && (
|
||||
<ActionButton onClick={onRetry}>
|
||||
<RiReplay15Line className="h-4 w-4" />
|
||||
<RiResetLeftLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{isInWebApp && !isWorkflow && !isTryApp && (
|
||||
|
||||
@@ -81,6 +81,14 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'paused') {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
<Indicator color="yellow" />
|
||||
<span className="text-util-colors-warning-warning-600">Pending</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'running') {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
@apply p-0.5 w-6 h-6 rounded-lg
|
||||
}
|
||||
|
||||
.action-btn-s {
|
||||
@apply w-5 h-5 rounded-[6px]
|
||||
}
|
||||
|
||||
.action-btn-xs {
|
||||
@apply p-0 w-4 h-4 rounded
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const actionButtonVariants = cva(
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'action-btn-xs',
|
||||
s: 'action-btn-s',
|
||||
m: 'action-btn-m',
|
||||
l: 'action-btn-l',
|
||||
xl: 'action-btn-xl',
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FileEntity } from '../../file-uploader/types'
|
||||
import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
ChatItemInTree,
|
||||
OnSend,
|
||||
} from '../types'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
@@ -16,7 +17,9 @@ import {
|
||||
fetchSuggestedQuestions,
|
||||
getUrl,
|
||||
stopChatMessageResponding,
|
||||
submitHumanInputForm,
|
||||
} from '@/service/share'
|
||||
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { formatBooleanInputs } from '@/utils/model-config'
|
||||
@@ -73,9 +76,9 @@ const ChatWrapper = () => {
|
||||
}, [appParams, currentConversationItem?.introduction])
|
||||
const {
|
||||
chatList,
|
||||
setTargetMessageId,
|
||||
handleSend,
|
||||
handleStop,
|
||||
handleSwitchSibling,
|
||||
isResponding: respondingState,
|
||||
suggestedQuestions,
|
||||
} = useChat(
|
||||
@@ -122,8 +125,11 @@ const ChatWrapper = () => {
|
||||
|
||||
if (fileIsUploading)
|
||||
return true
|
||||
|
||||
if (chatList.some(item => item.isAnswer && item.humanInputFormDataList && item.humanInputFormDataList.length > 0))
|
||||
return true
|
||||
return false
|
||||
}, [inputsFormValue, inputsForms, allInputsHidden])
|
||||
}, [allInputsHidden, inputsForms, chatList, inputsFormValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentChatInstanceRef.current)
|
||||
@@ -134,6 +140,40 @@ const ChatWrapper = () => {
|
||||
setIsResponding(respondingState)
|
||||
}, [respondingState, setIsResponding])
|
||||
|
||||
// Resume paused workflows when chat history is loaded
|
||||
useEffect(() => {
|
||||
if (!appPrevChatTree || appPrevChatTree.length === 0)
|
||||
return
|
||||
|
||||
// Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first)
|
||||
let lastPausedNode: ChatItemInTree | undefined
|
||||
const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => {
|
||||
nodes.forEach((node) => {
|
||||
// DFS: recurse to children first
|
||||
if (node.children && node.children.length > 0)
|
||||
findLastPausedWorkflow(node.children)
|
||||
|
||||
// Track the last node with humanInputFormDataList
|
||||
if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0)
|
||||
lastPausedNode = node
|
||||
})
|
||||
}
|
||||
|
||||
findLastPausedWorkflow(appPrevChatTree)
|
||||
|
||||
// Only resume the last paused workflow
|
||||
if (lastPausedNode) {
|
||||
handleSwitchSibling(
|
||||
lastPausedNode.id,
|
||||
{
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
},
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
|
||||
const data: any = {
|
||||
query: message,
|
||||
@@ -149,10 +189,10 @@ const ChatWrapper = () => {
|
||||
{
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: !isInstalledApp,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
},
|
||||
)
|
||||
}, [chatList, handleNewConversationCompleted, handleSend, currentConversationId, currentConversationInputs, newConversationInputs, isInstalledApp, appId])
|
||||
}, [inputsForms, currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, isHistoryConversation, handleNewConversationCompleted])
|
||||
|
||||
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
|
||||
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
|
||||
@@ -160,12 +200,27 @@ const ChatWrapper = () => {
|
||||
doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
|
||||
}, [chatList, doSend])
|
||||
|
||||
const doSwitchSibling = useCallback((siblingMessageId: string) => {
|
||||
handleSwitchSibling(siblingMessageId, {
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
})
|
||||
}, [handleSwitchSibling, currentConversationId, handleNewConversationCompleted, appSourceType, appId])
|
||||
|
||||
const messageList = useMemo(() => {
|
||||
if (currentConversationId || chatList.length > 1)
|
||||
return chatList
|
||||
// Without messages we are in the welcome screen, so hide the opening statement from chatlist
|
||||
return chatList.filter(item => !item.isOpeningStatement)
|
||||
}, [chatList])
|
||||
}, [chatList, currentConversationId])
|
||||
|
||||
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => {
|
||||
if (isInstalledApp)
|
||||
await submitHumanInputFormService(formToken, formData)
|
||||
else
|
||||
await submitHumanInputForm(formToken, formData)
|
||||
}, [isInstalledApp])
|
||||
|
||||
const [collapsed, setCollapsed] = useState(!!currentConversationId)
|
||||
|
||||
@@ -274,6 +329,7 @@ const ChatWrapper = () => {
|
||||
inputsForm={inputsForms}
|
||||
onRegenerate={doRegenerate}
|
||||
onStopResponding={handleStop}
|
||||
onHumanInputFormSubmit={handleSubmitHumanInputForm}
|
||||
chatNode={(
|
||||
<>
|
||||
{chatNode}
|
||||
@@ -286,7 +342,7 @@ const ChatWrapper = () => {
|
||||
answerIcon={answerIcon}
|
||||
hideProcessDetail
|
||||
themeBuilder={themeBuilder}
|
||||
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
|
||||
switchSibling={doSwitchSibling}
|
||||
inputDisabled={inputDisabled}
|
||||
sidebarCollapseState={sidebarCollapseState}
|
||||
questionIcon={
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ExtraContent } from '../chat/type'
|
||||
import type {
|
||||
Callback,
|
||||
ChatConfig,
|
||||
@@ -9,6 +10,7 @@ import type {
|
||||
AppData,
|
||||
ConversationItem,
|
||||
} from '@/models/share'
|
||||
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
|
||||
import { useLocalStorageState } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
@@ -57,6 +59,24 @@ function getFormattedChatList(messages: any[]) {
|
||||
parentMessageId: item.parent_message_id || undefined,
|
||||
})
|
||||
const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || []
|
||||
const humanInputFormDataList: HumanInputFormData[] = []
|
||||
const humanInputFilledFormDataList: HumanInputFilledFormData[] = []
|
||||
let workflowRunId = ''
|
||||
if (item.status === 'paused') {
|
||||
item.extra_contents?.forEach((content: ExtraContent) => {
|
||||
if (content.type === 'human_input' && !content.submitted) {
|
||||
humanInputFormDataList.push(content.form_definition)
|
||||
workflowRunId = content.workflow_run_id
|
||||
}
|
||||
})
|
||||
}
|
||||
else if (item.status === 'normal') {
|
||||
item.extra_contents?.forEach((content: ExtraContent) => {
|
||||
if (content.type === 'human_input' && content.submitted) {
|
||||
humanInputFilledFormDataList.push(content.form_submission_data)
|
||||
}
|
||||
})
|
||||
}
|
||||
newChatList.push({
|
||||
id: item.id,
|
||||
content: item.answer,
|
||||
@@ -66,6 +86,9 @@ function getFormattedChatList(messages: any[]) {
|
||||
citation: item.retriever_resources,
|
||||
message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))),
|
||||
parentMessageId: `question-${item.id}`,
|
||||
humanInputFormDataList,
|
||||
humanInputFilledFormDataList,
|
||||
workflow_run_id: workflowRunId,
|
||||
})
|
||||
})
|
||||
return newChatList
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ContentItemProps } from './type'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
const ContentItem = ({
|
||||
content,
|
||||
formInputFields,
|
||||
inputs,
|
||||
onInputChange,
|
||||
}: ContentItemProps) => {
|
||||
const isInputField = (field: string) => {
|
||||
const outputVarRegex = /\{\{#\$output\.[^#]+#\}\}/
|
||||
return outputVarRegex.test(field)
|
||||
}
|
||||
|
||||
const extractFieldName = (str: string): string => {
|
||||
const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/
|
||||
const match = str.match(outputVarRegex)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
const fieldName = useMemo(() => {
|
||||
return extractFieldName(content)
|
||||
}, [content])
|
||||
|
||||
const formInputField = useMemo(() => {
|
||||
return formInputFields.find(field => field.output_variable_name === fieldName)
|
||||
}, [formInputFields, fieldName])
|
||||
|
||||
if (!isInputField(content)) {
|
||||
return (
|
||||
<Markdown content={content} />
|
||||
)
|
||||
}
|
||||
|
||||
if (!formInputField)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="py-3">
|
||||
{formInputField.type === 'paragraph' && (
|
||||
<Textarea
|
||||
className="h-[104px] sm:text-xs"
|
||||
value={inputs[fieldName]}
|
||||
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ContentItem)
|
||||
@@ -0,0 +1,64 @@
|
||||
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type ContentWrapperProps = {
|
||||
nodeTitle: string
|
||||
children: React.ReactNode
|
||||
showExpandIcon?: boolean
|
||||
className?: string
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
const ContentWrapper = ({
|
||||
nodeTitle,
|
||||
children,
|
||||
showExpandIcon = false,
|
||||
className,
|
||||
expanded = false,
|
||||
}: ContentWrapperProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(expanded)
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
setIsExpanded(!isExpanded)
|
||||
}, [isExpanded])
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', className)}>
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
{/* node icon */}
|
||||
<BlockIcon type={BlockEnum.HumanInput} className="shrink-0" />
|
||||
{/* node name */}
|
||||
<div
|
||||
className="system-sm-semibold-uppercase grow truncate text-text-primary"
|
||||
title={nodeTitle}
|
||||
>
|
||||
{nodeTitle}
|
||||
</div>
|
||||
{showExpandIcon && (
|
||||
<div className="shrink-0 cursor-pointer" onClick={handleToggleExpand}>
|
||||
{
|
||||
isExpanded
|
||||
? (
|
||||
<RiArrowDownSLine className="size-4" />
|
||||
)
|
||||
: (
|
||||
<RiArrowRightSLine className="size-4" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(!showExpandIcon || isExpanded) && (
|
||||
<div className="px-2 py-1">
|
||||
{/* human input form content */}
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContentWrapper
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ExecutedAction as ExecutedActionType } from './type'
|
||||
import { memo } from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
|
||||
|
||||
type ExecutedActionProps = {
|
||||
executedAction: ExecutedActionType
|
||||
}
|
||||
|
||||
const ExecutedAction = ({
|
||||
executedAction,
|
||||
}: ExecutedActionProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1 py-1">
|
||||
<Divider className="mb-2 mt-1 w-[30px]" />
|
||||
<div className="system-xs-regular flex items-center gap-x-1 text-text-tertiary">
|
||||
<TriggerAll className="size-3.5 shrink-0" />
|
||||
<Trans
|
||||
i18nKey="nodes.humanInput.userActions.triggered"
|
||||
ns="workflow"
|
||||
components={{ strong: <span className="system-xs-medium text-text-secondary"></span> }}
|
||||
values={{ actionName: executedAction.id }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ExecutedAction)
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
import { RiAlertFill, RiTimeLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getRelativeTime, isRelativeTimeSameOrAfter } from './utils'
|
||||
|
||||
type ExpirationTimeProps = {
|
||||
expirationTime: number
|
||||
}
|
||||
|
||||
const ExpirationTime = ({
|
||||
expirationTime,
|
||||
}: ExpirationTimeProps) => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const relativeTime = getRelativeTime(expirationTime, locale)
|
||||
const isSameOrAfter = isRelativeTimeSameOrAfter(expirationTime)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-regular mt-1 flex items-center gap-x-1 text-text-tertiary',
|
||||
!isSameOrAfter && 'text-text-warning',
|
||||
)}
|
||||
>
|
||||
{
|
||||
isSameOrAfter
|
||||
? (
|
||||
<>
|
||||
<RiTimeLine className="size-3.5" />
|
||||
<span>{t('humanInput.expirationTimeNowOrFuture', { relativeTime, ns: 'share' })}</span>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<RiAlertFill className="size-3.5" />
|
||||
<span>{t('humanInput.expiredTip', { ns: 'share' })}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExpirationTime
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
import type { HumanInputFormProps } from './type'
|
||||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ContentItem from './content-item'
|
||||
import { getButtonStyle, initializeInputs, splitByOutputVar } from './utils'
|
||||
|
||||
const HumanInputForm = ({
|
||||
formData,
|
||||
onSubmit,
|
||||
}: HumanInputFormProps) => {
|
||||
const formToken = formData.form_token
|
||||
const defaultInputs = initializeInputs(formData.inputs, formData.resolved_default_values || {})
|
||||
const contentList = splitByOutputVar(formData.form_content)
|
||||
const [inputs, setInputs] = useState(defaultInputs)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleInputsChange = useCallback((name: string, value: string) => {
|
||||
setInputs(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const submit = async (formToken: string, actionID: string, inputs: Record<string, string>) => {
|
||||
setIsSubmitting(true)
|
||||
await onSubmit?.(formToken, { inputs, action: actionID })
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{contentList.map((content, index) => (
|
||||
<ContentItem
|
||||
key={index}
|
||||
content={content}
|
||||
formInputFields={formData.inputs}
|
||||
inputs={inputs}
|
||||
onInputChange={handleInputsChange}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-1 py-1">
|
||||
{formData.actions.map((action: UserAction) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
disabled={isSubmitting}
|
||||
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
|
||||
onClick={() => submit(formToken, action.id, inputs)}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(HumanInputForm)
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from 'react'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
|
||||
type SubmittedContentProps = {
|
||||
content: string
|
||||
}
|
||||
|
||||
const SubmittedContent = ({
|
||||
content,
|
||||
}: SubmittedContentProps) => {
|
||||
return (
|
||||
<Markdown content={content} />
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SubmittedContent)
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SubmittedHumanInputContentProps } from './type'
|
||||
import { useMemo } from 'react'
|
||||
import ExecutedAction from './executed-action'
|
||||
import SubmittedContent from './submitted-content'
|
||||
|
||||
export const SubmittedHumanInputContent = ({
|
||||
formData,
|
||||
}: SubmittedHumanInputContentProps) => {
|
||||
const { rendered_content, action_id, action_text } = formData
|
||||
|
||||
const executedAction = useMemo(() => {
|
||||
return {
|
||||
id: action_id,
|
||||
title: action_text,
|
||||
}
|
||||
}, [action_id, action_text])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubmittedContent content={rendered_content} />
|
||||
{/* Executed Action */}
|
||||
<ExecutedAction executedAction={executedAction} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { memo } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { useSelector as useAppSelector } from '@/context/app-context'
|
||||
|
||||
type TipsProps = {
|
||||
showEmailTip: boolean
|
||||
isEmailDebugMode: boolean
|
||||
showDebugModeTip: boolean
|
||||
}
|
||||
|
||||
const Tips = ({
|
||||
showEmailTip,
|
||||
isEmailDebugMode,
|
||||
showDebugModeTip,
|
||||
}: TipsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const email = useAppSelector(s => s.userProfile.email)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider className="!my-2 w-[30px]" />
|
||||
<div className="space-y-1 pt-1">
|
||||
{showEmailTip && !isEmailDebugMode && (
|
||||
<div className="system-xs-regular text-text-secondary">{t('common.humanInputEmailTip', { ns: 'workflow' })}</div>
|
||||
)}
|
||||
{showEmailTip && isEmailDebugMode && (
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
<Trans
|
||||
i18nKey="common.humanInputEmailTipInDebugMode"
|
||||
ns="workflow"
|
||||
components={{ email: <span className="system-xs-semibold"></span> }}
|
||||
values={{ email }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showDebugModeTip && <div className="system-xs-medium text-text-warning">{t('common.humanInputWebappTip', { ns: 'workflow' })}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Tips)
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
|
||||
|
||||
export type ExecutedAction = {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export type UnsubmittedHumanInputContentProps = {
|
||||
formData: HumanInputFormData
|
||||
showEmailTip?: boolean
|
||||
isEmailDebugMode?: boolean
|
||||
showDebugModeTip?: boolean
|
||||
onSubmit?: (formToken: string, data: { inputs: Record<string, string>, action: string }) => Promise<void>
|
||||
}
|
||||
|
||||
export type SubmittedHumanInputContentProps = {
|
||||
formData: HumanInputFilledFormData
|
||||
}
|
||||
|
||||
export type HumanInputFormProps = {
|
||||
formData: HumanInputFormData
|
||||
onSubmit?: (formToken: string, data: { inputs: Record<string, string>, action: string }) => Promise<void>
|
||||
}
|
||||
|
||||
export type ContentItemProps = {
|
||||
content: string
|
||||
formInputFields: FormInputItem[]
|
||||
inputs: Record<string, string>
|
||||
onInputChange: (name: string, value: string) => void
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { UnsubmittedHumanInputContentProps } from './type'
|
||||
import ExpirationTime from './expiration-time'
|
||||
import HumanInputForm from './human-input-form'
|
||||
import Tips from './tips'
|
||||
|
||||
export const UnsubmittedHumanInputContent = ({
|
||||
formData,
|
||||
showEmailTip = false,
|
||||
isEmailDebugMode = false,
|
||||
showDebugModeTip = false,
|
||||
onSubmit,
|
||||
}: UnsubmittedHumanInputContentProps) => {
|
||||
const { expiration_time } = formData
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Form */}
|
||||
<HumanInputForm
|
||||
formData={formData}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
{/* Tips */}
|
||||
{(showEmailTip || showDebugModeTip) && (
|
||||
<Tips
|
||||
showEmailTip={showEmailTip}
|
||||
isEmailDebugMode={isEmailDebugMode}
|
||||
showDebugModeTip={showDebugModeTip}
|
||||
/>
|
||||
)}
|
||||
{/* Expiration Time */}
|
||||
{typeof expiration_time === 'number' && (
|
||||
<ExpirationTime expirationTime={expiration_time * 1000} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import dayjs from 'dayjs'
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import 'dayjs/locale/en'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import 'dayjs/locale/ja'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.extend(isSameOrAfter)
|
||||
|
||||
export const getButtonStyle = (style: UserActionButtonType) => {
|
||||
if (style === UserActionButtonType.Primary)
|
||||
return 'primary'
|
||||
if (style === UserActionButtonType.Default)
|
||||
return 'secondary'
|
||||
if (style === UserActionButtonType.Accent)
|
||||
return 'secondary-accent'
|
||||
if (style === UserActionButtonType.Ghost)
|
||||
return 'ghost'
|
||||
}
|
||||
|
||||
export const splitByOutputVar = (content: string): string[] => {
|
||||
const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g
|
||||
const parts = content.split(outputVarRegex)
|
||||
return parts.filter(part => part.length > 0)
|
||||
}
|
||||
|
||||
export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Record<string, string> = {}) => {
|
||||
const initialInputs: Record<string, any> = {}
|
||||
formInputs.forEach((item) => {
|
||||
if (item.type === 'text-input' || item.type === 'paragraph')
|
||||
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? defaultValues[item.output_variable_name] || '' : item.default.value
|
||||
else
|
||||
initialInputs[item.output_variable_name] = undefined
|
||||
})
|
||||
return initialInputs
|
||||
}
|
||||
|
||||
const localeMap: Record<string, string> = {
|
||||
'en-US': 'en',
|
||||
'zh-Hans': 'zh-cn',
|
||||
'ja-JP': 'ja',
|
||||
}
|
||||
|
||||
export const getRelativeTime = (
|
||||
utcTimestamp: string | number,
|
||||
locale: Locale = 'en-US',
|
||||
) => {
|
||||
const dayjsLocale = localeMap[locale] ?? 'en'
|
||||
|
||||
return dayjs
|
||||
.utc(utcTimestamp)
|
||||
.locale(dayjsLocale)
|
||||
.fromNow()
|
||||
}
|
||||
|
||||
export const isRelativeTimeSameOrAfter = (utcTimestamp: string | number) => {
|
||||
return dayjs.utc(utcTimestamp).isSameOrAfter(dayjs())
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { HumanInputFilledFormData } from '@/types/workflow'
|
||||
import ContentWrapper from './human-input-content/content-wrapper'
|
||||
import { SubmittedHumanInputContent } from './human-input-content/submitted'
|
||||
|
||||
type HumanInputFilledFormListProps = {
|
||||
humanInputFilledFormDataList: HumanInputFilledFormData[]
|
||||
}
|
||||
|
||||
const HumanInputFilledFormList = ({
|
||||
humanInputFilledFormDataList,
|
||||
}: HumanInputFilledFormListProps) => {
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-y-2">
|
||||
{
|
||||
humanInputFilledFormDataList.map(formData => (
|
||||
<ContentWrapper
|
||||
key={formData.node_id}
|
||||
nodeTitle={formData.node_title}
|
||||
showExpandIcon
|
||||
>
|
||||
<SubmittedHumanInputContent
|
||||
key={formData.node_id}
|
||||
formData={formData}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HumanInputFilledFormList
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { DeliveryMethod, HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { useMemo } from 'react'
|
||||
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import ContentWrapper from './human-input-content/content-wrapper'
|
||||
import { UnsubmittedHumanInputContent } from './human-input-content/unsubmitted'
|
||||
|
||||
type HumanInputFormListProps = {
|
||||
humanInputFormDataList: HumanInputFormData[]
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: { inputs: Record<string, string>, action: string }) => Promise<void>
|
||||
getHumanInputNodeData?: (nodeID: string) => Node<HumanInputNodeType> | undefined
|
||||
}
|
||||
|
||||
const HumanInputFormList = ({
|
||||
humanInputFormDataList,
|
||||
onHumanInputFormSubmit,
|
||||
getHumanInputNodeData,
|
||||
}: HumanInputFormListProps) => {
|
||||
const deliveryMethodsConfig = useMemo((): Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }> => {
|
||||
if (!humanInputFormDataList.length)
|
||||
return {}
|
||||
return humanInputFormDataList.reduce((acc, formData) => {
|
||||
const deliveryMethodsConfig = getHumanInputNodeData?.(formData.node_id)?.data.delivery_methods || []
|
||||
if (!deliveryMethodsConfig.length) {
|
||||
acc[formData.node_id] = {
|
||||
showEmailTip: false,
|
||||
isEmailDebugMode: false,
|
||||
showDebugModeTip: false,
|
||||
}
|
||||
return acc
|
||||
}
|
||||
const isWebappEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.WebApp && method.enabled)
|
||||
const isEmailEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.enabled)
|
||||
const isEmailDebugMode = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.config?.debug_mode)
|
||||
acc[formData.node_id] = {
|
||||
showEmailTip: isEmailEnabled,
|
||||
isEmailDebugMode,
|
||||
showDebugModeTip: !isWebappEnabled,
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }>)
|
||||
}, [getHumanInputNodeData, humanInputFormDataList])
|
||||
|
||||
const filteredHumanInputFormDataList = humanInputFormDataList.filter(formData => formData.display_in_ui)
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-y-2">
|
||||
{
|
||||
filteredHumanInputFormDataList.map(formData => (
|
||||
<ContentWrapper
|
||||
key={formData.form_id}
|
||||
nodeTitle={formData.node_title}
|
||||
>
|
||||
<UnsubmittedHumanInputContent
|
||||
key={formData.form_id}
|
||||
formData={formData}
|
||||
showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip}
|
||||
isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode}
|
||||
showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip}
|
||||
onSubmit={onHumanInputFormSubmit}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HumanInputFormList
|
||||
@@ -16,8 +16,11 @@ import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
|
||||
import { FileList } from '@/app/components/base/file-uploader'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ContentSwitch from '../content-switch'
|
||||
import { useChatContext } from '../context'
|
||||
import AgentContent from './agent-content'
|
||||
import BasicContent from './basic-content'
|
||||
import HumanInputFilledFormList from './human-input-filled-form-list'
|
||||
import HumanInputFormList from './human-input-form-list'
|
||||
import More from './more'
|
||||
import Operation from './operation'
|
||||
import SuggestedQuestions from './suggested-questions'
|
||||
@@ -36,6 +39,8 @@ type AnswerProps = {
|
||||
appData?: AppData
|
||||
noChatInput?: boolean
|
||||
switchSibling?: (siblingMessageId: string) => void
|
||||
hideAvatar?: boolean
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
|
||||
}
|
||||
const Answer: FC<AnswerProps> = ({
|
||||
item,
|
||||
@@ -50,6 +55,8 @@ const Answer: FC<AnswerProps> = ({
|
||||
appData,
|
||||
noChatInput,
|
||||
switchSibling,
|
||||
hideAvatar,
|
||||
onHumanInputFormSubmit,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
@@ -61,13 +68,22 @@ const Answer: FC<AnswerProps> = ({
|
||||
workflowProcess,
|
||||
allFiles,
|
||||
message_files,
|
||||
humanInputFormDataList,
|
||||
humanInputFilledFormDataList,
|
||||
} = item
|
||||
const hasAgentThoughts = !!agent_thoughts?.length
|
||||
const hasHumanInputs = !!humanInputFormDataList?.length || !!humanInputFilledFormDataList?.length
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
const [contentWidth, setContentWidth] = useState(0)
|
||||
const [humanInputFormContainerWidth, setHumanInputFormContainerWidth] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const humanInputFormContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
getHumanInputNodeData,
|
||||
} = useChatContext()
|
||||
|
||||
const getContainerWidth = () => {
|
||||
if (containerRef.current)
|
||||
@@ -87,12 +103,23 @@ const Answer: FC<AnswerProps> = ({
|
||||
getContentWidth()
|
||||
}, [responding])
|
||||
|
||||
const getHumanInputFormContainerWidth = () => {
|
||||
if (humanInputFormContainerRef.current)
|
||||
setHumanInputFormContainerWidth(humanInputFormContainerRef.current?.clientWidth)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasHumanInputs)
|
||||
getHumanInputFormContainerWidth()
|
||||
}, [hasHumanInputs])
|
||||
|
||||
// Recalculate contentWidth when content changes (e.g., SVG preview/source toggle)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current)
|
||||
return
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
getContentWidth()
|
||||
getHumanInputFormContainerWidth()
|
||||
})
|
||||
resizeObserver.observe(containerRef.current)
|
||||
return () => {
|
||||
@@ -115,115 +142,285 @@ const Answer: FC<AnswerProps> = ({
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex last:mb-0">
|
||||
<div className="relative h-10 w-10 shrink-0">
|
||||
{answerIcon || <AnswerIcon />}
|
||||
{responding && (
|
||||
<div className="absolute left-[-3px] top-[-3px] flex h-4 w-4 items-center rounded-full border-[0.5px] border-divider-subtle bg-background-section-burn pl-[6px] shadow-xs">
|
||||
<LoadingAnim type="avatar" />
|
||||
{!hideAvatar && (
|
||||
<div className="relative h-10 w-10 shrink-0">
|
||||
{answerIcon || <AnswerIcon />}
|
||||
{responding && (
|
||||
<div className="absolute left-[-3px] top-[-3px] flex h-4 w-4 items-center rounded-full border-[0.5px] border-divider-subtle bg-background-section-burn pl-[6px] shadow-xs">
|
||||
<LoadingAnim type="avatar" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-answer-container group ml-4 w-0 grow pb-4" ref={containerRef}>
|
||||
{/* Block 1: Workflow Process + Human Input Forms */}
|
||||
{hasHumanInputs && (
|
||||
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
|
||||
<div
|
||||
ref={humanInputFormContainerRef}
|
||||
className={cn('body-lg-regular relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary')}
|
||||
>
|
||||
{
|
||||
!responding && contentIsEmpty && !hasAgentThoughts && (
|
||||
<Operation
|
||||
hasWorkflowProcess={!!workflowProcess}
|
||||
maxSize={containerWidth - humanInputFormContainerWidth - 4}
|
||||
contentWidth={humanInputFormContainerWidth}
|
||||
item={item}
|
||||
question={question}
|
||||
index={index}
|
||||
showPromptLog={showPromptLog}
|
||||
noChatInput={noChatInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{/** Render workflow process */}
|
||||
{
|
||||
workflowProcess && (
|
||||
<WorkflowProcessItem
|
||||
data={workflowProcess}
|
||||
item={item}
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
readonly={hideProcessDetail && appData ? !appData.site.show_workflow_steps : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
humanInputFormDataList && humanInputFormDataList.length > 0 && (
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={humanInputFormDataList}
|
||||
onHumanInputFormSubmit={onHumanInputFormSubmit}
|
||||
getHumanInputNodeData={getHumanInputNodeData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
humanInputFilledFormDataList && humanInputFilledFormDataList.length > 0 && (
|
||||
<HumanInputFilledFormList
|
||||
humanInputFilledFormDataList={humanInputFilledFormDataList}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
typeof item.siblingCount === 'number'
|
||||
&& item.siblingCount > 1
|
||||
&& !responding
|
||||
&& contentIsEmpty
|
||||
&& !hasAgentThoughts
|
||||
&& (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
currentIndex={item.siblingIndex}
|
||||
prevDisabled={!item.prevSibling}
|
||||
nextDisabled={!item.nextSibling}
|
||||
switchSibling={handleSwitchSibling}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-answer-container group ml-4 w-0 grow pb-4" ref={containerRef}>
|
||||
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn('body-lg-regular relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary', workflowProcess && 'w-full')}
|
||||
>
|
||||
{
|
||||
!responding && (
|
||||
<Operation
|
||||
hasWorkflowProcess={!!workflowProcess}
|
||||
maxSize={containerWidth - contentWidth - 4}
|
||||
contentWidth={contentWidth}
|
||||
item={item}
|
||||
question={question}
|
||||
index={index}
|
||||
showPromptLog={showPromptLog}
|
||||
noChatInput={noChatInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{/** Render workflow process */}
|
||||
{
|
||||
workflowProcess && (
|
||||
<WorkflowProcessItem
|
||||
data={workflowProcess}
|
||||
item={item}
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
responding && contentIsEmpty && !hasAgentThoughts && (
|
||||
<div className="flex h-5 w-6 items-center justify-center">
|
||||
<LoadingAnim type="text" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!contentIsEmpty && !hasAgentThoughts && (
|
||||
<BasicContent item={item} />
|
||||
)
|
||||
}
|
||||
{
|
||||
(hasAgentThoughts) && (
|
||||
<AgentContent
|
||||
item={item}
|
||||
responding={responding}
|
||||
content={content}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!allFiles?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={allFiles}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!message_files?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={message_files}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
annotation?.id && annotation.authorName && (
|
||||
<EditTitle
|
||||
className="mt-1"
|
||||
title={t('editBy', { ns: 'appAnnotation', author: annotation.authorName })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<SuggestedQuestions item={item} />
|
||||
{
|
||||
!!citation?.length && !responding && (
|
||||
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
|
||||
)
|
||||
}
|
||||
{
|
||||
!!(item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined) && (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
currentIndex={item.siblingIndex}
|
||||
prevDisabled={!item.prevSibling}
|
||||
nextDisabled={!item.nextSibling}
|
||||
switchSibling={handleSwitchSibling}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Block 2: Response Content (when human inputs exist) */}
|
||||
{hasHumanInputs && (responding || !contentIsEmpty || hasAgentThoughts) && (
|
||||
<div className={cn('group relative mt-2 pr-10', chatAnswerContainerInner)}>
|
||||
<div className="absolute -top-2 left-6 h-3 w-0.5 bg-chat-answer-human-input-form-divider-bg" />
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="body-lg-regular relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary"
|
||||
>
|
||||
{
|
||||
!responding && (
|
||||
<Operation
|
||||
hasWorkflowProcess={!!workflowProcess}
|
||||
maxSize={containerWidth - contentWidth - 4}
|
||||
contentWidth={contentWidth}
|
||||
item={item}
|
||||
question={question}
|
||||
index={index}
|
||||
showPromptLog={showPromptLog}
|
||||
noChatInput={noChatInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
responding && contentIsEmpty && !hasAgentThoughts && (
|
||||
<div className="flex h-5 w-6 items-center justify-center">
|
||||
<LoadingAnim type="text" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!contentIsEmpty && !hasAgentThoughts && (
|
||||
<BasicContent item={item} />
|
||||
)
|
||||
}
|
||||
{
|
||||
hasAgentThoughts && (
|
||||
<AgentContent
|
||||
item={item}
|
||||
responding={responding}
|
||||
content={content}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!allFiles?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={allFiles}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!message_files?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={message_files}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
annotation?.id && annotation.authorName && (
|
||||
<EditTitle
|
||||
className="mt-1"
|
||||
title={t('editBy', { ns: 'appAnnotation', author: annotation.authorName })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<SuggestedQuestions item={item} />
|
||||
{
|
||||
!!citation?.length && !responding && (
|
||||
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
|
||||
)
|
||||
}
|
||||
{
|
||||
typeof item.siblingCount === 'number'
|
||||
&& item.siblingCount > 1
|
||||
&& (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
currentIndex={item.siblingIndex}
|
||||
prevDisabled={!item.prevSibling}
|
||||
nextDisabled={!item.nextSibling}
|
||||
switchSibling={handleSwitchSibling}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Original single block layout (when no human inputs) */}
|
||||
{!hasHumanInputs && (
|
||||
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn('body-lg-regular relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary', workflowProcess && 'w-full')}
|
||||
>
|
||||
{
|
||||
!responding && (
|
||||
<Operation
|
||||
hasWorkflowProcess={!!workflowProcess}
|
||||
maxSize={containerWidth - contentWidth - 4}
|
||||
contentWidth={contentWidth}
|
||||
item={item}
|
||||
question={question}
|
||||
index={index}
|
||||
showPromptLog={showPromptLog}
|
||||
noChatInput={noChatInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{/** Render workflow process */}
|
||||
{
|
||||
workflowProcess && (
|
||||
<WorkflowProcessItem
|
||||
data={workflowProcess}
|
||||
item={item}
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
responding && contentIsEmpty && !hasAgentThoughts && (
|
||||
<div className="flex h-5 w-6 items-center justify-center">
|
||||
<LoadingAnim type="text" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!contentIsEmpty && !hasAgentThoughts && (
|
||||
<BasicContent item={item} />
|
||||
)
|
||||
}
|
||||
{
|
||||
hasAgentThoughts && (
|
||||
<AgentContent
|
||||
item={item}
|
||||
responding={responding}
|
||||
content={content}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!allFiles?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={allFiles}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!message_files?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={message_files}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
annotation?.id && annotation.authorName && (
|
||||
<EditTitle
|
||||
className="mt-1"
|
||||
title={t('editBy', { ns: 'appAnnotation', author: annotation.authorName })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<SuggestedQuestions item={item} />
|
||||
{
|
||||
!!citation?.length && !responding && (
|
||||
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
|
||||
)
|
||||
}
|
||||
{
|
||||
typeof item.siblingCount === 'number'
|
||||
&& item.siblingCount > 1 && (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
currentIndex={item.siblingIndex}
|
||||
prevDisabled={!item.prevSibling}
|
||||
nextDisabled={!item.nextSibling}
|
||||
switchSibling={handleSwitchSibling}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<More more={more} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,6 +69,7 @@ const Operation: FC<OperationProps> = ({
|
||||
feedback,
|
||||
adminFeedback,
|
||||
agent_thoughts,
|
||||
humanInputFormDataList,
|
||||
} = item
|
||||
const [userLocalFeedback, setUserLocalFeedback] = useState(feedback)
|
||||
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
|
||||
@@ -186,7 +187,7 @@ const Operation: FC<OperationProps> = ({
|
||||
)}
|
||||
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
|
||||
>
|
||||
{shouldShowUserFeedbackBar && (
|
||||
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
|
||||
<div className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
|
||||
hasUserFeedback ? 'flex' : 'hidden group-hover:flex',
|
||||
@@ -226,7 +227,7 @@ const Operation: FC<OperationProps> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{shouldShowAdminFeedbackBar && (
|
||||
{shouldShowAdminFeedbackBar && !humanInputFormDataList?.length && (
|
||||
<div className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
|
||||
(hasAdminFeedback || hasUserFeedback) ? 'flex' : 'hidden group-hover:flex',
|
||||
@@ -305,26 +306,28 @@ const Operation: FC<OperationProps> = ({
|
||||
)}
|
||||
{!isOpeningStatement && (
|
||||
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex">
|
||||
{(config?.text_to_speech?.enabled) && (
|
||||
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (
|
||||
<NewAudioButton
|
||||
id={id}
|
||||
value={content}
|
||||
voice={config?.text_to_speech?.voice}
|
||||
/>
|
||||
)}
|
||||
<ActionButton onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
>
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
{!humanInputFormDataList?.length && (
|
||||
<ActionButton onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
>
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{!noChatInput && (
|
||||
<ActionButton onClick={() => onRegenerate?.(item)}>
|
||||
<RiResetLeftLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{(config?.supportAnnotation && config.annotation_reply?.enabled) && (
|
||||
{config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && (
|
||||
<AnnotationCtrlButton
|
||||
appId={config?.appId || ''}
|
||||
messageId={id}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
RiArrowRightSLine,
|
||||
RiErrorWarningFill,
|
||||
RiLoader2Line,
|
||||
RiPauseCircleFill,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
useEffect,
|
||||
@@ -34,6 +35,8 @@ const WorkflowProcessItem = ({
|
||||
const running = data.status === WorkflowRunningStatus.Running
|
||||
const succeeded = data.status === WorkflowRunningStatus.Succeeded
|
||||
const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped
|
||||
const paused = data.status === WorkflowRunningStatus.Paused
|
||||
const latestNode = data.tracing[data.tracing.length - 1]
|
||||
|
||||
useEffect(() => {
|
||||
setCollapse(!expand)
|
||||
@@ -50,7 +53,10 @@ const WorkflowProcessItem = ({
|
||||
running && !collapse && 'bg-background-section-burn',
|
||||
succeeded && !collapse && 'bg-state-success-hover',
|
||||
failed && !collapse && 'bg-state-destructive-hover',
|
||||
collapse && 'bg-workflow-process-bg',
|
||||
paused && !collapse && 'bg-state-warning-hover',
|
||||
collapse && !failed && !paused && 'bg-workflow-process-bg',
|
||||
collapse && paused && 'bg-workflow-process-paused-bg',
|
||||
collapse && failed && 'bg-workflow-process-failed-bg',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -72,8 +78,13 @@ const WorkflowProcessItem = ({
|
||||
<RiErrorWarningFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive" />
|
||||
)
|
||||
}
|
||||
{
|
||||
paused && (
|
||||
<RiPauseCircleFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-warning-secondary" />
|
||||
)
|
||||
}
|
||||
<div className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}>
|
||||
{t('common.workflowProcess', { ns: 'workflow' })}
|
||||
{!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title}
|
||||
</div>
|
||||
<RiArrowRightSLine className={cn('ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,8 @@ export type ChatContextValue = Pick<ChatProps, 'config'
|
||||
| 'onAnnotationAdded'
|
||||
| 'onAnnotationRemoved'
|
||||
| 'disableFeedback'
|
||||
| 'onFeedback'> & {
|
||||
| 'onFeedback'
|
||||
| 'getHumanInputNodeData'> & {
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ export const ChatContextProvider = ({
|
||||
onAnnotationRemoved,
|
||||
disableFeedback,
|
||||
onFeedback,
|
||||
getHumanInputNodeData,
|
||||
}: ChatContextProviderProps) => {
|
||||
return (
|
||||
<ChatContext.Provider value={{
|
||||
@@ -62,6 +64,7 @@ export const ChatContextProvider = ({
|
||||
onAnnotationRemoved,
|
||||
disableFeedback,
|
||||
onFeedback,
|
||||
getHumanInputNodeData,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,9 @@ export type ChatProps = {
|
||||
noSpacing?: boolean
|
||||
inputDisabled?: boolean
|
||||
sidebarCollapseState?: boolean
|
||||
hideAvatar?: boolean
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
|
||||
getHumanInputNodeData?: (nodeID: string) => any
|
||||
}
|
||||
|
||||
const Chat: FC<ChatProps> = ({
|
||||
@@ -116,6 +119,9 @@ const Chat: FC<ChatProps> = ({
|
||||
noSpacing,
|
||||
inputDisabled,
|
||||
sidebarCollapseState,
|
||||
hideAvatar,
|
||||
onHumanInputFormSubmit,
|
||||
getHumanInputNodeData,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
|
||||
@@ -265,6 +271,7 @@ const Chat: FC<ChatProps> = ({
|
||||
onAnnotationRemoved={onAnnotationRemoved}
|
||||
disableFeedback={disableFeedback}
|
||||
onFeedback={onFeedback}
|
||||
getHumanInputNodeData={getHumanInputNodeData}
|
||||
>
|
||||
<div className={cn('relative h-full', isTryApp && 'flex flex-col')}>
|
||||
<div
|
||||
@@ -295,6 +302,8 @@ const Chat: FC<ChatProps> = ({
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
noChatInput={noChatInput}
|
||||
switchSibling={switchSibling}
|
||||
hideAvatar={hideAvatar}
|
||||
onHumanInputFormSubmit={onHumanInputFormSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -306,6 +315,7 @@ const Chat: FC<ChatProps> = ({
|
||||
theme={themeBuilder?.theme}
|
||||
enableEdit={config?.questionEditEnable}
|
||||
switchSibling={switchSibling}
|
||||
hideAvatar={hideAvatar}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@ type QuestionProps = {
|
||||
theme: Theme | null | undefined
|
||||
enableEdit?: boolean
|
||||
switchSibling?: (siblingMessageId: string) => void
|
||||
hideAvatar?: boolean
|
||||
}
|
||||
|
||||
const Question: FC<QuestionProps> = ({
|
||||
@@ -40,6 +41,7 @@ const Question: FC<QuestionProps> = ({
|
||||
theme,
|
||||
enableEdit = true,
|
||||
switchSibling,
|
||||
hideAvatar,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -174,15 +176,17 @@ const Question: FC<QuestionProps> = ({
|
||||
</div>
|
||||
<div className="mt-1 h-[18px]" />
|
||||
</div>
|
||||
<div className="h-10 w-10 shrink-0">
|
||||
{
|
||||
questionIcon || (
|
||||
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
|
||||
<User className="h-full w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{!hideAvatar && (
|
||||
<div className="h-10 w-10 shrink-0">
|
||||
{
|
||||
questionIcon || (
|
||||
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
|
||||
<User className="h-full w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { InputVarType } from '@/app/components/workflow/types'
|
||||
import type { Annotation, MessageRating } from '@/models/log'
|
||||
import type { FileResponse } from '@/types/workflow'
|
||||
import type {
|
||||
FileResponse,
|
||||
HumanInputFilledFormData,
|
||||
HumanInputFormData,
|
||||
} from '@/types/workflow'
|
||||
|
||||
export type MessageMore = {
|
||||
time: string
|
||||
@@ -64,6 +68,19 @@ export type CitationItem = {
|
||||
word_count: number
|
||||
}
|
||||
|
||||
export type ExtraContent
|
||||
= {
|
||||
type: 'human_input'
|
||||
submitted: false
|
||||
form_definition: HumanInputFormData
|
||||
workflow_run_id: string
|
||||
}
|
||||
| {
|
||||
type: 'human_input'
|
||||
submitted: true
|
||||
form_submission_data: HumanInputFilledFormData
|
||||
}
|
||||
|
||||
export type IChatItem = {
|
||||
id: string
|
||||
content: string
|
||||
@@ -104,6 +121,10 @@ export type IChatItem = {
|
||||
siblingIndex?: number
|
||||
prevSibling?: string
|
||||
nextSibling?: string
|
||||
// for human input
|
||||
humanInputFormDataList?: HumanInputFormData[]
|
||||
humanInputFilledFormDataList?: HumanInputFilledFormData[]
|
||||
extra_contents?: ExtraContent[]
|
||||
}
|
||||
|
||||
export type Metadata = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FileEntity } from '../../file-uploader/types'
|
||||
import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
ChatItemInTree,
|
||||
OnSend,
|
||||
} from '../types'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
@@ -17,7 +18,9 @@ import {
|
||||
fetchSuggestedQuestions,
|
||||
getUrl,
|
||||
stopChatMessageResponding,
|
||||
submitHumanInputForm,
|
||||
} from '@/service/share'
|
||||
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Avatar from '../../avatar'
|
||||
@@ -70,9 +73,9 @@ const ChatWrapper = () => {
|
||||
}, [appParams, currentConversationItem?.introduction])
|
||||
const {
|
||||
chatList,
|
||||
setTargetMessageId,
|
||||
handleSend,
|
||||
handleStop,
|
||||
handleSwitchSibling,
|
||||
isResponding: respondingState,
|
||||
suggestedQuestions,
|
||||
} = useChat(
|
||||
@@ -130,6 +133,40 @@ const ChatWrapper = () => {
|
||||
setIsResponding(respondingState)
|
||||
}, [respondingState, setIsResponding])
|
||||
|
||||
// Resume paused workflows when chat history is loaded
|
||||
useEffect(() => {
|
||||
if (!appPrevChatList || appPrevChatList.length === 0)
|
||||
return
|
||||
|
||||
// Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first)
|
||||
let lastPausedNode: ChatItemInTree | undefined
|
||||
const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => {
|
||||
nodes.forEach((node) => {
|
||||
// DFS: recurse to children first
|
||||
if (node.children && node.children.length > 0)
|
||||
findLastPausedWorkflow(node.children)
|
||||
|
||||
// Track the last node with humanInputFormDataList
|
||||
if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0)
|
||||
lastPausedNode = node
|
||||
})
|
||||
}
|
||||
|
||||
findLastPausedWorkflow(appPrevChatList)
|
||||
|
||||
// Only resume the last paused workflow
|
||||
if (lastPausedNode) {
|
||||
handleSwitchSibling(
|
||||
lastPausedNode.id,
|
||||
{
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
},
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
|
||||
const data: any = {
|
||||
query: message,
|
||||
@@ -147,7 +184,7 @@ const ChatWrapper = () => {
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
},
|
||||
)
|
||||
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, isInstalledApp, appId, handleNewConversationCompleted])
|
||||
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, handleNewConversationCompleted])
|
||||
|
||||
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
|
||||
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
|
||||
@@ -155,6 +192,14 @@ const ChatWrapper = () => {
|
||||
doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
|
||||
}, [chatList, doSend])
|
||||
|
||||
const doSwitchSibling = useCallback((siblingMessageId: string) => {
|
||||
handleSwitchSibling(siblingMessageId, {
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
})
|
||||
}, [handleSwitchSibling, appSourceType, appId, currentConversationId, handleNewConversationCompleted])
|
||||
|
||||
const messageList = useMemo(() => {
|
||||
if (currentConversationId || chatList.length > 1)
|
||||
return chatList
|
||||
@@ -178,6 +223,13 @@ const ChatWrapper = () => {
|
||||
}
|
||||
}, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden])
|
||||
|
||||
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => {
|
||||
if (isInstalledApp)
|
||||
await submitHumanInputFormService(formToken, formData)
|
||||
else
|
||||
await submitHumanInputForm(formToken, formData)
|
||||
}, [isInstalledApp])
|
||||
|
||||
const welcome = useMemo(() => {
|
||||
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
|
||||
if (respondingState)
|
||||
@@ -223,7 +275,7 @@ const ChatWrapper = () => {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [appData?.site, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden])
|
||||
}, [chatList, respondingState, currentConversationId, collapsed, inputsForms.length, allInputsHidden, appData?.site, isMobile])
|
||||
|
||||
const answerIcon = isDify()
|
||||
? <LogoAvatar className="relative shrink-0" />
|
||||
@@ -253,6 +305,7 @@ const ChatWrapper = () => {
|
||||
inputsForm={inputsForms}
|
||||
onRegenerate={doRegenerate}
|
||||
onStopResponding={handleStop}
|
||||
onHumanInputFormSubmit={handleSubmitHumanInputForm}
|
||||
chatNode={(
|
||||
<>
|
||||
{chatNode}
|
||||
@@ -266,7 +319,7 @@ const ChatWrapper = () => {
|
||||
answerIcon={answerIcon}
|
||||
hideProcessDetail
|
||||
themeBuilder={themeBuilder}
|
||||
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
|
||||
switchSibling={doSwitchSibling}
|
||||
inputDisabled={inputDisabled}
|
||||
questionIcon={
|
||||
initUserVariables?.avatar_url
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
ModelConfig,
|
||||
VisionSettings,
|
||||
} from '@/types/app'
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import type { HumanInputFilledFormData, HumanInputFormData, NodeTracing } from '@/types/workflow'
|
||||
|
||||
export type {
|
||||
Inputs,
|
||||
@@ -67,6 +67,8 @@ export type WorkflowProcess = {
|
||||
expand?: boolean // for UI
|
||||
resultText?: string
|
||||
files?: FileEntity[]
|
||||
humanInputFormDataList?: HumanInputFormData[]
|
||||
humanInputFilledFormDataList?: HumanInputFilledFormData[]
|
||||
}
|
||||
|
||||
export type ChatItem = IChatItem & {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.625 0C9.27992 0.000320434 8.93828 0.0686147 8.6196 0.200983C8.30091 0.333351 8.01142 0.527199 7.76766 0.771458C7.5239 1.01572 7.33064 1.3056 7.19893 1.62456C7.06721 1.94351 6.99962 2.28529 7 2.63037C6.99968 2.97541 7.06732 3.31714 7.19907 3.63603C7.33081 3.95493 7.52408 4.24476 7.76783 4.48897C8.01159 4.73317 8.30105 4.92698 8.61971 5.05932C8.93836 5.19165 9.27996 5.25993 9.625 5.26025H12.25V2.63037C12.2504 2.28529 12.1828 1.94351 12.0511 1.62456C11.9194 1.3056 11.7261 1.01572 11.4823 0.771458C11.2386 0.527199 10.9491 0.333351 10.6304 0.200983C10.3117 0.0686147 9.97008 0.000320434 9.625 0ZM9.625 7.01416H2.625C2.27996 7.01448 1.93836 7.08276 1.61971 7.21509C1.30106 7.34743 1.01159 7.54124 0.767835 7.78544C0.524081 8.02965 0.330814 8.31948 0.199069 8.63838C0.0673241 8.95728 -0.000319124 9.299 1.63476e-06 9.64404C-0.000383292 9.98912 0.0672126 10.3309 0.198929 10.6499C0.330645 10.9688 0.523902 11.2587 0.767662 11.503C1.01142 11.7472 1.30091 11.9411 1.6196 12.0734C1.93828 12.2058 2.27992 12.2741 2.625 12.2744H9.625C9.97008 12.2741 10.3117 12.2058 10.6304 12.0734C10.9491 11.9411 11.2386 11.7472 11.4823 11.503C11.7261 11.2587 11.9194 10.9688 12.0511 10.6499C12.1828 10.3309 12.2504 9.98912 12.25 9.64404C12.2503 9.299 12.1827 8.95728 12.0509 8.63838C11.9192 8.31948 11.7259 8.02965 11.4822 7.78544C11.2384 7.54124 10.9489 7.34743 10.6303 7.21509C10.3116 7.08276 9.97004 7.01448 9.625 7.01416Z" fill="#44BEDF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.25 9.64404C26.2503 9.299 26.1827 8.95728 26.0509 8.63838C25.9192 8.31948 25.7259 8.02965 25.4822 7.78544C25.2384 7.54124 24.9489 7.34743 24.6303 7.21509C24.3116 7.08276 23.97 7.01448 23.625 7.01416C23.28 7.01448 22.9384 7.08276 22.6197 7.21509C22.3011 7.34743 22.0116 7.54124 21.7678 7.78544C21.5241 8.02965 21.3308 8.31948 21.1991 8.63838C21.0673 8.95728 20.9997 9.299 21 9.64404V12.2744H23.625C23.9701 12.2741 24.3117 12.2058 24.6304 12.0734C24.9491 11.9411 25.2386 11.7472 25.4823 11.503C25.7261 11.2587 25.9194 10.9688 26.0511 10.6499C26.1828 10.3309 26.2504 9.98912 26.25 9.64404ZM19.25 9.64404V2.63037C19.2504 2.28529 19.1828 1.94351 19.0511 1.62456C18.9194 1.3056 18.7261 1.01572 18.4823 0.771458C18.2386 0.527199 17.9491 0.333351 17.6304 0.200983C17.3117 0.0686147 16.9701 0.000320434 16.625 0C16.2799 0.000320434 15.9383 0.0686147 15.6196 0.200983C15.3009 0.333351 15.0114 0.527199 14.7677 0.771458C14.5239 1.01572 14.3306 1.3056 14.1989 1.62456C14.0672 1.94351 13.9996 2.28529 14 2.63037V9.64404C13.9996 9.98912 14.0672 10.3309 14.1989 10.6499C14.3306 10.9688 14.5239 11.2587 14.7677 11.503C15.0114 11.7472 15.3009 11.9411 15.6196 12.0734C15.9383 12.2058 16.2799 12.2741 16.625 12.2744C16.9701 12.2741 17.3117 12.2058 17.6304 12.0734C17.9491 11.9411 18.2386 11.7472 18.4823 11.503C18.7261 11.2587 18.9194 10.9688 19.0511 10.6499C19.1828 10.3309 19.2504 9.98912 19.25 9.64404Z" fill="#2EB67D"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.625 26.302C16.9701 26.3017 17.3117 26.2334 17.6304 26.101C17.9491 25.9686 18.2386 25.7748 18.4823 25.5305C18.7261 25.2863 18.9194 24.9964 19.0511 24.6774C19.1828 24.3585 19.2504 24.0167 19.25 23.6716C19.2503 23.3266 19.1827 22.9849 19.0509 22.666C18.9192 22.3471 18.7259 22.0572 18.4822 21.813C18.2384 21.5688 17.9489 21.375 17.6303 21.2427C17.3116 21.1103 16.97 21.0421 16.625 21.0417H14V23.6716C13.9996 24.0167 14.0672 24.3585 14.1989 24.6774C14.3306 24.9964 14.5239 25.2863 14.7677 25.5305C15.0114 25.7748 15.3009 25.9686 15.6196 26.101C15.9383 26.2334 16.2799 26.3017 16.625 26.302ZM16.625 19.2878H23.625C23.97 19.2875 24.3116 19.2192 24.6303 19.0869C24.9489 18.9546 25.2384 18.7608 25.4822 18.5166C25.7259 18.2723 25.9192 17.9825 26.0509 17.6636C26.1827 17.3447 26.2503 17.003 26.25 16.658C26.2504 16.3129 26.1828 15.9711 26.0511 15.6521C25.9194 15.3332 25.7261 15.0433 25.4823 14.799C25.2386 14.5548 24.9491 14.3609 24.6304 14.2286C24.3117 14.0962 23.9701 14.0279 23.625 14.0276H16.625C16.2799 14.0279 15.9383 14.0962 15.6196 14.2286C15.3009 14.3609 15.0114 14.5548 14.7677 14.799C14.5239 15.0433 14.3306 15.3332 14.1989 15.6521C14.0672 15.9711 13.9996 16.3129 14 16.658C13.9997 17.003 14.0673 17.3447 14.1991 17.6636C14.3308 17.9825 14.5241 18.2723 14.7678 18.5166C15.0116 18.7608 15.3011 18.9546 15.6197 19.0869C15.9384 19.2192 16.28 19.2875 16.625 19.2878Z" fill="#ECB22E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.22485e-06 16.658C-0.000318534 17.003 0.0673247 17.3447 0.19907 17.6636C0.330815 17.9825 0.524081 18.2723 0.767835 18.5166C1.01159 18.7608 1.30106 18.9546 1.61971 19.0869C1.93836 19.2192 2.27996 19.2875 2.625 19.2878C2.97004 19.2875 3.31164 19.2192 3.63029 19.0869C3.94895 18.9546 4.23841 18.7608 4.48217 18.5166C4.72592 18.2723 4.91919 17.9825 5.05093 17.6636C5.18268 17.3447 5.25032 17.003 5.25 16.658V14.0276H2.625C2.27988 14.0279 1.9382 14.0962 1.61948 14.2286C1.30077 14.361 1.01126 14.5549 0.76749 14.7992C0.523723 15.0435 0.330477 15.3335 0.198789 15.6525C0.0671016 15.9715 -0.000446885 16.3128 2.22485e-06 16.658ZM7 16.658V23.6716C6.99962 24.0167 7.06721 24.3585 7.19893 24.6774C7.33064 24.9964 7.5239 25.2863 7.76766 25.5305C8.01142 25.7748 8.30091 25.9686 8.6196 26.101C8.93828 26.2334 9.27992 26.3017 9.625 26.302C9.97008 26.3017 10.3117 26.2334 10.6304 26.101C10.9491 25.9686 11.2386 25.7748 11.4823 25.5305C11.7261 25.2863 11.9194 24.9964 12.0511 24.6774C12.1828 24.3585 12.2504 24.0167 12.25 23.6716V16.6584C12.2504 16.3134 12.1828 15.9716 12.0511 15.6526C11.9194 15.3337 11.7261 15.0438 11.4823 14.7995C11.2386 14.5553 10.9491 14.3614 10.6304 14.2291C10.3117 14.0967 9.97008 14.0284 9.625 14.0281C9.27992 14.0284 8.93828 14.0967 8.6196 14.2291C8.30091 14.3614 8.01142 14.5553 7.76766 14.7995C7.5239 15.0438 7.33064 15.3337 7.19893 15.6526C7.06721 15.9716 6.99962 16.3129 7 16.658Z" fill="#E01E5A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
19
web/app/components/base/icons/assets/public/other/teams.svg
Normal file
19
web/app/components/base/icons/assets/public/other/teams.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_20307_29670)">
|
||||
<path opacity="0.1" d="M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z" fill="black"/>
|
||||
<path opacity="0.1" d="M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z" fill="black"/>
|
||||
<path d="M26.6817 10.5H20.8484L19.2267 11.8183V18.34C19.2846 19.4628 19.7715 20.5204 20.5867 21.2946C21.4019 22.0689 22.4833 22.5005 23.6075 22.5005C24.7318 22.5005 25.8131 22.0689 26.6283 21.2946C27.4436 20.5204 27.9304 19.4628 27.9884 18.34V11.8183C27.9899 11.6458 27.9572 11.4746 27.8923 11.3147C27.8273 11.1548 27.7313 11.0094 27.6098 10.8868C27.4883 10.7643 27.3437 10.667 27.1844 10.6006C27.0251 10.5342 26.8543 10.5 26.6817 10.5Z" fill="#5059C9"/>
|
||||
<path d="M23.9167 9.33333C25.5275 9.33333 26.8333 8.0275 26.8333 6.41667C26.8333 4.80584 25.5275 3.5 23.9167 3.5C22.3058 3.5 21 4.80584 21 6.41667C21 8.0275 22.3058 9.33333 23.9167 9.33333Z" fill="#5059C9"/>
|
||||
<path d="M10.64 10.5H20.86C21.2065 10.5 21.5389 10.6377 21.7839 10.8827C22.029 11.1278 22.1666 11.4601 22.1666 11.8067V20.4167C22.1666 22.1185 21.4906 23.7506 20.2872 24.9539C19.0839 26.1573 17.4518 26.8333 15.75 26.8333C14.0482 26.8333 12.4161 26.1573 11.2127 24.9539C10.0094 23.7506 9.33331 22.1185 9.33331 20.4167V11.8067C9.33331 11.4601 9.47098 11.1278 9.71603 10.8827C9.96107 10.6377 10.2934 10.5 10.64 10.5Z" fill="#7B83EB"/>
|
||||
<path d="M16.3333 9.69477C18.4661 9.69477 20.195 7.96584 20.195 5.8331C20.195 3.70036 18.4661 1.97144 16.3333 1.97144C14.2006 1.97144 12.4717 3.70036 12.4717 5.8331C12.4717 7.96584 14.2006 9.69477 16.3333 9.69477Z" fill="#7B83EB"/>
|
||||
<path opacity="0.5" d="M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z" fill="black"/>
|
||||
<path opacity="0.5" d="M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z" fill="black"/>
|
||||
<path d="M14.9683 5.83325H1.365C0.611131 5.83325 0 6.44438 0 7.19825V20.8016C0 21.5555 0.611131 22.1666 1.365 22.1666H14.9683C15.7222 22.1666 16.3333 21.5555 16.3333 20.8016V7.19825C16.3333 6.44438 15.7222 5.83325 14.9683 5.83325Z" fill="#4B53BC"/>
|
||||
<path d="M11.8767 11.1766H9.08833V18.6666H7.25666V11.1766H4.45667V9.33325H11.8767V11.1766Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_20307_29670">
|
||||
<rect width="28" height="28" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.66634 2.66675C3.66634 2.29856 3.36787 2.00008 2.99967 2.00008C2.63149 2.00008 2.33301 2.29855 2.33301 2.66675L3.66634 2.66675ZM2.99967 5.33341H2.33301C2.33301 5.51023 2.40325 5.6798 2.52827 5.80482C2.65329 5.92984 2.82286 6.00008 2.99967 6.00008V5.33341ZM5.66641 6.00008C6.03459 6.00008 6.33307 5.7016 6.33307 5.33341C6.33307 4.96523 6.03459 4.66675 5.66641 4.66675L5.66641 6.00008ZM2.41183 5.01659C2.23816 5.34125 2.36056 5.74523 2.68522 5.91889C3.00988 6.09256 3.41385 5.97016 3.58752 5.6455L2.41183 5.01659ZM3.03395 8.58915C2.99109 8.22348 2.6599 7.96175 2.29421 8.00461C1.92853 8.04748 1.66682 8.37868 1.70967 8.74435L3.03395 8.58915ZM12.0439 5.05931C12.2607 5.3569 12.6777 5.42238 12.9753 5.20557C13.2729 4.98876 13.3383 4.57176 13.1215 4.27417L12.0439 5.05931ZM5.02145 13.5907C5.34627 13.7641 5.75013 13.6413 5.92349 13.3165C6.09685 12.9917 5.97407 12.5878 5.64925 12.4145L5.02145 13.5907ZM2.33301 2.66675L2.33301 5.33341H3.66634L3.66634 2.66675L2.33301 2.66675ZM2.99967 6.00008L5.66641 6.00008L5.66641 4.66675H2.99967L2.99967 6.00008ZM3.58752 5.6455C4.43045 4.06972 6.09066 3.00008 7.99968 3.00008V1.66675C5.57951 1.66675 3.47747 3.02445 2.41183 5.01659L3.58752 5.6455ZM7.99968 3.00008C9.66128 3.00008 11.1336 3.80991 12.0439 5.05931L13.1215 4.27417C11.9711 2.69513 10.1055 1.66675 7.99968 1.66675V3.00008ZM5.64925 12.4145C4.23557 11.6599 3.22828 10.2474 3.03395 8.58915L1.70967 8.74435C1.95639 10.8495 3.23403 12.6367 5.02145 13.5907L5.64925 12.4145Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.1946 8.13826C12.9027 7.84637 12.4294 7.84638 12.1375 8.13829L9.11842 11.1574C9.01642 11.2594 8.95025 11.3917 8.92985 11.5345C8.92985 11.5345 8.92985 11.5345 8.92985 11.5345L8.78508 12.5479L9.79846 12.4031C9.94127 12.3827 10.0736 12.3165 10.1756 12.2145L13.1947 9.19548C13.4866 8.90359 13.4866 8.43027 13.1946 8.13826C13.1947 8.13827 13.1946 8.13825 13.1946 8.13826ZM11.1947 7.19548C12.0073 6.38286 13.3249 6.38286 14.1375 7.19548C14.95 8.00814 14.9501 9.32565 14.1375 10.1383L11.1184 13.1574C10.8124 13.4633 10.4154 13.6618 9.98703 13.723L8.09369 13.9935C7.88596 14.0232 7.67639 13.9533 7.52801 13.805C7.37963 13.6566 7.30977 13.447 7.33945 13.2393L7.60991 11.3459C7.67111 10.9175 7.86961 10.5205 8.17561 10.2145L11.1947 7.19548Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.528 10.8048L10.528 8.80482L11.4708 7.86201L13.4708 9.86201L12.528 10.8048Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
61
web/app/components/base/icons/src/public/other/Slack.json
Normal file
61
web/app/components/base/icons/src/public/other/Slack.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "27",
|
||||
"height": "27",
|
||||
"viewBox": "0 0 27 27",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M9.625 0C9.27992 0.000320434 8.93828 0.0686147 8.6196 0.200983C8.30091 0.333351 8.01142 0.527199 7.76766 0.771458C7.5239 1.01572 7.33064 1.3056 7.19893 1.62456C7.06721 1.94351 6.99962 2.28529 7 2.63037C6.99968 2.97541 7.06732 3.31714 7.19907 3.63603C7.33081 3.95493 7.52408 4.24476 7.76783 4.48897C8.01159 4.73317 8.30105 4.92698 8.61971 5.05932C8.93836 5.19165 9.27996 5.25993 9.625 5.26025H12.25V2.63037C12.2504 2.28529 12.1828 1.94351 12.0511 1.62456C11.9194 1.3056 11.7261 1.01572 11.4823 0.771458C11.2386 0.527199 10.9491 0.333351 10.6304 0.200983C10.3117 0.0686147 9.97008 0.000320434 9.625 0ZM9.625 7.01416H2.625C2.27996 7.01448 1.93836 7.08276 1.61971 7.21509C1.30106 7.34743 1.01159 7.54124 0.767835 7.78544C0.524081 8.02965 0.330814 8.31948 0.199069 8.63838C0.0673241 8.95728 -0.000319124 9.299 1.63476e-06 9.64404C-0.000383292 9.98912 0.0672126 10.3309 0.198929 10.6499C0.330645 10.9688 0.523902 11.2587 0.767662 11.503C1.01142 11.7472 1.30091 11.9411 1.6196 12.0734C1.93828 12.2058 2.27992 12.2741 2.625 12.2744H9.625C9.97008 12.2741 10.3117 12.2058 10.6304 12.0734C10.9491 11.9411 11.2386 11.7472 11.4823 11.503C11.7261 11.2587 11.9194 10.9688 12.0511 10.6499C12.1828 10.3309 12.2504 9.98912 12.25 9.64404C12.2503 9.299 12.1827 8.95728 12.0509 8.63838C11.9192 8.31948 11.7259 8.02965 11.4822 7.78544C11.2384 7.54124 10.9489 7.34743 10.6303 7.21509C10.3116 7.08276 9.97004 7.01448 9.625 7.01416Z",
|
||||
"fill": "#44BEDF"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M26.25 9.64404C26.2503 9.299 26.1827 8.95728 26.0509 8.63838C25.9192 8.31948 25.7259 8.02965 25.4822 7.78544C25.2384 7.54124 24.9489 7.34743 24.6303 7.21509C24.3116 7.08276 23.97 7.01448 23.625 7.01416C23.28 7.01448 22.9384 7.08276 22.6197 7.21509C22.3011 7.34743 22.0116 7.54124 21.7678 7.78544C21.5241 8.02965 21.3308 8.31948 21.1991 8.63838C21.0673 8.95728 20.9997 9.299 21 9.64404V12.2744H23.625C23.9701 12.2741 24.3117 12.2058 24.6304 12.0734C24.9491 11.9411 25.2386 11.7472 25.4823 11.503C25.7261 11.2587 25.9194 10.9688 26.0511 10.6499C26.1828 10.3309 26.2504 9.98912 26.25 9.64404ZM19.25 9.64404V2.63037C19.2504 2.28529 19.1828 1.94351 19.0511 1.62456C18.9194 1.3056 18.7261 1.01572 18.4823 0.771458C18.2386 0.527199 17.9491 0.333351 17.6304 0.200983C17.3117 0.0686147 16.9701 0.000320434 16.625 0C16.2799 0.000320434 15.9383 0.0686147 15.6196 0.200983C15.3009 0.333351 15.0114 0.527199 14.7677 0.771458C14.5239 1.01572 14.3306 1.3056 14.1989 1.62456C14.0672 1.94351 13.9996 2.28529 14 2.63037V9.64404C13.9996 9.98912 14.0672 10.3309 14.1989 10.6499C14.3306 10.9688 14.5239 11.2587 14.7677 11.503C15.0114 11.7472 15.3009 11.9411 15.6196 12.0734C15.9383 12.2058 16.2799 12.2741 16.625 12.2744C16.9701 12.2741 17.3117 12.2058 17.6304 12.0734C17.9491 11.9411 18.2386 11.7472 18.4823 11.503C18.7261 11.2587 18.9194 10.9688 19.0511 10.6499C19.1828 10.3309 19.2504 9.98912 19.25 9.64404Z",
|
||||
"fill": "#2EB67D"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M16.625 26.302C16.9701 26.3017 17.3117 26.2334 17.6304 26.101C17.9491 25.9686 18.2386 25.7748 18.4823 25.5305C18.7261 25.2863 18.9194 24.9964 19.0511 24.6774C19.1828 24.3585 19.2504 24.0167 19.25 23.6716C19.2503 23.3266 19.1827 22.9849 19.0509 22.666C18.9192 22.3471 18.7259 22.0572 18.4822 21.813C18.2384 21.5688 17.9489 21.375 17.6303 21.2427C17.3116 21.1103 16.97 21.0421 16.625 21.0417H14V23.6716C13.9996 24.0167 14.0672 24.3585 14.1989 24.6774C14.3306 24.9964 14.5239 25.2863 14.7677 25.5305C15.0114 25.7748 15.3009 25.9686 15.6196 26.101C15.9383 26.2334 16.2799 26.3017 16.625 26.302ZM16.625 19.2878H23.625C23.97 19.2875 24.3116 19.2192 24.6303 19.0869C24.9489 18.9546 25.2384 18.7608 25.4822 18.5166C25.7259 18.2723 25.9192 17.9825 26.0509 17.6636C26.1827 17.3447 26.2503 17.003 26.25 16.658C26.2504 16.3129 26.1828 15.9711 26.0511 15.6521C25.9194 15.3332 25.7261 15.0433 25.4823 14.799C25.2386 14.5548 24.9491 14.3609 24.6304 14.2286C24.3117 14.0962 23.9701 14.0279 23.625 14.0276H16.625C16.2799 14.0279 15.9383 14.0962 15.6196 14.2286C15.3009 14.3609 15.0114 14.5548 14.7677 14.799C14.5239 15.0433 14.3306 15.3332 14.1989 15.6521C14.0672 15.9711 13.9996 16.3129 14 16.658C13.9997 17.003 14.0673 17.3447 14.1991 17.6636C14.3308 17.9825 14.5241 18.2723 14.7678 18.5166C15.0116 18.7608 15.3011 18.9546 15.6197 19.0869C15.9384 19.2192 16.28 19.2875 16.625 19.2878Z",
|
||||
"fill": "#ECB22E"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M2.22485e-06 16.658C-0.000318534 17.003 0.0673247 17.3447 0.19907 17.6636C0.330815 17.9825 0.524081 18.2723 0.767835 18.5166C1.01159 18.7608 1.30106 18.9546 1.61971 19.0869C1.93836 19.2192 2.27996 19.2875 2.625 19.2878C2.97004 19.2875 3.31164 19.2192 3.63029 19.0869C3.94895 18.9546 4.23841 18.7608 4.48217 18.5166C4.72592 18.2723 4.91919 17.9825 5.05093 17.6636C5.18268 17.3447 5.25032 17.003 5.25 16.658V14.0276H2.625C2.27988 14.0279 1.9382 14.0962 1.61948 14.2286C1.30077 14.361 1.01126 14.5549 0.76749 14.7992C0.523723 15.0435 0.330477 15.3335 0.198789 15.6525C0.0671016 15.9715 -0.000446885 16.3128 2.22485e-06 16.658ZM7 16.658V23.6716C6.99962 24.0167 7.06721 24.3585 7.19893 24.6774C7.33064 24.9964 7.5239 25.2863 7.76766 25.5305C8.01142 25.7748 8.30091 25.9686 8.6196 26.101C8.93828 26.2334 9.27992 26.3017 9.625 26.302C9.97008 26.3017 10.3117 26.2334 10.6304 26.101C10.9491 25.9686 11.2386 25.7748 11.4823 25.5305C11.7261 25.2863 11.9194 24.9964 12.0511 24.6774C12.1828 24.3585 12.2504 24.0167 12.25 23.6716V16.6584C12.2504 16.3134 12.1828 15.9716 12.0511 15.6526C11.9194 15.3337 11.7261 15.0438 11.4823 14.7995C11.2386 14.5553 10.9491 14.3614 10.6304 14.2291C10.3117 14.0967 9.97008 14.0284 9.625 14.0281C9.27992 14.0284 8.93828 14.0967 8.6196 14.2291C8.30091 14.3614 8.01142 14.5553 7.76766 14.7995C7.5239 15.0438 7.33064 15.3337 7.19893 15.6526C7.06721 15.9716 6.99962 16.3129 7 16.658Z",
|
||||
"fill": "#E01E5A"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Slack"
|
||||
}
|
||||
20
web/app/components/base/icons/src/public/other/Slack.tsx
Normal file
20
web/app/components/base/icons/src/public/other/Slack.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 './Slack.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Slack'
|
||||
|
||||
export default Icon
|
||||
146
web/app/components/base/icons/src/public/other/Teams.json
Normal file
146
web/app/components/base/icons/src/public/other/Teams.json
Normal file
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "28",
|
||||
"height": "28",
|
||||
"viewBox": "0 0 28 28",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"clip-path": "url(#clip0_20307_29670)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"opacity": "0.1",
|
||||
"d": "M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z",
|
||||
"fill": "black"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"opacity": "0.1",
|
||||
"d": "M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z",
|
||||
"fill": "black"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M26.6817 10.5H20.8484L19.2267 11.8183V18.34C19.2846 19.4628 19.7715 20.5204 20.5867 21.2946C21.4019 22.0689 22.4833 22.5005 23.6075 22.5005C24.7318 22.5005 25.8131 22.0689 26.6283 21.2946C27.4436 20.5204 27.9304 19.4628 27.9884 18.34V11.8183C27.9899 11.6458 27.9572 11.4746 27.8923 11.3147C27.8273 11.1548 27.7313 11.0094 27.6098 10.8868C27.4883 10.7643 27.3437 10.667 27.1844 10.6006C27.0251 10.5342 26.8543 10.5 26.6817 10.5Z",
|
||||
"fill": "#5059C9"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M23.9167 9.33333C25.5275 9.33333 26.8333 8.0275 26.8333 6.41667C26.8333 4.80584 25.5275 3.5 23.9167 3.5C22.3058 3.5 21 4.80584 21 6.41667C21 8.0275 22.3058 9.33333 23.9167 9.33333Z",
|
||||
"fill": "#5059C9"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M10.64 10.5H20.86C21.2065 10.5 21.5389 10.6377 21.7839 10.8827C22.029 11.1278 22.1666 11.4601 22.1666 11.8067V20.4167C22.1666 22.1185 21.4906 23.7506 20.2872 24.9539C19.0839 26.1573 17.4518 26.8333 15.75 26.8333C14.0482 26.8333 12.4161 26.1573 11.2127 24.9539C10.0094 23.7506 9.33331 22.1185 9.33331 20.4167V11.8067C9.33331 11.4601 9.47098 11.1278 9.71603 10.8827C9.96107 10.6377 10.2934 10.5 10.64 10.5Z",
|
||||
"fill": "#7B83EB"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M16.3333 9.69477C18.4661 9.69477 20.195 7.96584 20.195 5.8331C20.195 3.70036 18.4661 1.97144 16.3333 1.97144C14.2006 1.97144 12.4717 3.70036 12.4717 5.8331C12.4717 7.96584 14.2006 9.69477 16.3333 9.69477Z",
|
||||
"fill": "#7B83EB"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"opacity": "0.5",
|
||||
"d": "M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z",
|
||||
"fill": "black"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"opacity": "0.5",
|
||||
"d": "M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z",
|
||||
"fill": "black"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M14.9683 5.83325H1.365C0.611131 5.83325 0 6.44438 0 7.19825V20.8016C0 21.5555 0.611131 22.1666 1.365 22.1666H14.9683C15.7222 22.1666 16.3333 21.5555 16.3333 20.8016V7.19825C16.3333 6.44438 15.7222 5.83325 14.9683 5.83325Z",
|
||||
"fill": "#4B53BC"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M11.8767 11.1766H9.08833V18.6666H7.25666V11.1766H4.45667V9.33325H11.8767V11.1766Z",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "defs",
|
||||
"attributes": {},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "clipPath",
|
||||
"attributes": {
|
||||
"id": "clip0_20307_29670"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "28",
|
||||
"height": "28",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Teams"
|
||||
}
|
||||
20
web/app/components/base/icons/src/public/other/Teams.tsx
Normal file
20
web/app/components/base/icons/src/public/other/Teams.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 './Teams.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Teams'
|
||||
|
||||
export default Icon
|
||||
@@ -2,3 +2,5 @@ export { default as DefaultToolIcon } from './DefaultToolIcon'
|
||||
export { default as Icon3Dots } from './Icon3Dots'
|
||||
export { default as Message3Fill } from './Message3Fill'
|
||||
export { default as RowStruct } from './RowStruct'
|
||||
export { default as Slack } from './Slack'
|
||||
export { default as Teams } from './Teams'
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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": "M3.66634 2.66675C3.66634 2.29856 3.36787 2.00008 2.99967 2.00008C2.63149 2.00008 2.33301 2.29855 2.33301 2.66675L3.66634 2.66675ZM2.99967 5.33341H2.33301C2.33301 5.51023 2.40325 5.6798 2.52827 5.80482C2.65329 5.92984 2.82286 6.00008 2.99967 6.00008V5.33341ZM5.66641 6.00008C6.03459 6.00008 6.33307 5.7016 6.33307 5.33341C6.33307 4.96523 6.03459 4.66675 5.66641 4.66675L5.66641 6.00008ZM2.41183 5.01659C2.23816 5.34125 2.36056 5.74523 2.68522 5.91889C3.00988 6.09256 3.41385 5.97016 3.58752 5.6455L2.41183 5.01659ZM3.03395 8.58915C2.99109 8.22348 2.6599 7.96175 2.29421 8.00461C1.92853 8.04748 1.66682 8.37868 1.70967 8.74435L3.03395 8.58915ZM12.0439 5.05931C12.2607 5.3569 12.6777 5.42238 12.9753 5.20557C13.2729 4.98876 13.3383 4.57176 13.1215 4.27417L12.0439 5.05931ZM5.02145 13.5907C5.34627 13.7641 5.75013 13.6413 5.92349 13.3165C6.09685 12.9917 5.97407 12.5878 5.64925 12.4145L5.02145 13.5907ZM2.33301 2.66675L2.33301 5.33341H3.66634L3.66634 2.66675L2.33301 2.66675ZM2.99967 6.00008L5.66641 6.00008L5.66641 4.66675H2.99967L2.99967 6.00008ZM3.58752 5.6455C4.43045 4.06972 6.09066 3.00008 7.99968 3.00008V1.66675C5.57951 1.66675 3.47747 3.02445 2.41183 5.01659L3.58752 5.6455ZM7.99968 3.00008C9.66128 3.00008 11.1336 3.80991 12.0439 5.05931L13.1215 4.27417C11.9711 2.69513 10.1055 1.66675 7.99968 1.66675V3.00008ZM5.64925 12.4145C4.23557 11.6599 3.22828 10.2474 3.03395 8.58915L1.70967 8.74435C1.95639 10.8495 3.23403 12.6367 5.02145 13.5907L5.64925 12.4145Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M13.1946 8.13826C12.9027 7.84637 12.4294 7.84638 12.1375 8.13829L9.11842 11.1574C9.01642 11.2594 8.95025 11.3917 8.92985 11.5345C8.92985 11.5345 8.92985 11.5345 8.92985 11.5345L8.78508 12.5479L9.79846 12.4031C9.94127 12.3827 10.0736 12.3165 10.1756 12.2145L13.1947 9.19548C13.4866 8.90359 13.4866 8.43027 13.1946 8.13826C13.1947 8.13827 13.1946 8.13825 13.1946 8.13826ZM11.1947 7.19548C12.0073 6.38286 13.3249 6.38286 14.1375 7.19548C14.95 8.00814 14.9501 9.32565 14.1375 10.1383L11.1184 13.1574C10.8124 13.4633 10.4154 13.6618 9.98703 13.723L8.09369 13.9935C7.88596 14.0232 7.67639 13.9533 7.52801 13.805C7.37963 13.6566 7.30977 13.447 7.33945 13.2393L7.60991 11.3459C7.67111 10.9175 7.86961 10.5205 8.17561 10.2145L11.1947 7.19548Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M12.528 10.8048L10.528 8.80482L11.4708 7.86201L13.4708 9.86201L12.528 10.8048Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "HumanInLoop"
|
||||
}
|
||||
@@ -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 './HumanInLoop.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'HumanInLoop'
|
||||
|
||||
export default Icon
|
||||
@@ -10,6 +10,7 @@ export { default as DocsExtractor } from './DocsExtractor'
|
||||
export { default as End } from './End'
|
||||
export { default as Home } from './Home'
|
||||
export { default as Http } from './Http'
|
||||
export { default as HumanInLoop } from './HumanInLoop'
|
||||
export { default as IfElse } from './IfElse'
|
||||
export { default as Iteration } from './Iteration'
|
||||
export { default as IterationStart } from './IterationStart'
|
||||
|
||||
@@ -18,7 +18,7 @@ export type MarkdownProps = {
|
||||
content: string
|
||||
className?: string
|
||||
pluginInfo?: SimplePluginInfo
|
||||
} & Pick<ReactMarkdownWrapperProps, 'customComponents' | 'customDisallowedElements'>
|
||||
} & Pick<ReactMarkdownWrapperProps, 'customComponents' | 'customDisallowedElements' | 'rehypePlugins'>
|
||||
|
||||
export const Markdown = (props: MarkdownProps) => {
|
||||
const { customComponents = {}, pluginInfo } = props
|
||||
@@ -29,7 +29,13 @@ export const Markdown = (props: MarkdownProps) => {
|
||||
|
||||
return (
|
||||
<div className={cn('markdown-body', '!text-text-primary', props.className)}>
|
||||
<ReactMarkdown pluginInfo={pluginInfo} latexContent={latexContent} customComponents={customComponents} customDisallowedElements={props.customDisallowedElements} />
|
||||
<ReactMarkdown
|
||||
pluginInfo={pluginInfo}
|
||||
latexContent={latexContent}
|
||||
customComponents={customComponents}
|
||||
customDisallowedElements={props.customDisallowedElements}
|
||||
rehypePlugins={props.rehypePlugins}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export type ReactMarkdownWrapperProps = {
|
||||
customDisallowedElements?: string[]
|
||||
customComponents?: Record<string, React.ComponentType<any>>
|
||||
pluginInfo?: SimplePluginInfo
|
||||
rehypePlugins?: any// js: PluggableList[]
|
||||
}
|
||||
|
||||
export const ReactMarkdownWrapper: FC<ReactMarkdownWrapperProps> = (props) => {
|
||||
@@ -55,6 +56,7 @@ export const ReactMarkdownWrapper: FC<ReactMarkdownWrapperProps> = (props) => {
|
||||
tree.children.forEach(iterate)
|
||||
}
|
||||
},
|
||||
...(props.rehypePlugins || []),
|
||||
]}
|
||||
urlTransform={customUrlTransform}
|
||||
disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SupportUploadFileTypes } from '../../workflow/types'
|
||||
export const CONTEXT_PLACEHOLDER_TEXT = '{{#context#}}'
|
||||
export const HISTORY_PLACEHOLDER_TEXT = '{{#histories#}}'
|
||||
export const QUERY_PLACEHOLDER_TEXT = '{{#query#}}'
|
||||
export const REQUEST_URL_PLACEHOLDER_TEXT = '{{#url#}}'
|
||||
export const CURRENT_PLACEHOLDER_TEXT = '{{#current#}}'
|
||||
export const ERROR_MESSAGE_PLACEHOLDER_TEXT = '{{#error_message#}}'
|
||||
export const LAST_RUN_PLACEHOLDER_TEXT = '{{#last_run#}}'
|
||||
@@ -30,6 +31,12 @@ export const checkHasQueryBlock = (text: string) => {
|
||||
return text.includes(QUERY_PLACEHOLDER_TEXT)
|
||||
}
|
||||
|
||||
export const checkHasRequestURLBlock = (text: string) => {
|
||||
if (!text)
|
||||
return false
|
||||
return text.includes(REQUEST_URL_PLACEHOLDER_TEXT)
|
||||
}
|
||||
|
||||
/*
|
||||
* {{#1711617514996.name#}} => [1711617514996, name]
|
||||
* {{#1711617514996.sys.query#}} => [sys, query]
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
import type {
|
||||
EditorState,
|
||||
LexicalCommand,
|
||||
} from 'lexical'
|
||||
import type { FC } from 'react'
|
||||
import type { Hotkey } from './plugins/shortcuts-popup-plugin'
|
||||
import type {
|
||||
ContextBlockType,
|
||||
CurrentBlockType,
|
||||
ErrorMessageBlockType,
|
||||
ExternalToolBlockType,
|
||||
HistoryBlockType,
|
||||
HITLInputBlockType,
|
||||
LastRunBlockType,
|
||||
QueryBlockType,
|
||||
RequestURLBlockType,
|
||||
VariableBlockType,
|
||||
WorkflowVariableBlockType,
|
||||
} from './types'
|
||||
@@ -27,7 +31,7 @@ import {
|
||||
TextNode,
|
||||
} from 'lexical'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
@@ -46,17 +50,23 @@ import {
|
||||
CurrentBlockReplacementBlock,
|
||||
} from './plugins/current-block'
|
||||
import { CustomTextNode } from './plugins/custom-text/node'
|
||||
import DraggableBlockPlugin from './plugins/draggable-plugin'
|
||||
import {
|
||||
ErrorMessageBlock,
|
||||
ErrorMessageBlockNode,
|
||||
ErrorMessageBlockReplacementBlock,
|
||||
} from './plugins/error-message-block'
|
||||
|
||||
import {
|
||||
HistoryBlock,
|
||||
HistoryBlockNode,
|
||||
HistoryBlockReplacementBlock,
|
||||
} from './plugins/history-block'
|
||||
|
||||
import {
|
||||
HITLInputBlock,
|
||||
HITLInputBlockReplacementBlock,
|
||||
HITLInputNode,
|
||||
} from './plugins/hitl-input-block'
|
||||
import {
|
||||
LastRunBlock,
|
||||
LastRunBlockNode,
|
||||
@@ -70,6 +80,12 @@ import {
|
||||
QueryBlockNode,
|
||||
QueryBlockReplacementBlock,
|
||||
} from './plugins/query-block'
|
||||
import {
|
||||
RequestURLBlock,
|
||||
RequestURLBlockNode,
|
||||
RequestURLBlockReplacementBlock,
|
||||
} from './plugins/request-url-block'
|
||||
import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin'
|
||||
import UpdateBlock from './plugins/update-block'
|
||||
import VariableBlock from './plugins/variable-block'
|
||||
import VariableValueBlock from './plugins/variable-value-block'
|
||||
@@ -96,14 +112,17 @@ export type PromptEditorProps = {
|
||||
onFocus?: () => void
|
||||
contextBlock?: ContextBlockType
|
||||
queryBlock?: QueryBlockType
|
||||
requestURLBlock?: RequestURLBlockType
|
||||
historyBlock?: HistoryBlockType
|
||||
variableBlock?: VariableBlockType
|
||||
externalToolBlock?: ExternalToolBlockType
|
||||
workflowVariableBlock?: WorkflowVariableBlockType
|
||||
hitlInputBlock?: HITLInputBlockType
|
||||
currentBlock?: CurrentBlockType
|
||||
errorMessageBlock?: ErrorMessageBlockType
|
||||
lastRunBlock?: LastRunBlockType
|
||||
isSupportFileVar?: boolean
|
||||
shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: (command: LexicalCommand<unknown>, params: any[]) => void }> }>
|
||||
}
|
||||
|
||||
const PromptEditor: FC<PromptEditorProps> = ({
|
||||
@@ -121,14 +140,17 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
onFocus,
|
||||
contextBlock,
|
||||
queryBlock,
|
||||
requestURLBlock,
|
||||
historyBlock,
|
||||
variableBlock,
|
||||
externalToolBlock,
|
||||
workflowVariableBlock,
|
||||
hitlInputBlock,
|
||||
currentBlock,
|
||||
errorMessageBlock,
|
||||
lastRunBlock,
|
||||
isSupportFileVar,
|
||||
shortcutPopups = [],
|
||||
}) => {
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const initialConfig = {
|
||||
@@ -143,8 +165,10 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
ContextBlockNode,
|
||||
HistoryBlockNode,
|
||||
QueryBlockNode,
|
||||
RequestURLBlockNode,
|
||||
WorkflowVariableBlockNode,
|
||||
VariableValueBlockNode,
|
||||
HITLInputNode,
|
||||
CurrentBlockNode,
|
||||
ErrorMessageBlockNode,
|
||||
LastRunBlockNode, // LastRunBlockNode is used for error message block replacement
|
||||
@@ -176,9 +200,16 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
} as any)
|
||||
}, [eventEmitter, historyBlock?.history])
|
||||
|
||||
const [floatingAnchorElem, setFloatingAnchorElem] = useState(null)
|
||||
|
||||
const onRef = (_floatingAnchorElem: any) => {
|
||||
if (_floatingAnchorElem !== null)
|
||||
setFloatingAnchorElem(_floatingAnchorElem)
|
||||
}
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>
|
||||
<div className={cn('relative', wrapperClassName)}>
|
||||
<div className={cn('relative', wrapperClassName)} ref={onRef}>
|
||||
<RichTextPlugin
|
||||
contentEditable={(
|
||||
<ContentEditable
|
||||
@@ -199,11 +230,17 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
)}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
{shortcutPopups?.map(({ hotkey, Popup }, idx) => (
|
||||
<ShortcutsPopupPlugin key={idx} hotkey={hotkey}>
|
||||
{(closePortal, onInsert) => <Popup onClose={closePortal} onInsert={onInsert} />}
|
||||
</ShortcutsPopupPlugin>
|
||||
))}
|
||||
<ComponentPickerBlock
|
||||
triggerString="/"
|
||||
contextBlock={contextBlock}
|
||||
historyBlock={historyBlock}
|
||||
queryBlock={queryBlock}
|
||||
requestURLBlock={requestURLBlock}
|
||||
variableBlock={variableBlock}
|
||||
externalToolBlock={externalToolBlock}
|
||||
workflowVariableBlock={workflowVariableBlock}
|
||||
@@ -217,6 +254,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
contextBlock={contextBlock}
|
||||
historyBlock={historyBlock}
|
||||
queryBlock={queryBlock}
|
||||
requestURLBlock={requestURLBlock}
|
||||
variableBlock={variableBlock}
|
||||
externalToolBlock={externalToolBlock}
|
||||
workflowVariableBlock={workflowVariableBlock}
|
||||
@@ -265,6 +303,14 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
hitlInputBlock?.show && (
|
||||
<>
|
||||
<HITLInputBlock {...hitlInputBlock} />
|
||||
<HITLInputBlockReplacementBlock {...hitlInputBlock} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
currentBlock?.show && (
|
||||
<>
|
||||
@@ -273,6 +319,14 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
requestURLBlock?.show && (
|
||||
<>
|
||||
<RequestURLBlock {...requestURLBlock} />
|
||||
<RequestURLBlockReplacementBlock {...requestURLBlock} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
errorMessageBlock?.show && (
|
||||
<>
|
||||
@@ -298,6 +352,9 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
|
||||
<UpdateBlock instanceId={instanceId} />
|
||||
<HistoryPlugin />
|
||||
{floatingAnchorElem && (
|
||||
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
|
||||
)}
|
||||
{/* <TreeView /> */}
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
|
||||
@@ -6,10 +6,12 @@ import type {
|
||||
HistoryBlockType,
|
||||
LastRunBlockType,
|
||||
QueryBlockType,
|
||||
RequestURLBlockType,
|
||||
VariableBlockType,
|
||||
WorkflowVariableBlockType,
|
||||
} from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { RiGlobalLine } from '@remixicon/react'
|
||||
import { $insertNodes } from 'lexical'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -27,6 +29,7 @@ import { INSERT_CONTEXT_BLOCK_COMMAND } from '../context-block'
|
||||
import { $createCustomTextNode } from '../custom-text/node'
|
||||
import { INSERT_HISTORY_BLOCK_COMMAND } from '../history-block'
|
||||
import { INSERT_QUERY_BLOCK_COMMAND } from '../query-block'
|
||||
import { INSERT_REQUEST_URL_BLOCK_COMMAND } from '../request-url-block'
|
||||
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block'
|
||||
import { PickerBlockMenuOption } from './menu'
|
||||
import { PromptMenuItem } from './prompt-option'
|
||||
@@ -36,6 +39,7 @@ export const usePromptOptions = (
|
||||
contextBlock?: ContextBlockType,
|
||||
queryBlock?: QueryBlockType,
|
||||
historyBlock?: HistoryBlockType,
|
||||
requestURLBlock?: RequestURLBlockType,
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
@@ -91,6 +95,30 @@ export const usePromptOptions = (
|
||||
)
|
||||
}
|
||||
|
||||
if (requestURLBlock?.show) {
|
||||
promptOptions.push(new PickerBlockMenuOption({
|
||||
key: t('promptEditor.requestURL.item.title', { ns: 'common' }),
|
||||
group: 'request URL',
|
||||
render: ({ isSelected, onSelect, onSetHighlight }) => {
|
||||
return (
|
||||
<PromptMenuItem
|
||||
title={t('promptEditor.requestURL.item.title', { ns: 'common' })}
|
||||
icon={<RiGlobalLine className="h-4 w-4 text-util-colors-violet-violet-600" />}
|
||||
disabled={!requestURLBlock.selectable}
|
||||
isSelected={isSelected}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onSetHighlight}
|
||||
/>
|
||||
)
|
||||
},
|
||||
onSelect: () => {
|
||||
if (!requestURLBlock?.selectable)
|
||||
return
|
||||
editor.dispatchCommand(INSERT_REQUEST_URL_BLOCK_COMMAND, undefined)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
if (historyBlock?.show) {
|
||||
promptOptions.push(
|
||||
new PickerBlockMenuOption({
|
||||
@@ -272,12 +300,13 @@ export const useOptions = (
|
||||
variableBlock?: VariableBlockType,
|
||||
externalToolBlockType?: ExternalToolBlockType,
|
||||
workflowVariableBlockType?: WorkflowVariableBlockType,
|
||||
requestURLBlock?: RequestURLBlockType,
|
||||
currentBlockType?: CurrentBlockType,
|
||||
errorMessageBlockType?: ErrorMessageBlockType,
|
||||
lastRunBlockType?: LastRunBlockType,
|
||||
queryString?: string,
|
||||
) => {
|
||||
const promptOptions = usePromptOptions(contextBlock, queryBlock, historyBlock)
|
||||
const promptOptions = usePromptOptions(contextBlock, queryBlock, historyBlock, requestURLBlock)
|
||||
const variableOptions = useVariableOptions(variableBlock, queryString)
|
||||
const externalToolOptions = useExternalToolOptions(externalToolBlockType, queryString)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
HistoryBlockType,
|
||||
LastRunBlockType,
|
||||
QueryBlockType,
|
||||
RequestURLBlockType,
|
||||
VariableBlockType,
|
||||
WorkflowVariableBlockType,
|
||||
} from '../../types'
|
||||
@@ -44,6 +45,7 @@ type ComponentPickerProps = {
|
||||
triggerString: string
|
||||
contextBlock?: ContextBlockType
|
||||
queryBlock?: QueryBlockType
|
||||
requestURLBlock?: RequestURLBlockType
|
||||
historyBlock?: HistoryBlockType
|
||||
variableBlock?: VariableBlockType
|
||||
externalToolBlock?: ExternalToolBlockType
|
||||
@@ -57,6 +59,7 @@ const ComponentPicker = ({
|
||||
triggerString,
|
||||
contextBlock,
|
||||
queryBlock,
|
||||
requestURLBlock,
|
||||
historyBlock,
|
||||
variableBlock,
|
||||
externalToolBlock,
|
||||
@@ -100,6 +103,7 @@ const ComponentPicker = ({
|
||||
variableBlock,
|
||||
externalToolBlock,
|
||||
workflowVariableBlock,
|
||||
requestURLBlock,
|
||||
currentBlock,
|
||||
errorMessageBlock,
|
||||
lastRunBlock,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { JSX } from 'react'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { DraggableBlockPlugin_EXPERIMENTAL } from '@lexical/react/LexicalDraggableBlockPlugin'
|
||||
import { RiDraggable } from '@remixicon/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu'
|
||||
|
||||
function isOnMenu(element: HTMLElement): boolean {
|
||||
return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`)
|
||||
}
|
||||
|
||||
const SUPPORT_DRAG_CLASS = 'support-drag'
|
||||
function checkSupportDrag(element: Element | null): boolean {
|
||||
if (!element)
|
||||
return false
|
||||
|
||||
if (element.classList.contains(SUPPORT_DRAG_CLASS))
|
||||
return true
|
||||
|
||||
if (element.querySelector(`.${SUPPORT_DRAG_CLASS}`))
|
||||
return true
|
||||
|
||||
return !!(element.closest(`.${SUPPORT_DRAG_CLASS}`))
|
||||
}
|
||||
|
||||
export default function DraggableBlockPlugin({
|
||||
anchorElem = document.body,
|
||||
}: {
|
||||
anchorElem?: HTMLElement
|
||||
}): JSX.Element {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const targetLineRef = useRef<HTMLDivElement>(null)
|
||||
const [, setDraggableElement] = useState<HTMLElement | null>(
|
||||
null,
|
||||
)
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const [isSupportDrag, setIsSupportDrag] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const root = editor.getRootElement()
|
||||
if (!root)
|
||||
return
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const isSupportDrag = checkSupportDrag(e.target as Element)
|
||||
setIsSupportDrag(isSupportDrag)
|
||||
}
|
||||
|
||||
root.addEventListener('mousemove', onMove)
|
||||
return () => root.removeEventListener('mousemove', onMove)
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<DraggableBlockPlugin_EXPERIMENTAL
|
||||
anchorElem={anchorElem}
|
||||
menuRef={menuRef as any}
|
||||
targetLineRef={targetLineRef as any}
|
||||
menuComponent={
|
||||
isSupportDrag
|
||||
? (
|
||||
<div ref={menuRef} className={cn(DRAGGABLE_BLOCK_MENU_CLASSNAME, 'absolute right-2.5 top-4 cursor-grab opacity-0 will-change-transform active:cursor-move')}>
|
||||
<RiDraggable className="size-3.5 text-text-tertiary" />
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
targetLineComponent={(
|
||||
<div
|
||||
ref={targetLineRef}
|
||||
className="pointer-events-none absolute left-[-21px] top-0 opacity-0 will-change-transform"
|
||||
// style={{ width: 500 }} // width not worked here
|
||||
>
|
||||
<div
|
||||
className="absolute -right-10 left-0 top-0 h-[2px] bg-text-accent-secondary"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
isOnMenu={isOnMenu}
|
||||
onElementChanged={setDraggableElement}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { RiDeleteBinLine, RiEditLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import ActionButton from '../../../action-button'
|
||||
import { VariableX } from '../../../icons/src/vender/workflow'
|
||||
import Modal from '../../../modal'
|
||||
import InputField from './input-field'
|
||||
import VariableBlock from './variable-block'
|
||||
|
||||
type HITLInputComponentUIProps = {
|
||||
nodeId: string
|
||||
varName: string
|
||||
formInput?: FormInputItem
|
||||
onChange: (input: FormInputItem) => void
|
||||
onRename: (payload: FormInputItem, oldName: string) => void
|
||||
onRemove: (varName: string) => void
|
||||
workflowNodesMap: WorkflowNodesMap
|
||||
environmentVariables?: Var[]
|
||||
conversationVariables?: Var[]
|
||||
ragVariables?: Var[]
|
||||
getVarType?: (payload: {
|
||||
nodeId: string
|
||||
valueSelector: ValueSelector
|
||||
}) => Type
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
nodeId,
|
||||
varName,
|
||||
formInput = {
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: varName,
|
||||
default: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
onChange,
|
||||
onRename,
|
||||
onRemove,
|
||||
workflowNodesMap = {},
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
readonly,
|
||||
}) => {
|
||||
const [isShowEditModal, {
|
||||
setTrue: showEditModal,
|
||||
setFalse: hideEditModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
// Lexical delegate the click make it unable to add click by the method of react
|
||||
const editBtnRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const editBtn = editBtnRef.current
|
||||
if (editBtn)
|
||||
editBtn.addEventListener('click', showEditModal)
|
||||
|
||||
return () => {
|
||||
if (editBtn)
|
||||
editBtn.removeEventListener('click', showEditModal)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const removeBtnRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const removeBtn = removeBtnRef.current
|
||||
const removeHandler = () => onRemove(varName)
|
||||
if (removeBtn)
|
||||
removeBtn.addEventListener('click', removeHandler)
|
||||
|
||||
return () => {
|
||||
if (removeBtn)
|
||||
removeBtn.removeEventListener('click', removeHandler)
|
||||
}
|
||||
}, [onRemove, varName])
|
||||
|
||||
const handleChange = useCallback((newPayload: FormInputItem) => {
|
||||
if (varName === newPayload.output_variable_name)
|
||||
onChange(newPayload)
|
||||
else
|
||||
onRename(newPayload, varName)
|
||||
hideEditModal()
|
||||
}, [hideEditModal, onChange, onRename, varName])
|
||||
|
||||
const isDefaultValueVariable = useMemo(() => {
|
||||
return formInput.default?.type === 'variable'
|
||||
}, [formInput.default?.type])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative flex h-8 w-full select-none items-center rounded-[8px] border-[1.5px] border-components-input-border-active bg-background-default-hover pl-1.5 pr-0.5"
|
||||
>
|
||||
<div className="absolute left-2.5 top-[-12px]">
|
||||
<div className="absolute bottom-1 h-[1.5px] w-full bg-background-default-subtle"></div>
|
||||
<div className="relative flex items-center space-x-0.5 px-1 text-text-accent-light-mode-only">
|
||||
<VariableX className="size-3" />
|
||||
<div className="system-xs-medium">{varName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center gap-x-0.5 pr-5">
|
||||
<div className="min-w-0 grow">
|
||||
{/* Default Value Info */}
|
||||
{isDefaultValueVariable && (
|
||||
<VariableBlock
|
||||
variables={formInput.default?.selector}
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
getVarType={getVarType}
|
||||
environmentVariables={environmentVariables}
|
||||
conversationVariables={conversationVariables}
|
||||
ragVariables={ragVariables}
|
||||
/>
|
||||
)}
|
||||
{!isDefaultValueVariable && (
|
||||
<div className="system-xs-medium max-w-full truncate text-components-input-text-filled">{formInput.default?.value}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!readonly && (
|
||||
<div className="hidden h-full shrink-0 items-center space-x-1 group-hover:flex">
|
||||
<div className="flex h-full items-center" ref={editBtnRef}>
|
||||
<ActionButton size="s">
|
||||
<RiEditLine className="size-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full items-center" ref={removeBtnRef}>
|
||||
<ActionButton size="s">
|
||||
<RiDeleteBinLine className="size-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isShowEditModal && (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={hideEditModal}
|
||||
wrapperClassName="z-[999]"
|
||||
className="max-w-[372px] !p-0"
|
||||
>
|
||||
<InputField
|
||||
nodeId={nodeId}
|
||||
isEdit
|
||||
payload={formInput}
|
||||
onChange={handleChange}
|
||||
onCancel={hideEditModal}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(HITLInputComponentUI)
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { FC } from 'react'
|
||||
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import { useSelectOrDelete } from '../../hooks'
|
||||
import { DELETE_HITL_INPUT_BLOCK_COMMAND } from './'
|
||||
import ComponentUi from './component-ui'
|
||||
|
||||
type HITLInputComponentProps = {
|
||||
nodeKey: string
|
||||
nodeId: string
|
||||
varName: string
|
||||
formInputs?: FormInputItem[]
|
||||
onChange: (inputs: FormInputItem[]) => void
|
||||
onRename: (payload: FormInputItem, oldName: string) => void
|
||||
onRemove: (varName: string) => void
|
||||
workflowNodesMap: WorkflowNodesMap
|
||||
environmentVariables?: Var[]
|
||||
conversationVariables?: Var[]
|
||||
ragVariables?: Var[]
|
||||
getVarType?: (payload: {
|
||||
nodeId: string
|
||||
valueSelector: ValueSelector
|
||||
}) => Type
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const HITLInputComponent: FC<HITLInputComponentProps> = ({
|
||||
nodeKey,
|
||||
nodeId,
|
||||
varName,
|
||||
formInputs = [],
|
||||
onChange,
|
||||
onRename,
|
||||
onRemove,
|
||||
workflowNodesMap = {},
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
readonly,
|
||||
}) => {
|
||||
const [ref] = useSelectOrDelete(nodeKey, DELETE_HITL_INPUT_BLOCK_COMMAND)
|
||||
const payload = formInputs.find(item => item.output_variable_name === varName)
|
||||
|
||||
const handleChange = useCallback((newPayload: FormInputItem) => {
|
||||
if (!payload) {
|
||||
onChange([...formInputs, newPayload])
|
||||
return
|
||||
}
|
||||
if (payload?.output_variable_name !== newPayload.output_variable_name) {
|
||||
onChange(produce(formInputs, (draft) => {
|
||||
draft.splice(draft.findIndex(item => item.output_variable_name === payload?.output_variable_name), 1, newPayload)
|
||||
}))
|
||||
return
|
||||
}
|
||||
onChange(formInputs.map(item => item.output_variable_name === varName ? newPayload : item))
|
||||
}, [formInputs, onChange, payload, varName])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="w-full pb-1 pt-3"
|
||||
>
|
||||
<ComponentUi
|
||||
nodeId={nodeId}
|
||||
varName={varName}
|
||||
formInput={payload}
|
||||
onChange={handleChange}
|
||||
onRename={onRename}
|
||||
onRemove={onRemove}
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
getVarType={getVarType}
|
||||
environmentVariables={environmentVariables}
|
||||
conversationVariables={conversationVariables}
|
||||
ragVariables={ragVariables}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HITLInputComponent
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { TextNode } from 'lexical'
|
||||
import type { HITLInputBlockType } from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { HITL_INPUT_REG } from '@/config'
|
||||
import { decoratorTransform } from '../../utils'
|
||||
import { CustomTextNode } from '../custom-text/node'
|
||||
import { $createHITLInputNode, HITLInputNode } from './node'
|
||||
|
||||
const REGEX = new RegExp(HITL_INPUT_REG)
|
||||
|
||||
const HITLInputReplacementBlock = ({
|
||||
nodeId,
|
||||
formInputs,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
variables,
|
||||
readonly,
|
||||
}: HITLInputBlockType) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const environmentVariables = useMemo(() => variables?.find(o => o.nodeId === 'env')?.vars || [], [variables])
|
||||
const conversationVariables = useMemo(() => variables?.find(o => o.nodeId === 'conversation')?.vars || [], [variables])
|
||||
const ragVariables = useMemo(() => variables?.reduce<any[]>((acc, curr) => {
|
||||
if (curr.nodeId === 'rag')
|
||||
acc.push(...curr.vars)
|
||||
else
|
||||
acc.push(...curr.vars.filter(v => v.isRagVariable))
|
||||
return acc
|
||||
}, []), [variables])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([HITLInputNode]))
|
||||
throw new Error('HITLInputNodePlugin: HITLInputNode not registered on editor')
|
||||
}, [editor])
|
||||
|
||||
const createHITLInputBlockNode = useCallback((textNode: TextNode): HITLInputNode => {
|
||||
const varName = textNode.getTextContent().split('.')[1].replace(/#\}\}$/, '')
|
||||
return $applyNodeReplacement($createHITLInputNode(
|
||||
varName,
|
||||
nodeId,
|
||||
formInputs || [],
|
||||
onFormInputsChange!,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove!,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
readonly,
|
||||
))
|
||||
}, [nodeId, formInputs, onFormInputsChange, onFormInputItemRename, onFormInputItemRemove, workflowNodesMap, getVarType, environmentVariables, conversationVariables, ragVariables, readonly])
|
||||
|
||||
const getMatch = useCallback((text: string) => {
|
||||
const matchArr = REGEX.exec(text)
|
||||
|
||||
if (matchArr === null)
|
||||
return null
|
||||
|
||||
const startOffset = matchArr.index
|
||||
const endOffset = startOffset + matchArr[0].length
|
||||
return {
|
||||
end: endOffset,
|
||||
start: startOffset,
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
REGEX.lastIndex = 0
|
||||
return mergeRegister(
|
||||
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createHITLInputBlockNode)),
|
||||
)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default memo(HITLInputReplacementBlock)
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { HITLInputBlockType } from '../../types'
|
||||
import type {
|
||||
HITLNodeProps,
|
||||
} from './node'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$insertNodes,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
createCommand,
|
||||
} from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import { CustomTextNode } from '../custom-text/node'
|
||||
import {
|
||||
$createHITLInputNode,
|
||||
HITLInputNode,
|
||||
} from './node'
|
||||
|
||||
export const INSERT_HITL_INPUT_BLOCK_COMMAND = createCommand('INSERT_HITL_INPUT_BLOCK_COMMAND')
|
||||
export const DELETE_HITL_INPUT_BLOCK_COMMAND = createCommand('DELETE_HITL_INPUT_BLOCK_COMMAND')
|
||||
export const UPDATE_WORKFLOW_NODES_MAP = createCommand('UPDATE_WORKFLOW_NODES_MAP')
|
||||
|
||||
export type HITLInputProps = {
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
const HITLInputBlock = memo(({
|
||||
onInsert,
|
||||
onDelete,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
readonly,
|
||||
}: HITLInputBlockType) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
editor.update(() => {
|
||||
editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap)
|
||||
})
|
||||
}, [editor, workflowNodesMap])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([HITLInputNode]))
|
||||
throw new Error('HITLInputBlockPlugin: HITLInputBlock not registered on editor')
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
INSERT_HITL_INPUT_BLOCK_COMMAND,
|
||||
(nodeProps: HITLNodeProps) => {
|
||||
const {
|
||||
variableName,
|
||||
nodeId,
|
||||
formInputs,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
} = nodeProps
|
||||
const currentHITLNode = $createHITLInputNode(
|
||||
variableName,
|
||||
nodeId,
|
||||
formInputs,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
readonly,
|
||||
)
|
||||
const prev = new CustomTextNode('\n')
|
||||
$insertNodes([prev])
|
||||
$insertNodes([currentHITLNode])
|
||||
const next = new CustomTextNode('\n')
|
||||
$insertNodes([next])
|
||||
if (onInsert)
|
||||
onInsert()
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand(
|
||||
DELETE_HITL_INPUT_BLOCK_COMMAND,
|
||||
() => {
|
||||
if (onDelete)
|
||||
onDelete()
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
}, [editor, onInsert, onDelete])
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
HITLInputBlock.displayName = 'HITLInputBlock'
|
||||
|
||||
export { HITLInputBlock }
|
||||
export { default as HITLInputBlockReplacementBlock } from './hitl-input-block-replacement-block'
|
||||
export { HITLInputNode } from './node'
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { FormInputItem, FormInputItemDefault } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { ValueSelector } from '@/app/components/workflow/types'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
|
||||
import Button from '../../../button'
|
||||
import PrePopulate from './pre-populate'
|
||||
|
||||
const i18nPrefix = 'nodes.humanInput.insertInputField'
|
||||
|
||||
type InputFieldProps = {
|
||||
nodeId: string
|
||||
isEdit: boolean
|
||||
payload?: FormInputItem
|
||||
onChange: (newPayload: FormInputItem) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
const defaultPayload: FormInputItem = {
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: '',
|
||||
default: { type: 'constant', selector: [], value: '' },
|
||||
}
|
||||
const InputField: React.FC<InputFieldProps> = ({
|
||||
nodeId,
|
||||
isEdit,
|
||||
payload,
|
||||
onChange,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [tempPayload, setTempPayload] = useState(payload || defaultPayload)
|
||||
const nameValid = useMemo(() => {
|
||||
const name = tempPayload.output_variable_name.trim()
|
||||
if (!name)
|
||||
return false
|
||||
if (name.includes(' '))
|
||||
return false
|
||||
return /^[a-z_]\w{0,29}$/.test(name)
|
||||
}, [tempPayload.output_variable_name])
|
||||
const handleSave = useCallback(() => {
|
||||
if (!nameValid)
|
||||
return
|
||||
onChange(tempPayload)
|
||||
}, [nameValid, onChange, tempPayload])
|
||||
const defaultValueConfig = tempPayload.default
|
||||
const handleDefaultValueChange = useCallback((key: keyof FormInputItemDefault) => {
|
||||
return (value: ValueSelector | string) => {
|
||||
const nextValue = produce(tempPayload, (draft) => {
|
||||
if (!draft.default)
|
||||
draft.default = { type: 'constant', selector: [], value: '' }
|
||||
if (key === 'selector') {
|
||||
draft.default.type = 'variable'
|
||||
draft.default.selector = value as ValueSelector
|
||||
}
|
||||
else if (key === 'value') {
|
||||
draft.default.type = 'constant'
|
||||
draft.default.value = value as string
|
||||
}
|
||||
else if (key === 'type') {
|
||||
draft.default.type = value as 'constant' | 'variable'
|
||||
}
|
||||
})
|
||||
setTempPayload(nextValue)
|
||||
}
|
||||
}, [tempPayload])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [handleSave])
|
||||
|
||||
return (
|
||||
<div className="w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-[5px]">
|
||||
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
|
||||
<div className="mt-3">
|
||||
<div className="system-xs-medium text-text-secondary">
|
||||
{t(`${i18nPrefix}.saveResponseAs`, { ns: 'workflow' })}
|
||||
<span className="system-xs-regular relative text-text-destructive-secondary">*</span>
|
||||
</div>
|
||||
<Input
|
||||
className="mt-1.5"
|
||||
placeholder={t(`${i18nPrefix}.saveResponseAsPlaceholder`, { ns: 'workflow' })}
|
||||
value={tempPayload.output_variable_name}
|
||||
onChange={(e) => {
|
||||
setTempPayload(prev => ({ ...prev, output_variable_name: e.target.value }))
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{tempPayload.output_variable_name && !nameValid && (
|
||||
<div className="system-xs-regular mt-1 px-1 text-text-destructive-secondary">
|
||||
{t(`${i18nPrefix}.variableNameInvalid`, { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="system-xs-medium mb-1.5 text-text-secondary">
|
||||
{t(`${i18nPrefix}.prePopulateField`, { ns: 'workflow' })}
|
||||
</div>
|
||||
<PrePopulate
|
||||
isVariable={defaultValueConfig?.type === 'variable'}
|
||||
onIsVariableChange={(isVariable) => {
|
||||
handleDefaultValueChange('type')(isVariable ? 'variable' : 'constant')
|
||||
}}
|
||||
nodeId={nodeId}
|
||||
valueSelector={defaultValueConfig?.selector}
|
||||
onValueSelectorChange={handleDefaultValueChange('selector')}
|
||||
value={defaultValueConfig?.value}
|
||||
onValueChange={handleDefaultValueChange('value')}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
{isEdit
|
||||
? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!nameValid}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
className="flex"
|
||||
variant="primary"
|
||||
disabled={!nameValid}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<span className="mr-1">{t(`${i18nPrefix}.insert`, { ns: 'workflow' })}</span>
|
||||
<span className="system-kbd mr-0.5 flex h-4 items-center rounded-[4px] bg-components-kbd-bg-white px-1">{getKeyboardKeyNameBySystem('ctrl')}</span>
|
||||
<span className=" system-kbd flex h-4 items-center rounded-[4px] bg-components-kbd-bg-white px-1">↩︎</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputField
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
|
||||
import type { GetVarType } from '../../types'
|
||||
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Var } from '@/app/components/workflow/types'
|
||||
import { DecoratorNode } from 'lexical'
|
||||
import HILTInputBlockComponent from './component'
|
||||
|
||||
export type HITLNodeProps = {
|
||||
variableName: string
|
||||
nodeId: string
|
||||
formInputs: FormInputItem[]
|
||||
onFormInputsChange: (inputs: FormInputItem[]) => void
|
||||
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
|
||||
onFormInputItemRemove: (varName: string) => void
|
||||
workflowNodesMap: WorkflowNodesMap
|
||||
getVarType?: GetVarType
|
||||
environmentVariables?: Var[]
|
||||
conversationVariables?: Var[]
|
||||
ragVariables?: Var[]
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export type SerializedNode = SerializedLexicalNode & HITLNodeProps
|
||||
|
||||
export class HITLInputNode extends DecoratorNode<React.JSX.Element> {
|
||||
__variableName: string
|
||||
__nodeId: string
|
||||
__formInputs?: FormInputItem[]
|
||||
__onFormInputsChange: (inputs: FormInputItem[]) => void
|
||||
__onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
|
||||
__onFormInputItemRemove: (varName: string) => void
|
||||
__workflowNodesMap: WorkflowNodesMap
|
||||
__getVarType?: GetVarType
|
||||
__environmentVariables?: Var[]
|
||||
__conversationVariables?: Var[]
|
||||
__ragVariables?: Var[]
|
||||
__readonly?: boolean
|
||||
|
||||
isIsolated(): boolean {
|
||||
return true // This is necessary for drag-and-drop to work correctly
|
||||
}
|
||||
|
||||
isTopLevel(): boolean {
|
||||
return true // This is necessary for drag-and-drop to work correctly
|
||||
}
|
||||
|
||||
static getType(): string {
|
||||
return 'hitl-input-block'
|
||||
}
|
||||
|
||||
getVariableName(): string {
|
||||
const self = this.getLatest()
|
||||
return self.__variableName
|
||||
}
|
||||
|
||||
getNodeId(): string {
|
||||
const self = this.getLatest()
|
||||
return self.__nodeId
|
||||
}
|
||||
|
||||
getFormInputs(): FormInputItem[] {
|
||||
const self = this.getLatest()
|
||||
return self.__formInputs || []
|
||||
}
|
||||
|
||||
getOnFormInputsChange(): (inputs: FormInputItem[]) => void {
|
||||
const self = this.getLatest()
|
||||
return self.__onFormInputsChange
|
||||
}
|
||||
|
||||
getOnFormInputItemRename(): (payload: FormInputItem, oldName: string) => void {
|
||||
const self = this.getLatest()
|
||||
return self.__onFormInputItemRename
|
||||
}
|
||||
|
||||
getOnFormInputItemRemove(): (varName: string) => void {
|
||||
const self = this.getLatest()
|
||||
return self.__onFormInputItemRemove
|
||||
}
|
||||
|
||||
getWorkflowNodesMap(): WorkflowNodesMap {
|
||||
const self = this.getLatest()
|
||||
return self.__workflowNodesMap
|
||||
}
|
||||
|
||||
getGetVarType(): GetVarType | undefined {
|
||||
const self = this.getLatest()
|
||||
return self.__getVarType
|
||||
}
|
||||
|
||||
getEnvironmentVariables(): Var[] {
|
||||
const self = this.getLatest()
|
||||
return self.__environmentVariables || []
|
||||
}
|
||||
|
||||
getConversationVariables(): Var[] {
|
||||
const self = this.getLatest()
|
||||
return self.__conversationVariables || []
|
||||
}
|
||||
|
||||
getRagVariables(): Var[] {
|
||||
const self = this.getLatest()
|
||||
return self.__ragVariables || []
|
||||
}
|
||||
|
||||
getReadonly(): boolean {
|
||||
const self = this.getLatest()
|
||||
return self.__readonly || false
|
||||
}
|
||||
|
||||
static clone(node: HITLInputNode): HITLInputNode {
|
||||
return new HITLInputNode(
|
||||
node.__variableName,
|
||||
node.__nodeId,
|
||||
node.__formInputs || [],
|
||||
node.__onFormInputsChange,
|
||||
node.__onFormInputItemRename,
|
||||
node.__onFormInputItemRemove,
|
||||
node.__workflowNodesMap,
|
||||
node.__getVarType,
|
||||
node.__environmentVariables,
|
||||
node.__conversationVariables,
|
||||
node.__ragVariables,
|
||||
node.__readonly,
|
||||
node.__key,
|
||||
)
|
||||
}
|
||||
|
||||
isInline(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
constructor(
|
||||
varName: string,
|
||||
nodeId: string,
|
||||
formInputs: FormInputItem[],
|
||||
onFormInputsChange: (inputs: FormInputItem[]) => void,
|
||||
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void,
|
||||
onFormInputItemRemove: (varName: string) => void,
|
||||
workflowNodesMap: WorkflowNodesMap,
|
||||
getVarType?: GetVarType,
|
||||
environmentVariables?: Var[],
|
||||
conversationVariables?: Var[],
|
||||
ragVariables?: Var[],
|
||||
readonly?: boolean,
|
||||
key?: NodeKey,
|
||||
) {
|
||||
super(key)
|
||||
|
||||
this.__variableName = varName
|
||||
this.__nodeId = nodeId
|
||||
this.__formInputs = formInputs
|
||||
this.__onFormInputsChange = onFormInputsChange
|
||||
this.__onFormInputItemRename = onFormInputItemRename
|
||||
this.__onFormInputItemRemove = onFormInputItemRemove
|
||||
this.__workflowNodesMap = workflowNodesMap
|
||||
this.__getVarType = getVarType
|
||||
this.__environmentVariables = environmentVariables
|
||||
this.__conversationVariables = conversationVariables
|
||||
this.__ragVariables = ragVariables
|
||||
this.__readonly = readonly
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const div = document.createElement('div')
|
||||
div.classList.add('inline-flex', 'w-[calc(100%-1px)]', 'items-center', 'align-middle', 'support-drag')
|
||||
return div
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
decorate(): React.JSX.Element {
|
||||
return (
|
||||
<HILTInputBlockComponent
|
||||
nodeKey={this.getKey()}
|
||||
varName={this.getVariableName()}
|
||||
nodeId={this.getNodeId()}
|
||||
formInputs={this.getFormInputs()}
|
||||
onChange={this.getOnFormInputsChange()}
|
||||
onRename={this.getOnFormInputItemRename()}
|
||||
onRemove={this.getOnFormInputItemRemove()}
|
||||
workflowNodesMap={this.getWorkflowNodesMap()}
|
||||
getVarType={this.getGetVarType()}
|
||||
environmentVariables={this.getEnvironmentVariables()}
|
||||
conversationVariables={this.getConversationVariables()}
|
||||
ragVariables={this.getRagVariables()}
|
||||
readonly={this.getReadonly()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedNode): HITLInputNode {
|
||||
const node = $createHITLInputNode(
|
||||
serializedNode.variableName,
|
||||
serializedNode.nodeId,
|
||||
serializedNode.formInputs,
|
||||
serializedNode.onFormInputsChange,
|
||||
serializedNode.onFormInputItemRename,
|
||||
serializedNode.onFormInputItemRemove,
|
||||
serializedNode.workflowNodesMap,
|
||||
serializedNode.getVarType,
|
||||
serializedNode.environmentVariables,
|
||||
serializedNode.conversationVariables,
|
||||
serializedNode.ragVariables,
|
||||
serializedNode.readonly,
|
||||
)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
exportJSON(): SerializedNode {
|
||||
return {
|
||||
type: 'hitl-input-block',
|
||||
version: 1,
|
||||
variableName: this.getVariableName(),
|
||||
nodeId: this.getNodeId(),
|
||||
formInputs: this.getFormInputs(),
|
||||
onFormInputsChange: this.getOnFormInputsChange(),
|
||||
onFormInputItemRename: this.getOnFormInputItemRename(),
|
||||
onFormInputItemRemove: this.getOnFormInputItemRemove(),
|
||||
workflowNodesMap: this.getWorkflowNodesMap(),
|
||||
getVarType: this.getGetVarType(),
|
||||
environmentVariables: this.getEnvironmentVariables(),
|
||||
conversationVariables: this.getConversationVariables(),
|
||||
ragVariables: this.getRagVariables(),
|
||||
readonly: this.getReadonly(),
|
||||
}
|
||||
}
|
||||
|
||||
getTextContent(): string {
|
||||
return `{{#$output.${this.getVariableName()}#}}`
|
||||
}
|
||||
}
|
||||
|
||||
export function $createHITLInputNode(
|
||||
variableName: string,
|
||||
nodeId: string,
|
||||
formInputs: FormInputItem[],
|
||||
onFormInputsChange: (inputs: FormInputItem[]) => void,
|
||||
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void,
|
||||
onFormInputItemRemove: (varName: string) => void,
|
||||
workflowNodesMap: WorkflowNodesMap,
|
||||
getVarType?: GetVarType,
|
||||
environmentVariables?: Var[],
|
||||
conversationVariables?: Var[],
|
||||
ragVariables?: Var[],
|
||||
readonly?: boolean,
|
||||
): HITLInputNode {
|
||||
return new HITLInputNode(
|
||||
variableName,
|
||||
nodeId,
|
||||
formInputs,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
readonly,
|
||||
)
|
||||
}
|
||||
|
||||
export function $isHITLInputNode(
|
||||
node: HITLInputNode | LexicalNode | null | undefined,
|
||||
): node is HITLInputNode {
|
||||
return node instanceof HITLInputNode
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Textarea from '../../../textarea'
|
||||
import TagLabel from './tag-label'
|
||||
import TypeSwitch from './type-switch'
|
||||
|
||||
type Props = {
|
||||
isVariable?: boolean
|
||||
onIsVariableChange?: (isVariable: boolean) => void
|
||||
nodeId: string
|
||||
valueSelector?: ValueSelector
|
||||
onValueSelectorChange?: (valueSelector: ValueSelector | string) => void
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
|
||||
const i18nPrefix = 'nodes.humanInput.insertInputField'
|
||||
|
||||
type PlaceholderProps = {
|
||||
varPickerProps: {
|
||||
nodeId: string
|
||||
value: ValueSelector
|
||||
onChange: (valueSelector: ValueSelector | string) => void
|
||||
readonly: boolean
|
||||
zIndex: number
|
||||
filterVar: (varPayload: Var) => boolean
|
||||
isJustShowValue?: boolean
|
||||
}
|
||||
onTypeClick: (isVariable: boolean) => void
|
||||
}
|
||||
const Placeholder = ({
|
||||
varPickerProps,
|
||||
onTypeClick,
|
||||
}: PlaceholderProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="system-sm-regular mt-1 h-[80px] rounded-lg bg-components-input-bg-normal px-3 pt-2 text-text-tertiary">
|
||||
<div className="flex flex-wrap items-center leading-5">
|
||||
<Trans
|
||||
i18nKey={`${i18nPrefix}.prePopulateFieldPlaceholder`}
|
||||
ns="workflow"
|
||||
components={{
|
||||
staticContent: <TagLabel type="edit" className="mx-1" onClick={() => onTypeClick(false)}>{t(`${i18nPrefix}.staticContent`, { ns: 'workflow' })}</TagLabel>,
|
||||
variable: (
|
||||
<VarReferencePicker
|
||||
{...varPickerProps}
|
||||
trigger={
|
||||
<TagLabel type="variable" className="mx-1">{t(`${i18nPrefix}.variable`, { ns: 'workflow' })}</TagLabel>
|
||||
}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PrePopulate: FC<Props> = ({
|
||||
isVariable = false,
|
||||
onIsVariableChange,
|
||||
nodeId,
|
||||
valueSelector,
|
||||
onValueSelectorChange,
|
||||
value,
|
||||
onValueChange,
|
||||
}) => {
|
||||
const [onPlaceholderClicked, setOnPlaceholderClicked] = useState(false)
|
||||
const handleTypeChange = useCallback((isVar: boolean) => {
|
||||
setOnPlaceholderClicked(true)
|
||||
onIsVariableChange?.(isVar)
|
||||
}, [onIsVariableChange])
|
||||
|
||||
const [isFocus, setIsFocus] = useState(false)
|
||||
|
||||
const varPickerProps = {
|
||||
nodeId,
|
||||
value: valueSelector || [],
|
||||
onChange: onValueSelectorChange!,
|
||||
readonly: false,
|
||||
zIndex: 1000000, // bigger than shortcut plugin popup
|
||||
filterVar: (varPayload: Var) => {
|
||||
return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
|
||||
},
|
||||
}
|
||||
|
||||
const isShowPlaceholder = !onPlaceholderClicked && (isVariable ? (!valueSelector || valueSelector.length === 0) : !value)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab' && !onPlaceholderClicked) {
|
||||
e.preventDefault()
|
||||
setOnPlaceholderClicked(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onPlaceholderClicked, setOnPlaceholderClicked])
|
||||
|
||||
if (isShowPlaceholder)
|
||||
return <Placeholder varPickerProps={varPickerProps} onTypeClick={handleTypeChange} />
|
||||
|
||||
if (isVariable) {
|
||||
return (
|
||||
<div className="relative h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal px-3 pt-2">
|
||||
<VarReferencePicker
|
||||
{...varPickerProps}
|
||||
isJustShowValue
|
||||
/>
|
||||
<TypeSwitch
|
||||
className="absolute bottom-1 left-1.5"
|
||||
isVariable={isVariable}
|
||||
onIsVariableChange={handleTypeChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={cn('relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal pb-1', isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs')}>
|
||||
<Textarea
|
||||
value={value || ''}
|
||||
className="h-[43px] min-h-[43px] rounded-none border-none bg-transparent px-3 hover:bg-transparent focus:bg-transparent focus:shadow-none"
|
||||
onChange={e => onValueChange?.(e.target.value)}
|
||||
onFocus={() => {
|
||||
setOnPlaceholderClicked(true)
|
||||
setIsFocus(true)
|
||||
}}
|
||||
onBlur={() => setIsFocus(false)}
|
||||
autoFocus
|
||||
/>
|
||||
<TypeSwitch
|
||||
className="absolute bottom-1 left-1.5"
|
||||
isVariable={isVariable}
|
||||
onIsVariableChange={handleTypeChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(PrePopulate)
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { Variable02 } from '../../../icons/src/vender/solid/development'
|
||||
|
||||
type Props = {
|
||||
type: 'edit' | 'variable'
|
||||
children: string
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const TagLabel: FC<Props> = ({
|
||||
type,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
}) => {
|
||||
const Icon = type === 'edit' ? RiEditLine : Variable02
|
||||
return (
|
||||
<div
|
||||
className={cn('inline-flex h-5 cursor-pointer items-center space-x-1 rounded-md bg-components-button-secondary-bg px-1 text-text-accent', className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
<div className="system-xs-medium ">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(TagLabel)
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { Variable02 } from '../../../icons/src/vender/solid/development'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
isVariable?: boolean
|
||||
onIsVariableChange?: (isVariable: boolean) => void
|
||||
}
|
||||
|
||||
const TypeSwitch: FC<Props> = ({
|
||||
className,
|
||||
isVariable,
|
||||
onIsVariableChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={cn('inline-flex h-6 cursor-pointer select-none items-center space-x-1 rounded-md pl-1.5 pr-2 text-text-tertiary hover:bg-components-button-ghost-bg-hover', className)} onClick={() => onIsVariableChange?.(!isVariable)}>
|
||||
<Variable02 className="size-3.5" />
|
||||
<div className="system-xs-medium">{t(`nodes.humanInput.insertInputField.${isVariable ? 'useConstantInstead' : 'useVarInstead'}`, { ns: 'workflow' })}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(TypeSwitch)
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
} from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
isConversationVar,
|
||||
isENV,
|
||||
isGlobalVar,
|
||||
isRagVariableVar,
|
||||
isSystemVar,
|
||||
} from '@/app/components/workflow/nodes/_base/components/variable/utils'
|
||||
import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel'
|
||||
import {
|
||||
VariableLabelInEditor,
|
||||
} from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { isExceptionVariable } from '@/app/components/workflow/utils'
|
||||
import { UPDATE_WORKFLOW_NODES_MAP } from '../workflow-variable-block'
|
||||
import { HITLInputNode } from './node'
|
||||
|
||||
type HITLInputVariableBlockComponentProps = {
|
||||
variables: string[]
|
||||
workflowNodesMap: WorkflowNodesMap
|
||||
environmentVariables?: Var[]
|
||||
conversationVariables?: Var[]
|
||||
ragVariables?: Var[]
|
||||
getVarType?: (payload: {
|
||||
nodeId: string
|
||||
valueSelector: ValueSelector
|
||||
}) => Type
|
||||
}
|
||||
|
||||
const HITLInputVariableBlockComponent = ({
|
||||
variables,
|
||||
workflowNodesMap = {},
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
}: HITLInputVariableBlockComponentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const variablesLength = variables.length
|
||||
const isRagVar = isRagVariableVar(variables)
|
||||
const isShowAPart = variablesLength > 2 && !isRagVar
|
||||
const varName = (
|
||||
() => {
|
||||
const isSystem = isSystemVar(variables)
|
||||
const varName = variables[variablesLength - 1]
|
||||
return `${isSystem ? 'sys.' : ''}${varName}`
|
||||
}
|
||||
)()
|
||||
const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState<WorkflowNodesMap>(workflowNodesMap)
|
||||
const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]]
|
||||
|
||||
const isException = isExceptionVariable(varName, node?.type)
|
||||
const variableValid = useMemo(() => {
|
||||
let variableValid = true
|
||||
const isEnv = isENV(variables)
|
||||
const isChatVar = isConversationVar(variables)
|
||||
const isGlobal = isGlobalVar(variables)
|
||||
if (isGlobal)
|
||||
return true
|
||||
|
||||
if (isEnv) {
|
||||
if (environmentVariables)
|
||||
variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
|
||||
}
|
||||
else if (isChatVar) {
|
||||
if (conversationVariables)
|
||||
variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
|
||||
}
|
||||
else if (isRagVar) {
|
||||
if (ragVariables)
|
||||
variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`)
|
||||
}
|
||||
else {
|
||||
variableValid = !!node
|
||||
}
|
||||
return variableValid
|
||||
}, [variables, node, environmentVariables, conversationVariables, isRagVar, ragVariables])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([HITLInputNode]))
|
||||
throw new Error('HITLInputNodePlugin: HITLInputNode not registered on editor')
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
UPDATE_WORKFLOW_NODES_MAP,
|
||||
(workflowNodesMap: WorkflowNodesMap) => {
|
||||
setLocalWorkflowNodesMap(workflowNodesMap)
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
const Item = (
|
||||
<VariableLabelInEditor
|
||||
nodeType={node?.type}
|
||||
nodeTitle={node?.title}
|
||||
variables={variables}
|
||||
isExceptionVariable={isException}
|
||||
errorMsg={!variableValid ? t('errorMsg.invalidVariable', { ns: 'workflow' }) : undefined}
|
||||
notShowFullPath={isShowAPart}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!node)
|
||||
return Item
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
noDecoration
|
||||
popupContent={(
|
||||
<VarFullPathPanel
|
||||
nodeName={node.title}
|
||||
path={variables.slice(1)}
|
||||
varType={getVarType
|
||||
? getVarType({
|
||||
nodeId: variables[0],
|
||||
valueSelector: variables,
|
||||
})
|
||||
: Type.string}
|
||||
nodeType={node?.type}
|
||||
/>
|
||||
)}
|
||||
disabled={!isShowAPart}
|
||||
>
|
||||
<div>{Item}</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(HITLInputVariableBlockComponent)
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { FC } from 'react'
|
||||
import { RiGlobalLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useSelectOrDelete } from '../../hooks'
|
||||
import { DELETE_REQUEST_URL_BLOCK_COMMAND } from './index'
|
||||
|
||||
type RequestURLBlockComponentProps = {
|
||||
nodeKey: string
|
||||
}
|
||||
|
||||
const RequestURLBlockComponent: FC<RequestURLBlockComponentProps> = ({
|
||||
nodeKey,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_REQUEST_URL_BLOCK_COMMAND)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/wrap relative mx-0.5 flex h-[18px] select-none items-center rounded-[5px] border border-components-panel-border-subtle bg-components-badge-white-to-dark px-1 hover:border-[#7839ee]',
|
||||
isSelected && '!border-[#7839ee] hover:!border-[#7839ee]',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<RiGlobalLine className="mr-0.5 h-3.5 w-3.5 text-util-colors-violet-violet-600" />
|
||||
<div className="system-xs-medium text-util-colors-violet-violet-600">{t('promptEditor.requestURL.item.title', { ns: 'common' })}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RequestURLBlockComponent
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { RequestURLBlockType } from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$insertNodes,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
createCommand,
|
||||
} from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import {
|
||||
$createRequestURLBlockNode,
|
||||
RequestURLBlockNode,
|
||||
} from './node'
|
||||
|
||||
export const INSERT_REQUEST_URL_BLOCK_COMMAND = createCommand('INSERT_REQUEST_URL_BLOCK_COMMAND')
|
||||
export const DELETE_REQUEST_URL_BLOCK_COMMAND = createCommand('DELETE_REQUEST_URL_BLOCK_COMMAND')
|
||||
|
||||
const RequestURLBlock = memo(({
|
||||
onInsert,
|
||||
onDelete,
|
||||
}: RequestURLBlockType) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([RequestURLBlockNode]))
|
||||
throw new Error('RequestURLBlockPlugin: RequestURLBlock not registered on editor')
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
INSERT_REQUEST_URL_BLOCK_COMMAND,
|
||||
() => {
|
||||
const contextBlockNode = $createRequestURLBlockNode()
|
||||
|
||||
$insertNodes([contextBlockNode])
|
||||
if (onInsert)
|
||||
onInsert()
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand(
|
||||
DELETE_REQUEST_URL_BLOCK_COMMAND,
|
||||
() => {
|
||||
if (onDelete)
|
||||
onDelete()
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
}, [editor, onInsert, onDelete])
|
||||
|
||||
return null
|
||||
})
|
||||
RequestURLBlock.displayName = 'RequestURLBlock'
|
||||
|
||||
export { RequestURLBlock }
|
||||
export { RequestURLBlockNode } from './node'
|
||||
export { default as RequestURLBlockReplacementBlock } from './request-url-block-replacement-block'
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { LexicalNode, SerializedLexicalNode } from 'lexical'
|
||||
import { DecoratorNode } from 'lexical'
|
||||
import RequestURLBlockComponent from './component'
|
||||
|
||||
export type SerializedNode = SerializedLexicalNode
|
||||
|
||||
export class RequestURLBlockNode extends DecoratorNode<React.JSX.Element> {
|
||||
static getType(): string {
|
||||
return 'request-url-block'
|
||||
}
|
||||
|
||||
static clone(node: RequestURLBlockNode): RequestURLBlockNode {
|
||||
return new RequestURLBlockNode(node.__key)
|
||||
}
|
||||
|
||||
isInline(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const div = document.createElement('div')
|
||||
div.classList.add('inline-flex', 'items-center', 'align-middle')
|
||||
return div
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
decorate(): React.JSX.Element {
|
||||
return <RequestURLBlockComponent nodeKey={this.getKey()} />
|
||||
}
|
||||
|
||||
static importJSON(): RequestURLBlockNode {
|
||||
const node = $createRequestURLBlockNode()
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
exportJSON(): SerializedNode {
|
||||
return {
|
||||
type: 'request-url-block',
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
getTextContent(): string {
|
||||
return '{{#url#}}'
|
||||
}
|
||||
}
|
||||
export function $createRequestURLBlockNode(): RequestURLBlockNode {
|
||||
return new RequestURLBlockNode()
|
||||
}
|
||||
|
||||
export function $isRequestURLBlockNode(
|
||||
node: RequestURLBlockNode | LexicalNode | null | undefined,
|
||||
): node is RequestURLBlockNode {
|
||||
return node instanceof RequestURLBlockNode
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { RequestURLBlockType } from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import { REQUEST_URL_PLACEHOLDER_TEXT } from '../../constants'
|
||||
import { decoratorTransform } from '../../utils'
|
||||
import { CustomTextNode } from '../custom-text/node'
|
||||
import {
|
||||
$createRequestURLBlockNode,
|
||||
RequestURLBlockNode,
|
||||
} from '../request-url-block/node'
|
||||
|
||||
const REGEX = new RegExp(REQUEST_URL_PLACEHOLDER_TEXT)
|
||||
|
||||
const RequestURLBlockReplacementBlock = ({
|
||||
onInsert,
|
||||
}: RequestURLBlockType) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([RequestURLBlockNode]))
|
||||
throw new Error('RequestURLBlockNodePlugin: RequestURLBlockNode not registered on editor')
|
||||
}, [editor])
|
||||
|
||||
const createRequestURLBlockNode = useCallback((): RequestURLBlockNode => {
|
||||
if (onInsert)
|
||||
onInsert()
|
||||
return $applyNodeReplacement($createRequestURLBlockNode())
|
||||
}, [onInsert])
|
||||
|
||||
const getMatch = useCallback((text: string) => {
|
||||
const matchArr = REGEX.exec(text)
|
||||
|
||||
if (matchArr === null)
|
||||
return null
|
||||
|
||||
const startOffset = matchArr.index
|
||||
const endOffset = startOffset + REQUEST_URL_PLACEHOLDER_TEXT.length
|
||||
return {
|
||||
end: endOffset,
|
||||
start: startOffset,
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
REGEX.lastIndex = 0
|
||||
return mergeRegister(
|
||||
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createRequestURLBlockNode)),
|
||||
)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default memo(RequestURLBlockReplacementBlock)
|
||||
@@ -0,0 +1,134 @@
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import ShortcutsPopupPlugin, { SHORTCUTS_EMPTY_CONTENT } from './index'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Mock Range.getClientRects and getBoundingClientRect for JSDOM
|
||||
const mockDOMRect = {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 20,
|
||||
top: 100,
|
||||
right: 200,
|
||||
bottom: 120,
|
||||
left: 100,
|
||||
toJSON: () => ({}),
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
// Mock getClientRects on Range prototype
|
||||
Range.prototype.getClientRects = vi.fn(() => {
|
||||
const rectList = [mockDOMRect] as unknown as DOMRectList
|
||||
Object.defineProperty(rectList, 'length', { value: 1 })
|
||||
Object.defineProperty(rectList, 'item', { value: (index: number) => index === 0 ? mockDOMRect : null })
|
||||
return rectList
|
||||
})
|
||||
|
||||
// Mock getBoundingClientRect on Range prototype
|
||||
Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
|
||||
})
|
||||
|
||||
const CONTAINER_ID = 'host'
|
||||
const CONTENT_EDITABLE_ID = 'ce'
|
||||
|
||||
const MinimalEditor: React.FC<{
|
||||
withContainer?: boolean
|
||||
}> = ({ withContainer = true }) => {
|
||||
const initialConfig = {
|
||||
namespace: 'shortcuts-popup-plugin-test',
|
||||
onError: (e: Error) => {
|
||||
throw e
|
||||
},
|
||||
}
|
||||
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<div data-testid={CONTAINER_ID} className="relative" ref={withContainer ? setContainerEl : undefined}>
|
||||
<RichTextPlugin
|
||||
contentEditable={<ContentEditable data-testid={CONTENT_EDITABLE_ID} />}
|
||||
placeholder={null}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<ShortcutsPopupPlugin
|
||||
container={withContainer ? containerEl : undefined}
|
||||
/>
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ShortcutsPopupPlugin', () => {
|
||||
it('opens on hotkey when editor is focused', async () => {
|
||||
render(<MinimalEditor />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true }) // 模拟 Ctrl+/
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not open when editor is not focused', async () => {
|
||||
render(<MinimalEditor />)
|
||||
// 未聚焦
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('closes on Escape', async () => {
|
||||
render(<MinimalEditor />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('closes on click outside', async () => {
|
||||
render(<MinimalEditor />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseDown(ce)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('portals into provided container when container is set', async () => {
|
||||
render(<MinimalEditor withContainer />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
const host = screen.getByTestId(CONTAINER_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
expect(host).toContainElement(portalContent)
|
||||
})
|
||||
|
||||
it('falls back to document.body when container is not provided', async () => {
|
||||
render(<MinimalEditor withContainer={false} />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
expect(document.body).toContainElement(portalContent)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,305 @@
|
||||
import type { LexicalCommand } from 'lexical'
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
size,
|
||||
useFloating,
|
||||
} from '@floating-ui/react'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
} from 'lexical'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export const SHORTCUTS_EMPTY_CONTENT = 'shortcuts_empty_content'
|
||||
|
||||
// Hotkey can be:
|
||||
// - string: 'mod+/'
|
||||
// - string[]: ['mod', '/']
|
||||
// - string[][]: [['mod', '/'], ['mod', 'shift', '/']] (any combo matches)
|
||||
// - function: custom matcher
|
||||
export type Hotkey = string | string[] | string[][] | ((e: KeyboardEvent) => boolean)
|
||||
|
||||
type ShortcutPopupPluginProps = {
|
||||
hotkey?: Hotkey
|
||||
children?: React.ReactNode | ((close: () => void, onInsert: (command: LexicalCommand<unknown>, params: any[]) => void) => React.ReactNode)
|
||||
className?: string
|
||||
container?: Element | null
|
||||
onOpen?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const META_ALIASES = new Set(['meta', 'cmd', 'command'])
|
||||
const CTRL_ALIASES = new Set(['ctrl'])
|
||||
const ALT_ALIASES = new Set(['alt', 'option'])
|
||||
const SHIFT_ALIASES = new Set(['shift'])
|
||||
|
||||
function matchHotkey(event: KeyboardEvent, hotkey?: Hotkey) {
|
||||
if (!hotkey)
|
||||
return false
|
||||
|
||||
if (typeof hotkey === 'function')
|
||||
return hotkey(event)
|
||||
|
||||
const matchCombo = (tokens: string[]) => {
|
||||
const parts = tokens.map(t => t.toLowerCase().trim()).filter(Boolean)
|
||||
let expectedKey: string | null = null
|
||||
|
||||
let needMod = false
|
||||
let needCtrl = false
|
||||
let needMeta = false
|
||||
let needAlt = false
|
||||
let needShift = false
|
||||
|
||||
for (const p of parts) {
|
||||
if (p === 'mod') {
|
||||
needMod = true
|
||||
continue
|
||||
}
|
||||
if (CTRL_ALIASES.has(p)) {
|
||||
needCtrl = true
|
||||
continue
|
||||
}
|
||||
if (META_ALIASES.has(p)) {
|
||||
needMeta = true
|
||||
continue
|
||||
}
|
||||
if (ALT_ALIASES.has(p)) {
|
||||
needAlt = true
|
||||
continue
|
||||
}
|
||||
if (SHIFT_ALIASES.has(p)) {
|
||||
needShift = true
|
||||
continue
|
||||
}
|
||||
expectedKey = p
|
||||
}
|
||||
|
||||
if (needMod && !(event.metaKey || event.ctrlKey))
|
||||
return false
|
||||
if (needCtrl && !event.ctrlKey)
|
||||
return false
|
||||
if (needMeta && !event.metaKey)
|
||||
return false
|
||||
if (needAlt && !event.altKey)
|
||||
return false
|
||||
if (needShift && !event.shiftKey)
|
||||
return false
|
||||
|
||||
if (expectedKey) {
|
||||
const k = event.key.toLowerCase()
|
||||
const normalized = k === ' ' ? 'space' : k
|
||||
if (normalized !== expectedKey)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (Array.isArray(hotkey)) {
|
||||
const isNested = hotkey.length > 0 && Array.isArray((hotkey as unknown[])[0])
|
||||
if (isNested) {
|
||||
const combos = hotkey as string[][]
|
||||
return combos.some(tokens => matchCombo(tokens))
|
||||
}
|
||||
else {
|
||||
const tokens = hotkey as string[]
|
||||
return matchCombo(tokens)
|
||||
}
|
||||
}
|
||||
|
||||
const tokensFromString = hotkey
|
||||
.toLowerCase()
|
||||
.split('+')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
return matchCombo(tokensFromString)
|
||||
}
|
||||
|
||||
export default function ShortcutsPopupPlugin({
|
||||
hotkey = 'mod+/',
|
||||
children,
|
||||
className,
|
||||
container,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: ShortcutPopupPluginProps): React.ReactPortal | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [open, setOpen] = useState(false)
|
||||
const portalRef = useRef<HTMLDivElement | null>(null)
|
||||
const lastSelectionRef = useRef<Range | null>(null)
|
||||
|
||||
const containerEl = useMemo(() => container ?? (typeof document !== 'undefined' ? document.body : null), [container])
|
||||
const useContainer = !!containerEl && containerEl !== document.body
|
||||
|
||||
const { refs, floatingStyles, isPositioned } = useFloating({
|
||||
placement: 'bottom-start',
|
||||
middleware: [
|
||||
offset(0), // fix hide cursor
|
||||
shift({
|
||||
padding: 8,
|
||||
altBoundary: true,
|
||||
}),
|
||||
flip(),
|
||||
size({
|
||||
apply({ availableWidth, availableHeight, elements }) {
|
||||
Object.assign(elements.floating.style, {
|
||||
maxWidth: `${Math.min(400, availableWidth)}px`,
|
||||
maxHeight: `${Math.min(300, availableHeight)}px`,
|
||||
overflow: 'auto',
|
||||
})
|
||||
},
|
||||
padding: 8,
|
||||
}),
|
||||
],
|
||||
whileElementsMounted: autoUpdate,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
const domSelection = window.getSelection()
|
||||
if (domSelection && domSelection.rangeCount > 0)
|
||||
lastSelectionRef.current = domSelection.getRangeAt(0).cloneRange()
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
const isEditorFocused = useCallback(() => {
|
||||
const root = editor.getRootElement()
|
||||
if (!root)
|
||||
return false
|
||||
return root.contains(document.activeElement)
|
||||
}, [editor])
|
||||
|
||||
const openPortal = useCallback(() => {
|
||||
const domSelection = window.getSelection()
|
||||
let range: Range | null = null
|
||||
if (domSelection && domSelection.rangeCount > 0)
|
||||
range = domSelection.getRangeAt(0).cloneRange()
|
||||
else
|
||||
range = lastSelectionRef.current
|
||||
|
||||
if (range) {
|
||||
const rects = range.getClientRects()
|
||||
let rect: DOMRect | null = null
|
||||
|
||||
if (rects && rects.length)
|
||||
rect = rects[rects.length - 1]
|
||||
|
||||
else
|
||||
rect = range.getBoundingClientRect()
|
||||
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
const root = editor.getRootElement()
|
||||
if (root) {
|
||||
const sc = range.startContainer
|
||||
const node = sc.nodeType === Node.ELEMENT_NODE
|
||||
? sc as Element
|
||||
: (sc.parentElement || root)
|
||||
|
||||
rect = node.getBoundingClientRect()
|
||||
|
||||
if (rect.width === 0 && rect.height === 0)
|
||||
rect = root.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
|
||||
if (rect && !(rect.top === 0 && rect.left === 0 && rect.width === 0 && rect.height === 0)) {
|
||||
const virtualEl = {
|
||||
getBoundingClientRect() {
|
||||
return rect!
|
||||
},
|
||||
}
|
||||
refs.setReference(virtualEl as Element)
|
||||
}
|
||||
}
|
||||
|
||||
setOpen(true)
|
||||
onOpen?.()
|
||||
}, [onOpen])
|
||||
|
||||
const closePortal = useCallback(() => {
|
||||
setOpen(false)
|
||||
onClose?.()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (open && event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
closePortal()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEditorFocused())
|
||||
return
|
||||
|
||||
if (matchHotkey(event, hotkey)) {
|
||||
event.preventDefault()
|
||||
openPortal()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [hotkey, open, isEditorFocused, openPortal, closePortal])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (!portalRef.current)
|
||||
return
|
||||
if (!portalRef.current.contains(e.target as Node))
|
||||
closePortal()
|
||||
}
|
||||
document.addEventListener('mousedown', onMouseDown, false)
|
||||
return () => document.removeEventListener('mousedown', onMouseDown, false)
|
||||
}, [open, closePortal])
|
||||
|
||||
const handleInsert = useCallback((command: LexicalCommand<unknown>, params: any) => {
|
||||
editor.dispatchCommand(command, params)
|
||||
closePortal()
|
||||
}, [editor, closePortal])
|
||||
|
||||
if (!open || !containerEl)
|
||||
return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={(node) => {
|
||||
portalRef.current = node
|
||||
refs.setFloating(node)
|
||||
}}
|
||||
className={cn(
|
||||
useContainer ? '' : 'z-[999999]',
|
||||
'absolute rounded-xl bg-components-panel-bg-blur shadow-lg',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
visibility: isPositioned ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{typeof children === 'function' ? children(closePortal, handleInsert) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
|
||||
</div>,
|
||||
containerEl,
|
||||
)
|
||||
}
|
||||
@@ -40,7 +40,7 @@ const WorkflowVariableBlockReplacementBlock = ({
|
||||
|
||||
const nodePathString = textNode.getTextContent().slice(3, -3)
|
||||
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap, getVarType, variables?.find(o => o.nodeId === 'env')?.vars || [], variables?.find(o => o.nodeId === 'conversation')?.vars || [], ragVariables))
|
||||
}, [onInsert, workflowNodesMap, getVarType, variables])
|
||||
}, [onInsert, workflowNodesMap, getVarType, variables, ragVariables])
|
||||
|
||||
const getMatch = useCallback((text: string) => {
|
||||
const matchArr = REGEX.exec(text)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GeneratorType } from '../../app/configuration/config/automatic/types'
|
||||
import type { FormInputItem } from '../../workflow/nodes/human-input/types'
|
||||
import type { Type } from '../../workflow/nodes/llm/types'
|
||||
import type { Dataset } from './plugins/context-block'
|
||||
import type { RoleName } from './plugins/history-block'
|
||||
@@ -46,6 +47,13 @@ export type HistoryBlockType = {
|
||||
onEditRole?: () => void
|
||||
}
|
||||
|
||||
export type RequestURLBlockType = {
|
||||
show?: boolean
|
||||
selectable?: boolean
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
export type VariableBlockType = {
|
||||
show?: boolean
|
||||
variables?: Option[]
|
||||
@@ -73,6 +81,21 @@ export type WorkflowVariableBlockType = {
|
||||
onManageInputField?: () => void
|
||||
}
|
||||
|
||||
export type HITLInputBlockType = {
|
||||
show?: boolean
|
||||
nodeId: string
|
||||
formInputs?: FormInputItem[]
|
||||
variables?: NodeOutPutVar[]
|
||||
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'>>
|
||||
getVarType?: GetVarType
|
||||
onFormInputsChange?: (inputs: FormInputItem[]) => void
|
||||
onFormInputItemRemove: (varName: string) => void
|
||||
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export type MenuTextMatch = {
|
||||
leadOffset: number
|
||||
matchingString: string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
|
||||
import { Plan } from '../type'
|
||||
import PlanComp from './index'
|
||||
@@ -188,7 +188,9 @@ describe('PlanComp', () => {
|
||||
expect(lastCall.onCancel).toBeDefined()
|
||||
|
||||
// Call onConfirm to close modal
|
||||
lastCall.onConfirm()
|
||||
lastCall.onCancel()
|
||||
act(() => {
|
||||
lastCall.onConfirm()
|
||||
lastCall.onCancel()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -118,6 +118,7 @@ export type CurrentPlanInfoBackend = {
|
||||
knowledge_pipeline: {
|
||||
publish_enabled: boolean
|
||||
}
|
||||
human_input_email_delivery_enabled: boolean
|
||||
}
|
||||
|
||||
export type SubscriptionItem = {
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('billing utils', () => {
|
||||
knowledge_pipeline: {
|
||||
publish_enabled: false,
|
||||
},
|
||||
human_input_email_delivery_enabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
|
||||
@@ -216,13 +216,22 @@ describe('image-uploader utils', () => {
|
||||
type FileCallback = (file: MockFile) => void
|
||||
type EntriesCallback = (entries: FileSystemEntry[]) => void
|
||||
|
||||
// Helper to create mock FileSystemEntry with required properties
|
||||
const createMockEntry = (props: {
|
||||
isFile: boolean
|
||||
isDirectory: boolean
|
||||
name?: string
|
||||
file?: (callback: FileCallback) => void
|
||||
createReader?: () => { readEntries: (callback: EntriesCallback) => void }
|
||||
}): FileSystemEntry => props as unknown as FileSystemEntry
|
||||
|
||||
it('should resolve with file array for file entry', async () => {
|
||||
const mockFile: MockFile = { name: 'test.png' }
|
||||
const mockEntry = {
|
||||
const mockEntry = createMockEntry({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
file: (callback: FileCallback) => callback(mockFile),
|
||||
}
|
||||
})
|
||||
|
||||
const result = await traverseFileEntry(mockEntry)
|
||||
expect(result).toHaveLength(1)
|
||||
@@ -232,11 +241,11 @@ describe('image-uploader utils', () => {
|
||||
|
||||
it('should resolve with file array with prefix for nested file', async () => {
|
||||
const mockFile: MockFile = { name: 'test.png' }
|
||||
const mockEntry = {
|
||||
const mockEntry = createMockEntry({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
file: (callback: FileCallback) => callback(mockFile),
|
||||
}
|
||||
})
|
||||
|
||||
const result = await traverseFileEntry(mockEntry, 'folder/')
|
||||
expect(result).toHaveLength(1)
|
||||
@@ -244,24 +253,24 @@ describe('image-uploader utils', () => {
|
||||
})
|
||||
|
||||
it('should resolve empty array for unknown entry type', async () => {
|
||||
const mockEntry = {
|
||||
const mockEntry = createMockEntry({
|
||||
isFile: false,
|
||||
isDirectory: false,
|
||||
}
|
||||
})
|
||||
|
||||
const result = await traverseFileEntry(mockEntry)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle directory with no files', async () => {
|
||||
const mockEntry = {
|
||||
const mockEntry = createMockEntry({
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name: 'empty-folder',
|
||||
createReader: () => ({
|
||||
readEntries: (callback: EntriesCallback) => callback([]),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const result = await traverseFileEntry(mockEntry)
|
||||
expect(result).toEqual([])
|
||||
@@ -271,20 +280,20 @@ describe('image-uploader utils', () => {
|
||||
const mockFile1: MockFile = { name: 'file1.png' }
|
||||
const mockFile2: MockFile = { name: 'file2.png' }
|
||||
|
||||
const mockFileEntry1 = {
|
||||
const mockFileEntry1 = createMockEntry({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
file: (callback: FileCallback) => callback(mockFile1),
|
||||
}
|
||||
})
|
||||
|
||||
const mockFileEntry2 = {
|
||||
const mockFileEntry2 = createMockEntry({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
file: (callback: FileCallback) => callback(mockFile2),
|
||||
}
|
||||
})
|
||||
|
||||
let readCount = 0
|
||||
const mockEntry = {
|
||||
const mockEntry = createMockEntry({
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name: 'folder',
|
||||
@@ -292,14 +301,14 @@ describe('image-uploader utils', () => {
|
||||
readEntries: (callback: EntriesCallback) => {
|
||||
if (readCount === 0) {
|
||||
readCount++
|
||||
callback([mockFileEntry1, mockFileEntry2] as unknown as FileSystemEntry[])
|
||||
callback([mockFileEntry1, mockFileEntry2])
|
||||
}
|
||||
else {
|
||||
callback([])
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const result = await traverseFileEntry(mockEntry)
|
||||
expect(result).toHaveLength(2)
|
||||
|
||||
@@ -18,17 +18,17 @@ type FileWithPath = {
|
||||
relativePath?: string
|
||||
} & File
|
||||
|
||||
export const traverseFileEntry = (entry: any, prefix = ''): Promise<FileWithPath[]> => {
|
||||
export const traverseFileEntry = (entry: FileSystemEntry, prefix = ''): Promise<FileWithPath[]> => {
|
||||
return new Promise((resolve) => {
|
||||
if (entry.isFile) {
|
||||
entry.file((file: FileWithPath) => {
|
||||
(entry as FileSystemFileEntry).file((file: FileWithPath) => {
|
||||
file.relativePath = `${prefix}${file.name}`
|
||||
resolve([file])
|
||||
})
|
||||
}
|
||||
else if (entry.isDirectory) {
|
||||
const reader = entry.createReader()
|
||||
const entries: any[] = []
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader()
|
||||
const entries: FileSystemEntry[] = []
|
||||
const read = () => {
|
||||
reader.readEntries(async (results: FileSystemEntry[]) => {
|
||||
if (!results.length) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import {
|
||||
DSLImportMode,
|
||||
DSLImportStatus,
|
||||
} from '@/models/app'
|
||||
import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '@/service/use-pipeline'
|
||||
|
||||
export enum CreateFromDSLModalTab {
|
||||
FROM_FILE = 'from-file',
|
||||
FROM_URL = 'from-url',
|
||||
}
|
||||
|
||||
export type UseDSLImportOptions = {
|
||||
activeTab?: CreateFromDSLModalTab
|
||||
dslUrl?: string
|
||||
onSuccess?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export type DSLVersions = {
|
||||
importedVersion: string
|
||||
systemVersion: string
|
||||
}
|
||||
|
||||
export const useDSLImport = ({
|
||||
activeTab = CreateFromDSLModalTab.FROM_FILE,
|
||||
dslUrl = '',
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: UseDSLImportOptions) => {
|
||||
const { push } = useRouter()
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
|
||||
const [currentFile, setDSLFile] = useState<File>()
|
||||
const [fileContent, setFileContent] = useState<string>()
|
||||
const [currentTab, setCurrentTab] = useState(activeTab)
|
||||
const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false)
|
||||
const [versions, setVersions] = useState<DSLVersions>()
|
||||
const [importId, setImportId] = useState<string>()
|
||||
const [isConfirming, setIsConfirming] = useState(false)
|
||||
|
||||
const { handleCheckPluginDependencies } = usePluginDependencies()
|
||||
const isCreatingRef = useRef(false)
|
||||
|
||||
const { mutateAsync: importDSL } = useImportPipelineDSL()
|
||||
const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm()
|
||||
|
||||
const readFile = useCallback((file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result
|
||||
setFileContent(content as string)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}, [])
|
||||
|
||||
const handleFile = useCallback((file?: File) => {
|
||||
setDSLFile(file)
|
||||
if (file)
|
||||
readFile(file)
|
||||
if (!file)
|
||||
setFileContent('')
|
||||
}, [readFile])
|
||||
|
||||
const onCreate = useCallback(async () => {
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
|
||||
return
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue)
|
||||
return
|
||||
if (isCreatingRef.current)
|
||||
return
|
||||
|
||||
isCreatingRef.current = true
|
||||
|
||||
let response
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE) {
|
||||
response = await importDSL({
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: fileContent || '',
|
||||
})
|
||||
}
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_URL) {
|
||||
response = await importDSL({
|
||||
mode: DSLImportMode.YAML_URL,
|
||||
yaml_url: dslUrlValue || '',
|
||||
})
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
|
||||
isCreatingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const { id, status, pipeline_id, dataset_id, imported_dsl_version, current_dsl_version } = response
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
onSuccess?.()
|
||||
onClose?.()
|
||||
|
||||
notify({
|
||||
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
|
||||
message: t(status === DSLImportStatus.COMPLETED ? 'creation.successTip' : 'creation.caution', { ns: 'datasetPipeline' }),
|
||||
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }),
|
||||
})
|
||||
|
||||
if (pipeline_id)
|
||||
await handleCheckPluginDependencies(pipeline_id, true)
|
||||
|
||||
push(`/datasets/${dataset_id}/pipeline`)
|
||||
isCreatingRef.current = false
|
||||
}
|
||||
else if (status === DSLImportStatus.PENDING) {
|
||||
setVersions({
|
||||
importedVersion: imported_dsl_version ?? '',
|
||||
systemVersion: current_dsl_version ?? '',
|
||||
})
|
||||
onClose?.()
|
||||
setTimeout(() => {
|
||||
setShowConfirmModal(true)
|
||||
}, 300)
|
||||
setImportId(id)
|
||||
isCreatingRef.current = false
|
||||
}
|
||||
else {
|
||||
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
|
||||
isCreatingRef.current = false
|
||||
}
|
||||
}, [
|
||||
currentTab,
|
||||
currentFile,
|
||||
dslUrlValue,
|
||||
fileContent,
|
||||
importDSL,
|
||||
notify,
|
||||
t,
|
||||
onSuccess,
|
||||
onClose,
|
||||
handleCheckPluginDependencies,
|
||||
push,
|
||||
])
|
||||
|
||||
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
|
||||
|
||||
const onDSLConfirm = useCallback(async () => {
|
||||
if (!importId)
|
||||
return
|
||||
|
||||
setIsConfirming(true)
|
||||
const response = await importDSLConfirm(importId)
|
||||
setIsConfirming(false)
|
||||
|
||||
if (!response) {
|
||||
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
|
||||
return
|
||||
}
|
||||
|
||||
const { status, pipeline_id, dataset_id } = response
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
onSuccess?.()
|
||||
setShowConfirmModal(false)
|
||||
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('creation.successTip', { ns: 'datasetPipeline' }),
|
||||
})
|
||||
|
||||
if (pipeline_id)
|
||||
await handleCheckPluginDependencies(pipeline_id, true)
|
||||
|
||||
push(`/datasets/${dataset_id}/pipeline`)
|
||||
}
|
||||
else if (status === DSLImportStatus.FAILED) {
|
||||
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
|
||||
}
|
||||
}, [importId, importDSLConfirm, notify, t, onSuccess, handleCheckPluginDependencies, push])
|
||||
|
||||
const handleCancelConfirm = useCallback(() => {
|
||||
setShowConfirmModal(false)
|
||||
}, [])
|
||||
|
||||
const buttonDisabled = useMemo(() => {
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE)
|
||||
return !currentFile
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_URL)
|
||||
return !dslUrlValue
|
||||
return false
|
||||
}, [currentTab, currentFile, dslUrlValue])
|
||||
|
||||
return {
|
||||
// State
|
||||
currentFile,
|
||||
currentTab,
|
||||
dslUrlValue,
|
||||
showConfirmModal,
|
||||
versions,
|
||||
buttonDisabled,
|
||||
isConfirming,
|
||||
|
||||
// Actions
|
||||
setCurrentTab,
|
||||
setDslUrlValue,
|
||||
handleFile,
|
||||
handleCreateApp,
|
||||
onDSLConfirm,
|
||||
handleCancelConfirm,
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
'use client'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import {
|
||||
DSLImportMode,
|
||||
DSLImportStatus,
|
||||
} from '@/models/app'
|
||||
import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '@/service/use-pipeline'
|
||||
import DSLConfirmModal from './dsl-confirm-modal'
|
||||
import Header from './header'
|
||||
import { CreateFromDSLModalTab, useDSLImport } from './hooks/use-dsl-import'
|
||||
import Tab from './tab'
|
||||
import Uploader from './uploader'
|
||||
|
||||
export { CreateFromDSLModalTab }
|
||||
|
||||
type CreateFromDSLModalProps = {
|
||||
show: boolean
|
||||
onSuccess?: () => void
|
||||
@@ -27,11 +21,6 @@ type CreateFromDSLModalProps = {
|
||||
dslUrl?: string
|
||||
}
|
||||
|
||||
export enum CreateFromDSLModalTab {
|
||||
FROM_FILE = 'from-file',
|
||||
FROM_URL = 'from-url',
|
||||
}
|
||||
|
||||
const CreateFromDSLModal = ({
|
||||
show,
|
||||
onSuccess,
|
||||
@@ -39,149 +28,33 @@ const CreateFromDSLModal = ({
|
||||
activeTab = CreateFromDSLModalTab.FROM_FILE,
|
||||
dslUrl = '',
|
||||
}: CreateFromDSLModalProps) => {
|
||||
const { push } = useRouter()
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [currentFile, setDSLFile] = useState<File>()
|
||||
const [fileContent, setFileContent] = useState<string>()
|
||||
const [currentTab, setCurrentTab] = useState(activeTab)
|
||||
const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
|
||||
const [showErrorModal, setShowErrorModal] = useState(false)
|
||||
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
|
||||
const [importId, setImportId] = useState<string>()
|
||||
const { handleCheckPluginDependencies } = usePluginDependencies()
|
||||
|
||||
const readFile = (file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = function (event) {
|
||||
const content = event.target?.result
|
||||
setFileContent(content as string)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const handleFile = (file?: File) => {
|
||||
setDSLFile(file)
|
||||
if (file)
|
||||
readFile(file)
|
||||
if (!file)
|
||||
setFileContent('')
|
||||
}
|
||||
|
||||
const isCreatingRef = useRef(false)
|
||||
|
||||
const { mutateAsync: importDSL } = useImportPipelineDSL()
|
||||
|
||||
const onCreate = async () => {
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
|
||||
return
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue)
|
||||
return
|
||||
if (isCreatingRef.current)
|
||||
return
|
||||
isCreatingRef.current = true
|
||||
let response
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE) {
|
||||
response = await importDSL({
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: fileContent || '',
|
||||
})
|
||||
}
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_URL) {
|
||||
response = await importDSL({
|
||||
mode: DSLImportMode.YAML_URL,
|
||||
yaml_url: dslUrlValue || '',
|
||||
})
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
|
||||
isCreatingRef.current = false
|
||||
return
|
||||
}
|
||||
const { id, status, pipeline_id, dataset_id, imported_dsl_version, current_dsl_version } = response
|
||||
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
if (onClose)
|
||||
onClose()
|
||||
|
||||
notify({
|
||||
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
|
||||
message: t(status === DSLImportStatus.COMPLETED ? 'creation.successTip' : 'creation.caution', { ns: 'datasetPipeline' }),
|
||||
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }),
|
||||
})
|
||||
if (pipeline_id)
|
||||
await handleCheckPluginDependencies(pipeline_id, true)
|
||||
push(`/datasets/${dataset_id}/pipeline`)
|
||||
isCreatingRef.current = false
|
||||
}
|
||||
else if (status === DSLImportStatus.PENDING) {
|
||||
setVersions({
|
||||
importedVersion: imported_dsl_version ?? '',
|
||||
systemVersion: current_dsl_version ?? '',
|
||||
})
|
||||
if (onClose)
|
||||
onClose()
|
||||
setTimeout(() => {
|
||||
setShowErrorModal(true)
|
||||
}, 300)
|
||||
setImportId(id)
|
||||
isCreatingRef.current = false
|
||||
}
|
||||
else {
|
||||
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
|
||||
isCreatingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
|
||||
|
||||
useKeyPress('esc', () => {
|
||||
if (show && !showErrorModal)
|
||||
onClose()
|
||||
const {
|
||||
currentFile,
|
||||
currentTab,
|
||||
dslUrlValue,
|
||||
showConfirmModal,
|
||||
versions,
|
||||
buttonDisabled,
|
||||
isConfirming,
|
||||
setCurrentTab,
|
||||
setDslUrlValue,
|
||||
handleFile,
|
||||
handleCreateApp,
|
||||
onDSLConfirm,
|
||||
handleCancelConfirm,
|
||||
} = useDSLImport({
|
||||
activeTab,
|
||||
dslUrl,
|
||||
onSuccess,
|
||||
onClose,
|
||||
})
|
||||
|
||||
const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm()
|
||||
|
||||
const onDSLConfirm = async () => {
|
||||
if (!importId)
|
||||
return
|
||||
const response = await importDSLConfirm(importId)
|
||||
|
||||
if (!response) {
|
||||
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
|
||||
return
|
||||
}
|
||||
|
||||
const { status, pipeline_id, dataset_id } = response
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
if (onClose)
|
||||
onClose()
|
||||
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('creation.successTip', { ns: 'datasetPipeline' }),
|
||||
})
|
||||
if (pipeline_id)
|
||||
await handleCheckPluginDependencies(pipeline_id, true)
|
||||
push(`datasets/${dataset_id}/pipeline`)
|
||||
}
|
||||
else if (status === DSLImportStatus.FAILED) {
|
||||
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
|
||||
}
|
||||
}
|
||||
|
||||
const buttonDisabled = useMemo(() => {
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE)
|
||||
return !currentFile
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_URL)
|
||||
return !dslUrlValue
|
||||
return false
|
||||
}, [currentTab, currentFile, dslUrlValue])
|
||||
useKeyPress('esc', () => {
|
||||
if (show && !showConfirmModal)
|
||||
onClose()
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -196,29 +69,25 @@ const CreateFromDSLModal = ({
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>
|
||||
<div className="px-6 py-4">
|
||||
{
|
||||
currentTab === CreateFromDSLModalTab.FROM_FILE && (
|
||||
<Uploader
|
||||
className="mt-0"
|
||||
file={currentFile}
|
||||
updateFile={handleFile}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
currentTab === CreateFromDSLModalTab.FROM_URL && (
|
||||
<div>
|
||||
<div className="system-md-semibold leading6 mb-1 text-text-secondary">
|
||||
DSL URL
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
|
||||
value={dslUrlValue}
|
||||
onChange={e => setDslUrlValue(e.target.value)}
|
||||
/>
|
||||
{currentTab === CreateFromDSLModalTab.FROM_FILE && (
|
||||
<Uploader
|
||||
className="mt-0"
|
||||
file={currentFile}
|
||||
updateFile={handleFile}
|
||||
/>
|
||||
)}
|
||||
{currentTab === CreateFromDSLModalTab.FROM_URL && (
|
||||
<div>
|
||||
<div className="system-md-semibold leading6 mb-1 text-text-secondary">
|
||||
DSL URL
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<Input
|
||||
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
|
||||
value={dslUrlValue}
|
||||
onChange={e => setDslUrlValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-x-2 p-6 pt-5">
|
||||
<Button onClick={onClose}>
|
||||
@@ -234,32 +103,14 @@ const CreateFromDSLModal = ({
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
isShow={showErrorModal}
|
||||
onClose={() => setShowErrorModal(false)}
|
||||
className="w-[480px]"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
|
||||
<div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
|
||||
<div className="system-md-regular flex grow flex-col text-text-secondary">
|
||||
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
|
||||
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
|
||||
<br />
|
||||
<div>
|
||||
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
|
||||
<span className="system-md-medium">{versions?.importedVersion}</span>
|
||||
</div>
|
||||
<div>
|
||||
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
|
||||
<span className="system-md-medium">{versions?.systemVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
|
||||
<Button variant="secondary" onClick={() => setShowErrorModal(false)}>{t('newApp.Cancel', { ns: 'app' })}</Button>
|
||||
<Button variant="primary" destructive onClick={onDSLConfirm}>{t('newApp.Confirm', { ns: 'app' })}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
{showConfirmModal && (
|
||||
<DSLConfirmModal
|
||||
versions={versions}
|
||||
onCancel={handleCancelConfirm}
|
||||
onConfirm={onDSLConfirm}
|
||||
confirmDisabled={isConfirming}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import type { FileListItemProps } from './file-list-item'
|
||||
import type { CustomFile as File, FileItem } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants'
|
||||
import FileListItem from './file-list-item'
|
||||
|
||||
// Mock theme hook - can be changed per test
|
||||
let mockTheme = 'light'
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: mockTheme }),
|
||||
}))
|
||||
|
||||
// Mock theme types
|
||||
vi.mock('@/types/app', () => ({
|
||||
Theme: { dark: 'dark', light: 'light' },
|
||||
}))
|
||||
|
||||
// Mock SimplePieChart with dynamic import handling
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: () => {
|
||||
const DynamicComponent = ({ percentage, stroke, fill }: { percentage: number, stroke: string, fill: string }) => (
|
||||
<div data-testid="pie-chart" data-percentage={percentage} data-stroke={stroke} data-fill={fill}>
|
||||
Pie Chart:
|
||||
{' '}
|
||||
{percentage}
|
||||
%
|
||||
</div>
|
||||
)
|
||||
DynamicComponent.displayName = 'SimplePieChart'
|
||||
return DynamicComponent
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock DocumentFileIcon
|
||||
vi.mock('@/app/components/datasets/common/document-file-icon', () => ({
|
||||
default: ({ name, extension, size }: { name: string, extension: string, size: string }) => (
|
||||
<div data-testid="document-icon" data-name={name} data-extension={extension} data-size={size}>
|
||||
Document Icon
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('FileListItem', () => {
|
||||
const createMockFile = (overrides: Partial<File> = {}): File => ({
|
||||
name: 'test-document.pdf',
|
||||
size: 1024 * 100, // 100KB
|
||||
type: 'application/pdf',
|
||||
lastModified: Date.now(),
|
||||
...overrides,
|
||||
} as File)
|
||||
|
||||
const createMockFileItem = (overrides: Partial<FileItem> = {}): FileItem => ({
|
||||
fileID: 'file-123',
|
||||
file: createMockFile(overrides.file as Partial<File>),
|
||||
progress: PROGRESS_NOT_STARTED,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const defaultProps: FileListItemProps = {
|
||||
fileItem: createMockFileItem(),
|
||||
onPreview: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the file item container', () => {
|
||||
const { container } = render(<FileListItem {...defaultProps} />)
|
||||
const item = container.firstChild as HTMLElement
|
||||
expect(item).toHaveClass('flex', 'h-12', 'items-center', 'rounded-lg')
|
||||
})
|
||||
|
||||
it('should render document icon with correct props', () => {
|
||||
render(<FileListItem {...defaultProps} />)
|
||||
const icon = screen.getByTestId('document-icon')
|
||||
expect(icon).toBeInTheDocument()
|
||||
expect(icon).toHaveAttribute('data-name', 'test-document.pdf')
|
||||
expect(icon).toHaveAttribute('data-extension', 'pdf')
|
||||
expect(icon).toHaveAttribute('data-size', 'xl')
|
||||
})
|
||||
|
||||
it('should render file name', () => {
|
||||
render(<FileListItem {...defaultProps} />)
|
||||
expect(screen.getByText('test-document.pdf')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file extension in uppercase via CSS class', () => {
|
||||
render(<FileListItem {...defaultProps} />)
|
||||
const extensionSpan = screen.getByText('pdf')
|
||||
expect(extensionSpan).toBeInTheDocument()
|
||||
expect(extensionSpan).toHaveClass('uppercase')
|
||||
})
|
||||
|
||||
it('should render file size', () => {
|
||||
render(<FileListItem {...defaultProps} />)
|
||||
// Default mock file is 100KB (1024 * 100 bytes)
|
||||
expect(screen.getByText('100.00 KB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete button', () => {
|
||||
const { container } = render(<FileListItem {...defaultProps} />)
|
||||
const deleteButton = container.querySelector('.cursor-pointer')
|
||||
expect(deleteButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('progress states', () => {
|
||||
it('should show progress chart when uploading (0-99)', () => {
|
||||
const fileItem = createMockFileItem({ progress: 50 })
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
|
||||
const pieChart = screen.getByTestId('pie-chart')
|
||||
expect(pieChart).toBeInTheDocument()
|
||||
expect(pieChart).toHaveAttribute('data-percentage', '50')
|
||||
})
|
||||
|
||||
it('should show progress chart at 0%', () => {
|
||||
const fileItem = createMockFileItem({ progress: 0 })
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
|
||||
const pieChart = screen.getByTestId('pie-chart')
|
||||
expect(pieChart).toHaveAttribute('data-percentage', '0')
|
||||
})
|
||||
|
||||
it('should not show progress chart when complete (100)', () => {
|
||||
const fileItem = createMockFileItem({ progress: PROGRESS_COMPLETE })
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
|
||||
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show progress chart when not started (-1)', () => {
|
||||
const fileItem = createMockFileItem({ progress: PROGRESS_NOT_STARTED })
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
|
||||
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error state', () => {
|
||||
it('should show error indicator when progress is PROGRESS_ERROR', () => {
|
||||
const fileItem = createMockFileItem({ progress: PROGRESS_ERROR })
|
||||
const { container } = render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
|
||||
const errorIndicator = container.querySelector('.text-text-destructive')
|
||||
expect(errorIndicator).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show error indicator when not in error state', () => {
|
||||
const { container } = render(<FileListItem {...defaultProps} />)
|
||||
const errorIndicator = container.querySelector('.text-text-destructive')
|
||||
expect(errorIndicator).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('theme handling', () => {
|
||||
it('should use correct chart color for light theme', () => {
|
||||
mockTheme = 'light'
|
||||
const fileItem = createMockFileItem({ progress: 50 })
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
|
||||
const pieChart = screen.getByTestId('pie-chart')
|
||||
expect(pieChart).toHaveAttribute('data-stroke', '#296dff')
|
||||
expect(pieChart).toHaveAttribute('data-fill', '#296dff')
|
||||
})
|
||||
|
||||
it('should use correct chart color for dark theme', () => {
|
||||
mockTheme = 'dark'
|
||||
const fileItem = createMockFileItem({ progress: 50 })
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
|
||||
const pieChart = screen.getByTestId('pie-chart')
|
||||
expect(pieChart).toHaveAttribute('data-stroke', '#5289ff')
|
||||
expect(pieChart).toHaveAttribute('data-fill', '#5289ff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('event handlers', () => {
|
||||
it('should call onPreview when item is clicked with file id', () => {
|
||||
const onPreview = vi.fn()
|
||||
const fileItem = createMockFileItem({
|
||||
file: createMockFile({ id: 'uploaded-id' } as Partial<File>),
|
||||
})
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} onPreview={onPreview} />)
|
||||
|
||||
const item = screen.getByText('test-document.pdf').closest('[class*="flex h-12"]')!
|
||||
fireEvent.click(item)
|
||||
|
||||
expect(onPreview).toHaveBeenCalledTimes(1)
|
||||
expect(onPreview).toHaveBeenCalledWith(fileItem.file)
|
||||
})
|
||||
|
||||
it('should not call onPreview when file has no id', () => {
|
||||
const onPreview = vi.fn()
|
||||
const fileItem = createMockFileItem()
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} onPreview={onPreview} />)
|
||||
|
||||
const item = screen.getByText('test-document.pdf').closest('[class*="flex h-12"]')!
|
||||
fireEvent.click(item)
|
||||
|
||||
expect(onPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onRemove when delete button is clicked', () => {
|
||||
const onRemove = vi.fn()
|
||||
const fileItem = createMockFileItem()
|
||||
const { container } = render(<FileListItem {...defaultProps} fileItem={fileItem} onRemove={onRemove} />)
|
||||
|
||||
const deleteButton = container.querySelector('.cursor-pointer')!
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(onRemove).toHaveBeenCalledTimes(1)
|
||||
expect(onRemove).toHaveBeenCalledWith('file-123')
|
||||
})
|
||||
|
||||
it('should stop propagation when delete button is clicked', () => {
|
||||
const onPreview = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
const fileItem = createMockFileItem({
|
||||
file: createMockFile({ id: 'uploaded-id' } as Partial<File>),
|
||||
})
|
||||
const { container } = render(<FileListItem {...defaultProps} fileItem={fileItem} onPreview={onPreview} onRemove={onRemove} />)
|
||||
|
||||
const deleteButton = container.querySelector('.cursor-pointer')!
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(onRemove).toHaveBeenCalledTimes(1)
|
||||
expect(onPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('file type handling', () => {
|
||||
it('should handle files with multiple dots in name', () => {
|
||||
const fileItem = createMockFileItem({
|
||||
file: createMockFile({ name: 'my.document.file.docx' }),
|
||||
})
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
|
||||
expect(screen.getByText('my.document.file.docx')).toBeInTheDocument()
|
||||
expect(screen.getByText('docx')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle files without extension', () => {
|
||||
const fileItem = createMockFileItem({
|
||||
file: createMockFile({ name: 'README' }),
|
||||
})
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
|
||||
// File name appears once, and extension area shows empty string
|
||||
expect(screen.getByText('README')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle various file extensions', () => {
|
||||
const extensions = ['txt', 'md', 'json', 'csv', 'xlsx']
|
||||
|
||||
extensions.forEach((ext) => {
|
||||
const fileItem = createMockFileItem({
|
||||
file: createMockFile({ name: `file.${ext}` }),
|
||||
})
|
||||
const { unmount } = render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
expect(screen.getByText(ext)).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('file size display', () => {
|
||||
it('should display size in KB for small files', () => {
|
||||
const fileItem = createMockFileItem({
|
||||
file: createMockFile({ size: 5 * 1024 }),
|
||||
})
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
expect(screen.getByText('5.00 KB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display size in MB for larger files', () => {
|
||||
const fileItem = createMockFileItem({
|
||||
file: createMockFile({ size: 5 * 1024 * 1024 }),
|
||||
})
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
expect(screen.getByText('5.00 MB')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload progress values', () => {
|
||||
it('should show chart at progress 1', () => {
|
||||
const fileItem = createMockFileItem({ progress: 1 })
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
expect(screen.getByTestId('pie-chart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show chart at progress 99', () => {
|
||||
const fileItem = createMockFileItem({ progress: 99 })
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
expect(screen.getByTestId('pie-chart')).toHaveAttribute('data-percentage', '99')
|
||||
})
|
||||
|
||||
it('should not show chart at progress 100', () => {
|
||||
const fileItem = createMockFileItem({ progress: 100 })
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('styling', () => {
|
||||
it('should have proper shadow styling', () => {
|
||||
const { container } = render(<FileListItem {...defaultProps} />)
|
||||
const item = container.firstChild as HTMLElement
|
||||
expect(item).toHaveClass('shadow-xs')
|
||||
})
|
||||
|
||||
it('should have proper border styling', () => {
|
||||
const { container } = render(<FileListItem {...defaultProps} />)
|
||||
const item = container.firstChild as HTMLElement
|
||||
expect(item).toHaveClass('border', 'border-components-panel-border')
|
||||
})
|
||||
|
||||
it('should truncate long file names', () => {
|
||||
const longFileName = 'this-is-a-very-long-file-name-that-should-be-truncated.pdf'
|
||||
const fileItem = createMockFileItem({
|
||||
file: createMockFile({ name: longFileName }),
|
||||
})
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
|
||||
const nameElement = screen.getByText(longFileName)
|
||||
expect(nameElement).toHaveClass('truncate')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
import type { CustomFile as File, FileItem } from '@/models/datasets'
|
||||
import { RiDeleteBinLine, RiErrorWarningFill } from '@remixicon/react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useMemo } from 'react'
|
||||
import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import { formatFileSize, getFileExtension } from '@/utils/format'
|
||||
import { PROGRESS_COMPLETE, PROGRESS_ERROR } from '../constants'
|
||||
|
||||
const SimplePieChart = dynamic(() => import('@/app/components/base/simple-pie-chart'), { ssr: false })
|
||||
|
||||
export type FileListItemProps = {
|
||||
fileItem: FileItem
|
||||
onPreview: (file: File) => void
|
||||
onRemove: (fileID: string) => void
|
||||
}
|
||||
|
||||
const FileListItem = ({
|
||||
fileItem,
|
||||
onPreview,
|
||||
onRemove,
|
||||
}: FileListItemProps) => {
|
||||
const { theme } = useTheme()
|
||||
const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme])
|
||||
|
||||
const isUploading = fileItem.progress >= 0 && fileItem.progress < PROGRESS_COMPLETE
|
||||
const isError = fileItem.progress === PROGRESS_ERROR
|
||||
|
||||
const handleClick = () => {
|
||||
if (fileItem.file?.id)
|
||||
onPreview(fileItem.file)
|
||||
}
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onRemove(fileItem.fileID)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className="flex h-12 max-w-[640px] items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg text-xs leading-3 text-text-tertiary shadow-xs"
|
||||
>
|
||||
<div className="flex w-12 shrink-0 items-center justify-center">
|
||||
<DocumentFileIcon
|
||||
size="xl"
|
||||
className="shrink-0"
|
||||
name={fileItem.file.name}
|
||||
extension={getFileExtension(fileItem.file.name)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink grow flex-col gap-0.5">
|
||||
<div className="flex w-full">
|
||||
<div className="w-0 grow truncate text-sm leading-4 text-text-secondary">
|
||||
{fileItem.file.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full truncate leading-3 text-text-tertiary">
|
||||
<span className="uppercase">{getFileExtension(fileItem.file.name)}</span>
|
||||
<span className="px-1 text-text-quaternary">·</span>
|
||||
<span>{formatFileSize(fileItem.file.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-16 shrink-0 items-center justify-end gap-1 pr-3">
|
||||
{isUploading && (
|
||||
<SimplePieChart
|
||||
percentage={fileItem.progress}
|
||||
stroke={chartColor}
|
||||
fill={chartColor}
|
||||
animationDuration={0}
|
||||
/>
|
||||
)}
|
||||
{isError && (
|
||||
<RiErrorWarningFill className="size-4 text-text-destructive" />
|
||||
)}
|
||||
<span
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center"
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<RiDeleteBinLine className="size-4 text-text-tertiary" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileListItem
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { UploadDropzoneProps } from './upload-dropzone'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import UploadDropzone from './upload-dropzone'
|
||||
|
||||
// Helper to create mock ref objects for testing
|
||||
const createMockRef = <T,>(value: T | null = null): RefObject<T | null> => ({ current: value })
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'stepOne.uploader.button': 'Drag and drop files, or',
|
||||
'stepOne.uploader.buttonSingleFile': 'Drag and drop file, or',
|
||||
'stepOne.uploader.browse': 'Browse',
|
||||
'stepOne.uploader.tip': 'Supports {{supportTypes}}, Max {{size}}MB each, up to {{batchCount}} files at a time, {{totalCount}} files total',
|
||||
}
|
||||
let result = translations[key] || key
|
||||
if (options && typeof options === 'object') {
|
||||
Object.entries(options).forEach(([k, v]) => {
|
||||
result = result.replace(`{{${k}}}`, String(v))
|
||||
})
|
||||
}
|
||||
return result
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('UploadDropzone', () => {
|
||||
const defaultProps: UploadDropzoneProps = {
|
||||
dropRef: createMockRef<HTMLDivElement>() as RefObject<HTMLDivElement | null>,
|
||||
dragRef: createMockRef<HTMLDivElement>() as RefObject<HTMLDivElement | null>,
|
||||
fileUploaderRef: createMockRef<HTMLInputElement>() as RefObject<HTMLInputElement | null>,
|
||||
dragging: false,
|
||||
supportBatchUpload: true,
|
||||
supportTypesShowNames: 'PDF, DOCX, TXT',
|
||||
fileUploadConfig: {
|
||||
file_size_limit: 15,
|
||||
batch_count_limit: 5,
|
||||
file_upload_limit: 10,
|
||||
},
|
||||
acceptTypes: ['.pdf', '.docx', '.txt'],
|
||||
onSelectFile: vi.fn(),
|
||||
onFileChange: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the dropzone container', () => {
|
||||
const { container } = render(<UploadDropzone {...defaultProps} />)
|
||||
const dropzone = container.querySelector('[class*="border-dashed"]')
|
||||
expect(dropzone).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render hidden file input', () => {
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
const input = document.getElementById('fileUploader') as HTMLInputElement
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveClass('hidden')
|
||||
expect(input).toHaveAttribute('type', 'file')
|
||||
})
|
||||
|
||||
it('should render upload icon', () => {
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
const icon = document.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render browse label when extensions are allowed', () => {
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
expect(screen.getByText('Browse')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render browse label when no extensions allowed', () => {
|
||||
render(<UploadDropzone {...defaultProps} acceptTypes={[]} />)
|
||||
expect(screen.queryByText('Browse')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file size and count limits', () => {
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
const tipText = screen.getByText(/Supports.*Max.*15MB/i)
|
||||
expect(tipText).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('file input configuration', () => {
|
||||
it('should allow multiple files when supportBatchUpload is true', () => {
|
||||
render(<UploadDropzone {...defaultProps} supportBatchUpload={true} />)
|
||||
const input = document.getElementById('fileUploader') as HTMLInputElement
|
||||
expect(input).toHaveAttribute('multiple')
|
||||
})
|
||||
|
||||
it('should not allow multiple files when supportBatchUpload is false', () => {
|
||||
render(<UploadDropzone {...defaultProps} supportBatchUpload={false} />)
|
||||
const input = document.getElementById('fileUploader') as HTMLInputElement
|
||||
expect(input).not.toHaveAttribute('multiple')
|
||||
})
|
||||
|
||||
it('should set accept attribute with correct types', () => {
|
||||
render(<UploadDropzone {...defaultProps} acceptTypes={['.pdf', '.docx']} />)
|
||||
const input = document.getElementById('fileUploader') as HTMLInputElement
|
||||
expect(input).toHaveAttribute('accept', '.pdf,.docx')
|
||||
})
|
||||
})
|
||||
|
||||
describe('text content', () => {
|
||||
it('should show batch upload text when supportBatchUpload is true', () => {
|
||||
render(<UploadDropzone {...defaultProps} supportBatchUpload={true} />)
|
||||
expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show single file text when supportBatchUpload is false', () => {
|
||||
render(<UploadDropzone {...defaultProps} supportBatchUpload={false} />)
|
||||
expect(screen.getByText(/Drag and drop file/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dragging state', () => {
|
||||
it('should apply dragging styles when dragging is true', () => {
|
||||
const { container } = render(<UploadDropzone {...defaultProps} dragging={true} />)
|
||||
const dropzone = container.querySelector('[class*="border-components-dropzone-border-accent"]')
|
||||
expect(dropzone).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render drag overlay when dragging', () => {
|
||||
const dragRef = createMockRef<HTMLDivElement>()
|
||||
render(<UploadDropzone {...defaultProps} dragging={true} dragRef={dragRef as RefObject<HTMLDivElement | null>} />)
|
||||
const overlay = document.querySelector('.absolute.left-0.top-0')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render drag overlay when not dragging', () => {
|
||||
render(<UploadDropzone {...defaultProps} dragging={false} />)
|
||||
const overlay = document.querySelector('.absolute.left-0.top-0')
|
||||
expect(overlay).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('event handlers', () => {
|
||||
it('should call onSelectFile when browse label is clicked', () => {
|
||||
const onSelectFile = vi.fn()
|
||||
render(<UploadDropzone {...defaultProps} onSelectFile={onSelectFile} />)
|
||||
|
||||
const browseLabel = screen.getByText('Browse')
|
||||
fireEvent.click(browseLabel)
|
||||
|
||||
expect(onSelectFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onFileChange when files are selected', () => {
|
||||
const onFileChange = vi.fn()
|
||||
render(<UploadDropzone {...defaultProps} onFileChange={onFileChange} />)
|
||||
|
||||
const input = document.getElementById('fileUploader') as HTMLInputElement
|
||||
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' })
|
||||
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
|
||||
expect(onFileChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refs', () => {
|
||||
it('should attach dropRef to drop container', () => {
|
||||
const dropRef = createMockRef<HTMLDivElement>()
|
||||
render(<UploadDropzone {...defaultProps} dropRef={dropRef as RefObject<HTMLDivElement | null>} />)
|
||||
expect(dropRef.current).toBeInstanceOf(HTMLDivElement)
|
||||
})
|
||||
|
||||
it('should attach fileUploaderRef to input element', () => {
|
||||
const fileUploaderRef = createMockRef<HTMLInputElement>()
|
||||
render(<UploadDropzone {...defaultProps} fileUploaderRef={fileUploaderRef as RefObject<HTMLInputElement | null>} />)
|
||||
expect(fileUploaderRef.current).toBeInstanceOf(HTMLInputElement)
|
||||
})
|
||||
|
||||
it('should attach dragRef to overlay when dragging', () => {
|
||||
const dragRef = createMockRef<HTMLDivElement>()
|
||||
render(<UploadDropzone {...defaultProps} dragging={true} dragRef={dragRef as RefObject<HTMLDivElement | null>} />)
|
||||
expect(dragRef.current).toBeInstanceOf(HTMLDivElement)
|
||||
})
|
||||
})
|
||||
|
||||
describe('styling', () => {
|
||||
it('should have base dropzone styling', () => {
|
||||
const { container } = render(<UploadDropzone {...defaultProps} />)
|
||||
const dropzone = container.querySelector('[class*="border-dashed"]')
|
||||
expect(dropzone).toBeInTheDocument()
|
||||
expect(dropzone).toHaveClass('rounded-xl')
|
||||
})
|
||||
|
||||
it('should have cursor-pointer on browse label', () => {
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
const browseLabel = screen.getByText('Browse')
|
||||
expect(browseLabel).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have an accessible file input', () => {
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
const input = document.getElementById('fileUploader') as HTMLInputElement
|
||||
expect(input).toHaveAttribute('id', 'fileUploader')
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user