Browse Source

Merge branch 'main' into feat/rag-2

tags/2.0.0-beta.1
twwu 2 months ago
parent
commit
40f3524cfe
89 changed files with 5775 additions and 364 deletions
  1. 27
    0
      .github/ISSUE_TEMPLATE/chore.yaml
  2. 1
    0
      .github/workflows/build-push.yml
  3. 3
    2
      api/controllers/console/app/annotation.py
  4. 27
    10
      api/controllers/console/explore/installed_app.py
  5. 0
    0
      api/core/model_runtime/model_providers/__base/tokenizers/gpt2_tokenizer.py
  6. 1
    0
      api/core/plugin/impl/base.py
  7. 3
    1
      api/core/plugin/impl/exc.py
  8. 1
    10
      api/core/rag/splitter/fixed_text_splitter.py
  9. 0
    3
      api/core/tools/__base/tool.py
  10. 0
    2
      api/core/tools/builtin_tool/tool.py
  11. 0
    3
      api/core/tools/custom_tool/tool.py
  12. 1
    8
      api/core/tools/mcp_tool/tool.py
  13. 1
    6
      api/core/tools/plugin_tool/tool.py
  14. 0
    2
      api/core/tools/utils/dataset_retriever_tool.py
  15. 0
    9
      api/core/tools/workflow_as_tool/tool.py
  16. 4
    0
      api/extensions/ext_otel.py
  17. 13
    0
      api/models/workflow.py
  18. 2
    0
      api/pyproject.toml
  19. 135
    1
      api/services/clear_free_plan_tenant_expired_logs.py
  20. 10
    0
      api/services/enterprise/enterprise_service.py
  21. 0
    0
      api/tests/test_containers_integration_tests/services/__init__.py
  22. 3340
    0
      api/tests/test_containers_integration_tests/services/test_account_service.py
  23. 739
    0
      api/tests/test_containers_integration_tests/services/test_workflow_draft_variable_service.py
  24. 168
    0
      api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py
  25. 35
    1
      api/uv.lock
  26. 1
    1
      docker/docker-compose-template.yaml
  27. 1
    1
      docker/docker-compose.yaml
  28. 156
    0
      web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx
  29. 7
    20
      web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx
  30. 62
    64
      web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx
  31. 1
    3
      web/app/(commonLayout)/datasets/create/page.tsx
  32. 2
    1
      web/app/components/app/annotation/header-opts/index.tsx
  33. 2
    2
      web/app/components/apps/footer.tsx
  34. 5
    2
      web/app/components/base/chat/chat-with-history/hooks.tsx
  35. 3
    3
      web/app/components/base/chat/embedded-chatbot/hooks.tsx
  36. 2
    14
      web/app/components/base/file-uploader/hooks.ts
  37. 17
    0
      web/app/components/base/icons/utils.ts
  38. 4
    1
      web/app/components/base/tag-management/filter.tsx
  39. 2
    2
      web/app/components/datasets/list/doc.tsx
  40. 40
    40
      web/app/components/datasets/list/template/template.en.mdx
  41. 37
    37
      web/app/components/datasets/list/template/template.ja.mdx
  42. 41
    41
      web/app/components/datasets/list/template/template.zh.mdx
  43. 2
    2
      web/app/components/develop/doc.tsx
  44. 1
    1
      web/app/components/share/text-generation/index.tsx
  45. 2
    1
      web/app/components/workflow/custom-edge.tsx
  46. 133
    1
      web/app/components/workflow/hooks/use-nodes-interactions.ts
  47. 24
    0
      web/app/components/workflow/hooks/use-selection-interactions.ts
  48. 33
    0
      web/app/components/workflow/hooks/use-shortcuts.ts
  49. 4
    0
      web/app/components/workflow/index.tsx
  50. 1
    0
      web/app/components/workflow/nodes/_base/components/workflow-panel/tab.tsx
  51. 1
    0
      web/app/components/workflow/nodes/_base/node.tsx
  52. 1
    1
      web/app/components/workflow/nodes/answer/utils.ts
  53. 1
    1
      web/app/components/workflow/nodes/assigner/utils.ts
  54. 1
    1
      web/app/components/workflow/nodes/end/utils.ts
  55. 1
    1
      web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx
  56. 1
    1
      web/app/components/workflow/nodes/llm/panel.tsx
  57. 2
    2
      web/app/components/workflow/nodes/llm/utils.ts
  58. 1
    6
      web/app/components/workflow/nodes/loop/use-config.ts
  59. 1
    1
      web/app/components/workflow/nodes/start/utils.ts
  60. 1
    1
      web/app/components/workflow/nodes/template-transform/utils.ts
  61. 1
    1
      web/app/components/workflow/nodes/tool/utils.ts
  62. 433
    0
      web/app/components/workflow/selection-contextmenu.tsx
  63. 8
    8
      web/app/components/workflow/store/workflow/debug/inspect-vars-slice.ts
  64. 7
    0
      web/app/components/workflow/store/workflow/panel-slice.ts
  65. 3
    1
      web/app/components/workflow/types.ts
  66. 1
    0
      web/eslint.config.mjs
  67. 5
    0
      web/global.d.ts
  68. 5
    5
      web/i18n-config/i18next-config.ts
  69. 9
    0
      web/i18n/de-DE/workflow.ts
  70. 21
    0
      web/i18n/en-US/workflow.ts
  71. 9
    0
      web/i18n/es-ES/workflow.ts
  72. 9
    3
      web/i18n/fa-IR/workflow.ts
  73. 9
    3
      web/i18n/fr-FR/workflow.ts
  74. 9
    3
      web/i18n/hi-IN/workflow.ts
  75. 9
    3
      web/i18n/it-IT/workflow.ts
  76. 9
    0
      web/i18n/ja-JP/workflow.ts
  77. 9
    0
      web/i18n/ko-KR/workflow.ts
  78. 9
    3
      web/i18n/pl-PL/workflow.ts
  79. 9
    3
      web/i18n/pt-BR/workflow.ts
  80. 9
    3
      web/i18n/ro-RO/workflow.ts
  81. 9
    3
      web/i18n/ru-RU/workflow.ts
  82. 9
    0
      web/i18n/sl-SI/workflow.ts
  83. 9
    3
      web/i18n/th-TH/workflow.ts
  84. 9
    3
      web/i18n/tr-TR/workflow.ts
  85. 9
    3
      web/i18n/uk-UA/workflow.ts
  86. 9
    3
      web/i18n/vi-VN/workflow.ts
  87. 21
    0
      web/i18n/zh-Hans/workflow.ts
  88. 11
    2
      web/i18n/zh-Hant/workflow.ts
  89. 1
    1
      web/service/base.ts

+ 27
- 0
.github/ISSUE_TEMPLATE/chore.yaml View File

@@ -0,0 +1,27 @@
name: "✨ Refactor"
description: Refactor existing code for improved readability and maintainability.
title: "[Chore/Refactor] "
labels:
- refactor
body:
- type: textarea
id: description
attributes:
label: Description
placeholder: "Describe the refactor you are proposing."
validations:
required: true
- type: textarea
id: motivation
attributes:
label: Motivation
placeholder: "Explain why this refactor is necessary."
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional Context
placeholder: "Add any other context or screenshots about the request here."
validations:
required: false

+ 1
- 0
.github/workflows/build-push.yml View File

@@ -7,6 +7,7 @@ on:
- "deploy/dev"
- "deploy/enterprise"
- "build/**"
- "release/e-*"
- "deploy/rag-dev"
tags:
- "*"

+ 3
- 2
api/controllers/console/app/annotation.py View File

@@ -225,14 +225,15 @@ class AnnotationBatchImportApi(Resource):
raise Forbidden()

app_id = str(app_id)
# get file from request
file = request.files["file"]
# check file
if "file" not in request.files:
raise NoFileUploadedError()

if len(request.files) > 1:
raise TooManyFilesError()

# get file from request
file = request.files["file"]
# check file type
if not file.filename or not file.filename.lower().endswith(".csv"):
raise ValueError("Invalid file type. Only CSV files are allowed")

+ 27
- 10
api/controllers/console/explore/installed_app.py View File

@@ -58,21 +58,38 @@ class InstalledAppsListApi(Resource):
# filter out apps that user doesn't have access to
if FeatureService.get_system_features().webapp_auth.enabled:
user_id = current_user.id
res = []
app_ids = [installed_app["app"].id for installed_app in installed_app_list]
webapp_settings = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids)

# Pre-filter out apps without setting or with sso_verified
filtered_installed_apps = []
app_id_to_app_code = {}

for installed_app in installed_app_list:
webapp_setting = webapp_settings.get(installed_app["app"].id)
if not webapp_setting:
app_id = installed_app["app"].id
webapp_setting = webapp_settings.get(app_id)
if not webapp_setting or webapp_setting.access_mode == "sso_verified":
continue
if webapp_setting.access_mode == "sso_verified":
continue
app_code = AppService.get_app_code_by_id(str(installed_app["app"].id))
if EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(
user_id=user_id,
app_code=app_code,
):
app_code = AppService.get_app_code_by_id(str(app_id))
app_id_to_app_code[app_id] = app_code
filtered_installed_apps.append(installed_app)

app_codes = list(app_id_to_app_code.values())

# Batch permission check
permissions = EnterpriseService.WebAppAuth.batch_is_user_allowed_to_access_webapps(
user_id=user_id,
app_codes=app_codes,
)

# Keep only allowed apps
res = []
for installed_app in filtered_installed_apps:
app_id = installed_app["app"].id
app_code = app_id_to_app_code[app_id]
if permissions.get(app_code):
res.append(installed_app)

installed_app_list = res
logger.debug("installed_app_list: %s, user_id: %s", installed_app_list, user_id)


api/core/model_runtime/model_providers/__base/tokenizers/gpt2_tokenzier.py → api/core/model_runtime/model_providers/__base/tokenizers/gpt2_tokenizer.py View File


+ 1
- 0
api/core/plugin/impl/base.py View File

@@ -208,6 +208,7 @@ class BasePluginClient:
except Exception:
raise PluginDaemonInnerError(code=rep.code, message=rep.message)

logger.error("Error in stream reponse for plugin %s", rep.__dict__)
self._handle_plugin_daemon_error(error.error_type, error.message)
raise ValueError(f"plugin daemon: {rep.message}, code: {rep.code}")
if rep.data is None:

+ 3
- 1
api/core/plugin/impl/exc.py View File

@@ -2,6 +2,8 @@ from collections.abc import Mapping

from pydantic import TypeAdapter

from extensions.ext_logging import get_request_id


class PluginDaemonError(Exception):
"""Base class for all plugin daemon errors."""
@@ -11,7 +13,7 @@ class PluginDaemonError(Exception):

def __str__(self) -> str:
# returns the class name and description
return f"{self.__class__.__name__}: {self.description}"
return f"req_id: {get_request_id()} {self.__class__.__name__}: {self.description}"


class PluginDaemonInternalError(PluginDaemonError):

+ 1
- 10
api/core/rag/splitter/fixed_text_splitter.py View File

@@ -5,14 +5,13 @@ from __future__ import annotations
from typing import Any, Optional

from core.model_manager import ModelInstance
from core.model_runtime.model_providers.__base.tokenizers.gpt2_tokenzier import GPT2Tokenizer
from core.model_runtime.model_providers.__base.tokenizers.gpt2_tokenizer import GPT2Tokenizer
from core.rag.splitter.text_splitter import (
TS,
Collection,
Literal,
RecursiveCharacterTextSplitter,
Set,
TokenTextSplitter,
Union,
)

@@ -45,14 +44,6 @@ class EnhanceRecursiveCharacterTextSplitter(RecursiveCharacterTextSplitter):

return [len(text) for text in texts]

if issubclass(cls, TokenTextSplitter):
extra_kwargs = {
"model_name": embedding_model_instance.model if embedding_model_instance else "gpt2",
"allowed_special": allowed_special,
"disallowed_special": disallowed_special,
}
kwargs = {**kwargs, **extra_kwargs}

return cls(length_function=_character_encoder, **kwargs)



+ 0
- 3
api/core/tools/__base/tool.py View File

@@ -20,9 +20,6 @@ class Tool(ABC):
The base class of a tool
"""

entity: ToolEntity
runtime: ToolRuntime

def __init__(self, entity: ToolEntity, runtime: ToolRuntime) -> None:
self.entity = entity
self.runtime = runtime

+ 0
- 2
api/core/tools/builtin_tool/tool.py View File

@@ -20,8 +20,6 @@ class BuiltinTool(Tool):
:param meta: the meta data of a tool call processing
"""

provider: str

def __init__(self, provider: str, **kwargs):
super().__init__(**kwargs)
self.provider = provider

+ 0
- 3
api/core/tools/custom_tool/tool.py View File

@@ -21,9 +21,6 @@ API_TOOL_DEFAULT_TIMEOUT = (


class ApiTool(Tool):
api_bundle: ApiToolBundle
provider_id: str

"""
Api tool
"""

+ 1
- 8
api/core/tools/mcp_tool/tool.py View File

@@ -8,23 +8,16 @@ from core.mcp.mcp_client import MCPClient
from core.mcp.types import ImageContent, TextContent
from core.tools.__base.tool import Tool
from core.tools.__base.tool_runtime import ToolRuntime
from core.tools.entities.tool_entities import ToolEntity, ToolInvokeMessage, ToolParameter, ToolProviderType
from core.tools.entities.tool_entities import ToolEntity, ToolInvokeMessage, ToolProviderType


class MCPTool(Tool):
tenant_id: str
icon: str
runtime_parameters: Optional[list[ToolParameter]]
server_url: str
provider_id: str

def __init__(
self, entity: ToolEntity, runtime: ToolRuntime, tenant_id: str, icon: str, server_url: str, provider_id: str
) -> None:
super().__init__(entity, runtime)
self.tenant_id = tenant_id
self.icon = icon
self.runtime_parameters = None
self.server_url = server_url
self.provider_id = provider_id


+ 1
- 6
api/core/tools/plugin_tool/tool.py View File

@@ -9,11 +9,6 @@ from core.tools.entities.tool_entities import ToolEntity, ToolInvokeMessage, Too


class PluginTool(Tool):
tenant_id: str
icon: str
plugin_unique_identifier: str
runtime_parameters: Optional[list[ToolParameter]]

def __init__(
self, entity: ToolEntity, runtime: ToolRuntime, tenant_id: str, icon: str, plugin_unique_identifier: str
) -> None:
@@ -21,7 +16,7 @@ class PluginTool(Tool):
self.tenant_id = tenant_id
self.icon = icon
self.plugin_unique_identifier = plugin_unique_identifier
self.runtime_parameters = None
self.runtime_parameters: Optional[list[ToolParameter]] = None

def tool_provider_type(self) -> ToolProviderType:
return ToolProviderType.PLUGIN

+ 0
- 2
api/core/tools/utils/dataset_retriever_tool.py View File

@@ -20,8 +20,6 @@ from core.tools.utils.dataset_retriever.dataset_retriever_base_tool import Datas


class DatasetRetrieverTool(Tool):
retrieval_tool: DatasetRetrieverBaseTool

def __init__(self, entity: ToolEntity, runtime: ToolRuntime, retrieval_tool: DatasetRetrieverBaseTool) -> None:
super().__init__(entity, runtime)
self.retrieval_tool = retrieval_tool

+ 0
- 9
api/core/tools/workflow_as_tool/tool.py View File

@@ -25,15 +25,6 @@ logger = logging.getLogger(__name__)


class WorkflowTool(Tool):
workflow_app_id: str
version: str
workflow_entities: dict[str, Any]
workflow_call_depth: int
thread_pool_id: Optional[str] = None
workflow_as_tool_id: str

label: str

"""
Workflow tool.
"""

+ 4
- 0
api/extensions/ext_otel.py View File

@@ -136,6 +136,8 @@ def init_app(app: DifyApp):
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as HTTPSpanExporter
from opentelemetry.instrumentation.celery import CeleryInstrumentor
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.metrics import get_meter, get_meter_provider, set_meter_provider
from opentelemetry.propagate import set_global_textmap
@@ -234,6 +236,8 @@ def init_app(app: DifyApp):
CeleryInstrumentor(tracer_provider=get_tracer_provider(), meter_provider=get_meter_provider()).instrument()
instrument_exception_logging()
init_sqlalchemy_instrumentor(app)
RedisInstrumentor().instrument()
RequestsInstrumentor().instrument()
atexit.register(shutdown_tracer)



+ 13
- 0
api/models/workflow.py View File

@@ -895,6 +895,19 @@ class WorkflowAppLog(Base):
created_by_role = CreatorUserRole(self.created_by_role)
return db.session.get(EndUser, self.created_by) if created_by_role == CreatorUserRole.END_USER else None

def to_dict(self):
return {
"id": self.id,
"tenant_id": self.tenant_id,
"app_id": self.app_id,
"workflow_id": self.workflow_id,
"workflow_run_id": self.workflow_run_id,
"created_from": self.created_from,
"created_by_role": self.created_by_role,
"created_by": self.created_by,
"created_at": self.created_at,
}


class ConversationVariable(Base):
__tablename__ = "workflow_conversation_variables"

+ 2
- 0
api/pyproject.toml View File

@@ -49,6 +49,8 @@ dependencies = [
"opentelemetry-instrumentation==0.48b0",
"opentelemetry-instrumentation-celery==0.48b0",
"opentelemetry-instrumentation-flask==0.48b0",
"opentelemetry-instrumentation-redis==0.48b0",
"opentelemetry-instrumentation-requests==0.48b0",
"opentelemetry-instrumentation-sqlalchemy==0.48b0",
"opentelemetry-propagator-b3==1.27.0",
# opentelemetry-proto1.28.0 depends on protobuf (>=5.0,<6.0),

+ 135
- 1
api/services/clear_free_plan_tenant_expired_logs.py View File

@@ -13,7 +13,19 @@ from core.model_runtime.utils.encoders import jsonable_encoder
from extensions.ext_database import db
from extensions.ext_storage import storage
from models.account import Tenant
from models.model import App, Conversation, Message
from models.model import (
App,
AppAnnotationHitHistory,
Conversation,
Message,
MessageAgentThought,
MessageAnnotation,
MessageChain,
MessageFeedback,
MessageFile,
)
from models.web import SavedMessage
from models.workflow import WorkflowAppLog
from repositories.factory import DifyAPIRepositoryFactory
from services.billing_service import BillingService

@@ -21,6 +33,85 @@ logger = logging.getLogger(__name__)


class ClearFreePlanTenantExpiredLogs:
@classmethod
def _clear_message_related_tables(cls, session: Session, tenant_id: str, batch_message_ids: list[str]) -> None:
"""
Clean up message-related tables to avoid data redundancy.
This method cleans up tables that have foreign key relationships with Message.

Args:
session: Database session, the same with the one in process_tenant method
tenant_id: Tenant ID for logging purposes
batch_message_ids: List of message IDs to clean up
"""
if not batch_message_ids:
return

# Clean up each related table
related_tables = [
(MessageFeedback, "message_feedbacks"),
(MessageFile, "message_files"),
(MessageAnnotation, "message_annotations"),
(MessageChain, "message_chains"),
(MessageAgentThought, "message_agent_thoughts"),
(AppAnnotationHitHistory, "app_annotation_hit_histories"),
(SavedMessage, "saved_messages"),
]

for model, table_name in related_tables:
# Query records related to expired messages
records = (
session.query(model)
.filter(
model.message_id.in_(batch_message_ids), # type: ignore
)
.all()
)

if len(records) == 0:
continue

# Save records before deletion
record_ids = [record.id for record in records]
try:
record_data = []
for record in records:
try:
if hasattr(record, "to_dict"):
record_data.append(record.to_dict())
else:
# if record doesn't have to_dict method, we need to transform it to dict manually
record_dict = {}
for column in record.__table__.columns:
record_dict[column.name] = getattr(record, column.name)
record_data.append(record_dict)
except Exception:
logger.exception("Failed to transform %s record: %s", table_name, record.id)
continue

if record_data:
storage.save(
f"free_plan_tenant_expired_logs/"
f"{tenant_id}/{table_name}/{datetime.datetime.now().strftime('%Y-%m-%d')}"
f"-{time.time()}.json",
json.dumps(
jsonable_encoder(record_data),
).encode("utf-8"),
)
except Exception:
logger.exception("Failed to save %s records", table_name)

session.query(model).filter(
model.id.in_(record_ids), # type: ignore
).delete(synchronize_session=False)

click.echo(
click.style(
f"[{datetime.datetime.now()}] Processed {len(record_ids)} "
f"{table_name} records for tenant {tenant_id}"
)
)

@classmethod
def process_tenant(cls, flask_app: Flask, tenant_id: str, days: int, batch: int):
with flask_app.app_context():
@@ -58,6 +149,7 @@ class ClearFreePlanTenantExpiredLogs:
Message.id.in_(message_ids),
).delete(synchronize_session=False)

cls._clear_message_related_tables(session, tenant_id, message_ids)
session.commit()

click.echo(
@@ -199,6 +291,48 @@ class ClearFreePlanTenantExpiredLogs:
if len(workflow_runs) < batch:
break

while True:
with Session(db.engine).no_autoflush as session:
workflow_app_logs = (
session.query(WorkflowAppLog)
.filter(
WorkflowAppLog.tenant_id == tenant_id,
WorkflowAppLog.created_at < datetime.datetime.now() - datetime.timedelta(days=days),
)
.limit(batch)
.all()
)

if len(workflow_app_logs) == 0:
break

# save workflow app logs
storage.save(
f"free_plan_tenant_expired_logs/"
f"{tenant_id}/workflow_app_logs/{datetime.datetime.now().strftime('%Y-%m-%d')}"
f"-{time.time()}.json",
json.dumps(
jsonable_encoder(
[workflow_app_log.to_dict() for workflow_app_log in workflow_app_logs],
),
).encode("utf-8"),
)

workflow_app_log_ids = [workflow_app_log.id for workflow_app_log in workflow_app_logs]

# delete workflow app logs
session.query(WorkflowAppLog).filter(
WorkflowAppLog.id.in_(workflow_app_log_ids),
).delete(synchronize_session=False)
session.commit()

click.echo(
click.style(
f"[{datetime.datetime.now()}] Processed {len(workflow_app_log_ids)}"
f" workflow app logs for tenant {tenant_id}"
)
)

@classmethod
def process(cls, days: int, batch: int, tenant_ids: list[str]):
"""

+ 10
- 0
api/services/enterprise/enterprise_service.py View File

@@ -52,6 +52,16 @@ class EnterpriseService:

return data.get("result", False)

@classmethod
def batch_is_user_allowed_to_access_webapps(cls, user_id: str, app_codes: list[str]):
if not app_codes:
return {}
body = {"userId": user_id, "appCodes": app_codes}
data = EnterpriseRequest.send_request("POST", "/webapp/permission/batch", json=body)
if not data:
raise ValueError("No data found.")
return data.get("permissions", {})

@classmethod
def get_app_access_mode_by_id(cls, app_id: str) -> WebAppSettings:
if not app_id:

+ 0
- 0
api/tests/test_containers_integration_tests/services/__init__.py View File


+ 3340
- 0
api/tests/test_containers_integration_tests/services/test_account_service.py
File diff suppressed because it is too large
View File


+ 739
- 0
api/tests/test_containers_integration_tests/services/test_workflow_draft_variable_service.py View File

@@ -0,0 +1,739 @@
import pytest
from faker import Faker

from core.variables.segments import StringSegment
from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from models import App, Workflow
from models.enums import DraftVariableType
from models.workflow import WorkflowDraftVariable
from services.workflow_draft_variable_service import (
UpdateNotSupportedError,
WorkflowDraftVariableService,
)


class TestWorkflowDraftVariableService:
"""
Comprehensive integration tests for WorkflowDraftVariableService using testcontainers.

This test class covers all major functionality of the WorkflowDraftVariableService:
- CRUD operations for workflow draft variables (Create, Read, Update, Delete)
- Variable listing and filtering by type (conversation, system, node)
- Variable updates and resets with proper validation
- Variable deletion operations at different scopes
- Special functionality like prefill and conversation ID retrieval
- Error handling for various edge cases and invalid operations

All tests use the testcontainers infrastructure to ensure proper database isolation
and realistic testing environment with actual database interactions.
"""

@pytest.fixture
def mock_external_service_dependencies(self):
"""
Mock setup for external service dependencies.

WorkflowDraftVariableService doesn't have external dependencies that need mocking,
so this fixture returns an empty dictionary to maintain consistency with other test classes.
This ensures the test structure remains consistent across different service test files.
"""
# WorkflowDraftVariableService doesn't have external dependencies that need mocking
return {}

def _create_test_app(self, db_session_with_containers, mock_external_service_dependencies, fake=None):
"""
Helper method to create a test app with realistic data for testing.

This method creates a complete App instance with all required fields populated
using Faker for generating realistic test data. The app is configured for
workflow mode to support workflow draft variable testing.

Args:
db_session_with_containers: Database session from testcontainers infrastructure
mock_external_service_dependencies: Mock dependencies (unused in this service)
fake: Faker instance for generating test data, creates new instance if not provided

Returns:
App: Created test app instance with all required fields populated
"""
fake = fake or Faker()
app = App()
app.id = fake.uuid4()
app.tenant_id = fake.uuid4()
app.name = fake.company()
app.description = fake.text()
app.mode = "workflow"
app.icon_type = "emoji"
app.icon = "🤖"
app.icon_background = "#FFEAD5"
app.enable_site = True
app.enable_api = True
app.created_by = fake.uuid4()
app.updated_by = app.created_by

from extensions.ext_database import db

db.session.add(app)
db.session.commit()
return app

def _create_test_workflow(self, db_session_with_containers, app, fake=None):
"""
Helper method to create a test workflow associated with an app.

This method creates a Workflow instance using the proper factory method
to ensure all required fields are set correctly. The workflow is configured
as a draft version with basic graph structure for testing workflow variables.

Args:
db_session_with_containers: Database session from testcontainers infrastructure
app: The app to associate the workflow with
fake: Faker instance for generating test data, creates new instance if not provided

Returns:
Workflow: Created test workflow instance with proper configuration
"""
fake = fake or Faker()
workflow = Workflow.new(
tenant_id=app.tenant_id,
app_id=app.id,
type="workflow",
version="draft",
graph='{"nodes": [], "edges": []}',
features="{}",
created_by=app.created_by,
environment_variables=[],
conversation_variables=[],
)
from extensions.ext_database import db

db.session.add(workflow)
db.session.commit()
return workflow

def _create_test_variable(
self, db_session_with_containers, app_id, node_id, name, value, variable_type="conversation", fake=None
):
"""
Helper method to create a test workflow draft variable with proper configuration.

This method creates different types of variables (conversation, system, node) using
the appropriate factory methods to ensure proper initialization. Each variable type
has specific requirements and this method handles the creation logic for all types.

Args:
db_session_with_containers: Database session from testcontainers infrastructure
app_id: ID of the app to associate the variable with
node_id: ID of the node (or special constants like CONVERSATION_VARIABLE_NODE_ID)
name: Name of the variable for identification
value: StringSegment value for the variable content
variable_type: Type of variable ("conversation", "system", "node") determining creation method
fake: Faker instance for generating test data, creates new instance if not provided

Returns:
WorkflowDraftVariable: Created test variable instance with proper type configuration
"""
fake = fake or Faker()
if variable_type == "conversation":
# Create conversation variable using the appropriate factory method
variable = WorkflowDraftVariable.new_conversation_variable(
app_id=app_id,
name=name,
value=value,
description=fake.text(max_nb_chars=20),
)
elif variable_type == "system":
# Create system variable with editable flag and execution context
variable = WorkflowDraftVariable.new_sys_variable(
app_id=app_id,
name=name,
value=value,
node_execution_id=fake.uuid4(),
editable=True,
)
else: # node variable
# Create node variable with visibility and editability settings
variable = WorkflowDraftVariable.new_node_variable(
app_id=app_id,
node_id=node_id,
name=name,
value=value,
node_execution_id=fake.uuid4(),
visible=True,
editable=True,
)
from extensions.ext_database import db

db.session.add(variable)
db.session.commit()
return variable

def test_get_variable_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test getting a single variable by ID successfully.

This test verifies that the service can retrieve a specific variable
by its ID and that the returned variable contains the correct data.
It ensures the basic CRUD read operation works correctly for workflow draft variables.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
test_value = StringSegment(value=fake.word())
variable = self._create_test_variable(
db_session_with_containers, app.id, CONVERSATION_VARIABLE_NODE_ID, "test_var", test_value, fake=fake
)
service = WorkflowDraftVariableService(db_session_with_containers)
retrieved_variable = service.get_variable(variable.id)
assert retrieved_variable is not None
assert retrieved_variable.id == variable.id
assert retrieved_variable.name == "test_var"
assert retrieved_variable.app_id == app.id
assert retrieved_variable.get_value().value == test_value.value

def test_get_variable_not_found(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test getting a variable that doesn't exist.

This test verifies that the service returns None when trying to
retrieve a variable with a non-existent ID. This ensures proper
handling of missing data scenarios.
"""
fake = Faker()
non_existent_id = fake.uuid4()
service = WorkflowDraftVariableService(db_session_with_containers)
retrieved_variable = service.get_variable(non_existent_id)
assert retrieved_variable is None

def test_get_draft_variables_by_selectors_success(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Test getting variables by selectors successfully.

This test verifies that the service can retrieve multiple variables
using selector pairs (node_id, variable_name) and returns the correct
variables for each selector. This is useful for bulk variable retrieval
operations in workflow execution contexts.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
var1_value = StringSegment(value=fake.word())
var2_value = StringSegment(value=fake.word())
var3_value = StringSegment(value=fake.word())
var1 = self._create_test_variable(
db_session_with_containers, app.id, CONVERSATION_VARIABLE_NODE_ID, "var1", var1_value, fake=fake
)
var2 = self._create_test_variable(
db_session_with_containers, app.id, CONVERSATION_VARIABLE_NODE_ID, "var2", var2_value, fake=fake
)
var3 = self._create_test_variable(
db_session_with_containers, app.id, "test_node_1", "var3", var3_value, "node", fake=fake
)
selectors = [
[CONVERSATION_VARIABLE_NODE_ID, "var1"],
[CONVERSATION_VARIABLE_NODE_ID, "var2"],
["test_node_1", "var3"],
]
service = WorkflowDraftVariableService(db_session_with_containers)
retrieved_variables = service.get_draft_variables_by_selectors(app.id, selectors)
assert len(retrieved_variables) == 3
var_names = [var.name for var in retrieved_variables]
assert "var1" in var_names
assert "var2" in var_names
assert "var3" in var_names
for var in retrieved_variables:
if var.name == "var1":
assert var.get_value().value == var1_value.value
elif var.name == "var2":
assert var.get_value().value == var2_value.value
elif var.name == "var3":
assert var.get_value().value == var3_value.value

def test_list_variables_without_values_success(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Test listing variables without values successfully with pagination.

This test verifies that the service can list variables with pagination
and that the returned variables don't include their values (for performance).
This is important for scenarios where only variable metadata is needed
without loading the actual content.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
for i in range(5):
test_value = StringSegment(value=fake.numerify("value##"))
self._create_test_variable(
db_session_with_containers, app.id, CONVERSATION_VARIABLE_NODE_ID, fake.word(), test_value, fake=fake
)
service = WorkflowDraftVariableService(db_session_with_containers)
result = service.list_variables_without_values(app.id, page=1, limit=3)
assert result.total == 5
assert len(result.variables) == 3
assert result.variables[0].created_at >= result.variables[1].created_at
assert result.variables[1].created_at >= result.variables[2].created_at
for var in result.variables:
assert var.name is not None
assert var.app_id == app.id

def test_list_node_variables_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test listing variables for a specific node successfully.

This test verifies that the service can filter and return only
variables associated with a specific node ID. This is crucial for
workflow execution where variables need to be scoped to specific nodes.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
node_id = fake.word()
var1_value = StringSegment(value=fake.word())
var2_value = StringSegment(value=fake.word())
var3_value = StringSegment(value=fake.word())
self._create_test_variable(db_session_with_containers, app.id, node_id, "var1", var1_value, "node", fake=fake)
self._create_test_variable(db_session_with_containers, app.id, node_id, "var2", var3_value, "node", fake=fake)
self._create_test_variable(
db_session_with_containers, app.id, "other_node", "var3", var2_value, "node", fake=fake
)
service = WorkflowDraftVariableService(db_session_with_containers)
result = service.list_node_variables(app.id, node_id)
assert len(result.variables) == 2
for var in result.variables:
assert var.node_id == node_id
assert var.app_id == app.id
var_names = [var.name for var in result.variables]
assert "var1" in var_names
assert "var2" in var_names
assert "var3" not in var_names

def test_list_conversation_variables_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test listing conversation variables successfully.

This test verifies that the service can filter and return only
conversation variables, excluding system and node variables.
Conversation variables are user-facing variables that can be
modified during conversation flows.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
conv_var1_value = StringSegment(value=fake.word())
conv_var2_value = StringSegment(value=fake.word())
conv_var1 = self._create_test_variable(
db_session_with_containers, app.id, CONVERSATION_VARIABLE_NODE_ID, "conv_var1", conv_var1_value, fake=fake
)
conv_var2 = self._create_test_variable(
db_session_with_containers, app.id, CONVERSATION_VARIABLE_NODE_ID, "conv_var2", conv_var2_value, fake=fake
)
sys_var_value = StringSegment(value=fake.word())
self._create_test_variable(
db_session_with_containers, app.id, SYSTEM_VARIABLE_NODE_ID, "sys_var", sys_var_value, "system", fake=fake
)
service = WorkflowDraftVariableService(db_session_with_containers)
result = service.list_conversation_variables(app.id)
assert len(result.variables) == 2
for var in result.variables:
assert var.node_id == CONVERSATION_VARIABLE_NODE_ID
assert var.app_id == app.id
assert var.get_variable_type() == DraftVariableType.CONVERSATION
var_names = [var.name for var in result.variables]
assert "conv_var1" in var_names
assert "conv_var2" in var_names
assert "sys_var" not in var_names

def test_update_variable_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test updating a variable's name and value successfully.

This test verifies that the service can update both the name and value
of an editable variable and that the changes are persisted correctly.
It also checks that the last_edited_at timestamp is updated appropriately.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
original_value = StringSegment(value=fake.word())
new_value = StringSegment(value=fake.word())
variable = self._create_test_variable(
db_session_with_containers,
app.id,
CONVERSATION_VARIABLE_NODE_ID,
"original_name",
original_value,
fake=fake,
)
service = WorkflowDraftVariableService(db_session_with_containers)
updated_variable = service.update_variable(variable, name="new_name", value=new_value)
assert updated_variable.name == "new_name"
assert updated_variable.get_value().value == new_value.value
assert updated_variable.last_edited_at is not None
from extensions.ext_database import db

db.session.refresh(variable)
assert variable.name == "new_name"
assert variable.get_value().value == new_value.value
assert variable.last_edited_at is not None

def test_update_variable_not_editable(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test that updating a non-editable variable raises an exception.

This test verifies that the service properly prevents updates to
variables that are not marked as editable. This is important for
maintaining data integrity and preventing unauthorized modifications
to system-controlled variables.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
original_value = StringSegment(value=fake.word())
new_value = StringSegment(value=fake.word())
variable = WorkflowDraftVariable.new_sys_variable(
app_id=app.id,
name=fake.word(), # This is typically not editable
value=original_value,
node_execution_id=fake.uuid4(),
editable=False, # Set as non-editable
)
from extensions.ext_database import db

db.session.add(variable)
db.session.commit()
service = WorkflowDraftVariableService(db_session_with_containers)
with pytest.raises(UpdateNotSupportedError) as exc_info:
service.update_variable(variable, name="new_name", value=new_value)
assert "variable not support updating" in str(exc_info.value)
assert variable.id in str(exc_info.value)

def test_reset_conversation_variable_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test resetting conversation variable successfully.

This test verifies that the service can reset a conversation variable
to its default value and clear the last_edited_at timestamp.
This functionality is useful for reverting user modifications
back to the original workflow configuration.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
workflow = self._create_test_workflow(db_session_with_containers, app, fake=fake)
from core.variables.variables import StringVariable

conv_var = StringVariable(
id=fake.uuid4(),
name="test_conv_var",
value="default_value",
selector=[CONVERSATION_VARIABLE_NODE_ID, "test_conv_var"],
)
workflow.conversation_variables = [conv_var]
from extensions.ext_database import db

db.session.commit()
modified_value = StringSegment(value=fake.word())
variable = self._create_test_variable(
db_session_with_containers,
app.id,
CONVERSATION_VARIABLE_NODE_ID,
"test_conv_var",
modified_value,
fake=fake,
)
variable.last_edited_at = fake.date_time()
db.session.commit()
service = WorkflowDraftVariableService(db_session_with_containers)
reset_variable = service.reset_variable(workflow, variable)
assert reset_variable is not None
assert reset_variable.get_value().value == "default_value"
assert reset_variable.last_edited_at is None
db.session.refresh(variable)
assert variable.get_value().value == "default_value"
assert variable.last_edited_at is None

def test_delete_variable_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test deleting a single variable successfully.

This test verifies that the service can delete a specific variable
and that it's properly removed from the database. It ensures that
the deletion operation is atomic and complete.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
test_value = StringSegment(value=fake.word())
variable = self._create_test_variable(
db_session_with_containers, app.id, CONVERSATION_VARIABLE_NODE_ID, "test_var", test_value, fake=fake
)
from extensions.ext_database import db

assert db.session.query(WorkflowDraftVariable).filter_by(id=variable.id).first() is not None
service = WorkflowDraftVariableService(db_session_with_containers)
service.delete_variable(variable)
assert db.session.query(WorkflowDraftVariable).filter_by(id=variable.id).first() is None

def test_delete_workflow_variables_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test deleting all variables for a workflow successfully.

This test verifies that the service can delete all variables
associated with a specific app/workflow. This is useful for
cleanup operations when workflows are deleted or reset.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
for i in range(3):
test_value = StringSegment(value=fake.numerify("value##"))
self._create_test_variable(
db_session_with_containers, app.id, CONVERSATION_VARIABLE_NODE_ID, fake.word(), test_value, fake=fake
)
other_app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
other_value = StringSegment(value=fake.word())
self._create_test_variable(
db_session_with_containers, other_app.id, CONVERSATION_VARIABLE_NODE_ID, fake.word(), other_value, fake=fake
)
from extensions.ext_database import db

app_variables = db.session.query(WorkflowDraftVariable).filter_by(app_id=app.id).all()
other_app_variables = db.session.query(WorkflowDraftVariable).filter_by(app_id=other_app.id).all()
assert len(app_variables) == 3
assert len(other_app_variables) == 1
service = WorkflowDraftVariableService(db_session_with_containers)
service.delete_workflow_variables(app.id)
app_variables_after = db.session.query(WorkflowDraftVariable).filter_by(app_id=app.id).all()
other_app_variables_after = db.session.query(WorkflowDraftVariable).filter_by(app_id=other_app.id).all()
assert len(app_variables_after) == 0
assert len(other_app_variables_after) == 1

def test_delete_node_variables_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test deleting all variables for a specific node successfully.

This test verifies that the service can delete all variables
associated with a specific node while preserving variables
for other nodes and conversation variables. This is important
for node-specific cleanup operations in workflow management.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
node_id = fake.word()
for i in range(2):
test_value = StringSegment(value=fake.numerify("node_value##"))
self._create_test_variable(
db_session_with_containers, app.id, node_id, fake.word(), test_value, "node", fake=fake
)
other_node_value = StringSegment(value=fake.word())
self._create_test_variable(
db_session_with_containers, app.id, "other_node", fake.word(), other_node_value, "node", fake=fake
)
conv_value = StringSegment(value=fake.word())
self._create_test_variable(
db_session_with_containers, app.id, CONVERSATION_VARIABLE_NODE_ID, fake.word(), conv_value, fake=fake
)
from extensions.ext_database import db

target_node_variables = db.session.query(WorkflowDraftVariable).filter_by(app_id=app.id, node_id=node_id).all()
other_node_variables = (
db.session.query(WorkflowDraftVariable).filter_by(app_id=app.id, node_id="other_node").all()
)
conv_variables = (
db.session.query(WorkflowDraftVariable)
.filter_by(app_id=app.id, node_id=CONVERSATION_VARIABLE_NODE_ID)
.all()
)
assert len(target_node_variables) == 2
assert len(other_node_variables) == 1
assert len(conv_variables) == 1
service = WorkflowDraftVariableService(db_session_with_containers)
service.delete_node_variables(app.id, node_id)
target_node_variables_after = (
db.session.query(WorkflowDraftVariable).filter_by(app_id=app.id, node_id=node_id).all()
)
other_node_variables_after = (
db.session.query(WorkflowDraftVariable).filter_by(app_id=app.id, node_id="other_node").all()
)
conv_variables_after = (
db.session.query(WorkflowDraftVariable)
.filter_by(app_id=app.id, node_id=CONVERSATION_VARIABLE_NODE_ID)
.all()
)
assert len(target_node_variables_after) == 0
assert len(other_node_variables_after) == 1
assert len(conv_variables_after) == 1

def test_prefill_conversation_variable_default_values_success(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Test prefill conversation variable default values successfully.

This test verifies that the service can automatically create
conversation variables with default values based on the workflow
configuration when none exist. This is important for initializing
workflow variables with proper defaults from the workflow definition.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
workflow = self._create_test_workflow(db_session_with_containers, app, fake=fake)
from core.variables.variables import StringVariable

conv_var1 = StringVariable(
id=fake.uuid4(),
name="conv_var1",
value="default_value1",
selector=[CONVERSATION_VARIABLE_NODE_ID, "conv_var1"],
)
conv_var2 = StringVariable(
id=fake.uuid4(),
name="conv_var2",
value="default_value2",
selector=[CONVERSATION_VARIABLE_NODE_ID, "conv_var2"],
)
workflow.conversation_variables = [conv_var1, conv_var2]
from extensions.ext_database import db

db.session.commit()
service = WorkflowDraftVariableService(db_session_with_containers)
service.prefill_conversation_variable_default_values(workflow)
draft_variables = (
db.session.query(WorkflowDraftVariable)
.filter_by(app_id=app.id, node_id=CONVERSATION_VARIABLE_NODE_ID)
.all()
)
assert len(draft_variables) == 2
var_names = [var.name for var in draft_variables]
assert "conv_var1" in var_names
assert "conv_var2" in var_names
for var in draft_variables:
assert var.app_id == app.id
assert var.node_id == CONVERSATION_VARIABLE_NODE_ID
assert var.editable is True
assert var.get_variable_type() == DraftVariableType.CONVERSATION

def test_get_conversation_id_from_draft_variable_success(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Test getting conversation ID from draft variable successfully.

This test verifies that the service can extract the conversation ID
from a system variable named "conversation_id". This is important
for maintaining conversation context across workflow executions.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
conversation_id = fake.uuid4()
conv_id_value = StringSegment(value=conversation_id)
self._create_test_variable(
db_session_with_containers,
app.id,
SYSTEM_VARIABLE_NODE_ID,
"conversation_id",
conv_id_value,
"system",
fake=fake,
)
service = WorkflowDraftVariableService(db_session_with_containers)
retrieved_conv_id = service._get_conversation_id_from_draft_variable(app.id)
assert retrieved_conv_id == conversation_id

def test_get_conversation_id_from_draft_variable_not_found(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Test getting conversation ID when it doesn't exist.

This test verifies that the service returns None when no
conversation_id variable exists for the app. This ensures
proper handling of missing conversation context scenarios.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
service = WorkflowDraftVariableService(db_session_with_containers)
retrieved_conv_id = service._get_conversation_id_from_draft_variable(app.id)
assert retrieved_conv_id is None

def test_list_system_variables_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test listing system variables successfully.

This test verifies that the service can filter and return only
system variables, excluding conversation and node variables.
System variables are internal variables used by the workflow
engine for maintaining state and context.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
sys_var1_value = StringSegment(value=fake.word())
sys_var2_value = StringSegment(value=fake.word())
sys_var1 = self._create_test_variable(
db_session_with_containers, app.id, SYSTEM_VARIABLE_NODE_ID, "sys_var1", sys_var1_value, "system", fake=fake
)
sys_var2 = self._create_test_variable(
db_session_with_containers, app.id, SYSTEM_VARIABLE_NODE_ID, "sys_var2", sys_var2_value, "system", fake=fake
)
conv_var_value = StringSegment(value=fake.word())
self._create_test_variable(
db_session_with_containers, app.id, CONVERSATION_VARIABLE_NODE_ID, "conv_var", conv_var_value, fake=fake
)
service = WorkflowDraftVariableService(db_session_with_containers)
result = service.list_system_variables(app.id)
assert len(result.variables) == 2
for var in result.variables:
assert var.node_id == SYSTEM_VARIABLE_NODE_ID
assert var.app_id == app.id
assert var.get_variable_type() == DraftVariableType.SYS
var_names = [var.name for var in result.variables]
assert "sys_var1" in var_names
assert "sys_var2" in var_names
assert "conv_var" not in var_names

def test_get_variable_by_name_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test getting variables by name successfully for different types.

This test verifies that the service can retrieve variables by name
for different variable types (conversation, system, node). This
functionality is important for variable lookup operations during
workflow execution and user interactions.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
test_value = StringSegment(value=fake.word())
conv_var = self._create_test_variable(
db_session_with_containers, app.id, CONVERSATION_VARIABLE_NODE_ID, "test_conv_var", test_value, fake=fake
)
sys_var = self._create_test_variable(
db_session_with_containers, app.id, SYSTEM_VARIABLE_NODE_ID, "test_sys_var", test_value, "system", fake=fake
)
node_var = self._create_test_variable(
db_session_with_containers, app.id, "test_node", "test_node_var", test_value, "node", fake=fake
)
service = WorkflowDraftVariableService(db_session_with_containers)
retrieved_conv_var = service.get_conversation_variable(app.id, "test_conv_var")
assert retrieved_conv_var is not None
assert retrieved_conv_var.name == "test_conv_var"
assert retrieved_conv_var.node_id == CONVERSATION_VARIABLE_NODE_ID
retrieved_sys_var = service.get_system_variable(app.id, "test_sys_var")
assert retrieved_sys_var is not None
assert retrieved_sys_var.name == "test_sys_var"
assert retrieved_sys_var.node_id == SYSTEM_VARIABLE_NODE_ID
retrieved_node_var = service.get_node_variable(app.id, "test_node", "test_node_var")
assert retrieved_node_var is not None
assert retrieved_node_var.name == "test_node_var"
assert retrieved_node_var.node_id == "test_node"

def test_get_variable_by_name_not_found(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test getting variables by name when they don't exist.

This test verifies that the service returns None when trying to
retrieve variables by name that don't exist. This ensures proper
handling of missing variable scenarios for all variable types.
"""
fake = Faker()
app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, fake=fake)
service = WorkflowDraftVariableService(db_session_with_containers)
retrieved_conv_var = service.get_conversation_variable(app.id, "non_existent_conv_var")
assert retrieved_conv_var is None
retrieved_sys_var = service.get_system_variable(app.id, "non_existent_sys_var")
assert retrieved_sys_var is None
retrieved_node_var = service.get_node_variable(app.id, "test_node", "non_existent_node_var")
assert retrieved_node_var is None

+ 168
- 0
api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py View File

@@ -0,0 +1,168 @@
import datetime
from unittest.mock import Mock, patch

import pytest
from sqlalchemy.orm import Session

from services.clear_free_plan_tenant_expired_logs import ClearFreePlanTenantExpiredLogs


class TestClearFreePlanTenantExpiredLogs:
"""Unit tests for ClearFreePlanTenantExpiredLogs._clear_message_related_tables method."""

@pytest.fixture
def mock_session(self):
"""Create a mock database session."""
session = Mock(spec=Session)
session.query.return_value.filter.return_value.all.return_value = []
session.query.return_value.filter.return_value.delete.return_value = 0
return session

@pytest.fixture
def mock_storage(self):
"""Create a mock storage object."""
storage = Mock()
storage.save.return_value = None
return storage

@pytest.fixture
def sample_message_ids(self):
"""Sample message IDs for testing."""
return ["msg-1", "msg-2", "msg-3"]

@pytest.fixture
def sample_records(self):
"""Sample records for testing."""
records = []
for i in range(3):
record = Mock()
record.id = f"record-{i}"
record.to_dict.return_value = {
"id": f"record-{i}",
"message_id": f"msg-{i}",
"created_at": datetime.datetime.now().isoformat(),
}
records.append(record)
return records

def test_clear_message_related_tables_empty_message_ids(self, mock_session):
"""Test that method returns early when message_ids is empty."""
with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage:
ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", [])

# Should not call any database operations
mock_session.query.assert_not_called()
mock_storage.save.assert_not_called()

def test_clear_message_related_tables_no_records_found(self, mock_session, sample_message_ids):
"""Test when no related records are found."""
with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage:
mock_session.query.return_value.filter.return_value.all.return_value = []

ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids)

# Should call query for each related table but find no records
assert mock_session.query.call_count > 0
mock_storage.save.assert_not_called()

def test_clear_message_related_tables_with_records_and_to_dict(
self, mock_session, sample_message_ids, sample_records
):
"""Test when records are found and have to_dict method."""
with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage:
mock_session.query.return_value.filter.return_value.all.return_value = sample_records

ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids)

# Should call to_dict on each record (called once per table, so 7 times total)
for record in sample_records:
assert record.to_dict.call_count == 7

# Should save backup data
assert mock_storage.save.call_count > 0

def test_clear_message_related_tables_with_records_no_to_dict(self, mock_session, sample_message_ids):
"""Test when records are found but don't have to_dict method."""
with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage:
# Create records without to_dict method
records = []
for i in range(2):
record = Mock()
mock_table = Mock()
mock_id_column = Mock()
mock_id_column.name = "id"
mock_message_id_column = Mock()
mock_message_id_column.name = "message_id"
mock_table.columns = [mock_id_column, mock_message_id_column]
record.__table__ = mock_table
record.id = f"record-{i}"
record.message_id = f"msg-{i}"
del record.to_dict
records.append(record)

# Mock records for first table only, empty for others
mock_session.query.return_value.filter.return_value.all.side_effect = [
records,
[],
[],
[],
[],
[],
[],
]

ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids)

# Should save backup data even without to_dict
assert mock_storage.save.call_count > 0

def test_clear_message_related_tables_storage_error_continues(
self, mock_session, sample_message_ids, sample_records
):
"""Test that method continues even when storage.save fails."""
with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage:
mock_storage.save.side_effect = Exception("Storage error")

mock_session.query.return_value.filter.return_value.all.return_value = sample_records

# Should not raise exception
ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids)

# Should still delete records even if backup fails
assert mock_session.query.return_value.filter.return_value.delete.called

def test_clear_message_related_tables_serialization_error_continues(self, mock_session, sample_message_ids):
"""Test that method continues even when record serialization fails."""
with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage:
record = Mock()
record.id = "record-1"
record.to_dict.side_effect = Exception("Serialization error")

mock_session.query.return_value.filter.return_value.all.return_value = [record]

# Should not raise exception
ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids)

# Should still delete records even if serialization fails
assert mock_session.query.return_value.filter.return_value.delete.called

def test_clear_message_related_tables_deletion_called(self, mock_session, sample_message_ids, sample_records):
"""Test that deletion is called for found records."""
with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage:
mock_session.query.return_value.filter.return_value.all.return_value = sample_records

ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids)

# Should call delete for each table that has records
assert mock_session.query.return_value.filter.return_value.delete.called

def test_clear_message_related_tables_logging_output(
self, mock_session, sample_message_ids, sample_records, capsys
):
"""Test that logging output is generated."""
with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage:
mock_session.query.return_value.filter.return_value.all.return_value = sample_records

ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids)

pass

+ 35
- 1
api/uv.lock View File

@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.11, <3.13"
resolution-markers = [
"python_full_version >= '3.12.4' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'",
@@ -1265,6 +1265,8 @@ dependencies = [
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-instrumentation-celery" },
{ name = "opentelemetry-instrumentation-flask" },
{ name = "opentelemetry-instrumentation-redis" },
{ name = "opentelemetry-instrumentation-requests" },
{ name = "opentelemetry-instrumentation-sqlalchemy" },
{ name = "opentelemetry-propagator-b3" },
{ name = "opentelemetry-proto" },
@@ -1448,6 +1450,8 @@ requires-dist = [
{ name = "opentelemetry-instrumentation", specifier = "==0.48b0" },
{ name = "opentelemetry-instrumentation-celery", specifier = "==0.48b0" },
{ name = "opentelemetry-instrumentation-flask", specifier = "==0.48b0" },
{ name = "opentelemetry-instrumentation-redis", specifier = "==0.48b0" },
{ name = "opentelemetry-instrumentation-requests", specifier = "==0.48b0" },
{ name = "opentelemetry-instrumentation-sqlalchemy", specifier = "==0.48b0" },
{ name = "opentelemetry-propagator-b3", specifier = "==1.27.0" },
{ name = "opentelemetry-proto", specifier = "==1.27.0" },
@@ -3670,6 +3674,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/3d/fcde4f8f0bf9fa1ee73a12304fa538076fb83fe0a2ae966ab0f0b7da5109/opentelemetry_instrumentation_flask-0.48b0-py3-none-any.whl", hash = "sha256:26b045420b9d76e85493b1c23fcf27517972423480dc6cf78fd6924248ba5808", size = 14588, upload-time = "2024-08-28T21:26:58.504Z" },
]

[[package]]
name = "opentelemetry-instrumentation-redis"
version = "0.48b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/70/be/92e98e4c7f275be3d373899a41b0a7d4df64266657d985dbbdb9a54de0d5/opentelemetry_instrumentation_redis-0.48b0.tar.gz", hash = "sha256:61e33e984b4120e1b980d9fba6e9f7ca0c8d972f9970654d8f6e9f27fa115a8c", size = 10511, upload-time = "2024-08-28T21:28:15.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/40/892f30d400091106309cc047fd3f6d76a828fedd984a953fd5386b78a2fb/opentelemetry_instrumentation_redis-0.48b0-py3-none-any.whl", hash = "sha256:48c7f2e25cbb30bde749dc0d8b9c74c404c851f554af832956b9630b27f5bcb7", size = 11610, upload-time = "2024-08-28T21:27:18.759Z" },
]

[[package]]
name = "opentelemetry-instrumentation-requests"
version = "0.48b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "opentelemetry-util-http" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/ac/5eb78efde21ff21d0ad5dc8c6cc6a0f8ae482ce8a46293c2f45a628b6166/opentelemetry_instrumentation_requests-0.48b0.tar.gz", hash = "sha256:67ab9bd877a0352ee0db4616c8b4ae59736ddd700c598ed907482d44f4c9a2b3", size = 14120, upload-time = "2024-08-28T21:28:16.933Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/df/0df9226d1b14f29d23c07e6194b9fd5ad50e7d987b7fd13df7dcf718aeb1/opentelemetry_instrumentation_requests-0.48b0-py3-none-any.whl", hash = "sha256:d4f01852121d0bd4c22f14f429654a735611d4f7bf3cf93f244bdf1489b2233d", size = 12366, upload-time = "2024-08-28T21:27:20.771Z" },
]

[[package]]
name = "opentelemetry-instrumentation-sqlalchemy"
version = "0.48b0"

+ 1
- 1
docker/docker-compose-template.yaml View File

@@ -538,7 +538,7 @@ services:

milvus-standalone:
container_name: milvus-standalone
image: milvusdb/milvus:v2.5.0-beta
image: milvusdb/milvus:v2.5.15
profiles:
- milvus
command: [ 'milvus', 'run', 'standalone' ]

+ 1
- 1
docker/docker-compose.yaml View File

@@ -1087,7 +1087,7 @@ services:

milvus-standalone:
container_name: milvus-standalone
image: milvusdb/milvus:v2.5.0-beta
image: milvusdb/milvus:v2.5.15
profiles:
- milvus
command: [ 'milvus', 'run', 'standalone' ]

+ 156
- 0
web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx View File

@@ -0,0 +1,156 @@
import React from 'react'
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import { OpikIconBig } from '@/app/components/base/icons/src/public/tracing'

// Mock dependencies to isolate the SVG rendering issue
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))

describe('SVG Attribute Error Reproduction', () => {
// Capture console errors
const originalError = console.error
let errorMessages: string[] = []

beforeEach(() => {
errorMessages = []
console.error = jest.fn((message) => {
errorMessages.push(message)
originalError(message)
})
})

afterEach(() => {
console.error = originalError
})

it('should reproduce inkscape attribute errors when rendering OpikIconBig', () => {
console.log('\n=== TESTING OpikIconBig SVG ATTRIBUTE ERRORS ===')

// Test multiple renders to check for inconsistency
for (let i = 0; i < 5; i++) {
console.log(`\nRender attempt ${i + 1}:`)

const { unmount } = render(<OpikIconBig />)

// Check for specific inkscape attribute errors
const inkscapeErrors = errorMessages.filter(msg =>
typeof msg === 'string' && msg.includes('inkscape'),
)

if (inkscapeErrors.length > 0) {
console.log(`Found ${inkscapeErrors.length} inkscape errors:`)
inkscapeErrors.forEach((error, index) => {
console.log(` ${index + 1}. ${error.substring(0, 100)}...`)
})
}
else {
console.log('No inkscape errors found in this render')
}

unmount()

// Clear errors for next iteration
errorMessages = []
}
})

it('should analyze the SVG structure causing the errors', () => {
console.log('\n=== ANALYZING SVG STRUCTURE ===')

// Import the JSON data directly
const iconData = require('@/app/components/base/icons/src/public/tracing/OpikIconBig.json')

console.log('Icon structure analysis:')
console.log('- Root element:', iconData.icon.name)
console.log('- Children count:', iconData.icon.children?.length || 0)

// Find problematic elements
const findProblematicElements = (node: any, path = '') => {
const problematicElements: any[] = []

if (node.name && (node.name.includes(':') || node.name.startsWith('sodipodi'))) {
problematicElements.push({
path,
name: node.name,
attributes: Object.keys(node.attributes || {}),
})
}

// Check attributes for inkscape/sodipodi properties
if (node.attributes) {
const problematicAttrs = Object.keys(node.attributes).filter(attr =>
attr.startsWith('inkscape:') || attr.startsWith('sodipodi:'),
)

if (problematicAttrs.length > 0) {
problematicElements.push({
path,
name: node.name,
problematicAttributes: problematicAttrs,
})
}
}

if (node.children) {
node.children.forEach((child: any, index: number) => {
problematicElements.push(
...findProblematicElements(child, `${path}/${node.name}[${index}]`),
)
})
}

return problematicElements
}

const problematicElements = findProblematicElements(iconData.icon, 'root')

console.log(`\n🚨 Found ${problematicElements.length} problematic elements:`)
problematicElements.forEach((element, index) => {
console.log(`\n${index + 1}. Element: ${element.name}`)
console.log(` Path: ${element.path}`)
if (element.problematicAttributes)
console.log(` Problematic attributes: ${element.problematicAttributes.join(', ')}`)
})
})

it('should test the normalizeAttrs function behavior', () => {
console.log('\n=== TESTING normalizeAttrs FUNCTION ===')

const { normalizeAttrs } = require('@/app/components/base/icons/utils')

const testAttributes = {
'inkscape:showpageshadow': '2',
'inkscape:pageopacity': '0.0',
'inkscape:pagecheckerboard': '0',
'inkscape:deskcolor': '#d1d1d1',
'sodipodi:docname': 'opik-icon-big.svg',
'xmlns:inkscape': 'https://www.inkscape.org/namespaces/inkscape',
'xmlns:sodipodi': 'https://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd',
'xmlns:svg': 'https://www.w3.org/2000/svg',
'data-name': 'Layer 1',
'normal-attr': 'value',
'class': 'test-class',
}

console.log('Input attributes:', Object.keys(testAttributes))

const normalized = normalizeAttrs(testAttributes)

console.log('Normalized attributes:', Object.keys(normalized))
console.log('Normalized values:', normalized)

// Check if problematic attributes are still present
const problematicKeys = Object.keys(normalized).filter(key =>
key.toLowerCase().includes('inkscape') || key.toLowerCase().includes('sodipodi'),
)

if (problematicKeys.length > 0)
console.log(`🚨 PROBLEM: Still found problematic attributes: ${problematicKeys.join(', ')}`)
else
console.log('✅ No problematic attributes found after normalization')
})
})

+ 7
- 20
web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx View File

@@ -1,12 +1,9 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import {
RiEqualizer2Line,
} from '@remixicon/react'
import React, { useCallback, useRef, useState } from 'react'

import type { PopupProps } from './config-popup'
import ConfigPopup from './config-popup'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@@ -17,13 +14,13 @@ type Props = {
readOnly: boolean
className?: string
hasConfigured: boolean
controlShowPopup?: number
children?: React.ReactNode
} & PopupProps

const ConfigBtn: FC<Props> = ({
className,
hasConfigured,
controlShowPopup,
children,
...popupProps
}) => {
const [open, doSetOpen] = useState(false)
@@ -37,13 +34,6 @@ const ConfigBtn: FC<Props> = ({
setOpen(!openRef.current)
}, [setOpen])

useEffect(() => {
if (controlShowPopup)
// setOpen(!openRef.current)
setOpen(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlShowPopup])

if (popupProps.readOnly && !hasConfigured)
return null

@@ -52,14 +42,11 @@ const ConfigBtn: FC<Props> = ({
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 12,
crossAxis: hasConfigured ? 8 : 49,
}}
offset={12}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div className={cn(className, 'rounded-md p-1')}>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
<div className="select-none">
{children}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[11]'>

+ 62
- 64
web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx View File

@@ -1,8 +1,9 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import React, { useEffect, useState } from 'react'
import {
RiArrowDownDoubleLine,
RiEqualizer2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { usePathname } from 'next/navigation'
@@ -180,10 +181,6 @@ const Panel: FC = () => {
})()
}, [])

const [controlShowPopup, setControlShowPopup] = useState<number>(0)
const showPopup = useCallback(() => {
setControlShowPopup(Date.now())
}, [setControlShowPopup])
if (!isLoaded) {
return (
<div className='mb-3 flex items-center justify-between'>
@@ -196,46 +193,66 @@ const Panel: FC = () => {

return (
<div className={cn('flex items-center justify-between')}>
<div
className={cn(
'flex cursor-pointer items-center rounded-xl border-l-[0.5px] border-t border-effects-highlight bg-background-default-dodge p-2 shadow-xs hover:border-effects-highlight-lightmode-off hover:bg-background-default-lighter',
controlShowPopup && 'border-effects-highlight-lightmode-off bg-background-default-lighter',
)}
onClick={showPopup}
>
{!inUseTracingProvider && (
<>
{!inUseTracingProvider && (
<ConfigButton
appId={appId}
readOnly={readOnly}
hasConfigured={false}
enabled={enabled}
onStatusChange={handleTracingEnabledChange}
chosenProvider={inUseTracingProvider}
onChooseProvider={handleChooseProvider}
arizeConfig={arizeConfig}
phoenixConfig={phoenixConfig}
langSmithConfig={langSmithConfig}
langFuseConfig={langFuseConfig}
opikConfig={opikConfig}
weaveConfig={weaveConfig}
aliyunConfig={aliyunConfig}
onConfigUpdated={handleTracingConfigUpdated}
onConfigRemoved={handleTracingConfigRemoved}
>
<div
className={cn(
'flex cursor-pointer select-none items-center rounded-xl border-l-[0.5px] border-t border-effects-highlight bg-background-default-dodge p-2 shadow-xs hover:border-effects-highlight-lightmode-off hover:bg-background-default-lighter',
)}
>
<TracingIcon size='md' />
<div className='system-sm-semibold mx-2 text-text-secondary'>{t(`${I18N_PREFIX}.title`)}</div>
<div className='flex items-center' onClick={e => e.stopPropagation()}>
<ConfigButton
appId={appId}
readOnly={readOnly}
hasConfigured={false}
enabled={enabled}
onStatusChange={handleTracingEnabledChange}
chosenProvider={inUseTracingProvider}
onChooseProvider={handleChooseProvider}
arizeConfig={arizeConfig}
phoenixConfig={phoenixConfig}
langSmithConfig={langSmithConfig}
langFuseConfig={langFuseConfig}
opikConfig={opikConfig}
weaveConfig={weaveConfig}
aliyunConfig={aliyunConfig}
onConfigUpdated={handleTracingConfigUpdated}
onConfigRemoved={handleTracingConfigRemoved}
controlShowPopup={controlShowPopup}
/>
<div className='rounded-md p-1'>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
<Divider type='vertical' className='h-3.5' />
<div className='rounded-md p-1'>
<RiArrowDownDoubleLine className='h-4 w-4 text-text-tertiary' />
</div>
</>
)}
{hasConfiguredTracing && (
<>
</div>
</ConfigButton>
)}
{hasConfiguredTracing && (
<ConfigButton
appId={appId}
readOnly={readOnly}
hasConfigured
enabled={enabled}
onStatusChange={handleTracingEnabledChange}
chosenProvider={inUseTracingProvider}
onChooseProvider={handleChooseProvider}
arizeConfig={arizeConfig}
phoenixConfig={phoenixConfig}
langSmithConfig={langSmithConfig}
langFuseConfig={langFuseConfig}
opikConfig={opikConfig}
weaveConfig={weaveConfig}
aliyunConfig={aliyunConfig}
onConfigUpdated={handleTracingConfigUpdated}
onConfigRemoved={handleTracingConfigRemoved}
>
<div
className={cn(
'flex cursor-pointer select-none items-center rounded-xl border-l-[0.5px] border-t border-effects-highlight bg-background-default-dodge p-2 shadow-xs hover:border-effects-highlight-lightmode-off hover:bg-background-default-lighter',
)}
>
<div className='ml-4 mr-1 flex items-center'>
<Indicator color={enabled ? 'green' : 'gray'} />
<div className='system-xs-semibold-uppercase ml-1.5 text-text-tertiary'>
@@ -243,33 +260,14 @@ const Panel: FC = () => {
</div>
</div>
{InUseProviderIcon && <InUseProviderIcon className='ml-1 h-4' />}
<Divider type='vertical' className='h-3.5' />
<div className='flex items-center' onClick={e => e.stopPropagation()}>
<ConfigButton
appId={appId}
readOnly={readOnly}
hasConfigured
className='ml-2'
enabled={enabled}
onStatusChange={handleTracingEnabledChange}
chosenProvider={inUseTracingProvider}
onChooseProvider={handleChooseProvider}
arizeConfig={arizeConfig}
phoenixConfig={phoenixConfig}
langSmithConfig={langSmithConfig}
langFuseConfig={langFuseConfig}
opikConfig={opikConfig}
weaveConfig={weaveConfig}
aliyunConfig={aliyunConfig}
onConfigUpdated={handleTracingConfigUpdated}
onConfigRemoved={handleTracingConfigRemoved}
controlShowPopup={controlShowPopup}
/>
<div className='ml-2 rounded-md p-1'>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
</>
)}
</div >
</div >
<Divider type='vertical' className='h-3.5' />
</div>
</ConfigButton>
)}
</div>
)
}
export default React.memo(Panel)

+ 1
- 3
web/app/(commonLayout)/datasets/create/page.tsx View File

@@ -1,9 +1,7 @@
import React from 'react'
import DatasetUpdateForm from '@/app/components/datasets/create'

type Props = {}

const DatasetCreation = async (props: Props) => {
const DatasetCreation = async () => {
return (
<DatasetUpdateForm />
)

+ 2
- 1
web/app/components/app/annotation/header-opts/index.tsx View File

@@ -88,7 +88,8 @@ const HeaderOptions: FC<Props> = ({
await clearAllAnnotations(appId)
onAdded()
}
catch (_) {
catch (e) {
console.error(`failed to clear all annotations, ${e}`)
}
finally {
setShowClearConfirm(false)

+ 2
- 2
web/app/components/apps/footer.tsx View File

@@ -39,10 +39,10 @@ const Footer = () => {
<footer className='relative shrink-0 grow-0 px-12 py-2'>
<button
onClick={handleClose}
className='absolute right-2 top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full transition-colors duration-200 ease-in-out hover:bg-gray-100 dark:hover:bg-gray-800'
className='absolute right-2 top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full transition-colors duration-200 ease-in-out hover:bg-components-main-nav-nav-button-bg-active'
aria-label="Close footer"
>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
<RiCloseLine className='h-4 w-4 text-text-tertiary hover:text-text-secondary' />
</button>
<h3 className='text-gradient text-xl font-semibold leading-tight'>{t('app.join')}</h3>
<p className='system-sm-regular mt-1 text-text-tertiary'>{t('app.communityIntro')}</p>

+ 5
- 2
web/app/components/base/chat/chat-with-history/hooks.tsx View File

@@ -115,8 +115,11 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}, [])

useEffect(() => {
if (appData?.site.default_language)
changeLanguage(appData.site.default_language)
const setLocaleFromProps = async () => {
if (appData?.site.default_language)
await changeLanguage(appData.site.default_language)
}
setLocaleFromProps()
}, [appData])

const [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(false)

+ 3
- 3
web/app/components/base/chat/embedded-chatbot/hooks.tsx View File

@@ -101,15 +101,15 @@ export const useEmbeddedChatbot = () => {

if (localeParam) {
// If locale parameter exists in URL, use it instead of default
changeLanguage(localeParam)
await changeLanguage(localeParam)
}
else if (localeFromSysVar) {
// If locale is set as a system variable, use that
changeLanguage(localeFromSysVar)
await changeLanguage(localeFromSysVar)
}
else if (appInfo?.site.default_language) {
// Otherwise use the default from app config
changeLanguage(appInfo.site.default_language)
await changeLanguage(appInfo.site.default_language)
}
}


+ 2
- 14
web/app/components/base/file-uploader/hooks.ts View File

@@ -68,6 +68,7 @@ export const useFile = (fileConfig: FileUpload) => {
}
return true
}
case SupportUploadFileTypes.custom:
case SupportUploadFileTypes.document: {
if (fileSize > docSizeLimit) {
notify({
@@ -107,19 +108,6 @@ export const useFile = (fileConfig: FileUpload) => {
}
return true
}
case SupportUploadFileTypes.custom: {
if (fileSize > docSizeLimit) {
notify({
type: 'error',
message: t('common.fileUploader.uploadFromComputerLimit', {
type: SupportUploadFileTypes.document,
size: formatFileSize(docSizeLimit),
}),
})
return false
}
return true
}
default: {
return true
}
@@ -231,7 +219,7 @@ export const useFile = (fileConfig: FileUpload) => {
url: res.url,
}
if (!isAllowedFileExtension(res.name, res.mime_type, fileConfig.allowed_file_types || [], fileConfig.allowed_file_extensions || [])) {
notify({ type: 'error', message: `${t('common.fileUploader.fileExtensionNotSupport')} ${file.type}` })
notify({ type: 'error', message: `${t('common.fileUploader.fileExtensionNotSupport')} ${newFile.type}` })
handleRemoveFile(uploadingFile.id)
}
if (!checkSizeLimit(newFile.supportFileType, newFile.size))

+ 17
- 0
web/app/components/base/icons/utils.ts View File

@@ -14,9 +14,26 @@ export type Attrs = {

export function normalizeAttrs(attrs: Attrs = {}): Attrs {
return Object.keys(attrs).reduce((acc: Attrs, key) => {
// Filter out editor metadata attributes before processing
if (key.startsWith('inkscape:')
|| key.startsWith('sodipodi:')
|| key.startsWith('xmlns:inkscape')
|| key.startsWith('xmlns:sodipodi')
|| key.startsWith('xmlns:svg')
|| key === 'data-name')
return acc

const val = attrs[key]
key = key.replace(/([-]\w)/g, (g: string) => g[1].toUpperCase())
key = key.replace(/([:]\w)/g, (g: string) => g[1].toUpperCase())

// Additional filter after camelCase conversion
if (key === 'xmlnsInkscape'
|| key === 'xmlnsSodipodi'
|| key === 'xmlnsSvg'
|| key === 'dataName')
return acc

switch (key) {
case 'class':
acc.className = val

+ 4
- 1
web/app/components/base/tag-management/filter.tsx View File

@@ -139,7 +139,10 @@ const TagFilter: FC<TagFilterProps> = ({
</div>
<div className='border-t-[0.5px] border-divider-regular' />
<div className='p-1'>
<div className='flex cursor-pointer items-center gap-2 rounded-lg py-[6px] pl-3 pr-2 hover:bg-state-base-hover' onClick={() => setShowTagManagementModal(true)}>
<div className='flex cursor-pointer items-center gap-2 rounded-lg py-[6px] pl-3 pr-2 hover:bg-state-base-hover' onClick={() => {
setShowTagManagementModal(true)
setOpen(false)
}}>
<Tag03 className='h-4 w-4 text-text-tertiary' />
<div className='grow truncate text-sm leading-5 text-text-secondary'>
{t('common.tag.manageTags')}

+ 2
- 2
web/app/components/datasets/list/doc.tsx View File

@@ -87,7 +87,7 @@ const Doc = ({ apiBaseUrl }: DocProps) => {
<div className={`fixed right-20 top-32 z-10 transition-all ${isTocExpanded ? 'w-64' : 'w-10'}`}>
{isTocExpanded
? (
<nav className='toc max-h-[calc(100vh-150px)] w-full overflow-y-auto rounded-lg bg-components-panel-bg p-4 shadow-md'>
<nav className='toc max-h-[calc(100vh-150px)] w-full overflow-y-auto rounded-lg border border-components-panel-border bg-components-panel-bg p-4 shadow-md'>
<div className='mb-4 flex items-center justify-between'>
<h3 className='text-lg font-semibold text-text-primary'>{t('appApi.develop.toc')}</h3>
<button
@@ -115,7 +115,7 @@ const Doc = ({ apiBaseUrl }: DocProps) => {
: (
<button
onClick={() => setIsTocExpanded(true)}
className='flex h-10 w-10 items-center justify-center rounded-full bg-components-button-secondary-bg shadow-md transition-colors duration-200 hover:bg-components-button-secondary-bg-hover'
className='flex h-10 w-10 items-center justify-center rounded-full border border-components-panel-border bg-components-button-secondary-bg shadow-md transition-colors duration-200 hover:bg-components-button-secondary-bg-hover'
>
<RiListUnordered className='h-6 w-6 text-components-button-secondary-text' />
</button>

+ 40
- 40
web/app/components/datasets/list/template/template.en.mdx View File

@@ -25,7 +25,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</CodeGroup>
</div>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/document/create-by-text'
@@ -163,7 +163,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/document/create-by-file'
@@ -294,7 +294,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets'
@@ -400,7 +400,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets'
@@ -472,7 +472,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}'
@@ -553,7 +553,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}'
@@ -714,7 +714,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}'
@@ -751,7 +751,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/update-by-text'
@@ -853,7 +853,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/update-by-file'
@@ -952,7 +952,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{batch}/indexing-status'
@@ -1007,7 +1007,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}'
@@ -1047,7 +1047,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents'
@@ -1122,7 +1122,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}'
@@ -1245,7 +1245,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>
___
<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/status/{action}'
@@ -1302,7 +1302,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments'
@@ -1388,7 +1388,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments'
@@ -1476,7 +1476,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}'
@@ -1546,7 +1546,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}'
@@ -1590,7 +1590,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}'
@@ -1679,7 +1679,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks'
@@ -1750,7 +1750,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks'
@@ -1827,7 +1827,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}'
@@ -1873,7 +1873,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}'
@@ -1947,7 +1947,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/upload-file'
@@ -1998,7 +1998,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/retrieve'
@@ -2177,7 +2177,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/metadata'
@@ -2224,7 +2224,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/metadata/{metadata_id}'
@@ -2273,7 +2273,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/metadata/{metadata_id}'
@@ -2306,7 +2306,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/metadata/built-in/{action}'
@@ -2339,7 +2339,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/metadata'
@@ -2378,7 +2378,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/metadata'
@@ -2424,7 +2424,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />
<Heading
url='/workspaces/current/models/model-types/text-embedding'
@@ -2528,7 +2528,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />
Okay, I will translate the Chinese text in your document while keeping all formatting and code content unchanged.

<Heading
@@ -2574,7 +2574,7 @@ Okay, I will translate the Chinese text in your document while keeping all forma
</Row>


<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/tags'
@@ -2615,7 +2615,7 @@ Okay, I will translate the Chinese text in your document while keeping all forma
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/tags'
@@ -2662,7 +2662,7 @@ Okay, I will translate the Chinese text in your document while keeping all forma
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />


<Heading
@@ -2704,7 +2704,7 @@ Okay, I will translate the Chinese text in your document while keeping all forma
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/tags/binding'
@@ -2746,7 +2746,7 @@ Okay, I will translate the Chinese text in your document while keeping all forma
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/tags/unbinding'
@@ -2789,7 +2789,7 @@ Okay, I will translate the Chinese text in your document while keeping all forma
</Row>


<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/<uuid:dataset_id>/tags'
@@ -2837,7 +2837,7 @@ Okay, I will translate the Chinese text in your document while keeping all forma
</Row>


<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />


<Row>

+ 37
- 37
web/app/components/datasets/list/template/template.ja.mdx View File

@@ -25,7 +25,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</CodeGroup>
</div>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/document/create-by-text'
@@ -163,7 +163,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/document/create-by-file'
@@ -294,7 +294,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets'
@@ -399,7 +399,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets'
@@ -471,7 +471,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}'
@@ -508,7 +508,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/update-by-text'
@@ -610,7 +610,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/update-by-file'
@@ -709,7 +709,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{batch}/indexing-status'
@@ -764,7 +764,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}'
@@ -804,7 +804,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents'
@@ -879,7 +879,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}'
@@ -1002,7 +1002,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>
___
<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />


<Heading
@@ -1060,7 +1060,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments'
@@ -1146,7 +1146,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments'
@@ -1234,7 +1234,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}'
@@ -1304,7 +1304,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
method='DELETE'
@@ -1347,7 +1347,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
method='POST'
@@ -1435,7 +1435,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks'
@@ -1506,7 +1506,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks'
@@ -1583,7 +1583,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}'
@@ -1629,7 +1629,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}'
@@ -1703,7 +1703,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/upload-file'
@@ -1754,7 +1754,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/retrieve'
@@ -1933,7 +1933,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/metadata'
@@ -1980,7 +1980,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/metadata/{metadata_id}'
@@ -2029,7 +2029,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/metadata/{metadata_id}'
@@ -2062,7 +2062,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/metadata/built-in/{action}'
@@ -2095,7 +2095,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/metadata'
@@ -2136,7 +2136,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/metadata'
@@ -2182,7 +2182,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />
<Heading
url='/datasets/tags'
method='POST'
@@ -2226,7 +2226,7 @@ ___
</Row>


<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/tags'
@@ -2267,7 +2267,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/tags'
@@ -2314,7 +2314,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />


<Heading
@@ -2356,7 +2356,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/tags/binding'
@@ -2398,7 +2398,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/tags/unbinding'
@@ -2441,7 +2441,7 @@ ___
</Row>


<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/<uuid:dataset_id>/tags'
@@ -2489,7 +2489,7 @@ ___
</Row>


<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Row>
<Col>

+ 41
- 41
web/app/components/datasets/list/template/template.zh.mdx View File

@@ -25,7 +25,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</CodeGroup>
</div>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/document/create-by-text'
@@ -167,7 +167,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/document/create-by-file'
@@ -298,7 +298,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets'
@@ -403,7 +403,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets'
@@ -475,7 +475,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}'
@@ -556,7 +556,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}'
@@ -721,7 +721,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}'
@@ -758,7 +758,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/update-by-text'
@@ -860,7 +860,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/update-by-file'
@@ -959,7 +959,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{batch}/indexing-status'
@@ -1014,7 +1014,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}'
@@ -1054,7 +1054,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents'
@@ -1129,7 +1129,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}'
@@ -1252,7 +1252,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
</Col>
</Row>
___
<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />


<Heading
@@ -1310,7 +1310,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments'
@@ -1396,7 +1396,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments'
@@ -1484,7 +1484,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}'
@@ -1528,7 +1528,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}'
@@ -1598,7 +1598,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
method='POST'
@@ -1687,7 +1687,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks'
@@ -1758,7 +1758,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks'
@@ -1835,7 +1835,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}'
@@ -1881,7 +1881,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Row>
<Col>
@@ -1915,7 +1915,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}'
@@ -1989,7 +1989,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/{document_id}/upload-file'
@@ -2040,7 +2040,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/retrieve'
@@ -2219,7 +2219,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/metadata'
@@ -2266,7 +2266,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/metadata/{metadata_id}'
@@ -2315,7 +2315,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/metadata/{metadata_id}'
@@ -2348,7 +2348,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/metadata/built-in/{action}'
@@ -2381,7 +2381,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/documents/metadata'
@@ -2422,7 +2422,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/{dataset_id}/metadata'
@@ -2468,7 +2468,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />
<Heading
url='/workspaces/current/models/model-types/text-embedding'
@@ -2572,7 +2572,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/tags'
@@ -2617,7 +2617,7 @@ ___
</Row>


<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/tags'
@@ -2658,7 +2658,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/tags'
@@ -2705,7 +2705,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />


<Heading
@@ -2747,7 +2747,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/tags/binding'
@@ -2789,7 +2789,7 @@ ___
</Col>
</Row>

<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/tags/unbinding'
@@ -2832,7 +2832,7 @@ ___
</Row>


<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Heading
url='/datasets/<uuid:dataset_id>/tags'
@@ -2880,7 +2880,7 @@ ___
</Row>


<hr className='ml-0 mr-0' />
<hr style={{ marginLeft: 0, marginRight: 0, width: '100%', maxWidth: '100%' }} />

<Row>
<Col>

+ 2
- 2
web/app/components/develop/doc.tsx View File

@@ -87,7 +87,7 @@ const Doc = ({ appDetail }: IDocProps) => {
<div className={`fixed right-8 top-32 z-10 transition-all ${isTocExpanded ? 'w-64' : 'w-10'}`}>
{isTocExpanded
? (
<nav className="toc max-h-[calc(100vh-150px)] w-full overflow-y-auto rounded-lg bg-components-panel-bg p-4 shadow-md">
<nav className="toc max-h-[calc(100vh-150px)] w-full overflow-y-auto rounded-lg border border-components-panel-border bg-components-panel-bg p-4 shadow-md">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-text-primary">{t('appApi.develop.toc')}</h3>
<button
@@ -115,7 +115,7 @@ const Doc = ({ appDetail }: IDocProps) => {
: (
<button
onClick={() => setIsTocExpanded(true)}
className="flex h-10 w-10 items-center justify-center rounded-full bg-components-button-secondary-bg shadow-md transition-colors duration-200 hover:bg-components-button-secondary-bg-hover"
className="flex h-10 w-10 items-center justify-center rounded-full border border-components-panel-border bg-components-button-secondary-bg shadow-md transition-colors duration-200 hover:bg-components-button-secondary-bg-hover"
>
<RiListUnordered className="h-6 w-6 text-components-button-secondary-text" />
</button>

+ 1
- 1
web/app/components/share/text-generation/index.tsx View File

@@ -371,7 +371,7 @@ const TextGeneration: FC<IMainProps> = ({
setAppId(appId)
setSiteInfo(siteInfo as SiteInfo)
setCustomConfig(custom_config)
changeLanguage(siteInfo.default_language)
await changeLanguage(siteInfo.default_language)

const { user_input_form, more_like_this, file_upload, text_to_speech }: any = appParams
setVisionConfig({

+ 2
- 1
web/app/components/workflow/custom-edge.tsx View File

@@ -134,7 +134,8 @@ const CustomEdge = ({
style={{
stroke,
strokeWidth: 2,
opacity: data._waitingRun ? 0.7 : 1,
opacity: data._dimmed ? 0.3 : (data._waitingRun ? 0.7 : 1),
strokeDasharray: data._isTemp ? '8 8' : undefined,
}}
/>
<EdgeLabelRenderer>

+ 133
- 1
web/app/components/workflow/hooks/use-nodes-interactions.ts View File

@@ -1,5 +1,5 @@
import type { MouseEvent } from 'react'
import { useCallback, useRef } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import produce from 'immer'
import type {
@@ -62,6 +62,7 @@ import { WorkflowHistoryEvent, useWorkflowHistory } from './use-workflow-history
import { useNodesMetaData } from './use-nodes-meta-data'
import type { RAGPipelineVariables } from '@/models/pipeline'
import useInspectVarsCrud from './use-inspect-vars-crud'
import { getNodeUsedVars } from '../nodes/_base/components/variable/utils'

export const useNodesInteractions = () => {
const { t } = useTranslation()
@@ -1564,6 +1565,135 @@ export const useNodesInteractions = () => {
setNodes(nodes)
}, [redo, store, workflowHistoryStore, getNodesReadOnly, getWorkflowReadOnly])

const [isDimming, setIsDimming] = useState(false)
/** Add opacity-30 to all nodes except the nodeId */
const dimOtherNodes = useCallback(() => {
if (isDimming)
return
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()

const selectedNode = nodes.find(n => n.data.selected)
if (!selectedNode)
return

setIsDimming(true)

// const workflowNodes = useStore(s => s.getNodes())
const workflowNodes = nodes

const usedVars = getNodeUsedVars(selectedNode)
const dependencyNodes: Node[] = []
usedVars.forEach((valueSelector) => {
const node = workflowNodes.find(node => node.id === valueSelector?.[0])
if (node) {
if (!dependencyNodes.includes(node))
dependencyNodes.push(node)
}
})

const outgoers = getOutgoers(selectedNode as Node, nodes as Node[], edges)
for (let currIdx = 0; currIdx < outgoers.length; currIdx++) {
const node = outgoers[currIdx]
const outgoersForNode = getOutgoers(node, nodes as Node[], edges)
outgoersForNode.forEach((item) => {
const existed = outgoers.some(v => v.id === item.id)
if (!existed)
outgoers.push(item)
})
}

const dependentNodes: Node[] = []
outgoers.forEach((node) => {
const usedVars = getNodeUsedVars(node)
const used = usedVars.some(v => v?.[0] === selectedNode.id)
if (used) {
const existed = dependentNodes.some(v => v.id === node.id)
if (!existed)
dependentNodes.push(node)
}
})

const dimNodes = [...dependencyNodes, ...dependentNodes, selectedNode]

const newNodes = produce(nodes, (draft) => {
draft.forEach((n) => {
const dimNode = dimNodes.find(v => v.id === n.id)
if (!dimNode)
n.data._dimmed = true
})
})

setNodes(newNodes)

const tempEdges: Edge[] = []

dependencyNodes.forEach((n) => {
tempEdges.push({
id: `tmp_${n.id}-source-${selectedNode.id}-target`,
type: CUSTOM_EDGE,
source: n.id,
sourceHandle: 'source_tmp',
target: selectedNode.id,
targetHandle: 'target_tmp',
animated: true,
data: {
sourceType: n.data.type,
targetType: selectedNode.data.type,
_isTemp: true,
_connectedNodeIsHovering: true,
},
})
})
dependentNodes.forEach((n) => {
tempEdges.push({
id: `tmp_${selectedNode.id}-source-${n.id}-target`,
type: CUSTOM_EDGE,
source: selectedNode.id,
sourceHandle: 'source_tmp',
target: n.id,
targetHandle: 'target_tmp',
animated: true,
data: {
sourceType: selectedNode.data.type,
targetType: n.data.type,
_isTemp: true,
_connectedNodeIsHovering: true,
},
})
})

const newEdges = produce(edges, (draft) => {
draft.forEach((e) => {
e.data._dimmed = true
})
draft.push(...tempEdges)
})
setEdges(newEdges)
}, [isDimming, store])

/** Restore all nodes to full opacity */
const undimAllNodes = useCallback(() => {
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
setIsDimming(false)

const newNodes = produce(nodes, (draft) => {
draft.forEach((n) => {
n.data._dimmed = false
})
})

setNodes(newNodes)

const newEdges = produce(edges.filter(e => !e.data._isTemp), (draft) => {
draft.forEach((e) => {
e.data._dimmed = false
})
})
setEdges(newEdges)
}, [store])

return {
handleNodeDragStart,
handleNodeDrag,
@@ -1588,5 +1718,7 @@ export const useNodesInteractions = () => {
handleNodeDisconnect,
handleHistoryBack,
handleHistoryForward,
dimOtherNodes,
undimAllNodes,
}
}

+ 24
- 0
web/app/components/workflow/hooks/use-selection-interactions.ts View File

@@ -131,10 +131,34 @@ export const useSelectionInteractions = () => {
setEdges(newEdges)
}, [store])

const handleSelectionContextMenu = useCallback((e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.classList.contains('react-flow__nodesselection-rect'))
return

e.preventDefault()
const container = document.querySelector('#workflow-container')
const { x, y } = container!.getBoundingClientRect()
workflowStore.setState({
selectionMenu: {
top: e.clientY - y,
left: e.clientX - x,
},
})
}, [workflowStore])

const handleSelectionContextmenuCancel = useCallback(() => {
workflowStore.setState({
selectionMenu: undefined,
})
}, [workflowStore])

return {
handleSelectionStart,
handleSelectionChange,
handleSelectionDrag,
handleSelectionCancel,
handleSelectionContextMenu,
handleSelectionContextmenuCancel,
}
}

+ 33
- 0
web/app/components/workflow/hooks/use-shortcuts.ts View File

@@ -25,6 +25,8 @@ export const useShortcuts = (): void => {
handleNodesDelete,
handleHistoryBack,
handleHistoryForward,
dimOtherNodes,
undimAllNodes,
} = useNodesInteractions()
const { handleStartWorkflowRun } = useWorkflowStartRun()
const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore()
@@ -211,4 +213,35 @@ export const useShortcuts = (): void => {
exactMatch: true,
useCapture: true,
})

// Shift ↓
useKeyPress(
'shift',
(e) => {
console.log('Shift down', e)
if (shouldHandleShortcut(e))
dimOtherNodes()
},
{
exactMatch: true,
useCapture: true,
events: ['keydown'],
},
)

// Shift ↑
useKeyPress(
(e) => {
return e.key === 'Shift'
},
(e) => {
if (shouldHandleShortcut(e))
undimAllNodes()
},
{
exactMatch: true,
useCapture: true,
events: ['keyup'],
},
)
}

+ 4
- 0
web/app/components/workflow/index.tsx View File

@@ -67,6 +67,7 @@ import HelpLine from './help-line'
import CandidateNode from './candidate-node'
import PanelContextmenu from './panel-contextmenu'
import NodeContextmenu from './node-contextmenu'
import SelectionContextmenu from './selection-contextmenu'
import SyncingDataModal from './syncing-data-modal'
import LimitTips from './limit-tips'
import {
@@ -266,6 +267,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
handleSelectionStart,
handleSelectionChange,
handleSelectionDrag,
handleSelectionContextMenu,
} = useSelectionInteractions()
const {
handlePaneContextMenu,
@@ -316,6 +318,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
<Operator handleRedo={handleHistoryForward} handleUndo={handleHistoryBack} />
<PanelContextmenu />
<NodeContextmenu />
<SelectionContextmenu />
<HelpLine />
{
!!showConfirm && (
@@ -352,6 +355,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
onSelectionChange={handleSelectionChange}
onSelectionDrag={handleSelectionDrag}
onPaneContextMenu={handlePaneContextMenu}
onSelectionContextMenu={handleSelectionContextMenu}
connectionLineComponent={CustomConnectionLine}
// TODO: For LOOP node, how to distinguish between ITERATION and LOOP here? Maybe both are the same?
connectionLineContainerStyle={{ zIndex: ITERATION_CHILDREN_Z_INDEX }}

+ 1
- 0
web/app/components/workflow/nodes/_base/components/workflow-panel/tab.tsx View File

@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
export enum TabType {
settings = 'settings',
lastRun = 'lastRun',
relations = 'relations',
}

type Props = {

+ 1
- 0
web/app/components/workflow/nodes/_base/node.tsx View File

@@ -143,6 +143,7 @@ const BaseNode: FC<BaseNodeProps> = ({
showSelectedBorder ? 'border-components-option-card-option-selected-border' : 'border-transparent',
!showSelectedBorder && data._inParallelHovering && 'border-workflow-block-border-highlight',
data._waitingRun && 'opacity-70',
data._dimmed && 'opacity-30',
)}
ref={nodeRef}
style={{

+ 1
- 1
web/app/components/workflow/nodes/answer/utils.ts View File

@@ -1,5 +1,5 @@
import type { AnswerNodeType } from './types'

export const checkNodeValid = (payload: AnswerNodeType) => {
export const checkNodeValid = (_payload: AnswerNodeType) => {
return true
}

+ 1
- 1
web/app/components/workflow/nodes/assigner/utils.ts View File

@@ -1,7 +1,7 @@
import type { AssignerNodeType } from './types'
import { AssignerNodeInputType, WriteMode } from './types'

export const checkNodeValid = (payload: AssignerNodeType) => {
export const checkNodeValid = (_payload: AssignerNodeType) => {
return true
}


+ 1
- 1
web/app/components/workflow/nodes/end/utils.ts View File

@@ -1,5 +1,5 @@
import type { EndNodeType } from './types'

export const checkNodeValid = (payload: EndNodeType) => {
export const checkNodeValid = (_payload: EndNodeType) => {
return true
}

+ 1
- 1
web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx View File

@@ -202,7 +202,7 @@ const ConditionItem = ({
onRemoveCondition?.(caseId, condition.id)
}, [caseId, condition, conditionId, isSubVariableKey, onRemoveCondition, onRemoveSubVariableCondition])

const handleVarChange = useCallback((valueSelector: ValueSelector, varItem: Var) => {
const handleVarChange = useCallback((valueSelector: ValueSelector, _varItem: Var) => {
const resolvedVarType = getVarType({
valueSelector,
availableNodes,

+ 1
- 1
web/app/components/workflow/nodes/llm/panel.tsx View File

@@ -82,7 +82,7 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
Toast.notify({ type: 'warning', message: `${t('common.modelProvider.parametersInvalidRemoved')}: ${keys.map(k => `${k} (${removedDetails[k]})`).join(', ')}` })
handleCompletionParamsChange(filtered)
}
catch (e) {
catch {
Toast.notify({ type: 'error', message: t('common.error') })
handleCompletionParamsChange({})
}

+ 2
- 2
web/app/components/workflow/nodes/llm/utils.ts View File

@@ -5,7 +5,7 @@ import { Validator } from 'jsonschema'
import produce from 'immer'
import { z } from 'zod'

export const checkNodeValid = (payload: LLMNodeType) => {
export const checkNodeValid = (_payload: LLMNodeType) => {
return true
}

@@ -280,7 +280,7 @@ const validator = new Validator()

export const validateSchemaAgainstDraft7 = (schemaToValidate: any) => {
const schema = produce(schemaToValidate, (draft: any) => {
// Make sure the schema has the $schema property for draft-07
// Make sure the schema has the $schema property for draft-07
if (!draft.$schema)
draft.$schema = 'http://json-schema.org/draft-07/schema#'
})

+ 1
- 6
web/app/components/workflow/nodes/loop/use-config.ts View File

@@ -6,7 +6,6 @@ import produce from 'immer'
import { v4 as uuid4 } from 'uuid'
import {
useIsChatMode,
useIsNodeInLoop,
useNodesReadOnly,
useWorkflow,
} from '../../hooks'
@@ -20,10 +19,8 @@ import type { HandleAddCondition, HandleAddSubVariableCondition, HandleRemoveCon
import useIsVarFileAttribute from './use-is-var-file-attribute'
import { useStore } from '@/app/components/workflow/store'

const DELIMITER = '@@@@@'
const useConfig = (id: string, payload: LoopNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly()
const { isNodeInLoop } = useIsNodeInLoop(id)
const isChatMode = useIsChatMode()
const conversationVariables = useStore(s => s.conversationVariables)

@@ -39,10 +36,8 @@ const useConfig = (id: string, payload: LoopNodeType) => {
}, [])

// output
const { getLoopNodeChildren, getBeforeNodesInSameBranch } = useWorkflow()
const beforeNodes = getBeforeNodesInSameBranch(id)
const { getLoopNodeChildren } = useWorkflow()
const loopChildrenNodes = [{ id, data: payload } as any, ...getLoopNodeChildren(id)]
const canChooseVarNodes = [...beforeNodes, ...loopChildrenNodes]
const childrenNodeVars = toNodeOutputVars(loopChildrenNodes, isChatMode, undefined, [], conversationVariables)

const {

+ 1
- 1
web/app/components/workflow/nodes/start/utils.ts View File

@@ -1,5 +1,5 @@
import type { StartNodeType } from './types'

export const checkNodeValid = (payload: StartNodeType) => {
export const checkNodeValid = (_payload: StartNodeType) => {
return true
}

+ 1
- 1
web/app/components/workflow/nodes/template-transform/utils.ts View File

@@ -1,5 +1,5 @@
import type { TemplateTransformNodeType } from './types'

export const checkNodeValid = (payload: TemplateTransformNodeType) => {
export const checkNodeValid = (_payload: TemplateTransformNodeType) => {
return true
}

+ 1
- 1
web/app/components/workflow/nodes/tool/utils.ts View File

@@ -1,5 +1,5 @@
import type { ToolNodeType } from './types'

export const checkNodeValid = (payload: ToolNodeType) => {
export const checkNodeValid = (_payload: ToolNodeType) => {
return true
}

+ 433
- 0
web/app/components/workflow/selection-contextmenu.tsx View File

@@ -0,0 +1,433 @@
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useClickAway } from 'ahooks'
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
import {
RiAlignBottom,
RiAlignCenter,
RiAlignJustify,
RiAlignLeft,
RiAlignRight,
RiAlignTop,
} from '@remixicon/react'
import { useNodesReadOnly, useNodesSyncDraft } from './hooks'
import produce from 'immer'
import { WorkflowHistoryEvent, useWorkflowHistory } from './hooks/use-workflow-history'
import { useStore } from './store'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
import { useWorkflowStore } from './store'

enum AlignType {
Left = 'left',
Center = 'center',
Right = 'right',
Top = 'top',
Middle = 'middle',
Bottom = 'bottom',
DistributeHorizontal = 'distributeHorizontal',
DistributeVertical = 'distributeVertical',
}

const SelectionContextmenu = () => {
const { t } = useTranslation()
const ref = useRef(null)
const { getNodesReadOnly } = useNodesReadOnly()
const { handleSelectionContextmenuCancel } = useSelectionInteractions()
const selectionMenu = useStore(s => s.selectionMenu)

// Access React Flow methods
const store = useStoreApi()
const workflowStore = useWorkflowStore()

// Get selected nodes for alignment logic
const selectedNodes = useReactFlowStore(state =>
state.getNodes().filter(node => node.selected),
)

const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { saveStateToHistory } = useWorkflowHistory()

const menuRef = useRef<HTMLDivElement>(null)

const menuPosition = useMemo(() => {
if (!selectionMenu) return { left: 0, top: 0 }

let left = selectionMenu.left
let top = selectionMenu.top

const container = document.querySelector('#workflow-container')
if (container) {
const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect()

const menuWidth = 240

const estimatedMenuHeight = 380

if (left + menuWidth > containerWidth)
left = left - menuWidth

if (top + estimatedMenuHeight > containerHeight)
top = top - estimatedMenuHeight

left = Math.max(0, left)
top = Math.max(0, top)
}

return { left, top }
}, [selectionMenu])

useClickAway(() => {
handleSelectionContextmenuCancel()
}, ref)

useEffect(() => {
if (selectionMenu && selectedNodes.length <= 1)
handleSelectionContextmenuCancel()
}, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel])

// Handle align nodes logic
const handleAlignNode = useCallback((currentNode: any, nodeToAlign: any, alignType: AlignType, minX: number, maxX: number, minY: number, maxY: number) => {
const width = nodeToAlign.width
const height = nodeToAlign.height

// Calculate new positions based on alignment type
switch (alignType) {
case AlignType.Left:
// For left alignment, align left edge of each node to minX
currentNode.position.x = minX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = minX
break

case AlignType.Center: {
// For center alignment, center each node horizontally in the selection bounds
const centerX = minX + (maxX - minX) / 2 - width / 2
currentNode.position.x = centerX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = centerX
break
}

case AlignType.Right: {
// For right alignment, align right edge of each node to maxX
const rightX = maxX - width
currentNode.position.x = rightX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = rightX
break
}

case AlignType.Top: {
// For top alignment, align top edge of each node to minY
currentNode.position.y = minY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = minY
break
}

case AlignType.Middle: {
// For middle alignment, center each node vertically in the selection bounds
const middleY = minY + (maxY - minY) / 2 - height / 2
currentNode.position.y = middleY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = middleY
break
}

case AlignType.Bottom: {
// For bottom alignment, align bottom edge of each node to maxY
const newY = Math.round(maxY - height)
currentNode.position.y = newY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = newY
break
}
}
}, [])

// Handle distribute nodes logic
const handleDistributeNodes = useCallback((nodesToAlign: any[], nodes: any[], alignType: AlignType) => {
// Sort nodes appropriately
const sortedNodes = [...nodesToAlign].sort((a, b) => {
if (alignType === AlignType.DistributeHorizontal) {
// Sort by left position for horizontal distribution
return a.position.x - b.position.x
}
else {
// Sort by top position for vertical distribution
return a.position.y - b.position.y
}
})

if (sortedNodes.length < 3)
return null // Need at least 3 nodes for distribution

let totalGap = 0
let fixedSpace = 0

if (alignType === AlignType.DistributeHorizontal) {
// Fixed positions - first node's left edge and last node's right edge
const firstNodeLeft = sortedNodes[0].position.x
const lastNodeRight = sortedNodes[sortedNodes.length - 1].position.x + (sortedNodes[sortedNodes.length - 1].width || 0)

// Total available space
totalGap = lastNodeRight - firstNodeLeft

// Space occupied by nodes themselves
fixedSpace = sortedNodes.reduce((sum, node) => sum + (node.width || 0), 0)
}
else {
// Fixed positions - first node's top edge and last node's bottom edge
const firstNodeTop = sortedNodes[0].position.y
const lastNodeBottom = sortedNodes[sortedNodes.length - 1].position.y + (sortedNodes[sortedNodes.length - 1].height || 0)

// Total available space
totalGap = lastNodeBottom - firstNodeTop

// Space occupied by nodes themselves
fixedSpace = sortedNodes.reduce((sum, node) => sum + (node.height || 0), 0)
}

// Available space for gaps
const availableSpace = totalGap - fixedSpace

// Calculate even spacing between node edges
const spacing = availableSpace / (sortedNodes.length - 1)

if (spacing <= 0)
return null // Nodes are overlapping, can't distribute evenly

return produce(nodes, (draft) => {
// Keep first node fixed, position others with even gaps
let currentPosition

if (alignType === AlignType.DistributeHorizontal) {
// Start from first node's right edge
currentPosition = sortedNodes[0].position.x + (sortedNodes[0].width || 0)
}
else {
// Start from first node's bottom edge
currentPosition = sortedNodes[0].position.y + (sortedNodes[0].height || 0)
}

// Skip first node (index 0), it stays in place
for (let i = 1; i < sortedNodes.length - 1; i++) {
const nodeToAlign = sortedNodes[i]
const currentNode = draft.find(n => n.id === nodeToAlign.id)
if (!currentNode) continue

if (alignType === AlignType.DistributeHorizontal) {
// Position = previous right edge + spacing
const newX: number = currentPosition + spacing
currentNode.position.x = newX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = newX

// Update for next iteration - current node's right edge
currentPosition = newX + (nodeToAlign.width || 0)
}
else {
// Position = previous bottom edge + spacing
const newY: number = currentPosition + spacing
currentNode.position.y = newY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = newY

// Update for next iteration - current node's bottom edge
currentPosition = newY + (nodeToAlign.height || 0)
}
}
})
}, [])

const handleAlignNodes = useCallback((alignType: AlignType) => {
if (getNodesReadOnly() || selectedNodes.length <= 1) {
handleSelectionContextmenuCancel()
return
}

// Disable node animation state - same as handleNodeDragStart
workflowStore.setState({ nodeAnimation: false })

// Get all current nodes
const nodes = store.getState().getNodes()

// Get all selected nodes
const selectedNodeIds = selectedNodes.map(node => node.id)
const nodesToAlign = nodes.filter(node => selectedNodeIds.includes(node.id))

if (nodesToAlign.length <= 1) {
handleSelectionContextmenuCancel()
return
}

// Calculate node boundaries for alignment
let minX = Number.MAX_SAFE_INTEGER
let maxX = Number.MIN_SAFE_INTEGER
let minY = Number.MAX_SAFE_INTEGER
let maxY = Number.MIN_SAFE_INTEGER

// Calculate boundaries of selected nodes
const validNodes = nodesToAlign.filter(node => node.width && node.height)
validNodes.forEach((node) => {
const width = node.width!
const height = node.height!
minX = Math.min(minX, node.position.x)
maxX = Math.max(maxX, node.position.x + width)
minY = Math.min(minY, node.position.y)
maxY = Math.max(maxY, node.position.y + height)
})

// Handle distribute nodes logic
if (alignType === AlignType.DistributeHorizontal || alignType === AlignType.DistributeVertical) {
const distributeNodes = handleDistributeNodes(nodesToAlign, nodes, alignType)
if (distributeNodes) {
// Apply node distribution updates
store.getState().setNodes(distributeNodes)
handleSelectionContextmenuCancel()

// Clear guide lines
const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState()
setHelpLineHorizontal()
setHelpLineVertical()

// Sync workflow draft
handleSyncWorkflowDraft()

// Save to history
saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)

return // End function execution
}
}

const newNodes = produce(nodes, (draft) => {
// Iterate through all selected nodes
const validNodesToAlign = nodesToAlign.filter(node => node.width && node.height)
validNodesToAlign.forEach((nodeToAlign) => {
// Find the corresponding node in draft - consistent with handleNodeDrag
const currentNode = draft.find(n => n.id === nodeToAlign.id)
if (!currentNode)
return

// Use the extracted alignment function
handleAlignNode(currentNode, nodeToAlign, alignType, minX, maxX, minY, maxY)
})
})

// Apply node position updates - consistent with handleNodeDrag and handleNodeDragStop
try {
// Directly use setNodes to update nodes - consistent with handleNodeDrag
store.getState().setNodes(newNodes)

// Close popup
handleSelectionContextmenuCancel()

// Clear guide lines - consistent with handleNodeDragStop
const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState()
setHelpLineHorizontal()
setHelpLineVertical()

// Sync workflow draft - consistent with handleNodeDragStop
handleSyncWorkflowDraft()

// Save to history - consistent with handleNodeDragStop
saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)
}
catch (err) {
console.error('Failed to update nodes:', err)
}
}, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes])

if (!selectionMenu)
return null

return (
<div
className='absolute z-[9]'
style={{
left: menuPosition.left,
top: menuPosition.top,
}}
ref={ref}
>
<div ref={menuRef} className='w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl'>
<div className='p-1'>
<div className='system-xs-medium px-2 py-2 text-text-tertiary'>
{t('workflow.operator.vertical')}
</div>
<div
className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
onClick={() => handleAlignNodes(AlignType.Top)}
>
<RiAlignTop className='h-4 w-4' />
{t('workflow.operator.alignTop')}
</div>
<div
className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
onClick={() => handleAlignNodes(AlignType.Middle)}
>
<RiAlignCenter className='h-4 w-4 rotate-90' />
{t('workflow.operator.alignMiddle')}
</div>
<div
className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
onClick={() => handleAlignNodes(AlignType.Bottom)}
>
<RiAlignBottom className='h-4 w-4' />
{t('workflow.operator.alignBottom')}
</div>
<div
className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
onClick={() => handleAlignNodes(AlignType.DistributeVertical)}
>
<RiAlignJustify className='h-4 w-4 rotate-90' />
{t('workflow.operator.distributeVertical')}
</div>
</div>
<div className='h-[1px] bg-divider-regular'></div>
<div className='p-1'>
<div className='system-xs-medium px-2 py-2 text-text-tertiary'>
{t('workflow.operator.horizontal')}
</div>
<div
className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
onClick={() => handleAlignNodes(AlignType.Left)}
>
<RiAlignLeft className='h-4 w-4' />
{t('workflow.operator.alignLeft')}
</div>
<div
className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
onClick={() => handleAlignNodes(AlignType.Center)}
>
<RiAlignCenter className='h-4 w-4' />
{t('workflow.operator.alignCenter')}
</div>
<div
className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
onClick={() => handleAlignNodes(AlignType.Right)}
>
<RiAlignRight className='h-4 w-4' />
{t('workflow.operator.alignRight')}
</div>
<div
className='flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
onClick={() => handleAlignNodes(AlignType.DistributeHorizontal)}
>
<RiAlignJustify className='h-4 w-4' />
{t('workflow.operator.distributeHorizontal')}
</div>
</div>
</div>
</div>
)
}

export default memo(SelectionContextmenu)

+ 8
- 8
web/app/components/workflow/store/workflow/debug/inspect-vars-slice.ts View File

@@ -23,7 +23,7 @@ type InspectVarsActions = {

export type InspectVarsSliceShape = InspectVarsState & InspectVarsActions

export const createInspectVarsSlice: StateCreator<InspectVarsSliceShape> = (set, get) => {
export const createInspectVarsSlice: StateCreator<InspectVarsSliceShape> = (set) => {
return ({
currentFocusNodeId: null,
nodesWithInspectVars: [],
@@ -75,11 +75,11 @@ export const createInspectVarsSlice: StateCreator<InspectVarsSliceShape> = (set,
if (!targetNode)
return
const targetVar = targetNode.vars.find(varItem => varItem.id === varId)
if(!targetVar)
if (!targetVar)
return
targetVar.value = value
targetVar.edited = true
},
},
)
return {
nodesWithInspectVars: nodes,
@@ -93,11 +93,11 @@ export const createInspectVarsSlice: StateCreator<InspectVarsSliceShape> = (set,
if (!targetNode)
return
const targetVar = targetNode.vars.find(varItem => varItem.id === varId)
if(!targetVar)
if (!targetVar)
return
targetVar.value = value
targetVar.edited = false
},
},
)
return {
nodesWithInspectVars: nodes,
@@ -111,11 +111,11 @@ export const createInspectVarsSlice: StateCreator<InspectVarsSliceShape> = (set,
if (!targetNode)
return
const targetVar = targetNode.vars.find(varItem => varItem.id === varId)
if(!targetVar)
if (!targetVar)
return
targetVar.name = selector[1]
targetVar.selector = selector
},
},
)
return {
nodesWithInspectVars: nodes,
@@ -131,7 +131,7 @@ export const createInspectVarsSlice: StateCreator<InspectVarsSliceShape> = (set,
const needChangeVarIndex = targetNode.vars.findIndex(varItem => varItem.id === varId)
if (needChangeVarIndex !== -1)
targetNode.vars.splice(needChangeVarIndex, 1)
},
},
)
return {
nodesWithInspectVars: nodes,

+ 7
- 0
web/app/components/workflow/store/workflow/panel-slice.ts View File

@@ -15,6 +15,11 @@ export type PanelSliceShape = {
left: number
}
setPanelMenu: (panelMenu: PanelSliceShape['panelMenu']) => void
selectionMenu?: {
top: number
left: number
}
setSelectionMenu: (selectionMenu: PanelSliceShape['selectionMenu']) => void
showVariableInspectPanel: boolean
setShowVariableInspectPanel: (showVariableInspectPanel: boolean) => void
initShowLastRunTab: boolean
@@ -33,6 +38,8 @@ export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
setShowDebugAndPreviewPanel: showDebugAndPreviewPanel => set(() => ({ showDebugAndPreviewPanel })),
panelMenu: undefined,
setPanelMenu: panelMenu => set(() => ({ panelMenu })),
selectionMenu: undefined,
setSelectionMenu: selectionMenu => set(() => ({ selectionMenu })),
showVariableInspectPanel: false,
setShowVariableInspectPanel: showVariableInspectPanel => set(() => ({ showVariableInspectPanel })),
initShowLastRunTab: false,

+ 3
- 1
web/app/components/workflow/types.ts View File

@@ -103,6 +103,7 @@ export type CommonNodeType<T = {}> = {
retry_config?: WorkflowRetryConfig
default_value?: DefaultValueForm[]
credential_id?: string
_dimmed?: boolean
} & T & Partial<Pick<ToolDefaultValue, 'provider_id' | 'provider_type' | 'provider_name' | 'tool_name'>>
& Partial<Pick<DataSourceDefaultValue, 'plugin_id' | 'provider_type' | 'provider_name' | 'datasource_name'>>

@@ -119,7 +120,8 @@ export type CommonEdgeType = {
isInLoop?: boolean
loop_id?: string
sourceType: BlockEnum
targetType: BlockEnum
targetType: BlockEnum,
_isTemp?: boolean,
}

export type Node<T = {}> = ReactFlowNode<CommonNodeType<T>>

+ 1
- 0
web/eslint.config.mjs View File

@@ -82,6 +82,7 @@ export default combine(
'**/.next/',
'**/public/*',
'**/*.json',
'**/*.js',
],
},
{

+ 5
- 0
web/global.d.ts View File

@@ -3,3 +3,8 @@ declare module 'lamejs/src/js/MPEGMode';
declare module 'lamejs/src/js/Lame';
declare module 'lamejs/src/js/BitStream';
declare module 'react-18-input-autosize';

declare module '*.mdx' {
let MDXComponent: (props: any) => JSX.Element
export default MDXComponent
}

+ 5
- 5
web/i18n-config/i18next-config.ts View File

@@ -89,11 +89,11 @@ if (!i18n.isInitialized) {
}

export const changeLanguage = async (lng?: string) => {
const resolvedLng = lng ?? 'en-US'
const resource = await loadLangResources(resolvedLng)
if (!i18n.hasResourceBundle(resolvedLng, 'translation'))
i18n.addResourceBundle(resolvedLng, 'translation', resource, true, true)
await i18n.changeLanguage(resolvedLng)
if (!lng) return
const resource = await loadLangResources(lng)
if (!i18n.hasResourceBundle(lng, 'translation'))
i18n.addResourceBundle(lng, 'translation', resource, true, true)
await i18n.changeLanguage(lng)
}

export default i18n

+ 9
- 0
web/i18n/de-DE/workflow.ts View File

@@ -968,6 +968,15 @@ const translation = {
},
settingsTab: 'Einstellungen',
lastRunTab: 'Letzte Ausführung',
relations: {
dependents: 'Angehörige',
dependenciesDescription: 'Knoten, auf die sich dieser Knoten stützt',
dependencies: 'Abhängigkeiten',
noDependencies: 'Keine Abhängigkeiten',
dependentsDescription: 'Knoten, die auf diesem Knoten basieren',
noDependents: 'Keine Angehörigen',
},
relationsTab: 'Beziehungen',
},
}


+ 21
- 0
web/i18n/en-US/workflow.ts View File

@@ -294,6 +294,18 @@ const translation = {
zoomTo50: 'Zoom to 50%',
zoomTo100: 'Zoom to 100%',
zoomToFit: 'Zoom to Fit',
alignNodes: 'Align Nodes',
alignLeft: 'Left',
alignCenter: 'Center',
alignRight: 'Right',
alignTop: 'Top',
alignMiddle: 'Middle',
alignBottom: 'Bottom',
vertical: 'Vertical',
horizontal: 'Horizontal',
distributeHorizontal: 'Space Horizontally',
distributeVertical: 'Space Vertically',
selectionAlignment: 'Selection Alignment',
},
variableReference: {
noAvailableVars: 'No available variables',
@@ -965,6 +977,7 @@ const translation = {
debug: {
settingsTab: 'Settings',
lastRunTab: 'Last Run',
relationsTab: 'Relations',
noData: {
description: 'The results of the last run will be displayed here',
runThisNode: 'Run this node',
@@ -990,6 +1003,14 @@ const translation = {
chatNode: 'Conversation',
systemNode: 'System',
},
relations: {
dependencies: 'Dependencies',
dependents: 'Dependents',
dependenciesDescription: 'Nodes that this node relies on',
dependentsDescription: 'Nodes that rely on this node',
noDependencies: 'No dependencies',
noDependents: 'No dependents',
},
},
}


+ 9
- 0
web/i18n/es-ES/workflow.ts View File

@@ -968,6 +968,15 @@ const translation = {
},
lastRunTab: 'Última ejecución',
settingsTab: 'Ajustes',
relations: {
dependents: 'Dependientes',
dependenciesDescription: 'Nodos en los que se basa este nodo',
dependentsDescription: 'Nodos que dependen de este nodo',
noDependencies: 'Sin dependencias',
noDependents: 'Sin dependientes',
dependencies: 'Dependencias',
},
relationsTab: 'Relaciones',
},
}


+ 9
- 3
web/i18n/fa-IR/workflow.ts View File

@@ -104,9 +104,7 @@ const translation = {
noHistory: 'بدون تاریخچه',
loadMore: 'بارگذاری گردش کار بیشتر',
exportPNG: 'صادرات به فرمت PNG',
noExist: 'هیچگونه متغیری وجود ندارد',
exitVersions: 'نسخه‌های خروجی',
referenceVar: 'متغیر مرجع',
exportSVG: 'صادرات به فرمت SVG',
exportJPEG: 'صادرات به فرمت JPEG',
exportImage: 'تصویر را صادر کنید',
@@ -608,7 +606,6 @@ const translation = {
},
select: 'انتخاب',
addSubVariable: 'متغیر فرعی',
condition: 'شرط',
},
variableAssigner: {
title: 'تخصیص متغیرها',
@@ -971,6 +968,15 @@ const translation = {
},
settingsTab: 'تنظیمات',
lastRunTab: 'آخرین اجرا',
relations: {
dependents: 'وابسته',
dependencies: 'وابسته',
noDependents: 'بدون وابستگان',
noDependencies: 'بدون وابستگی',
dependenciesDescription: 'گره هایی که این گره به آنها متکی است',
dependentsDescription: 'گره هایی که به این گره متکی هستند',
},
relationsTab: 'روابط',
},
}


+ 9
- 3
web/i18n/fr-FR/workflow.ts View File

@@ -107,9 +107,7 @@ const translation = {
exitVersions: 'Versions de sortie',
exportSVG: 'Exporter en SVG',
publishUpdate: 'Publier une mise à jour',
noExist: 'Aucune variable de ce type',
versionHistory: 'Historique des versions',
referenceVar: 'Variable de référence',
exportImage: 'Exporter l\'image',
exportJPEG: 'Exporter en JPEG',
needEndNode: 'Le nœud de fin doit être ajouté',
@@ -608,7 +606,6 @@ const translation = {
},
select: 'Choisir',
addSubVariable: 'Sous-variable',
condition: 'Condition',
},
variableAssigner: {
title: 'Attribuer des variables',
@@ -971,6 +968,15 @@ const translation = {
},
settingsTab: 'Paramètres',
lastRunTab: 'Dernière Exécution',
relations: {
dependencies: 'Dépendances',
dependentsDescription: 'Nœuds qui s’appuient sur ce nœud',
noDependents: 'Pas de personnes à charge',
dependents: 'Dépendants',
noDependencies: 'Aucune dépendance',
dependenciesDescription: 'Nœuds sur lesquels repose ce nœud',
},
relationsTab: 'Relations',
},
}


+ 9
- 3
web/i18n/hi-IN/workflow.ts View File

@@ -109,8 +109,6 @@ const translation = {
exitVersions: 'निकलने के संस्करण',
exportPNG: 'PNG के रूप में निर्यात करें',
exportJPEG: 'JPEG के रूप में निर्यात करें',
referenceVar: 'संदर्भ चर',
noExist: 'कोई ऐसा चर नहीं है',
exportImage: 'छवि निर्यात करें',
publishUpdate: 'अपडेट प्रकाशित करें',
exportSVG: 'SVG के रूप में निर्यात करें',
@@ -623,7 +621,6 @@ const translation = {
},
select: 'चुनना',
addSubVariable: 'उप चर',
condition: 'स्थिति',
},
variableAssigner: {
title: 'वेरिएबल्स असाइन करें',
@@ -991,6 +988,15 @@ const translation = {
},
settingsTab: 'सेटिंग्स',
lastRunTab: 'अंतिम रन',
relations: {
dependents: 'निष्पाभ लोग',
dependentsDescription: 'इस नोड पर निर्भर नोड्स',
dependencies: 'निर्भरता',
noDependents: 'कोई आश्रित नहीं',
dependenciesDescription: 'यह नोड जिस नोड पर निर्भर करता है',
noDependencies: 'कोई निर्भरताएँ नहीं',
},
relationsTab: 'रिश्ते',
},
}


+ 9
- 3
web/i18n/it-IT/workflow.ts View File

@@ -110,11 +110,9 @@ const translation = {
publishUpdate: 'Pubblica aggiornamento',
versionHistory: 'Cronologia delle versioni',
exitVersions: 'Uscita Versioni',
referenceVar: 'Variabile di riferimento',
exportSVG: 'Esporta come SVG',
exportImage: 'Esporta immagine',
exportJPEG: 'Esporta come JPEG',
noExist: 'Nessuna variabile del genere',
exportPNG: 'Esporta come PNG',
needEndNode: 'Deve essere aggiunto il nodo finale',
addBlock: 'Aggiungi nodo',
@@ -627,7 +625,6 @@ const translation = {
},
addSubVariable: 'Variabile secondaria',
select: 'Selezionare',
condition: 'Condizione',
},
variableAssigner: {
title: 'Assegna variabili',
@@ -997,6 +994,15 @@ const translation = {
},
settingsTab: 'Impostazioni',
lastRunTab: 'Ultima corsa',
relations: {
dependents: 'Dipendenti',
noDependencies: 'Nessuna dipendenza',
dependencies: 'Dipendenze',
noDependents: 'Nessuna persona a carico',
dependentsDescription: 'Nodi che si basano su questo nodo',
dependenciesDescription: 'Nodi su cui si basa questo nodo',
},
relationsTab: 'Relazioni',
},
}


+ 9
- 0
web/i18n/ja-JP/workflow.ts View File

@@ -968,6 +968,15 @@ const translation = {
},
settingsTab: '設定',
lastRunTab: '最後の実行',
relationsTab: '関係',
relations: {
dependencies: '依存元',
dependents: '依存先',
dependenciesDescription: 'このノードが依存している他のノード',
dependentsDescription: 'このノードに依存している他のノード',
noDependencies: '依存元なし',
noDependents: '依存先なし',
},
},
}


+ 9
- 0
web/i18n/ko-KR/workflow.ts View File

@@ -1019,6 +1019,15 @@ const translation = {
},
settingsTab: '설정',
lastRunTab: '마지막 실행',
relations: {
dependencies: '종속성',
dependentsDescription: '이 노드에 의존하는 노드',
noDependents: '부양가족 없음',
noDependencies: '종속성 없음',
dependents: '부양 가족',
dependenciesDescription: '이 노드가 의존하는 노드',
},
relationsTab: '관계',
},
}


+ 9
- 3
web/i18n/pl-PL/workflow.ts View File

@@ -108,10 +108,8 @@ const translation = {
versionHistory: 'Historia wersji',
exportSVG: 'Eksportuj jako SVG',
exportJPEG: 'Eksportuj jako JPEG',
noExist: 'Nie ma takiej zmiennej',
exportPNG: 'Eksportuj jako PNG',
publishUpdate: 'Opublikuj aktualizację',
referenceVar: 'Zmienna odniesienia',
addBlock: 'Dodaj węzeł',
needEndNode: 'Należy dodać węzeł końcowy',
needAnswerNode: 'Węzeł odpowiedzi musi zostać dodany',
@@ -608,7 +606,6 @@ const translation = {
},
addSubVariable: 'Zmienna podrzędna',
select: 'Wybrać',
condition: 'Stan',
},
variableAssigner: {
title: 'Przypisz zmienne',
@@ -971,6 +968,15 @@ const translation = {
},
settingsTab: 'Ustawienia',
lastRunTab: 'Ostatnie uruchomienie',
relations: {
dependencies: 'Zależności',
dependenciesDescription: 'Węzły, na których opiera się ten węzeł',
noDependents: 'Brak osób na utrzymaniu',
dependents: 'Zależności',
dependentsDescription: 'Węzły, które opierają się na tym węźle',
noDependencies: 'Brak zależności',
},
relationsTab: 'Stosunków',
},
}


+ 9
- 3
web/i18n/pt-BR/workflow.ts View File

@@ -107,8 +107,6 @@ const translation = {
publishUpdate: 'Publicar Atualização',
versionHistory: 'Histórico de Versão',
exportImage: 'Exportar Imagem',
referenceVar: 'Variável de Referência',
noExist: 'Nenhuma variável desse tipo',
exitVersions: 'Versões de Sair',
exportSVG: 'Exportar como SVG',
exportJPEG: 'Exportar como JPEG',
@@ -608,7 +606,6 @@ const translation = {
},
addSubVariable: 'Subvariável',
select: 'Selecionar',
condition: 'Condição',
},
variableAssigner: {
title: 'Atribuir variáveis',
@@ -971,6 +968,15 @@ const translation = {
},
settingsTab: 'Configurações',
lastRunTab: 'Última execução',
relations: {
noDependents: 'Sem dependentes',
dependenciesDescription: 'Nós dos quais esse nó depende',
dependents: 'Dependentes',
dependencies: 'Dependências',
dependentsDescription: 'Nós que dependem desse nó',
noDependencies: 'Sem dependências',
},
relationsTab: 'Relações',
},
}


+ 9
- 3
web/i18n/ro-RO/workflow.ts View File

@@ -106,11 +106,9 @@ const translation = {
exportImage: 'Exportă imaginea',
exportSVG: 'Exportă ca SVG',
exportPNG: 'Exportă ca PNG',
noExist: 'Nu există o astfel de variabilă',
exitVersions: 'Ieșire Versiuni',
versionHistory: 'Istoricul versiunilor',
publishUpdate: 'Publicați actualizarea',
referenceVar: 'Variabilă de referință',
exportJPEG: 'Exportă ca JPEG',
addBlock: 'Adaugă nod',
needAnswerNode: 'Nodul de răspuns trebuie adăugat',
@@ -608,7 +606,6 @@ const translation = {
},
select: 'Alege',
addSubVariable: 'Subvariabilă',
condition: 'Condiție',
},
variableAssigner: {
title: 'Atribuie variabile',
@@ -971,6 +968,15 @@ const translation = {
},
settingsTab: 'Setări',
lastRunTab: 'Ultima execuție',
relations: {
dependencies: 'Dependenţele',
noDependencies: 'Fără dependențe',
dependents: 'Dependenţe',
noDependents: 'Fără persoane aflate în întreținere',
dependentsDescription: 'Noduri care se bazează pe acest nod',
dependenciesDescription: 'Noduri pe care se bazează acest nod',
},
relationsTab: 'Relații',
},
}


+ 9
- 3
web/i18n/ru-RU/workflow.ts View File

@@ -103,12 +103,10 @@ const translation = {
addFailureBranch: 'Добавить ветвь Fail',
noHistory: 'Без истории',
loadMore: 'Загрузите больше рабочих процессов',
noExist: 'Такой переменной не существует',
versionHistory: 'История версий',
exportPNG: 'Экспортировать как PNG',
exportImage: 'Экспортировать изображение',
exportJPEG: 'Экспортировать как JPEG',
referenceVar: 'Ссылочная переменная',
exitVersions: 'Выходные версии',
exportSVG: 'Экспортировать как SVG',
publishUpdate: 'Опубликовать обновление',
@@ -608,7 +606,6 @@ const translation = {
},
select: 'Выбирать',
addSubVariable: 'Подпеременная',
condition: 'Условие',
},
variableAssigner: {
title: 'Назначить переменные',
@@ -971,6 +968,15 @@ const translation = {
},
lastRunTab: 'Последний запуск',
settingsTab: 'Настройки',
relations: {
dependencies: 'Зависимости',
dependents: 'Иждивенцев',
noDependencies: 'Нет зависимостей',
dependentsDescription: 'Узлы, которые полагаются на этот узел',
noDependents: 'Отсутствие иждивенцев',
dependenciesDescription: 'Узлы, на которые опирается этот узел',
},
relationsTab: 'Отношения',
},
}


+ 9
- 0
web/i18n/sl-SI/workflow.ts View File

@@ -968,6 +968,15 @@ const translation = {
},
settingsTab: 'Nastavitve',
lastRunTab: 'Zadnji zagon',
relations: {
dependencies: 'Odvisnosti',
dependents: 'Odvisnim',
noDependents: 'Brez vzdrževanih oseb',
dependentsDescription: 'Vozlišča, ki se zanašajo na to vozlišče',
dependenciesDescription: 'Vozlišča, na katera se zanaša to vozlišče',
noDependencies: 'Brez odvisnosti',
},
relationsTab: 'Odnose',
},
}


+ 9
- 3
web/i18n/th-TH/workflow.ts View File

@@ -105,9 +105,7 @@ const translation = {
noHistory: 'ไม่มีประวัติ',
versionHistory: 'ประวัติรุ่น',
exportPNG: 'ส่งออกเป็น PNG',
noExist: 'ไม่มีตัวแปรดังกล่าว',
exportJPEG: 'ส่งออกเป็น JPEG',
referenceVar: 'ตัวแปรอ้างอิง',
publishUpdate: 'เผยแพร่การอัปเดต',
exitVersions: 'ออกเวอร์ชัน',
exportImage: 'ส่งออกภาพ',
@@ -608,7 +606,6 @@ const translation = {
selectVariable: 'เลือกตัวแปร...',
addSubVariable: 'ตัวแปรย่อย',
select: 'เลือก',
condition: 'เงื่อนไข',
},
variableAssigner: {
title: 'กําหนดตัวแปร',
@@ -971,6 +968,15 @@ const translation = {
},
settingsTab: 'การตั้งค่า',
lastRunTab: 'รอบสุดท้าย',
relations: {
dependents: 'ผู้อยู่ในอุปการะ',
dependencies: 'อ้าง อิง',
dependenciesDescription: 'โหนดที่โหนดนี้อาศัย',
noDependencies: 'ไม่มีการพึ่งพา',
noDependents: 'ไม่มีผู้อยู่ในอุปการะ',
dependentsDescription: 'โหนดที่อาศัยโหนดนี้',
},
relationsTab: 'สัมพันธ์',
},
}


+ 9
- 3
web/i18n/tr-TR/workflow.ts View File

@@ -109,9 +109,7 @@ const translation = {
exitVersions: 'Çıkış Sürümleri',
versionHistory: 'Sürüm Geçmişi',
exportJPEG: 'JPEG olarak dışa aktar',
noExist: 'Böyle bir değişken yok',
exportSVG: 'SVG olarak dışa aktar',
referenceVar: 'Referans Değişken',
addBlock: 'Düğüm Ekle',
needAnswerNode: 'Cevap düğümü eklenmelidir.',
needEndNode: 'Son düğüm eklenmelidir',
@@ -609,7 +607,6 @@ const translation = {
},
addSubVariable: 'Alt Değişken',
select: 'Seçmek',
condition: 'Koşul',
},
variableAssigner: {
title: 'Değişken ata',
@@ -972,6 +969,15 @@ const translation = {
},
lastRunTab: 'Son Koşu',
settingsTab: 'Ayarlar',
relations: {
noDependents: 'Bakmakla yükümlü olunan kişi yok',
dependentsDescription: 'Bu düğüme dayanan düğümler',
dependenciesDescription: 'Bu düğümün dayandığı düğümler',
dependencies: 'Bağımlılık',
dependents: 'Bağımlı',
noDependencies: 'Bağımlılık yok',
},
relationsTab: 'Ilişkiler',
},
}


+ 9
- 3
web/i18n/uk-UA/workflow.ts View File

@@ -103,9 +103,7 @@ const translation = {
addFailureBranch: 'Додано гілку помилки',
noHistory: 'Без історії',
loadMore: 'Завантажте більше робочих процесів',
referenceVar: 'Посилальна змінна',
exportPNG: 'Експортувати як PNG',
noExist: 'Такої змінної не існує',
exitVersions: 'Вихідні версії',
versionHistory: 'Історія версій',
publishUpdate: 'Опублікувати оновлення',
@@ -608,7 +606,6 @@ const translation = {
},
select: 'Виберіть',
addSubVariable: 'Підзмінна',
condition: 'Умова',
},
variableAssigner: {
title: 'Присвоєння змінних',
@@ -971,6 +968,15 @@ const translation = {
},
lastRunTab: 'Останній запуск',
settingsTab: 'Налаштування',
relations: {
noDependents: 'Без утриманців',
dependents: 'Утриманців',
dependencies: 'Залежностей',
noDependencies: 'Відсутність залежностей',
dependenciesDescription: 'Вузли, на які спирається цей вузол',
dependentsDescription: 'Вузли, які спираються на цей вузол',
},
relationsTab: 'Відносин',
},
}


+ 9
- 3
web/i18n/vi-VN/workflow.ts View File

@@ -109,9 +109,7 @@ const translation = {
exitVersions: 'Phiên bản thoát',
exportImage: 'Xuất hình ảnh',
exportPNG: 'Xuất dưới dạng PNG',
noExist: 'Không có biến như vậy',
exportJPEG: 'Xuất dưới dạng JPEG',
referenceVar: 'Biến tham chiếu',
needAnswerNode: 'Nút Trả lời phải được thêm vào',
addBlock: 'Thêm Node',
needEndNode: 'Nút Kết thúc phải được thêm vào',
@@ -608,7 +606,6 @@ const translation = {
},
addSubVariable: 'Biến phụ',
select: 'Lựa',
condition: 'Điều kiện',
},
variableAssigner: {
title: 'Gán biến',
@@ -971,6 +968,15 @@ const translation = {
},
settingsTab: 'Cài đặt',
lastRunTab: 'Chạy Lần Cuối',
relations: {
noDependencies: 'Không phụ thuộc',
dependenciesDescription: 'Các nút mà nút này dựa vào',
dependents: 'Người phụ thuộc',
dependencies: 'Phụ thuộc',
noDependents: 'Không có người phụ thuộc',
dependentsDescription: 'Các nút dựa vào nút này',
},
relationsTab: 'Mối quan hệ',
},
}


+ 21
- 0
web/i18n/zh-Hans/workflow.ts View File

@@ -294,6 +294,18 @@ const translation = {
zoomTo50: '缩放到 50%',
zoomTo100: '放大到 100%',
zoomToFit: '自适应视图',
alignNodes: '对齐节点',
alignLeft: '左对齐',
alignCenter: '居中对齐',
alignRight: '右对齐',
alignTop: '顶部对齐',
alignMiddle: '中部对齐',
alignBottom: '底部对齐',
vertical: '垂直方向',
horizontal: '水平方向',
distributeHorizontal: '水平等间距',
distributeVertical: '垂直等间距',
selectionAlignment: '选择对齐',
},
variableReference: {
noAvailableVars: '没有可用变量',
@@ -978,6 +990,7 @@ const translation = {
debug: {
settingsTab: '设置',
lastRunTab: '上次运行',
relationsTab: '关系',
noData: {
description: '上次运行的结果将显示在这里',
runThisNode: '运行此节点',
@@ -1003,6 +1016,14 @@ const translation = {
chatNode: '会话变量',
systemNode: '系统变量',
},
relations: {
dependencies: '依赖',
dependents: '被依赖',
dependenciesDescription: '本节点依赖的其他节点',
dependentsDescription: '依赖于本节点的其他节点',
noDependencies: '无依赖',
noDependents: '无被依赖',
},
},
}


+ 11
- 2
web/i18n/zh-Hant/workflow.ts View File

@@ -941,6 +941,9 @@ const translation = {
copyId: '複製ID',
},
debug: {
settingsTab: '設定',
lastRunTab: '最後一次運行',
relationsTab: '關係',
noData: {
runThisNode: '運行此節點',
description: '上次運行的結果將顯示在這裡',
@@ -966,8 +969,14 @@ const translation = {
emptyTip: '在畫布上逐步執行節點或逐步運行節點後,您可以在變數檢視中查看節點變數的當前值。',
resetConversationVar: '將對話變數重置為默認值',
},
settingsTab: '設定',
lastRunTab: '最後一次運行',
relations: {
dependencies: '依賴',
dependents: '被依賴',
dependenciesDescription: '此節點所依賴的其他節點',
dependentsDescription: '依賴此節點的其他節點',
noDependencies: '無依賴',
noDependents: '無被依賴',
},
},
}


+ 1
- 1
web/service/base.ts View File

@@ -117,7 +117,7 @@ function unicodeToChar(text: string) {
if (!text)
return ''

return text.replace(/\\u[0-9a-f]{4}/g, (_match, p1) => {
return text.replace(/\\u([0-9a-f]{4})/g, (_match, p1) => {
return String.fromCharCode(Number.parseInt(p1, 16))
})
}

Loading…
Cancel
Save