Merge branch 'langgenius:main' into fix-doc-TOC-style

pull/18314/head
GuanMu 1 year ago committed by GitHub
commit 0f40bbf046
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -13,6 +13,7 @@ from .observability import ObservabilityConfig
from .packaging import PackagingInfo from .packaging import PackagingInfo
from .remote_settings_sources import RemoteSettingsSource, RemoteSettingsSourceConfig, RemoteSettingsSourceName from .remote_settings_sources import RemoteSettingsSource, RemoteSettingsSourceConfig, RemoteSettingsSourceName
from .remote_settings_sources.apollo import ApolloSettingsSource from .remote_settings_sources.apollo import ApolloSettingsSource
from .remote_settings_sources.nacos import NacosSettingsSource
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -34,6 +35,8 @@ class RemoteSettingsSourceFactory(PydanticBaseSettingsSource):
match remote_source_name: match remote_source_name:
case RemoteSettingsSourceName.APOLLO: case RemoteSettingsSourceName.APOLLO:
remote_source = ApolloSettingsSource(current_state) remote_source = ApolloSettingsSource(current_state)
case RemoteSettingsSourceName.NACOS:
remote_source = NacosSettingsSource(current_state)
case _: case _:
logger.warning(f"Unsupported remote source: {remote_source_name}") logger.warning(f"Unsupported remote source: {remote_source_name}")
return {} return {}

@ -22,6 +22,7 @@ from .vdb.baidu_vector_config import BaiduVectorDBConfig
from .vdb.chroma_config import ChromaConfig from .vdb.chroma_config import ChromaConfig
from .vdb.couchbase_config import CouchbaseConfig from .vdb.couchbase_config import CouchbaseConfig
from .vdb.elasticsearch_config import ElasticsearchConfig from .vdb.elasticsearch_config import ElasticsearchConfig
from .vdb.huawei_cloud_config import HuaweiCloudConfig
from .vdb.lindorm_config import LindormConfig from .vdb.lindorm_config import LindormConfig
from .vdb.milvus_config import MilvusConfig from .vdb.milvus_config import MilvusConfig
from .vdb.myscale_config import MyScaleConfig from .vdb.myscale_config import MyScaleConfig
@ -263,6 +264,7 @@ class MiddlewareConfig(
VectorStoreConfig, VectorStoreConfig,
AnalyticdbConfig, AnalyticdbConfig,
ChromaConfig, ChromaConfig,
HuaweiCloudConfig,
MilvusConfig, MilvusConfig,
MyScaleConfig, MyScaleConfig,
OpenSearchConfig, OpenSearchConfig,

@ -0,0 +1,25 @@
from typing import Optional
from pydantic import Field
from pydantic_settings import BaseSettings
class HuaweiCloudConfig(BaseSettings):
"""
Configuration settings for Huawei cloud search service
"""
HUAWEI_CLOUD_HOSTS: Optional[str] = Field(
description="Hostname or IP address of the Huawei cloud search service instance",
default=None,
)
HUAWEI_CLOUD_USER: Optional[str] = Field(
description="Username for authenticating with Huawei cloud search service",
default=None,
)
HUAWEI_CLOUD_PASSWORD: Optional[str] = Field(
description="Password for authenticating with Huawei cloud search service",
default=None,
)

@ -3,3 +3,4 @@ from enum import StrEnum
class RemoteSettingsSourceName(StrEnum): class RemoteSettingsSourceName(StrEnum):
APOLLO = "apollo" APOLLO = "apollo"
NACOS = "nacos"

@ -0,0 +1,52 @@
import logging
import os
from collections.abc import Mapping
from typing import Any
from pydantic.fields import FieldInfo
from .http_request import NacosHttpClient
logger = logging.getLogger(__name__)
from configs.remote_settings_sources.base import RemoteSettingsSource
from .utils import _parse_config
class NacosSettingsSource(RemoteSettingsSource):
def __init__(self, configs: Mapping[str, Any]):
self.configs = configs
self.remote_configs: dict[str, Any] = {}
self.async_init()
def async_init(self):
data_id = os.getenv("DIFY_ENV_NACOS_DATA_ID", "dify-api-env.properties")
group = os.getenv("DIFY_ENV_NACOS_GROUP", "nacos-dify")
tenant = os.getenv("DIFY_ENV_NACOS_NAMESPACE", "")
params = {"dataId": data_id, "group": group, "tenant": tenant}
try:
content = NacosHttpClient().http_request("/nacos/v1/cs/configs", method="GET", headers={}, params=params)
self.remote_configs = self._parse_config(content)
except Exception as e:
logger.exception("[get-access-token] exception occurred")
raise
def _parse_config(self, content: str) -> dict:
if not content:
return {}
try:
return _parse_config(self, content)
except Exception as e:
raise RuntimeError(f"Failed to parse config: {e}")
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
if not isinstance(self.remote_configs, dict):
raise ValueError(f"remote configs is not dict, but {type(self.remote_configs)}")
field_value = self.remote_configs.get(field_name)
if field_value is None:
return None, field_name, False
return field_value, field_name, False

@ -0,0 +1,83 @@
import base64
import hashlib
import hmac
import logging
import os
import time
import requests
logger = logging.getLogger(__name__)
class NacosHttpClient:
def __init__(self):
self.username = os.getenv("DIFY_ENV_NACOS_USERNAME")
self.password = os.getenv("DIFY_ENV_NACOS_PASSWORD")
self.ak = os.getenv("DIFY_ENV_NACOS_ACCESS_KEY")
self.sk = os.getenv("DIFY_ENV_NACOS_SECRET_KEY")
self.server = os.getenv("DIFY_ENV_NACOS_SERVER_ADDR", "localhost:8848")
self.token = None
self.token_ttl = 18000
self.token_expire_time: float = 0
def http_request(self, url, method="GET", headers=None, params=None):
try:
self._inject_auth_info(headers, params)
response = requests.request(method, url="http://" + self.server + url, headers=headers, params=params)
response.raise_for_status()
return response.text
except requests.exceptions.RequestException as e:
return f"Request to Nacos failed: {e}"
def _inject_auth_info(self, headers, params, module="config"):
headers.update({"User-Agent": "Nacos-Http-Client-In-Dify:v0.0.1"})
if module == "login":
return
ts = str(int(time.time() * 1000))
if self.ak and self.sk:
sign_str = self.get_sign_str(params["group"], params["tenant"], ts)
headers["Spas-AccessKey"] = self.ak
headers["Spas-Signature"] = self.__do_sign(sign_str, self.sk)
headers["timeStamp"] = ts
if self.username and self.password:
self.get_access_token(force_refresh=False)
params["accessToken"] = self.token
def __do_sign(self, sign_str, sk):
return (
base64.encodebytes(hmac.new(sk.encode(), sign_str.encode(), digestmod=hashlib.sha1).digest())
.decode()
.strip()
)
def get_sign_str(self, group, tenant, ts):
sign_str = ""
if tenant:
sign_str = tenant + "+"
if group:
sign_str = sign_str + group + "+"
if sign_str:
sign_str += ts
return sign_str
def get_access_token(self, force_refresh=False):
current_time = time.time()
if self.token and not force_refresh and self.token_expire_time > current_time:
return self.token
params = {"username": self.username, "password": self.password}
url = "http://" + self.server + "/nacos/v1/auth/login"
try:
resp = requests.request("POST", url, headers=None, params=params)
resp.raise_for_status()
response_data = resp.json()
self.token = response_data.get("accessToken")
self.token_ttl = response_data.get("tokenTtl", 18000)
self.token_expire_time = current_time + self.token_ttl - 10
except Exception as e:
logger.exception("[get-access-token] exception occur")
raise

@ -0,0 +1,31 @@
def _parse_config(self, content: str) -> dict[str, str]:
config: dict[str, str] = {}
if not content:
return config
for line in content.splitlines():
cleaned_line = line.strip()
if not cleaned_line or cleaned_line.startswith(("#", "!")):
continue
separator_index = -1
for i, c in enumerate(cleaned_line):
if c in ("=", ":") and (i == 0 or cleaned_line[i - 1] != "\\"):
separator_index = i
break
if separator_index == -1:
continue
key = cleaned_line[:separator_index].strip()
raw_value = cleaned_line[separator_index + 1 :].strip()
try:
decoded_value = bytes(raw_value, "utf-8").decode("unicode_escape")
decoded_value = decoded_value.replace(r"\=", "=").replace(r"\:", ":")
except UnicodeDecodeError:
decoded_value = raw_value
config[key] = decoded_value
return config

@ -664,6 +664,7 @@ class DatasetRetrievalSettingApi(Resource):
| VectorType.OPENGAUSS | VectorType.OPENGAUSS
| VectorType.OCEANBASE | VectorType.OCEANBASE
| VectorType.TABLESTORE | VectorType.TABLESTORE
| VectorType.HUAWEI_CLOUD
| VectorType.TENCENT | VectorType.TENCENT
): ):
return { return {
@ -710,6 +711,7 @@ class DatasetRetrievalSettingMockApi(Resource):
| VectorType.OCEANBASE | VectorType.OCEANBASE
| VectorType.TABLESTORE | VectorType.TABLESTORE
| VectorType.TENCENT | VectorType.TENCENT
| VectorType.HUAWEI_CLOUD
): ):
return { return {
"retrieval_method": [ "retrieval_method": [

@ -1,3 +1,5 @@
from mimetypes import guess_extension
from flask import request from flask import request
from flask_restful import Resource, marshal_with # type: ignore from flask_restful import Resource, marshal_with # type: ignore
from werkzeug.exceptions import Forbidden from werkzeug.exceptions import Forbidden
@ -9,8 +11,8 @@ from controllers.files.error import UnsupportedFileTypeError
from controllers.inner_api.plugin.wraps import get_user from controllers.inner_api.plugin.wraps import get_user
from controllers.service_api.app.error import FileTooLargeError from controllers.service_api.app.error import FileTooLargeError
from core.file.helpers import verify_plugin_file_signature from core.file.helpers import verify_plugin_file_signature
from core.tools.tool_file_manager import ToolFileManager
from fields.file_fields import file_fields from fields.file_fields import file_fields
from services.file_service import FileService
class PluginUploadFileApi(Resource): class PluginUploadFileApi(Resource):
@ -51,19 +53,26 @@ class PluginUploadFileApi(Resource):
raise Forbidden("Invalid request.") raise Forbidden("Invalid request.")
try: try:
upload_file = FileService.upload_file( tool_file = ToolFileManager.create_file_by_raw(
filename=filename, user_id=user.id,
content=file.read(), tenant_id=tenant_id,
file_binary=file.read(),
mimetype=mimetype, mimetype=mimetype,
user=user, filename=filename,
source=None, conversation_id=None,
) )
extension = guess_extension(tool_file.mimetype) or ".bin"
preview_url = ToolFileManager.sign_file(tool_file_id=tool_file.id, extension=extension)
tool_file.mime_type = mimetype
tool_file.extension = extension
tool_file.preview_url = preview_url
except services.errors.file.FileTooLargeError as file_too_large_error: except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description) raise FileTooLargeError(file_too_large_error.description)
except services.errors.file.UnsupportedFileTypeError: except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError() raise UnsupportedFileTypeError()
return upload_file, 201 return tool_file, 201
api.add_resource(PluginUploadFileApi, "/files/upload/for-plugin") api.add_resource(PluginUploadFileApi, "/files/upload/for-plugin")

@ -0,0 +1,215 @@
import json
import logging
import ssl
from typing import Any, Optional
from elasticsearch import Elasticsearch
from pydantic import BaseModel, model_validator
from configs import dify_config
from core.rag.datasource.vdb.field import Field
from core.rag.datasource.vdb.vector_base import BaseVector
from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory
from core.rag.datasource.vdb.vector_type import VectorType
from core.rag.embedding.embedding_base import Embeddings
from core.rag.models.document import Document
from extensions.ext_redis import redis_client
from models.dataset import Dataset
logger = logging.getLogger(__name__)
def create_ssl_context() -> ssl.SSLContext:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
return ssl_context
class HuaweiCloudVectorConfig(BaseModel):
hosts: str
username: str | None
password: str | None
@model_validator(mode="before")
@classmethod
def validate_config(cls, values: dict) -> dict:
if not values["hosts"]:
raise ValueError("config HOSTS is required")
return values
def to_elasticsearch_params(self) -> dict[str, Any]:
params = {
"hosts": self.hosts.split(","),
"verify_certs": False,
"ssl_show_warn": False,
"request_timeout": 30000,
"retry_on_timeout": True,
"max_retries": 10,
}
if self.username and self.password:
params["basic_auth"] = (self.username, self.password)
return params
class HuaweiCloudVector(BaseVector):
def __init__(self, index_name: str, config: HuaweiCloudVectorConfig):
super().__init__(index_name.lower())
self._client = Elasticsearch(**config.to_elasticsearch_params())
def get_type(self) -> str:
return VectorType.HUAWEI_CLOUD
def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs):
uuids = self._get_uuids(documents)
for i in range(len(documents)):
self._client.index(
index=self._collection_name,
id=uuids[i],
document={
Field.CONTENT_KEY.value: documents[i].page_content,
Field.VECTOR.value: embeddings[i] or None,
Field.METADATA_KEY.value: documents[i].metadata or {},
},
)
self._client.indices.refresh(index=self._collection_name)
return uuids
def text_exists(self, id: str) -> bool:
return bool(self._client.exists(index=self._collection_name, id=id))
def delete_by_ids(self, ids: list[str]) -> None:
if not ids:
return
for id in ids:
self._client.delete(index=self._collection_name, id=id)
def delete_by_metadata_field(self, key: str, value: str) -> None:
query_str = {"query": {"match": {f"metadata.{key}": f"{value}"}}}
results = self._client.search(index=self._collection_name, body=query_str)
ids = [hit["_id"] for hit in results["hits"]["hits"]]
if ids:
self.delete_by_ids(ids)
def delete(self) -> None:
self._client.indices.delete(index=self._collection_name)
def search_by_vector(self, query_vector: list[float], **kwargs: Any) -> list[Document]:
top_k = kwargs.get("top_k", 4)
query = {
"size": top_k,
"query": {
"vector": {
Field.VECTOR.value: {
"vector": query_vector,
"topk": top_k,
}
}
},
}
results = self._client.search(index=self._collection_name, body=query)
docs_and_scores = []
for hit in results["hits"]["hits"]:
docs_and_scores.append(
(
Document(
page_content=hit["_source"][Field.CONTENT_KEY.value],
vector=hit["_source"][Field.VECTOR.value],
metadata=hit["_source"][Field.METADATA_KEY.value],
),
hit["_score"],
)
)
docs = []
for doc, score in docs_and_scores:
score_threshold = float(kwargs.get("score_threshold") or 0.0)
if score > score_threshold:
if doc.metadata is not None:
doc.metadata["score"] = score
docs.append(doc)
return docs
def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]:
query_str = {"match": {Field.CONTENT_KEY.value: query}}
results = self._client.search(index=self._collection_name, query=query_str, size=kwargs.get("top_k", 4))
docs = []
for hit in results["hits"]["hits"]:
docs.append(
Document(
page_content=hit["_source"][Field.CONTENT_KEY.value],
vector=hit["_source"][Field.VECTOR.value],
metadata=hit["_source"][Field.METADATA_KEY.value],
)
)
return docs
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs):
metadatas = [d.metadata if d.metadata is not None else {} for d in texts]
self.create_collection(embeddings, metadatas)
self.add_texts(texts, embeddings, **kwargs)
def create_collection(
self,
embeddings: list[list[float]],
metadatas: Optional[list[dict[Any, Any]]] = None,
index_params: Optional[dict] = None,
):
lock_name = f"vector_indexing_lock_{self._collection_name}"
with redis_client.lock(lock_name, timeout=20):
collection_exist_cache_key = f"vector_indexing_{self._collection_name}"
if redis_client.get(collection_exist_cache_key):
logger.info(f"Collection {self._collection_name} already exists.")
return
if not self._client.indices.exists(index=self._collection_name):
dim = len(embeddings[0])
mappings = {
"properties": {
Field.CONTENT_KEY.value: {"type": "text"},
Field.VECTOR.value: { # Make sure the dimension is correct here
"type": "vector",
"dimension": dim,
"indexing": True,
"algorithm": "GRAPH",
"metric": "cosine",
"neighbors": 32,
"efc": 128,
},
Field.METADATA_KEY.value: {
"type": "object",
"properties": {
"doc_id": {"type": "keyword"} # Map doc_id to keyword type
},
},
}
}
settings = {"index.vector": True}
self._client.indices.create(index=self._collection_name, mappings=mappings, settings=settings)
redis_client.set(collection_exist_cache_key, 1, ex=3600)
class HuaweiCloudVectorFactory(AbstractVectorFactory):
def init_vector(self, dataset: Dataset, attributes: list, embeddings: Embeddings) -> HuaweiCloudVector:
if dataset.index_struct_dict:
class_prefix: str = dataset.index_struct_dict["vector_store"]["class_prefix"]
collection_name = class_prefix.lower()
else:
dataset_id = dataset.id
collection_name = Dataset.gen_collection_name_by_id(dataset_id).lower()
dataset.index_struct = json.dumps(self.gen_index_struct_dict(VectorType.HUAWEI_CLOUD, collection_name))
return HuaweiCloudVector(
index_name=collection_name,
config=HuaweiCloudVectorConfig(
hosts=dify_config.HUAWEI_CLOUD_HOSTS or "http://localhost:9200",
username=dify_config.HUAWEI_CLOUD_USER,
password=dify_config.HUAWEI_CLOUD_PASSWORD,
),
)

@ -156,6 +156,10 @@ class Vector:
from core.rag.datasource.vdb.tablestore.tablestore_vector import TableStoreVectorFactory from core.rag.datasource.vdb.tablestore.tablestore_vector import TableStoreVectorFactory
return TableStoreVectorFactory return TableStoreVectorFactory
case VectorType.HUAWEI_CLOUD:
from core.rag.datasource.vdb.huawei.huawei_cloud_vector import HuaweiCloudVectorFactory
return HuaweiCloudVectorFactory
case _: case _:
raise ValueError(f"Vector store {vector_type} is not supported.") raise ValueError(f"Vector store {vector_type} is not supported.")

@ -26,3 +26,4 @@ class VectorType(StrEnum):
OCEANBASE = "oceanbase" OCEANBASE = "oceanbase"
OPENGAUSS = "opengauss" OPENGAUSS = "opengauss"
TABLESTORE = "tablestore" TABLESTORE = "tablestore"
HUAWEI_CLOUD = "huawei_cloud"

@ -14,7 +14,7 @@ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExport
from opentelemetry.instrumentation.celery import CeleryInstrumentor from opentelemetry.instrumentation.celery import CeleryInstrumentor
from opentelemetry.instrumentation.flask import FlaskInstrumentor from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.metrics import get_meter_provider, set_meter_provider from opentelemetry.metrics import get_meter, get_meter_provider, set_meter_provider
from opentelemetry.propagate import set_global_textmap from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.b3 import B3Format from opentelemetry.propagators.b3 import B3Format
from opentelemetry.propagators.composite import CompositePropagator from opentelemetry.propagators.composite import CompositePropagator
@ -112,6 +112,11 @@ def is_celery_worker():
def init_flask_instrumentor(app: DifyApp): def init_flask_instrumentor(app: DifyApp):
meter = get_meter("http_metrics", version=dify_config.CURRENT_VERSION)
_http_response_counter = meter.create_counter(
"http.server.response.count", description="Total number of HTTP responses by status code", unit="{response}"
)
def response_hook(span: Span, status: str, response_headers: list): def response_hook(span: Span, status: str, response_headers: list):
if span and span.is_recording(): if span and span.is_recording():
if status.startswith("2"): if status.startswith("2"):
@ -119,6 +124,11 @@ def init_flask_instrumentor(app: DifyApp):
else: else:
span.set_status(StatusCode.ERROR, status) span.set_status(StatusCode.ERROR, status)
status = status.split(" ")[0]
status_code = int(status)
status_class = f"{status_code // 100}xx"
_http_response_counter.add(1, {"status_code": status_code, "status_class": status_class})
instrumentor = FlaskInstrumentor() instrumentor = FlaskInstrumentor()
if dify_config.DEBUG: if dify_config.DEBUG:
logging.info("Initializing Flask instrumentor") logging.info("Initializing Flask instrumentor")

@ -19,6 +19,7 @@ file_fields = {
"mime_type": fields.String, "mime_type": fields.String,
"created_by": fields.String, "created_by": fields.String,
"created_at": TimestampField, "created_at": TimestampField,
"preview_url": fields.String,
} }
remote_file_info_fields = { remote_file_info_fields = {

@ -289,7 +289,7 @@ class WorkflowService:
params={ params={
"tenant_id": app_model.tenant_id, "tenant_id": app_model.tenant_id,
"app_id": app_model.id, "app_id": app_model.id,
"session_factory": db.session.get_bind, "session_factory": db.session.get_bind(),
} }
) )
repository.save(workflow_node_execution) repository.save(workflow_node_execution)

@ -0,0 +1,88 @@
import os
import pytest
from _pytest.monkeypatch import MonkeyPatch
from api.core.rag.datasource.vdb.field import Field
from elasticsearch import Elasticsearch
class MockIndicesClient:
def __init__(self):
pass
def create(self, index, mappings, settings):
return {"acknowledge": True}
def refresh(self, index):
return {"acknowledge": True}
def delete(self, index):
return {"acknowledge": True}
def exists(self, index):
return True
class MockClient:
def __init__(self, **kwargs):
self.indices = MockIndicesClient()
def index(self, **kwargs):
return {"acknowledge": True}
def exists(self, **kwargs):
return True
def delete(self, **kwargs):
return {"acknowledge": True}
def search(self, **kwargs):
return {
"took": 1,
"hits": {
"hits": [
{
"_source": {
Field.CONTENT_KEY.value: "abcdef",
Field.VECTOR.value: [1, 2],
Field.METADATA_KEY.value: {},
},
"_score": 1.0,
},
{
"_source": {
Field.CONTENT_KEY.value: "123456",
Field.VECTOR.value: [2, 2],
Field.METADATA_KEY.value: {},
},
"_score": 0.9,
},
{
"_source": {
Field.CONTENT_KEY.value: "a1b2c3",
Field.VECTOR.value: [3, 2],
Field.METADATA_KEY.value: {},
},
"_score": 0.8,
},
]
},
}
MOCK = os.getenv("MOCK_SWITCH", "false").lower() == "true"
@pytest.fixture
def setup_client_mock(request, monkeypatch: MonkeyPatch):
if MOCK:
monkeypatch.setattr(Elasticsearch, "__init__", MockClient.__init__)
monkeypatch.setattr(Elasticsearch, "index", MockClient.index)
monkeypatch.setattr(Elasticsearch, "exists", MockClient.exists)
monkeypatch.setattr(Elasticsearch, "delete", MockClient.delete)
monkeypatch.setattr(Elasticsearch, "search", MockClient.search)
yield
if MOCK:
monkeypatch.undo()

@ -0,0 +1,28 @@
from core.rag.datasource.vdb.huawei.huawei_cloud_vector import HuaweiCloudVector, HuaweiCloudVectorConfig
from tests.integration_tests.vdb.__mock.huaweicloudvectordb import setup_client_mock
from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, get_example_text, setup_mock_redis
class HuaweiCloudVectorTest(AbstractVectorTest):
def __init__(self):
super().__init__()
self.vector = HuaweiCloudVector(
"dify",
HuaweiCloudVectorConfig(
hosts="https://127.0.0.1:9200",
username="dify",
password="dify",
),
)
def search_by_vector(self):
hits_by_vector = self.vector.search_by_vector(query_vector=self.example_embedding)
assert len(hits_by_vector) == 3
def search_by_full_text(self):
hits_by_full_text = self.vector.search_by_full_text(query=get_example_text())
assert len(hits_by_full_text) == 3
def test_huawei_cloud_vector(setup_mock_redis, setup_client_mock):
HuaweiCloudVectorTest().run_all_tests()

@ -15,3 +15,4 @@ pytest api/tests/integration_tests/vdb/chroma \
api/tests/integration_tests/vdb/couchbase \ api/tests/integration_tests/vdb/couchbase \
api/tests/integration_tests/vdb/oceanbase \ api/tests/integration_tests/vdb/oceanbase \
api/tests/integration_tests/vdb/tidb_vector \ api/tests/integration_tests/vdb/tidb_vector \
api/tests/integration_tests/vdb/huawei \

@ -574,6 +574,11 @@ OPENGAUSS_MIN_CONNECTION=1
OPENGAUSS_MAX_CONNECTION=5 OPENGAUSS_MAX_CONNECTION=5
OPENGAUSS_ENABLE_PQ=false OPENGAUSS_ENABLE_PQ=false
# huawei cloud search service vector configurations, only available when VECTOR_STORE is `huawei_cloud`
HUAWEI_CLOUD_HOSTS=https://127.0.0.1:9200
HUAWEI_CLOUD_USER=admin
HUAWEI_CLOUD_PASSWORD=admin
# Upstash Vector configuration, only available when VECTOR_STORE is `upstash` # Upstash Vector configuration, only available when VECTOR_STORE is `upstash`
UPSTASH_VECTOR_URL=https://xxx-vector.upstash.io UPSTASH_VECTOR_URL=https://xxx-vector.upstash.io
UPSTASH_VECTOR_TOKEN=dify UPSTASH_VECTOR_TOKEN=dify

@ -266,6 +266,9 @@ x-shared-env: &shared-api-worker-env
OPENGAUSS_MIN_CONNECTION: ${OPENGAUSS_MIN_CONNECTION:-1} OPENGAUSS_MIN_CONNECTION: ${OPENGAUSS_MIN_CONNECTION:-1}
OPENGAUSS_MAX_CONNECTION: ${OPENGAUSS_MAX_CONNECTION:-5} OPENGAUSS_MAX_CONNECTION: ${OPENGAUSS_MAX_CONNECTION:-5}
OPENGAUSS_ENABLE_PQ: ${OPENGAUSS_ENABLE_PQ:-false} OPENGAUSS_ENABLE_PQ: ${OPENGAUSS_ENABLE_PQ:-false}
HUAWEI_CLOUD_HOSTS: ${HUAWEI_CLOUD_HOSTS:-https://127.0.0.1:9200}
HUAWEI_CLOUD_USER: ${HUAWEI_CLOUD_USER:-admin}
HUAWEI_CLOUD_PASSWORD: ${HUAWEI_CLOUD_PASSWORD:-admin}
UPSTASH_VECTOR_URL: ${UPSTASH_VECTOR_URL:-https://xxx-vector.upstash.io} UPSTASH_VECTOR_URL: ${UPSTASH_VECTOR_URL:-https://xxx-vector.upstash.io}
UPSTASH_VECTOR_TOKEN: ${UPSTASH_VECTOR_TOKEN:-dify} UPSTASH_VECTOR_TOKEN: ${UPSTASH_VECTOR_TOKEN:-dify}
TABLESTORE_ENDPOINT: ${TABLESTORE_ENDPOINT:-https://instance-name.cn-hangzhou.ots.aliyuncs.com} TABLESTORE_ENDPOINT: ${TABLESTORE_ENDPOINT:-https://instance-name.cn-hangzhou.ots.aliyuncs.com}

@ -163,7 +163,7 @@ const SettingBuiltInTool: FC<Props> = ({
footer={null} footer={null}
mask={false} mask={false}
positionCenter={false} positionCenter={false}
panelClassname={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')} panelClassName={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')}
> >
<> <>
{isLoading && <Loading type='app' />} {isLoading && <Loading type='app' />}

@ -97,7 +97,7 @@ const Item: FC<ItemProps> = ({
<RiDeleteBinLine className='h-4 w-4' /> <RiDeleteBinLine className='h-4 w-4' />
</div> </div>
</div> </div>
<Drawer isOpen={showSettingsModal} onClose={() => setShowSettingsModal(false)} footer={null} mask={isMobile} panelClassname='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl'> <Drawer isOpen={showSettingsModal} onClose={() => setShowSettingsModal(false)} footer={null} mask={isMobile} panelClassName='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl'>
<SettingsModal <SettingsModal
currentDataset={config} currentDataset={config}
onCancel={() => setShowSettingsModal(false)} onCancel={() => setShowSettingsModal(false)}

@ -62,13 +62,13 @@ const SettingsModal: FC<SettingsModalProps> = ({
const { notify } = useToastContext() const { notify } = useToastContext()
const ref = useRef(null) const ref = useRef(null)
const isExternal = currentDataset.provider === 'external' const isExternal = currentDataset.provider === 'external'
const [topK, setTopK] = useState(currentDataset?.external_retrieval_model.top_k ?? 2)
const [scoreThreshold, setScoreThreshold] = useState(currentDataset?.external_retrieval_model.score_threshold ?? 0.5)
const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(currentDataset?.external_retrieval_model.score_threshold_enabled ?? false)
const { setShowAccountSettingModal } = useModalContext() const { setShowAccountSettingModal } = useModalContext()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const { isCurrentWorkspaceDatasetOperator } = useAppContext() const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const [localeCurrentDataset, setLocaleCurrentDataset] = useState({ ...currentDataset }) const [localeCurrentDataset, setLocaleCurrentDataset] = useState({ ...currentDataset })
const [topK, setTopK] = useState(localeCurrentDataset?.external_retrieval_model.top_k ?? 2)
const [scoreThreshold, setScoreThreshold] = useState(localeCurrentDataset?.external_retrieval_model.score_threshold ?? 0.5)
const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(localeCurrentDataset?.external_retrieval_model.score_threshold_enabled ?? false)
const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(currentDataset.partial_member_list || []) const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(currentDataset.partial_member_list || [])
const [memberList, setMemberList] = useState<Member[]>([]) const [memberList, setMemberList] = useState<Member[]>([])
@ -88,6 +88,14 @@ const SettingsModal: FC<SettingsModalProps> = ({
setScoreThreshold(data.score_threshold) setScoreThreshold(data.score_threshold)
if (data.score_threshold_enabled !== undefined) if (data.score_threshold_enabled !== undefined)
setScoreThresholdEnabled(data.score_threshold_enabled) setScoreThresholdEnabled(data.score_threshold_enabled)
setLocaleCurrentDataset({
...localeCurrentDataset,
external_retrieval_model: {
...localeCurrentDataset?.external_retrieval_model,
...data,
},
})
} }
const handleSave = async () => { const handleSave = async () => {

@ -743,7 +743,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
onClose={onCloseDrawer} onClose={onCloseDrawer}
mask={isMobile} mask={isMobile}
footer={null} footer={null}
panelClassname='mt-16 mx-2 sm:mr-2 mb-4 !p-0 !max-w-[640px] rounded-xl bg-components-panel-bg' panelClassName='mt-16 mx-2 sm:mr-2 mb-4 !p-0 !max-w-[640px] rounded-xl bg-components-panel-bg'
> >
<DrawerContext.Provider value={{ <DrawerContext.Provider value={{
onClose: onCloseDrawer, onClose: onCloseDrawer,

@ -134,7 +134,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
onClose={onCloseDrawer} onClose={onCloseDrawer}
mask={isMobile} mask={isMobile}
footer={null} footer={null}
panelClassname='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[600px] rounded-xl border border-components-panel-border' panelClassName='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[600px] rounded-xl border border-components-panel-border'
> >
<DetailPanel onClose={onCloseDrawer} runID={currentLog?.workflow_run.id || ''} /> <DetailPanel onClose={onCloseDrawer} runID={currentLog?.workflow_run.id || ''} />
</Drawer> </Drawer>

@ -80,8 +80,30 @@ export const useEmbeddedChatbot = () => {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (appInfo?.site.default_language) const setLanguageFromParams = async () => {
changeLanguage(appInfo.site.default_language) // Check URL parameters for language override
const urlParams = new URLSearchParams(window.location.search)
const localeParam = urlParams.get('locale')
// Check for encoded system variables
const systemVariables = await getProcessedSystemVariablesFromUrlParams()
const localeFromSysVar = systemVariables.locale
if (localeParam) {
// If locale parameter exists in URL, use it instead of default
changeLanguage(localeParam)
}
else if (localeFromSysVar) {
// If locale is set as a system variable, use that
changeLanguage(localeFromSysVar)
}
else if (appInfo?.site.default_language) {
// Otherwise use the default from app config
changeLanguage(appInfo.site.default_language)
}
}
setLanguageFromParams()
}, [appInfo]) }, [appInfo])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, { const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {

@ -9,6 +9,8 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
type Props = { type Props = {
isShow: boolean isShow: boolean
onHide: () => void onHide: () => void
dialogClassName?: string
dialogBackdropClassName?: string
panelClassName?: string panelClassName?: string
maxWidthClassName?: string maxWidthClassName?: string
contentClassName?: string contentClassName?: string
@ -26,6 +28,8 @@ type Props = {
const DrawerPlus: FC<Props> = ({ const DrawerPlus: FC<Props> = ({
isShow, isShow,
onHide, onHide,
dialogClassName = '',
dialogBackdropClassName = '',
panelClassName = '', panelClassName = '',
maxWidthClassName = '!max-w-[640px]', maxWidthClassName = '!max-w-[640px]',
height = 'calc(100vh - 72px)', height = 'calc(100vh - 72px)',
@ -55,7 +59,9 @@ const DrawerPlus: FC<Props> = ({
footer={null} footer={null}
mask={isMobile || isShowMask} mask={isMobile || isShowMask}
positionCenter={positionCenter} positionCenter={positionCenter}
panelClassname={cn('mx-2 mb-3 mt-16 rounded-xl !p-0 sm:mr-2', panelClassName, maxWidthClassName)} dialogClassName={dialogClassName}
dialogBackdropClassName={dialogBackdropClassName}
panelClassName={cn('mx-2 mb-3 mt-16 rounded-xl !p-0 sm:mr-2', panelClassName, maxWidthClassName)}
> >
<div <div
className={cn(contentClassName, 'flex w-full flex-col rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg shadow-xl')} className={cn(contentClassName, 'flex w-full flex-col rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg shadow-xl')}

@ -8,7 +8,9 @@ import cn from '@/utils/classnames'
export type IDrawerProps = { export type IDrawerProps = {
title?: string title?: string
description?: string description?: string
panelClassname?: string dialogClassName?: string
dialogBackdropClassName?: string
panelClassName?: string
children: React.ReactNode children: React.ReactNode
footer?: React.ReactNode footer?: React.ReactNode
mask?: boolean mask?: boolean
@ -25,7 +27,9 @@ export type IDrawerProps = {
export default function Drawer({ export default function Drawer({
title = '', title = '',
description = '', description = '',
panelClassname = '', dialogClassName = '',
dialogBackdropClassName = '',
panelClassName = '',
children, children,
footer, footer,
mask = true, mask = true,
@ -44,17 +48,17 @@ export default function Drawer({
unmount={unmount} unmount={unmount}
open={isOpen} open={isOpen}
onClose={() => !clickOutsideNotOpen && onClose()} onClose={() => !clickOutsideNotOpen && onClose()}
className="fixed inset-0 z-[80] overflow-y-auto" className={cn('fixed inset-0 z-[30] overflow-y-auto', dialogClassName)}
> >
<div className={cn('flex h-screen w-screen justify-end', positionCenter && '!justify-center')}> <div className={cn('flex h-screen w-screen justify-end', positionCenter && '!justify-center')}>
{/* mask */} {/* mask */}
<DialogBackdrop <DialogBackdrop
className={cn('fixed inset-0 z-[90]', mask && 'bg-black bg-opacity-30')} className={cn('fixed inset-0 z-[40]', mask && 'bg-black/30', dialogBackdropClassName)}
onClick={() => { onClick={() => {
!clickOutsideNotOpen && onClose() !clickOutsideNotOpen && onClose()
}} }}
/> />
<div className={cn('relative z-[100] flex w-full max-w-sm flex-col justify-between overflow-hidden bg-components-panel-bg p-6 text-left align-middle shadow-xl', panelClassname)}> <div className={cn('relative z-[50] flex w-full max-w-sm flex-col justify-between overflow-hidden bg-components-panel-bg p-6 text-left align-middle shadow-xl', panelClassName)}>
<> <>
<div className='flex justify-between'> <div className='flex justify-between'>
{title && <DialogTitle {title && <DialogTitle

@ -252,7 +252,7 @@ const Img = ({ src }: any) => {
return <div className="markdown-img-wrapper"><ImageGallery srcs={[src]} /></div> return <div className="markdown-img-wrapper"><ImageGallery srcs={[src]} /></div>
} }
const Link = ({ node, ...props }: any) => { const Link = ({ node, children, ...props }: any) => {
if (node.properties?.href && node.properties.href?.toString().startsWith('abbr')) { if (node.properties?.href && node.properties.href?.toString().startsWith('abbr')) {
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const { onSend } = useChatContext() const { onSend } = useChatContext()
@ -261,7 +261,7 @@ const Link = ({ node, ...props }: any) => {
return <abbr className="cursor-pointer underline !decoration-primary-700 decoration-dashed" onClick={() => onSend?.(hidden_text)} title={node.children[0]?.value}>{node.children[0]?.value}</abbr> return <abbr className="cursor-pointer underline !decoration-primary-700 decoration-dashed" onClick={() => onSend?.(hidden_text)} title={node.children[0]?.value}>{node.children[0]?.value}</abbr>
} }
else { else {
return <a {...props} target="_blank" className="cursor-pointer underline !decoration-primary-700 decoration-dashed">{node.children[0] ? node.children[0]?.value : 'Download'}</a> return <a {...props} target="_blank" className="cursor-pointer underline !decoration-primary-700 decoration-dashed">{children || 'Download'}</a>
} }
} }

@ -1,116 +1,528 @@
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import mermaid from 'mermaid' import mermaid from 'mermaid'
import { usePrevious } from 'ahooks'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline' import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
import { cleanUpSvgCode } from './utils' import { MoonIcon, SunIcon } from '@heroicons/react/24/solid'
import {
cleanUpSvgCode,
isMermaidCodeComplete,
prepareMermaidCode,
processSvgForTheme,
svgToBase64,
waitForDOMElement,
} from './utils'
import LoadingAnim from '@/app/components/base/chat/chat/loading-anim' import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import ImagePreview from '@/app/components/base/image-uploader/image-preview' import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import { Theme } from '@/types/app'
let mermaidAPI: any // Global flags and cache for mermaid
mermaidAPI = null let isMermaidInitialized = false
const diagramCache = new Map<string, string>()
let mermaidAPI: any = null
if (typeof window !== 'undefined') if (typeof window !== 'undefined')
mermaidAPI = mermaid.mermaidAPI mermaidAPI = mermaid.mermaidAPI
const svgToBase64 = (svgGraph: string) => { // Theme configurations
const svgBytes = new TextEncoder().encode(svgGraph) const THEMES = {
const blob = new Blob([svgBytes], { type: 'image/svg+xml;charset=utf-8' }) light: {
return new Promise((resolve, reject) => { name: 'Light Theme',
const reader = new FileReader() background: '#ffffff',
reader.onloadend = () => resolve(reader.result) primaryColor: '#ffffff',
reader.onerror = reject primaryBorderColor: '#000000',
reader.readAsDataURL(blob) primaryTextColor: '#000000',
}) secondaryColor: '#ffffff',
tertiaryColor: '#ffffff',
nodeColors: [
{ bg: '#f0f9ff', color: '#0369a1' },
{ bg: '#f0fdf4', color: '#166534' },
{ bg: '#fef2f2', color: '#b91c1c' },
{ bg: '#faf5ff', color: '#7e22ce' },
{ bg: '#fffbeb', color: '#b45309' },
],
connectionColor: '#74a0e0',
},
dark: {
name: 'Dark Theme',
background: '#1e293b',
primaryColor: '#334155',
primaryBorderColor: '#94a3b8',
primaryTextColor: '#e2e8f0',
secondaryColor: '#475569',
tertiaryColor: '#334155',
nodeColors: [
{ bg: '#164e63', color: '#e0f2fe' },
{ bg: '#14532d', color: '#dcfce7' },
{ bg: '#7f1d1d', color: '#fee2e2' },
{ bg: '#581c87', color: '#f3e8ff' },
{ bg: '#78350f', color: '#fef3c7' },
],
connectionColor: '#60a5fa',
},
} }
const Flowchart = ( /**
{ * Initializes mermaid library with default configuration
ref, */
...props const initMermaid = () => {
}: { if (typeof window !== 'undefined' && !isMermaidInitialized) {
PrimitiveCode: string try {
} & { mermaid.initialize({
ref: React.RefObject<unknown>; startOnLoad: false,
}, fontFamily: 'sans-serif',
) => { securityLevel: 'loose',
flowchart: {
htmlLabels: true,
useMaxWidth: true,
diagramPadding: 10,
curve: 'basis',
nodeSpacing: 50,
rankSpacing: 70,
},
gantt: {
titleTopMargin: 25,
barHeight: 20,
barGap: 4,
topPadding: 50,
leftPadding: 75,
gridLineStartPadding: 35,
fontSize: 11,
numberSectionStyles: 4,
axisFormat: '%Y-%m-%d',
},
maxTextSize: 50000,
})
isMermaidInitialized = true
}
catch (error) {
console.error('Mermaid initialization error:', error)
return null
}
}
return isMermaidInitialized
}
const Flowchart = React.forwardRef((props: {
PrimitiveCode: string
theme?: 'light' | 'dark'
}, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const [svgCode, setSvgCode] = useState(null) const [svgCode, setSvgCode] = useState<string | null>(null)
const [look, setLook] = useState<'classic' | 'handDrawn'>('classic') const [look, setLook] = useState<'classic' | 'handDrawn'>('classic')
const [isInitialized, setIsInitialized] = useState(false)
const prevPrimitiveCode = usePrevious(props.PrimitiveCode) const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(props.theme || 'light')
const containerRef = useRef<HTMLDivElement>(null)
const chartId = useRef(`mermaid-chart-${Math.random().toString(36).substr(2, 9)}`).current
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const timeRef = useRef<number>(0) const renderTimeoutRef = useRef<NodeJS.Timeout>()
const [errMsg, setErrMsg] = useState('') const [errMsg, setErrMsg] = useState('')
const [imagePreviewUrl, setImagePreviewUrl] = useState('') const [imagePreviewUrl, setImagePreviewUrl] = useState('')
const [isCodeComplete, setIsCodeComplete] = useState(false)
const codeCompletionCheckRef = useRef<NodeJS.Timeout>()
// Create cache key from code, style and theme
const cacheKey = useMemo(() => {
return `${props.PrimitiveCode}-${look}-${currentTheme}`
}, [props.PrimitiveCode, look, currentTheme])
/**
* Renders Mermaid chart
*/
const renderMermaidChart = async (code: string, style: 'classic' | 'handDrawn') => {
if (style === 'handDrawn') {
// Special handling for hand-drawn style
if (containerRef.current)
containerRef.current.innerHTML = `<div id="${chartId}"></div>`
await new Promise(resolve => setTimeout(resolve, 30))
if (typeof window !== 'undefined' && mermaidAPI) {
// Prefer using mermaidAPI directly for hand-drawn style
return await mermaidAPI.render(chartId, code)
}
else {
// Fall back to standard rendering if mermaidAPI is not available
const { svg } = await mermaid.render(chartId, code)
return { svg }
}
}
else {
// Standard rendering for classic style - using the extracted waitForDOMElement function
const renderWithRetry = async () => {
if (containerRef.current)
containerRef.current.innerHTML = `<div id="${chartId}"></div>`
await new Promise(resolve => setTimeout(resolve, 30))
const { svg } = await mermaid.render(chartId, code)
return { svg }
}
return await waitForDOMElement(renderWithRetry)
}
}
/**
* Handle rendering errors
*/
const handleRenderError = (error: any) => {
console.error('Mermaid rendering error:', error)
const errorMsg = (error as Error).message
if (errorMsg.includes('getAttribute')) {
diagramCache.clear()
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
})
}
else {
setErrMsg(`Rendering chart failed, please refresh and try again ${look === 'handDrawn' ? 'Or try using classic mode' : ''}`)
}
if (look === 'handDrawn') {
try {
// Clear possible cache issues
diagramCache.delete(`${props.PrimitiveCode}-handDrawn-${currentTheme}`)
// Reset mermaid configuration
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
theme: 'default',
maxTextSize: 50000,
})
// Try rendering with standard mode
setLook('classic')
setErrMsg('Hand-drawn mode is not supported for this diagram. Switched to classic mode.')
// Delay error clearing
setTimeout(() => {
if (containerRef.current) {
// Try rendering again with standard mode, but can't call renderFlowchart directly due to circular dependency
// Instead set state to trigger re-render
setIsCodeComplete(true) // This will trigger useEffect re-render
}
}, 500)
}
catch (e) {
console.error('Reset after handDrawn error failed:', e)
}
}
setIsLoading(false)
}
// Initialize mermaid
useEffect(() => {
const api = initMermaid()
if (api)
setIsInitialized(true)
}, [])
// Update theme when prop changes
useEffect(() => {
if (props.theme)
setCurrentTheme(props.theme)
}, [props.theme])
// Validate mermaid code and check for completeness
useEffect(() => {
if (codeCompletionCheckRef.current)
clearTimeout(codeCompletionCheckRef.current)
// Reset code complete status when code changes
setIsCodeComplete(false)
// If no code or code is extremely short, don't proceed
if (!props.PrimitiveCode || props.PrimitiveCode.length < 10)
return
// Check if code already in cache - if so we know it's valid
if (diagramCache.has(cacheKey)) {
setIsCodeComplete(true)
return
}
// Initial check using the extracted isMermaidCodeComplete function
const isComplete = isMermaidCodeComplete(props.PrimitiveCode)
if (isComplete) {
setIsCodeComplete(true)
return
}
// Set a delay to check again in case code is still being generated
codeCompletionCheckRef.current = setTimeout(() => {
setIsCodeComplete(isMermaidCodeComplete(props.PrimitiveCode))
}, 300)
return () => {
if (codeCompletionCheckRef.current)
clearTimeout(codeCompletionCheckRef.current)
}
}, [props.PrimitiveCode, cacheKey])
/**
* Renders flowchart based on provided code
*/
const renderFlowchart = useCallback(async (primitiveCode: string) => {
if (!isInitialized || !containerRef.current) {
setIsLoading(false)
setErrMsg(!isInitialized ? 'Mermaid initialization failed' : 'Container element not found')
return
}
// Don't render if code is not complete yet
if (!isCodeComplete) {
setIsLoading(true)
return
}
// Return cached result if available
if (diagramCache.has(cacheKey)) {
setSvgCode(diagramCache.get(cacheKey) || null)
setIsLoading(false)
return
}
const renderFlowchart = useCallback(async (PrimitiveCode: string) => {
setSvgCode(null)
setIsLoading(true) setIsLoading(true)
setErrMsg('')
try { try {
if (typeof window !== 'undefined' && mermaidAPI) { let finalCode: string
const svgGraph = await mermaidAPI.render('flowchart', PrimitiveCode)
const base64Svg: any = await svgToBase64(cleanUpSvgCode(svgGraph.svg)) // Check if it's a gantt chart
const isGanttChart = primitiveCode.trim().startsWith('gantt')
if (isGanttChart) {
// For gantt charts, ensure each task is on its own line
// and preserve exact whitespace/format
finalCode = primitiveCode.trim()
}
else {
// Step 1: Clean and prepare Mermaid code using the extracted prepareMermaidCode function
finalCode = prepareMermaidCode(primitiveCode, look)
}
// Step 2: Render chart
const svgGraph = await renderMermaidChart(finalCode, look)
// Step 3: Apply theme to SVG using the extracted processSvgForTheme function
const processedSvg = processSvgForTheme(
svgGraph.svg,
currentTheme === Theme.dark,
look === 'handDrawn',
THEMES,
)
// Step 4: Clean SVG code and convert to base64 using the extracted functions
const cleanedSvg = cleanUpSvgCode(processedSvg)
const base64Svg = await svgToBase64(cleanedSvg)
if (base64Svg && typeof base64Svg === 'string') {
diagramCache.set(cacheKey, base64Svg)
setSvgCode(base64Svg) setSvgCode(base64Svg)
setIsLoading(false)
} }
setIsLoading(false)
} }
catch (error) { catch (error) {
if (prevPrimitiveCode === props.PrimitiveCode) { // Error handling
setIsLoading(false) handleRenderError(error)
setErrMsg((error as Error).message)
}
} }
}, [props.PrimitiveCode]) }, [chartId, isInitialized, cacheKey, isCodeComplete, look, currentTheme, t])
useEffect(() => { /**
if (typeof window !== 'undefined') { * Configure mermaid based on selected style and theme
mermaid.initialize({ */
startOnLoad: true, const configureMermaid = useCallback(() => {
theme: 'neutral', if (typeof window !== 'undefined' && isInitialized) {
look, const themeVars = THEMES[currentTheme]
flowchart: { const config: any = {
startOnLoad: false,
securityLevel: 'loose',
fontFamily: 'sans-serif',
maxTextSize: 50000,
gantt: {
titleTopMargin: 25,
barHeight: 20,
barGap: 4,
topPadding: 50,
leftPadding: 75,
gridLineStartPadding: 35,
fontSize: 11,
numberSectionStyles: 4,
axisFormat: '%Y-%m-%d',
},
}
if (look === 'classic') {
config.theme = currentTheme === 'dark' ? 'dark' : 'neutral'
config.flowchart = {
htmlLabels: true, htmlLabels: true,
useMaxWidth: true, useMaxWidth: true,
}, diagramPadding: 12,
}) nodeSpacing: 60,
rankSpacing: 80,
curve: 'linear',
ranker: 'tight-tree',
}
}
else {
config.theme = 'default'
config.themeCSS = `
.node rect { fill-opacity: 0.85; }
.edgePath .path { stroke-width: 1.5px; }
.label { font-family: 'sans-serif'; }
.edgeLabel { font-family: 'sans-serif'; }
.cluster rect { rx: 5px; ry: 5px; }
`
config.themeVariables = {
fontSize: '14px',
fontFamily: 'sans-serif',
}
config.flowchart = {
htmlLabels: true,
useMaxWidth: true,
diagramPadding: 10,
nodeSpacing: 40,
rankSpacing: 60,
curve: 'basis',
}
config.themeVariables.primaryBorderColor = currentTheme === 'dark' ? THEMES.dark.connectionColor : THEMES.light.connectionColor
}
renderFlowchart(props.PrimitiveCode) if (currentTheme === 'dark' && !config.themeVariables) {
config.themeVariables = {
background: themeVars.background,
primaryColor: themeVars.primaryColor,
primaryBorderColor: themeVars.primaryBorderColor,
primaryTextColor: themeVars.primaryTextColor,
secondaryColor: themeVars.secondaryColor,
tertiaryColor: themeVars.tertiaryColor,
fontFamily: 'sans-serif',
}
}
try {
mermaid.initialize(config)
return true
}
catch (error) {
console.error('Config error:', error)
return false
}
} }
}, [look]) return false
}, [currentTheme, isInitialized, look])
// Effect for theme and style configuration
useEffect(() => { useEffect(() => {
if (timeRef.current) if (diagramCache.has(cacheKey)) {
window.clearTimeout(timeRef.current) setSvgCode(diagramCache.get(cacheKey) || null)
setIsLoading(false)
return
}
timeRef.current = window.setTimeout(() => { if (configureMermaid() && containerRef.current && isCodeComplete)
renderFlowchart(props.PrimitiveCode) renderFlowchart(props.PrimitiveCode)
}, 300) }, [look, props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, currentTheme, isCodeComplete, configureMermaid])
}, [props.PrimitiveCode])
// Effect for rendering with debounce
useEffect(() => {
if (diagramCache.has(cacheKey)) {
setSvgCode(diagramCache.get(cacheKey) || null)
setIsLoading(false)
return
}
if (renderTimeoutRef.current)
clearTimeout(renderTimeoutRef.current)
if (isCodeComplete) {
renderTimeoutRef.current = setTimeout(() => {
if (isInitialized)
renderFlowchart(props.PrimitiveCode)
}, 300)
}
else {
setIsLoading(true)
}
return () => {
if (renderTimeoutRef.current)
clearTimeout(renderTimeoutRef.current)
}
}, [props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, isCodeComplete])
// Cleanup on unmount
useEffect(() => {
return () => {
if (containerRef.current)
containerRef.current.innerHTML = ''
if (renderTimeoutRef.current)
clearTimeout(renderTimeoutRef.current)
if (codeCompletionCheckRef.current)
clearTimeout(codeCompletionCheckRef.current)
}
}, [])
const toggleTheme = () => {
setCurrentTheme(prevTheme => prevTheme === 'light' ? Theme.dark : Theme.light)
diagramCache.clear()
}
// Style classes for theme-dependent elements
const themeClasses = {
container: cn('relative', {
'bg-white': currentTheme === Theme.light,
'bg-slate-900': currentTheme === Theme.dark,
}),
mermaidDiv: cn('mermaid cursor-pointer h-auto w-full relative', {
'bg-white': currentTheme === Theme.light,
'bg-slate-900': currentTheme === Theme.dark,
}),
errorMessage: cn('py-4 px-[26px]', {
'text-red-500': currentTheme === Theme.light,
'text-red-400': currentTheme === Theme.dark,
}),
errorIcon: cn('w-6 h-6', {
'text-red-500': currentTheme === Theme.light,
'text-red-400': currentTheme === Theme.dark,
}),
segmented: cn('msh-segmented msh-segmented-sm css-23bs09 css-var-r1', {
'text-gray-700': currentTheme === Theme.light,
'text-gray-300': currentTheme === Theme.dark,
}),
themeToggle: cn('flex items-center justify-center w-10 h-10 rounded-full transition-all duration-300 shadow-md backdrop-blur-sm', {
'bg-white/80 hover:bg-white hover:shadow-lg text-gray-700 border border-gray-200': currentTheme === Theme.light,
'bg-slate-800/80 hover:bg-slate-700 hover:shadow-lg text-yellow-300 border border-slate-600': currentTheme === Theme.dark,
}),
}
// Style classes for look options
const getLookButtonClass = (lookType: 'classic' | 'handDrawn') => {
return cn(
'flex items-center justify-center mb-4 w-[calc((100%-8px)/2)] h-8 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg cursor-pointer system-sm-medium text-text-secondary',
look === lookType && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary',
currentTheme === Theme.dark && 'border-slate-600 bg-slate-800 text-slate-300',
look === lookType && currentTheme === Theme.dark && 'border-blue-500 bg-slate-700 text-white',
)
}
return ( return (
// eslint-disable-next-line ts/ban-ts-comment <div ref={ref as React.RefObject<HTMLDivElement>} className={themeClasses.container}>
// @ts-expect-error <div className={themeClasses.segmented}>
(<div ref={ref}>
<div className="msh-segmented msh-segmented-sm css-23bs09 css-var-r1">
<div className="msh-segmented-group"> <div className="msh-segmented-group">
<label className="msh-segmented-item m-2 flex w-[200px] items-center space-x-1"> <label className="msh-segmented-item flex items-center space-x-1 m-2 w-[200px]">
<div key='classic' <div
className={cn('system-sm-medium mb-4 flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary', key='classic'
look === 'classic' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary', className={getLookButtonClass('classic')}
)}
onClick={() => setLook('classic')} onClick={() => setLook('classic')}
> >
<div className="msh-segmented-item-label">{t('app.mermaid.classic')}</div> <div className="msh-segmented-item-label">{t('app.mermaid.classic')}</div>
</div> </div>
<div key='handDrawn' <div
className={cn( key='handDrawn'
'system-sm-medium mb-4 flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary', className={getLookButtonClass('handDrawn')}
look === 'handDrawn' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary',
)}
onClick={() => setLook('handDrawn')} onClick={() => setLook('handDrawn')}
> >
<div className="msh-segmented-item-label">{t('app.mermaid.handDrawn')}</div> <div className="msh-segmented-item-label">{t('app.mermaid.handDrawn')}</div>
@ -118,31 +530,60 @@ const Flowchart = (
</label> </label>
</div> </div>
</div> </div>
{
svgCode <div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} />
&& <div className="mermaid object-fit: cover h-auto w-full cursor-pointer" onClick={() => setImagePreviewUrl(svgCode)}>
{svgCode && <img src={svgCode} alt="mermaid_chart" />} {isLoading && !svgCode && (
<div className='py-4 px-[26px]'>
<LoadingAnim type='text'/>
{!isCodeComplete && (
<div className="mt-2 text-sm text-gray-500">
{t('common.wait_for_completion', 'Waiting for diagram code to complete...')}
</div>
)}
</div> </div>
} )}
{isLoading
&& <div className='px-[26px] py-4'> {svgCode && (
<LoadingAnim type='text' /> <div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={() => setImagePreviewUrl(svgCode)}>
<div className="absolute left-2 bottom-2 z-[100]">
<button
onClick={(e) => {
e.stopPropagation()
toggleTheme()
}}
className={themeClasses.themeToggle}
title={(currentTheme === Theme.light ? t('app.theme.switchDark') : t('app.theme.switchLight')) || ''}
style={{ transform: 'translate3d(0, 0, 0)' }}
>
{currentTheme === Theme.light ? <MoonIcon className="h-5 w-5" /> : <SunIcon className="h-5 w-5" />}
</button>
</div>
<img
src={svgCode}
alt="mermaid_chart"
style={{ maxWidth: '100%' }}
onError={() => { setErrMsg('Chart rendering failed, please refresh and retry') }}
/>
</div> </div>
} )}
{
errMsg {errMsg && (
&& <div className='px-[26px] py-4'> <div className={themeClasses.errorMessage}>
<ExclamationTriangleIcon className='h-6 w-6 text-red-500' /> <div className="flex items-center">
&nbsp; <ExclamationTriangleIcon className={themeClasses.errorIcon}/>
{errMsg} <span className="ml-2">{errMsg}</span>
</div>
</div> </div>
} )}
{
imagePreviewUrl && (<ImagePreview title='mermaid_chart' url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} />) {imagePreviewUrl && (
} <ImagePreview title='mermaid_chart' url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} />
</div>) )}
</div>
) )
} })
Flowchart.displayName = 'Flowchart' Flowchart.displayName = 'Flowchart'

@ -1,3 +1,236 @@
export function cleanUpSvgCode(svgCode: string): string { export function cleanUpSvgCode(svgCode: string): string {
return svgCode.replaceAll('<br>', '<br/>') return svgCode.replaceAll('<br>', '<br/>')
} }
/**
* Preprocesses mermaid code to fix common syntax issues
*/
export function preprocessMermaidCode(code: string): string {
if (!code || typeof code !== 'string')
return ''
// First check if this is a gantt chart
if (code.trim().startsWith('gantt')) {
// For gantt charts, we need to ensure each task is on its own line
// Split the code into lines and process each line separately
const lines = code.split('\n').map(line => line.trim())
return lines.join('\n')
}
return code
// Replace English colons with Chinese colons in section nodes to avoid parsing issues
.replace(/section\s+([^:]+):/g, (match, sectionName) => `section ${sectionName}`)
// Fix common syntax issues
.replace(/fifopacket/g, 'rect')
// Ensure graph has direction
.replace(/^graph\s+((?:TB|BT|RL|LR)*)/, (match, direction) => {
return direction ? match : 'graph TD'
})
// Clean up empty lines and extra spaces
.trim()
}
/**
* Prepares mermaid code based on selected style
*/
export function prepareMermaidCode(code: string, style: 'classic' | 'handDrawn'): string {
let finalCode = preprocessMermaidCode(code)
// Special handling for gantt charts
if (finalCode.trim().startsWith('gantt')) {
// For gantt charts, preserve the structure exactly as is
return finalCode
}
if (style === 'handDrawn') {
finalCode = finalCode
// Remove style definitions that interfere with hand-drawn style
.replace(/style\s+[^\n]+/g, '')
.replace(/linkStyle\s+[^\n]+/g, '')
.replace(/^flowchart/, 'graph')
// Remove any styles that might interfere with hand-drawn style
.replace(/class="[^"]*"/g, '')
.replace(/fill="[^"]*"/g, '')
.replace(/stroke="[^"]*"/g, '')
// Ensure hand-drawn style charts always start with graph
if (!finalCode.startsWith('graph') && !finalCode.startsWith('flowchart'))
finalCode = `graph TD\n${finalCode}`
}
return finalCode
}
/**
* Converts SVG to base64 string for image rendering
*/
export function svgToBase64(svgGraph: string): Promise<string> {
if (!svgGraph)
return Promise.resolve('')
try {
// Ensure SVG has correct XML declaration
if (!svgGraph.includes('<?xml'))
svgGraph = `<?xml version="1.0" encoding="UTF-8"?>${svgGraph}`
const blob = new Blob([new TextEncoder().encode(svgGraph)], { type: 'image/svg+xml;charset=utf-8' })
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
catch (error) {
console.error('Error converting SVG to base64:', error)
return Promise.resolve('')
}
}
/**
* Processes SVG for theme styling
*/
export function processSvgForTheme(
svg: string,
isDark: boolean,
isHandDrawn: boolean,
themes: {
light: any
dark: any
},
): string {
let processedSvg = svg
if (isDark) {
processedSvg = processedSvg
.replace(/style="fill: ?#000000"/g, 'style="fill: #e2e8f0"')
.replace(/style="stroke: ?#000000"/g, 'style="stroke: #94a3b8"')
.replace(/<rect [^>]*fill="#ffffff"/g, '<rect $& fill="#1e293b"')
if (isHandDrawn) {
processedSvg = processedSvg
.replace(/fill="#[a-fA-F0-9]{6}"/g, `fill="${themes.dark.nodeColors[0].bg}"`)
.replace(/stroke="#[a-fA-F0-9]{6}"/g, `stroke="${themes.dark.connectionColor}"`)
.replace(/stroke-width="1"/g, 'stroke-width="1.5"')
}
else {
let i = 0
themes.dark.nodeColors.forEach(() => {
const regex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
processedSvg = processedSvg.replace(regex, (match: string) => {
const colorIndex = i % themes.dark.nodeColors.length
i++
return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.dark.nodeColors[colorIndex].bg}"`)
})
})
processedSvg = processedSvg
.replace(/<path [^>]*stroke="#[a-fA-F0-9]{6}"/g,
`<path stroke="${themes.dark.connectionColor}" stroke-width="1.5"`)
.replace(/<(line|polyline) [^>]*stroke="#[a-fA-F0-9]{6}"/g,
`<$1 stroke="${themes.dark.connectionColor}" stroke-width="1.5"`)
}
}
else {
if (isHandDrawn) {
processedSvg = processedSvg
.replace(/fill="#[a-fA-F0-9]{6}"/g, `fill="${themes.light.nodeColors[0].bg}"`)
.replace(/stroke="#[a-fA-F0-9]{6}"/g, `stroke="${themes.light.connectionColor}"`)
.replace(/stroke-width="1"/g, 'stroke-width="1.5"')
}
else {
themes.light.nodeColors.forEach(() => {
const regex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
let i = 0
processedSvg = processedSvg.replace(regex, (match: string) => {
const colorIndex = i % themes.light.nodeColors.length
i++
return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.light.nodeColors[colorIndex].bg}"`)
})
})
processedSvg = processedSvg
.replace(/<path [^>]*stroke="#[a-fA-F0-9]{6}"/g,
`<path stroke="${themes.light.connectionColor}"`)
.replace(/<(line|polyline) [^>]*stroke="#[a-fA-F0-9]{6}"/g,
`<$1 stroke="${themes.light.connectionColor}"`)
}
}
return processedSvg
}
/**
* Checks if mermaid code is complete and valid
*/
export function isMermaidCodeComplete(code: string): boolean {
if (!code || code.trim().length === 0)
return false
try {
const trimmedCode = code.trim()
// Special handling for gantt charts
if (trimmedCode.startsWith('gantt')) {
// For gantt charts, check if it has at least a title and one task
const lines = trimmedCode.split('\n').filter(line => line.trim().length > 0)
return lines.length >= 3
}
// Check for basic syntax structure
const hasValidStart = /^(graph|flowchart|sequenceDiagram|classDiagram|classDef|class|stateDiagram|gantt|pie|er|journey|requirementDiagram)/.test(trimmedCode)
// Check for balanced brackets and parentheses
const isBalanced = (() => {
const stack = []
const pairs = { '{': '}', '[': ']', '(': ')' }
for (const char of trimmedCode) {
if (char in pairs) {
stack.push(char)
}
else if (Object.values(pairs).includes(char)) {
const last = stack.pop()
if (pairs[last as keyof typeof pairs] !== char)
return false
}
}
return stack.length === 0
})()
// Check for common syntax errors
const hasNoSyntaxErrors = !trimmedCode.includes('undefined')
&& !trimmedCode.includes('[object Object]')
&& trimmedCode.split('\n').every(line =>
!(line.includes('-->') && !line.match(/\S+\s*-->\s*\S+/)))
return hasValidStart && isBalanced && hasNoSyntaxErrors
}
catch (error) {
console.debug('Mermaid code validation error:', error)
return false
}
}
/**
* Helper to wait for DOM element with retry mechanism
*/
export function waitForDOMElement(callback: () => Promise<any>, maxAttempts = 3, delay = 100): Promise<any> {
return new Promise((resolve, reject) => {
let attempts = 0
const tryRender = async () => {
try {
resolve(await callback())
}
catch (error) {
attempts++
if (attempts < maxAttempts)
setTimeout(tryRender, delay)
else
reject(error)
}
}
tryRender()
})
}

@ -31,6 +31,7 @@ import { useOptions } from './hooks'
import type { PickerBlockMenuOption } from './menu' import type { PickerBlockMenuOption } from './menu'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
import { KEY_ESCAPE_COMMAND } from 'lexical'
type ComponentPickerProps = { type ComponentPickerProps = {
triggerString: string triggerString: string
@ -118,6 +119,13 @@ const ComponentPicker = ({
editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variables) editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variables)
}, [editor, checkForTriggerMatch, triggerString]) }, [editor, checkForTriggerMatch, triggerString])
const handleClose = useCallback(() => {
ReactDOM.flushSync(() => {
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' })
editor.dispatchCommand(KEY_ESCAPE_COMMAND, escapeEvent)
})
}, [editor])
const renderMenu = useCallback<MenuRenderFn<PickerBlockMenuOption>>(( const renderMenu = useCallback<MenuRenderFn<PickerBlockMenuOption>>((
anchorElementRef, anchorElementRef,
{ options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }, { options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
@ -141,51 +149,54 @@ const ComponentPicker = ({
visibility: isPositioned ? 'visible' : 'hidden', visibility: isPositioned ? 'visible' : 'hidden',
}} }}
ref={refs.setFloating} ref={refs.setFloating}
data-testid="component-picker-container"
> >
{ {
options.map((option, index) => ( workflowVariableBlock?.show && (
<Fragment key={option.key}> <div className='p-1'>
{ <VarReferenceVars
// Divider searchBoxClassName='mt-1'
index !== 0 && options.at(index - 1)?.group !== option.group && ( vars={workflowVariableOptions}
<div className='my-1 h-px w-full -translate-x-1 bg-divider-subtle'></div> onChange={(variables: string[]) => {
) handleSelectWorkflowVariable(variables)
} }}
{option.renderMenuOption({ maxHeightClass='max-h-[34vh]'
queryString, isSupportFileVar={isSupportFileVar}
isSelected: selectedIndex === index, onClose={handleClose}
onSelect: () => { onBlur={handleClose}
selectOptionAndCleanUp(option) />
}, </div>
onSetHighlight: () => { )
setHighlightedIndex(index)
},
})}
</Fragment>
))
} }
{ {
workflowVariableBlock?.show && ( workflowVariableBlock?.show && !!options.length && (
<> <div className='my-1 h-px w-full -translate-x-1 bg-divider-subtle'></div>
{
(!!options.length) && (
<div className='my-1 h-px w-full -translate-x-1 bg-divider-subtle'></div>
)
}
<div className='p-1'>
<VarReferenceVars
hideSearch
vars={workflowVariableOptions}
onChange={(variables: string[]) => {
handleSelectWorkflowVariable(variables)
}}
maxHeightClass='max-h-[34vh]'
isSupportFileVar={isSupportFileVar}
/>
</div>
</>
) )
} }
<div data-testid="options-list">
{
options.map((option, index) => (
<Fragment key={option.key}>
{
// Divider
index !== 0 && options.at(index - 1)?.group !== option.group && (
<div className='my-1 h-px w-full -translate-x-1 bg-divider-subtle'></div>
)
}
{option.renderMenuOption({
queryString,
isSelected: selectedIndex === index,
onSelect: () => {
selectOptionAndCleanUp(option)
},
onSetHighlight: () => {
setHighlightedIndex(index)
},
})}
</Fragment>
))
}
</div>
</div> </div>
</div>, </div>,
anchorElementRef.current, anchorElementRef.current,
@ -193,7 +204,7 @@ const ComponentPicker = ({
} }
</> </>
) )
}, [allFlattenOptions.length, workflowVariableBlock?.show, refs, isPositioned, floatingStyles, queryString, workflowVariableOptions, handleSelectWorkflowVariable]) }, [allFlattenOptions.length, workflowVariableBlock?.show, refs, isPositioned, floatingStyles, queryString, workflowVariableOptions, handleSelectWorkflowVariable, handleClose, isSupportFileVar])
return ( return (
<LexicalTypeaheadMenuPlugin <LexicalTypeaheadMenuPlugin

@ -37,14 +37,16 @@ const OnBlurBlock: FC<OnBlurBlockProps> = ({
), ),
editor.registerCommand( editor.registerCommand(
BLUR_COMMAND, BLUR_COMMAND,
() => { (event) => {
ref.current = setTimeout(() => { // Check if the clicked target element is var-search-input
editor.dispatchCommand(KEY_ESCAPE_COMMAND, new KeyboardEvent('keydown', { key: 'Escape' })) const target = event?.relatedTarget as HTMLElement
}, 200) if (!target?.classList?.contains('var-search-input')) {
ref.current = setTimeout(() => {
if (onBlur) editor.dispatchCommand(KEY_ESCAPE_COMMAND, new KeyboardEvent('keydown', { key: 'Escape' }))
onBlur() }, 200)
if (onBlur)
onBlur()
}
return true return true
}, },
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,

@ -20,7 +20,7 @@ const FullScreenDrawer: FC<IFullScreenDrawerProps> = ({
<Drawer <Drawer
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
panelClassname={classNames('!p-0 bg-components-panel-bg', panelClassName={classNames('!p-0 bg-components-panel-bg',
fullScreen fullScreen
? '!max-w-full !w-full' ? '!max-w-full !w-full'
: 'mt-16 mr-2 mb-2 !max-w-[560px] !w-[560px] border-[0.5px] border-components-panel-border rounded-xl', : 'mt-16 mr-2 mb-2 !max-w-[560px] !w-[560px] border-[0.5px] border-components-panel-border rounded-xl',

@ -277,7 +277,7 @@ const DocumentDetail: FC<Props> = ({ datasetId, documentId }) => {
} }
</div> </div>
} }
<FloatRightContainer showClose isOpen={showMetadata} onClose={() => setShowMetadata(false)} isMobile={isMobile} panelClassname='!justify-start' footer={null}> <FloatRightContainer showClose isOpen={showMetadata} onClose={() => setShowMetadata(false)} isMobile={isMobile} panelClassName='!justify-start' footer={null}>
<Metadata <Metadata
className='mr-2 mt-3' className='mr-2 mt-3'
datasetId={datasetId} datasetId={datasetId}

@ -176,7 +176,7 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
<RecordsEmpty /> <RecordsEmpty />
)} )}
</div> </div>
<FloatRightContainer panelClassname='!justify-start !overflow-y-auto' showClose isMobile={isMobile} isOpen={isShowRightPanel} onClose={hideRightPanel} footer={null}> <FloatRightContainer panelClassName='!justify-start !overflow-y-auto' showClose isMobile={isMobile} isOpen={isShowRightPanel} onClose={hideRightPanel} footer={null}>
<div className='flex flex-col pt-3'> <div className='flex flex-col pt-3'>
{/* {renderHitResults(generalResultData)} */} {/* {renderHitResults(generalResultData)} */}
{submitLoading {submitLoading
@ -197,7 +197,7 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
} }
</div> </div>
</FloatRightContainer> </FloatRightContainer>
<Drawer unmount={true} isOpen={isShowModifyRetrievalModal} onClose={() => setIsShowModifyRetrievalModal(false)} footer={null} mask={isMobile} panelClassname='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl'> <Drawer unmount={true} isOpen={isShowModifyRetrievalModal} onClose={() => setIsShowModifyRetrievalModal(false)} footer={null} mask={isMobile} panelClassName='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl'>
<ModifyRetrievalModal <ModifyRetrievalModal
indexMethod={currentDataset?.indexing_technique || ''} indexMethod={currentDataset?.indexing_technique || ''}
value={retrievalConfig} value={retrievalConfig}

@ -173,7 +173,7 @@ const DatasetMetadataDrawer: FC<Props> = ({
showClose showClose
title={t('dataset.metadata.metadata')} title={t('dataset.metadata.metadata')}
footer={null} footer={null}
panelClassname='px-4 block !max-w-[420px] my-2 rounded-l-2xl' panelClassName='px-4 block !max-w-[420px] my-2 rounded-l-2xl'
> >
<div className='h-full overflow-y-auto'> <div className='h-full overflow-y-auto'>
<div className='system-sm-regular text-text-tertiary'>{t(`${i18nPrefix}.description`)}</div> <div className='system-sm-regular text-text-tertiary'>{t(`${i18nPrefix}.description`)}</div>

@ -150,8 +150,8 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange,
</div> </div>
</div> </div>
{isPartialMembers && ( {isPartialMembers && (
<div className='max-h-[360px] overflow-y-auto border-t-[1px] border-divider-regular p-1'> <div className='max-h-[360px] overflow-y-auto border-t-[1px] border-divider-regular pb-1 pl-1 pr-1'>
<div className='sticky left-0 top-0 p-2 pb-1'> <div className='sticky left-0 top-0 z-10 bg-white p-2 pb-1'>
<Input <Input
showLeftIcon showLeftIcon
showClearIcon showClearIcon

@ -74,7 +74,7 @@ const Popup: FC<PopupProps> = ({
/> />
<input <input
className='block h-[18px] grow appearance-none bg-transparent text-[13px] text-text-primary outline-none' className='block h-[18px] grow appearance-none bg-transparent text-[13px] text-text-primary outline-none'
placeholder='Search model' placeholder={t('datasetSettings.form.searchModel') || ''}
value={searchText} value={searchText}
onChange={e => setSearchText(e.target.value)} onChange={e => setSearchText(e.target.value)}
/> />

@ -32,7 +32,9 @@ const ListWithCollection = ({
return ( return (
<> <>
{ {
marketplaceCollections.map(collection => ( marketplaceCollections.filter((collection) => {
return marketplaceCollectionPluginsMap[collection.name]?.length
}).map(collection => (
<div <div
key={collection.name} key={collection.name}
className='py-3' className='py-3'

@ -46,7 +46,7 @@ const EndpointModal: FC<Props> = ({
footer={null} footer={null}
mask mask
positionCenter={false} positionCenter={false}
panelClassname={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')} panelClassName={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')}
> >
<> <>
<div className='p-4 pb-2'> <div className='p-4 pb-2'>

@ -38,7 +38,7 @@ const PluginDetailPanel: FC<Props> = ({
footer={null} footer={null}
mask={false} mask={false}
positionCenter={false} positionCenter={false}
panelClassname={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')} panelClassName={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')}
> >
{detail && ( {detail && (
<> <>

@ -78,7 +78,7 @@ const StrategyDetail: FC<Props> = ({
footer={null} footer={null}
mask={false} mask={false}
positionCenter={false} positionCenter={false}
panelClassname={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')} panelClassName={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')}
> >
<> <>
{/* header */} {/* header */}

@ -178,7 +178,7 @@ const AddToolModal: FC<Props> = ({
clickOutsideNotOpen clickOutsideNotOpen
onClose={onHide} onClose={onHide}
footer={null} footer={null}
panelClassname={cn('mx-2 mb-3 mt-16 rounded-xl !p-0 sm:mr-2', 'mt-2 !w-[640px]', '!max-w-[640px]')} panelClassName={cn('mx-2 mb-3 mt-16 rounded-xl !p-0 sm:mr-2', 'mt-2 !w-[640px]', '!max-w-[640px]')}
> >
<div <div
className='flex w-full rounded-xl border-[0.5px] border-gray-200 bg-white shadow-xl' className='flex w-full rounded-xl border-[0.5px] border-gray-200 bg-white shadow-xl'

@ -52,7 +52,9 @@ const ConfigCredential: FC<Props> = ({
positionCenter={positionCenter} positionCenter={positionCenter}
onHide={onHide} onHide={onHide}
title={t('tools.createTool.authMethod.title')!} title={t('tools.createTool.authMethod.title')!}
panelClassName='mt-2 !w-[520px] h-fit' dialogClassName='z-[60]'
dialogBackdropClassName='z-[70]'
panelClassName='mt-2 !w-[520px] h-fit z-[80]'
maxWidthClassName='!max-w-[520px]' maxWidthClassName='!max-w-[520px]'
height={'fit-content'} height={'fit-content'}
headerClassName='!border-b-divider-regular' headerClassName='!border-b-divider-regular'

@ -234,29 +234,31 @@ const ProviderDetail = ({
footer={null} footer={null}
mask={false} mask={false}
positionCenter={false} positionCenter={false}
panelClassname={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')} panelClassName={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')}
> >
<div className='p-4'> <div className='flex h-full flex-col p-4'>
<div className='mb-3 flex'> <div className="shrink-0">
<Icon src={collection.icon} /> <div className='mb-3 flex'>
<div className="ml-3 w-0 grow"> <Icon src={collection.icon} />
<div className="flex h-5 items-center"> <div className="ml-3 w-0 grow">
<Title title={collection.label[language]} /> <div className="flex h-5 items-center">
<Title title={collection.label[language]} />
</div>
<div className='mb-1 flex h-4 items-center justify-between'>
<OrgInfo
className="mt-0.5"
packageNameClassName='w-auto'
orgName={collection.author}
packageName={collection.name}
/>
</div>
</div> </div>
<div className='mb-1 flex h-4 items-center justify-between'> <div className='flex gap-1'>
<OrgInfo <ActionButton onClick={onHide}>
className="mt-0.5" <RiCloseLine className='h-4 w-4' />
packageNameClassName='w-auto' </ActionButton>
orgName={collection.author}
packageName={collection.name}
/>
</div> </div>
</div> </div>
<div className='flex gap-1'>
<ActionButton onClick={onHide}>
<RiCloseLine className='h-4 w-4' />
</ActionButton>
</div>
</div> </div>
{!!collection.description[language] && ( {!!collection.description[language] && (
<Description text={collection.description[language]} descriptionLineRows={2}></Description> <Description text={collection.description[language]} descriptionLineRows={2}></Description>
@ -292,85 +294,84 @@ const ProviderDetail = ({
</> </>
)} )}
</div> </div>
{/* Tools */} <div className='flex min-h-0 flex-1 flex-col pt-3'>
<div className='pt-3'>
{isDetailLoading && <div className='flex h-[200px]'><Loading type='app' /></div>} {isDetailLoading && <div className='flex h-[200px]'><Loading type='app' /></div>}
{/* Builtin type */} {!isDetailLoading && (
{!isDetailLoading && (collection.type === CollectionType.builtIn) && isAuthed && (
<div className='system-sm-semibold-uppercase mb-1 flex h-6 items-center justify-between text-text-secondary'>
{t('plugin.detailPanel.actionNum', { num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' })}
{needAuth && (
<Button
variant='secondary'
size='small'
onClick={() => {
if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model)
showSettingAuthModal()
}}
disabled={!isCurrentWorkspaceManager}
>
<Indicator className='mr-2' color={'green'} />
{t('tools.auth.authorized')}
</Button>
)}
</div>
)}
{!isDetailLoading && (collection.type === CollectionType.builtIn) && needAuth && !isAuthed && (
<> <>
<div className='system-sm-semibold-uppercase text-text-secondary'> <div className="shrink-0">
<span className=''>{t('tools.includeToolNum', { num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span> {(collection.type === CollectionType.builtIn || collection.type === CollectionType.model) && isAuthed && (
<span className='px-1'>·</span> <div className='system-sm-semibold-uppercase mb-1 flex h-6 items-center justify-between text-text-secondary'>
<span className='text-util-colors-orange-orange-600'>{t('tools.auth.setup').toLocaleUpperCase()}</span> {t('plugin.detailPanel.actionNum', { num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' })}
{needAuth && (
<Button
variant='secondary'
size='small'
onClick={() => {
if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model)
showSettingAuthModal()
}}
disabled={!isCurrentWorkspaceManager}
>
<Indicator className='mr-2' color={'green'} />
{t('tools.auth.authorized')}
</Button>
)}
</div>
)}
{(collection.type === CollectionType.builtIn || collection.type === CollectionType.model) && needAuth && !isAuthed && (
<>
<div className='system-sm-semibold-uppercase text-text-secondary'>
<span className=''>{t('tools.includeToolNum', { num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span>
<span className='px-1'>·</span>
<span className='text-util-colors-orange-orange-600'>{t('tools.auth.setup').toLocaleUpperCase()}</span>
</div>
<Button
variant='primary'
className={cn('my-3 w-full shrink-0')}
onClick={() => {
if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model)
showSettingAuthModal()
}}
disabled={!isCurrentWorkspaceManager}
>
{t('tools.auth.unauthorized')}
</Button>
</>
)}
{(collection.type === CollectionType.custom) && (
<div className='system-sm-semibold-uppercase text-text-secondary'>
<span className=''>{t('tools.includeToolNum', { num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span>
</div>
)}
{(collection.type === CollectionType.workflow) && (
<div className='system-sm-semibold-uppercase text-text-secondary'>
<span className=''>{t('tools.createTool.toolInput.title').toLocaleUpperCase()}</span>
</div>
)}
</div> </div>
<Button <div className='mt-1 flex-1 overflow-y-auto py-2'>
variant='primary' {collection.type !== CollectionType.workflow && toolList.map(tool => (
className={cn('my-3 w-full shrink-0')} <ToolItem
onClick={() => { key={tool.name}
if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model) disabled={false}
showSettingAuthModal() collection={collection}
}} tool={tool}
disabled={!isCurrentWorkspaceManager} isBuiltIn={isBuiltIn}
> isModel={isModel}
{t('tools.auth.unauthorized')} />
</Button> ))}
</> {collection.type === CollectionType.workflow && (customCollection as WorkflowToolProviderResponse)?.tool?.parameters.map(item => (
)} <div key={item.name} className='mb-1 py-1'>
{/* Custom type */} <div className='mb-1 flex items-center gap-2'>
{!isDetailLoading && (collection.type === CollectionType.custom) && ( <span className='code-sm-semibold text-text-secondary'>{item.name}</span>
<div className='system-sm-semibold-uppercase text-text-secondary'> <span className='system-xs-regular text-text-tertiary'>{item.type}</span>
<span className=''>{t('tools.includeToolNum', { num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span> <span className='system-xs-medium text-text-warning-secondary'>{item.required ? t('tools.createTool.toolInput.required') : ''}</span>
</div> </div>
)} <div className='system-xs-regular text-text-tertiary'>{item.llm_description}</div>
{/* Workflow type */}
{!isDetailLoading && (collection.type === CollectionType.workflow) && (
<div className='system-sm-semibold-uppercase text-text-secondary'>
<span className=''>{t('tools.createTool.toolInput.title').toLocaleUpperCase()}</span>
</div>
)}
{!isDetailLoading && (
<div className='mt-1 py-2'>
{collection.type !== CollectionType.workflow && toolList.map(tool => (
<ToolItem
key={tool.name}
disabled={false}
// disabled={needAuth && (isBuiltIn || isModel) && !isAuthed}
collection={collection}
tool={tool}
isBuiltIn={isBuiltIn}
isModel={isModel}
/>
))}
{collection.type === CollectionType.workflow && (customCollection as WorkflowToolProviderResponse)?.tool?.parameters.map(item => (
<div key={item.name} className='mb-1 py-1'>
<div className='mb-1 flex items-center gap-2'>
<span className='code-sm-semibold text-text-secondary'>{item.name}</span>
<span className='system-xs-regular text-text-tertiary'>{item.type}</span>
<span className='system-xs-medium text-text-warning-secondary'>{item.required ? t('tools.createTool.toolInput.required') : ''}</span>
</div> </div>
<div className='system-xs-regular text-text-tertiary'>{item.llm_description}</div> ))}
</div> </div>
))} </>
</div>
)} )}
</div> </div>
{showSettingAuth && ( {showSettingAuth && (

@ -258,6 +258,8 @@ type Props = {
onChange: (value: ValueSelector, item: Var) => void onChange: (value: ValueSelector, item: Var) => void
itemWidth?: number itemWidth?: number
maxHeightClass?: string maxHeightClass?: string
onClose?: () => void
onBlur?: () => void
} }
const VarReferenceVars: FC<Props> = ({ const VarReferenceVars: FC<Props> = ({
hideSearch, hideSearch,
@ -267,10 +269,19 @@ const VarReferenceVars: FC<Props> = ({
onChange, onChange,
itemWidth, itemWidth,
maxHeightClass, maxHeightClass,
onClose,
onBlur,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onClose?.()
}
}
const filteredVars = vars.filter((v) => { const filteredVars = vars.filter((v) => {
const children = v.vars.filter(v => checkKeys([v.variable], false).isValid || v.variable.startsWith('sys.') || v.variable.startsWith('env.') || v.variable.startsWith('conversation.')) const children = v.vars.filter(v => checkKeys([v.variable], false).isValid || v.variable.startsWith('sys.') || v.variable.startsWith('env.') || v.variable.startsWith('conversation.'))
return children.length > 0 return children.length > 0
@ -301,14 +312,17 @@ const VarReferenceVars: FC<Props> = ({
{ {
!hideSearch && ( !hideSearch && (
<> <>
<div className={cn('mx-2 mb-1 mt-2', searchBoxClassName)} onClick={e => e.stopPropagation()}> <div className={cn('var-search-input-wrapper mx-2 mb-1 mt-2', searchBoxClassName)} onClick={e => e.stopPropagation()}>
<Input <Input
className='var-search-input'
showLeftIcon showLeftIcon
showClearIcon showClearIcon
value={searchText} value={searchText}
placeholder={t('workflow.common.searchVar') || ''} placeholder={t('workflow.common.searchVar') || ''}
onChange={e => setSearchText(e.target.value)} onChange={e => setSearchText(e.target.value)}
onKeyDown={handleKeyDown}
onClear={() => setSearchText('')} onClear={() => setSearchText('')}
onBlur={onBlur}
autoFocus autoFocus
/> />
</div> </div>

@ -111,7 +111,7 @@ const DatasetItem: FC<Props> = ({
} }
{isShowSettingsModal && ( {isShowSettingsModal && (
<Drawer isOpen={isShowSettingsModal} onClose={hideSettingsModal} footer={null} mask={isMobile} panelClassname='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl'> <Drawer isOpen={isShowSettingsModal} onClose={hideSettingsModal} footer={null} mask={isMobile} panelClassName='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl'>
<SettingsModal <SettingsModal
currentDataset={payload} currentDataset={payload}
onCancel={hideSettingsModal} onCancel={hideSettingsModal}

@ -35,6 +35,7 @@ const translation = {
upgradeHighQualityTip: 'Nach dem Upgrade auf den Modus "Hohe Qualität" ist das Zurücksetzen auf den Modus "Wirtschaftlich" nicht mehr möglich', upgradeHighQualityTip: 'Nach dem Upgrade auf den Modus "Hohe Qualität" ist das Zurücksetzen auf den Modus "Wirtschaftlich" nicht mehr möglich',
helpText: 'Erfahren Sie, wie Sie eine gute Datensatzbeschreibung schreiben.', helpText: 'Erfahren Sie, wie Sie eine gute Datensatzbeschreibung schreiben.',
indexMethodChangeToEconomyDisabledTip: 'Nicht verfügbar für ein Downgrade von HQ auf ECO', indexMethodChangeToEconomyDisabledTip: 'Nicht verfügbar für ein Downgrade von HQ auf ECO',
searchModel: 'Modell suchen',
}, },
} }

@ -36,6 +36,7 @@ const translation = {
retrievalSettings: 'Retrieval Settings', retrievalSettings: 'Retrieval Settings',
save: 'Save', save: 'Save',
indexMethodChangeToEconomyDisabledTip: 'Not available for downgrading from HQ to ECO', indexMethodChangeToEconomyDisabledTip: 'Not available for downgrading from HQ to ECO',
searchModel: 'Search model',
}, },
} }

@ -35,6 +35,7 @@ const translation = {
indexMethodChangeToEconomyDisabledTip: 'No disponible para degradar de HQ a ECO', indexMethodChangeToEconomyDisabledTip: 'No disponible para degradar de HQ a ECO',
helpText: 'Aprenda a escribir una buena descripción del conjunto de datos.', helpText: 'Aprenda a escribir una buena descripción del conjunto de datos.',
upgradeHighQualityTip: 'Una vez que se actualiza al modo de alta calidad, no está disponible volver al modo económico', upgradeHighQualityTip: 'Una vez que se actualiza al modo de alta calidad, no está disponible volver al modo económico',
searchModel: 'Buscar modelo',
}, },
} }

@ -35,6 +35,7 @@ const translation = {
indexMethodChangeToEconomyDisabledTip: 'برای تنزل رتبه از HQ به ECO در دسترس نیست', indexMethodChangeToEconomyDisabledTip: 'برای تنزل رتبه از HQ به ECO در دسترس نیست',
helpText: 'یاد بگیرید که چگونه یک توضیحات مجموعه داده خوب بنویسید.', helpText: 'یاد بگیرید که چگونه یک توضیحات مجموعه داده خوب بنویسید.',
upgradeHighQualityTip: 'پس از ارتقاء به حالت کیفیت بالا، بازگشت به حالت اقتصادی در دسترس نیست', upgradeHighQualityTip: 'پس از ارتقاء به حالت کیفیت بالا، بازگشت به حالت اقتصادی در دسترس نیست',
searchModel: 'جستجوی مدل',
}, },
} }

@ -35,6 +35,7 @@ const translation = {
indexMethodChangeToEconomyDisabledTip: 'Non disponible pour le déclassement de HQ à ECO', indexMethodChangeToEconomyDisabledTip: 'Non disponible pour le déclassement de HQ à ECO',
upgradeHighQualityTip: 'Une fois la mise à niveau vers le mode Haute Qualité, il nest pas possible de revenir au mode Économique', upgradeHighQualityTip: 'Une fois la mise à niveau vers le mode Haute Qualité, il nest pas possible de revenir au mode Économique',
helpText: 'Apprenez à rédiger une bonne description de jeu de données.', helpText: 'Apprenez à rédiger une bonne description de jeu de données.',
searchModel: 'Rechercher un modèle',
}, },
} }

@ -40,6 +40,7 @@ const translation = {
indexMethodChangeToEconomyDisabledTip: 'मुख्यालय से ईसीओ में डाउनग्रेड करने के लिए उपलब्ध नहीं है', indexMethodChangeToEconomyDisabledTip: 'मुख्यालय से ईसीओ में डाउनग्रेड करने के लिए उपलब्ध नहीं है',
helpText: 'एक अच्छा डेटासेट विवरण लिखना सीखें।', helpText: 'एक अच्छा डेटासेट विवरण लिखना सीखें।',
upgradeHighQualityTip: 'एक बार उच्च गुणवत्ता मोड में अपग्रेड करने के बाद, किफायती मोड में वापस जाना उपलब्ध नहीं है', upgradeHighQualityTip: 'एक बार उच्च गुणवत्ता मोड में अपग्रेड करने के बाद, किफायती मोड में वापस जाना उपलब्ध नहीं है',
searchModel: 'मॉडल खोजें',
}, },
} }

@ -40,6 +40,7 @@ const translation = {
helpText: 'Scopri come scrivere una buona descrizione del set di dati.', helpText: 'Scopri come scrivere una buona descrizione del set di dati.',
upgradeHighQualityTip: 'Una volta effettuato l\'aggiornamento alla modalità Alta qualità, il ripristino della modalità Risparmio non è disponibile', upgradeHighQualityTip: 'Una volta effettuato l\'aggiornamento alla modalità Alta qualità, il ripristino della modalità Risparmio non è disponibile',
indexMethodChangeToEconomyDisabledTip: 'Non disponibile per il downgrade da HQ a ECO', indexMethodChangeToEconomyDisabledTip: 'Non disponibile per il downgrade da HQ a ECO',
searchModel: 'Cerca modello',
}, },
} }

@ -36,6 +36,7 @@ const translation = {
retrievalSettings: '取得設定', retrievalSettings: '取得設定',
externalKnowledgeAPI: '外部ナレッジベースAPI', externalKnowledgeAPI: '外部ナレッジベースAPI',
indexMethodChangeToEconomyDisabledTip: 'HQからECOへのダウングレードはできません。', indexMethodChangeToEconomyDisabledTip: 'HQからECOへのダウングレードはできません。',
searchModel: 'モデル検索',
}, },
} }

@ -35,6 +35,7 @@ const translation = {
upgradeHighQualityTip: '고품질 모드로 업그레이드한 후에는 경제적 모드로 되돌릴 수 없습니다.', upgradeHighQualityTip: '고품질 모드로 업그레이드한 후에는 경제적 모드로 되돌릴 수 없습니다.',
indexMethodChangeToEconomyDisabledTip: 'HQ에서 ECO로 다운그레이드할 수 없습니다.', indexMethodChangeToEconomyDisabledTip: 'HQ에서 ECO로 다운그레이드할 수 없습니다.',
helpText: '좋은 데이터 세트 설명을 작성하는 방법을 알아보세요.', helpText: '좋은 데이터 세트 설명을 작성하는 방법을 알아보세요.',
searchModel: '모델 검색',
}, },
} }

@ -40,6 +40,7 @@ const translation = {
helpText: 'Dowiedz się, jak napisać dobry opis zestawu danych.', helpText: 'Dowiedz się, jak napisać dobry opis zestawu danych.',
upgradeHighQualityTip: 'Po uaktualnieniu do trybu wysokiej jakości powrót do trybu ekonomicznego nie jest dostępny', upgradeHighQualityTip: 'Po uaktualnieniu do trybu wysokiej jakości powrót do trybu ekonomicznego nie jest dostępny',
indexMethodChangeToEconomyDisabledTip: 'Niedostępne w przypadku zmiany z HQ na ECO', indexMethodChangeToEconomyDisabledTip: 'Niedostępne w przypadku zmiany z HQ na ECO',
searchModel: 'Szukaj modelu',
}, },
} }

@ -35,6 +35,7 @@ const translation = {
indexMethodChangeToEconomyDisabledTip: 'Não disponível para rebaixamento de HQ para ECO', indexMethodChangeToEconomyDisabledTip: 'Não disponível para rebaixamento de HQ para ECO',
helpText: 'Aprenda a escrever uma boa descrição do conjunto de dados.', helpText: 'Aprenda a escrever uma boa descrição do conjunto de dados.',
upgradeHighQualityTip: 'Depois de atualizar para o modo de alta qualidade, reverter para o modo econômico não está disponível', upgradeHighQualityTip: 'Depois de atualizar para o modo de alta qualidade, reverter para o modo econômico não está disponível',
searchModel: 'Pesquisar modelo',
}, },
} }

@ -35,6 +35,7 @@ const translation = {
indexMethodChangeToEconomyDisabledTip: 'Nu este disponibil pentru retrogradarea de la HQ la ECO', indexMethodChangeToEconomyDisabledTip: 'Nu este disponibil pentru retrogradarea de la HQ la ECO',
upgradeHighQualityTip: 'După ce faceți upgrade la modul Înaltă calitate, revenirea la modul Economic nu este disponibilă', upgradeHighQualityTip: 'După ce faceți upgrade la modul Înaltă calitate, revenirea la modul Economic nu este disponibilă',
helpText: 'Aflați cum să scrieți o descriere bună a setului de date.', helpText: 'Aflați cum să scrieți o descriere bună a setului de date.',
searchModel: 'Căutare model',
}, },
} }

@ -35,6 +35,7 @@ const translation = {
helpText: 'Узнайте, как написать хорошее описание набора данных.', helpText: 'Узнайте, как написать хорошее описание набора данных.',
upgradeHighQualityTip: 'После обновления до режима «Высокое качество» возврат к экономичному режиму невозможен', upgradeHighQualityTip: 'После обновления до режима «Высокое качество» возврат к экономичному режиму невозможен',
indexMethodChangeToEconomyDisabledTip: 'Недоступно для понижения уровня с HQ до ECO', indexMethodChangeToEconomyDisabledTip: 'Недоступно для понижения уровня с HQ до ECO',
searchModel: 'Поиск модели',
}, },
} }

@ -35,6 +35,7 @@ const translation = {
indexMethodChangeToEconomyDisabledTip: 'Ni na voljo za pregradnjo iz HQ v ECO', indexMethodChangeToEconomyDisabledTip: 'Ni na voljo za pregradnjo iz HQ v ECO',
upgradeHighQualityTip: 'Ko nadgradite na način visoke kakovosti, vrnitev v ekonomični način ni na voljo', upgradeHighQualityTip: 'Ko nadgradite na način visoke kakovosti, vrnitev v ekonomični način ni na voljo',
helpText: 'Naučite se napisati dober opis nabora podatkov.', helpText: 'Naučite se napisati dober opis nabora podatkov.',
searchModel: 'Išči model',
}, },
} }

@ -35,6 +35,7 @@ const translation = {
indexMethodChangeToEconomyDisabledTip: 'ไม่สามารถดาวน์เกรดจาก HQ เป็น ECO ได้', indexMethodChangeToEconomyDisabledTip: 'ไม่สามารถดาวน์เกรดจาก HQ เป็น ECO ได้',
helpText: 'เรียนรู้วิธีเขียนคําอธิบายชุดข้อมูลที่ดี', helpText: 'เรียนรู้วิธีเขียนคําอธิบายชุดข้อมูลที่ดี',
upgradeHighQualityTip: 'เมื่ออัปเกรดเป็นโหมดคุณภาพสูงแล้ว จะไม่สามารถเปลี่ยนกลับเป็นโหมดประหยัดได้', upgradeHighQualityTip: 'เมื่ออัปเกรดเป็นโหมดคุณภาพสูงแล้ว จะไม่สามารถเปลี่ยนกลับเป็นโหมดประหยัดได้',
searchModel: 'ค้นหารุ่น',
}, },
} }

@ -35,6 +35,7 @@ const translation = {
upgradeHighQualityTip: 'Yüksek Kalite moduna yükselttikten sonra Ekonomik moda geri dönülemez', upgradeHighQualityTip: 'Yüksek Kalite moduna yükselttikten sonra Ekonomik moda geri dönülemez',
indexMethodChangeToEconomyDisabledTip: 'Genel Merkezden ECO\'ya düşürme için mevcut değil', indexMethodChangeToEconomyDisabledTip: 'Genel Merkezden ECO\'ya düşürme için mevcut değil',
helpText: 'İyi bir veri kümesi açıklamasının nasıl yazılacağını öğrenin.', helpText: 'İyi bir veri kümesi açıklamasının nasıl yazılacağını öğrenin.',
searchModel: 'Model Ara',
}, },
} }

@ -35,6 +35,7 @@ const translation = {
helpText: 'Дізнайтеся, як написати хороший опис набору даних.', helpText: 'Дізнайтеся, як написати хороший опис набору даних.',
indexMethodChangeToEconomyDisabledTip: 'Недоступно для пониження з HQ до ECO', indexMethodChangeToEconomyDisabledTip: 'Недоступно для пониження з HQ до ECO',
upgradeHighQualityTip: 'Після оновлення до режиму високої якості повернення до економного режиму недоступне', upgradeHighQualityTip: 'Після оновлення до режиму високої якості повернення до економного режиму недоступне',
searchModel: 'Пошук моделі',
}, },
} }

@ -35,6 +35,7 @@ const translation = {
helpText: 'Tìm hiểu cách viết mô tả tập dữ liệu tốt.', helpText: 'Tìm hiểu cách viết mô tả tập dữ liệu tốt.',
indexMethodChangeToEconomyDisabledTip: 'Không khả dụng để hạ cấp từ HQ xuống ECO', indexMethodChangeToEconomyDisabledTip: 'Không khả dụng để hạ cấp từ HQ xuống ECO',
upgradeHighQualityTip: 'Sau khi nâng cấp lên chế độ Chất lượng cao, không thể hoàn nguyên về chế độ Tiết kiệm', upgradeHighQualityTip: 'Sau khi nâng cấp lên chế độ Chất lượng cao, không thể hoàn nguyên về chế độ Tiết kiệm',
searchModel: 'Tìm kiếm mô hình',
}, },
} }

@ -36,6 +36,7 @@ const translation = {
save: '保存', save: '保存',
retrievalSettings: '检索设置', retrievalSettings: '检索设置',
indexMethodChangeToEconomyDisabledTip: '无法从高质量降级为经济', indexMethodChangeToEconomyDisabledTip: '无法从高质量降级为经济',
searchModel: '搜索模型',
}, },
} }

@ -35,6 +35,7 @@ const translation = {
indexMethodChangeToEconomyDisabledTip: '不適用於從 HQ 降級到 ECO', indexMethodChangeToEconomyDisabledTip: '不適用於從 HQ 降級到 ECO',
upgradeHighQualityTip: '升級到高品質模式后,無法恢復到經濟模式', upgradeHighQualityTip: '升級到高品質模式后,無法恢復到經濟模式',
helpText: '瞭解如何編寫良好的數據集描述。', helpText: '瞭解如何編寫良好的數據集描述。',
searchModel: '搜索模型',
}, },
} }

Loading…
Cancel
Save