Compare commits

..

2 Commits

Author SHA1 Message Date
yyh
21f1187f82 Merge branch 'main' into feat/add-agentation 2026-01-26 12:32:16 +08:00
2eca49e099 feat: Add Agentation devtools component for development 2026-01-24 16:28:01 +08:00
111 changed files with 4633 additions and 27789 deletions

View File

@ -1,27 +0,0 @@
# Notes: `large_language_model.py`
## Purpose
Provides the base `LargeLanguageModel` implementation used by the model runtime to invoke plugin-backed LLMs and to
bridge plugin daemon streaming semantics back into API-layer entities (`LLMResult`, `LLMResultChunk`).
## Key behaviors / invariants
- `invoke(..., stream=False)` still calls the plugin in streaming mode and then synthesizes a single `LLMResult` from
the first yielded `LLMResultChunk`.
- Plugin invocation is wrapped by `_invoke_llm_via_plugin(...)`, and `stream=False` normalization is handled by
`_normalize_non_stream_plugin_result(...)` / `_build_llm_result_from_first_chunk(...)`.
- Tool call deltas are merged incrementally via `_increase_tool_call(...)` to support multiple provider chunking
patterns (IDs anchored to first chunk, every chunk, or missing entirely).
- A tool-call delta with an empty `id` requires at least one existing tool call; otherwise we raise `ValueError` to
surface invalid delta sequences explicitly.
- Callback invocation is centralized in `_run_callbacks(...)` to ensure consistent error handling/logging.
- For compatibility with dify issue `#17799`, `prompt_messages` may be removed by the plugin daemon in chunks and must
be re-attached in this layer before callbacks/consumers use them.
- Callback hooks (`on_before_invoke`, `on_new_chunk`, `on_after_invoke`, `on_invoke_error`) must not break invocation
unless `callback.raise_error` is true.
## Test focus
- `api/tests/unit_tests/core/model_runtime/__base/test_increase_tool_call.py` validates tool-call delta merging and
patches `_gen_tool_call_id` for deterministic IDs.

View File

@ -1,19 +1,20 @@
from typing import Literal
from flask import request
from flask_restx import Resource, fields
from pydantic import BaseModel, Field, field_validator
from configs import dify_config
from controllers.fastopenapi import console_router
from libs.helper import EmailStr, extract_remote_ip
from libs.password import valid_password
from models.model import DifySetup, db
from services.account_service import RegisterService, TenantService
from . import console_ns
from .error import AlreadySetupError, NotInitValidateError
from .init_validate import get_init_validate_status
from .wraps import only_edition_self_hosted
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class SetupRequestPayload(BaseModel):
email: EmailStr = Field(..., description="Admin email address")
@ -27,66 +28,78 @@ class SetupRequestPayload(BaseModel):
return valid_password(value)
class SetupStatusResponse(BaseModel):
step: Literal["not_started", "finished"] = Field(description="Setup step status")
setup_at: str | None = Field(default=None, description="Setup completion time (ISO format)")
class SetupResponse(BaseModel):
result: str = Field(description="Setup result", examples=["success"])
@console_router.get(
"/setup",
response_model=SetupStatusResponse,
tags=["console"],
console_ns.schema_model(
SetupRequestPayload.__name__,
SetupRequestPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
def get_setup_status_api() -> SetupStatusResponse:
"""Get system setup status."""
if dify_config.EDITION == "SELF_HOSTED":
setup_status = get_setup_status()
if setup_status and not isinstance(setup_status, bool):
return SetupStatusResponse(step="finished", setup_at=setup_status.setup_at.isoformat())
if setup_status:
return SetupStatusResponse(step="finished")
return SetupStatusResponse(step="not_started")
return SetupStatusResponse(step="finished")
@console_router.post(
"/setup",
response_model=SetupResponse,
tags=["console"],
status_code=201,
)
@only_edition_self_hosted
def setup_system(payload: SetupRequestPayload) -> SetupResponse:
"""Initialize system setup with admin account."""
if get_setup_status():
raise AlreadySetupError()
tenant_count = TenantService.get_tenant_count()
if tenant_count > 0:
raise AlreadySetupError()
if not get_init_validate_status():
raise NotInitValidateError()
normalized_email = payload.email.lower()
RegisterService.setup(
email=normalized_email,
name=payload.name,
password=payload.password,
ip_address=extract_remote_ip(request),
language=payload.language,
@console_ns.route("/setup")
class SetupApi(Resource):
@console_ns.doc("get_setup_status")
@console_ns.doc(description="Get system setup status")
@console_ns.response(
200,
"Success",
console_ns.model(
"SetupStatusResponse",
{
"step": fields.String(description="Setup step status", enum=["not_started", "finished"]),
"setup_at": fields.String(description="Setup completion time (ISO format)", required=False),
},
),
)
def get(self):
"""Get system setup status"""
if dify_config.EDITION == "SELF_HOSTED":
setup_status = get_setup_status()
# Check if setup_status is a DifySetup object rather than a bool
if setup_status and not isinstance(setup_status, bool):
return {"step": "finished", "setup_at": setup_status.setup_at.isoformat()}
elif setup_status:
return {"step": "finished"}
return {"step": "not_started"}
return {"step": "finished"}
return SetupResponse(result="success")
@console_ns.doc("setup_system")
@console_ns.doc(description="Initialize system setup with admin account")
@console_ns.expect(console_ns.models[SetupRequestPayload.__name__])
@console_ns.response(
201, "Success", console_ns.model("SetupResponse", {"result": fields.String(description="Setup result")})
)
@console_ns.response(400, "Already setup or validation failed")
@only_edition_self_hosted
def post(self):
"""Initialize system setup with admin account"""
# is set up
if get_setup_status():
raise AlreadySetupError()
# is tenant created
tenant_count = TenantService.get_tenant_count()
if tenant_count > 0:
raise AlreadySetupError()
if not get_init_validate_status():
raise NotInitValidateError()
args = SetupRequestPayload.model_validate(console_ns.payload)
normalized_email = args.email.lower()
# setup
RegisterService.setup(
email=normalized_email,
name=args.name,
password=args.password,
ip_address=extract_remote_ip(request),
language=args.language,
)
return {"result": "success"}, 201
def get_setup_status() -> DifySetup | bool | None:
def get_setup_status():
if dify_config.EDITION == "SELF_HOSTED":
return db.session.query(DifySetup).first()
return True
else:
return True

View File

@ -1,11 +1,15 @@
import json
import logging
import httpx
from flask import request
from flask_restx import Resource, fields
from packaging import version
from pydantic import BaseModel, Field
from configs import dify_config
from controllers.fastopenapi import console_router
from . import console_ns
logger = logging.getLogger(__name__)
@ -14,60 +18,68 @@ class VersionQuery(BaseModel):
current_version: str = Field(..., description="Current application version")
class VersionFeatures(BaseModel):
can_replace_logo: bool = Field(description="Whether logo replacement is supported")
model_load_balancing_enabled: bool = Field(description="Whether model load balancing is enabled")
class VersionResponse(BaseModel):
version: str = Field(description="Latest version number")
release_date: str = Field(description="Release date of latest version")
release_notes: str = Field(description="Release notes for latest version")
can_auto_update: bool = Field(description="Whether auto-update is supported")
features: VersionFeatures = Field(description="Feature flags and capabilities")
@console_router.get(
"/version",
response_model=VersionResponse,
tags=["console"],
console_ns.schema_model(
VersionQuery.__name__,
VersionQuery.model_json_schema(ref_template="#/definitions/{model}"),
)
def check_version_update(query: VersionQuery) -> VersionResponse:
"""Check for application version updates."""
check_update_url = dify_config.CHECK_UPDATE_URL
result = VersionResponse(
version=dify_config.project.version,
release_date="",
release_notes="",
can_auto_update=False,
features=VersionFeatures(
can_replace_logo=dify_config.CAN_REPLACE_LOGO,
model_load_balancing_enabled=dify_config.MODEL_LB_ENABLED,
@console_ns.route("/version")
class VersionApi(Resource):
@console_ns.doc("check_version_update")
@console_ns.doc(description="Check for application version updates")
@console_ns.expect(console_ns.models[VersionQuery.__name__])
@console_ns.response(
200,
"Success",
console_ns.model(
"VersionResponse",
{
"version": fields.String(description="Latest version number"),
"release_date": fields.String(description="Release date of latest version"),
"release_notes": fields.String(description="Release notes for latest version"),
"can_auto_update": fields.Boolean(description="Whether auto-update is supported"),
"features": fields.Raw(description="Feature flags and capabilities"),
},
),
)
def get(self):
"""Check for application version updates"""
args = VersionQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
check_update_url = dify_config.CHECK_UPDATE_URL
if not check_update_url:
return result
result = {
"version": dify_config.project.version,
"release_date": "",
"release_notes": "",
"can_auto_update": False,
"features": {
"can_replace_logo": dify_config.CAN_REPLACE_LOGO,
"model_load_balancing_enabled": dify_config.MODEL_LB_ENABLED,
},
}
try:
response = httpx.get(
check_update_url,
params={"current_version": query.current_version},
timeout=httpx.Timeout(timeout=10.0, connect=3.0),
)
content = response.json()
except Exception as error:
logger.warning("Check update version error: %s.", str(error))
result.version = query.current_version
if not check_update_url:
return result
try:
response = httpx.get(
check_update_url,
params={"current_version": args.current_version},
timeout=httpx.Timeout(timeout=10.0, connect=3.0),
)
except Exception as error:
logger.warning("Check update version error: %s.", str(error))
result["version"] = args.current_version
return result
content = json.loads(response.content)
if _has_new_version(latest_version=content["version"], current_version=f"{args.current_version}"):
result["version"] = content["version"]
result["release_date"] = content["releaseDate"]
result["release_notes"] = content["releaseNotes"]
result["can_auto_update"] = content["canAutoUpdate"]
return result
latest_version = content.get("version", result.version)
if _has_new_version(latest_version=latest_version, current_version=f"{query.current_version}"):
result.version = latest_version
result.release_date = content.get("releaseDate", "")
result.release_notes = content.get("releaseNotes", "")
result.can_auto_update = content.get("canAutoUpdate", False)
return result
def _has_new_version(*, latest_version: str, current_version: str) -> bool:

View File

@ -1,7 +1,7 @@
import logging
import time
import uuid
from collections.abc import Callable, Generator, Iterator, Sequence
from collections.abc import Generator, Sequence
from typing import Union
from pydantic import ConfigDict
@ -30,142 +30,6 @@ def _gen_tool_call_id() -> str:
return f"chatcmpl-tool-{str(uuid.uuid4().hex)}"
def _run_callbacks(callbacks: Sequence[Callback] | None, *, event: str, invoke: Callable[[Callback], None]) -> None:
if not callbacks:
return
for callback in callbacks:
try:
invoke(callback)
except Exception as e:
if callback.raise_error:
raise
logger.warning("Callback %s %s failed with error %s", callback.__class__.__name__, event, e)
def _get_or_create_tool_call(
existing_tools_calls: list[AssistantPromptMessage.ToolCall],
tool_call_id: str,
) -> AssistantPromptMessage.ToolCall:
"""
Get or create a tool call by ID.
If `tool_call_id` is empty, returns the most recently created tool call.
"""
if not tool_call_id:
if not existing_tools_calls:
raise ValueError("tool_call_id is empty but no existing tool call is available to apply the delta")
return existing_tools_calls[-1]
tool_call = next((tool_call for tool_call in existing_tools_calls if tool_call.id == tool_call_id), None)
if tool_call is None:
tool_call = AssistantPromptMessage.ToolCall(
id=tool_call_id,
type="function",
function=AssistantPromptMessage.ToolCall.ToolCallFunction(name="", arguments=""),
)
existing_tools_calls.append(tool_call)
return tool_call
def _merge_tool_call_delta(
tool_call: AssistantPromptMessage.ToolCall,
delta: AssistantPromptMessage.ToolCall,
) -> None:
if delta.id:
tool_call.id = delta.id
if delta.type:
tool_call.type = delta.type
if delta.function.name:
tool_call.function.name = delta.function.name
if delta.function.arguments:
tool_call.function.arguments += delta.function.arguments
def _build_llm_result_from_first_chunk(
model: str,
prompt_messages: Sequence[PromptMessage],
chunks: Iterator[LLMResultChunk],
) -> LLMResult:
"""
Build a single `LLMResult` from the first returned chunk.
This is used for `stream=False` because the plugin side may still implement the response via a chunked stream.
"""
content = ""
content_list: list[PromptMessageContentUnionTypes] = []
usage = LLMUsage.empty_usage()
system_fingerprint: str | None = None
tools_calls: list[AssistantPromptMessage.ToolCall] = []
first_chunk = next(chunks, None)
if first_chunk is not None:
if isinstance(first_chunk.delta.message.content, str):
content += first_chunk.delta.message.content
elif isinstance(first_chunk.delta.message.content, list):
content_list.extend(first_chunk.delta.message.content)
if first_chunk.delta.message.tool_calls:
_increase_tool_call(first_chunk.delta.message.tool_calls, tools_calls)
usage = first_chunk.delta.usage or LLMUsage.empty_usage()
system_fingerprint = first_chunk.system_fingerprint
return LLMResult(
model=model,
prompt_messages=prompt_messages,
message=AssistantPromptMessage(
content=content or content_list,
tool_calls=tools_calls,
),
usage=usage,
system_fingerprint=system_fingerprint,
)
def _invoke_llm_via_plugin(
*,
tenant_id: str,
user_id: str,
plugin_id: str,
provider: str,
model: str,
credentials: dict,
model_parameters: dict,
prompt_messages: Sequence[PromptMessage],
tools: list[PromptMessageTool] | None,
stop: Sequence[str] | None,
stream: bool,
) -> Union[LLMResult, Generator[LLMResultChunk, None, None]]:
from core.plugin.impl.model import PluginModelClient
plugin_model_manager = PluginModelClient()
return plugin_model_manager.invoke_llm(
tenant_id=tenant_id,
user_id=user_id,
plugin_id=plugin_id,
provider=provider,
model=model,
credentials=credentials,
model_parameters=model_parameters,
prompt_messages=list(prompt_messages),
tools=tools,
stop=list(stop) if stop else None,
stream=stream,
)
def _normalize_non_stream_plugin_result(
model: str,
prompt_messages: Sequence[PromptMessage],
result: Union[LLMResult, Iterator[LLMResultChunk]],
) -> LLMResult:
if isinstance(result, LLMResult):
return result
return _build_llm_result_from_first_chunk(model=model, prompt_messages=prompt_messages, chunks=result)
def _increase_tool_call(
new_tool_calls: list[AssistantPromptMessage.ToolCall], existing_tools_calls: list[AssistantPromptMessage.ToolCall]
):
@ -176,13 +40,42 @@ def _increase_tool_call(
:param existing_tools_calls: List of existing tool calls to be modified IN-PLACE.
"""
def get_tool_call(tool_call_id: str):
"""
Get or create a tool call by ID
:param tool_call_id: tool call ID
:return: existing or new tool call
"""
if not tool_call_id:
return existing_tools_calls[-1]
_tool_call = next((_tool_call for _tool_call in existing_tools_calls if _tool_call.id == tool_call_id), None)
if _tool_call is None:
_tool_call = AssistantPromptMessage.ToolCall(
id=tool_call_id,
type="function",
function=AssistantPromptMessage.ToolCall.ToolCallFunction(name="", arguments=""),
)
existing_tools_calls.append(_tool_call)
return _tool_call
for new_tool_call in new_tool_calls:
# generate ID for tool calls with function name but no ID to track them
if new_tool_call.function.name and not new_tool_call.id:
new_tool_call.id = _gen_tool_call_id()
tool_call = _get_or_create_tool_call(existing_tools_calls, new_tool_call.id)
_merge_tool_call_delta(tool_call, new_tool_call)
# get tool call
tool_call = get_tool_call(new_tool_call.id)
# update tool call
if new_tool_call.id:
tool_call.id = new_tool_call.id
if new_tool_call.type:
tool_call.type = new_tool_call.type
if new_tool_call.function.name:
tool_call.function.name = new_tool_call.function.name
if new_tool_call.function.arguments:
tool_call.function.arguments += new_tool_call.function.arguments
class LargeLanguageModel(AIModel):
@ -248,7 +141,10 @@ class LargeLanguageModel(AIModel):
result: Union[LLMResult, Generator[LLMResultChunk, None, None]]
try:
result = _invoke_llm_via_plugin(
from core.plugin.impl.model import PluginModelClient
plugin_model_manager = PluginModelClient()
result = plugin_model_manager.invoke_llm(
tenant_id=self.tenant_id,
user_id=user or "unknown",
plugin_id=self.plugin_id,
@ -258,13 +154,38 @@ class LargeLanguageModel(AIModel):
model_parameters=model_parameters,
prompt_messages=prompt_messages,
tools=tools,
stop=stop,
stop=list(stop) if stop else None,
stream=stream,
)
if not stream:
result = _normalize_non_stream_plugin_result(
model=model, prompt_messages=prompt_messages, result=result
content = ""
content_list = []
usage = LLMUsage.empty_usage()
system_fingerprint = None
tools_calls: list[AssistantPromptMessage.ToolCall] = []
for chunk in result:
if isinstance(chunk.delta.message.content, str):
content += chunk.delta.message.content
elif isinstance(chunk.delta.message.content, list):
content_list.extend(chunk.delta.message.content)
if chunk.delta.message.tool_calls:
_increase_tool_call(chunk.delta.message.tool_calls, tools_calls)
usage = chunk.delta.usage or LLMUsage.empty_usage()
system_fingerprint = chunk.system_fingerprint
break
result = LLMResult(
model=model,
prompt_messages=prompt_messages,
message=AssistantPromptMessage(
content=content or content_list,
tool_calls=tools_calls,
),
usage=usage,
system_fingerprint=system_fingerprint,
)
except Exception as e:
self._trigger_invoke_error_callbacks(
@ -504,21 +425,27 @@ class LargeLanguageModel(AIModel):
:param user: unique user id
:param callbacks: callbacks
"""
_run_callbacks(
callbacks,
event="on_before_invoke",
invoke=lambda callback: callback.on_before_invoke(
llm_instance=self,
model=model,
credentials=credentials,
prompt_messages=prompt_messages,
model_parameters=model_parameters,
tools=tools,
stop=stop,
stream=stream,
user=user,
),
)
if callbacks:
for callback in callbacks:
try:
callback.on_before_invoke(
llm_instance=self,
model=model,
credentials=credentials,
prompt_messages=prompt_messages,
model_parameters=model_parameters,
tools=tools,
stop=stop,
stream=stream,
user=user,
)
except Exception as e:
if callback.raise_error:
raise e
else:
logger.warning(
"Callback %s on_before_invoke failed with error %s", callback.__class__.__name__, e
)
def _trigger_new_chunk_callbacks(
self,
@ -546,22 +473,26 @@ class LargeLanguageModel(AIModel):
:param stream: is stream response
:param user: unique user id
"""
_run_callbacks(
callbacks,
event="on_new_chunk",
invoke=lambda callback: callback.on_new_chunk(
llm_instance=self,
chunk=chunk,
model=model,
credentials=credentials,
prompt_messages=prompt_messages,
model_parameters=model_parameters,
tools=tools,
stop=stop,
stream=stream,
user=user,
),
)
if callbacks:
for callback in callbacks:
try:
callback.on_new_chunk(
llm_instance=self,
chunk=chunk,
model=model,
credentials=credentials,
prompt_messages=prompt_messages,
model_parameters=model_parameters,
tools=tools,
stop=stop,
stream=stream,
user=user,
)
except Exception as e:
if callback.raise_error:
raise e
else:
logger.warning("Callback %s on_new_chunk failed with error %s", callback.__class__.__name__, e)
def _trigger_after_invoke_callbacks(
self,
@ -590,22 +521,28 @@ class LargeLanguageModel(AIModel):
:param user: unique user id
:param callbacks: callbacks
"""
_run_callbacks(
callbacks,
event="on_after_invoke",
invoke=lambda callback: callback.on_after_invoke(
llm_instance=self,
result=result,
model=model,
credentials=credentials,
prompt_messages=prompt_messages,
model_parameters=model_parameters,
tools=tools,
stop=stop,
stream=stream,
user=user,
),
)
if callbacks:
for callback in callbacks:
try:
callback.on_after_invoke(
llm_instance=self,
result=result,
model=model,
credentials=credentials,
prompt_messages=prompt_messages,
model_parameters=model_parameters,
tools=tools,
stop=stop,
stream=stream,
user=user,
)
except Exception as e:
if callback.raise_error:
raise e
else:
logger.warning(
"Callback %s on_after_invoke failed with error %s", callback.__class__.__name__, e
)
def _trigger_invoke_error_callbacks(
self,
@ -634,19 +571,25 @@ class LargeLanguageModel(AIModel):
:param user: unique user id
:param callbacks: callbacks
"""
_run_callbacks(
callbacks,
event="on_invoke_error",
invoke=lambda callback: callback.on_invoke_error(
llm_instance=self,
ex=ex,
model=model,
credentials=credentials,
prompt_messages=prompt_messages,
model_parameters=model_parameters,
tools=tools,
stop=stop,
stream=stream,
user=user,
),
)
if callbacks:
for callback in callbacks:
try:
callback.on_invoke_error(
llm_instance=self,
ex=ex,
model=model,
credentials=credentials,
prompt_messages=prompt_messages,
model_parameters=model_parameters,
tools=tools,
stop=stop,
stream=stream,
user=user,
)
except Exception as e:
if callback.raise_error:
raise e
else:
logger.warning(
"Callback %s on_invoke_error failed with error %s", callback.__class__.__name__, e
)

View File

@ -28,10 +28,8 @@ def init_app(app: DifyApp) -> None:
# Ensure route decorators are evaluated.
import controllers.console.ping as ping_module
from controllers.console import setup
_ = ping_module
_ = setup
router.include_router(console_router, prefix="/console/api")
CORS(

View File

@ -781,16 +781,15 @@ class AppDslService:
return dependencies
@classmethod
def get_leaked_dependencies(
cls, tenant_id: str, dsl_dependencies: list[PluginDependency]
) -> list[PluginDependency]:
def get_leaked_dependencies(cls, tenant_id: str, dsl_dependencies: list[dict]) -> list[PluginDependency]:
"""
Returns the leaked dependencies in current workspace
"""
if not dsl_dependencies:
dependencies = [PluginDependency.model_validate(dep) for dep in dsl_dependencies]
if not dependencies:
return []
return DependenciesAnalysisService.get_leaked_dependencies(tenant_id=tenant_id, dependencies=dsl_dependencies)
return DependenciesAnalysisService.get_leaked_dependencies(tenant_id=tenant_id, dependencies=dependencies)
@staticmethod
def _generate_aes_key(tenant_id: str) -> bytes:

View File

@ -870,16 +870,15 @@ class RagPipelineDslService:
return dependencies
@classmethod
def get_leaked_dependencies(
cls, tenant_id: str, dsl_dependencies: list[PluginDependency]
) -> list[PluginDependency]:
def get_leaked_dependencies(cls, tenant_id: str, dsl_dependencies: list[dict]) -> list[PluginDependency]:
"""
Returns the leaked dependencies in current workspace
"""
if not dsl_dependencies:
dependencies = [PluginDependency.model_validate(dep) for dep in dsl_dependencies]
if not dependencies:
return []
return DependenciesAnalysisService.get_leaked_dependencies(tenant_id=tenant_id, dependencies=dsl_dependencies)
return DependenciesAnalysisService.get_leaked_dependencies(tenant_id=tenant_id, dependencies=dependencies)
def _generate_aes_key(self, tenant_id: str) -> bytes:
"""Generate AES key based on tenant_id"""

View File

@ -44,7 +44,7 @@ class RagPipelineTransformService:
doc_form = dataset.doc_form
if not doc_form:
return self._transform_to_empty_pipeline(dataset)
retrieval_model = RetrievalSetting.model_validate(dataset.retrieval_model) if dataset.retrieval_model else None
retrieval_model = dataset.retrieval_model
pipeline_yaml = self._get_transform_yaml(doc_form, datasource_type, indexing_technique)
# deal dependencies
self._deal_dependencies(pipeline_yaml, dataset.tenant_id)
@ -154,12 +154,7 @@ class RagPipelineTransformService:
return node
def _deal_knowledge_index(
self,
dataset: Dataset,
doc_form: str,
indexing_technique: str | None,
retrieval_model: RetrievalSetting | None,
node: dict,
self, dataset: Dataset, doc_form: str, indexing_technique: str | None, retrieval_model: dict, node: dict
):
knowledge_configuration_dict = node.get("data", {})
knowledge_configuration = KnowledgeConfiguration.model_validate(knowledge_configuration_dict)
@ -168,9 +163,10 @@ class RagPipelineTransformService:
knowledge_configuration.embedding_model = dataset.embedding_model
knowledge_configuration.embedding_model_provider = dataset.embedding_model_provider
if retrieval_model:
retrieval_setting = RetrievalSetting.model_validate(retrieval_model)
if indexing_technique == "economy":
retrieval_model.search_method = RetrievalMethod.KEYWORD_SEARCH
knowledge_configuration.retrieval_model = retrieval_model
retrieval_setting.search_method = RetrievalMethod.KEYWORD_SEARCH
knowledge_configuration.retrieval_model = retrieval_setting
else:
dataset.retrieval_model = knowledge_configuration.retrieval_model.model_dump()

View File

@ -1,56 +0,0 @@
import builtins
from unittest.mock import patch
import pytest
from flask import Flask
from flask.views import MethodView
from extensions import ext_fastopenapi
if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
@pytest.fixture
def app() -> Flask:
app = Flask(__name__)
app.config["TESTING"] = True
return app
def test_console_setup_fastopenapi_get_not_started(app: Flask):
ext_fastopenapi.init_app(app)
with (
patch("controllers.console.setup.dify_config.EDITION", "SELF_HOSTED"),
patch("controllers.console.setup.get_setup_status", return_value=None),
):
client = app.test_client()
response = client.get("/console/api/setup")
assert response.status_code == 200
assert response.get_json() == {"step": "not_started", "setup_at": None}
def test_console_setup_fastopenapi_post_success(app: Flask):
ext_fastopenapi.init_app(app)
payload = {
"email": "admin@example.com",
"name": "Admin",
"password": "Passw0rd1",
"language": "en-US",
}
with (
patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"),
patch("controllers.console.setup.get_setup_status", return_value=None),
patch("controllers.console.setup.TenantService.get_tenant_count", return_value=0),
patch("controllers.console.setup.get_init_validate_status", return_value=True),
patch("controllers.console.setup.RegisterService.setup"),
):
client = app.test_client()
response = client.post("/console/api/setup", json=payload)
assert response.status_code == 201
assert response.get_json() == {"result": "success"}

View File

@ -1,35 +0,0 @@
import builtins
from unittest.mock import patch
import pytest
from flask import Flask
from flask.views import MethodView
from configs import dify_config
from extensions import ext_fastopenapi
if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
@pytest.fixture
def app() -> Flask:
app = Flask(__name__)
app.config["TESTING"] = True
return app
def test_console_version_fastopenapi_returns_current_version(app: Flask):
ext_fastopenapi.init_app(app)
with patch("controllers.console.version.dify_config.CHECK_UPDATE_URL", None):
client = app.test_client()
response = client.get("/console/api/version", query_string={"current_version": "0.0.0"})
assert response.status_code == 200
data = response.get_json()
assert data["version"] == dify_config.project.version
assert data["release_date"] == ""
assert data["release_notes"] == ""
assert data["can_auto_update"] is False
assert "features" in data

View File

@ -0,0 +1,39 @@
from types import SimpleNamespace
from unittest.mock import patch
from controllers.console.setup import SetupApi
class TestSetupApi:
def test_post_lowercases_email_before_register(self):
"""Ensure setup registration normalizes email casing."""
payload = {
"email": "Admin@Example.com",
"name": "Admin User",
"password": "ValidPass123!",
"language": "en-US",
}
setup_api = SetupApi(api=None)
mock_console_ns = SimpleNamespace(payload=payload)
with (
patch("controllers.console.setup.console_ns", mock_console_ns),
patch("controllers.console.setup.get_setup_status", return_value=False),
patch("controllers.console.setup.TenantService.get_tenant_count", return_value=0),
patch("controllers.console.setup.get_init_validate_status", return_value=True),
patch("controllers.console.setup.extract_remote_ip", return_value="127.0.0.1"),
patch("controllers.console.setup.request", object()),
patch("controllers.console.setup.RegisterService.setup") as mock_register,
):
response, status = setup_api.post()
assert response == {"result": "success"}
assert status == 201
mock_register.assert_called_once_with(
email="admin@example.com",
name=payload["name"],
password=payload["password"],
ip_address="127.0.0.1",
language=payload["language"],
)

View File

@ -1,7 +1,5 @@
from unittest.mock import MagicMock, patch
import pytest
from core.model_runtime.entities.message_entities import AssistantPromptMessage
from core.model_runtime.model_providers.__base.large_language_model import _increase_tool_call
@ -99,14 +97,3 @@ def test__increase_tool_call():
mock_id_generator.side_effect = [_exp_case.id for _exp_case in EXPECTED_CASE_4]
with patch("core.model_runtime.model_providers.__base.large_language_model._gen_tool_call_id", mock_id_generator):
_run_case(INPUTS_CASE_4, EXPECTED_CASE_4)
def test__increase_tool_call__no_id_no_name_first_delta_should_raise():
inputs = [
ToolCall(id="", type="function", function=ToolCall.ToolCallFunction(name="", arguments='{"arg1": ')),
ToolCall(id="", type="function", function=ToolCall.ToolCallFunction(name="func_foo", arguments='"value"}')),
]
actual: list[ToolCall] = []
with patch("core.model_runtime.model_providers.__base.large_language_model._gen_tool_call_id", MagicMock()):
with pytest.raises(ValueError):
_increase_tool_call(inputs, actual)

View File

@ -1,103 +0,0 @@
from core.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
from core.model_runtime.entities.message_entities import (
AssistantPromptMessage,
TextPromptMessageContent,
UserPromptMessage,
)
from core.model_runtime.model_providers.__base.large_language_model import _normalize_non_stream_plugin_result
def _make_chunk(
*,
model: str = "test-model",
content: str | list[TextPromptMessageContent] | None,
tool_calls: list[AssistantPromptMessage.ToolCall] | None = None,
usage: LLMUsage | None = None,
system_fingerprint: str | None = None,
) -> LLMResultChunk:
message = AssistantPromptMessage(content=content, tool_calls=tool_calls or [])
delta = LLMResultChunkDelta(index=0, message=message, usage=usage)
return LLMResultChunk(model=model, delta=delta, system_fingerprint=system_fingerprint)
def test__normalize_non_stream_plugin_result__from_first_chunk_str_content_and_tool_calls():
prompt_messages = [UserPromptMessage(content="hi")]
tool_calls = [
AssistantPromptMessage.ToolCall(
id="1",
type="function",
function=AssistantPromptMessage.ToolCall.ToolCallFunction(name="func_foo", arguments=""),
),
AssistantPromptMessage.ToolCall(
id="",
type="function",
function=AssistantPromptMessage.ToolCall.ToolCallFunction(name="", arguments='{"arg1": '),
),
AssistantPromptMessage.ToolCall(
id="",
type="function",
function=AssistantPromptMessage.ToolCall.ToolCallFunction(name="", arguments='"value"}'),
),
]
usage = LLMUsage.empty_usage().model_copy(update={"prompt_tokens": 1, "total_tokens": 1})
chunk = _make_chunk(content="hello", tool_calls=tool_calls, usage=usage, system_fingerprint="fp-1")
result = _normalize_non_stream_plugin_result(
model="test-model", prompt_messages=prompt_messages, result=iter([chunk])
)
assert result.model == "test-model"
assert result.prompt_messages == prompt_messages
assert result.message.content == "hello"
assert result.usage.prompt_tokens == 1
assert result.system_fingerprint == "fp-1"
assert result.message.tool_calls == [
AssistantPromptMessage.ToolCall(
id="1",
type="function",
function=AssistantPromptMessage.ToolCall.ToolCallFunction(name="func_foo", arguments='{"arg1": "value"}'),
)
]
def test__normalize_non_stream_plugin_result__from_first_chunk_list_content():
prompt_messages = [UserPromptMessage(content="hi")]
content_list = [TextPromptMessageContent(data="a"), TextPromptMessageContent(data="b")]
chunk = _make_chunk(content=content_list, usage=LLMUsage.empty_usage())
result = _normalize_non_stream_plugin_result(
model="test-model", prompt_messages=prompt_messages, result=iter([chunk])
)
assert result.message.content == content_list
def test__normalize_non_stream_plugin_result__passthrough_llm_result():
prompt_messages = [UserPromptMessage(content="hi")]
llm_result = LLMResult(
model="test-model",
prompt_messages=prompt_messages,
message=AssistantPromptMessage(content="ok"),
usage=LLMUsage.empty_usage(),
)
assert (
_normalize_non_stream_plugin_result(model="test-model", prompt_messages=prompt_messages, result=llm_result)
== llm_result
)
def test__normalize_non_stream_plugin_result__empty_iterator_defaults():
prompt_messages = [UserPromptMessage(content="hi")]
result = _normalize_non_stream_plugin_result(model="test-model", prompt_messages=prompt_messages, result=iter([]))
assert result.model == "test-model"
assert result.prompt_messages == prompt_messages
assert result.message.content == []
assert result.message.tool_calls == []
assert result.usage == LLMUsage.empty_usage()
assert result.system_fingerprint is None

View File

@ -1,6 +1,3 @@
/**
* @vitest-environment jsdom
*/
import type { ReactNode } from 'react'
import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'

View File

@ -61,12 +61,9 @@ export function usePortalToFollowElem({
}),
shift({ padding: 5 }),
size({
apply({ rects, elements, availableHeight }) {
Object.assign(elements.floating.style, {
maxHeight: `${Math.max(0, availableHeight)}px`,
overflowY: 'auto',
...(triggerPopupSameWidth && { width: `${rects.reference.width}px` }),
})
apply({ rects, elements }) {
if (triggerPopupSameWidth)
elements.floating.style.width = `${rects.reference.width}px`
},
}),
],

View File

@ -1,6 +1,3 @@
/**
* @vitest-environment jsdom
*/
import type { Mock } from 'vitest'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'

View File

@ -1,499 +0,0 @@
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
import type { ChildChunkDetail, ChunkingMode, ParentMode } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import ChildSegmentList from './child-segment-list'
// ============================================================================
// Hoisted Mocks
// ============================================================================
const {
mockParentMode,
mockCurrChildChunk,
} = vi.hoisted(() => ({
mockParentMode: { current: 'paragraph' as ParentMode },
mockCurrChildChunk: { current: { childChunkInfo: undefined, showModal: false } as { childChunkInfo?: ChildChunkDetail, showModal: boolean } },
}))
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { count?: number, ns?: string }) => {
if (key === 'segment.childChunks')
return options?.count === 1 ? 'child chunk' : 'child chunks'
if (key === 'segment.searchResults')
return 'search results'
if (key === 'segment.edited')
return 'edited'
if (key === 'operation.add')
return 'Add'
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
}))
// Mock document context
vi.mock('../context', () => ({
useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => {
const value: DocumentContextValue = {
datasetId: 'test-dataset-id',
documentId: 'test-document-id',
docForm: 'text' as ChunkingMode,
parentMode: mockParentMode.current,
}
return selector(value)
},
}))
// Mock segment list context
vi.mock('./index', () => ({
useSegmentListContext: (selector: (value: { currChildChunk: { childChunkInfo?: ChildChunkDetail, showModal: boolean } }) => unknown) => {
return selector({ currChildChunk: mockCurrChildChunk.current })
},
}))
// Mock skeleton component
vi.mock('./skeleton/full-doc-list-skeleton', () => ({
default: () => <div data-testid="full-doc-list-skeleton">Loading...</div>,
}))
// Mock Empty component
vi.mock('./common/empty', () => ({
default: ({ onClearFilter }: { onClearFilter: () => void }) => (
<div data-testid="empty-component">
<button onClick={onClearFilter}>Clear Filter</button>
</div>
),
}))
// Mock FormattedText and EditSlice
vi.mock('../../../formatted-text/formatted', () => ({
FormattedText: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="formatted-text" className={className}>{children}</div>
),
}))
vi.mock('../../../formatted-text/flavours/edit-slice', () => ({
EditSlice: ({ label, text, onDelete, onClick, labelClassName, contentClassName }: {
label: string
text: string
onDelete: () => void
onClick: (e: React.MouseEvent) => void
labelClassName?: string
contentClassName?: string
}) => (
<div data-testid="edit-slice" onClick={onClick}>
<span data-testid="edit-slice-label" className={labelClassName}>{label}</span>
<span data-testid="edit-slice-content" className={contentClassName}>{text}</span>
<button
data-testid="delete-button"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
Delete
</button>
</div>
),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildChunkDetail => ({
id: `child-${Math.random().toString(36).substr(2, 9)}`,
position: 1,
segment_id: 'segment-1',
content: 'Child chunk content',
word_count: 100,
created_at: 1700000000,
updated_at: 1700000000,
type: 'automatic',
...overrides,
})
// ============================================================================
// Tests
// ============================================================================
describe('ChildSegmentList', () => {
const defaultProps = {
childChunks: [] as ChildChunkDetail[],
parentChunkId: 'parent-1',
enabled: true,
}
beforeEach(() => {
vi.clearAllMocks()
mockParentMode.current = 'paragraph'
mockCurrChildChunk.current = { childChunkInfo: undefined, showModal: false }
})
describe('Rendering', () => {
it('should render with empty child chunks', () => {
render(<ChildSegmentList {...defaultProps} />)
expect(screen.getByText(/child chunks/i)).toBeInTheDocument()
})
it('should render child chunks when provided', () => {
const childChunks = [
createMockChildChunk({ id: 'child-1', position: 1, content: 'First chunk' }),
createMockChildChunk({ id: 'child-2', position: 2, content: 'Second chunk' }),
]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
// In paragraph mode, content is collapsed by default
expect(screen.getByText(/2 child chunks/i)).toBeInTheDocument()
})
it('should render total count correctly with total prop in full-doc mode', () => {
mockParentMode.current = 'full-doc'
const childChunks = [createMockChildChunk()]
// Pass inputValue="" to ensure isSearching is false
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} total={5} isLoading={false} inputValue="" />)
expect(screen.getByText(/5 child chunks/i)).toBeInTheDocument()
})
it('should render loading skeleton in full-doc mode when loading', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
expect(screen.getByTestId('full-doc-list-skeleton')).toBeInTheDocument()
})
it('should not render loading skeleton when not loading', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} isLoading={false} />)
expect(screen.queryByTestId('full-doc-list-skeleton')).not.toBeInTheDocument()
})
})
describe('Paragraph Mode', () => {
beforeEach(() => {
mockParentMode.current = 'paragraph'
})
it('should show collapse icon in paragraph mode', () => {
const childChunks = [createMockChildChunk()]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
// Check for collapse/expand behavior
const totalRow = screen.getByText(/1 child chunk/i).closest('div')
expect(totalRow).toBeInTheDocument()
})
it('should toggle collapsed state when clicked', () => {
const childChunks = [createMockChildChunk({ content: 'Test content' })]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
// Initially collapsed in paragraph mode - content should not be visible
expect(screen.queryByTestId('formatted-text')).not.toBeInTheDocument()
// Find and click the toggle area
const toggleArea = screen.getByText(/1 child chunk/i).closest('div')
// Click to expand
if (toggleArea)
fireEvent.click(toggleArea)
// After expansion, content should be visible
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
})
it('should apply opacity when disabled', () => {
const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} />)
const wrapper = container.firstChild
expect(wrapper).toHaveClass('opacity-50')
})
it('should not apply opacity when enabled', () => {
const { container } = render(<ChildSegmentList {...defaultProps} enabled={true} />)
const wrapper = container.firstChild
expect(wrapper).not.toHaveClass('opacity-50')
})
})
describe('Full-Doc Mode', () => {
beforeEach(() => {
mockParentMode.current = 'full-doc'
})
it('should show content by default in full-doc mode', () => {
const childChunks = [createMockChildChunk({ content: 'Full doc content' })]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} isLoading={false} />)
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
})
it('should render search input in full-doc mode', () => {
render(<ChildSegmentList {...defaultProps} inputValue="" handleInputChange={vi.fn()} />)
const input = document.querySelector('input')
expect(input).toBeInTheDocument()
})
it('should call handleInputChange when input changes', () => {
const handleInputChange = vi.fn()
render(<ChildSegmentList {...defaultProps} inputValue="" handleInputChange={handleInputChange} />)
const input = document.querySelector('input')
if (input) {
fireEvent.change(input, { target: { value: 'test search' } })
expect(handleInputChange).toHaveBeenCalledWith('test search')
}
})
it('should show search results text when searching', () => {
render(<ChildSegmentList {...defaultProps} inputValue="search term" total={3} />)
expect(screen.getByText(/3 search results/i)).toBeInTheDocument()
})
it('should show empty component when no results and searching', () => {
render(
<ChildSegmentList
{...defaultProps}
childChunks={[]}
inputValue="search term"
onClearFilter={vi.fn()}
isLoading={false}
/>,
)
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
})
it('should call onClearFilter when clear button clicked in empty state', () => {
const onClearFilter = vi.fn()
render(
<ChildSegmentList
{...defaultProps}
childChunks={[]}
inputValue="search term"
onClearFilter={onClearFilter}
isLoading={false}
/>,
)
const clearButton = screen.getByText('Clear Filter')
fireEvent.click(clearButton)
expect(onClearFilter).toHaveBeenCalled()
})
})
describe('Child Chunk Items', () => {
it('should render edited label when chunk is edited', () => {
mockParentMode.current = 'full-doc'
const editedChunk = createMockChildChunk({
id: 'edited-chunk',
position: 1,
created_at: 1700000000,
updated_at: 1700000001, // Different from created_at
})
render(<ChildSegmentList {...defaultProps} childChunks={[editedChunk]} isLoading={false} />)
expect(screen.getByText(/C-1 · edited/i)).toBeInTheDocument()
})
it('should not show edited label when chunk is not edited', () => {
mockParentMode.current = 'full-doc'
const normalChunk = createMockChildChunk({
id: 'normal-chunk',
position: 2,
created_at: 1700000000,
updated_at: 1700000000, // Same as created_at
})
render(<ChildSegmentList {...defaultProps} childChunks={[normalChunk]} isLoading={false} />)
expect(screen.getByText('C-2')).toBeInTheDocument()
expect(screen.queryByText(/edited/i)).not.toBeInTheDocument()
})
it('should call onClickSlice when chunk is clicked', () => {
mockParentMode.current = 'full-doc'
const onClickSlice = vi.fn()
const chunk = createMockChildChunk({ id: 'clickable-chunk' })
render(
<ChildSegmentList
{...defaultProps}
childChunks={[chunk]}
onClickSlice={onClickSlice}
isLoading={false}
/>,
)
const editSlice = screen.getByTestId('edit-slice')
fireEvent.click(editSlice)
expect(onClickSlice).toHaveBeenCalledWith(chunk)
})
it('should call onDelete when delete button is clicked', () => {
mockParentMode.current = 'full-doc'
const onDelete = vi.fn()
const chunk = createMockChildChunk({ id: 'deletable-chunk', segment_id: 'seg-1' })
render(
<ChildSegmentList
{...defaultProps}
childChunks={[chunk]}
onDelete={onDelete}
isLoading={false}
/>,
)
const deleteButton = screen.getByTestId('delete-button')
fireEvent.click(deleteButton)
expect(onDelete).toHaveBeenCalledWith('seg-1', 'deletable-chunk')
})
it('should apply focused styles when chunk is currently selected', () => {
mockParentMode.current = 'full-doc'
const chunk = createMockChildChunk({ id: 'focused-chunk' })
mockCurrChildChunk.current = { childChunkInfo: chunk, showModal: true }
render(<ChildSegmentList {...defaultProps} childChunks={[chunk]} isLoading={false} />)
const label = screen.getByTestId('edit-slice-label')
expect(label).toHaveClass('bg-state-accent-solid')
})
})
describe('Add Button', () => {
it('should call handleAddNewChildChunk when Add button is clicked', () => {
const handleAddNewChildChunk = vi.fn()
render(
<ChildSegmentList
{...defaultProps}
handleAddNewChildChunk={handleAddNewChildChunk}
parentChunkId="parent-123"
/>,
)
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
expect(handleAddNewChildChunk).toHaveBeenCalledWith('parent-123')
})
it('should disable Add button when loading in full-doc mode', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
const addButton = screen.getByText('Add')
expect(addButton).toBeDisabled()
})
it('should stop propagation when Add button is clicked', () => {
const handleAddNewChildChunk = vi.fn()
const parentClickHandler = vi.fn()
render(
<div onClick={parentClickHandler}>
<ChildSegmentList
{...defaultProps}
handleAddNewChildChunk={handleAddNewChildChunk}
/>
</div>,
)
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
expect(handleAddNewChildChunk).toHaveBeenCalled()
// Parent should not be called due to stopPropagation
})
})
describe('computeTotalInfo function', () => {
it('should return search results when searching in full-doc mode', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} inputValue="search" total={10} />)
expect(screen.getByText(/10 search results/i)).toBeInTheDocument()
})
it('should return "--" when total is 0 in full-doc mode', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} total={0} />)
// When total is 0, displayText is '--'
expect(screen.getByText(/--/)).toBeInTheDocument()
})
it('should use childChunks length in paragraph mode', () => {
mockParentMode.current = 'paragraph'
const childChunks = [
createMockChildChunk(),
createMockChildChunk(),
createMockChildChunk(),
]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
expect(screen.getByText(/3 child chunks/i)).toBeInTheDocument()
})
})
describe('Focused State', () => {
it('should not apply opacity when focused even if disabled', () => {
const { container } = render(
<ChildSegmentList {...defaultProps} enabled={false} focused={true} />,
)
const wrapper = container.firstChild
expect(wrapper).not.toHaveClass('opacity-50')
})
})
describe('Input clear button', () => {
it('should call handleInputChange with empty string when clear is clicked', () => {
mockParentMode.current = 'full-doc'
const handleInputChange = vi.fn()
render(
<ChildSegmentList
{...defaultProps}
inputValue="test"
handleInputChange={handleInputChange}
/>,
)
// Find the clear button (it's the showClearIcon button in Input)
const input = document.querySelector('input')
if (input) {
// Trigger clear by simulating the input's onClear
const clearButton = document.querySelector('[class*="cursor-pointer"]')
if (clearButton)
fireEvent.click(clearButton)
}
})
})
})

View File

@ -1,7 +1,7 @@
import type { FC } from 'react'
import type { ChildChunkDetail } from '@/models/datasets'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
@ -29,37 +29,6 @@ type IChildSegmentCardProps = {
focused?: boolean
}
function computeTotalInfo(
isFullDocMode: boolean,
isSearching: boolean,
total: number | undefined,
childChunksLength: number,
): { displayText: string, count: number, translationKey: 'segment.searchResults' | 'segment.childChunks' } {
if (isSearching) {
const count = total ?? 0
return {
displayText: count === 0 ? '--' : String(formatNumber(count)),
count,
translationKey: 'segment.searchResults',
}
}
if (isFullDocMode) {
const count = total ?? 0
return {
displayText: count === 0 ? '--' : String(formatNumber(count)),
count,
translationKey: 'segment.childChunks',
}
}
return {
displayText: String(formatNumber(childChunksLength)),
count: childChunksLength,
translationKey: 'segment.childChunks',
}
}
const ChildSegmentList: FC<IChildSegmentCardProps> = ({
childChunks,
parentChunkId,
@ -80,87 +49,59 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({
const [collapsed, setCollapsed] = useState(true)
const isParagraphMode = parentMode === 'paragraph'
const isFullDocMode = parentMode === 'full-doc'
const isSearching = inputValue !== '' && isFullDocMode
const contentOpacity = (enabled || focused) ? '' : 'opacity-50 group-hover/card:opacity-100'
const { displayText, count, translationKey } = computeTotalInfo(isFullDocMode, isSearching, total, childChunks.length)
const totalText = `${displayText} ${t(translationKey, { ns: 'datasetDocuments', count })}`
const toggleCollapse = () => setCollapsed(prev => !prev)
const showContent = (isFullDocMode && !isLoading) || !collapsed
const hoverVisibleClass = isParagraphMode ? 'hidden group-hover/card:inline-block' : ''
const renderCollapseIcon = () => {
if (!isParagraphMode)
return null
const Icon = collapsed ? RiArrowRightSLine : RiArrowDownSLine
return <Icon className={cn('mr-0.5 h-4 w-4 text-text-secondary', collapsed && 'opacity-50')} />
const toggleCollapse = () => {
setCollapsed(!collapsed)
}
const renderChildChunkItem = (childChunk: ChildChunkDetail) => {
const isEdited = childChunk.updated_at !== childChunk.created_at
const isFocused = currChildChunk?.childChunkInfo?.id === childChunk.id
const label = isEdited
? `C-${childChunk.position} · ${t('segment.edited', { ns: 'datasetDocuments' })}`
: `C-${childChunk.position}`
const isParagraphMode = useMemo(() => {
return parentMode === 'paragraph'
}, [parentMode])
return (
<EditSlice
key={childChunk.id}
label={label}
text={childChunk.content}
onDelete={() => onDelete?.(childChunk.segment_id, childChunk.id)}
className="child-chunk"
labelClassName={isFocused ? 'bg-state-accent-solid text-text-primary-on-surface' : ''}
labelInnerClassName="text-[10px] font-semibold align-bottom leading-6"
contentClassName={cn('!leading-6', isFocused ? 'bg-state-accent-hover-alt text-text-primary' : 'text-text-secondary')}
showDivider={false}
onClick={(e) => {
e.stopPropagation()
onClickSlice?.(childChunk)
}}
offsetOptions={({ rects }) => ({
mainAxis: isFullDocMode ? -rects.floating.width : 12 - rects.floating.width,
crossAxis: (20 - rects.floating.height) / 2,
})}
/>
)
}
const isFullDocMode = useMemo(() => {
return parentMode === 'full-doc'
}, [parentMode])
const renderContent = () => {
if (childChunks.length > 0) {
return (
<FormattedText className={cn('flex w-full flex-col !leading-6', isParagraphMode ? 'gap-y-2' : 'gap-y-3')}>
{childChunks.map(renderChildChunkItem)}
</FormattedText>
)
const contentOpacity = useMemo(() => {
return (enabled || focused) ? '' : 'opacity-50 group-hover/card:opacity-100'
}, [enabled, focused])
const totalText = useMemo(() => {
const isSearch = inputValue !== '' && isFullDocMode
if (!isSearch) {
const text = isFullDocMode
? !total
? '--'
: formatNumber(total)
: formatNumber(childChunks.length)
const count = isFullDocMode
? text === '--'
? 0
: total
: childChunks.length
return `${text} ${t('segment.childChunks', { ns: 'datasetDocuments', count })}`
}
if (inputValue !== '') {
return (
<div className="h-full w-full">
<Empty onClearFilter={onClearFilter!} />
</div>
)
else {
const text = !total ? '--' : formatNumber(total)
const count = text === '--' ? 0 : total
return `${count} ${t('segment.searchResults', { ns: 'datasetDocuments', count })}`
}
return null
}
}, [isFullDocMode, total, childChunks.length, inputValue])
return (
<div className={cn(
'flex flex-col',
contentOpacity,
isParagraphMode ? 'pb-2 pt-1' : 'grow px-3',
isFullDocMode && isLoading && 'overflow-y-hidden',
(isFullDocMode && isLoading) && 'overflow-y-hidden',
)}
>
{isFullDocMode && <Divider type="horizontal" className="my-1 h-px bg-divider-subtle" />}
<div className={cn('flex items-center justify-between', isFullDocMode && 'sticky -top-2 left-0 bg-background-default pb-3 pt-2')}>
{isFullDocMode ? <Divider type="horizontal" className="my-1 h-px bg-divider-subtle" /> : null}
<div className={cn('flex items-center justify-between', isFullDocMode ? 'sticky -top-2 left-0 bg-background-default pb-3 pt-2' : '')}>
<div
className={cn(
'flex h-7 items-center rounded-lg pl-1 pr-3',
isParagraphMode && 'cursor-pointer',
isParagraphMode && collapsed && 'bg-dataset-child-chunk-expand-btn-bg',
(isParagraphMode && collapsed) && 'bg-dataset-child-chunk-expand-btn-bg',
isFullDocMode && 'pl-0',
)}
onClick={(event) => {
@ -168,15 +109,23 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({
toggleCollapse()
}}
>
{renderCollapseIcon()}
{
isParagraphMode
? collapsed
? (
<RiArrowRightSLine className="mr-0.5 h-4 w-4 text-text-secondary opacity-50" />
)
: (<RiArrowDownSLine className="mr-0.5 h-4 w-4 text-text-secondary" />)
: null
}
<span className="system-sm-semibold-uppercase text-text-secondary">{totalText}</span>
<span className={cn('pl-1.5 text-xs font-medium text-text-quaternary', hoverVisibleClass)}>·</span>
<span className={cn('pl-1.5 text-xs font-medium text-text-quaternary', isParagraphMode ? 'hidden group-hover/card:inline-block' : '')}>·</span>
<button
type="button"
className={cn(
'system-xs-semibold-uppercase px-1.5 py-1 text-components-button-secondary-accent-text',
hoverVisibleClass,
isFullDocMode && isLoading && 'text-components-button-secondary-accent-text-disabled',
isParagraphMode ? 'hidden group-hover/card:inline-block' : '',
(isFullDocMode && isLoading) ? 'text-components-button-secondary-accent-text-disabled' : '',
)}
onClick={(event) => {
event.stopPropagation()
@ -187,28 +136,70 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({
{t('operation.add', { ns: 'common' })}
</button>
</div>
{isFullDocMode && (
<Input
showLeftIcon
showClearIcon
wrapperClassName="!w-52"
value={inputValue}
onChange={e => handleInputChange?.(e.target.value)}
onClear={() => handleInputChange?.('')}
/>
)}
{isFullDocMode
? (
<Input
showLeftIcon
showClearIcon
wrapperClassName="!w-52"
value={inputValue}
onChange={e => handleInputChange?.(e.target.value)}
onClear={() => handleInputChange?.('')}
/>
)
: null}
</div>
{isLoading && <FullDocListSkeleton />}
{showContent && (
<div className={cn('flex gap-x-0.5', isFullDocMode ? 'mb-6 grow' : 'items-center')}>
{isParagraphMode && (
<div className="self-stretch">
<Divider type="vertical" className="mx-[7px] w-[2px] bg-text-accent-secondary" />
{isLoading ? <FullDocListSkeleton /> : null}
{((isFullDocMode && !isLoading) || !collapsed)
? (
<div className={cn('flex gap-x-0.5', isFullDocMode ? 'mb-6 grow' : 'items-center')}>
{isParagraphMode && (
<div className="self-stretch">
<Divider type="vertical" className="mx-[7px] w-[2px] bg-text-accent-secondary" />
</div>
)}
{childChunks.length > 0
? (
<FormattedText className={cn('flex w-full flex-col !leading-6', isParagraphMode ? 'gap-y-2' : 'gap-y-3')}>
{childChunks.map((childChunk) => {
const edited = childChunk.updated_at !== childChunk.created_at
const focused = currChildChunk?.childChunkInfo?.id === childChunk.id
return (
<EditSlice
key={childChunk.id}
label={`C-${childChunk.position}${edited ? ` · ${t('segment.edited', { ns: 'datasetDocuments' })}` : ''}`}
text={childChunk.content}
onDelete={() => onDelete?.(childChunk.segment_id, childChunk.id)}
className="child-chunk"
labelClassName={focused ? 'bg-state-accent-solid text-text-primary-on-surface' : ''}
labelInnerClassName="text-[10px] font-semibold align-bottom leading-6"
contentClassName={cn('!leading-6', focused ? 'bg-state-accent-hover-alt text-text-primary' : 'text-text-secondary')}
showDivider={false}
onClick={(e) => {
e.stopPropagation()
onClickSlice?.(childChunk)
}}
offsetOptions={({ rects }) => {
return {
mainAxis: isFullDocMode ? -rects.floating.width : 12 - rects.floating.width,
crossAxis: (20 - rects.floating.height) / 2,
}
}}
/>
)
})}
</FormattedText>
)
: inputValue !== ''
? (
<div className="h-full w-full">
<Empty onClearFilter={onClearFilter!} />
</div>
)
: null}
</div>
)}
{renderContent()}
</div>
)}
)
: null}
</div>
)
}

View File

@ -17,31 +17,6 @@ type DrawerProps = {
needCheckChunks?: boolean
}
const SIDE_POSITION_CLASS = {
right: 'right-0',
left: 'left-0',
bottom: 'bottom-0',
top: 'top-0',
} as const
function containsTarget(selector: string, target: Node | null): boolean {
const elements = document.querySelectorAll(selector)
return Array.from(elements).some(el => el?.contains(target))
}
function shouldReopenChunkDetail(
isClickOnChunk: boolean,
isClickOnChildChunk: boolean,
segmentModalOpen: boolean,
childChunkModalOpen: boolean,
): boolean {
if (segmentModalOpen && isClickOnChildChunk)
return true
if (childChunkModalOpen && isClickOnChunk && !isClickOnChildChunk)
return true
return !isClickOnChunk && !isClickOnChildChunk
}
const Drawer = ({
open,
onClose,
@ -66,22 +41,22 @@ const Drawer = ({
const shouldCloseDrawer = useCallback((target: Node | null) => {
const panelContent = panelContentRef.current
if (!panelContent || !target)
if (!panelContent)
return false
if (panelContent.contains(target))
return false
if (containsTarget('.image-previewer', target))
return false
if (!needCheckChunks)
return true
const isClickOnChunk = containsTarget('.chunk-card', target)
const isClickOnChildChunk = containsTarget('.child-chunk', target)
return shouldReopenChunkDetail(isClickOnChunk, isClickOnChildChunk, currSegment.showModal, currChildChunk.showModal)
}, [currSegment.showModal, currChildChunk.showModal, needCheckChunks])
const chunks = document.querySelectorAll('.chunk-card')
const childChunks = document.querySelectorAll('.child-chunk')
const imagePreviewer = document.querySelector('.image-previewer')
const isClickOnChunk = Array.from(chunks).some((chunk) => {
return chunk && chunk.contains(target)
})
const isClickOnChildChunk = Array.from(childChunks).some((chunk) => {
return chunk && chunk.contains(target)
})
const reopenChunkDetail = (currSegment.showModal && isClickOnChildChunk)
|| (currChildChunk.showModal && isClickOnChunk && !isClickOnChildChunk) || (!isClickOnChunk && !isClickOnChildChunk)
const isClickOnImagePreviewer = imagePreviewer && imagePreviewer.contains(target)
return target && !panelContent.contains(target) && (!needCheckChunks || reopenChunkDetail) && !isClickOnImagePreviewer
}, [currSegment, currChildChunk, needCheckChunks])
const onDownCapture = useCallback((e: PointerEvent) => {
if (!open || modal)
@ -102,27 +77,32 @@ const Drawer = ({
const isHorizontal = side === 'left' || side === 'right'
const overlayPointerEvents = modal && open ? 'pointer-events-auto' : 'pointer-events-none'
const content = (
<div className="pointer-events-none fixed inset-0 z-[9999]">
{showOverlay && (
<div
onClick={modal ? onClose : undefined}
aria-hidden="true"
className={cn(
'fixed inset-0 bg-black/30 opacity-0 transition-opacity duration-200 ease-in',
open && 'opacity-100',
overlayPointerEvents,
)}
/>
)}
{showOverlay
? (
<div
onClick={modal ? onClose : undefined}
aria-hidden="true"
className={cn(
'fixed inset-0 bg-black/30 opacity-0 transition-opacity duration-200 ease-in',
open && 'opacity-100',
modal && open ? 'pointer-events-auto' : 'pointer-events-none',
)}
/>
)
: null}
{/* Drawer panel */}
<div
role="dialog"
aria-modal={modal ? 'true' : 'false'}
className={cn(
'pointer-events-auto fixed flex flex-col',
SIDE_POSITION_CLASS[side],
side === 'right' && 'right-0',
side === 'left' && 'left-0',
side === 'bottom' && 'bottom-0',
side === 'top' && 'top-0',
isHorizontal ? 'h-screen' : 'w-screen',
panelClassName,
)}
@ -134,10 +114,7 @@ const Drawer = ({
</div>
)
if (!open)
return null
return createPortal(content, document.body)
return open && createPortal(content, document.body)
}
export default Drawer

View File

@ -1,129 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Empty from './empty'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
if (key === 'segment.empty')
return 'No results found'
if (key === 'segment.clearFilter')
return 'Clear Filter'
return key
},
}),
}))
describe('Empty Component', () => {
const defaultProps = {
onClearFilter: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render empty state message', () => {
render(<Empty {...defaultProps} />)
expect(screen.getByText('No results found')).toBeInTheDocument()
})
it('should render clear filter button', () => {
render(<Empty {...defaultProps} />)
expect(screen.getByText('Clear Filter')).toBeInTheDocument()
})
it('should render icon', () => {
const { container } = render(<Empty {...defaultProps} />)
// Check for the icon container
const iconContainer = container.querySelector('.shadow-lg')
expect(iconContainer).toBeInTheDocument()
})
it('should render decorative lines', () => {
const { container } = render(<Empty {...defaultProps} />)
// Check for SVG lines
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
})
it('should render background cards', () => {
const { container } = render(<Empty {...defaultProps} />)
// Check for background empty cards (10 of them)
const backgroundCards = container.querySelectorAll('.rounded-xl.bg-background-section-burn')
expect(backgroundCards.length).toBe(10)
})
it('should render mask overlay', () => {
const { container } = render(<Empty {...defaultProps} />)
const maskOverlay = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(maskOverlay).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should call onClearFilter when clear filter button is clicked', () => {
const onClearFilter = vi.fn()
render(<Empty onClearFilter={onClearFilter} />)
const clearButton = screen.getByText('Clear Filter')
fireEvent.click(clearButton)
expect(onClearFilter).toHaveBeenCalledTimes(1)
})
})
describe('Memoization', () => {
it('should be memoized', () => {
// Empty is wrapped with React.memo
const { rerender } = render(<Empty {...defaultProps} />)
// Same props should not cause re-render issues
rerender(<Empty {...defaultProps} />)
expect(screen.getByText('No results found')).toBeInTheDocument()
})
})
})
describe('EmptyCard Component', () => {
it('should render within Empty component', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// EmptyCard renders as background cards
const emptyCards = container.querySelectorAll('.h-32.w-full')
expect(emptyCards.length).toBe(10)
})
it('should have correct opacity', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
const emptyCards = container.querySelectorAll('.opacity-30')
expect(emptyCards.length).toBe(10)
})
})
describe('Line Component', () => {
it('should render SVG lines within Empty component', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// Line components render as SVG elements (4 Line components + 1 icon SVG)
const lines = container.querySelectorAll('svg')
expect(lines.length).toBeGreaterThanOrEqual(4)
})
it('should have gradient definition', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
const gradients = container.querySelectorAll('linearGradient')
expect(gradients.length).toBeGreaterThan(0)
})
})

View File

@ -1,151 +0,0 @@
'use client'
import type { FC } from 'react'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { ChildChunkDetail, ChunkingMode, SegmentDetailModel } from '@/models/datasets'
import NewSegment from '@/app/components/datasets/documents/detail/new-segment'
import ChildSegmentDetail from '../child-segment-detail'
import FullScreenDrawer from '../common/full-screen-drawer'
import NewChildSegment from '../new-child-segment'
import SegmentDetail from '../segment-detail'
type DrawerGroupProps = {
// Segment detail drawer
currSegment: {
segInfo?: SegmentDetailModel
showModal: boolean
isEditMode?: boolean
}
onCloseSegmentDetail: () => void
onUpdateSegment: (
segmentId: string,
question: string,
answer: string,
keywords: string[],
attachments: FileEntity[],
needRegenerate?: boolean,
) => Promise<void>
isRegenerationModalOpen: boolean
setIsRegenerationModalOpen: (open: boolean) => void
// New segment drawer
showNewSegmentModal: boolean
onCloseNewSegmentModal: () => void
onSaveNewSegment: () => void
viewNewlyAddedChunk: () => void
// Child segment detail drawer
currChildChunk: {
childChunkInfo?: ChildChunkDetail
showModal: boolean
}
currChunkId: string
onCloseChildSegmentDetail: () => void
onUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise<void>
// New child segment drawer
showNewChildSegmentModal: boolean
onCloseNewChildChunkModal: () => void
onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void
viewNewlyAddedChildChunk: () => void
// Common props
fullScreen: boolean
docForm: ChunkingMode
}
const DrawerGroup: FC<DrawerGroupProps> = ({
// Segment detail drawer
currSegment,
onCloseSegmentDetail,
onUpdateSegment,
isRegenerationModalOpen,
setIsRegenerationModalOpen,
// New segment drawer
showNewSegmentModal,
onCloseNewSegmentModal,
onSaveNewSegment,
viewNewlyAddedChunk,
// Child segment detail drawer
currChildChunk,
currChunkId,
onCloseChildSegmentDetail,
onUpdateChildChunk,
// New child segment drawer
showNewChildSegmentModal,
onCloseNewChildChunkModal,
onSaveNewChildChunk,
viewNewlyAddedChildChunk,
// Common props
fullScreen,
docForm,
}) => {
return (
<>
{/* Edit or view segment detail */}
<FullScreenDrawer
isOpen={currSegment.showModal}
fullScreen={fullScreen}
onClose={onCloseSegmentDetail}
showOverlay={false}
needCheckChunks
modal={isRegenerationModalOpen}
>
<SegmentDetail
key={currSegment.segInfo?.id}
segInfo={currSegment.segInfo ?? { id: '' }}
docForm={docForm}
isEditMode={currSegment.isEditMode}
onUpdate={onUpdateSegment}
onCancel={onCloseSegmentDetail}
onModalStateChange={setIsRegenerationModalOpen}
/>
</FullScreenDrawer>
{/* Create New Segment */}
<FullScreenDrawer
isOpen={showNewSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewSegmentModal}
modal
>
<NewSegment
docForm={docForm}
onCancel={onCloseNewSegmentModal}
onSave={onSaveNewSegment}
viewNewlyAddedChunk={viewNewlyAddedChunk}
/>
</FullScreenDrawer>
{/* Edit or view child segment detail */}
<FullScreenDrawer
isOpen={currChildChunk.showModal}
fullScreen={fullScreen}
onClose={onCloseChildSegmentDetail}
showOverlay={false}
needCheckChunks
>
<ChildSegmentDetail
key={currChildChunk.childChunkInfo?.id}
chunkId={currChunkId}
childChunkInfo={currChildChunk.childChunkInfo ?? { id: '' }}
docForm={docForm}
onUpdate={onUpdateChildChunk}
onCancel={onCloseChildSegmentDetail}
/>
</FullScreenDrawer>
{/* Create New Child Segment */}
<FullScreenDrawer
isOpen={showNewChildSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewChildChunkModal}
modal
>
<NewChildSegment
chunkId={currChunkId}
onCancel={onCloseNewChildChunkModal}
onSave={onSaveNewChildChunk}
viewNewlyAddedChildChunk={viewNewlyAddedChildChunk}
/>
</FullScreenDrawer>
</>
)
}
export default DrawerGroup

View File

@ -1,3 +0,0 @@
export { default as DrawerGroup } from './drawer-group'
export { default as MenuBar } from './menu-bar'
export { FullDocModeContent, GeneralModeContent } from './segment-list-content'

View File

@ -1,76 +0,0 @@
'use client'
import type { FC } from 'react'
import type { Item } from '@/app/components/base/select'
import Checkbox from '@/app/components/base/checkbox'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
import DisplayToggle from '../display-toggle'
import StatusItem from '../status-item'
import s from '../style.module.css'
type MenuBarProps = {
isAllSelected: boolean
isSomeSelected: boolean
onSelectedAll: () => void
isLoading: boolean
totalText: string
statusList: Item[]
selectDefaultValue: 'all' | 0 | 1
onChangeStatus: (item: Item) => void
inputValue: string
onInputChange: (value: string) => void
isCollapsed: boolean
toggleCollapsed: () => void
}
const MenuBar: FC<MenuBarProps> = ({
isAllSelected,
isSomeSelected,
onSelectedAll,
isLoading,
totalText,
statusList,
selectDefaultValue,
onChangeStatus,
inputValue,
onInputChange,
isCollapsed,
toggleCollapsed,
}) => {
return (
<div className={s.docSearchWrapper}>
<Checkbox
className="shrink-0"
checked={isAllSelected}
indeterminate={!isAllSelected && isSomeSelected}
onCheck={onSelectedAll}
disabled={isLoading}
/>
<div className="system-sm-semibold-uppercase flex-1 pl-5 text-text-secondary">{totalText}</div>
<SimpleSelect
onSelect={onChangeStatus}
items={statusList}
defaultValue={selectDefaultValue}
className={s.select}
wrapperClassName="h-fit mr-2"
optionWrapClassName="w-[160px]"
optionClassName="p-0"
renderOption={({ item, selected }) => <StatusItem item={item} selected={selected} />}
notClearable
/>
<Input
showLeftIcon
showClearIcon
wrapperClassName="!w-52"
value={inputValue}
onChange={e => onInputChange(e.target.value)}
onClear={() => onInputChange('')}
/>
<Divider type="vertical" className="mx-3 h-3.5" />
<DisplayToggle isCollapsed={isCollapsed} toggleCollapsed={toggleCollapsed} />
</div>
)
}
export default MenuBar

View File

@ -1,127 +0,0 @@
'use client'
import type { FC } from 'react'
import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
import { cn } from '@/utils/classnames'
import ChildSegmentList from '../child-segment-list'
import SegmentCard from '../segment-card'
import SegmentList from '../segment-list'
type FullDocModeContentProps = {
segments: SegmentDetailModel[]
childSegments: ChildChunkDetail[]
isLoadingSegmentList: boolean
isLoadingChildSegmentList: boolean
currSegmentId?: string
onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void
onDeleteChildChunk: (segmentId: string, childChunkId: string) => Promise<void>
handleInputChange: (value: string) => void
handleAddNewChildChunk: (parentChunkId: string) => void
onClickSlice: (detail: ChildChunkDetail) => void
archived?: boolean
childChunkTotal: number
inputValue: string
onClearFilter: () => void
}
export const FullDocModeContent: FC<FullDocModeContentProps> = ({
segments,
childSegments,
isLoadingSegmentList,
isLoadingChildSegmentList,
currSegmentId,
onClickCard,
onDeleteChildChunk,
handleInputChange,
handleAddNewChildChunk,
onClickSlice,
archived,
childChunkTotal,
inputValue,
onClearFilter,
}) => {
const firstSegment = segments[0]
return (
<div className={cn(
'flex grow flex-col overflow-x-hidden',
(isLoadingSegmentList || isLoadingChildSegmentList) ? 'overflow-y-hidden' : 'overflow-y-auto',
)}
>
<SegmentCard
detail={firstSegment}
onClick={() => onClickCard(firstSegment)}
loading={isLoadingSegmentList}
focused={{
segmentIndex: currSegmentId === firstSegment?.id,
segmentContent: currSegmentId === firstSegment?.id,
}}
/>
<ChildSegmentList
parentChunkId={firstSegment?.id}
onDelete={onDeleteChildChunk}
childChunks={childSegments}
handleInputChange={handleInputChange}
handleAddNewChildChunk={handleAddNewChildChunk}
onClickSlice={onClickSlice}
enabled={!archived}
total={childChunkTotal}
inputValue={inputValue}
onClearFilter={onClearFilter}
isLoading={isLoadingSegmentList || isLoadingChildSegmentList}
/>
</div>
)
}
type GeneralModeContentProps = {
segmentListRef: React.RefObject<HTMLDivElement | null>
embeddingAvailable: boolean
isLoadingSegmentList: boolean
segments: SegmentDetailModel[]
selectedSegmentIds: string[]
onSelected: (segId: string) => void
onChangeSwitch: (enable: boolean, segId?: string) => Promise<void>
onDelete: (segId?: string) => Promise<void>
onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void
archived?: boolean
onDeleteChildChunk: (segmentId: string, childChunkId: string) => Promise<void>
handleAddNewChildChunk: (parentChunkId: string) => void
onClickSlice: (detail: ChildChunkDetail) => void
onClearFilter: () => void
}
export const GeneralModeContent: FC<GeneralModeContentProps> = ({
segmentListRef,
embeddingAvailable,
isLoadingSegmentList,
segments,
selectedSegmentIds,
onSelected,
onChangeSwitch,
onDelete,
onClickCard,
archived,
onDeleteChildChunk,
handleAddNewChildChunk,
onClickSlice,
onClearFilter,
}) => {
return (
<SegmentList
ref={segmentListRef}
embeddingAvailable={embeddingAvailable}
isLoading={isLoadingSegmentList}
items={segments}
selectedSegmentIds={selectedSegmentIds}
onSelected={onSelected}
onChangeSwitch={onChangeSwitch}
onDelete={onDelete}
onClick={onClickCard}
archived={archived}
onDeleteChildChunk={onDeleteChildChunk}
handleAddNewChildChunk={handleAddNewChildChunk}
onClickSlice={onClickSlice}
onClearFilter={onClearFilter}
/>
)
}

View File

@ -1,14 +0,0 @@
export { useChildSegmentData } from './use-child-segment-data'
export type { UseChildSegmentDataReturn } from './use-child-segment-data'
export { useModalState } from './use-modal-state'
export type { CurrChildChunkType, CurrSegmentType, UseModalStateReturn } from './use-modal-state'
export { useSearchFilter } from './use-search-filter'
export type { UseSearchFilterReturn } from './use-search-filter'
export { useSegmentListData } from './use-segment-list-data'
export type { UseSegmentListDataReturn } from './use-segment-list-data'
export { useSegmentSelection } from './use-segment-selection'
export type { UseSegmentSelectionReturn } from './use-segment-selection'

View File

@ -1,568 +0,0 @@
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
import type { ChildChunkDetail, ChildSegmentsResponse, ChunkingMode, ParentMode, SegmentDetailModel } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { useChildSegmentData } from './use-child-segment-data'
// Type for mutation callbacks
type MutationResponse = { data: ChildChunkDetail }
type MutationCallbacks = {
onSuccess: (res: MutationResponse) => void
onSettled: () => void
}
type _ErrorCallback = { onSuccess?: () => void, onError: () => void }
// ============================================================================
// Hoisted Mocks
// ============================================================================
const {
mockParentMode,
mockDatasetId,
mockDocumentId,
mockNotify,
mockEventEmitter,
mockQueryClient,
mockChildSegmentListData,
mockDeleteChildSegment,
mockUpdateChildSegment,
mockInvalidChildSegmentList,
} = vi.hoisted(() => ({
mockParentMode: { current: 'paragraph' as ParentMode },
mockDatasetId: { current: 'test-dataset-id' },
mockDocumentId: { current: 'test-document-id' },
mockNotify: vi.fn(),
mockEventEmitter: { emit: vi.fn(), on: vi.fn(), off: vi.fn() },
mockQueryClient: { setQueryData: vi.fn() },
mockChildSegmentListData: { current: { data: [] as ChildChunkDetail[], total: 0, total_pages: 0 } as ChildSegmentsResponse | undefined },
mockDeleteChildSegment: vi.fn(),
mockUpdateChildSegment: vi.fn(),
mockInvalidChildSegmentList: vi.fn(),
}))
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
if (key === 'actionMsg.modifiedSuccessfully')
return 'Modified successfully'
if (key === 'actionMsg.modifiedUnsuccessfully')
return 'Modified unsuccessfully'
if (key === 'segment.contentEmpty')
return 'Content cannot be empty'
return key
},
}),
}))
vi.mock('@tanstack/react-query', async () => {
const actual = await vi.importActual('@tanstack/react-query')
return {
...actual,
useQueryClient: () => mockQueryClient,
}
})
vi.mock('../../context', () => ({
useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => {
const value: DocumentContextValue = {
datasetId: mockDatasetId.current,
documentId: mockDocumentId.current,
docForm: 'text' as ChunkingMode,
parentMode: mockParentMode.current,
}
return selector(value)
},
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }),
}))
vi.mock('@/service/knowledge/use-segment', () => ({
useChildSegmentList: () => ({
isLoading: false,
data: mockChildSegmentListData.current,
}),
useChildSegmentListKey: ['segment', 'childChunkList'],
useDeleteChildSegment: () => ({ mutateAsync: mockDeleteChildSegment }),
useUpdateChildSegment: () => ({ mutateAsync: mockUpdateChildSegment }),
}))
vi.mock('@/service/use-base', () => ({
useInvalid: () => mockInvalidChildSegmentList,
}))
// ============================================================================
// Test Utilities
// ============================================================================
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const createWrapper = () => {
const queryClient = createQueryClient()
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildChunkDetail => ({
id: `child-${Math.random().toString(36).substr(2, 9)}`,
position: 1,
segment_id: 'segment-1',
content: 'Child chunk content',
word_count: 100,
created_at: 1700000000,
updated_at: 1700000000,
type: 'automatic',
...overrides,
})
const createMockSegment = (overrides: Partial<SegmentDetailModel> = {}): SegmentDetailModel => ({
id: 'segment-1',
position: 1,
document_id: 'doc-1',
content: 'Test content',
sign_content: 'Test signed content',
word_count: 100,
tokens: 50,
keywords: [],
index_node_id: 'index-1',
index_node_hash: 'hash-1',
hit_count: 0,
enabled: true,
disabled_at: 0,
disabled_by: '',
status: 'completed',
created_by: 'user-1',
created_at: 1700000000,
indexing_at: 1700000100,
completed_at: 1700000200,
error: null,
stopped_at: 0,
updated_at: 1700000000,
attachments: [],
child_chunks: [],
...overrides,
})
const defaultOptions = {
searchValue: '',
currentPage: 1,
limit: 10,
segments: [createMockSegment()] as SegmentDetailModel[],
currChunkId: 'segment-1',
isFullDocMode: true,
onCloseChildSegmentDetail: vi.fn(),
refreshChunkListDataWithDetailChanged: vi.fn(),
updateSegmentInCache: vi.fn(),
}
// ============================================================================
// Tests
// ============================================================================
describe('useChildSegmentData', () => {
beforeEach(() => {
vi.clearAllMocks()
mockParentMode.current = 'paragraph'
mockDatasetId.current = 'test-dataset-id'
mockDocumentId.current = 'test-document-id'
mockChildSegmentListData.current = { data: [], total: 0, total_pages: 0, page: 1, limit: 20 }
})
describe('Initial State', () => {
it('should return empty child segments initially', () => {
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.childSegments).toEqual([])
expect(result.current.isLoadingChildSegmentList).toBe(false)
})
})
describe('resetChildList', () => {
it('should call invalidChildSegmentList', () => {
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
act(() => {
result.current.resetChildList()
})
expect(mockInvalidChildSegmentList).toHaveBeenCalled()
})
})
describe('onDeleteChildChunk', () => {
it('should delete child chunk and update parent cache in paragraph mode', async () => {
mockParentMode.current = 'paragraph'
const updateSegmentInCache = vi.fn()
mockDeleteChildSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
updateSegmentInCache,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDeleteChildChunk('seg-1', 'child-1')
})
expect(mockDeleteChildSegment).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' })
expect(updateSegmentInCache).toHaveBeenCalledWith('seg-1', expect.any(Function))
})
it('should delete child chunk and reset list in full-doc mode', async () => {
mockParentMode.current = 'full-doc'
mockDeleteChildSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDeleteChildChunk('seg-1', 'child-1')
})
expect(mockInvalidChildSegmentList).toHaveBeenCalled()
})
it('should notify error on failure', async () => {
mockDeleteChildSegment.mockImplementation(async (_params, { onError }: { onError: () => void }) => {
onError()
})
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDeleteChildChunk('seg-1', 'child-1')
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Modified unsuccessfully' })
})
})
describe('handleUpdateChildChunk', () => {
it('should validate empty content', async () => {
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateChildChunk('seg-1', 'child-1', ' ')
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Content cannot be empty' })
expect(mockUpdateChildSegment).not.toHaveBeenCalled()
})
it('should update child chunk and parent cache in paragraph mode', async () => {
mockParentMode.current = 'paragraph'
const updateSegmentInCache = vi.fn()
const onCloseChildSegmentDetail = vi.fn()
const refreshChunkListDataWithDetailChanged = vi.fn()
mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => {
onSuccess({
data: createMockChildChunk({
content: 'updated content',
type: 'customized',
word_count: 50,
updated_at: 1700000001,
}),
})
onSettled()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
updateSegmentInCache,
onCloseChildSegmentDetail,
refreshChunkListDataWithDetailChanged,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'updated content')
})
expect(mockUpdateChildSegment).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' })
expect(onCloseChildSegmentDetail).toHaveBeenCalled()
expect(updateSegmentInCache).toHaveBeenCalled()
expect(refreshChunkListDataWithDetailChanged).toHaveBeenCalled()
expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-child-segment')
expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-child-segment-done')
})
it('should update child chunk cache in full-doc mode', async () => {
mockParentMode.current = 'full-doc'
const onCloseChildSegmentDetail = vi.fn()
mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => {
onSuccess({
data: createMockChildChunk({
content: 'updated content',
type: 'customized',
word_count: 50,
updated_at: 1700000001,
}),
})
onSettled()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
onCloseChildSegmentDetail,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'updated content')
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
})
describe('onSaveNewChildChunk', () => {
it('should update parent cache in paragraph mode', () => {
mockParentMode.current = 'paragraph'
const updateSegmentInCache = vi.fn()
const refreshChunkListDataWithDetailChanged = vi.fn()
const newChildChunk = createMockChildChunk({ id: 'new-child' })
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
updateSegmentInCache,
refreshChunkListDataWithDetailChanged,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.onSaveNewChildChunk(newChildChunk)
})
expect(updateSegmentInCache).toHaveBeenCalled()
expect(refreshChunkListDataWithDetailChanged).toHaveBeenCalled()
})
it('should reset child list in full-doc mode', () => {
mockParentMode.current = 'full-doc'
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
act(() => {
result.current.onSaveNewChildChunk(createMockChildChunk())
})
expect(mockInvalidChildSegmentList).toHaveBeenCalled()
})
})
describe('viewNewlyAddedChildChunk', () => {
it('should set needScrollToBottom and not reset when adding new page', () => {
mockChildSegmentListData.current = { data: [], total: 10, total_pages: 1, page: 1, limit: 20 }
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
limit: 10,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.viewNewlyAddedChildChunk()
})
expect(result.current.needScrollToBottom.current).toBe(true)
})
it('should call resetChildList when not adding new page', () => {
mockChildSegmentListData.current = { data: [], total: 5, total_pages: 1, page: 1, limit: 20 }
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
limit: 10,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.viewNewlyAddedChildChunk()
})
expect(mockInvalidChildSegmentList).toHaveBeenCalled()
})
})
describe('Query disabled states', () => {
it('should disable query when not in fullDocMode', () => {
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
isFullDocMode: false,
}), {
wrapper: createWrapper(),
})
// Query should be disabled but hook should still work
expect(result.current.childSegments).toEqual([])
})
it('should disable query when segments is empty', () => {
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
segments: [],
}), {
wrapper: createWrapper(),
})
expect(result.current.childSegments).toEqual([])
})
})
describe('Cache update callbacks', () => {
it('should use updateSegmentInCache when deleting in paragraph mode', async () => {
mockParentMode.current = 'paragraph'
const updateSegmentInCache = vi.fn()
mockDeleteChildSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
updateSegmentInCache,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDeleteChildChunk('seg-1', 'child-1')
})
expect(updateSegmentInCache).toHaveBeenCalledWith('seg-1', expect.any(Function))
// Verify the updater function filters correctly
const updaterFn = updateSegmentInCache.mock.calls[0][1]
const testSegment = createMockSegment({
child_chunks: [
createMockChildChunk({ id: 'child-1' }),
createMockChildChunk({ id: 'child-2' }),
],
})
const updatedSegment = updaterFn(testSegment)
expect(updatedSegment.child_chunks).toHaveLength(1)
expect(updatedSegment.child_chunks[0].id).toBe('child-2')
})
it('should use updateSegmentInCache when updating in paragraph mode', async () => {
mockParentMode.current = 'paragraph'
const updateSegmentInCache = vi.fn()
const onCloseChildSegmentDetail = vi.fn()
const refreshChunkListDataWithDetailChanged = vi.fn()
mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => {
onSuccess({
data: createMockChildChunk({
id: 'child-1',
content: 'new content',
type: 'customized',
word_count: 50,
updated_at: 1700000001,
}),
})
onSettled()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
updateSegmentInCache,
onCloseChildSegmentDetail,
refreshChunkListDataWithDetailChanged,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'new content')
})
expect(updateSegmentInCache).toHaveBeenCalledWith('seg-1', expect.any(Function))
// Verify the updater function maps correctly
const updaterFn = updateSegmentInCache.mock.calls[0][1]
const testSegment = createMockSegment({
child_chunks: [
createMockChildChunk({ id: 'child-1', content: 'old content' }),
createMockChildChunk({ id: 'child-2', content: 'other content' }),
],
})
const updatedSegment = updaterFn(testSegment)
expect(updatedSegment.child_chunks).toHaveLength(2)
expect(updatedSegment.child_chunks[0].content).toBe('new content')
expect(updatedSegment.child_chunks[1].content).toBe('other content')
})
})
describe('updateChildSegmentInCache in full-doc mode', () => {
it('should use updateChildSegmentInCache when updating in full-doc mode', async () => {
mockParentMode.current = 'full-doc'
const onCloseChildSegmentDetail = vi.fn()
mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => {
onSuccess({
data: createMockChildChunk({
id: 'child-1',
content: 'new content',
type: 'customized',
word_count: 50,
updated_at: 1700000001,
}),
})
onSettled()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
onCloseChildSegmentDetail,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'new content')
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
})
})

View File

@ -1,241 +0,0 @@
import type { ChildChunkDetail, ChildSegmentsResponse, SegmentDetailModel, SegmentUpdater } from '@/models/datasets'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import {
useChildSegmentList,
useChildSegmentListKey,
useDeleteChildSegment,
useUpdateChildSegment,
} from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
import { useDocumentContext } from '../../context'
export type UseChildSegmentDataOptions = {
searchValue: string
currentPage: number
limit: number
segments: SegmentDetailModel[]
currChunkId: string
isFullDocMode: boolean
onCloseChildSegmentDetail: () => void
refreshChunkListDataWithDetailChanged: () => void
updateSegmentInCache: (segmentId: string, updater: (seg: SegmentDetailModel) => SegmentDetailModel) => void
}
export type UseChildSegmentDataReturn = {
childSegments: ChildChunkDetail[]
isLoadingChildSegmentList: boolean
childChunkListData: ReturnType<typeof useChildSegmentList>['data']
childSegmentListRef: React.RefObject<HTMLDivElement | null>
needScrollToBottom: React.RefObject<boolean>
// Operations
onDeleteChildChunk: (segmentId: string, childChunkId: string) => Promise<void>
handleUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise<void>
onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void
resetChildList: () => void
viewNewlyAddedChildChunk: () => void
}
export const useChildSegmentData = (options: UseChildSegmentDataOptions): UseChildSegmentDataReturn => {
const {
searchValue,
currentPage,
limit,
segments,
currChunkId,
isFullDocMode,
onCloseChildSegmentDetail,
refreshChunkListDataWithDetailChanged,
updateSegmentInCache,
} = options
const { t } = useTranslation()
const { notify } = useToastContext()
const { eventEmitter } = useEventEmitterContextContext()
const queryClient = useQueryClient()
const datasetId = useDocumentContext(s => s.datasetId) || ''
const documentId = useDocumentContext(s => s.documentId) || ''
const parentMode = useDocumentContext(s => s.parentMode)
const childSegmentListRef = useRef<HTMLDivElement>(null)
const needScrollToBottom = useRef(false)
// Build query params
const queryParams = useMemo(() => ({
page: currentPage === 0 ? 1 : currentPage,
limit,
keyword: searchValue,
}), [currentPage, limit, searchValue])
const segmentId = segments[0]?.id || ''
// Build query key for optimistic updates
const currentQueryKey = useMemo(() =>
[...useChildSegmentListKey, datasetId, documentId, segmentId, queryParams], [datasetId, documentId, segmentId, queryParams])
// Fetch child segment list
const { isLoading: isLoadingChildSegmentList, data: childChunkListData } = useChildSegmentList(
{
datasetId,
documentId,
segmentId,
params: queryParams,
},
!isFullDocMode || segments.length === 0,
)
// Derive child segments from query data
const childSegments = useMemo(() => childChunkListData?.data || [], [childChunkListData])
const invalidChildSegmentList = useInvalid(useChildSegmentListKey)
// Scroll to bottom when child segments change
useEffect(() => {
if (childSegmentListRef.current && needScrollToBottom.current) {
childSegmentListRef.current.scrollTo({ top: childSegmentListRef.current.scrollHeight, behavior: 'smooth' })
needScrollToBottom.current = false
}
}, [childSegments])
const resetChildList = useCallback(() => {
invalidChildSegmentList()
}, [invalidChildSegmentList])
// Optimistic update helper for child segments
const updateChildSegmentInCache = useCallback((
childChunkId: string,
updater: (chunk: ChildChunkDetail) => ChildChunkDetail,
) => {
queryClient.setQueryData<ChildSegmentsResponse>(currentQueryKey, (old) => {
if (!old)
return old
return {
...old,
data: old.data.map(chunk => chunk.id === childChunkId ? updater(chunk) : chunk),
}
})
}, [queryClient, currentQueryKey])
// Mutations
const { mutateAsync: deleteChildSegment } = useDeleteChildSegment()
const { mutateAsync: updateChildSegment } = useUpdateChildSegment()
const onDeleteChildChunk = useCallback(async (segmentIdParam: string, childChunkId: string) => {
await deleteChildSegment(
{ datasetId, documentId, segmentId: segmentIdParam, childChunkId },
{
onSuccess: () => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
if (parentMode === 'paragraph') {
// Update parent segment's child_chunks in cache
updateSegmentInCache(segmentIdParam, seg => ({
...seg,
child_chunks: seg.child_chunks?.filter(chunk => chunk.id !== childChunkId),
}))
}
else {
resetChildList()
}
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
},
)
}, [datasetId, documentId, parentMode, deleteChildSegment, updateSegmentInCache, resetChildList, t, notify])
const handleUpdateChildChunk = useCallback(async (
segmentIdParam: string,
childChunkId: string,
content: string,
) => {
const params: SegmentUpdater = { content: '' }
if (!content.trim()) {
notify({ type: 'error', message: t('segment.contentEmpty', { ns: 'datasetDocuments' }) })
return
}
params.content = content
eventEmitter?.emit('update-child-segment')
await updateChildSegment({ datasetId, documentId, segmentId: segmentIdParam, childChunkId, body: params }, {
onSuccess: (res) => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
onCloseChildSegmentDetail()
if (parentMode === 'paragraph') {
// Update parent segment's child_chunks in cache
updateSegmentInCache(segmentIdParam, seg => ({
...seg,
child_chunks: seg.child_chunks?.map(childSeg =>
childSeg.id === childChunkId
? {
...childSeg,
content: res.data.content,
type: res.data.type,
word_count: res.data.word_count,
updated_at: res.data.updated_at,
}
: childSeg,
),
}))
refreshChunkListDataWithDetailChanged()
}
else {
updateChildSegmentInCache(childChunkId, chunk => ({
...chunk,
content: res.data.content,
type: res.data.type,
word_count: res.data.word_count,
updated_at: res.data.updated_at,
}))
}
},
onSettled: () => {
eventEmitter?.emit('update-child-segment-done')
},
})
}, [datasetId, documentId, parentMode, updateChildSegment, notify, eventEmitter, onCloseChildSegmentDetail, updateSegmentInCache, updateChildSegmentInCache, refreshChunkListDataWithDetailChanged, t])
const onSaveNewChildChunk = useCallback((newChildChunk?: ChildChunkDetail) => {
if (parentMode === 'paragraph') {
// Update parent segment's child_chunks in cache
updateSegmentInCache(currChunkId, seg => ({
...seg,
child_chunks: [...(seg.child_chunks || []), newChildChunk!],
}))
refreshChunkListDataWithDetailChanged()
}
else {
resetChildList()
}
}, [parentMode, currChunkId, updateSegmentInCache, refreshChunkListDataWithDetailChanged, resetChildList])
const viewNewlyAddedChildChunk = useCallback(() => {
const totalPages = childChunkListData?.total_pages || 0
const total = childChunkListData?.total || 0
const newPage = Math.ceil((total + 1) / limit)
needScrollToBottom.current = true
if (newPage > totalPages)
return
resetChildList()
}, [childChunkListData, limit, resetChildList])
return {
childSegments,
isLoadingChildSegmentList,
childChunkListData,
childSegmentListRef,
needScrollToBottom,
onDeleteChildChunk,
handleUpdateChildChunk,
onSaveNewChildChunk,
resetChildList,
viewNewlyAddedChildChunk,
}
}

View File

@ -1,141 +0,0 @@
import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
import { useCallback, useState } from 'react'
export type CurrSegmentType = {
segInfo?: SegmentDetailModel
showModal: boolean
isEditMode?: boolean
}
export type CurrChildChunkType = {
childChunkInfo?: ChildChunkDetail
showModal: boolean
}
export type UseModalStateReturn = {
// Segment detail modal
currSegment: CurrSegmentType
onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void
onCloseSegmentDetail: () => void
// Child segment detail modal
currChildChunk: CurrChildChunkType
currChunkId: string
onClickSlice: (detail: ChildChunkDetail) => void
onCloseChildSegmentDetail: () => void
// New segment modal
onCloseNewSegmentModal: () => void
// New child segment modal
showNewChildSegmentModal: boolean
handleAddNewChildChunk: (parentChunkId: string) => void
onCloseNewChildChunkModal: () => void
// Regeneration modal
isRegenerationModalOpen: boolean
setIsRegenerationModalOpen: (open: boolean) => void
// Full screen
fullScreen: boolean
toggleFullScreen: () => void
setFullScreen: (fullScreen: boolean) => void
// Collapsed state
isCollapsed: boolean
toggleCollapsed: () => void
}
type UseModalStateOptions = {
onNewSegmentModalChange: (state: boolean) => void
}
export const useModalState = (options: UseModalStateOptions): UseModalStateReturn => {
const { onNewSegmentModalChange } = options
// Segment detail modal state
const [currSegment, setCurrSegment] = useState<CurrSegmentType>({ showModal: false })
// Child segment detail modal state
const [currChildChunk, setCurrChildChunk] = useState<CurrChildChunkType>({ showModal: false })
const [currChunkId, setCurrChunkId] = useState('')
// New child segment modal state
const [showNewChildSegmentModal, setShowNewChildSegmentModal] = useState(false)
// Regeneration modal state
const [isRegenerationModalOpen, setIsRegenerationModalOpen] = useState(false)
// Display state
const [fullScreen, setFullScreen] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(true)
// Segment detail handlers
const onClickCard = useCallback((detail: SegmentDetailModel, isEditMode = false) => {
setCurrSegment({ segInfo: detail, showModal: true, isEditMode })
}, [])
const onCloseSegmentDetail = useCallback(() => {
setCurrSegment({ showModal: false })
setFullScreen(false)
}, [])
// Child segment detail handlers
const onClickSlice = useCallback((detail: ChildChunkDetail) => {
setCurrChildChunk({ childChunkInfo: detail, showModal: true })
setCurrChunkId(detail.segment_id)
}, [])
const onCloseChildSegmentDetail = useCallback(() => {
setCurrChildChunk({ showModal: false })
setFullScreen(false)
}, [])
// New segment modal handlers
const onCloseNewSegmentModal = useCallback(() => {
onNewSegmentModalChange(false)
setFullScreen(false)
}, [onNewSegmentModalChange])
// New child segment modal handlers
const handleAddNewChildChunk = useCallback((parentChunkId: string) => {
setShowNewChildSegmentModal(true)
setCurrChunkId(parentChunkId)
}, [])
const onCloseNewChildChunkModal = useCallback(() => {
setShowNewChildSegmentModal(false)
setFullScreen(false)
}, [])
// Display handlers - handles both direct calls and click events
const toggleFullScreen = useCallback(() => {
setFullScreen(prev => !prev)
}, [])
const toggleCollapsed = useCallback(() => {
setIsCollapsed(prev => !prev)
}, [])
return {
// Segment detail modal
currSegment,
onClickCard,
onCloseSegmentDetail,
// Child segment detail modal
currChildChunk,
currChunkId,
onClickSlice,
onCloseChildSegmentDetail,
// New segment modal
onCloseNewSegmentModal,
// New child segment modal
showNewChildSegmentModal,
handleAddNewChildChunk,
onCloseNewChildChunkModal,
// Regeneration modal
isRegenerationModalOpen,
setIsRegenerationModalOpen,
// Full screen
fullScreen,
toggleFullScreen,
setFullScreen,
// Collapsed state
isCollapsed,
toggleCollapsed,
}
}

View File

@ -1,85 +0,0 @@
import type { Item } from '@/app/components/base/select'
import { useDebounceFn } from 'ahooks'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
export type SearchFilterState = {
inputValue: string
searchValue: string
selectedStatus: boolean | 'all'
}
export type UseSearchFilterReturn = {
inputValue: string
searchValue: string
selectedStatus: boolean | 'all'
statusList: Item[]
selectDefaultValue: 'all' | 0 | 1
handleInputChange: (value: string) => void
onChangeStatus: (item: Item) => void
onClearFilter: () => void
resetPage: () => void
}
type UseSearchFilterOptions = {
onPageChange: (page: number) => void
}
export const useSearchFilter = (options: UseSearchFilterOptions): UseSearchFilterReturn => {
const { t } = useTranslation()
const { onPageChange } = options
const [inputValue, setInputValue] = useState<string>('')
const [searchValue, setSearchValue] = useState<string>('')
const [selectedStatus, setSelectedStatus] = useState<boolean | 'all'>('all')
const statusList = useRef<Item[]>([
{ value: 'all', name: t('list.index.all', { ns: 'datasetDocuments' }) },
{ value: 0, name: t('list.status.disabled', { ns: 'datasetDocuments' }) },
{ value: 1, name: t('list.status.enabled', { ns: 'datasetDocuments' }) },
])
const { run: handleSearch } = useDebounceFn(() => {
setSearchValue(inputValue)
onPageChange(1)
}, { wait: 500 })
const handleInputChange = useCallback((value: string) => {
setInputValue(value)
handleSearch()
}, [handleSearch])
const onChangeStatus = useCallback(({ value }: Item) => {
setSelectedStatus(value === 'all' ? 'all' : !!value)
onPageChange(1)
}, [onPageChange])
const onClearFilter = useCallback(() => {
setInputValue('')
setSearchValue('')
setSelectedStatus('all')
onPageChange(1)
}, [onPageChange])
const resetPage = useCallback(() => {
onPageChange(1)
}, [onPageChange])
const selectDefaultValue = useMemo(() => {
if (selectedStatus === 'all')
return 'all'
return selectedStatus ? 1 : 0
}, [selectedStatus])
return {
inputValue,
searchValue,
selectedStatus,
statusList: statusList.current,
selectDefaultValue,
handleInputChange,
onChangeStatus,
onClearFilter,
resetPage,
}
}

View File

@ -1,942 +0,0 @@
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
import type { ChunkingMode, ParentMode, SegmentDetailModel, SegmentsResponse } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets'
import { ProcessStatus } from '../../segment-add'
import { useSegmentListData } from './use-segment-list-data'
// Type for mutation callbacks
type SegmentMutationResponse = { data: SegmentDetailModel }
type SegmentMutationCallbacks = {
onSuccess: (res: SegmentMutationResponse) => void
onSettled: () => void
}
// Mock file entity factory
const createMockFileEntity = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'file-1',
name: 'test.png',
size: 1024,
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: undefined,
base64Url: undefined,
...overrides,
})
// ============================================================================
// Hoisted Mocks
// ============================================================================
const {
mockDocForm,
mockParentMode,
mockDatasetId,
mockDocumentId,
mockNotify,
mockEventEmitter,
mockQueryClient,
mockSegmentListData,
mockEnableSegment,
mockDisableSegment,
mockDeleteSegment,
mockUpdateSegment,
mockInvalidSegmentList,
mockInvalidChunkListAll,
mockInvalidChunkListEnabled,
mockInvalidChunkListDisabled,
mockPathname,
} = vi.hoisted(() => ({
mockDocForm: { current: 'text' as ChunkingMode },
mockParentMode: { current: 'paragraph' as ParentMode },
mockDatasetId: { current: 'test-dataset-id' },
mockDocumentId: { current: 'test-document-id' },
mockNotify: vi.fn(),
mockEventEmitter: { emit: vi.fn(), on: vi.fn(), off: vi.fn() },
mockQueryClient: { setQueryData: vi.fn() },
mockSegmentListData: { current: { data: [] as SegmentDetailModel[], total: 0, total_pages: 0, has_more: false, limit: 20, page: 1 } as SegmentsResponse | undefined },
mockEnableSegment: vi.fn(),
mockDisableSegment: vi.fn(),
mockDeleteSegment: vi.fn(),
mockUpdateSegment: vi.fn(),
mockInvalidSegmentList: vi.fn(),
mockInvalidChunkListAll: vi.fn(),
mockInvalidChunkListEnabled: vi.fn(),
mockInvalidChunkListDisabled: vi.fn(),
mockPathname: { current: '/datasets/test/documents/test' },
}))
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { count?: number, ns?: string }) => {
if (key === 'actionMsg.modifiedSuccessfully')
return 'Modified successfully'
if (key === 'actionMsg.modifiedUnsuccessfully')
return 'Modified unsuccessfully'
if (key === 'segment.contentEmpty')
return 'Content cannot be empty'
if (key === 'segment.questionEmpty')
return 'Question cannot be empty'
if (key === 'segment.answerEmpty')
return 'Answer cannot be empty'
if (key === 'segment.allFilesUploaded')
return 'All files must be uploaded'
if (key === 'segment.chunks')
return options?.count === 1 ? 'chunk' : 'chunks'
if (key === 'segment.parentChunks')
return options?.count === 1 ? 'parent chunk' : 'parent chunks'
if (key === 'segment.searchResults')
return 'search results'
return `${options?.ns || ''}.${key}`
},
}),
}))
vi.mock('next/navigation', () => ({
usePathname: () => mockPathname.current,
}))
vi.mock('@tanstack/react-query', async () => {
const actual = await vi.importActual('@tanstack/react-query')
return {
...actual,
useQueryClient: () => mockQueryClient,
}
})
vi.mock('../../context', () => ({
useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => {
const value: DocumentContextValue = {
datasetId: mockDatasetId.current,
documentId: mockDocumentId.current,
docForm: mockDocForm.current,
parentMode: mockParentMode.current,
}
return selector(value)
},
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }),
}))
vi.mock('@/service/knowledge/use-segment', () => ({
useSegmentList: () => ({
isLoading: false,
data: mockSegmentListData.current,
}),
useSegmentListKey: ['segment', 'chunkList'],
useChunkListAllKey: ['segment', 'chunkList', { enabled: 'all' }],
useChunkListEnabledKey: ['segment', 'chunkList', { enabled: true }],
useChunkListDisabledKey: ['segment', 'chunkList', { enabled: false }],
useEnableSegment: () => ({ mutateAsync: mockEnableSegment }),
useDisableSegment: () => ({ mutateAsync: mockDisableSegment }),
useDeleteSegment: () => ({ mutateAsync: mockDeleteSegment }),
useUpdateSegment: () => ({ mutateAsync: mockUpdateSegment }),
}))
vi.mock('@/service/use-base', () => ({
useInvalid: (key: unknown[]) => {
const keyObj = key[2] as { enabled?: boolean | 'all' } | undefined
if (keyObj?.enabled === 'all')
return mockInvalidChunkListAll
if (keyObj?.enabled === true)
return mockInvalidChunkListEnabled
if (keyObj?.enabled === false)
return mockInvalidChunkListDisabled
return mockInvalidSegmentList
},
}))
// ============================================================================
// Test Utilities
// ============================================================================
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const createWrapper = () => {
const queryClient = createQueryClient()
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockSegment = (overrides: Partial<SegmentDetailModel> = {}): SegmentDetailModel => ({
id: `segment-${Math.random().toString(36).substr(2, 9)}`,
position: 1,
document_id: 'doc-1',
content: 'Test content',
sign_content: 'Test signed content',
word_count: 100,
tokens: 50,
keywords: [],
index_node_id: 'index-1',
index_node_hash: 'hash-1',
hit_count: 0,
enabled: true,
disabled_at: 0,
disabled_by: '',
status: 'completed',
created_by: 'user-1',
created_at: 1700000000,
indexing_at: 1700000100,
completed_at: 1700000200,
error: null,
stopped_at: 0,
updated_at: 1700000000,
attachments: [],
child_chunks: [],
...overrides,
})
const defaultOptions = {
searchValue: '',
selectedStatus: 'all' as boolean | 'all',
selectedSegmentIds: [] as string[],
importStatus: undefined as ProcessStatus | string | undefined,
currentPage: 1,
limit: 10,
onCloseSegmentDetail: vi.fn(),
clearSelection: vi.fn(),
}
// ============================================================================
// Tests
// ============================================================================
describe('useSegmentListData', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDocForm.current = ChunkingModeEnum.text as ChunkingMode
mockParentMode.current = 'paragraph'
mockDatasetId.current = 'test-dataset-id'
mockDocumentId.current = 'test-document-id'
mockSegmentListData.current = { data: [], total: 0, total_pages: 0, has_more: false, limit: 20, page: 1 }
mockPathname.current = '/datasets/test/documents/test'
})
describe('Initial State', () => {
it('should return empty segments initially', () => {
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.segments).toEqual([])
expect(result.current.isLoadingSegmentList).toBe(false)
})
it('should compute isFullDocMode correctly', () => {
mockDocForm.current = ChunkingModeEnum.parentChild
mockParentMode.current = 'full-doc'
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.isFullDocMode).toBe(true)
})
it('should compute isFullDocMode as false for text mode', () => {
mockDocForm.current = ChunkingModeEnum.text
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.isFullDocMode).toBe(false)
})
})
describe('totalText computation', () => {
it('should show chunks count when not searching', () => {
mockSegmentListData.current = { data: [], total: 10, total_pages: 1, has_more: false, limit: 20, page: 1 }
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.totalText).toContain('10')
expect(result.current.totalText).toContain('chunks')
})
it('should show search results when searching', () => {
mockSegmentListData.current = { data: [], total: 5, total_pages: 1, has_more: false, limit: 20, page: 1 }
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
searchValue: 'test',
}), {
wrapper: createWrapper(),
})
expect(result.current.totalText).toContain('5')
expect(result.current.totalText).toContain('search results')
})
it('should show search results when status is filtered', () => {
mockSegmentListData.current = { data: [], total: 3, total_pages: 1, has_more: false, limit: 20, page: 1 }
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: true,
}), {
wrapper: createWrapper(),
})
expect(result.current.totalText).toContain('search results')
})
it('should show parent chunks in parentChild paragraph mode', () => {
mockDocForm.current = ChunkingModeEnum.parentChild
mockParentMode.current = 'paragraph'
mockSegmentListData.current = { data: [], total: 7, total_pages: 1, has_more: false, limit: 20, page: 1 }
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.totalText).toContain('parent chunk')
})
it('should show "--" when total is undefined', () => {
mockSegmentListData.current = undefined
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.totalText).toContain('--')
})
})
describe('resetList', () => {
it('should call clearSelection and invalidSegmentList', () => {
const clearSelection = vi.fn()
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
clearSelection,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.resetList()
})
expect(clearSelection).toHaveBeenCalled()
expect(mockInvalidSegmentList).toHaveBeenCalled()
})
})
describe('refreshChunkListWithStatusChanged', () => {
it('should invalidate disabled and enabled when status is all', async () => {
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: 'all',
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, 'seg-1')
})
expect(mockInvalidChunkListDisabled).toHaveBeenCalled()
expect(mockInvalidChunkListEnabled).toHaveBeenCalled()
})
it('should invalidate segment list when status is not all', async () => {
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: true,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, 'seg-1')
})
expect(mockInvalidSegmentList).toHaveBeenCalled()
})
})
describe('onChangeSwitch', () => {
it('should call enableSegment when enable is true', async () => {
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, 'seg-1')
})
expect(mockEnableSegment).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' })
})
it('should call disableSegment when enable is false', async () => {
mockDisableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(false, 'seg-1')
})
expect(mockDisableSegment).toHaveBeenCalled()
})
it('should use selectedSegmentIds when segId is empty', async () => {
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedSegmentIds: ['seg-1', 'seg-2'],
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, '')
})
expect(mockEnableSegment).toHaveBeenCalledWith(
expect.objectContaining({ segmentIds: ['seg-1', 'seg-2'] }),
expect.any(Object),
)
})
it('should notify error on failure', async () => {
mockEnableSegment.mockImplementation(async (_params, { onError }: { onError: () => void }) => {
onError()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, 'seg-1')
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Modified unsuccessfully' })
})
})
describe('onDelete', () => {
it('should call deleteSegment and resetList on success', async () => {
const clearSelection = vi.fn()
mockDeleteSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
clearSelection,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDelete('seg-1')
})
expect(mockDeleteSegment).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' })
})
it('should clear selection when deleting batch (no segId)', async () => {
const clearSelection = vi.fn()
mockDeleteSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedSegmentIds: ['seg-1', 'seg-2'],
clearSelection,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDelete('')
})
// clearSelection is called twice: once in resetList, once after
expect(clearSelection).toHaveBeenCalled()
})
it('should notify error on failure', async () => {
mockDeleteSegment.mockImplementation(async (_params, { onError }: { onError: () => void }) => {
onError()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDelete('seg-1')
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Modified unsuccessfully' })
})
})
describe('handleUpdateSegment', () => {
it('should validate empty content', async () => {
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', ' ', '', [], [])
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Content cannot be empty' })
expect(mockUpdateSegment).not.toHaveBeenCalled()
})
it('should validate empty question in QA mode', async () => {
mockDocForm.current = ChunkingModeEnum.qa
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', '', 'answer', [], [])
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Question cannot be empty' })
})
it('should validate empty answer in QA mode', async () => {
mockDocForm.current = ChunkingModeEnum.qa
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'question', ' ', [], [])
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Answer cannot be empty' })
})
it('should validate attachments are uploaded', async () => {
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [
createMockFileEntity({ id: '1', name: 'test.png', uploadedId: undefined }),
])
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'All files must be uploaded' })
})
it('should call updateSegment with correct params', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const onCloseSegmentDetail = vi.fn()
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
onCloseSegmentDetail,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'updated content', '', ['keyword1'], [])
})
expect(mockUpdateSegment).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' })
expect(onCloseSegmentDetail).toHaveBeenCalled()
expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-segment')
expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-segment-success')
expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-segment-done')
})
it('should not close modal when needRegenerate is true', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const onCloseSegmentDetail = vi.fn()
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
onCloseSegmentDetail,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [], true)
})
expect(onCloseSegmentDetail).not.toHaveBeenCalled()
})
it('should include attachments in params', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [
createMockFileEntity({ id: '1', name: 'test.png', uploadedId: 'uploaded-1' }),
])
})
expect(mockUpdateSegment).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({ attachment_ids: ['uploaded-1'] }),
}),
expect.any(Object),
)
})
})
describe('viewNewlyAddedChunk', () => {
it('should set needScrollToBottom and not call resetList when adding new page', () => {
mockSegmentListData.current = { data: [], total: 10, total_pages: 1, has_more: false, limit: 20, page: 1 }
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
limit: 10,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.viewNewlyAddedChunk()
})
expect(result.current.needScrollToBottom.current).toBe(true)
})
it('should call resetList when not adding new page', () => {
mockSegmentListData.current = { data: [], total: 5, total_pages: 1, has_more: false, limit: 20, page: 1 }
const clearSelection = vi.fn()
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
clearSelection,
limit: 10,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.viewNewlyAddedChunk()
})
// resetList should be called
expect(clearSelection).toHaveBeenCalled()
})
})
describe('updateSegmentInCache', () => {
it('should call queryClient.setQueryData', () => {
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
act(() => {
result.current.updateSegmentInCache('seg-1', seg => ({ ...seg, enabled: false }))
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
})
describe('Effect: pathname change', () => {
it('should reset list when pathname changes', async () => {
const clearSelection = vi.fn()
renderHook(() => useSegmentListData({
...defaultOptions,
clearSelection,
}), {
wrapper: createWrapper(),
})
// Initial call from effect
expect(clearSelection).toHaveBeenCalled()
expect(mockInvalidSegmentList).toHaveBeenCalled()
})
})
describe('Effect: import status', () => {
it('should reset list when import status is COMPLETED', () => {
const clearSelection = vi.fn()
renderHook(() => useSegmentListData({
...defaultOptions,
importStatus: ProcessStatus.COMPLETED,
clearSelection,
}), {
wrapper: createWrapper(),
})
expect(clearSelection).toHaveBeenCalled()
})
})
describe('refreshChunkListDataWithDetailChanged', () => {
it('should call correct invalidation for status all', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: 'all',
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [])
})
expect(mockInvalidChunkListDisabled).toHaveBeenCalled()
expect(mockInvalidChunkListEnabled).toHaveBeenCalled()
})
it('should call correct invalidation for status true', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: true,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [])
})
expect(mockInvalidChunkListAll).toHaveBeenCalled()
expect(mockInvalidChunkListDisabled).toHaveBeenCalled()
})
it('should call correct invalidation for status false', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: false,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [])
})
expect(mockInvalidChunkListAll).toHaveBeenCalled()
expect(mockInvalidChunkListEnabled).toHaveBeenCalled()
})
})
describe('QA Mode validation', () => {
it('should set content and answer for QA mode', async () => {
mockDocForm.current = ChunkingModeEnum.qa as ChunkingMode
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'question', 'answer', [], [])
})
expect(mockUpdateSegment).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
content: 'question',
answer: 'answer',
}),
}),
expect.any(Object),
)
})
})
describe('updateSegmentsInCache', () => {
it('should handle undefined old data', () => {
mockQueryClient.setQueryData.mockImplementation((_key, updater) => {
const result = typeof updater === 'function' ? updater(undefined) : updater
return result
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
// Call updateSegmentInCache which should handle undefined gracefully
act(() => {
result.current.updateSegmentInCache('seg-1', seg => ({ ...seg, enabled: false }))
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
it('should map segments correctly when old data exists', () => {
const mockOldData = {
data: [
createMockSegment({ id: 'seg-1', enabled: true }),
createMockSegment({ id: 'seg-2', enabled: true }),
],
total: 2,
total_pages: 1,
}
mockQueryClient.setQueryData.mockImplementation((_key, updater) => {
const result = typeof updater === 'function' ? updater(mockOldData) : updater
// Verify the updater transforms the data correctly
expect(result.data[0].enabled).toBe(false) // seg-1 should be updated
expect(result.data[1].enabled).toBe(true) // seg-2 should remain unchanged
return result
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
act(() => {
result.current.updateSegmentInCache('seg-1', seg => ({ ...seg, enabled: false }))
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
})
describe('updateSegmentsInCache batch', () => {
it('should handle undefined old data in batch update', async () => {
mockQueryClient.setQueryData.mockImplementation((_key, updater) => {
const result = typeof updater === 'function' ? updater(undefined) : updater
return result
})
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedSegmentIds: ['seg-1', 'seg-2'],
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, '')
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
it('should map multiple segments correctly when old data exists', async () => {
const mockOldData = {
data: [
createMockSegment({ id: 'seg-1', enabled: false }),
createMockSegment({ id: 'seg-2', enabled: false }),
createMockSegment({ id: 'seg-3', enabled: false }),
],
total: 3,
total_pages: 1,
}
mockQueryClient.setQueryData.mockImplementation((_key, updater) => {
const result = typeof updater === 'function' ? updater(mockOldData) : updater
// Verify only selected segments are updated
if (result && result.data) {
expect(result.data[0].enabled).toBe(true) // seg-1 should be updated
expect(result.data[1].enabled).toBe(true) // seg-2 should be updated
expect(result.data[2].enabled).toBe(false) // seg-3 should remain unchanged
}
return result
})
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedSegmentIds: ['seg-1', 'seg-2'],
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, '')
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
})
})

View File

@ -1,363 +0,0 @@
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { SegmentDetailModel, SegmentsResponse, SegmentUpdater } from '@/models/datasets'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname } from 'next/navigation'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { ChunkingMode } from '@/models/datasets'
import {
useChunkListAllKey,
useChunkListDisabledKey,
useChunkListEnabledKey,
useDeleteSegment,
useDisableSegment,
useEnableSegment,
useSegmentList,
useSegmentListKey,
useUpdateSegment,
} from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
import { formatNumber } from '@/utils/format'
import { useDocumentContext } from '../../context'
import { ProcessStatus } from '../../segment-add'
const DEFAULT_LIMIT = 10
export type UseSegmentListDataOptions = {
searchValue: string
selectedStatus: boolean | 'all'
selectedSegmentIds: string[]
importStatus: ProcessStatus | string | undefined
currentPage: number
limit: number
onCloseSegmentDetail: () => void
clearSelection: () => void
}
export type UseSegmentListDataReturn = {
segments: SegmentDetailModel[]
isLoadingSegmentList: boolean
segmentListData: ReturnType<typeof useSegmentList>['data']
totalText: string
isFullDocMode: boolean
segmentListRef: React.RefObject<HTMLDivElement | null>
needScrollToBottom: React.RefObject<boolean>
// Operations
onChangeSwitch: (enable: boolean, segId?: string) => Promise<void>
onDelete: (segId?: string) => Promise<void>
handleUpdateSegment: (
segmentId: string,
question: string,
answer: string,
keywords: string[],
attachments: FileEntity[],
needRegenerate?: boolean,
) => Promise<void>
resetList: () => void
viewNewlyAddedChunk: () => void
invalidSegmentList: () => void
updateSegmentInCache: (segmentId: string, updater: (seg: SegmentDetailModel) => SegmentDetailModel) => void
}
export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegmentListDataReturn => {
const {
searchValue,
selectedStatus,
selectedSegmentIds,
importStatus,
currentPage,
limit,
onCloseSegmentDetail,
clearSelection,
} = options
const { t } = useTranslation()
const { notify } = useToastContext()
const pathname = usePathname()
const { eventEmitter } = useEventEmitterContextContext()
const queryClient = useQueryClient()
const datasetId = useDocumentContext(s => s.datasetId) || ''
const documentId = useDocumentContext(s => s.documentId) || ''
const docForm = useDocumentContext(s => s.docForm)
const parentMode = useDocumentContext(s => s.parentMode)
const segmentListRef = useRef<HTMLDivElement>(null)
const needScrollToBottom = useRef(false)
const isFullDocMode = useMemo(() => {
return docForm === ChunkingMode.parentChild && parentMode === 'full-doc'
}, [docForm, parentMode])
// Build query params
const queryParams = useMemo(() => ({
page: isFullDocMode ? 1 : currentPage,
limit: isFullDocMode ? DEFAULT_LIMIT : limit,
keyword: isFullDocMode ? '' : searchValue,
enabled: selectedStatus,
}), [isFullDocMode, currentPage, limit, searchValue, selectedStatus])
// Build query key for optimistic updates
const currentQueryKey = useMemo(() =>
[...useSegmentListKey, datasetId, documentId, queryParams], [datasetId, documentId, queryParams])
// Fetch segment list
const { isLoading: isLoadingSegmentList, data: segmentListData } = useSegmentList({
datasetId,
documentId,
params: queryParams,
})
// Derive segments from query data
const segments = useMemo(() => segmentListData?.data || [], [segmentListData])
// Invalidation hooks
const invalidSegmentList = useInvalid(useSegmentListKey)
const invalidChunkListAll = useInvalid(useChunkListAllKey)
const invalidChunkListEnabled = useInvalid(useChunkListEnabledKey)
const invalidChunkListDisabled = useInvalid(useChunkListDisabledKey)
// Scroll to bottom when needed
useEffect(() => {
if (segmentListRef.current && needScrollToBottom.current) {
segmentListRef.current.scrollTo({ top: segmentListRef.current.scrollHeight, behavior: 'smooth' })
needScrollToBottom.current = false
}
}, [segments])
// Reset list on pathname change
useEffect(() => {
clearSelection()
invalidSegmentList()
}, [pathname])
// Reset list on import completion
useEffect(() => {
if (importStatus === ProcessStatus.COMPLETED) {
clearSelection()
invalidSegmentList()
}
}, [importStatus])
const resetList = useCallback(() => {
clearSelection()
invalidSegmentList()
}, [clearSelection, invalidSegmentList])
const refreshChunkListWithStatusChanged = useCallback(() => {
if (selectedStatus === 'all') {
invalidChunkListDisabled()
invalidChunkListEnabled()
}
else {
invalidSegmentList()
}
}, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidSegmentList])
const refreshChunkListDataWithDetailChanged = useCallback(() => {
const refreshMap: Record<string, () => void> = {
all: () => {
invalidChunkListDisabled()
invalidChunkListEnabled()
},
true: () => {
invalidChunkListAll()
invalidChunkListDisabled()
},
false: () => {
invalidChunkListAll()
invalidChunkListEnabled()
},
}
refreshMap[String(selectedStatus)]?.()
}, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidChunkListAll])
// Optimistic update helper using React Query's setQueryData
const updateSegmentInCache = useCallback((
segmentId: string,
updater: (seg: SegmentDetailModel) => SegmentDetailModel,
) => {
queryClient.setQueryData<SegmentsResponse>(currentQueryKey, (old) => {
if (!old)
return old
return {
...old,
data: old.data.map(seg => seg.id === segmentId ? updater(seg) : seg),
}
})
}, [queryClient, currentQueryKey])
// Batch update helper
const updateSegmentsInCache = useCallback((
segmentIds: string[],
updater: (seg: SegmentDetailModel) => SegmentDetailModel,
) => {
queryClient.setQueryData<SegmentsResponse>(currentQueryKey, (old) => {
if (!old)
return old
return {
...old,
data: old.data.map(seg => segmentIds.includes(seg.id) ? updater(seg) : seg),
}
})
}, [queryClient, currentQueryKey])
// Mutations
const { mutateAsync: enableSegment } = useEnableSegment()
const { mutateAsync: disableSegment } = useDisableSegment()
const { mutateAsync: deleteSegment } = useDeleteSegment()
const { mutateAsync: updateSegment } = useUpdateSegment()
const onChangeSwitch = useCallback(async (enable: boolean, segId?: string) => {
const operationApi = enable ? enableSegment : disableSegment
const targetIds = segId ? [segId] : selectedSegmentIds
await operationApi({ datasetId, documentId, segmentIds: targetIds }, {
onSuccess: () => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
updateSegmentsInCache(targetIds, seg => ({ ...seg, enabled: enable }))
refreshChunkListWithStatusChanged()
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
})
}, [datasetId, documentId, selectedSegmentIds, disableSegment, enableSegment, t, notify, updateSegmentsInCache, refreshChunkListWithStatusChanged])
const onDelete = useCallback(async (segId?: string) => {
const targetIds = segId ? [segId] : selectedSegmentIds
await deleteSegment({ datasetId, documentId, segmentIds: targetIds }, {
onSuccess: () => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
resetList()
if (!segId)
clearSelection()
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
})
}, [datasetId, documentId, selectedSegmentIds, deleteSegment, resetList, clearSelection, t, notify])
const handleUpdateSegment = useCallback(async (
segmentId: string,
question: string,
answer: string,
keywords: string[],
attachments: FileEntity[],
needRegenerate = false,
) => {
const params: SegmentUpdater = { content: '', attachment_ids: [] }
// Validate and build params based on doc form
if (docForm === ChunkingMode.qa) {
if (!question.trim()) {
notify({ type: 'error', message: t('segment.questionEmpty', { ns: 'datasetDocuments' }) })
return
}
if (!answer.trim()) {
notify({ type: 'error', message: t('segment.answerEmpty', { ns: 'datasetDocuments' }) })
return
}
params.content = question
params.answer = answer
}
else {
if (!question.trim()) {
notify({ type: 'error', message: t('segment.contentEmpty', { ns: 'datasetDocuments' }) })
return
}
params.content = question
}
if (keywords.length)
params.keywords = keywords
if (attachments.length) {
const notAllUploaded = attachments.some(item => !item.uploadedId)
if (notAllUploaded) {
notify({ type: 'error', message: t('segment.allFilesUploaded', { ns: 'datasetDocuments' }) })
return
}
params.attachment_ids = attachments.map(item => item.uploadedId!)
}
if (needRegenerate)
params.regenerate_child_chunks = needRegenerate
eventEmitter?.emit('update-segment')
await updateSegment({ datasetId, documentId, segmentId, body: params }, {
onSuccess(res) {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
if (!needRegenerate)
onCloseSegmentDetail()
updateSegmentInCache(segmentId, seg => ({
...seg,
answer: res.data.answer,
content: res.data.content,
sign_content: res.data.sign_content,
keywords: res.data.keywords,
attachments: res.data.attachments,
word_count: res.data.word_count,
hit_count: res.data.hit_count,
enabled: res.data.enabled,
updated_at: res.data.updated_at,
child_chunks: res.data.child_chunks,
}))
refreshChunkListDataWithDetailChanged()
eventEmitter?.emit('update-segment-success')
},
onSettled() {
eventEmitter?.emit('update-segment-done')
},
})
}, [datasetId, documentId, docForm, updateSegment, notify, eventEmitter, onCloseSegmentDetail, updateSegmentInCache, refreshChunkListDataWithDetailChanged, t])
const viewNewlyAddedChunk = useCallback(() => {
const totalPages = segmentListData?.total_pages || 0
const total = segmentListData?.total || 0
const newPage = Math.ceil((total + 1) / limit)
needScrollToBottom.current = true
if (newPage > totalPages)
return
resetList()
}, [segmentListData, limit, resetList])
// Compute total text for display
const totalText = useMemo(() => {
const isSearch = searchValue !== '' || selectedStatus !== 'all'
if (!isSearch) {
const total = segmentListData?.total ? formatNumber(segmentListData.total) : '--'
const count = total === '--' ? 0 : segmentListData!.total
const translationKey = (docForm === ChunkingMode.parentChild && parentMode === 'paragraph')
? 'segment.parentChunks' as const
: 'segment.chunks' as const
return `${total} ${t(translationKey, { ns: 'datasetDocuments', count })}`
}
const total = typeof segmentListData?.total === 'number' ? formatNumber(segmentListData.total) : 0
const count = segmentListData?.total || 0
return `${total} ${t('segment.searchResults', { ns: 'datasetDocuments', count })}`
}, [segmentListData, docForm, parentMode, searchValue, selectedStatus, t])
return {
segments,
isLoadingSegmentList,
segmentListData,
totalText,
isFullDocMode,
segmentListRef,
needScrollToBottom,
onChangeSwitch,
onDelete,
handleUpdateSegment,
resetList,
viewNewlyAddedChunk,
invalidSegmentList,
updateSegmentInCache,
}
}

View File

@ -1,58 +0,0 @@
import type { SegmentDetailModel } from '@/models/datasets'
import { useCallback, useMemo, useState } from 'react'
export type UseSegmentSelectionReturn = {
selectedSegmentIds: string[]
isAllSelected: boolean
isSomeSelected: boolean
onSelected: (segId: string) => void
onSelectedAll: () => void
onCancelBatchOperation: () => void
clearSelection: () => void
}
export const useSegmentSelection = (segments: SegmentDetailModel[]): UseSegmentSelectionReturn => {
const [selectedSegmentIds, setSelectedSegmentIds] = useState<string[]>([])
const onSelected = useCallback((segId: string) => {
setSelectedSegmentIds(prev =>
prev.includes(segId)
? prev.filter(id => id !== segId)
: [...prev, segId],
)
}, [])
const isAllSelected = useMemo(() => {
return segments.length > 0 && segments.every(seg => selectedSegmentIds.includes(seg.id))
}, [segments, selectedSegmentIds])
const isSomeSelected = useMemo(() => {
return segments.some(seg => selectedSegmentIds.includes(seg.id))
}, [segments, selectedSegmentIds])
const onSelectedAll = useCallback(() => {
setSelectedSegmentIds((prev) => {
const currentAllSegIds = segments.map(seg => seg.id)
const prevSelectedIds = prev.filter(item => !currentAllSegIds.includes(item))
return [...prevSelectedIds, ...(isAllSelected ? [] : currentAllSegIds)]
})
}, [segments, isAllSelected])
const onCancelBatchOperation = useCallback(() => {
setSelectedSegmentIds([])
}, [])
const clearSelection = useCallback(() => {
setSelectedSegmentIds([])
}, [])
return {
selectedSegmentIds,
isAllSelected,
isSomeSelected,
onSelected,
onSelectedAll,
onCancelBatchOperation,
clearSelection,
}
}

View File

@ -1,33 +1,89 @@
'use client'
import type { FC } from 'react'
import type { ProcessStatus } from '../segment-add'
import type { SegmentListContextValue } from './segment-list-context'
import { useCallback, useMemo, useState } from 'react'
import type { Item } from '@/app/components/base/select'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { ChildChunkDetail, SegmentDetailModel, SegmentUpdater } from '@/models/datasets'
import { useDebounceFn } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { usePathname } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { createContext, useContext, useContextSelector } from 'use-context-selector'
import Checkbox from '@/app/components/base/checkbox'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import Pagination from '@/app/components/base/pagination'
import { SimpleSelect } from '@/app/components/base/select'
import { ToastContext } from '@/app/components/base/toast'
import NewSegment from '@/app/components/datasets/documents/detail/new-segment'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { ChunkingMode } from '@/models/datasets'
import {
useChildSegmentList,
useChildSegmentListKey,
useChunkListAllKey,
useChunkListDisabledKey,
useChunkListEnabledKey,
useDeleteChildSegment,
useDeleteSegment,
useDisableSegment,
useEnableSegment,
useSegmentList,
useSegmentListKey,
useUpdateChildSegment,
useUpdateSegment,
} from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
import { cn } from '@/utils/classnames'
import { formatNumber } from '@/utils/format'
import { useDocumentContext } from '../context'
import { ProcessStatus } from '../segment-add'
import ChildSegmentDetail from './child-segment-detail'
import ChildSegmentList from './child-segment-list'
import BatchAction from './common/batch-action'
import { DrawerGroup, FullDocModeContent, GeneralModeContent, MenuBar } from './components'
import {
useChildSegmentData,
useModalState,
useSearchFilter,
useSegmentListData,
useSegmentSelection,
} from './hooks'
import {
SegmentListContext,
useSegmentListContext,
} from './segment-list-context'
import FullScreenDrawer from './common/full-screen-drawer'
import DisplayToggle from './display-toggle'
import NewChildSegment from './new-child-segment'
import SegmentCard from './segment-card'
import SegmentDetail from './segment-detail'
import SegmentList from './segment-list'
import StatusItem from './status-item'
import s from './style.module.css'
const DEFAULT_LIMIT = 10
type CurrSegmentType = {
segInfo?: SegmentDetailModel
showModal: boolean
isEditMode?: boolean
}
type CurrChildChunkType = {
childChunkInfo?: ChildChunkDetail
showModal: boolean
}
export type SegmentListContextValue = {
isCollapsed: boolean
fullScreen: boolean
toggleFullScreen: (fullscreen?: boolean) => void
currSegment: CurrSegmentType
currChildChunk: CurrChildChunkType
}
const SegmentListContext = createContext<SegmentListContextValue>({
isCollapsed: true,
fullScreen: false,
toggleFullScreen: noop,
currSegment: { showModal: false },
currChildChunk: { showModal: false },
})
export const useSegmentListContext = (selector: (value: SegmentListContextValue) => any) => {
return useContextSelector(SegmentListContext, selector)
}
type ICompletedProps = {
embeddingAvailable: boolean
showNewSegmentModal: boolean
@ -35,7 +91,6 @@ type ICompletedProps = {
importStatus: ProcessStatus | string | undefined
archived?: boolean
}
/**
* Embedding done, show list of all segments
* Support search and filter
@ -47,219 +102,669 @@ const Completed: FC<ICompletedProps> = ({
importStatus,
archived,
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const pathname = usePathname()
const datasetId = useDocumentContext(s => s.datasetId) || ''
const documentId = useDocumentContext(s => s.documentId) || ''
const docForm = useDocumentContext(s => s.docForm)
const parentMode = useDocumentContext(s => s.parentMode)
// the current segment id and whether to show the modal
const [currSegment, setCurrSegment] = useState<CurrSegmentType>({ showModal: false })
const [currChildChunk, setCurrChildChunk] = useState<CurrChildChunkType>({ showModal: false })
const [currChunkId, setCurrChunkId] = useState('')
// Pagination state
const [currentPage, setCurrentPage] = useState(1)
const [inputValue, setInputValue] = useState<string>('') // the input value
const [searchValue, setSearchValue] = useState<string>('') // the search value
const [selectedStatus, setSelectedStatus] = useState<boolean | 'all'>('all') // the selected status, enabled/disabled/undefined
const [segments, setSegments] = useState<SegmentDetailModel[]>([]) // all segments data
const [childSegments, setChildSegments] = useState<ChildChunkDetail[]>([]) // all child segments data
const [selectedSegmentIds, setSelectedSegmentIds] = useState<string[]>([])
const { eventEmitter } = useEventEmitterContextContext()
const [isCollapsed, setIsCollapsed] = useState(true)
const [currentPage, setCurrentPage] = useState(1) // start from 1
const [limit, setLimit] = useState(DEFAULT_LIMIT)
const [fullScreen, setFullScreen] = useState(false)
const [showNewChildSegmentModal, setShowNewChildSegmentModal] = useState(false)
const [isRegenerationModalOpen, setIsRegenerationModalOpen] = useState(false)
// Search and filter state
const searchFilter = useSearchFilter({
onPageChange: setCurrentPage,
})
const segmentListRef = useRef<HTMLDivElement>(null)
const childSegmentListRef = useRef<HTMLDivElement>(null)
const needScrollToBottom = useRef(false)
const statusList = useRef<Item[]>([
{ value: 'all', name: t('list.index.all', { ns: 'datasetDocuments' }) },
{ value: 0, name: t('list.status.disabled', { ns: 'datasetDocuments' }) },
{ value: 1, name: t('list.status.enabled', { ns: 'datasetDocuments' }) },
])
// Modal state
const modalState = useModalState({
onNewSegmentModalChange,
})
const { run: handleSearch } = useDebounceFn(() => {
setSearchValue(inputValue)
setCurrentPage(1)
}, { wait: 500 })
// Selection state (need segments first, so we use a placeholder initially)
const [segmentsForSelection, setSegmentsForSelection] = useState<string[]>([])
const handleInputChange = (value: string) => {
setInputValue(value)
handleSearch()
}
// Invalidation hooks for child segment data
const onChangeStatus = ({ value }: Item) => {
setSelectedStatus(value === 'all' ? 'all' : !!value)
setCurrentPage(1)
}
const isFullDocMode = useMemo(() => {
return docForm === ChunkingMode.parentChild && parentMode === 'full-doc'
}, [docForm, parentMode])
const { isLoading: isLoadingSegmentList, data: segmentListData } = useSegmentList(
{
datasetId,
documentId,
params: {
page: isFullDocMode ? 1 : currentPage,
limit: isFullDocMode ? 10 : limit,
keyword: isFullDocMode ? '' : searchValue,
enabled: selectedStatus,
},
},
)
const invalidSegmentList = useInvalid(useSegmentListKey)
useEffect(() => {
if (segmentListData) {
setSegments(segmentListData.data || [])
const totalPages = segmentListData.total_pages
if (totalPages < currentPage)
setCurrentPage(totalPages === 0 ? 1 : totalPages)
}
}, [segmentListData])
useEffect(() => {
if (segmentListRef.current && needScrollToBottom.current) {
segmentListRef.current.scrollTo({ top: segmentListRef.current.scrollHeight, behavior: 'smooth' })
needScrollToBottom.current = false
}
}, [segments])
const { isLoading: isLoadingChildSegmentList, data: childChunkListData } = useChildSegmentList(
{
datasetId,
documentId,
segmentId: segments[0]?.id || '',
params: {
page: currentPage === 0 ? 1 : currentPage,
limit,
keyword: searchValue,
},
},
!isFullDocMode || segments.length === 0,
)
const invalidChildSegmentList = useInvalid(useChildSegmentListKey)
useEffect(() => {
if (childSegmentListRef.current && needScrollToBottom.current) {
childSegmentListRef.current.scrollTo({ top: childSegmentListRef.current.scrollHeight, behavior: 'smooth' })
needScrollToBottom.current = false
}
}, [childSegments])
useEffect(() => {
if (childChunkListData) {
setChildSegments(childChunkListData.data || [])
const totalPages = childChunkListData.total_pages
if (totalPages < currentPage)
setCurrentPage(totalPages === 0 ? 1 : totalPages)
}
}, [childChunkListData])
const resetList = useCallback(() => {
setSelectedSegmentIds([])
invalidSegmentList()
}, [invalidSegmentList])
const resetChildList = useCallback(() => {
invalidChildSegmentList()
}, [invalidChildSegmentList])
const onClickCard = (detail: SegmentDetailModel, isEditMode = false) => {
setCurrSegment({ segInfo: detail, showModal: true, isEditMode })
}
const onCloseSegmentDetail = useCallback(() => {
setCurrSegment({ showModal: false })
setFullScreen(false)
}, [])
const onCloseNewSegmentModal = useCallback(() => {
onNewSegmentModalChange(false)
setFullScreen(false)
}, [onNewSegmentModalChange])
const onCloseNewChildChunkModal = useCallback(() => {
setShowNewChildSegmentModal(false)
setFullScreen(false)
}, [])
const { mutateAsync: enableSegment } = useEnableSegment()
const { mutateAsync: disableSegment } = useDisableSegment()
const invalidChunkListAll = useInvalid(useChunkListAllKey)
const invalidChunkListEnabled = useInvalid(useChunkListEnabledKey)
const invalidChunkListDisabled = useInvalid(useChunkListDisabledKey)
const refreshChunkListDataWithDetailChanged = useCallback(() => {
const refreshMap: Record<string, () => void> = {
all: () => {
const refreshChunkListWithStatusChanged = useCallback(() => {
switch (selectedStatus) {
case 'all':
invalidChunkListDisabled()
invalidChunkListEnabled()
},
true: () => {
invalidChunkListAll()
invalidChunkListDisabled()
},
false: () => {
invalidChunkListAll()
invalidChunkListEnabled()
},
break
default:
invalidSegmentList()
}
refreshMap[String(searchFilter.selectedStatus)]?.()
}, [searchFilter.selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidChunkListAll])
}, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidSegmentList])
// Segment list data
const segmentListDataHook = useSegmentListData({
searchValue: searchFilter.searchValue,
selectedStatus: searchFilter.selectedStatus,
selectedSegmentIds: segmentsForSelection,
importStatus,
currentPage,
limit,
onCloseSegmentDetail: modalState.onCloseSegmentDetail,
clearSelection: () => setSegmentsForSelection([]),
})
const onChangeSwitch = useCallback(async (enable: boolean, segId?: string) => {
const operationApi = enable ? enableSegment : disableSegment
await operationApi({ datasetId, documentId, segmentIds: segId ? [segId] : selectedSegmentIds }, {
onSuccess: () => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
for (const seg of segments) {
if (segId ? seg.id === segId : selectedSegmentIds.includes(seg.id))
seg.enabled = enable
}
setSegments([...segments])
refreshChunkListWithStatusChanged()
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
})
}, [datasetId, documentId, selectedSegmentIds, segments, disableSegment, enableSegment, t, notify, refreshChunkListWithStatusChanged])
// Selection state (with actual segments)
const selectionState = useSegmentSelection(segmentListDataHook.segments)
const { mutateAsync: deleteSegment } = useDeleteSegment()
// Sync selection state for segment list data hook
useMemo(() => {
setSegmentsForSelection(selectionState.selectedSegmentIds)
}, [selectionState.selectedSegmentIds])
const onDelete = useCallback(async (segId?: string) => {
await deleteSegment({ datasetId, documentId, segmentIds: segId ? [segId] : selectedSegmentIds }, {
onSuccess: () => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
resetList()
if (!segId)
setSelectedSegmentIds([])
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
})
}, [datasetId, documentId, selectedSegmentIds, deleteSegment, resetList, t, notify])
// Child segment data
const childSegmentDataHook = useChildSegmentData({
searchValue: searchFilter.searchValue,
currentPage,
limit,
segments: segmentListDataHook.segments,
currChunkId: modalState.currChunkId,
isFullDocMode: segmentListDataHook.isFullDocMode,
onCloseChildSegmentDetail: modalState.onCloseChildSegmentDetail,
refreshChunkListDataWithDetailChanged,
updateSegmentInCache: segmentListDataHook.updateSegmentInCache,
})
const { mutateAsync: updateSegment } = useUpdateSegment()
// Compute total for pagination
const paginationTotal = useMemo(() => {
if (segmentListDataHook.isFullDocMode)
return childSegmentDataHook.childChunkListData?.total || 0
return segmentListDataHook.segmentListData?.total || 0
}, [segmentListDataHook.isFullDocMode, childSegmentDataHook.childChunkListData, segmentListDataHook.segmentListData])
const refreshChunkListDataWithDetailChanged = useCallback(() => {
switch (selectedStatus) {
case 'all':
invalidChunkListDisabled()
invalidChunkListEnabled()
break
case true:
invalidChunkListAll()
invalidChunkListDisabled()
break
case false:
invalidChunkListAll()
invalidChunkListEnabled()
break
}
}, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidChunkListAll])
// Handle page change
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page + 1)
const handleUpdateSegment = useCallback(async (
segmentId: string,
question: string,
answer: string,
keywords: string[],
attachments: FileEntity[],
needRegenerate = false,
) => {
const params: SegmentUpdater = { content: '', attachment_ids: [] }
if (docForm === ChunkingMode.qa) {
if (!question.trim())
return notify({ type: 'error', message: t('segment.questionEmpty', { ns: 'datasetDocuments' }) })
if (!answer.trim())
return notify({ type: 'error', message: t('segment.answerEmpty', { ns: 'datasetDocuments' }) })
params.content = question
params.answer = answer
}
else {
if (!question.trim())
return notify({ type: 'error', message: t('segment.contentEmpty', { ns: 'datasetDocuments' }) })
params.content = question
}
if (keywords.length)
params.keywords = keywords
if (attachments.length) {
const notAllUploaded = attachments.some(item => !item.uploadedId)
if (notAllUploaded)
return notify({ type: 'error', message: t('segment.allFilesUploaded', { ns: 'datasetDocuments' }) })
params.attachment_ids = attachments.map(item => item.uploadedId!)
}
if (needRegenerate)
params.regenerate_child_chunks = needRegenerate
eventEmitter?.emit('update-segment')
await updateSegment({ datasetId, documentId, segmentId, body: params }, {
onSuccess(res) {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
if (!needRegenerate)
onCloseSegmentDetail()
for (const seg of segments) {
if (seg.id === segmentId) {
seg.answer = res.data.answer
seg.content = res.data.content
seg.sign_content = res.data.sign_content
seg.keywords = res.data.keywords
seg.attachments = res.data.attachments
seg.word_count = res.data.word_count
seg.hit_count = res.data.hit_count
seg.enabled = res.data.enabled
seg.updated_at = res.data.updated_at
seg.child_chunks = res.data.child_chunks
}
}
setSegments([...segments])
refreshChunkListDataWithDetailChanged()
eventEmitter?.emit('update-segment-success')
},
onSettled() {
eventEmitter?.emit('update-segment-done')
},
})
}, [segments, datasetId, documentId, updateSegment, docForm, notify, eventEmitter, onCloseSegmentDetail, refreshChunkListDataWithDetailChanged, t])
useEffect(() => {
resetList()
}, [pathname])
useEffect(() => {
if (importStatus === ProcessStatus.COMPLETED)
resetList()
}, [importStatus])
const onCancelBatchOperation = useCallback(() => {
setSelectedSegmentIds([])
}, [])
// Context value
const onSelected = useCallback((segId: string) => {
setSelectedSegmentIds(prev =>
prev.includes(segId)
? prev.filter(id => id !== segId)
: [...prev, segId],
)
}, [])
const isAllSelected = useMemo(() => {
return segments.length > 0 && segments.every(seg => selectedSegmentIds.includes(seg.id))
}, [segments, selectedSegmentIds])
const isSomeSelected = useMemo(() => {
return segments.some(seg => selectedSegmentIds.includes(seg.id))
}, [segments, selectedSegmentIds])
const onSelectedAll = useCallback(() => {
setSelectedSegmentIds((prev) => {
const currentAllSegIds = segments.map(seg => seg.id)
const prevSelectedIds = prev.filter(item => !currentAllSegIds.includes(item))
return [...prevSelectedIds, ...(isAllSelected ? [] : currentAllSegIds)]
})
}, [segments, isAllSelected])
const totalText = useMemo(() => {
const isSearch = searchValue !== '' || selectedStatus !== 'all'
if (!isSearch) {
const total = segmentListData?.total ? formatNumber(segmentListData.total) : '--'
const count = total === '--' ? 0 : segmentListData!.total
const translationKey = (docForm === ChunkingMode.parentChild && parentMode === 'paragraph')
? 'segment.parentChunks' as const
: 'segment.chunks' as const
return `${total} ${t(translationKey, { ns: 'datasetDocuments', count })}`
}
else {
const total = typeof segmentListData?.total === 'number' ? formatNumber(segmentListData.total) : 0
const count = segmentListData?.total || 0
return `${total} ${t('segment.searchResults', { ns: 'datasetDocuments', count })}`
}
}, [segmentListData, docForm, parentMode, searchValue, selectedStatus, t])
const toggleFullScreen = useCallback(() => {
setFullScreen(!fullScreen)
}, [fullScreen])
const toggleCollapsed = useCallback(() => {
setIsCollapsed(prev => !prev)
}, [])
const viewNewlyAddedChunk = useCallback(async () => {
const totalPages = segmentListData?.total_pages || 0
const total = segmentListData?.total || 0
const newPage = Math.ceil((total + 1) / limit)
needScrollToBottom.current = true
if (newPage > totalPages) {
setCurrentPage(totalPages + 1)
}
else {
resetList()
if (currentPage !== totalPages)
setCurrentPage(totalPages)
}
}, [segmentListData, limit, currentPage, resetList])
const { mutateAsync: deleteChildSegment } = useDeleteChildSegment()
const onDeleteChildChunk = useCallback(async (segmentId: string, childChunkId: string) => {
await deleteChildSegment(
{ datasetId, documentId, segmentId, childChunkId },
{
onSuccess: () => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
if (parentMode === 'paragraph')
resetList()
else
resetChildList()
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
},
)
}, [datasetId, documentId, parentMode, deleteChildSegment, resetList, resetChildList, t, notify])
const handleAddNewChildChunk = useCallback((parentChunkId: string) => {
setShowNewChildSegmentModal(true)
setCurrChunkId(parentChunkId)
}, [])
const onSaveNewChildChunk = useCallback((newChildChunk?: ChildChunkDetail) => {
if (parentMode === 'paragraph') {
for (const seg of segments) {
if (seg.id === currChunkId)
seg.child_chunks?.push(newChildChunk!)
}
setSegments([...segments])
refreshChunkListDataWithDetailChanged()
}
else {
resetChildList()
}
}, [parentMode, currChunkId, segments, refreshChunkListDataWithDetailChanged, resetChildList])
const viewNewlyAddedChildChunk = useCallback(() => {
const totalPages = childChunkListData?.total_pages || 0
const total = childChunkListData?.total || 0
const newPage = Math.ceil((total + 1) / limit)
needScrollToBottom.current = true
if (newPage > totalPages) {
setCurrentPage(totalPages + 1)
}
else {
resetChildList()
if (currentPage !== totalPages)
setCurrentPage(totalPages)
}
}, [childChunkListData, limit, currentPage, resetChildList])
const onClickSlice = useCallback((detail: ChildChunkDetail) => {
setCurrChildChunk({ childChunkInfo: detail, showModal: true })
setCurrChunkId(detail.segment_id)
}, [])
const onCloseChildSegmentDetail = useCallback(() => {
setCurrChildChunk({ showModal: false })
setFullScreen(false)
}, [])
const { mutateAsync: updateChildSegment } = useUpdateChildSegment()
const handleUpdateChildChunk = useCallback(async (
segmentId: string,
childChunkId: string,
content: string,
) => {
const params: SegmentUpdater = { content: '' }
if (!content.trim())
return notify({ type: 'error', message: t('segment.contentEmpty', { ns: 'datasetDocuments' }) })
params.content = content
eventEmitter?.emit('update-child-segment')
await updateChildSegment({ datasetId, documentId, segmentId, childChunkId, body: params }, {
onSuccess: (res) => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
onCloseChildSegmentDetail()
if (parentMode === 'paragraph') {
for (const seg of segments) {
if (seg.id === segmentId) {
for (const childSeg of seg.child_chunks!) {
if (childSeg.id === childChunkId) {
childSeg.content = res.data.content
childSeg.type = res.data.type
childSeg.word_count = res.data.word_count
childSeg.updated_at = res.data.updated_at
}
}
}
}
setSegments([...segments])
refreshChunkListDataWithDetailChanged()
}
else {
resetChildList()
}
},
onSettled: () => {
eventEmitter?.emit('update-child-segment-done')
},
})
}, [segments, datasetId, documentId, parentMode, updateChildSegment, notify, eventEmitter, onCloseChildSegmentDetail, refreshChunkListDataWithDetailChanged, resetChildList, t])
const onClearFilter = useCallback(() => {
setInputValue('')
setSearchValue('')
setSelectedStatus('all')
setCurrentPage(1)
}, [])
const selectDefaultValue = useMemo(() => {
if (selectedStatus === 'all')
return 'all'
return selectedStatus ? 1 : 0
}, [selectedStatus])
const contextValue = useMemo<SegmentListContextValue>(() => ({
isCollapsed: modalState.isCollapsed,
fullScreen: modalState.fullScreen,
toggleFullScreen: modalState.toggleFullScreen,
currSegment: modalState.currSegment,
currChildChunk: modalState.currChildChunk,
}), [
modalState.isCollapsed,
modalState.fullScreen,
modalState.toggleFullScreen,
modalState.currSegment,
modalState.currChildChunk,
])
isCollapsed,
fullScreen,
toggleFullScreen,
currSegment,
currChildChunk,
}), [isCollapsed, fullScreen, toggleFullScreen, currSegment, currChildChunk])
return (
<SegmentListContext.Provider value={contextValue}>
{/* Menu Bar */}
{!segmentListDataHook.isFullDocMode && (
<MenuBar
isAllSelected={selectionState.isAllSelected}
isSomeSelected={selectionState.isSomeSelected}
onSelectedAll={selectionState.onSelectedAll}
isLoading={segmentListDataHook.isLoadingSegmentList}
totalText={segmentListDataHook.totalText}
statusList={searchFilter.statusList}
selectDefaultValue={searchFilter.selectDefaultValue}
onChangeStatus={searchFilter.onChangeStatus}
inputValue={searchFilter.inputValue}
onInputChange={searchFilter.handleInputChange}
isCollapsed={modalState.isCollapsed}
toggleCollapsed={modalState.toggleCollapsed}
/>
{!isFullDocMode && (
<div className={s.docSearchWrapper}>
<Checkbox
className="shrink-0"
checked={isAllSelected}
indeterminate={!isAllSelected && isSomeSelected}
onCheck={onSelectedAll}
disabled={isLoadingSegmentList}
/>
<div className="system-sm-semibold-uppercase flex-1 pl-5 text-text-secondary">{totalText}</div>
<SimpleSelect
onSelect={onChangeStatus}
items={statusList.current}
defaultValue={selectDefaultValue}
className={s.select}
wrapperClassName="h-fit mr-2"
optionWrapClassName="w-[160px]"
optionClassName="p-0"
renderOption={({ item, selected }) => <StatusItem item={item} selected={selected} />}
notClearable
/>
<Input
showLeftIcon
showClearIcon
wrapperClassName="!w-52"
value={inputValue}
onChange={e => handleInputChange(e.target.value)}
onClear={() => handleInputChange('')}
/>
<Divider type="vertical" className="mx-3 h-3.5" />
<DisplayToggle isCollapsed={isCollapsed} toggleCollapsed={toggleCollapsed} />
</div>
)}
{/* Segment list */}
{segmentListDataHook.isFullDocMode
? (
<FullDocModeContent
segments={segmentListDataHook.segments}
childSegments={childSegmentDataHook.childSegments}
isLoadingSegmentList={segmentListDataHook.isLoadingSegmentList}
isLoadingChildSegmentList={childSegmentDataHook.isLoadingChildSegmentList}
currSegmentId={modalState.currSegment?.segInfo?.id}
onClickCard={modalState.onClickCard}
onDeleteChildChunk={childSegmentDataHook.onDeleteChildChunk}
handleInputChange={searchFilter.handleInputChange}
handleAddNewChildChunk={modalState.handleAddNewChildChunk}
onClickSlice={modalState.onClickSlice}
archived={archived}
childChunkTotal={childSegmentDataHook.childChunkListData?.total || 0}
inputValue={searchFilter.inputValue}
onClearFilter={searchFilter.onClearFilter}
/>
)
: (
<GeneralModeContent
segmentListRef={segmentListDataHook.segmentListRef}
embeddingAvailable={embeddingAvailable}
isLoadingSegmentList={segmentListDataHook.isLoadingSegmentList}
segments={segmentListDataHook.segments}
selectedSegmentIds={selectionState.selectedSegmentIds}
onSelected={selectionState.onSelected}
onChangeSwitch={segmentListDataHook.onChangeSwitch}
onDelete={segmentListDataHook.onDelete}
onClickCard={modalState.onClickCard}
archived={archived}
onDeleteChildChunk={childSegmentDataHook.onDeleteChildChunk}
handleAddNewChildChunk={modalState.handleAddNewChildChunk}
onClickSlice={modalState.onClickSlice}
onClearFilter={searchFilter.onClearFilter}
/>
)}
{
isFullDocMode
? (
<div className={cn(
'flex grow flex-col overflow-x-hidden',
(isLoadingSegmentList || isLoadingChildSegmentList) ? 'overflow-y-hidden' : 'overflow-y-auto',
)}
>
<SegmentCard
detail={segments[0]}
onClick={() => onClickCard(segments[0])}
loading={isLoadingSegmentList}
focused={{
segmentIndex: currSegment?.segInfo?.id === segments[0]?.id,
segmentContent: currSegment?.segInfo?.id === segments[0]?.id,
}}
/>
<ChildSegmentList
parentChunkId={segments[0]?.id}
onDelete={onDeleteChildChunk}
childChunks={childSegments}
handleInputChange={handleInputChange}
handleAddNewChildChunk={handleAddNewChildChunk}
onClickSlice={onClickSlice}
enabled={!archived}
total={childChunkListData?.total || 0}
inputValue={inputValue}
onClearFilter={onClearFilter}
isLoading={isLoadingSegmentList || isLoadingChildSegmentList}
/>
</div>
)
: (
<SegmentList
ref={segmentListRef}
embeddingAvailable={embeddingAvailable}
isLoading={isLoadingSegmentList}
items={segments}
selectedSegmentIds={selectedSegmentIds}
onSelected={onSelected}
onChangeSwitch={onChangeSwitch}
onDelete={onDelete}
onClick={onClickCard}
archived={archived}
onDeleteChildChunk={onDeleteChildChunk}
handleAddNewChildChunk={handleAddNewChildChunk}
onClickSlice={onClickSlice}
onClearFilter={onClearFilter}
/>
)
}
{/* Pagination */}
<Divider type="horizontal" className="mx-6 my-0 h-px w-auto bg-divider-subtle" />
<Pagination
current={currentPage - 1}
onChange={handlePageChange}
total={paginationTotal}
onChange={cur => setCurrentPage(cur + 1)}
total={(isFullDocMode ? childChunkListData?.total : segmentListData?.total) || 0}
limit={limit}
onLimitChange={setLimit}
className={segmentListDataHook.isFullDocMode ? 'px-3' : ''}
onLimitChange={limit => setLimit(limit)}
className={isFullDocMode ? 'px-3' : ''}
/>
{/* Drawer Group - only render when docForm is available */}
{docForm && (
<DrawerGroup
currSegment={modalState.currSegment}
onCloseSegmentDetail={modalState.onCloseSegmentDetail}
onUpdateSegment={segmentListDataHook.handleUpdateSegment}
isRegenerationModalOpen={modalState.isRegenerationModalOpen}
setIsRegenerationModalOpen={modalState.setIsRegenerationModalOpen}
showNewSegmentModal={showNewSegmentModal}
onCloseNewSegmentModal={modalState.onCloseNewSegmentModal}
onSaveNewSegment={segmentListDataHook.resetList}
viewNewlyAddedChunk={segmentListDataHook.viewNewlyAddedChunk}
currChildChunk={modalState.currChildChunk}
currChunkId={modalState.currChunkId}
onCloseChildSegmentDetail={modalState.onCloseChildSegmentDetail}
onUpdateChildChunk={childSegmentDataHook.handleUpdateChildChunk}
showNewChildSegmentModal={modalState.showNewChildSegmentModal}
onCloseNewChildChunkModal={modalState.onCloseNewChildChunkModal}
onSaveNewChildChunk={childSegmentDataHook.onSaveNewChildChunk}
viewNewlyAddedChildChunk={childSegmentDataHook.viewNewlyAddedChildChunk}
fullScreen={modalState.fullScreen}
{/* Edit or view segment detail */}
<FullScreenDrawer
isOpen={currSegment.showModal}
fullScreen={fullScreen}
onClose={onCloseSegmentDetail}
showOverlay={false}
needCheckChunks
modal={isRegenerationModalOpen}
>
<SegmentDetail
key={currSegment.segInfo?.id}
segInfo={currSegment.segInfo ?? { id: '' }}
docForm={docForm}
isEditMode={currSegment.isEditMode}
onUpdate={handleUpdateSegment}
onCancel={onCloseSegmentDetail}
onModalStateChange={setIsRegenerationModalOpen}
/>
)}
</FullScreenDrawer>
{/* Create New Segment */}
<FullScreenDrawer
isOpen={showNewSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewSegmentModal}
modal
>
<NewSegment
docForm={docForm}
onCancel={onCloseNewSegmentModal}
onSave={resetList}
viewNewlyAddedChunk={viewNewlyAddedChunk}
/>
</FullScreenDrawer>
{/* Edit or view child segment detail */}
<FullScreenDrawer
isOpen={currChildChunk.showModal}
fullScreen={fullScreen}
onClose={onCloseChildSegmentDetail}
showOverlay={false}
needCheckChunks
>
<ChildSegmentDetail
key={currChildChunk.childChunkInfo?.id}
chunkId={currChunkId}
childChunkInfo={currChildChunk.childChunkInfo ?? { id: '' }}
docForm={docForm}
onUpdate={handleUpdateChildChunk}
onCancel={onCloseChildSegmentDetail}
/>
</FullScreenDrawer>
{/* Create New Child Segment */}
<FullScreenDrawer
isOpen={showNewChildSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewChildChunkModal}
modal
>
<NewChildSegment
chunkId={currChunkId}
onCancel={onCloseNewChildChunkModal}
onSave={onSaveNewChildChunk}
viewNewlyAddedChildChunk={viewNewlyAddedChildChunk}
/>
</FullScreenDrawer>
{/* Batch Action Buttons */}
{selectionState.selectedSegmentIds.length > 0 && (
{selectedSegmentIds.length > 0 && (
<BatchAction
className="absolute bottom-16 left-0 z-20"
selectedIds={selectionState.selectedSegmentIds}
onBatchEnable={() => segmentListDataHook.onChangeSwitch(true, '')}
onBatchDisable={() => segmentListDataHook.onChangeSwitch(false, '')}
onBatchDelete={() => segmentListDataHook.onDelete('')}
onCancel={selectionState.onCancelBatchOperation}
selectedIds={selectedSegmentIds}
onBatchEnable={onChangeSwitch.bind(null, true, '')}
onBatchDisable={onChangeSwitch.bind(null, false, '')}
onBatchDelete={onDelete.bind(null, '')}
onCancel={onCancelBatchOperation}
/>
)}
</SegmentListContext.Provider>
)
}
export { useSegmentListContext }
export type { SegmentListContextValue }
export default Completed

View File

@ -1,34 +0,0 @@
import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
import { noop } from 'es-toolkit/function'
import { createContext, useContextSelector } from 'use-context-selector'
export type CurrSegmentType = {
segInfo?: SegmentDetailModel
showModal: boolean
isEditMode?: boolean
}
export type CurrChildChunkType = {
childChunkInfo?: ChildChunkDetail
showModal: boolean
}
export type SegmentListContextValue = {
isCollapsed: boolean
fullScreen: boolean
toggleFullScreen: () => void
currSegment: CurrSegmentType
currChildChunk: CurrChildChunkType
}
export const SegmentListContext = createContext<SegmentListContextValue>({
isCollapsed: true,
fullScreen: false,
toggleFullScreen: noop,
currSegment: { showModal: false },
currChildChunk: { showModal: false },
})
export const useSegmentListContext = <T>(selector: (value: SegmentListContextValue) => T): T => {
return useContextSelector(SegmentListContext, selector)
}

View File

@ -1,93 +0,0 @@
import { render } from '@testing-library/react'
import FullDocListSkeleton from './full-doc-list-skeleton'
describe('FullDocListSkeleton', () => {
describe('Rendering', () => {
it('should render the skeleton container', () => {
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('flex', 'w-full', 'grow', 'flex-col')
})
it('should render 15 Slice components', () => {
const { container } = render(<FullDocListSkeleton />)
// Each Slice has a specific structure with gap-y-1
const slices = container.querySelectorAll('.gap-y-1')
expect(slices.length).toBe(15)
})
it('should render mask overlay', () => {
const { container } = render(<FullDocListSkeleton />)
const maskOverlay = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(maskOverlay).toBeInTheDocument()
})
it('should have overflow hidden', () => {
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('overflow-y-hidden')
})
})
describe('Slice Component', () => {
it('should render slice with correct structure', () => {
const { container } = render(<FullDocListSkeleton />)
// Each slice has two rows
const sliceRows = container.querySelectorAll('.bg-state-base-hover')
expect(sliceRows.length).toBeGreaterThan(0)
})
it('should render label placeholder in each slice', () => {
const { container } = render(<FullDocListSkeleton />)
// Label placeholder has specific width
const labelPlaceholders = container.querySelectorAll('.w-\\[30px\\]')
expect(labelPlaceholders.length).toBe(15) // One per slice
})
it('should render content placeholder in each slice', () => {
const { container } = render(<FullDocListSkeleton />)
// Content placeholder has 2/3 width
const contentPlaceholders = container.querySelectorAll('.w-2\\/3')
expect(contentPlaceholders.length).toBe(15) // One per slice
})
})
describe('Memoization', () => {
it('should be memoized', () => {
const { rerender, container } = render(<FullDocListSkeleton />)
const initialContent = container.innerHTML
// Rerender should produce same output
rerender(<FullDocListSkeleton />)
expect(container.innerHTML).toBe(initialContent)
})
})
describe('Styling', () => {
it('should have correct z-index layering', () => {
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('z-10')
const maskOverlay = container.querySelector('.z-20')
expect(maskOverlay).toBeInTheDocument()
})
it('should have gap between slices', () => {
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('gap-y-3')
})
})
})

View File

@ -0,0 +1,7 @@
'use client'
import { Agentation as AgentationComponent } from 'agentation'
export const Agentation = () => {
return <AgentationComponent />
}

View File

@ -0,0 +1,21 @@
'use client'
import { lazy, Suspense } from 'react'
import { IS_DEV } from '@/config'
const Agentation = lazy(() =>
import('./agentation').then(module => ({
default: module.Agentation,
})),
)
export const AgentationLoader = () => {
if (!IS_DEV)
return null
return (
<Suspense fallback={null}>
<Agentation />
</Suspense>
)
}

View File

@ -1,43 +1,34 @@
import type { ComponentType, FC } from 'react'
import type { FC } from 'react'
import type { ModelProvider } from '../declarations'
import type { Plugin } from '@/app/components/plugins/types'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AnthropicShortLight, Deepseek, Gemini, Grok, OpenaiSmall, Tongyi } from '@/app/components/base/icons/src/public/llm'
import { OpenaiSmall } from '@/app/components/base/icons/src/public/llm'
import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useTimestamp from '@/hooks/use-timestamp'
import { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { cn } from '@/utils/classnames'
import { formatNumber } from '@/utils/format'
import { PreferredProviderTypeEnum } from '../declarations'
import { useMarketplaceAllPlugins } from '../hooks'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap } from '../utils'
import { modelNameMap, ModelProviderQuotaGetPaid } from '../utils'
// Icon map for each provider - single source of truth for provider icons
const providerIconMap: Record<ModelProviderQuotaGetPaid, ComponentType<{ className?: string }>> = {
[ModelProviderQuotaGetPaid.OPENAI]: OpenaiSmall,
[ModelProviderQuotaGetPaid.ANTHROPIC]: AnthropicShortLight,
[ModelProviderQuotaGetPaid.GEMINI]: Gemini,
[ModelProviderQuotaGetPaid.X]: Grok,
[ModelProviderQuotaGetPaid.DEEPSEEK]: Deepseek,
[ModelProviderQuotaGetPaid.TONGYI]: Tongyi,
}
// Derive allProviders from the shared constant
const allProviders = MODEL_PROVIDER_QUOTA_GET_PAID.map(key => ({
key,
Icon: providerIconMap[key],
}))
const allProviders = [
{ key: ModelProviderQuotaGetPaid.OPENAI, Icon: OpenaiSmall },
// { key: ModelProviderQuotaGetPaid.ANTHROPIC, Icon: AnthropicShortLight },
// { key: ModelProviderQuotaGetPaid.GEMINI, Icon: Gemini },
// { key: ModelProviderQuotaGetPaid.X, Icon: Grok },
// { key: ModelProviderQuotaGetPaid.DEEPSEEK, Icon: Deepseek },
// { key: ModelProviderQuotaGetPaid.TONGYI, Icon: Tongyi },
] as const
// Map provider key to plugin ID
// provider key format: langgenius/provider/model, plugin ID format: langgenius/provider
const providerKeyToPluginId: Record<ModelProviderQuotaGetPaid, string> = {
const providerKeyToPluginId: Record<string, string> = {
[ModelProviderQuotaGetPaid.OPENAI]: 'langgenius/openai',
[ModelProviderQuotaGetPaid.ANTHROPIC]: 'langgenius/anthropic',
[ModelProviderQuotaGetPaid.GEMINI]: 'langgenius/gemini',
@ -56,7 +47,6 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
}) => {
const { t } = useTranslation()
const { currentWorkspace } = useAppContext()
const { trial_models } = useGlobalPublicStore(s => s.systemFeatures)
const credits = Math.max((currentWorkspace.trial_credits - currentWorkspace.trial_credits_used) || 0, 0)
const providerMap = useMemo(() => new Map(
providers.map(p => [p.provider, p.preferred_provider_type]),
@ -72,7 +62,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
}] = useBoolean(false)
const selectedPluginIdRef = useRef<string | null>(null)
const handleIconClick = useCallback((key: ModelProviderQuotaGetPaid) => {
const handleIconClick = useCallback((key: string) => {
const providerType = providerMap.get(key)
if (!providerType && allPlugins) {
const pluginId = providerKeyToPluginId[key]
@ -107,7 +97,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
<div className={cn('my-2 min-w-[72px] shrink-0 rounded-xl border-[0.5px] pb-2.5 pl-4 pr-2.5 pt-3 shadow-xs', credits <= 0 ? 'border-state-destructive-border hover:bg-state-destructive-hover' : 'border-components-panel-border bg-third-party-model-bg-default')}>
<div className="system-xs-medium-uppercase mb-2 flex h-4 items-center text-text-tertiary">
{t('modelProvider.quota', { ns: 'common' })}
<Tooltip popupContent={t('modelProvider.card.tip', { ns: 'common', modelNames: trial_models.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', ') })} />
<Tooltip popupContent={t('modelProvider.card.tip', { ns: 'common' })} />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-text-tertiary">
@ -129,7 +119,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
: null}
</div>
<div className="flex items-center gap-1">
{allProviders.filter(({ key }) => trial_models.includes(key)).map(({ key, Icon }) => {
{allProviders.map(({ key, Icon }) => {
const providerType = providerMap.get(key)
const usingQuota = providerType === PreferredProviderTypeEnum.system
const getTooltipKey = () => {

View File

@ -1,5 +1,4 @@
import type {
CredentialFormSchemaSelect,
CredentialFormSchemaTextInput,
FormValue,
ModelLoadBalancingConfig,
@ -10,7 +9,6 @@ import {
validateModelLoadBalancingCredentials,
validateModelProvider,
} from '@/service/common'
import { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { ValidatedStatus } from '../key-validator/declarations'
import {
ConfigurationMethodEnum,
@ -19,8 +17,15 @@ import {
ModelTypeEnum,
} from './declarations'
export { ModelProviderQuotaGetPaid } from '@/types/model-provider'
export enum ModelProviderQuotaGetPaid {
ANTHROPIC = 'langgenius/anthropic/anthropic',
OPENAI = 'langgenius/openai/openai',
// AZURE_OPENAI = 'langgenius/azure_openai/azure_openai',
GEMINI = 'langgenius/gemini/google',
X = 'langgenius/x/x',
DEEPSEEK = 'langgenius/deepseek/deepseek',
TONGYI = 'langgenius/tongyi/tongyi',
}
export const MODEL_PROVIDER_QUOTA_GET_PAID = [ModelProviderQuotaGetPaid.ANTHROPIC, ModelProviderQuotaGetPaid.OPENAI, ModelProviderQuotaGetPaid.GEMINI, ModelProviderQuotaGetPaid.X, ModelProviderQuotaGetPaid.DEEPSEEK, ModelProviderQuotaGetPaid.TONGYI]
export const modelNameMap = {
@ -32,7 +37,7 @@ export const modelNameMap = {
[ModelProviderQuotaGetPaid.TONGYI]: 'Tongyi',
}
export const isNullOrUndefined = (value: unknown): value is null | undefined => {
export const isNullOrUndefined = (value: any) => {
return value === undefined || value === null
}
@ -61,9 +66,8 @@ export const validateCredentials = async (predefined: boolean, provider: string,
else
return Promise.resolve({ status: ValidatedStatus.Error, message: res.error || 'error' })
}
catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Unknown error'
return Promise.resolve({ status: ValidatedStatus.Error, message })
catch (e: any) {
return Promise.resolve({ status: ValidatedStatus.Error, message: e.message })
}
}
@ -86,9 +90,8 @@ export const validateLoadBalancingCredentials = async (predefined: boolean, prov
else
return Promise.resolve({ status: ValidatedStatus.Error, message: res.error || 'error' })
}
catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Unknown error'
return Promise.resolve({ status: ValidatedStatus.Error, message })
catch (e: any) {
return Promise.resolve({ status: ValidatedStatus.Error, message: e.message })
}
}
@ -174,7 +177,7 @@ export const modelTypeFormat = (modelType: ModelTypeEnum) => {
return modelType.toLocaleUpperCase()
}
export const genModelTypeFormSchema = (modelTypes: ModelTypeEnum[]): Omit<CredentialFormSchemaSelect, 'name'> => {
export const genModelTypeFormSchema = (modelTypes: ModelTypeEnum[]) => {
return {
type: FormTypeEnum.select,
label: {
@ -195,10 +198,10 @@ export const genModelTypeFormSchema = (modelTypes: ModelTypeEnum[]): Omit<Creden
show_on: [],
}
}),
}
} as any
}
export const genModelNameFormSchema = (model?: Pick<CredentialFormSchemaTextInput, 'label' | 'placeholder'>): Omit<CredentialFormSchemaTextInput, 'name'> => {
export const genModelNameFormSchema = (model?: Pick<CredentialFormSchemaTextInput, 'label' | 'placeholder'>) => {
return {
type: FormTypeEnum.textInput,
label: model?.label || {
@ -212,5 +215,5 @@ export const genModelNameFormSchema = (model?: Pick<CredentialFormSchemaTextInpu
zh_Hans: '请输入模型名称',
en_US: 'Please enter model name',
},
}
} as any
}

View File

@ -1,259 +0,0 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Theme } from '@/types/app'
import IconWithTooltip from './icon-with-tooltip'
// Mock Tooltip component
vi.mock('@/app/components/base/tooltip', () => ({
default: ({
children,
popupContent,
popupClassName,
}: {
children: React.ReactNode
popupContent?: string
popupClassName?: string
}) => (
<div data-testid="tooltip" data-popup-content={popupContent} data-popup-classname={popupClassName}>
{children}
</div>
),
}))
// Mock icon components
const MockLightIcon = ({ className }: { className?: string }) => (
<div data-testid="light-icon" className={className}>Light Icon</div>
)
const MockDarkIcon = ({ className }: { className?: string }) => (
<div data-testid="dark-icon" className={className}>Dark Icon</div>
)
describe('IconWithTooltip', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
})
it('should render Tooltip wrapper', () => {
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
popupContent="Test tooltip"
/>,
)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-popup-content', 'Test tooltip')
})
it('should apply correct popupClassName to Tooltip', () => {
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
const tooltip = screen.getByTestId('tooltip')
expect(tooltip).toHaveAttribute('data-popup-classname')
expect(tooltip.getAttribute('data-popup-classname')).toContain('border-components-panel-border')
})
})
describe('Theme Handling', () => {
it('should render light icon when theme is light', () => {
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
expect(screen.getByTestId('light-icon')).toBeInTheDocument()
expect(screen.queryByTestId('dark-icon')).not.toBeInTheDocument()
})
it('should render dark icon when theme is dark', () => {
render(
<IconWithTooltip
theme={Theme.dark}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
expect(screen.getByTestId('dark-icon')).toBeInTheDocument()
expect(screen.queryByTestId('light-icon')).not.toBeInTheDocument()
})
it('should render light icon when theme is system (not dark)', () => {
render(
<IconWithTooltip
theme={'system' as Theme}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
// When theme is not 'dark', it should use light icon
expect(screen.getByTestId('light-icon')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className to icon', () => {
render(
<IconWithTooltip
className="custom-class"
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
const icon = screen.getByTestId('light-icon')
expect(icon).toHaveClass('custom-class')
})
it('should apply default h-5 w-5 class to icon', () => {
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
const icon = screen.getByTestId('light-icon')
expect(icon).toHaveClass('h-5')
expect(icon).toHaveClass('w-5')
})
it('should merge custom className with default classes', () => {
render(
<IconWithTooltip
className="ml-2"
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
const icon = screen.getByTestId('light-icon')
expect(icon).toHaveClass('h-5')
expect(icon).toHaveClass('w-5')
expect(icon).toHaveClass('ml-2')
})
it('should pass popupContent to Tooltip', () => {
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
popupContent="Custom tooltip content"
/>,
)
expect(screen.getByTestId('tooltip')).toHaveAttribute(
'data-popup-content',
'Custom tooltip content',
)
})
it('should handle undefined popupContent', () => {
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
})
})
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
// The component is exported as React.memo(IconWithTooltip)
expect(IconWithTooltip).toBeDefined()
// Check if it's a memo component
expect(typeof IconWithTooltip).toBe('object')
})
})
describe('Container Structure', () => {
it('should render icon inside flex container', () => {
const { container } = render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
const flexContainer = container.querySelector('.flex.shrink-0.items-center.justify-center')
expect(flexContainer).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty className', () => {
render(
<IconWithTooltip
className=""
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
expect(screen.getByTestId('light-icon')).toBeInTheDocument()
})
it('should handle long popupContent', () => {
const longContent = 'A'.repeat(500)
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
popupContent={longContent}
/>,
)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-popup-content', longContent)
})
it('should handle special characters in popupContent', () => {
const specialContent = '<script>alert("xss")</script> & "quotes"'
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
popupContent={specialContent}
/>,
)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-popup-content', specialContent)
})
})
})

View File

@ -1,205 +0,0 @@
import type { ComponentProps } from 'react'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Theme } from '@/types/app'
import Partner from './partner'
// Mock useTheme hook
const mockUseTheme = vi.fn()
vi.mock('@/hooks/use-theme', () => ({
default: () => mockUseTheme(),
}))
// Mock IconWithTooltip to directly test Partner's behavior
type IconWithTooltipProps = ComponentProps<typeof import('./icon-with-tooltip').default>
const mockIconWithTooltip = vi.fn()
vi.mock('./icon-with-tooltip', () => ({
default: (props: IconWithTooltipProps) => {
mockIconWithTooltip(props)
const { theme, BadgeIconLight, BadgeIconDark, className, popupContent } = props
const isDark = theme === Theme.dark
const Icon = isDark ? BadgeIconDark : BadgeIconLight
return (
<div data-testid="icon-with-tooltip" data-popup-content={popupContent} data-theme={theme}>
<Icon className={className} data-testid={isDark ? 'partner-dark-icon' : 'partner-light-icon'} />
</div>
)
},
}))
// Mock Partner icons
vi.mock('@/app/components/base/icons/src/public/plugins/PartnerDark', () => ({
default: ({ className, ...rest }: { className?: string }) => (
<div data-testid="partner-dark-icon" className={className} {...rest}>PartnerDark</div>
),
}))
vi.mock('@/app/components/base/icons/src/public/plugins/PartnerLight', () => ({
default: ({ className, ...rest }: { className?: string }) => (
<div data-testid="partner-light-icon" className={className} {...rest}>PartnerLight</div>
),
}))
describe('Partner', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTheme.mockReturnValue({ theme: Theme.light })
mockIconWithTooltip.mockClear()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Partner text="Partner Tip" />)
expect(screen.getByTestId('icon-with-tooltip')).toBeInTheDocument()
})
it('should call useTheme hook', () => {
render(<Partner text="Partner" />)
expect(mockUseTheme).toHaveBeenCalled()
})
it('should pass text prop as popupContent to IconWithTooltip', () => {
render(<Partner text="This is a partner" />)
expect(screen.getByTestId('icon-with-tooltip')).toHaveAttribute(
'data-popup-content',
'This is a partner',
)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ popupContent: 'This is a partner' }),
)
})
it('should pass theme from useTheme to IconWithTooltip', () => {
mockUseTheme.mockReturnValue({ theme: Theme.light })
render(<Partner text="Partner" />)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ theme: Theme.light }),
)
})
it('should render light icon in light theme', () => {
mockUseTheme.mockReturnValue({ theme: Theme.light })
render(<Partner text="Partner" />)
expect(screen.getByTestId('partner-light-icon')).toBeInTheDocument()
})
it('should render dark icon in dark theme', () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark })
render(<Partner text="Partner" />)
expect(screen.getByTestId('partner-dark-icon')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass className to IconWithTooltip', () => {
render(<Partner className="custom-class" text="Partner" />)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ className: 'custom-class' }),
)
})
it('should pass correct BadgeIcon components to IconWithTooltip', () => {
render(<Partner text="Partner" />)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({
BadgeIconLight: expect.any(Function),
BadgeIconDark: expect.any(Function),
}),
)
})
})
describe('Theme Handling', () => {
it('should handle light theme correctly', () => {
mockUseTheme.mockReturnValue({ theme: Theme.light })
render(<Partner text="Partner" />)
expect(mockUseTheme).toHaveBeenCalled()
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ theme: Theme.light }),
)
expect(screen.getByTestId('partner-light-icon')).toBeInTheDocument()
})
it('should handle dark theme correctly', () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark })
render(<Partner text="Partner" />)
expect(mockUseTheme).toHaveBeenCalled()
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ theme: Theme.dark }),
)
expect(screen.getByTestId('partner-dark-icon')).toBeInTheDocument()
})
it('should pass updated theme when theme changes', () => {
mockUseTheme.mockReturnValue({ theme: Theme.light })
const { rerender } = render(<Partner text="Partner" />)
expect(mockIconWithTooltip).toHaveBeenLastCalledWith(
expect.objectContaining({ theme: Theme.light }),
)
mockIconWithTooltip.mockClear()
mockUseTheme.mockReturnValue({ theme: Theme.dark })
rerender(<Partner text="Partner" />)
expect(mockIconWithTooltip).toHaveBeenLastCalledWith(
expect.objectContaining({ theme: Theme.dark }),
)
})
})
describe('Edge Cases', () => {
it('should handle empty text', () => {
render(<Partner text="" />)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ popupContent: '' }),
)
})
it('should handle long text', () => {
const longText = 'A'.repeat(500)
render(<Partner text={longText} />)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ popupContent: longText }),
)
})
it('should handle special characters in text', () => {
const specialText = '<script>alert("xss")</script>'
render(<Partner text={specialText} />)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ popupContent: specialText }),
)
})
it('should handle undefined className', () => {
render(<Partner text="Partner" />)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ className: undefined }),
)
})
it('should always call useTheme to get current theme', () => {
render(<Partner text="Partner 1" />)
expect(mockUseTheme).toHaveBeenCalledTimes(1)
mockUseTheme.mockClear()
render(<Partner text="Partner 2" />)
expect(mockUseTheme).toHaveBeenCalledTimes(1)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -1,404 +0,0 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PLUGIN_PAGE_TABS_MAP, useCategories, usePluginPageTabs, useTags } from './hooks'
// Create mock translation function
const mockT = vi.fn((key: string, _options?: Record<string, string>) => {
const translations: Record<string, string> = {
'tags.agent': 'Agent',
'tags.rag': 'RAG',
'tags.search': 'Search',
'tags.image': 'Image',
'tags.videos': 'Videos',
'tags.weather': 'Weather',
'tags.finance': 'Finance',
'tags.design': 'Design',
'tags.travel': 'Travel',
'tags.social': 'Social',
'tags.news': 'News',
'tags.medical': 'Medical',
'tags.productivity': 'Productivity',
'tags.education': 'Education',
'tags.business': 'Business',
'tags.entertainment': 'Entertainment',
'tags.utilities': 'Utilities',
'tags.other': 'Other',
'category.models': 'Models',
'category.tools': 'Tools',
'category.datasources': 'Datasources',
'category.agents': 'Agents',
'category.extensions': 'Extensions',
'category.bundles': 'Bundles',
'category.triggers': 'Triggers',
'categorySingle.model': 'Model',
'categorySingle.tool': 'Tool',
'categorySingle.datasource': 'Datasource',
'categorySingle.agent': 'Agent',
'categorySingle.extension': 'Extension',
'categorySingle.bundle': 'Bundle',
'categorySingle.trigger': 'Trigger',
'menus.plugins': 'Plugins',
'menus.exploreMarketplace': 'Explore Marketplace',
}
return translations[key] || key
})
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: mockT,
}),
}))
describe('useTags', () => {
beforeEach(() => {
vi.clearAllMocks()
mockT.mockClear()
})
describe('Rendering', () => {
it('should return tags array', () => {
const { result } = renderHook(() => useTags())
expect(result.current.tags).toBeDefined()
expect(Array.isArray(result.current.tags)).toBe(true)
expect(result.current.tags.length).toBeGreaterThan(0)
})
it('should call translation function for each tag', () => {
renderHook(() => useTags())
// Verify t() was called for tag translations
expect(mockT).toHaveBeenCalled()
const tagCalls = mockT.mock.calls.filter(call => call[0].startsWith('tags.'))
expect(tagCalls.length).toBeGreaterThan(0)
})
it('should return tags with name and label properties', () => {
const { result } = renderHook(() => useTags())
result.current.tags.forEach((tag) => {
expect(tag).toHaveProperty('name')
expect(tag).toHaveProperty('label')
expect(typeof tag.name).toBe('string')
expect(typeof tag.label).toBe('string')
})
})
it('should return tagsMap object', () => {
const { result } = renderHook(() => useTags())
expect(result.current.tagsMap).toBeDefined()
expect(typeof result.current.tagsMap).toBe('object')
})
})
describe('tagsMap', () => {
it('should map tag name to tag object', () => {
const { result } = renderHook(() => useTags())
expect(result.current.tagsMap.agent).toBeDefined()
expect(result.current.tagsMap.agent.name).toBe('agent')
expect(result.current.tagsMap.agent.label).toBe('Agent')
})
it('should contain all tags from tags array', () => {
const { result } = renderHook(() => useTags())
result.current.tags.forEach((tag) => {
expect(result.current.tagsMap[tag.name]).toBeDefined()
expect(result.current.tagsMap[tag.name]).toEqual(tag)
})
})
})
describe('getTagLabel', () => {
it('should return label for existing tag', () => {
const { result } = renderHook(() => useTags())
// Test existing tags - this covers the branch where tagsMap[name] exists
expect(result.current.getTagLabel('agent')).toBe('Agent')
expect(result.current.getTagLabel('search')).toBe('Search')
})
it('should return name for non-existing tag', () => {
const { result } = renderHook(() => useTags())
// Test non-existing tags - this covers the branch where !tagsMap[name]
expect(result.current.getTagLabel('non-existing')).toBe('non-existing')
expect(result.current.getTagLabel('custom-tag')).toBe('custom-tag')
})
it('should cover both branches of getTagLabel conditional', () => {
const { result } = renderHook(() => useTags())
// Branch 1: tag exists in tagsMap - returns label
const existingTagResult = result.current.getTagLabel('rag')
expect(existingTagResult).toBe('RAG')
// Branch 2: tag does not exist in tagsMap - returns name itself
const nonExistingTagResult = result.current.getTagLabel('unknown-tag-xyz')
expect(nonExistingTagResult).toBe('unknown-tag-xyz')
})
it('should be a function', () => {
const { result } = renderHook(() => useTags())
expect(typeof result.current.getTagLabel).toBe('function')
})
it('should return correct labels for all predefined tags', () => {
const { result } = renderHook(() => useTags())
// Test all predefined tags
expect(result.current.getTagLabel('rag')).toBe('RAG')
expect(result.current.getTagLabel('image')).toBe('Image')
expect(result.current.getTagLabel('videos')).toBe('Videos')
expect(result.current.getTagLabel('weather')).toBe('Weather')
expect(result.current.getTagLabel('finance')).toBe('Finance')
expect(result.current.getTagLabel('design')).toBe('Design')
expect(result.current.getTagLabel('travel')).toBe('Travel')
expect(result.current.getTagLabel('social')).toBe('Social')
expect(result.current.getTagLabel('news')).toBe('News')
expect(result.current.getTagLabel('medical')).toBe('Medical')
expect(result.current.getTagLabel('productivity')).toBe('Productivity')
expect(result.current.getTagLabel('education')).toBe('Education')
expect(result.current.getTagLabel('business')).toBe('Business')
expect(result.current.getTagLabel('entertainment')).toBe('Entertainment')
expect(result.current.getTagLabel('utilities')).toBe('Utilities')
expect(result.current.getTagLabel('other')).toBe('Other')
})
it('should handle empty string tag name', () => {
const { result } = renderHook(() => useTags())
// Empty string tag doesn't exist, so should return the empty string
expect(result.current.getTagLabel('')).toBe('')
})
it('should handle special characters in tag name', () => {
const { result } = renderHook(() => useTags())
expect(result.current.getTagLabel('tag-with-dashes')).toBe('tag-with-dashes')
expect(result.current.getTagLabel('tag_with_underscores')).toBe('tag_with_underscores')
})
})
describe('Memoization', () => {
it('should return same structure on re-render', () => {
const { result, rerender } = renderHook(() => useTags())
const firstTagsLength = result.current.tags.length
const firstTagNames = result.current.tags.map(t => t.name)
rerender()
// Structure should remain consistent
expect(result.current.tags.length).toBe(firstTagsLength)
expect(result.current.tags.map(t => t.name)).toEqual(firstTagNames)
})
})
})
describe('useCategories', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should return categories array', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categories).toBeDefined()
expect(Array.isArray(result.current.categories)).toBe(true)
expect(result.current.categories.length).toBeGreaterThan(0)
})
it('should return categories with name and label properties', () => {
const { result } = renderHook(() => useCategories())
result.current.categories.forEach((category) => {
expect(category).toHaveProperty('name')
expect(category).toHaveProperty('label')
expect(typeof category.name).toBe('string')
expect(typeof category.label).toBe('string')
})
})
it('should return categoriesMap object', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categoriesMap).toBeDefined()
expect(typeof result.current.categoriesMap).toBe('object')
})
})
describe('categoriesMap', () => {
it('should map category name to category object', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categoriesMap.tool).toBeDefined()
expect(result.current.categoriesMap.tool.name).toBe('tool')
})
it('should contain all categories from categories array', () => {
const { result } = renderHook(() => useCategories())
result.current.categories.forEach((category) => {
expect(result.current.categoriesMap[category.name]).toBeDefined()
expect(result.current.categoriesMap[category.name]).toEqual(category)
})
})
})
describe('isSingle parameter', () => {
it('should use plural labels when isSingle is false', () => {
const { result } = renderHook(() => useCategories(false))
expect(result.current.categoriesMap.tool.label).toBe('Tools')
})
it('should use plural labels when isSingle is undefined', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categoriesMap.tool.label).toBe('Tools')
})
it('should use singular labels when isSingle is true', () => {
const { result } = renderHook(() => useCategories(true))
expect(result.current.categoriesMap.tool.label).toBe('Tool')
})
it('should handle agent category specially', () => {
const { result: resultPlural } = renderHook(() => useCategories(false))
const { result: resultSingle } = renderHook(() => useCategories(true))
expect(resultPlural.current.categoriesMap['agent-strategy'].label).toBe('Agents')
expect(resultSingle.current.categoriesMap['agent-strategy'].label).toBe('Agent')
})
})
describe('Memoization', () => {
it('should return same structure on re-render', () => {
const { result, rerender } = renderHook(() => useCategories())
const firstCategoriesLength = result.current.categories.length
const firstCategoryNames = result.current.categories.map(c => c.name)
rerender()
// Structure should remain consistent
expect(result.current.categories.length).toBe(firstCategoriesLength)
expect(result.current.categories.map(c => c.name)).toEqual(firstCategoryNames)
})
})
})
describe('usePluginPageTabs', () => {
beforeEach(() => {
vi.clearAllMocks()
mockT.mockClear()
})
describe('Rendering', () => {
it('should return tabs array', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current).toBeDefined()
expect(Array.isArray(result.current)).toBe(true)
})
it('should return two tabs', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current.length).toBe(2)
})
it('should return tabs with value and text properties', () => {
const { result } = renderHook(() => usePluginPageTabs())
result.current.forEach((tab) => {
expect(tab).toHaveProperty('value')
expect(tab).toHaveProperty('text')
expect(typeof tab.value).toBe('string')
expect(typeof tab.text).toBe('string')
})
})
it('should call translation function for tab texts', () => {
renderHook(() => usePluginPageTabs())
// Verify t() was called for menu translations
expect(mockT).toHaveBeenCalledWith('menus.plugins', { ns: 'common' })
expect(mockT).toHaveBeenCalledWith('menus.exploreMarketplace', { ns: 'common' })
})
})
describe('Tab Values', () => {
it('should have plugins tab with correct value', () => {
const { result } = renderHook(() => usePluginPageTabs())
const pluginsTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.plugins)
expect(pluginsTab).toBeDefined()
expect(pluginsTab?.value).toBe('plugins')
expect(pluginsTab?.text).toBe('Plugins')
})
it('should have marketplace tab with correct value', () => {
const { result } = renderHook(() => usePluginPageTabs())
const marketplaceTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.marketplace)
expect(marketplaceTab).toBeDefined()
expect(marketplaceTab?.value).toBe('discover')
expect(marketplaceTab?.text).toBe('Explore Marketplace')
})
})
describe('Tab Order', () => {
it('should return plugins tab as first tab', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current[0].value).toBe('plugins')
expect(result.current[0].text).toBe('Plugins')
})
it('should return marketplace tab as second tab', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current[1].value).toBe('discover')
expect(result.current[1].text).toBe('Explore Marketplace')
})
})
describe('Tab Structure', () => {
it('should have consistent structure across re-renders', () => {
const { result, rerender } = renderHook(() => usePluginPageTabs())
const firstTabs = [...result.current]
rerender()
expect(result.current).toEqual(firstTabs)
})
it('should return new array reference on each call', () => {
const { result, rerender } = renderHook(() => usePluginPageTabs())
const firstTabs = result.current
rerender()
// Each call creates a new array (not memoized)
expect(result.current).not.toBe(firstTabs)
})
})
})
describe('PLUGIN_PAGE_TABS_MAP', () => {
it('should have plugins key with correct value', () => {
expect(PLUGIN_PAGE_TABS_MAP.plugins).toBe('plugins')
})
it('should have marketplace key with correct value', () => {
expect(PLUGIN_PAGE_TABS_MAP.marketplace).toBe('discover')
})
})

View File

@ -1,945 +0,0 @@
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '../../../types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '../../../types'
import InstallMulti from './install-multi'
// ==================== Mock Setup ====================
// Mock useFetchPluginsInMarketPlaceByInfo
const mockMarketplaceData = {
data: {
list: [
{
plugin: {
plugin_id: 'plugin-0',
org: 'test-org',
name: 'Test Plugin 0',
version: '1.0.0',
latest_version: '1.0.0',
},
version: {
unique_identifier: 'plugin-0-uid',
},
},
],
},
}
let mockInfoByIdError: Error | null = null
let mockInfoByMetaError: Error | null = null
vi.mock('@/service/use-plugins', () => ({
useFetchPluginsInMarketPlaceByInfo: () => {
// Return error based on the mock variables to simulate different error scenarios
if (mockInfoByIdError || mockInfoByMetaError) {
return {
isLoading: false,
data: null,
error: mockInfoByIdError || mockInfoByMetaError,
}
}
return {
isLoading: false,
data: mockMarketplaceData,
error: null,
}
},
}))
// Mock useCheckInstalled
const mockInstalledInfo: Record<string, VersionInfo> = {}
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
default: () => ({
installedInfo: mockInstalledInfo,
}),
}))
// Mock useGlobalPublicStore
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({}),
}))
// Mock pluginInstallLimit
vi.mock('../../hooks/use-install-plugin-limit', () => ({
pluginInstallLimit: () => ({ canInstall: true }),
}))
// Mock child components
vi.mock('../item/github-item', () => ({
default: vi.fn().mockImplementation(({
checked,
onCheckedChange,
dependency,
onFetchedPayload,
}: {
checked: boolean
onCheckedChange: () => void
dependency: GitHubItemAndMarketPlaceDependency
onFetchedPayload: (plugin: Plugin) => void
}) => {
// Simulate successful fetch - use ref to avoid dependency
const fetchedRef = React.useRef(false)
React.useEffect(() => {
if (fetchedRef.current)
return
fetchedRef.current = true
const mockPlugin: Plugin = {
type: 'plugin',
org: 'test-org',
name: 'GitHub Plugin',
plugin_id: 'github-plugin-id',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'github-pkg-id',
icon: 'icon.png',
verified: true,
label: { 'en-US': 'GitHub Plugin' },
brief: { 'en-US': 'Brief' },
description: { 'en-US': 'Description' },
introduction: 'Intro',
repository: 'https://github.com/test/plugin',
category: PluginCategoryEnum.tool,
install_count: 100,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'community' },
from: 'github',
}
onFetchedPayload(mockPlugin)
}, [onFetchedPayload])
return (
<div data-testid="github-item" onClick={onCheckedChange}>
<span data-testid="github-item-checked">{checked ? 'checked' : 'unchecked'}</span>
<span data-testid="github-item-repo">{dependency.value.repo}</span>
</div>
)
}),
}))
vi.mock('../item/marketplace-item', () => ({
default: vi.fn().mockImplementation(({
checked,
onCheckedChange,
payload,
version,
_versionInfo,
}: {
checked: boolean
onCheckedChange: () => void
payload: Plugin
version: string
_versionInfo: VersionInfo
}) => (
<div data-testid="marketplace-item" onClick={onCheckedChange}>
<span data-testid="marketplace-item-checked">{checked ? 'checked' : 'unchecked'}</span>
<span data-testid="marketplace-item-name">{payload?.name || 'Loading'}</span>
<span data-testid="marketplace-item-version">{version}</span>
</div>
)),
}))
vi.mock('../item/package-item', () => ({
default: vi.fn().mockImplementation(({
checked,
onCheckedChange,
payload,
_isFromMarketPlace,
_versionInfo,
}: {
checked: boolean
onCheckedChange: () => void
payload: PackageDependency
_isFromMarketPlace: boolean
_versionInfo: VersionInfo
}) => (
<div data-testid="package-item" onClick={onCheckedChange}>
<span data-testid="package-item-checked">{checked ? 'checked' : 'unchecked'}</span>
<span data-testid="package-item-name">{payload.value.manifest.name}</span>
</div>
)),
}))
vi.mock('../../base/loading-error', () => ({
default: () => <div data-testid="loading-error">Loading Error</div>,
}))
// ==================== Test Utilities ====================
const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'plugin',
org: 'test-org',
name: 'Test Plugin',
plugin_id: 'test-plugin-id',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'test-package-id',
icon: 'test-icon.png',
verified: true,
label: { 'en-US': 'Test Plugin' },
brief: { 'en-US': 'A test plugin' },
description: { 'en-US': 'A test plugin description' },
introduction: 'Introduction text',
repository: 'https://github.com/test/plugin',
category: PluginCategoryEnum.tool,
install_count: 100,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
...overrides,
})
const createMarketplaceDependency = (index: number): GitHubItemAndMarketPlaceDependency => ({
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: `test-org/plugin-${index}:1.0.0`,
plugin_unique_identifier: `plugin-${index}`,
version: '1.0.0',
},
})
const createGitHubDependency = (index: number): GitHubItemAndMarketPlaceDependency => ({
type: 'github',
value: {
repo: `test-org/plugin-${index}`,
version: 'v1.0.0',
package: `plugin-${index}.zip`,
},
})
const createPackageDependency = (index: number) => ({
type: 'package',
value: {
unique_identifier: `package-plugin-${index}-uid`,
manifest: {
plugin_unique_identifier: `package-plugin-${index}-uid`,
version: '1.0.0',
author: 'test-author',
icon: 'icon.png',
name: `Package Plugin ${index}`,
category: PluginCategoryEnum.tool,
label: { 'en-US': `Package Plugin ${index}` },
description: { 'en-US': 'Test package plugin' },
created_at: '2024-01-01',
resource: {},
plugins: [],
verified: true,
endpoint: { settings: [], endpoints: [] },
model: null,
tags: [],
agent_strategy: null,
meta: { version: '1.0.0' },
trigger: {},
},
},
} as unknown as PackageDependency)
// ==================== InstallMulti Component Tests ====================
describe('InstallMulti Component', () => {
const defaultProps = {
allPlugins: [createPackageDependency(0)] as Dependency[],
selectedPlugins: [] as Plugin[],
onSelect: vi.fn(),
onSelectAll: vi.fn(),
onDeSelectAll: vi.fn(),
onLoadedAllPlugin: vi.fn(),
isFromMarketPlace: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
// ==================== Rendering Tests ====================
describe('Rendering', () => {
it('should render without crashing', () => {
render(<InstallMulti {...defaultProps} />)
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
it('should render PackageItem for package type dependency', () => {
render(<InstallMulti {...defaultProps} />)
expect(screen.getByTestId('package-item')).toBeInTheDocument()
expect(screen.getByTestId('package-item-name')).toHaveTextContent('Package Plugin 0')
})
it('should render GithubItem for github type dependency', async () => {
const githubProps = {
...defaultProps,
allPlugins: [createGitHubDependency(0)] as Dependency[],
}
render(<InstallMulti {...githubProps} />)
await waitFor(() => {
expect(screen.getByTestId('github-item')).toBeInTheDocument()
})
expect(screen.getByTestId('github-item-repo')).toHaveTextContent('test-org/plugin-0')
})
it('should render MarketplaceItem for marketplace type dependency', async () => {
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
await waitFor(() => {
expect(screen.getByTestId('marketplace-item')).toBeInTheDocument()
})
})
it('should render multiple items for mixed dependency types', async () => {
const mixedProps = {
...defaultProps,
allPlugins: [
createPackageDependency(0),
createGitHubDependency(1),
] as Dependency[],
}
render(<InstallMulti {...mixedProps} />)
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
expect(screen.getByTestId('github-item')).toBeInTheDocument()
})
})
it('should render LoadingError for failed plugin fetches', async () => {
// This test requires simulating an error state
// The component tracks errorIndexes for failed fetches
// We'll test this through the GitHub item's onFetchError callback
const githubProps = {
...defaultProps,
allPlugins: [createGitHubDependency(0)] as Dependency[],
}
// The actual error handling is internal to the component
// Just verify component renders
render(<InstallMulti {...githubProps} />)
await waitFor(() => {
expect(screen.queryByTestId('github-item')).toBeInTheDocument()
})
})
})
// ==================== Selection Tests ====================
describe('Selection', () => {
it('should call onSelect when item is clicked', async () => {
render(<InstallMulti {...defaultProps} />)
const packageItem = screen.getByTestId('package-item')
await act(async () => {
fireEvent.click(packageItem)
})
expect(defaultProps.onSelect).toHaveBeenCalled()
})
it('should show checked state when plugin is selected', async () => {
const selectedPlugin = createMockPlugin({ plugin_id: 'package-plugin-0-uid' })
const propsWithSelected = {
...defaultProps,
selectedPlugins: [selectedPlugin],
}
render(<InstallMulti {...propsWithSelected} />)
expect(screen.getByTestId('package-item-checked')).toHaveTextContent('checked')
})
it('should show unchecked state when plugin is not selected', () => {
render(<InstallMulti {...defaultProps} />)
expect(screen.getByTestId('package-item-checked')).toHaveTextContent('unchecked')
})
})
// ==================== useImperativeHandle Tests ====================
describe('Imperative Handle', () => {
it('should expose selectAllPlugins function', async () => {
const ref: { current: { selectAllPlugins: () => void, deSelectAllPlugins: () => void } | null } = { current: null }
render(<InstallMulti {...defaultProps} ref={ref} />)
await waitFor(() => {
expect(ref.current).not.toBeNull()
})
await act(async () => {
ref.current?.selectAllPlugins()
})
expect(defaultProps.onSelectAll).toHaveBeenCalled()
})
it('should expose deSelectAllPlugins function', async () => {
const ref: { current: { selectAllPlugins: () => void, deSelectAllPlugins: () => void } | null } = { current: null }
render(<InstallMulti {...defaultProps} ref={ref} />)
await waitFor(() => {
expect(ref.current).not.toBeNull()
})
await act(async () => {
ref.current?.deSelectAllPlugins()
})
expect(defaultProps.onDeSelectAll).toHaveBeenCalled()
})
})
// ==================== onLoadedAllPlugin Callback Tests ====================
describe('onLoadedAllPlugin Callback', () => {
it('should call onLoadedAllPlugin when all plugins are loaded', async () => {
render(<InstallMulti {...defaultProps} />)
await waitFor(() => {
expect(defaultProps.onLoadedAllPlugin).toHaveBeenCalled()
})
})
it('should pass installedInfo to onLoadedAllPlugin', async () => {
render(<InstallMulti {...defaultProps} />)
await waitFor(() => {
expect(defaultProps.onLoadedAllPlugin).toHaveBeenCalledWith(expect.any(Object))
})
})
})
// ==================== Version Info Tests ====================
describe('Version Info', () => {
it('should pass version info to items', async () => {
render(<InstallMulti {...defaultProps} />)
// The getVersionInfo function returns hasInstalled, installedVersion, toInstallVersion
// These are passed to child components
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
})
})
// ==================== GitHub Plugin Fetch Tests ====================
describe('GitHub Plugin Fetch', () => {
it('should handle successful GitHub plugin fetch', async () => {
const githubProps = {
...defaultProps,
allPlugins: [createGitHubDependency(0)] as Dependency[],
}
render(<InstallMulti {...githubProps} />)
await waitFor(() => {
expect(screen.getByTestId('github-item')).toBeInTheDocument()
})
// The onFetchedPayload callback should have been called by the mock
// which updates the internal plugins state
})
})
// ==================== Marketplace Data Fetch Tests ====================
describe('Marketplace Data Fetch', () => {
it('should fetch and display marketplace plugin data', async () => {
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
await waitFor(() => {
expect(screen.getByTestId('marketplace-item')).toBeInTheDocument()
})
})
})
// ==================== Edge Cases ====================
describe('Edge Cases', () => {
it('should handle empty allPlugins array', () => {
const emptyProps = {
...defaultProps,
allPlugins: [],
}
const { container } = render(<InstallMulti {...emptyProps} />)
// Should render empty fragment
expect(container.firstChild).toBeNull()
})
it('should handle plugins without version info', async () => {
render(<InstallMulti {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
})
it('should pass isFromMarketPlace to PackageItem', async () => {
const propsWithMarketplace = {
...defaultProps,
isFromMarketPlace: true,
}
render(<InstallMulti {...propsWithMarketplace} />)
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
})
})
// ==================== Plugin State Management ====================
describe('Plugin State Management', () => {
it('should initialize plugins array with package plugins', () => {
render(<InstallMulti {...defaultProps} />)
// Package plugins are initialized immediately
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
it('should update plugins when GitHub plugin is fetched', async () => {
const githubProps = {
...defaultProps,
allPlugins: [createGitHubDependency(0)] as Dependency[],
}
render(<InstallMulti {...githubProps} />)
await waitFor(() => {
expect(screen.getByTestId('github-item')).toBeInTheDocument()
})
})
})
// ==================== Multiple Marketplace Plugins ====================
describe('Multiple Marketplace Plugins', () => {
it('should handle multiple marketplace plugins', async () => {
const multipleMarketplace = {
...defaultProps,
allPlugins: [
createMarketplaceDependency(0),
createMarketplaceDependency(1),
] as Dependency[],
}
render(<InstallMulti {...multipleMarketplace} />)
await waitFor(() => {
const items = screen.getAllByTestId('marketplace-item')
expect(items.length).toBeGreaterThanOrEqual(1)
})
})
})
// ==================== Error Handling ====================
describe('Error Handling', () => {
it('should handle fetch errors gracefully', async () => {
// Component should still render even with errors
render(<InstallMulti {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
})
it('should show LoadingError for failed marketplace fetch', async () => {
// This tests the error handling branch in useEffect
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
// Component should render
await waitFor(() => {
expect(screen.queryByTestId('marketplace-item') || screen.queryByTestId('loading-error')).toBeTruthy()
})
})
})
// ==================== selectAllPlugins Edge Cases ====================
describe('selectAllPlugins Edge Cases', () => {
it('should skip plugins that are not loaded', async () => {
const ref: { current: { selectAllPlugins: () => void, deSelectAllPlugins: () => void } | null } = { current: null }
// Use mixed plugins where some might not be loaded
const mixedProps = {
...defaultProps,
allPlugins: [
createPackageDependency(0),
createMarketplaceDependency(1),
] as Dependency[],
}
render(<InstallMulti {...mixedProps} ref={ref} />)
await waitFor(() => {
expect(ref.current).not.toBeNull()
})
await act(async () => {
ref.current?.selectAllPlugins()
})
// onSelectAll should be called with only the loaded plugins
expect(defaultProps.onSelectAll).toHaveBeenCalled()
})
})
// ==================== Version with fallback ====================
describe('Version Handling', () => {
it('should handle marketplace item version display', async () => {
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
await waitFor(() => {
expect(screen.getByTestId('marketplace-item')).toBeInTheDocument()
})
// Version should be displayed
expect(screen.getByTestId('marketplace-item-version')).toBeInTheDocument()
})
})
// ==================== GitHub Plugin Error Handling ====================
describe('GitHub Plugin Error Handling', () => {
it('should handle GitHub fetch error', async () => {
const githubProps = {
...defaultProps,
allPlugins: [createGitHubDependency(0)] as Dependency[],
}
render(<InstallMulti {...githubProps} />)
// Should render even with error
await waitFor(() => {
expect(screen.queryByTestId('github-item')).toBeTruthy()
})
})
})
// ==================== Marketplace Fetch Error Scenarios ====================
describe('Marketplace Fetch Error Scenarios', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInfoByIdError = null
mockInfoByMetaError = null
})
afterEach(() => {
mockInfoByIdError = null
mockInfoByMetaError = null
})
it('should add to errorIndexes when infoByIdError occurs', async () => {
// Set the error to simulate API failure
mockInfoByIdError = new Error('Failed to fetch by ID')
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
// Component should handle error gracefully
await waitFor(() => {
// Either loading error or marketplace item should be present
expect(
screen.queryByTestId('loading-error')
|| screen.queryByTestId('marketplace-item'),
).toBeTruthy()
})
})
it('should add to errorIndexes when infoByMetaError occurs', async () => {
// Set the error to simulate API failure
mockInfoByMetaError = new Error('Failed to fetch by meta')
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
// Component should handle error gracefully
await waitFor(() => {
expect(
screen.queryByTestId('loading-error')
|| screen.queryByTestId('marketplace-item'),
).toBeTruthy()
})
})
it('should handle both infoByIdError and infoByMetaError', async () => {
// Set both errors
mockInfoByIdError = new Error('Failed to fetch by ID')
mockInfoByMetaError = new Error('Failed to fetch by meta')
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0), createMarketplaceDependency(1)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
await waitFor(() => {
// Component should render
expect(document.body).toBeInTheDocument()
})
})
})
// ==================== Installed Info Handling ====================
describe('Installed Info', () => {
it('should pass installed info to getVersionInfo', async () => {
render(<InstallMulti {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
// The getVersionInfo callback should return correct structure
// This is tested indirectly through the item rendering
})
})
// ==================== Selected Plugins Checked State ====================
describe('Selected Plugins Checked State', () => {
it('should show checked state for github item when selected', async () => {
const selectedPlugin = createMockPlugin({ plugin_id: 'github-plugin-id' })
const propsWithSelected = {
...defaultProps,
allPlugins: [createGitHubDependency(0)] as Dependency[],
selectedPlugins: [selectedPlugin],
}
render(<InstallMulti {...propsWithSelected} />)
await waitFor(() => {
expect(screen.getByTestId('github-item')).toBeInTheDocument()
})
expect(screen.getByTestId('github-item-checked')).toHaveTextContent('checked')
})
it('should show checked state for marketplace item when selected', async () => {
const selectedPlugin = createMockPlugin({ plugin_id: 'plugin-0' })
const propsWithSelected = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
selectedPlugins: [selectedPlugin],
}
render(<InstallMulti {...propsWithSelected} />)
await waitFor(() => {
expect(screen.getByTestId('marketplace-item')).toBeInTheDocument()
})
// The checked prop should be passed to the item
})
it('should handle unchecked state for items not in selectedPlugins', async () => {
const propsWithoutSelected = {
...defaultProps,
allPlugins: [createGitHubDependency(0)] as Dependency[],
selectedPlugins: [],
}
render(<InstallMulti {...propsWithoutSelected} />)
await waitFor(() => {
expect(screen.getByTestId('github-item')).toBeInTheDocument()
})
expect(screen.getByTestId('github-item-checked')).toHaveTextContent('unchecked')
})
})
// ==================== Plugin Not Loaded Scenario ====================
describe('Plugin Not Loaded', () => {
it('should skip undefined plugins in selectAllPlugins', async () => {
const ref: { current: { selectAllPlugins: () => void, deSelectAllPlugins: () => void } | null } = { current: null }
// Create a scenario where some plugins might not be loaded
const mixedProps = {
...defaultProps,
allPlugins: [
createPackageDependency(0),
createGitHubDependency(1),
createMarketplaceDependency(2),
] as Dependency[],
}
render(<InstallMulti {...mixedProps} ref={ref} />)
await waitFor(() => {
expect(ref.current).not.toBeNull()
})
// Call selectAllPlugins - it should handle undefined plugins gracefully
await act(async () => {
ref.current?.selectAllPlugins()
})
expect(defaultProps.onSelectAll).toHaveBeenCalled()
})
})
// ==================== handleSelect with Plugin Install Limits ====================
describe('handleSelect with Plugin Install Limits', () => {
it('should filter plugins based on canInstall when selecting', async () => {
const mixedProps = {
...defaultProps,
allPlugins: [
createPackageDependency(0),
createPackageDependency(1),
] as Dependency[],
}
render(<InstallMulti {...mixedProps} />)
const packageItems = screen.getAllByTestId('package-item')
await act(async () => {
fireEvent.click(packageItems[0])
})
// onSelect should be called with filtered plugin count
expect(defaultProps.onSelect).toHaveBeenCalled()
})
})
// ==================== Version fallback handling ====================
describe('Version Fallback', () => {
it('should use latest_version when version is not available', async () => {
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
await waitFor(() => {
expect(screen.getByTestId('marketplace-item')).toBeInTheDocument()
})
// The version should be displayed (from dependency or plugin)
expect(screen.getByTestId('marketplace-item-version')).toBeInTheDocument()
})
})
// ==================== getVersionInfo edge cases ====================
describe('getVersionInfo Edge Cases', () => {
it('should return correct version info structure', async () => {
render(<InstallMulti {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
// The component should pass versionInfo to items
// This is verified indirectly through successful rendering
})
it('should handle plugins with author instead of org', async () => {
// Package plugins use author instead of org
render(<InstallMulti {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
expect(defaultProps.onLoadedAllPlugin).toHaveBeenCalled()
})
})
})
// ==================== Multiple marketplace items ====================
describe('Multiple Marketplace Items', () => {
it('should process all marketplace items correctly', async () => {
const multiMarketplace = {
...defaultProps,
allPlugins: [
createMarketplaceDependency(0),
createMarketplaceDependency(1),
createMarketplaceDependency(2),
] as Dependency[],
}
render(<InstallMulti {...multiMarketplace} />)
await waitFor(() => {
const items = screen.getAllByTestId('marketplace-item')
expect(items.length).toBeGreaterThanOrEqual(1)
})
})
})
// ==================== Multiple GitHub items ====================
describe('Multiple GitHub Items', () => {
it('should handle multiple GitHub plugin fetches', async () => {
const multiGithub = {
...defaultProps,
allPlugins: [
createGitHubDependency(0),
createGitHubDependency(1),
] as Dependency[],
}
render(<InstallMulti {...multiGithub} />)
await waitFor(() => {
const items = screen.getAllByTestId('github-item')
expect(items.length).toBe(2)
})
})
})
// ==================== canInstall false scenario ====================
describe('canInstall False Scenario', () => {
it('should skip plugins that cannot be installed in selectAllPlugins', async () => {
const ref: { current: { selectAllPlugins: () => void, deSelectAllPlugins: () => void } | null } = { current: null }
const multiplePlugins = {
...defaultProps,
allPlugins: [
createPackageDependency(0),
createPackageDependency(1),
createPackageDependency(2),
] as Dependency[],
}
render(<InstallMulti {...multiplePlugins} ref={ref} />)
await waitFor(() => {
expect(ref.current).not.toBeNull()
})
await act(async () => {
ref.current?.selectAllPlugins()
})
expect(defaultProps.onSelectAll).toHaveBeenCalled()
})
})
})

View File

@ -1,846 +0,0 @@
import type { Dependency, InstallStatusResponse, PackageDependency } from '../../../types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum, TaskStatus } from '../../../types'
import Install from './install'
// ==================== Mock Setup ====================
// Mock useInstallOrUpdate and usePluginTaskList
const mockInstallOrUpdate = vi.fn()
const mockHandleRefetch = vi.fn()
let mockInstallResponse: 'success' | 'failed' | 'running' = 'success'
vi.mock('@/service/use-plugins', () => ({
useInstallOrUpdate: (options: { onSuccess: (res: InstallStatusResponse[]) => void }) => {
mockInstallOrUpdate.mockImplementation((params: { payload: Dependency[] }) => {
// Call onSuccess with mock response based on mockInstallResponse
const getStatus = () => {
if (mockInstallResponse === 'success')
return TaskStatus.success
if (mockInstallResponse === 'failed')
return TaskStatus.failed
return TaskStatus.running
}
const mockResponse: InstallStatusResponse[] = params.payload.map(() => ({
status: getStatus(),
taskId: 'mock-task-id',
uniqueIdentifier: 'mock-uid',
}))
options.onSuccess(mockResponse)
})
return {
mutate: mockInstallOrUpdate,
isPending: false,
}
},
usePluginTaskList: () => ({
handleRefetch: mockHandleRefetch,
}),
}))
// Mock checkTaskStatus
const mockCheck = vi.fn()
const mockStop = vi.fn()
vi.mock('../../base/check-task-status', () => ({
default: () => ({
check: mockCheck,
stop: mockStop,
}),
}))
// Mock useRefreshPluginList
const mockRefreshPluginList = vi.fn()
vi.mock('../../hooks/use-refresh-plugin-list', () => ({
default: () => ({
refreshPluginList: mockRefreshPluginList,
}),
}))
// Mock mitt context
const mockEmit = vi.fn()
vi.mock('@/context/mitt-context', () => ({
useMittContextSelector: () => mockEmit,
}))
// Mock useCanInstallPluginFromMarketplace
vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
useCanInstallPluginFromMarketplace: () => ({ canInstallPluginFromMarketplace: true }),
}))
// Mock InstallMulti component with forwardRef support
vi.mock('./install-multi', async () => {
const React = await import('react')
const createPlugin = (index: number) => ({
type: 'plugin',
org: 'test-org',
name: `Test Plugin ${index}`,
plugin_id: `test-plugin-${index}`,
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: `test-pkg-${index}`,
icon: 'icon.png',
verified: true,
label: { 'en-US': `Test Plugin ${index}` },
brief: { 'en-US': 'Brief' },
description: { 'en-US': 'Description' },
introduction: 'Intro',
repository: 'https://github.com/test/plugin',
category: 'tool',
install_count: 100,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
})
const MockInstallMulti = React.forwardRef((props: {
allPlugins: { length: number }[]
selectedPlugins: { plugin_id: string }[]
onSelect: (plugin: ReturnType<typeof createPlugin>, index: number, total: number) => void
onSelectAll: (plugins: ReturnType<typeof createPlugin>[], indexes: number[]) => void
onDeSelectAll: () => void
onLoadedAllPlugin: (info: Record<string, unknown>) => void
}, ref: React.ForwardedRef<{ selectAllPlugins: () => void, deSelectAllPlugins: () => void }>) => {
const {
allPlugins,
selectedPlugins,
onSelect,
onSelectAll,
onDeSelectAll,
onLoadedAllPlugin,
} = props
const allPluginsRef = React.useRef(allPlugins)
React.useEffect(() => {
allPluginsRef.current = allPlugins
}, [allPlugins])
// Expose ref methods
React.useImperativeHandle(ref, () => ({
selectAllPlugins: () => {
const plugins = allPluginsRef.current.map((_, i) => createPlugin(i))
const indexes = allPluginsRef.current.map((_, i) => i)
onSelectAll(plugins, indexes)
},
deSelectAllPlugins: () => {
onDeSelectAll()
},
}), [onSelectAll, onDeSelectAll])
// Simulate loading completion when mounted
React.useEffect(() => {
const installedInfo = {}
onLoadedAllPlugin(installedInfo)
}, [onLoadedAllPlugin])
return (
<div data-testid="install-multi">
<span data-testid="all-plugins-count">{allPlugins.length}</span>
<span data-testid="selected-plugins-count">{selectedPlugins.length}</span>
<button
data-testid="select-plugin-0"
onClick={() => {
onSelect(createPlugin(0), 0, allPlugins.length)
}}
>
Select Plugin 0
</button>
<button
data-testid="select-plugin-1"
onClick={() => {
onSelect(createPlugin(1), 1, allPlugins.length)
}}
>
Select Plugin 1
</button>
<button
data-testid="toggle-plugin-0"
onClick={() => {
const plugin = createPlugin(0)
onSelect(plugin, 0, allPlugins.length)
}}
>
Toggle Plugin 0
</button>
<button
data-testid="select-all-plugins"
onClick={() => {
const plugins = allPlugins.map((_, i) => createPlugin(i))
const indexes = allPlugins.map((_, i) => i)
onSelectAll(plugins, indexes)
}}
>
Select All
</button>
<button
data-testid="deselect-all-plugins"
onClick={() => onDeSelectAll()}
>
Deselect All
</button>
</div>
)
})
return { default: MockInstallMulti }
})
// ==================== Test Utilities ====================
const createMockDependency = (type: 'marketplace' | 'github' | 'package' = 'marketplace', index = 0): Dependency => {
if (type === 'marketplace') {
return {
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: `plugin-${index}-uid`,
},
} as Dependency
}
if (type === 'github') {
return {
type: 'github',
value: {
repo: `test/plugin${index}`,
version: 'v1.0.0',
package: `plugin${index}.zip`,
},
} as Dependency
}
return {
type: 'package',
value: {
unique_identifier: `package-plugin-${index}-uid`,
manifest: {
plugin_unique_identifier: `package-plugin-${index}-uid`,
version: '1.0.0',
author: 'test-author',
icon: 'icon.png',
name: `Package Plugin ${index}`,
category: PluginCategoryEnum.tool,
label: { 'en-US': `Package Plugin ${index}` },
description: { 'en-US': 'Test package plugin' },
created_at: '2024-01-01',
resource: {},
plugins: [],
verified: true,
endpoint: { settings: [], endpoints: [] },
model: null,
tags: [],
agent_strategy: null,
meta: { version: '1.0.0' },
trigger: {},
},
},
} as unknown as PackageDependency
}
// ==================== Install Component Tests ====================
describe('Install Component', () => {
const defaultProps = {
allPlugins: [createMockDependency('marketplace', 0), createMockDependency('github', 1)],
onStartToInstall: vi.fn(),
onInstalled: vi.fn(),
onCancel: vi.fn(),
isFromMarketPlace: true,
}
beforeEach(() => {
vi.clearAllMocks()
})
// ==================== Rendering Tests ====================
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Install {...defaultProps} />)
expect(screen.getByTestId('install-multi')).toBeInTheDocument()
})
it('should render InstallMulti component with correct props', () => {
render(<Install {...defaultProps} />)
expect(screen.getByTestId('all-plugins-count')).toHaveTextContent('2')
})
it('should show singular text when one plugin is selected', async () => {
render(<Install {...defaultProps} />)
// Select one plugin
await act(async () => {
fireEvent.click(screen.getByTestId('select-plugin-0'))
})
// Should show "1" in the ready to install message
expect(screen.getByText(/plugin\.installModal\.readyToInstallPackage/i)).toBeInTheDocument()
})
it('should show plural text when multiple plugins are selected', async () => {
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Should show "2" in the ready to install packages message
expect(screen.getByText(/plugin\.installModal\.readyToInstallPackages/i)).toBeInTheDocument()
})
it('should render action buttons when isHideButton is false', () => {
render(<Install {...defaultProps} />)
// Install button should be present
expect(screen.getByText(/plugin\.installModal\.install/i)).toBeInTheDocument()
})
it('should not render action buttons when isHideButton is true', () => {
render(<Install {...defaultProps} isHideButton={true} />)
// Install button should not be present
expect(screen.queryByText(/plugin\.installModal\.install/i)).not.toBeInTheDocument()
})
it('should show cancel button when canInstall is false', () => {
// Create a fresh component that hasn't loaded yet
vi.doMock('./install-multi', () => ({
default: vi.fn().mockImplementation(() => (
<div data-testid="install-multi">Loading...</div>
)),
}))
// Since InstallMulti doesn't call onLoadedAllPlugin, canInstall stays false
// But we need to test this properly - for now just verify button states
render(<Install {...defaultProps} />)
// After loading, cancel button should not be shown
// Wait for the component to load
expect(screen.getByText(/plugin\.installModal\.install/i)).toBeInTheDocument()
})
})
// ==================== Selection Tests ====================
describe('Selection', () => {
it('should handle single plugin selection', async () => {
render(<Install {...defaultProps} />)
await act(async () => {
fireEvent.click(screen.getByTestId('select-plugin-0'))
})
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('1')
})
it('should handle select all plugins', async () => {
render(<Install {...defaultProps} />)
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2')
})
it('should handle deselect all plugins', async () => {
render(<Install {...defaultProps} />)
// First select all
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Then deselect all
await act(async () => {
fireEvent.click(screen.getByTestId('deselect-all-plugins'))
})
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('0')
})
it('should toggle select all checkbox state', async () => {
render(<Install {...defaultProps} />)
// After loading, handleLoadedAllPlugin triggers handleClickSelectAll which selects all
// So initially it shows deSelectAll
await waitFor(() => {
expect(screen.getByText(/common\.operation\.deSelectAll/i)).toBeInTheDocument()
})
// Click deselect all to deselect
await act(async () => {
fireEvent.click(screen.getByTestId('deselect-all-plugins'))
})
// Now should show selectAll since none are selected
await waitFor(() => {
expect(screen.getByText(/common\.operation\.selectAll/i)).toBeInTheDocument()
})
})
it('should call deSelectAllPlugins when clicking selectAll checkbox while isSelectAll is true', async () => {
render(<Install {...defaultProps} />)
// After loading, handleLoadedAllPlugin is called which triggers handleClickSelectAll
// Since isSelectAll is initially false, it calls selectAllPlugins
// So all plugins are selected after loading
await waitFor(() => {
expect(screen.getByText(/common\.operation\.deSelectAll/i)).toBeInTheDocument()
})
// Click the checkbox container div (parent of the text) to trigger handleClickSelectAll
// The div has onClick={handleClickSelectAll}
// Since isSelectAll is true, it should call deSelectAllPlugins
const deSelectText = screen.getByText(/common\.operation\.deSelectAll/i)
const checkboxContainer = deSelectText.parentElement
await act(async () => {
if (checkboxContainer)
fireEvent.click(checkboxContainer)
})
// Should now show selectAll again (deSelectAllPlugins was called)
await waitFor(() => {
expect(screen.getByText(/common\.operation\.selectAll/i)).toBeInTheDocument()
})
})
it('should show indeterminate state when some plugins are selected', async () => {
const threePlugins = [
createMockDependency('marketplace', 0),
createMockDependency('marketplace', 1),
createMockDependency('marketplace', 2),
]
render(<Install {...defaultProps} allPlugins={threePlugins} />)
// After loading, all 3 plugins are selected
await waitFor(() => {
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('3')
})
// Deselect two plugins to get to indeterminate state (1 selected out of 3)
await act(async () => {
fireEvent.click(screen.getByTestId('toggle-plugin-0'))
})
await act(async () => {
fireEvent.click(screen.getByTestId('toggle-plugin-0'))
})
// After toggle twice, we're back to all selected
// Let's instead click toggle once and check the checkbox component
// For now, verify the component handles partial selection
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('3')
})
})
// ==================== Install Action Tests ====================
describe('Install Actions', () => {
it('should call onStartToInstall when install is clicked', async () => {
render(<Install {...defaultProps} />)
// Select a plugin first
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install button
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
expect(defaultProps.onStartToInstall).toHaveBeenCalled()
})
it('should call installOrUpdate with correct payload', async () => {
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
expect(mockInstallOrUpdate).toHaveBeenCalled()
})
it('should call onInstalled when installation succeeds', async () => {
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
await waitFor(() => {
expect(defaultProps.onInstalled).toHaveBeenCalled()
})
})
it('should refresh plugin list on successful installation', async () => {
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
await waitFor(() => {
expect(mockRefreshPluginList).toHaveBeenCalled()
})
})
it('should emit plugin:install:success event on successful installation', async () => {
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
await waitFor(() => {
expect(mockEmit).toHaveBeenCalledWith('plugin:install:success', expect.any(Array))
})
})
it('should disable install button when no plugins are selected', async () => {
render(<Install {...defaultProps} />)
// Deselect all
await act(async () => {
fireEvent.click(screen.getByTestId('deselect-all-plugins'))
})
const installButton = screen.getByText(/plugin\.installModal\.install/i).closest('button')
expect(installButton).toBeDisabled()
})
})
// ==================== Cancel Action Tests ====================
describe('Cancel Actions', () => {
it('should call stop and onCancel when cancel is clicked', async () => {
// Need to test when canInstall is false
// For now, the cancel button appears only before loading completes
// After loading, it disappears
render(<Install {...defaultProps} />)
// The cancel button should not be visible after loading
// This is the expected behavior based on the component logic
await waitFor(() => {
expect(screen.queryByText(/common\.operation\.cancel/i)).not.toBeInTheDocument()
})
})
it('should trigger handleCancel when cancel button is visible and clicked', async () => {
// Override the mock to NOT call onLoadedAllPlugin immediately
// This keeps canInstall = false so the cancel button is visible
vi.doMock('./install-multi', () => ({
default: vi.fn().mockImplementation(() => (
<div data-testid="install-multi-no-load">Loading...</div>
)),
}))
// For this test, we just verify the cancel behavior
// The actual cancel button appears when canInstall is false
render(<Install {...defaultProps} />)
// Initially before loading completes, cancel should be visible
// After loading completes in our mock, it disappears
expect(document.body).toBeInTheDocument()
})
})
// ==================== Edge Cases ====================
describe('Edge Cases', () => {
it('should handle empty plugins array', () => {
render(<Install {...defaultProps} allPlugins={[]} />)
expect(screen.getByTestId('all-plugins-count')).toHaveTextContent('0')
})
it('should handle single plugin', () => {
render(<Install {...defaultProps} allPlugins={[createMockDependency('marketplace', 0)]} />)
expect(screen.getByTestId('all-plugins-count')).toHaveTextContent('1')
})
it('should handle mixed dependency types', () => {
const mixedPlugins = [
createMockDependency('marketplace', 0),
createMockDependency('github', 1),
createMockDependency('package', 2),
]
render(<Install {...defaultProps} allPlugins={mixedPlugins} />)
expect(screen.getByTestId('all-plugins-count')).toHaveTextContent('3')
})
it('should handle failed installation', async () => {
mockInstallResponse = 'failed'
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
// onInstalled should still be called with failure status
await waitFor(() => {
expect(defaultProps.onInstalled).toHaveBeenCalled()
})
// Reset for other tests
mockInstallResponse = 'success'
})
it('should handle running status and check task', async () => {
mockInstallResponse = 'running'
mockCheck.mockResolvedValue({ status: TaskStatus.success })
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
await waitFor(() => {
expect(mockHandleRefetch).toHaveBeenCalled()
})
await waitFor(() => {
expect(mockCheck).toHaveBeenCalled()
})
// Reset for other tests
mockInstallResponse = 'success'
})
it('should handle mixed status (some success/failed, some running)', async () => {
// Override mock to return mixed statuses
const mixedMockInstallOrUpdate = vi.fn()
vi.doMock('@/service/use-plugins', () => ({
useInstallOrUpdate: (options: { onSuccess: (res: InstallStatusResponse[]) => void }) => {
mixedMockInstallOrUpdate.mockImplementation((_params: { payload: Dependency[] }) => {
// Return mixed statuses: first one is success, second is running
const mockResponse: InstallStatusResponse[] = [
{ status: TaskStatus.success, taskId: 'task-1', uniqueIdentifier: 'uid-1' },
{ status: TaskStatus.running, taskId: 'task-2', uniqueIdentifier: 'uid-2' },
]
options.onSuccess(mockResponse)
})
return {
mutate: mixedMockInstallOrUpdate,
isPending: false,
}
},
usePluginTaskList: () => ({
handleRefetch: mockHandleRefetch,
}),
}))
// The actual test logic would need to trigger this scenario
// For now, we verify the component renders correctly
render(<Install {...defaultProps} />)
expect(screen.getByTestId('install-multi')).toBeInTheDocument()
})
it('should not refresh plugin list when all installations fail', async () => {
mockInstallResponse = 'failed'
mockRefreshPluginList.mockClear()
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
await waitFor(() => {
expect(defaultProps.onInstalled).toHaveBeenCalled()
})
// refreshPluginList should not be called when all fail
expect(mockRefreshPluginList).not.toHaveBeenCalled()
// Reset for other tests
mockInstallResponse = 'success'
})
})
// ==================== Selection State Management ====================
describe('Selection State Management', () => {
it('should set isSelectAll to false and isIndeterminate to false when all plugins are deselected', async () => {
render(<Install {...defaultProps} />)
// First select all
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Then deselect using the mock button
await act(async () => {
fireEvent.click(screen.getByTestId('deselect-all-plugins'))
})
// Should show selectAll text (not deSelectAll)
await waitFor(() => {
expect(screen.getByText(/common\.operation\.selectAll/i)).toBeInTheDocument()
})
})
it('should set isIndeterminate to true when some but not all plugins are selected', async () => {
const threePlugins = [
createMockDependency('marketplace', 0),
createMockDependency('marketplace', 1),
createMockDependency('marketplace', 2),
]
render(<Install {...defaultProps} allPlugins={threePlugins} />)
// After loading, all 3 plugins are selected
await waitFor(() => {
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('3')
})
// Deselect one plugin to get to indeterminate state (2 selected out of 3)
await act(async () => {
fireEvent.click(screen.getByTestId('toggle-plugin-0'))
})
// Component should be in indeterminate state (2 out of 3)
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2')
})
it('should toggle plugin selection correctly - deselect previously selected', async () => {
render(<Install {...defaultProps} />)
// After loading, all plugins (2) are selected via handleLoadedAllPlugin -> handleClickSelectAll
await waitFor(() => {
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2')
})
// Click toggle to deselect plugin 0 (toggle behavior)
await act(async () => {
fireEvent.click(screen.getByTestId('toggle-plugin-0'))
})
// Should have 1 selected now
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('1')
})
it('should set isSelectAll true when selecting last remaining plugin', async () => {
const twoPlugins = [
createMockDependency('marketplace', 0),
createMockDependency('marketplace', 1),
]
render(<Install {...defaultProps} allPlugins={twoPlugins} />)
// After loading, all plugins are selected
await waitFor(() => {
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2')
})
// Should show deSelectAll since all are selected
await waitFor(() => {
expect(screen.getByText(/common\.operation\.deSelectAll/i)).toBeInTheDocument()
})
})
it('should handle selection when nextSelectedPlugins.length equals allPluginsLength', async () => {
const twoPlugins = [
createMockDependency('marketplace', 0),
createMockDependency('marketplace', 1),
]
render(<Install {...defaultProps} allPlugins={twoPlugins} />)
// After loading, all plugins are selected via handleLoadedAllPlugin -> handleClickSelectAll
// Wait for initial selection
await waitFor(() => {
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2')
})
// Both should be selected
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2')
})
it('should handle deselection to zero plugins', async () => {
render(<Install {...defaultProps} />)
// After loading, all plugins are selected via handleLoadedAllPlugin
await waitFor(() => {
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2')
})
// Use the deselect-all-plugins button to deselect all
await act(async () => {
fireEvent.click(screen.getByTestId('deselect-all-plugins'))
})
// Should have 0 selected
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('0')
// Should show selectAll
await waitFor(() => {
expect(screen.getByText(/common\.operation\.selectAll/i)).toBeInTheDocument()
})
})
})
// ==================== Memoization Test ====================
describe('Memoization', () => {
it('should be memoized', async () => {
const InstallModule = await import('./install')
// memo returns an object with $$typeof
expect(typeof InstallModule.default).toBe('object')
})
})
})

View File

@ -1,502 +0,0 @@
import type { PluginDeclaration, PluginManifestInMarket } from '../types'
import { describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '../types'
import {
convertRepoToUrl,
parseGitHubUrl,
pluginManifestInMarketToPluginProps,
pluginManifestToCardPluginProps,
} from './utils'
// Mock es-toolkit/compat
vi.mock('es-toolkit/compat', () => ({
isEmpty: (obj: unknown) => {
if (obj === null || obj === undefined)
return true
if (typeof obj === 'object')
return Object.keys(obj).length === 0
return false
},
}))
describe('pluginManifestToCardPluginProps', () => {
const createMockPluginDeclaration = (overrides?: Partial<PluginDeclaration>): PluginDeclaration => ({
plugin_unique_identifier: 'test-plugin-123',
version: '1.0.0',
author: 'test-author',
icon: '/test-icon.png',
name: 'test-plugin',
category: PluginCategoryEnum.tool,
label: { 'en-US': 'Test Plugin' } as Record<string, string>,
description: { 'en-US': 'Test description' } as Record<string, string>,
created_at: '2024-01-01',
resource: {},
plugins: {},
verified: true,
endpoint: { settings: [], endpoints: [] },
model: {},
tags: ['search', 'api'],
agent_strategy: {},
meta: { version: '1.0.0' },
trigger: {} as PluginDeclaration['trigger'],
...overrides,
})
describe('Basic Conversion', () => {
it('should convert plugin_unique_identifier to plugin_id', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.plugin_id).toBe('test-plugin-123')
})
it('should convert category to type', () => {
const manifest = createMockPluginDeclaration({ category: PluginCategoryEnum.model })
const result = pluginManifestToCardPluginProps(manifest)
expect(result.type).toBe(PluginCategoryEnum.model)
expect(result.category).toBe(PluginCategoryEnum.model)
})
it('should map author to org', () => {
const manifest = createMockPluginDeclaration({ author: 'my-org' })
const result = pluginManifestToCardPluginProps(manifest)
expect(result.org).toBe('my-org')
expect(result.author).toBe('my-org')
})
it('should map label correctly', () => {
const manifest = createMockPluginDeclaration({
label: { 'en-US': 'My Plugin', 'zh-Hans': '我的插件' } as Record<string, string>,
})
const result = pluginManifestToCardPluginProps(manifest)
expect(result.label).toEqual({ 'en-US': 'My Plugin', 'zh-Hans': '我的插件' })
})
it('should map description to brief and description', () => {
const manifest = createMockPluginDeclaration({
description: { 'en-US': 'Plugin description' } as Record<string, string>,
})
const result = pluginManifestToCardPluginProps(manifest)
expect(result.brief).toEqual({ 'en-US': 'Plugin description' })
expect(result.description).toEqual({ 'en-US': 'Plugin description' })
})
})
describe('Tags Conversion', () => {
it('should convert tags array to objects with name property', () => {
const manifest = createMockPluginDeclaration({
tags: ['search', 'image', 'api'],
})
const result = pluginManifestToCardPluginProps(manifest)
expect(result.tags).toEqual([
{ name: 'search' },
{ name: 'image' },
{ name: 'api' },
])
})
it('should handle empty tags array', () => {
const manifest = createMockPluginDeclaration({ tags: [] })
const result = pluginManifestToCardPluginProps(manifest)
expect(result.tags).toEqual([])
})
it('should handle single tag', () => {
const manifest = createMockPluginDeclaration({ tags: ['single'] })
const result = pluginManifestToCardPluginProps(manifest)
expect(result.tags).toEqual([{ name: 'single' }])
})
})
describe('Default Values', () => {
it('should set latest_version to empty string', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.latest_version).toBe('')
})
it('should set latest_package_identifier to empty string', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.latest_package_identifier).toBe('')
})
it('should set introduction to empty string', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.introduction).toBe('')
})
it('should set repository to empty string', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.repository).toBe('')
})
it('should set install_count to 0', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.install_count).toBe(0)
})
it('should set empty badges array', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.badges).toEqual([])
})
it('should set verification with langgenius category', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.verification).toEqual({ authorized_category: 'langgenius' })
})
it('should set from to package', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.from).toBe('package')
})
})
describe('Icon Handling', () => {
it('should map icon correctly', () => {
const manifest = createMockPluginDeclaration({ icon: '/custom-icon.png' })
const result = pluginManifestToCardPluginProps(manifest)
expect(result.icon).toBe('/custom-icon.png')
})
it('should map icon_dark when provided', () => {
const manifest = createMockPluginDeclaration({
icon: '/light-icon.png',
icon_dark: '/dark-icon.png',
})
const result = pluginManifestToCardPluginProps(manifest)
expect(result.icon).toBe('/light-icon.png')
expect(result.icon_dark).toBe('/dark-icon.png')
})
})
describe('Endpoint Settings', () => {
it('should set endpoint with empty settings array', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.endpoint).toEqual({ settings: [] })
})
})
})
describe('pluginManifestInMarketToPluginProps', () => {
const createMockPluginManifestInMarket = (overrides?: Partial<PluginManifestInMarket>): PluginManifestInMarket => ({
plugin_unique_identifier: 'market-plugin-123',
name: 'market-plugin',
org: 'market-org',
icon: '/market-icon.png',
label: { 'en-US': 'Market Plugin' } as Record<string, string>,
category: PluginCategoryEnum.tool,
version: '1.0.0',
latest_version: '1.2.0',
brief: { 'en-US': 'Market plugin description' } as Record<string, string>,
introduction: 'Full introduction text',
verified: true,
install_count: 5000,
badges: ['partner', 'verified'],
verification: { authorized_category: 'langgenius' },
from: 'marketplace',
...overrides,
})
describe('Basic Conversion', () => {
it('should convert plugin_unique_identifier to plugin_id', () => {
const manifest = createMockPluginManifestInMarket()
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.plugin_id).toBe('market-plugin-123')
})
it('should convert category to type', () => {
const manifest = createMockPluginManifestInMarket({ category: PluginCategoryEnum.model })
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.type).toBe(PluginCategoryEnum.model)
expect(result.category).toBe(PluginCategoryEnum.model)
})
it('should use latest_version for version', () => {
const manifest = createMockPluginManifestInMarket({
version: '1.0.0',
latest_version: '2.0.0',
})
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.version).toBe('2.0.0')
expect(result.latest_version).toBe('2.0.0')
})
it('should map org correctly', () => {
const manifest = createMockPluginManifestInMarket({ org: 'my-organization' })
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.org).toBe('my-organization')
})
})
describe('Brief and Description', () => {
it('should map brief to both brief and description', () => {
const manifest = createMockPluginManifestInMarket({
brief: { 'en-US': 'Brief description' } as Record<string, string>,
})
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.brief).toEqual({ 'en-US': 'Brief description' })
expect(result.description).toEqual({ 'en-US': 'Brief description' })
})
})
describe('Badges and Verification', () => {
it('should map badges array', () => {
const manifest = createMockPluginManifestInMarket({
badges: ['partner', 'premium'],
})
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.badges).toEqual(['partner', 'premium'])
})
it('should map verification when provided', () => {
const manifest = createMockPluginManifestInMarket({
verification: { authorized_category: 'partner' },
})
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.verification).toEqual({ authorized_category: 'partner' })
})
it('should use default verification when empty', () => {
const manifest = createMockPluginManifestInMarket({
verification: {} as PluginManifestInMarket['verification'],
})
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.verification).toEqual({ authorized_category: 'langgenius' })
})
})
describe('Default Values', () => {
it('should set verified to true', () => {
const manifest = createMockPluginManifestInMarket()
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.verified).toBe(true)
})
it('should set latest_package_identifier to empty string', () => {
const manifest = createMockPluginManifestInMarket()
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.latest_package_identifier).toBe('')
})
it('should set repository to empty string', () => {
const manifest = createMockPluginManifestInMarket()
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.repository).toBe('')
})
it('should set install_count to 0', () => {
const manifest = createMockPluginManifestInMarket()
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.install_count).toBe(0)
})
it('should set empty tags array', () => {
const manifest = createMockPluginManifestInMarket()
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.tags).toEqual([])
})
it('should set endpoint with empty settings', () => {
const manifest = createMockPluginManifestInMarket()
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.endpoint).toEqual({ settings: [] })
})
})
describe('From Property', () => {
it('should map from property correctly', () => {
const manifest = createMockPluginManifestInMarket({ from: 'marketplace' })
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.from).toBe('marketplace')
})
it('should handle github from type', () => {
const manifest = createMockPluginManifestInMarket({ from: 'github' })
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.from).toBe('github')
})
})
})
describe('parseGitHubUrl', () => {
describe('Valid URLs', () => {
it('should parse valid GitHub URL', () => {
const result = parseGitHubUrl('https://github.com/owner/repo')
expect(result.isValid).toBe(true)
expect(result.owner).toBe('owner')
expect(result.repo).toBe('repo')
})
it('should parse URL with trailing slash', () => {
const result = parseGitHubUrl('https://github.com/owner/repo/')
expect(result.isValid).toBe(true)
expect(result.owner).toBe('owner')
expect(result.repo).toBe('repo')
})
it('should handle hyphenated owner and repo names', () => {
const result = parseGitHubUrl('https://github.com/my-org/my-repo')
expect(result.isValid).toBe(true)
expect(result.owner).toBe('my-org')
expect(result.repo).toBe('my-repo')
})
it('should handle underscored names', () => {
const result = parseGitHubUrl('https://github.com/my_org/my_repo')
expect(result.isValid).toBe(true)
expect(result.owner).toBe('my_org')
expect(result.repo).toBe('my_repo')
})
it('should handle numeric characters in names', () => {
const result = parseGitHubUrl('https://github.com/org123/repo456')
expect(result.isValid).toBe(true)
expect(result.owner).toBe('org123')
expect(result.repo).toBe('repo456')
})
})
describe('Invalid URLs', () => {
it('should return invalid for non-GitHub URL', () => {
const result = parseGitHubUrl('https://gitlab.com/owner/repo')
expect(result.isValid).toBe(false)
expect(result.owner).toBeUndefined()
expect(result.repo).toBeUndefined()
})
it('should return invalid for URL with extra path segments', () => {
const result = parseGitHubUrl('https://github.com/owner/repo/tree/main')
expect(result.isValid).toBe(false)
})
it('should return invalid for URL without repo', () => {
const result = parseGitHubUrl('https://github.com/owner')
expect(result.isValid).toBe(false)
})
it('should return invalid for empty string', () => {
const result = parseGitHubUrl('')
expect(result.isValid).toBe(false)
})
it('should return invalid for malformed URL', () => {
const result = parseGitHubUrl('not-a-url')
expect(result.isValid).toBe(false)
})
it('should return invalid for http URL', () => {
// Testing invalid http protocol - construct URL dynamically to avoid lint error
const httpUrl = `${'http'}://github.com/owner/repo`
const result = parseGitHubUrl(httpUrl)
expect(result.isValid).toBe(false)
})
it('should return invalid for URL with www', () => {
const result = parseGitHubUrl('https://www.github.com/owner/repo')
expect(result.isValid).toBe(false)
})
})
})
describe('convertRepoToUrl', () => {
describe('Valid Repos', () => {
it('should convert repo to GitHub URL', () => {
const result = convertRepoToUrl('owner/repo')
expect(result).toBe('https://github.com/owner/repo')
})
it('should handle hyphenated names', () => {
const result = convertRepoToUrl('my-org/my-repo')
expect(result).toBe('https://github.com/my-org/my-repo')
})
it('should handle complex repo strings', () => {
const result = convertRepoToUrl('organization_name/repository-name')
expect(result).toBe('https://github.com/organization_name/repository-name')
})
})
describe('Edge Cases', () => {
it('should return empty string for empty repo', () => {
const result = convertRepoToUrl('')
expect(result).toBe('')
})
it('should return empty string for undefined-like values', () => {
// TypeScript would normally prevent this, but testing runtime behavior
const result = convertRepoToUrl(undefined as unknown as string)
expect(result).toBe('')
})
it('should return empty string for null-like values', () => {
const result = convertRepoToUrl(null as unknown as string)
expect(result).toBe('')
})
it('should handle repo with special characters', () => {
const result = convertRepoToUrl('org/repo.js')
expect(result).toBe('https://github.com/org/repo.js')
})
})
})

View File

@ -1,837 +0,0 @@
import type { Credential } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CredentialTypeEnum } from '../types'
import Item from './item'
// ==================== Test Utilities ====================
const createCredential = (overrides: Partial<Credential> = {}): Credential => ({
id: 'test-credential-id',
name: 'Test Credential',
provider: 'test-provider',
credential_type: CredentialTypeEnum.API_KEY,
is_default: false,
credentials: { api_key: 'test-key' },
...overrides,
})
// ==================== Item Component Tests ====================
describe('Item Component', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// ==================== Rendering Tests ====================
describe('Rendering', () => {
it('should render credential name', () => {
const credential = createCredential({ name: 'My API Key' })
render(<Item credential={credential} />)
expect(screen.getByText('My API Key')).toBeInTheDocument()
})
it('should render default badge when is_default is true', () => {
const credential = createCredential({ is_default: true })
render(<Item credential={credential} />)
expect(screen.getByText('plugin.auth.default')).toBeInTheDocument()
})
it('should not render default badge when is_default is false', () => {
const credential = createCredential({ is_default: false })
render(<Item credential={credential} />)
expect(screen.queryByText('plugin.auth.default')).not.toBeInTheDocument()
})
it('should render enterprise badge when from_enterprise is true', () => {
const credential = createCredential({ from_enterprise: true })
render(<Item credential={credential} />)
expect(screen.getByText('Enterprise')).toBeInTheDocument()
})
it('should not render enterprise badge when from_enterprise is false', () => {
const credential = createCredential({ from_enterprise: false })
render(<Item credential={credential} />)
expect(screen.queryByText('Enterprise')).not.toBeInTheDocument()
})
it('should render selected icon when showSelectedIcon is true and credential is selected', () => {
const credential = createCredential({ id: 'selected-id' })
render(
<Item
credential={credential}
showSelectedIcon={true}
selectedCredentialId="selected-id"
/>,
)
// RiCheckLine should be rendered
expect(document.querySelector('.text-text-accent')).toBeInTheDocument()
})
it('should not render selected icon when credential is not selected', () => {
const credential = createCredential({ id: 'not-selected-id' })
render(
<Item
credential={credential}
showSelectedIcon={true}
selectedCredentialId="other-id"
/>,
)
// Check icon should not be visible
expect(document.querySelector('.text-text-accent')).not.toBeInTheDocument()
})
it('should render with gray indicator when not_allowed_to_use is true', () => {
const credential = createCredential({ not_allowed_to_use: true })
const { container } = render(<Item credential={credential} />)
// The item should have tooltip wrapper with data-state attribute for unavailable credential
const tooltipTrigger = container.querySelector('[data-state]')
expect(tooltipTrigger).toBeInTheDocument()
// The item should have disabled styles
expect(container.querySelector('.cursor-not-allowed')).toBeInTheDocument()
})
it('should apply disabled styles when disabled is true', () => {
const credential = createCredential()
const { container } = render(<Item credential={credential} disabled={true} />)
const itemDiv = container.querySelector('.cursor-not-allowed')
expect(itemDiv).toBeInTheDocument()
})
it('should apply disabled styles when not_allowed_to_use is true', () => {
const credential = createCredential({ not_allowed_to_use: true })
const { container } = render(<Item credential={credential} />)
const itemDiv = container.querySelector('.cursor-not-allowed')
expect(itemDiv).toBeInTheDocument()
})
})
// ==================== Click Interaction Tests ====================
describe('Click Interactions', () => {
it('should call onItemClick with credential id when clicked', () => {
const onItemClick = vi.fn()
const credential = createCredential({ id: 'click-test-id' })
const { container } = render(
<Item credential={credential} onItemClick={onItemClick} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
expect(onItemClick).toHaveBeenCalledWith('click-test-id')
})
it('should call onItemClick with empty string for workspace default credential', () => {
const onItemClick = vi.fn()
const credential = createCredential({ id: '__workspace_default__' })
const { container } = render(
<Item credential={credential} onItemClick={onItemClick} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
expect(onItemClick).toHaveBeenCalledWith('')
})
it('should not call onItemClick when disabled', () => {
const onItemClick = vi.fn()
const credential = createCredential()
const { container } = render(
<Item credential={credential} onItemClick={onItemClick} disabled={true} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
expect(onItemClick).not.toHaveBeenCalled()
})
it('should not call onItemClick when not_allowed_to_use is true', () => {
const onItemClick = vi.fn()
const credential = createCredential({ not_allowed_to_use: true })
const { container } = render(
<Item credential={credential} onItemClick={onItemClick} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
expect(onItemClick).not.toHaveBeenCalled()
})
})
// ==================== Rename Mode Tests ====================
describe('Rename Mode', () => {
it('should enter rename mode when rename button is clicked', () => {
const credential = createCredential()
const { container } = render(
<Item
credential={credential}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Since buttons are hidden initially, we need to find the ActionButton
// In the actual implementation, they are rendered but hidden
const actionButtons = container.querySelectorAll('button')
const renameBtn = Array.from(actionButtons).find(btn =>
btn.querySelector('.ri-edit-line') || btn.innerHTML.includes('RiEditLine'),
)
if (renameBtn) {
fireEvent.click(renameBtn)
// Should show input for rename
expect(screen.getByRole('textbox')).toBeInTheDocument()
}
})
it('should show save and cancel buttons in rename mode', () => {
const onRename = vi.fn()
const credential = createCredential({ name: 'Original Name' })
const { container } = render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Find and click rename button to enter rename mode
const actionButtons = container.querySelectorAll('button')
// Find the rename action button by looking for RiEditLine icon
actionButtons.forEach((btn) => {
if (btn.querySelector('svg')) {
fireEvent.click(btn)
}
})
// If we're in rename mode, there should be save/cancel buttons
const buttons = screen.queryAllByRole('button')
if (buttons.length >= 2) {
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
}
})
it('should call onRename with new name when save is clicked', () => {
const onRename = vi.fn()
const credential = createCredential({ id: 'rename-test-id', name: 'Original' })
const { container } = render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Trigger rename mode by clicking the rename button
const editIcon = container.querySelector('svg.ri-edit-line')
if (editIcon) {
fireEvent.click(editIcon.closest('button')!)
// Now in rename mode, change input and save
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'New Name' } })
// Click save
const saveButton = screen.getByText('common.operation.save')
fireEvent.click(saveButton)
expect(onRename).toHaveBeenCalledWith({
credential_id: 'rename-test-id',
name: 'New Name',
})
}
})
it('should call onRename and exit rename mode when save button is clicked', () => {
const onRename = vi.fn()
const credential = createCredential({ id: 'rename-save-test', name: 'Original Name' })
const { container } = render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Find and click rename button to enter rename mode
// The button contains RiEditLine svg
const allButtons = Array.from(container.querySelectorAll('button'))
let renameButton: Element | null = null
for (const btn of allButtons) {
if (btn.querySelector('svg')) {
renameButton = btn
break
}
}
if (renameButton) {
fireEvent.click(renameButton)
// Should be in rename mode now
const input = screen.queryByRole('textbox')
if (input) {
expect(input).toHaveValue('Original Name')
// Change the value
fireEvent.change(input, { target: { value: 'Updated Name' } })
expect(input).toHaveValue('Updated Name')
// Click save button
const saveButton = screen.getByText('common.operation.save')
fireEvent.click(saveButton)
// Verify onRename was called with correct parameters
expect(onRename).toHaveBeenCalledTimes(1)
expect(onRename).toHaveBeenCalledWith({
credential_id: 'rename-save-test',
name: 'Updated Name',
})
// Should exit rename mode - input should be gone
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
}
}
})
it('should exit rename mode when cancel is clicked', () => {
const credential = createCredential({ name: 'Original' })
const { container } = render(
<Item
credential={credential}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Enter rename mode
const editIcon = container.querySelector('svg')?.closest('button')
if (editIcon) {
fireEvent.click(editIcon)
// If in rename mode, cancel button should exist
const cancelButton = screen.queryByText('common.operation.cancel')
if (cancelButton) {
fireEvent.click(cancelButton)
// Should exit rename mode - input should be gone
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
}
}
})
it('should update rename value when input changes', () => {
const credential = createCredential({ name: 'Original' })
const { container } = render(
<Item
credential={credential}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// We need to get into rename mode first
// The rename button appears on hover in the actions area
const allButtons = container.querySelectorAll('button')
if (allButtons.length > 0) {
fireEvent.click(allButtons[0])
const input = screen.queryByRole('textbox')
if (input) {
fireEvent.change(input, { target: { value: 'Updated Value' } })
expect(input).toHaveValue('Updated Value')
}
}
})
it('should stop propagation when clicking input in rename mode', () => {
const onItemClick = vi.fn()
const credential = createCredential()
const { container } = render(
<Item
credential={credential}
onItemClick={onItemClick}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Enter rename mode and click on input
const allButtons = container.querySelectorAll('button')
if (allButtons.length > 0) {
fireEvent.click(allButtons[0])
const input = screen.queryByRole('textbox')
if (input) {
fireEvent.click(input)
// onItemClick should not be called when clicking the input
expect(onItemClick).not.toHaveBeenCalled()
}
}
})
})
// ==================== Action Button Tests ====================
describe('Action Buttons', () => {
it('should call onSetDefault when set default button is clicked', () => {
const onSetDefault = vi.fn()
const credential = createCredential({ is_default: false })
render(
<Item
credential={credential}
onSetDefault={onSetDefault}
disableSetDefault={false}
disableRename={true}
disableEdit={true}
disableDelete={true}
/>,
)
// Find set default button
const setDefaultButton = screen.queryByText('plugin.auth.setDefault')
if (setDefaultButton) {
fireEvent.click(setDefaultButton)
expect(onSetDefault).toHaveBeenCalledWith('test-credential-id')
}
})
it('should not show set default button when credential is already default', () => {
const onSetDefault = vi.fn()
const credential = createCredential({ is_default: true })
render(
<Item
credential={credential}
onSetDefault={onSetDefault}
disableSetDefault={false}
disableRename={true}
disableEdit={true}
disableDelete={true}
/>,
)
expect(screen.queryByText('plugin.auth.setDefault')).not.toBeInTheDocument()
})
it('should not show set default button when disableSetDefault is true', () => {
const onSetDefault = vi.fn()
const credential = createCredential({ is_default: false })
render(
<Item
credential={credential}
onSetDefault={onSetDefault}
disableSetDefault={true}
disableRename={true}
disableEdit={true}
disableDelete={true}
/>,
)
expect(screen.queryByText('plugin.auth.setDefault')).not.toBeInTheDocument()
})
it('should not show set default button when not_allowed_to_use is true', () => {
const credential = createCredential({ is_default: false, not_allowed_to_use: true })
render(
<Item
credential={credential}
disableSetDefault={false}
disableRename={true}
disableEdit={true}
disableDelete={true}
/>,
)
expect(screen.queryByText('plugin.auth.setDefault')).not.toBeInTheDocument()
})
it('should call onEdit with credential id and values when edit button is clicked', () => {
const onEdit = vi.fn()
const credential = createCredential({
id: 'edit-test-id',
name: 'Edit Test',
credential_type: CredentialTypeEnum.API_KEY,
credentials: { api_key: 'secret' },
})
const { container } = render(
<Item
credential={credential}
onEdit={onEdit}
disableEdit={false}
disableRename={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Find the edit button (RiEqualizer2Line icon)
const editButton = container.querySelector('svg')?.closest('button')
if (editButton) {
fireEvent.click(editButton)
expect(onEdit).toHaveBeenCalledWith('edit-test-id', {
api_key: 'secret',
__name__: 'Edit Test',
__credential_id__: 'edit-test-id',
})
}
})
it('should not show edit button for OAuth credentials', () => {
const onEdit = vi.fn()
const credential = createCredential({ credential_type: CredentialTypeEnum.OAUTH2 })
render(
<Item
credential={credential}
onEdit={onEdit}
disableEdit={false}
disableRename={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Edit button should not appear for OAuth
const editTooltip = screen.queryByText('common.operation.edit')
expect(editTooltip).not.toBeInTheDocument()
})
it('should not show edit button when from_enterprise is true', () => {
const onEdit = vi.fn()
const credential = createCredential({ from_enterprise: true })
render(
<Item
credential={credential}
onEdit={onEdit}
disableEdit={false}
disableRename={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Edit button should not appear for enterprise credentials
const editTooltip = screen.queryByText('common.operation.edit')
expect(editTooltip).not.toBeInTheDocument()
})
it('should call onDelete when delete button is clicked', () => {
const onDelete = vi.fn()
const credential = createCredential({ id: 'delete-test-id' })
const { container } = render(
<Item
credential={credential}
onDelete={onDelete}
disableDelete={false}
disableRename={true}
disableEdit={true}
disableSetDefault={true}
/>,
)
// Find delete button (RiDeleteBinLine icon)
const deleteButton = container.querySelector('svg')?.closest('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(onDelete).toHaveBeenCalledWith('delete-test-id')
}
})
it('should not show delete button when disableDelete is true', () => {
const onDelete = vi.fn()
const credential = createCredential()
render(
<Item
credential={credential}
onDelete={onDelete}
disableDelete={true}
disableRename={true}
disableEdit={true}
disableSetDefault={true}
/>,
)
// Delete tooltip should not be present
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
it('should not show delete button for enterprise credentials', () => {
const onDelete = vi.fn()
const credential = createCredential({ from_enterprise: true })
render(
<Item
credential={credential}
onDelete={onDelete}
disableDelete={false}
disableRename={true}
disableEdit={true}
disableSetDefault={true}
/>,
)
// Delete tooltip should not be present for enterprise
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
it('should not show rename button for enterprise credentials', () => {
const onRename = vi.fn()
const credential = createCredential({ from_enterprise: true })
render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Rename tooltip should not be present for enterprise
expect(screen.queryByText('common.operation.rename')).not.toBeInTheDocument()
})
it('should not show rename button when not_allowed_to_use is true', () => {
const onRename = vi.fn()
const credential = createCredential({ not_allowed_to_use: true })
render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Rename tooltip should not be present when not allowed to use
expect(screen.queryByText('common.operation.rename')).not.toBeInTheDocument()
})
it('should not show edit button when not_allowed_to_use is true', () => {
const onEdit = vi.fn()
const credential = createCredential({ not_allowed_to_use: true })
render(
<Item
credential={credential}
onEdit={onEdit}
disableEdit={false}
disableRename={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Edit tooltip should not be present when not allowed to use
expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
})
it('should stop propagation when clicking action buttons', () => {
const onItemClick = vi.fn()
const onDelete = vi.fn()
const credential = createCredential()
const { container } = render(
<Item
credential={credential}
onItemClick={onItemClick}
onDelete={onDelete}
disableDelete={false}
disableRename={true}
disableEdit={true}
disableSetDefault={true}
/>,
)
// Find delete button and click
const deleteButton = container.querySelector('svg')?.closest('button')
if (deleteButton) {
fireEvent.click(deleteButton)
// onDelete should be called but not onItemClick (due to stopPropagation)
expect(onDelete).toHaveBeenCalled()
// Note: onItemClick might still be called due to event bubbling in test environment
}
})
it('should disable action buttons when disabled prop is true', () => {
const onSetDefault = vi.fn()
const credential = createCredential({ is_default: false })
render(
<Item
credential={credential}
onSetDefault={onSetDefault}
disabled={true}
disableSetDefault={false}
disableRename={true}
disableEdit={true}
disableDelete={true}
/>,
)
// Set default button should be disabled
const setDefaultButton = screen.queryByText('plugin.auth.setDefault')
if (setDefaultButton) {
const button = setDefaultButton.closest('button')
expect(button).toBeDisabled()
}
})
})
// ==================== showAction Logic Tests ====================
describe('Show Action Logic', () => {
it('should not show action area when all actions are disabled', () => {
const credential = createCredential()
const { container } = render(
<Item
credential={credential}
disableRename={true}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Should not have action area with hover:flex
const actionArea = container.querySelector('.group-hover\\:flex')
expect(actionArea).not.toBeInTheDocument()
})
it('should show action area when at least one action is enabled', () => {
const credential = createCredential()
const { container } = render(
<Item
credential={credential}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Should have action area
const actionArea = container.querySelector('.group-hover\\:flex')
expect(actionArea).toBeInTheDocument()
})
})
// ==================== Edge Cases ====================
describe('Edge Cases', () => {
it('should handle credential with empty name', () => {
const credential = createCredential({ name: '' })
render(<Item credential={credential} />)
// Should render without crashing
expect(document.querySelector('.group')).toBeInTheDocument()
})
it('should handle credential with undefined credentials object', () => {
const credential = createCredential({ credentials: undefined })
render(
<Item
credential={credential}
disableEdit={false}
disableRename={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Should render without crashing
expect(document.querySelector('.group')).toBeInTheDocument()
})
it('should handle all optional callbacks being undefined', () => {
const credential = createCredential()
expect(() => {
render(<Item credential={credential} />)
}).not.toThrow()
})
it('should properly display long credential names with truncation', () => {
const longName = 'A'.repeat(100)
const credential = createCredential({ name: longName })
const { container } = render(<Item credential={credential} />)
const nameElement = container.querySelector('.truncate')
expect(nameElement).toBeInTheDocument()
expect(nameElement?.getAttribute('title')).toBe(longName)
})
})
// ==================== Memoization Test ====================
describe('Memoization', () => {
it('should be memoized', async () => {
const ItemModule = await import('./item')
// memo returns an object with $$typeof
expect(typeof ItemModule.default).toBe('object')
})
})
})

View File

@ -23,8 +23,8 @@ const PAGE_SIZE = 20
type Props = {
value?: {
app_id: string
inputs: Record<string, unknown>
files?: unknown[]
inputs: Record<string, any>
files?: any[]
}
scope?: string
disabled?: boolean
@ -32,8 +32,8 @@ type Props = {
offset?: OffsetOptions
onSelect: (app: {
app_id: string
inputs: Record<string, unknown>
files?: unknown[]
inputs: Record<string, any>
files?: any[]
}) => void
supportAddCustomTool?: boolean
}
@ -63,12 +63,12 @@ const AppSelector: FC<Props> = ({
name: searchText,
})
const pages = data?.pages ?? []
const displayedApps = useMemo(() => {
const pages = data?.pages ?? []
if (!pages.length)
return []
return pages.flatMap(({ data: apps }) => apps)
}, [data?.pages])
}, [pages])
// fetch selected app by id to avoid pagination gaps
const { data: selectedAppDetail } = useAppDetail(value?.app_id || '')
@ -130,7 +130,7 @@ const AppSelector: FC<Props> = ({
setIsShowChooseApp(false)
}
const handleFormChange = (inputs: Record<string, unknown>) => {
const handleFormChange = (inputs: Record<string, any>) => {
const newFiles = inputs['#image#']
delete inputs['#image#']
const newValue = {

View File

@ -1,8 +0,0 @@
export { default as ReasoningConfigForm } from './reasoning-config-form'
export { default as SchemaModal } from './schema-modal'
export { default as ToolAuthorizationSection } from './tool-authorization-section'
export { default as ToolBaseForm } from './tool-base-form'
export { default as ToolCredentialsForm } from './tool-credentials-form'
export { default as ToolItem } from './tool-item'
export { default as ToolSettingsPanel } from './tool-settings-panel'
export { default as ToolTrigger } from './tool-trigger'

View File

@ -1,48 +0,0 @@
'use client'
import type { FC } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import Divider from '@/app/components/base/divider'
import {
AuthCategory,
PluginAuthInAgent,
} from '@/app/components/plugins/plugin-auth'
import { CollectionType } from '@/app/components/tools/types'
type ToolAuthorizationSectionProps = {
currentProvider?: ToolWithProvider
credentialId?: string
onAuthorizationItemClick: (id: string) => void
}
const ToolAuthorizationSection: FC<ToolAuthorizationSectionProps> = ({
currentProvider,
credentialId,
onAuthorizationItemClick,
}) => {
// Only show for built-in providers that allow deletion
const shouldShow = currentProvider
&& currentProvider.type === CollectionType.builtIn
&& currentProvider.allow_delete
if (!shouldShow)
return null
return (
<>
<Divider className="my-1 w-full" />
<div className="px-4 py-2">
<PluginAuthInAgent
pluginPayload={{
provider: currentProvider.name,
category: AuthCategory.tool,
providerType: currentProvider.type,
}}
credentialId={credentialId}
onAuthorizationItemClick={onAuthorizationItemClick}
/>
</div>
</>
)
}
export default ToolAuthorizationSection

View File

@ -1,98 +0,0 @@
'use client'
import type { OffsetOptions } from '@floating-ui/react'
import type { FC } from 'react'
import type { PluginDetail } from '@/app/components/plugins/types'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import { ReadmeEntrance } from '../../../readme-panel/entrance'
import ToolTrigger from './tool-trigger'
type ToolBaseFormProps = {
value?: ToolValue
currentProvider?: ToolWithProvider
offset?: OffsetOptions
scope?: string
selectedTools?: ToolValue[]
isShowChooseTool: boolean
panelShowState?: boolean
hasTrigger: boolean
onShowChange: (show: boolean) => void
onPanelShowStateChange?: (state: boolean) => void
onSelectTool: (tool: ToolDefaultValue) => void
onSelectMultipleTool: (tools: ToolDefaultValue[]) => void
onDescriptionChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
}
const ToolBaseForm: FC<ToolBaseFormProps> = ({
value,
currentProvider,
offset = 4,
scope,
selectedTools,
isShowChooseTool,
panelShowState,
hasTrigger,
onShowChange,
onPanelShowStateChange,
onSelectTool,
onSelectMultipleTool,
onDescriptionChange,
}) => {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-3 px-4 py-2">
{/* Tool picker */}
<div className="flex flex-col gap-1">
<div className="system-sm-semibold flex h-6 items-center justify-between text-text-secondary">
{t('detailPanel.toolSelector.toolLabel', { ns: 'plugin' })}
{currentProvider?.plugin_unique_identifier && (
<ReadmeEntrance
pluginDetail={currentProvider as unknown as PluginDetail}
showShortTip
className="pb-0"
/>
)}
</div>
<ToolPicker
placement="bottom"
offset={offset}
trigger={(
<ToolTrigger
open={panelShowState || isShowChooseTool}
value={value}
provider={currentProvider}
/>
)}
isShow={panelShowState || isShowChooseTool}
onShowChange={hasTrigger ? (onPanelShowStateChange || (() => {})) : onShowChange}
disabled={false}
supportAddCustomTool
onSelect={onSelectTool}
onSelectMultiple={onSelectMultipleTool}
scope={scope}
selectedTools={selectedTools}
/>
</div>
{/* Description */}
<div className="flex flex-col gap-1">
<div className="system-sm-semibold flex h-6 items-center text-text-secondary">
{t('detailPanel.toolSelector.descriptionLabel', { ns: 'plugin' })}
</div>
<Textarea
className="resize-none"
placeholder={t('detailPanel.toolSelector.descriptionPlaceholder', { ns: 'plugin' })}
value={value?.extra?.description || ''}
onChange={onDescriptionChange}
disabled={!value?.provider_name}
/>
</div>
</div>
)
}
export default ToolBaseForm

View File

@ -1,157 +0,0 @@
'use client'
import type { FC } from 'react'
import type { Node } from 'reactflow'
import type { TabType } from '../hooks/use-tool-selector-state'
import type { ReasoningConfigValue } from './reasoning-config-form'
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types'
import type { NodeOutPutVar, ToolWithProvider } from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import TabSlider from '@/app/components/base/tab-slider-plain'
import ToolForm from '@/app/components/workflow/nodes/tool/components/tool-form'
import ReasoningConfigForm from './reasoning-config-form'
type ToolSettingsPanelProps = {
value?: ToolValue
currentProvider?: ToolWithProvider
nodeId: string
currType: TabType
settingsFormSchemas: ToolFormSchema[]
paramsFormSchemas: ToolFormSchema[]
settingsValue: ToolVarInputs
showTabSlider: boolean
userSettingsOnly: boolean
reasoningConfigOnly: boolean
nodeOutputVars: NodeOutPutVar[]
availableNodes: Node[]
onCurrTypeChange: (type: TabType) => void
onSettingsFormChange: (v: ToolVarInputs) => void
onParamsFormChange: (v: ReasoningConfigValue) => void
}
/**
* Renders the settings/params tips section
*/
const ParamsTips: FC = () => {
const { t } = useTranslation()
return (
<div className="pb-1">
<div className="system-xs-regular text-text-tertiary">
{t('detailPanel.toolSelector.paramsTip1', { ns: 'plugin' })}
</div>
<div className="system-xs-regular text-text-tertiary">
{t('detailPanel.toolSelector.paramsTip2', { ns: 'plugin' })}
</div>
</div>
)
}
const ToolSettingsPanel: FC<ToolSettingsPanelProps> = ({
value,
currentProvider,
nodeId,
currType,
settingsFormSchemas,
paramsFormSchemas,
settingsValue,
showTabSlider,
userSettingsOnly,
reasoningConfigOnly,
nodeOutputVars,
availableNodes,
onCurrTypeChange,
onSettingsFormChange,
onParamsFormChange,
}) => {
const { t } = useTranslation()
// Check if panel should be shown
const hasSettings = settingsFormSchemas.length > 0
const hasParams = paramsFormSchemas.length > 0
const isTeamAuthorized = currentProvider?.is_team_authorization
if ((!hasSettings && !hasParams) || !isTeamAuthorized)
return null
return (
<>
<Divider className="my-1 w-full" />
{/* Tab slider - shown only when both settings and params exist */}
{nodeId && showTabSlider && (
<TabSlider
className="mt-1 shrink-0 px-4"
itemClassName="py-3"
noBorderBottom
smallItem
value={currType}
onChange={(v) => {
if (v === 'settings' || v === 'params')
onCurrTypeChange(v)
}}
options={[
{ value: 'settings', text: t('detailPanel.toolSelector.settings', { ns: 'plugin' })! },
{ value: 'params', text: t('detailPanel.toolSelector.params', { ns: 'plugin' })! },
]}
/>
)}
{/* Params tips when tab slider and params tab is active */}
{nodeId && showTabSlider && currType === 'params' && (
<div className="px-4 py-2">
<ParamsTips />
</div>
)}
{/* User settings only header */}
{userSettingsOnly && (
<div className="p-4 pb-1">
<div className="system-sm-semibold-uppercase text-text-primary">
{t('detailPanel.toolSelector.settings', { ns: 'plugin' })}
</div>
</div>
)}
{/* Reasoning config only header */}
{nodeId && reasoningConfigOnly && (
<div className="mb-1 p-4 pb-1">
<div className="system-sm-semibold-uppercase text-text-primary">
{t('detailPanel.toolSelector.params', { ns: 'plugin' })}
</div>
<ParamsTips />
</div>
)}
{/* User settings form */}
{(currType === 'settings' || userSettingsOnly) && (
<div className="px-4 py-2">
<ToolForm
inPanel
readOnly={false}
nodeId={nodeId}
schema={settingsFormSchemas as CredentialFormSchema[]}
value={settingsValue}
onChange={onSettingsFormChange}
/>
</div>
)}
{/* Reasoning config form */}
{nodeId && (currType === 'params' || reasoningConfigOnly) && (
<ReasoningConfigForm
value={(value?.parameters || {}) as ReasoningConfigValue}
onChange={onParamsFormChange}
schemas={paramsFormSchemas}
nodeOutputVars={nodeOutputVars}
availableNodes={availableNodes}
nodeId={nodeId}
/>
)}
</>
)
}
export default ToolSettingsPanel

View File

@ -10,6 +10,5 @@ export const usePluginInstalledCheck = (providerName = '') => {
return {
inMarketPlace: !!manifest,
manifest: manifest?.data.plugin,
pluginID,
}
}

View File

@ -1,3 +0,0 @@
export { usePluginInstalledCheck } from './use-plugin-installed-check'
export { useToolSelectorState } from './use-tool-selector-state'
export type { TabType, ToolSelectorState, UseToolSelectorStateProps } from './use-tool-selector-state'

View File

@ -1,250 +0,0 @@
'use client'
import type { ReasoningConfigValue } from '../components/reasoning-config-form'
import type { ToolParameter } from '@/app/components/tools/types'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ResourceVarInputs } from '@/app/components/workflow/nodes/_base/types'
import { useCallback, useMemo, useState } from 'react'
import { generateFormValue, getPlainValue, getStructureValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
useInvalidateAllBuiltInTools,
} from '@/service/use-tools'
import { getIconFromMarketPlace } from '@/utils/get-icon'
import { usePluginInstalledCheck } from './use-plugin-installed-check'
export type TabType = 'settings' | 'params'
export type UseToolSelectorStateProps = {
value?: ToolValue
onSelect: (tool: ToolValue) => void
onSelectMultiple?: (tool: ToolValue[]) => void
}
/**
* Custom hook for managing tool selector state and computed values.
* Consolidates state management, data fetching, and event handlers.
*/
export const useToolSelectorState = ({
value,
onSelect,
onSelectMultiple,
}: UseToolSelectorStateProps) => {
// Panel visibility states
const [isShow, setIsShow] = useState(false)
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
const [currType, setCurrType] = useState<TabType>('settings')
// Fetch all tools data
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const invalidateAllBuiltinTools = useInvalidateAllBuiltInTools()
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
// Plugin info check
const { inMarketPlace, manifest, pluginID } = usePluginInstalledCheck(value?.provider_name)
// Merge all tools and find current provider
const currentProvider = useMemo(() => {
const mergedTools = [
...(buildInTools || []),
...(customTools || []),
...(workflowTools || []),
...(mcpTools || []),
]
return mergedTools.find(toolWithProvider => toolWithProvider.id === value?.provider_name)
}, [value, buildInTools, customTools, workflowTools, mcpTools])
// Current tool from provider
const currentTool = useMemo(() => {
return currentProvider?.tools.find(tool => tool.name === value?.tool_name)
}, [currentProvider?.tools, value?.tool_name])
// Tool settings and params
const currentToolSettings = useMemo(() => {
if (!currentProvider)
return []
return currentProvider.tools
.find(tool => tool.name === value?.tool_name)
?.parameters
.filter(param => param.form !== 'llm') || []
}, [currentProvider, value])
const currentToolParams = useMemo(() => {
if (!currentProvider)
return []
return currentProvider.tools
.find(tool => tool.name === value?.tool_name)
?.parameters
.filter(param => param.form === 'llm') || []
}, [currentProvider, value])
// Form schemas
const settingsFormSchemas = useMemo(
() => toolParametersToFormSchemas(currentToolSettings),
[currentToolSettings],
)
const paramsFormSchemas = useMemo(
() => toolParametersToFormSchemas(currentToolParams),
[currentToolParams],
)
// Tab visibility flags
const showTabSlider = currentToolSettings.length > 0 && currentToolParams.length > 0
const userSettingsOnly = currentToolSettings.length > 0 && !currentToolParams.length
const reasoningConfigOnly = currentToolParams.length > 0 && !currentToolSettings.length
// Manifest icon URL
const manifestIcon = useMemo(() => {
if (!manifest || !pluginID)
return ''
return getIconFromMarketPlace(pluginID)
}, [manifest, pluginID])
// Convert tool default value to tool value format
const getToolValue = useCallback((tool: ToolDefaultValue): ToolValue => {
const settingValues = generateFormValue(
tool.params,
toolParametersToFormSchemas((tool.paramSchemas as ToolParameter[]).filter(param => param.form !== 'llm')),
)
const paramValues = generateFormValue(
tool.params,
toolParametersToFormSchemas((tool.paramSchemas as ToolParameter[]).filter(param => param.form === 'llm')),
true,
)
return {
provider_name: tool.provider_id,
provider_show_name: tool.provider_name,
tool_name: tool.tool_name,
tool_label: tool.tool_label,
tool_description: tool.tool_description,
settings: settingValues,
parameters: paramValues,
enabled: tool.is_team_authorization,
extra: {
description: tool.tool_description,
},
}
}, [])
// Event handlers
const handleSelectTool = useCallback((tool: ToolDefaultValue) => {
const toolValue = getToolValue(tool)
onSelect(toolValue)
}, [getToolValue, onSelect])
const handleSelectMultipleTool = useCallback((tools: ToolDefaultValue[]) => {
const toolValues = tools.map(item => getToolValue(item))
onSelectMultiple?.(toolValues)
}, [getToolValue, onSelectMultiple])
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!value)
return
onSelect({
...value,
extra: {
...value.extra,
description: e.target.value || '',
},
})
}, [value, onSelect])
const handleSettingsFormChange = useCallback((v: ResourceVarInputs) => {
if (!value)
return
const newValue = getStructureValue(v)
onSelect({
...value,
settings: newValue,
})
}, [value, onSelect])
const handleParamsFormChange = useCallback((v: ReasoningConfigValue) => {
if (!value)
return
onSelect({
...value,
parameters: v,
})
}, [value, onSelect])
const handleEnabledChange = useCallback((state: boolean) => {
if (!value)
return
onSelect({
...value,
enabled: state,
})
}, [value, onSelect])
const handleAuthorizationItemClick = useCallback((id: string) => {
if (!value)
return
onSelect({
...value,
credential_id: id,
})
}, [value, onSelect])
const handleInstall = useCallback(async () => {
try {
await invalidateAllBuiltinTools()
}
catch (error) {
console.error('Failed to invalidate built-in tools cache', error)
}
try {
await invalidateInstalledPluginList()
}
catch (error) {
console.error('Failed to invalidate installed plugin list cache', error)
}
}, [invalidateAllBuiltinTools, invalidateInstalledPluginList])
const getSettingsValue = useCallback((): ResourceVarInputs => {
return getPlainValue((value?.settings || {}) as Record<string, { value: unknown }>) as ResourceVarInputs
}, [value?.settings])
return {
// State
isShow,
setIsShow,
isShowChooseTool,
setIsShowChooseTool,
currType,
setCurrType,
// Computed values
currentProvider,
currentTool,
currentToolSettings,
currentToolParams,
settingsFormSchemas,
paramsFormSchemas,
showTabSlider,
userSettingsOnly,
reasoningConfigOnly,
manifestIcon,
inMarketPlace,
manifest,
// Event handlers
handleSelectTool,
handleSelectMultipleTool,
handleDescriptionChange,
handleSettingsFormChange,
handleParamsFormChange,
handleEnabledChange,
handleAuthorizationItemClick,
handleInstall,
getSettingsValue,
}
}
export type ToolSelectorState = ReturnType<typeof useToolSelectorState>

View File

@ -5,26 +5,43 @@ import type {
} from '@floating-ui/react'
import type { FC } from 'react'
import type { Node } from 'reactflow'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import Link from 'next/link'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { CollectionType } from '@/app/components/tools/types'
import { cn } from '@/utils/classnames'
import TabSlider from '@/app/components/base/tab-slider-plain'
import Textarea from '@/app/components/base/textarea'
import {
ToolAuthorizationSection,
ToolBaseForm,
ToolItem,
ToolSettingsPanel,
ToolTrigger,
} from './components'
import { useToolSelectorState } from './hooks/use-tool-selector-state'
AuthCategory,
PluginAuthInAgent,
} from '@/app/components/plugins/plugin-auth'
import { usePluginInstalledCheck } from '@/app/components/plugins/plugin-detail-panel/tool-selector/hooks'
import ReasoningConfigForm from '@/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form'
import ToolItem from '@/app/components/plugins/plugin-detail-panel/tool-selector/tool-item'
import ToolTrigger from '@/app/components/plugins/plugin-detail-panel/tool-selector/tool-trigger'
import { CollectionType } from '@/app/components/tools/types'
import { generateFormValue, getPlainValue, getStructureValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import ToolForm from '@/app/components/workflow/nodes/tool/components/tool-form'
import { MARKETPLACE_API_PREFIX } from '@/config'
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
useInvalidateAllBuiltInTools,
} from '@/service/use-tools'
import { cn } from '@/utils/classnames'
import { ReadmeEntrance } from '../../readme-panel/entrance'
type Props = {
disabled?: boolean
@ -48,7 +65,6 @@ type Props = {
availableNodes: Node[]
nodeId?: string
}
const ToolSelector: FC<Props> = ({
value,
selectedTools,
@ -71,177 +87,321 @@ const ToolSelector: FC<Props> = ({
nodeId = '',
}) => {
const { t } = useTranslation()
// Use custom hook for state management
const state = useToolSelectorState({ value, onSelect, onSelectMultiple })
const {
isShow,
setIsShow,
isShowChooseTool,
setIsShowChooseTool,
currType,
setCurrType,
currentProvider,
currentTool,
settingsFormSchemas,
paramsFormSchemas,
showTabSlider,
userSettingsOnly,
reasoningConfigOnly,
manifestIcon,
inMarketPlace,
manifest,
handleSelectTool,
handleSelectMultipleTool,
handleDescriptionChange,
handleSettingsFormChange,
handleParamsFormChange,
handleEnabledChange,
handleAuthorizationItemClick,
handleInstall,
getSettingsValue,
} = state
const [isShow, onShowChange] = useState(false)
const handleTriggerClick = () => {
if (disabled)
return
setIsShow(true)
onShowChange(true)
}
// Determine portal open state based on controlled vs uncontrolled mode
const portalOpen = trigger ? controlledState : isShow
const onPortalOpenChange = trigger ? onControlledStateChange : setIsShow
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const invalidateAllBuiltinTools = useInvalidateAllBuiltInTools()
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
// Build error tooltip content
const renderErrorTip = () => (
<div className="max-w-[240px] space-y-1 text-xs">
<h3 className="font-semibold text-text-primary">
{currentTool
? t('detailPanel.toolSelector.uninstalledTitle', { ns: 'plugin' })
: t('detailPanel.toolSelector.unsupportedTitle', { ns: 'plugin' })}
</h3>
<p className="tracking-tight text-text-secondary">
{currentTool
? t('detailPanel.toolSelector.uninstalledContent', { ns: 'plugin' })
: t('detailPanel.toolSelector.unsupportedContent', { ns: 'plugin' })}
</p>
<p>
<Link href="/plugins" className="tracking-tight text-text-accent">
{t('detailPanel.toolSelector.uninstalledLink', { ns: 'plugin' })}
</Link>
</p>
</div>
)
// plugin info check
const { inMarketPlace, manifest } = usePluginInstalledCheck(value?.provider_name)
const currentProvider = useMemo(() => {
const mergedTools = [...(buildInTools || []), ...(customTools || []), ...(workflowTools || []), ...(mcpTools || [])]
return mergedTools.find((toolWithProvider) => {
return toolWithProvider.id === value?.provider_name
})
}, [value, buildInTools, customTools, workflowTools, mcpTools])
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
const getToolValue = (tool: ToolDefaultValue) => {
const settingValues = generateFormValue(tool.params, toolParametersToFormSchemas(tool.paramSchemas.filter(param => param.form !== 'llm') as any))
const paramValues = generateFormValue(tool.params, toolParametersToFormSchemas(tool.paramSchemas.filter(param => param.form === 'llm') as any), true)
return {
provider_name: tool.provider_id,
provider_show_name: tool.provider_name,
type: tool.provider_type,
tool_name: tool.tool_name,
tool_label: tool.tool_label,
tool_description: tool.tool_description,
settings: settingValues,
parameters: paramValues,
enabled: tool.is_team_authorization,
extra: {
description: tool.tool_description,
},
schemas: tool.paramSchemas,
}
}
const handleSelectTool = (tool: ToolDefaultValue) => {
const toolValue = getToolValue(tool)
onSelect(toolValue)
// setIsShowChooseTool(false)
}
const handleSelectMultipleTool = (tool: ToolDefaultValue[]) => {
const toolValues = tool.map(item => getToolValue(item))
onSelectMultiple?.(toolValues)
}
const handleDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onSelect({
...value,
extra: {
...value?.extra,
description: e.target.value || '',
},
} as any)
}
// tool settings & params
const currentToolSettings = useMemo(() => {
if (!currentProvider)
return []
return currentProvider.tools.find(tool => tool.name === value?.tool_name)?.parameters.filter(param => param.form !== 'llm') || []
}, [currentProvider, value])
const currentToolParams = useMemo(() => {
if (!currentProvider)
return []
return currentProvider.tools.find(tool => tool.name === value?.tool_name)?.parameters.filter(param => param.form === 'llm') || []
}, [currentProvider, value])
const [currType, setCurrType] = useState('settings')
const showTabSlider = currentToolSettings.length > 0 && currentToolParams.length > 0
const userSettingsOnly = currentToolSettings.length > 0 && !currentToolParams.length
const reasoningConfigOnly = currentToolParams.length > 0 && !currentToolSettings.length
const settingsFormSchemas = useMemo(() => toolParametersToFormSchemas(currentToolSettings), [currentToolSettings])
const paramsFormSchemas = useMemo(() => toolParametersToFormSchemas(currentToolParams), [currentToolParams])
const handleSettingsFormChange = (v: Record<string, any>) => {
const newValue = getStructureValue(v)
const toolValue = {
...value,
settings: newValue,
}
onSelect(toolValue as any)
}
const handleParamsFormChange = (v: Record<string, any>) => {
const toolValue = {
...value,
parameters: v,
}
onSelect(toolValue as any)
}
const handleEnabledChange = (state: boolean) => {
onSelect({
...value,
enabled: state,
} as any)
}
// install from marketplace
const currentTool = useMemo(() => {
return currentProvider?.tools.find(tool => tool.name === value?.tool_name)
}, [currentProvider?.tools, value?.tool_name])
const manifestIcon = useMemo(() => {
if (!manifest)
return ''
return `${MARKETPLACE_API_PREFIX}/plugins/${(manifest as any).plugin_id}/icon`
}, [manifest])
const handleInstall = async () => {
invalidateAllBuiltinTools()
invalidateInstalledPluginList()
}
const handleAuthorizationItemClick = (id: string) => {
onSelect({
...value,
credential_id: id,
} as any)
}
return (
<PortalToFollowElem
placement={placement}
offset={offset}
open={portalOpen}
onOpenChange={onPortalOpenChange}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={() => {
if (!currentProvider || !currentTool)
return
handleTriggerClick()
}}
<>
<PortalToFollowElem
placement={placement}
offset={offset}
open={trigger ? controlledState : isShow}
onOpenChange={trigger ? onControlledStateChange : onShowChange}
>
{trigger}
{/* Default trigger - no value */}
{!trigger && !value?.provider_name && (
<ToolTrigger
isConfigure
open={isShow}
value={value}
provider={currentProvider}
/>
)}
{/* Default trigger - with value */}
{!trigger && value?.provider_name && (
<ToolItem
open={isShow}
icon={currentProvider?.icon || manifestIcon}
isMCPTool={currentProvider?.type === CollectionType.mcp}
providerName={value.provider_name}
providerShowName={value.provider_show_name}
toolLabel={value.tool_label || value.tool_name}
showSwitch={supportEnableSwitch}
switchValue={value.enabled}
onSwitchChange={handleEnabledChange}
onDelete={onDelete}
noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
uninstalled={!currentProvider && inMarketPlace}
versionMismatch={currentProvider && inMarketPlace && !currentTool}
installInfo={manifest?.latest_package_identifier}
onInstall={handleInstall}
isError={(!currentProvider || !currentTool) && !inMarketPlace}
errorTip={renderErrorTip()}
/>
)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
<div className={cn(
'relative max-h-[642px] min-h-20 w-[361px] rounded-xl',
'border-[0.5px] border-components-panel-border bg-components-panel-bg-blur',
'overflow-y-auto pb-2 pb-4 shadow-lg backdrop-blur-sm',
)}
<PortalToFollowElemTrigger
className="w-full"
onClick={() => {
if (!currentProvider || !currentTool)
return
handleTriggerClick()
}}
>
{/* Header */}
<div className="system-xl-semibold px-4 pb-1 pt-3.5 text-text-primary">
{t(`detailPanel.toolSelector.${isEdit ? 'toolSetting' : 'title'}`, { ns: 'plugin' })}
{trigger}
{!trigger && !value?.provider_name && (
<ToolTrigger
isConfigure
open={isShow}
value={value}
provider={currentProvider}
/>
)}
{!trigger && value?.provider_name && (
<ToolItem
open={isShow}
icon={currentProvider?.icon || manifestIcon}
isMCPTool={currentProvider?.type === CollectionType.mcp}
providerName={value.provider_name}
providerShowName={value.provider_show_name}
toolLabel={value.tool_label || value.tool_name}
showSwitch={supportEnableSwitch}
switchValue={value.enabled}
onSwitchChange={handleEnabledChange}
onDelete={onDelete}
noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
uninstalled={!currentProvider && inMarketPlace}
versionMismatch={currentProvider && inMarketPlace && !currentTool}
installInfo={manifest?.latest_package_identifier}
onInstall={() => handleInstall()}
isError={(!currentProvider || !currentTool) && !inMarketPlace}
errorTip={(
<div className="max-w-[240px] space-y-1 text-xs">
<h3 className="font-semibold text-text-primary">{currentTool ? t('detailPanel.toolSelector.uninstalledTitle', { ns: 'plugin' }) : t('detailPanel.toolSelector.unsupportedTitle', { ns: 'plugin' })}</h3>
<p className="tracking-tight text-text-secondary">{currentTool ? t('detailPanel.toolSelector.uninstalledContent', { ns: 'plugin' }) : t('detailPanel.toolSelector.unsupportedContent', { ns: 'plugin' })}</p>
<p>
<Link href="/plugins" className="tracking-tight text-text-accent">{t('detailPanel.toolSelector.uninstalledLink', { ns: 'plugin' })}</Link>
</p>
</div>
)}
/>
)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
<div className={cn('relative max-h-[642px] min-h-20 w-[361px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-4 shadow-lg backdrop-blur-sm', 'overflow-y-auto pb-2')}>
<>
<div className="system-xl-semibold px-4 pb-1 pt-3.5 text-text-primary">{t(`detailPanel.toolSelector.${isEdit ? 'toolSetting' : 'title'}`, { ns: 'plugin' })}</div>
{/* base form */}
<div className="flex flex-col gap-3 px-4 py-2">
<div className="flex flex-col gap-1">
<div className="system-sm-semibold flex h-6 items-center justify-between text-text-secondary">
{t('detailPanel.toolSelector.toolLabel', { ns: 'plugin' })}
<ReadmeEntrance pluginDetail={currentProvider as any} showShortTip className="pb-0" />
</div>
<ToolPicker
placement="bottom"
offset={offset}
trigger={(
<ToolTrigger
open={panelShowState || isShowChooseTool}
value={value}
provider={currentProvider}
/>
)}
isShow={panelShowState || isShowChooseTool}
onShowChange={trigger ? onPanelShowStateChange as any : setIsShowChooseTool}
disabled={false}
supportAddCustomTool
onSelect={handleSelectTool}
onSelectMultiple={handleSelectMultipleTool}
scope={scope}
selectedTools={selectedTools}
/>
</div>
<div className="flex flex-col gap-1">
<div className="system-sm-semibold flex h-6 items-center text-text-secondary">{t('detailPanel.toolSelector.descriptionLabel', { ns: 'plugin' })}</div>
<Textarea
className="resize-none"
placeholder={t('detailPanel.toolSelector.descriptionPlaceholder', { ns: 'plugin' })}
value={value?.extra?.description || ''}
onChange={handleDescriptionChange}
disabled={!value?.provider_name}
/>
</div>
</div>
{/* authorization */}
{currentProvider && currentProvider.type === CollectionType.builtIn && currentProvider.allow_delete && (
<>
<Divider className="my-1 w-full" />
<div className="px-4 py-2">
<PluginAuthInAgent
pluginPayload={{
provider: currentProvider.name,
category: AuthCategory.tool,
providerType: currentProvider.type,
detail: currentProvider as any,
}}
credentialId={value?.credential_id}
onAuthorizationItemClick={handleAuthorizationItemClick}
/>
</div>
</>
)}
{/* tool settings */}
{(currentToolSettings.length > 0 || currentToolParams.length > 0) && currentProvider?.is_team_authorization && (
<>
<Divider className="my-1 w-full" />
{/* tabs */}
{nodeId && showTabSlider && (
<TabSlider
className="mt-1 shrink-0 px-4"
itemClassName="py-3"
noBorderBottom
smallItem
value={currType}
onChange={(value) => {
setCurrType(value)
}}
options={[
{ value: 'settings', text: t('detailPanel.toolSelector.settings', { ns: 'plugin' })! },
{ value: 'params', text: t('detailPanel.toolSelector.params', { ns: 'plugin' })! },
]}
/>
)}
{nodeId && showTabSlider && currType === 'params' && (
<div className="px-4 py-2">
<div className="system-xs-regular text-text-tertiary">{t('detailPanel.toolSelector.paramsTip1', { ns: 'plugin' })}</div>
<div className="system-xs-regular text-text-tertiary">{t('detailPanel.toolSelector.paramsTip2', { ns: 'plugin' })}</div>
</div>
)}
{/* user settings only */}
{userSettingsOnly && (
<div className="p-4 pb-1">
<div className="system-sm-semibold-uppercase text-text-primary">{t('detailPanel.toolSelector.settings', { ns: 'plugin' })}</div>
</div>
)}
{/* reasoning config only */}
{nodeId && reasoningConfigOnly && (
<div className="mb-1 p-4 pb-1">
<div className="system-sm-semibold-uppercase text-text-primary">{t('detailPanel.toolSelector.params', { ns: 'plugin' })}</div>
<div className="pb-1">
<div className="system-xs-regular text-text-tertiary">{t('detailPanel.toolSelector.paramsTip1', { ns: 'plugin' })}</div>
<div className="system-xs-regular text-text-tertiary">{t('detailPanel.toolSelector.paramsTip2', { ns: 'plugin' })}</div>
</div>
</div>
)}
{/* user settings form */}
{(currType === 'settings' || userSettingsOnly) && (
<div className="px-4 py-2">
<ToolForm
inPanel
readOnly={false}
nodeId={nodeId}
schema={settingsFormSchemas as any}
value={getPlainValue(value?.settings || {})}
onChange={handleSettingsFormChange}
/>
</div>
)}
{/* reasoning config form */}
{nodeId && (currType === 'params' || reasoningConfigOnly) && (
<ReasoningConfigForm
value={value?.parameters || {}}
onChange={handleParamsFormChange}
schemas={paramsFormSchemas as any}
nodeOutputVars={nodeOutputVars}
availableNodes={availableNodes}
nodeId={nodeId}
/>
)}
</>
)}
</>
</div>
{/* Base form: tool picker + description */}
<ToolBaseForm
value={value}
currentProvider={currentProvider}
offset={offset}
scope={scope}
selectedTools={selectedTools}
isShowChooseTool={isShowChooseTool}
panelShowState={panelShowState}
hasTrigger={!!trigger}
onShowChange={setIsShowChooseTool}
onPanelShowStateChange={onPanelShowStateChange}
onSelectTool={handleSelectTool}
onSelectMultipleTool={handleSelectMultipleTool}
onDescriptionChange={handleDescriptionChange}
/>
{/* Authorization section */}
<ToolAuthorizationSection
currentProvider={currentProvider}
credentialId={value?.credential_id}
onAuthorizationItemClick={handleAuthorizationItemClick}
/>
{/* Settings panel */}
<ToolSettingsPanel
value={value}
currentProvider={currentProvider}
nodeId={nodeId}
currType={currType}
settingsFormSchemas={settingsFormSchemas}
paramsFormSchemas={paramsFormSchemas}
settingsValue={getSettingsValue()}
showTabSlider={showTabSlider}
userSettingsOnly={userSettingsOnly}
reasoningConfigOnly={reasoningConfigOnly}
nodeOutputVars={nodeOutputVars}
availableNodes={availableNodes}
onCurrTypeChange={setCurrType}
onSettingsFormChange={handleSettingsFormChange}
onParamsFormChange={handleParamsFormChange}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PortalToFollowElemContent>
</PortalToFollowElem>
</>
)
}
export default React.memo(ToolSelector)

View File

@ -1,12 +1,9 @@
import type { Node } from 'reactflow'
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types'
import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types'
import type {
NodeOutPutVar,
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import {
RiArrowRightUpLine,
@ -35,22 +32,10 @@ import { VarType } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import SchemaModal from './schema-modal'
type ReasoningConfigInputValue = {
type?: VarKindType
value?: unknown
} | null
type ReasoningConfigInput = {
value: ReasoningConfigInputValue
auto?: 0 | 1
}
export type ReasoningConfigValue = Record<string, ReasoningConfigInput>
type Props = {
value: ReasoningConfigValue
onChange: (val: ReasoningConfigValue) => void
schemas: ToolFormSchema[]
value: Record<string, any>
onChange: (val: Record<string, any>) => void
schemas: any[]
nodeOutputVars: NodeOutPutVar[]
availableNodes: Node[]
nodeId: string
@ -66,7 +51,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
}) => {
const { t } = useTranslation()
const language = useLanguage()
const getVarKindType = (type: string) => {
const getVarKindType = (type: FormTypeEnum) => {
if (type === FormTypeEnum.file || type === FormTypeEnum.files)
return VarKindType.variable
if (type === FormTypeEnum.select || type === FormTypeEnum.checkbox || type === FormTypeEnum.textNumber || type === FormTypeEnum.array || type === FormTypeEnum.object)
@ -75,7 +60,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
return VarKindType.mixed
}
const handleAutomatic = (key: string, val: boolean, type: string) => {
const handleAutomatic = (key: string, val: any, type: FormTypeEnum) => {
onChange({
...value,
[key]: {
@ -84,7 +69,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
},
})
}
const handleTypeChange = useCallback((variable: string, defaultValue: unknown) => {
const handleTypeChange = useCallback((variable: string, defaultValue: any) => {
return (newType: VarKindType) => {
const res = produce(value, (draft: ToolVarInputs) => {
draft[variable].value = {
@ -95,8 +80,8 @@ const ReasoningConfigForm: React.FC<Props> = ({
onChange(res)
}
}, [onChange, value])
const handleValueChange = useCallback((variable: string, varType: string) => {
return (newValue: unknown) => {
const handleValueChange = useCallback((variable: string, varType: FormTypeEnum) => {
return (newValue: any) => {
const res = produce(value, (draft: ToolVarInputs) => {
draft[variable].value = {
type: getVarKindType(varType),
@ -109,23 +94,22 @@ const ReasoningConfigForm: React.FC<Props> = ({
const handleAppChange = useCallback((variable: string) => {
return (app: {
app_id: string
inputs: Record<string, unknown>
files?: unknown[]
inputs: Record<string, any>
files?: any[]
}) => {
const newValue = produce(value, (draft: ToolVarInputs) => {
draft[variable].value = app
draft[variable].value = app as any
})
onChange(newValue)
}
}, [onChange, value])
const handleModelChange = useCallback((variable: string) => {
return (model: Record<string, unknown>) => {
return (model: any) => {
const newValue = produce(value, (draft: ToolVarInputs) => {
const currentValue = draft[variable].value as Record<string, unknown> | undefined
draft[variable].value = {
...currentValue,
...draft[variable].value,
...model,
}
} as any
})
onChange(newValue)
}
@ -150,7 +134,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
const [schema, setSchema] = useState<SchemaRoot | null>(null)
const [schemaRootName, setSchemaRootName] = useState<string>('')
const renderField = (schema: ToolFormSchema, showSchema: (schema: SchemaRoot, rootName: string) => void) => {
const renderField = (schema: any, showSchema: (schema: SchemaRoot, rootName: string) => void) => {
const {
default: defaultValue,
variable,
@ -210,17 +194,17 @@ const ReasoningConfigForm: React.FC<Props> = ({
}
const getFilterVar = () => {
if (isNumber)
return (varPayload: Var) => varPayload.type === VarType.number
return (varPayload: any) => varPayload.type === VarType.number
else if (isString)
return (varPayload: Var) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
return (varPayload: any) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
else if (isFile)
return (varPayload: Var) => [VarType.file, VarType.arrayFile].includes(varPayload.type)
return (varPayload: any) => [VarType.file, VarType.arrayFile].includes(varPayload.type)
else if (isBoolean)
return (varPayload: Var) => varPayload.type === VarType.boolean
return (varPayload: any) => varPayload.type === VarType.boolean
else if (isObject)
return (varPayload: Var) => varPayload.type === VarType.object
return (varPayload: any) => varPayload.type === VarType.object
else if (isArray)
return (varPayload: Var) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type)
return (varPayload: any) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type)
return undefined
}
@ -280,7 +264,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
<Input
className="h-8 grow"
type="number"
value={(varInput?.value as string | number) || ''}
value={varInput?.value || ''}
onChange={e => handleValueChange(variable, type)(e.target.value)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
@ -291,16 +275,16 @@ const ReasoningConfigForm: React.FC<Props> = ({
onChange={handleValueChange(variable, type)}
/>
)}
{isSelect && options && (
{isSelect && (
<SimpleSelect
wrapperClassName="h-8 grow"
defaultValue={varInput?.value as string | number | undefined}
items={options.filter((option) => {
defaultValue={varInput?.value}
items={options.filter((option: { show_on: any[] }) => {
if (option.show_on.length)
return option.show_on.every(showOnItem => value[showOnItem.variable]?.value?.value === showOnItem.value)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
}).map((option: { value: any, label: { [x: string]: any, en_US: any } }) => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
onSelect={item => handleValueChange(variable, type)(item.value as string)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
@ -309,7 +293,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
<div className="mt-1 w-full">
<CodeEditor
title="JSON"
value={varInput?.value as string}
value={varInput?.value as any}
isExpand
isInNode
height={100}
@ -324,7 +308,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
<AppSelector
disabled={false}
scope={scope || 'all'}
value={varInput as { app_id: string, inputs: Record<string, unknown>, files?: unknown[] } | undefined}
value={varInput as any}
onSelect={handleAppChange(variable)}
/>
)}
@ -345,10 +329,10 @@ const ReasoningConfigForm: React.FC<Props> = ({
readonly={false}
isShowNodeName
nodeId={nodeId}
value={(varInput?.value as string | ValueSelector) || []}
value={varInput?.value || []}
onChange={handleVariableSelectorChange(variable)}
filterVar={getFilterVar()}
schema={schema as Partial<CredentialFormSchema>}
schema={schema}
valueTypePlaceHolder={targetVarType()}
/>
)}

View File

@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import type { Collection } from '@/app/components/tools/types'
import type { ToolCredentialFormSchema } from '@/app/components/tools/utils/to-form-schema'
import {
RiArrowRightUpLine,
} from '@remixicon/react'
@ -20,7 +19,7 @@ import { cn } from '@/utils/classnames'
type Props = {
collection: Collection
onCancel: () => void
onSaved: (value: Record<string, unknown>) => void
onSaved: (value: Record<string, any>) => void
}
const ToolCredentialForm: FC<Props> = ({
@ -30,9 +29,9 @@ const ToolCredentialForm: FC<Props> = ({
}) => {
const getValueFromI18nObject = useRenderI18nObject()
const { t } = useTranslation()
const [credentialSchema, setCredentialSchema] = useState<ToolCredentialFormSchema[] | null>(null)
const [credentialSchema, setCredentialSchema] = useState<any>(null)
const { name: collectionName } = collection
const [tempCredential, setTempCredential] = React.useState<Record<string, unknown>>({})
const [tempCredential, setTempCredential] = React.useState<any>({})
useEffect(() => {
fetchBuiltInToolCredentialSchema(collectionName).then(async (res) => {
const toolCredentialSchemas = toolCredentialToFormSchemas(res)
@ -45,8 +44,6 @@ const ToolCredentialForm: FC<Props> = ({
}, [])
const handleSave = () => {
if (!credentialSchema)
return
for (const field of credentialSchema) {
if (field.required && !tempCredential[field.name]) {
Toast.notify({ type: 'error', message: t('errorMsg.fieldRequired', { ns: 'common', field: getValueFromI18nObject(field.label) }) })

View File

@ -22,7 +22,7 @@ import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/compo
import { cn } from '@/utils/classnames'
type Props = {
icon?: string | { content?: string, background?: string }
icon?: any
providerName?: string
isMCPTool?: boolean
providerShowName?: string
@ -33,7 +33,7 @@ type Props = {
onDelete?: () => void
noAuth?: boolean
isError?: boolean
errorTip?: React.ReactNode
errorTip?: any
uninstalled?: boolean
installInfo?: string
onInstall?: () => void

View File

@ -19,9 +19,8 @@ vi.mock('@/service/use-plugins', () => ({
}))
// Mock useLanguage hook
let mockLanguage = 'en-US'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => mockLanguage,
useLanguage: () => 'en-US',
}))
// Mock DetailHeader component (complex component with many dependencies)
@ -694,23 +693,6 @@ describe('ReadmePanel', () => {
expect(currentPluginDetail).toBeDefined()
})
})
it('should not close panel when content area is clicked in modal mode', async () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
renderWithQueryClient(<ReadmePanel />)
// Click on the content container in modal mode (should stop propagation)
const contentContainer = document.querySelector('.pointer-events-auto')
fireEvent.click(contentContainer!)
await waitFor(() => {
const { currentPluginDetail } = useReadmePanelStore.getState()
expect(currentPluginDetail).toBeDefined()
})
})
})
// ================================
@ -733,25 +715,20 @@ describe('ReadmePanel', () => {
})
it('should pass undefined language for zh-Hans locale', () => {
// Set language to zh-Hans
mockLanguage = 'zh-Hans'
// Re-mock useLanguage to return zh-Hans
vi.doMock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'zh-Hans',
}))
const mockDetail = createMockPluginDetail({
plugin_unique_identifier: 'zh-plugin@1.0.0',
})
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
// This test verifies the language handling logic exists in the component
renderWithQueryClient(<ReadmePanel />)
// The component should pass undefined for language when zh-Hans
expect(mockUsePluginReadme).toHaveBeenCalledWith({
plugin_unique_identifier: 'zh-plugin@1.0.0',
language: undefined,
})
// Reset language
mockLanguage = 'en-US'
// The component should have called the hook
expect(mockUsePluginReadme).toHaveBeenCalled()
})
it('should handle empty plugin_unique_identifier', () => {

View File

@ -410,9 +410,9 @@ const ProviderDetail = ({
onRemove={onClickCustomToolDelete}
/>
)}
{isShowEditWorkflowToolModal && customCollection && (
{isShowEditWorkflowToolModal && (
<WorkflowToolModal
payload={customCollection as WorkflowToolProviderResponse & { parameters: { name: string, description: string, form: string, required?: boolean, type?: string }[], labels: string[] }}
payload={customCollection}
onHide={() => setIsShowEditWorkflowToolModal(false)}
onRemove={onClickWorkflowToolDelete}
onSave={updateWorkflowToolProvider}

View File

@ -52,7 +52,7 @@ export type Collection = {
icon_dark?: string | Emoji
label: TypeWithI18N
type: CollectionType | string
team_credentials: Record<string, unknown>
team_credentials: Record<string, any>
is_team_authorization: boolean
allow_delete: boolean
labels: string[]
@ -124,7 +124,6 @@ export type Event = {
description: TypeWithI18N
parameters: TriggerParameter[]
labels: string[]
// eslint-disable-next-line ts/no-explicit-any
output_schema: Record<string, any>
}
@ -132,10 +131,9 @@ export type Tool = {
name: string
author: string
label: TypeWithI18N
description: TypeWithI18N
description: any
parameters: ToolParameter[]
labels: string[]
// eslint-disable-next-line ts/no-explicit-any
output_schema: Record<string, any>
}
@ -217,7 +215,6 @@ export type WorkflowToolProviderOutputSchema = {
export type WorkflowToolProviderRequest = {
name: string
label: string
icon: Emoji
description: string
parameters: WorkflowToolProviderParameter[]

View File

@ -1,70 +1,8 @@
import type { TriggerEventParameter } from '../../plugins/types'
import type { ToolCredential, ToolParameter } from '../types'
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
// Type for form value input with type and value properties
type FormValueInput = {
type?: string
value?: unknown
}
/**
* Form schema type for tool credentials.
* This type represents the schema returned by toolCredentialToFormSchemas.
*/
export type ToolCredentialFormSchema = {
name: string
variable: string
label: TypeWithI18N
type: string
required: boolean
default?: string
tooltip?: TypeWithI18N
placeholder?: TypeWithI18N
show_on: { variable: string, value: string }[]
options?: {
label: TypeWithI18N
value: string
show_on: { variable: string, value: string }[]
}[]
help?: TypeWithI18N | null
url?: string
}
/**
* Form schema type for tool parameters.
* This type represents the schema returned by toolParametersToFormSchemas.
*/
export type ToolFormSchema = {
name: string
variable: string
label: TypeWithI18N
type: string
_type: string
form: string
required: boolean
default?: string
tooltip?: TypeWithI18N
show_on: { variable: string, value: string }[]
options?: {
label: TypeWithI18N
value: string
show_on: { variable: string, value: string }[]
}[]
placeholder?: TypeWithI18N
min?: number
max?: number
llm_description?: string
human_description?: TypeWithI18N
multiple?: boolean
url?: string
scope?: string
input_schema?: SchemaRoot
}
export const toType = (type: string) => {
switch (type) {
case 'string':
@ -92,11 +30,11 @@ export const triggerEventParametersToFormSchemas = (parameters: TriggerEventPara
})
}
export const toolParametersToFormSchemas = (parameters: ToolParameter[]): ToolFormSchema[] => {
export const toolParametersToFormSchemas = (parameters: ToolParameter[]) => {
if (!parameters)
return []
const formSchemas = parameters.map((parameter): ToolFormSchema => {
const formSchemas = parameters.map((parameter) => {
return {
...parameter,
variable: parameter.name,
@ -115,17 +53,17 @@ export const toolParametersToFormSchemas = (parameters: ToolParameter[]): ToolFo
return formSchemas
}
export const toolCredentialToFormSchemas = (parameters: ToolCredential[]): ToolCredentialFormSchema[] => {
export const toolCredentialToFormSchemas = (parameters: ToolCredential[]) => {
if (!parameters)
return []
const formSchemas = parameters.map((parameter): ToolCredentialFormSchema => {
const formSchemas = parameters.map((parameter) => {
return {
...parameter,
variable: parameter.name,
type: toType(parameter.type),
label: parameter.label,
tooltip: parameter.help ?? undefined,
tooltip: parameter.help,
show_on: [],
options: parameter.options?.map((option) => {
return {
@ -138,7 +76,7 @@ export const toolCredentialToFormSchemas = (parameters: ToolCredential[]): ToolC
return formSchemas
}
export const addDefaultValue = (value: Record<string, unknown>, formSchemas: { variable: string, type: string, default?: unknown }[]) => {
export const addDefaultValue = (value: Record<string, any>, formSchemas: { variable: string, type: string, default?: any }[]) => {
const newValues = { ...value }
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
@ -158,7 +96,7 @@ export const addDefaultValue = (value: Record<string, unknown>, formSchemas: { v
return newValues
}
const correctInitialData = (type: string, target: FormValueInput, defaultValue: unknown): FormValueInput => {
const correctInitialData = (type: string, target: any, defaultValue: any) => {
if (type === 'text-input' || type === 'secret-input')
target.type = 'mixed'
@ -184,39 +122,39 @@ const correctInitialData = (type: string, target: FormValueInput, defaultValue:
return target
}
export const generateFormValue = (value: Record<string, unknown>, formSchemas: { variable: string, default?: unknown, type: string }[], isReasoning = false) => {
const newValues: Record<string, unknown> = {}
export const generateFormValue = (value: Record<string, any>, formSchemas: { variable: string, default?: any, type: string }[], isReasoning = false) => {
const newValues = {} as any
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) {
const defaultVal = formSchema.default
if (isReasoning) {
newValues[formSchema.variable] = { auto: 1, value: null }
}
else {
const initialValue: FormValueInput = { type: 'constant', value: formSchema.default }
newValues[formSchema.variable] = {
value: correctInitialData(formSchema.type, initialValue, defaultVal),
}
const value = formSchema.default
newValues[formSchema.variable] = {
value: {
type: 'constant',
value: formSchema.default,
},
...(isReasoning ? { auto: 1, value: null } : {}),
}
if (!isReasoning)
newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value, value)
}
})
return newValues
}
export const getPlainValue = (value: Record<string, { value: unknown }>) => {
const plainValue: Record<string, unknown> = {}
Object.keys(value).forEach((key) => {
export const getPlainValue = (value: Record<string, any>) => {
const plainValue = { ...value }
Object.keys(plainValue).forEach((key) => {
plainValue[key] = {
...(value[key].value as object),
...value[key].value,
}
})
return plainValue
}
export const getStructureValue = (value: Record<string, unknown>): Record<string, { value: unknown }> => {
const newValue: Record<string, { value: unknown }> = {}
Object.keys(value).forEach((key) => {
export const getStructureValue = (value: Record<string, any>) => {
const newValue = { ...value } as any
Object.keys(newValue).forEach((key) => {
newValue[key] = {
value: value[key],
}
@ -224,17 +162,17 @@ export const getStructureValue = (value: Record<string, unknown>): Record<string
return newValue
}
export const getConfiguredValue = (value: Record<string, unknown>, formSchemas: { variable: string, type: string, default?: unknown }[]) => {
const newValues: Record<string, unknown> = { ...value }
export const getConfiguredValue = (value: Record<string, any>, formSchemas: { variable: string, type: string, default?: any }[]) => {
const newValues = { ...value }
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) {
const defaultVal = formSchema.default
const initialValue: FormValueInput = {
const value = formSchema.default
newValues[formSchema.variable] = {
type: 'constant',
value: typeof formSchema.default === 'string' ? formSchema.default.replace(/\n/g, '\\n') : formSchema.default,
}
newValues[formSchema.variable] = correctInitialData(formSchema.type, initialValue, defaultVal)
newValues[formSchema.variable] = correctInitialData(formSchema.type, newValues[formSchema.variable], value)
}
})
return newValues
@ -249,24 +187,24 @@ const getVarKindType = (type: FormTypeEnum) => {
return VarKindType.mixed
}
export const generateAgentToolValue = (value: Record<string, { value?: unknown, auto?: 0 | 1 }>, formSchemas: { variable: string, default?: unknown, type: string }[], isReasoning = false) => {
const newValues: Record<string, { value: FormValueInput | null, auto?: 0 | 1 }> = {}
export const generateAgentToolValue = (value: Record<string, any>, formSchemas: { variable: string, default?: any, type: string }[], isReasoning = false) => {
const newValues = {} as any
if (!isReasoning) {
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
newValues[formSchema.variable] = {
value: {
type: 'constant',
value: itemValue?.value,
value: itemValue.value,
},
}
newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value!, itemValue?.value)
newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value, itemValue.value)
})
}
else {
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
if (itemValue?.auto === 1) {
if (itemValue.auto === 1) {
newValues[formSchema.variable] = {
auto: 1,
value: null,
@ -275,7 +213,7 @@ export const generateAgentToolValue = (value: Record<string, { value?: unknown,
else {
newValues[formSchema.variable] = {
auto: 0,
value: (itemValue?.value as FormValueInput) || {
value: itemValue.value || {
type: getVarKindType(formSchema.type as FormTypeEnum),
value: null,
},

View File

@ -1,105 +0,0 @@
'use client'
import type { FC } from 'react'
import type { WorkflowToolProviderParameter } from '@/app/components/tools/types'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import MethodSelector from '../method-selector'
type ToolInputTableProps = {
parameters: WorkflowToolProviderParameter[]
onParameterChange: (key: 'description' | 'form', value: string, index: number) => void
}
type ParameterRowProps = {
item: WorkflowToolProviderParameter
index: number
onParameterChange: (key: 'description' | 'form', value: string, index: number) => void
}
const ParameterRow: FC<ParameterRowProps> = ({ item, index, onParameterChange }) => {
const { t } = useTranslation()
const isImageParameter = item.name === '__image'
return (
<tr className="border-b border-divider-regular last:border-0">
<td className="max-w-[156px] p-2 pl-3">
<div className="text-[13px] leading-[18px]">
<div title={item.name} className="flex">
<span className="truncate font-medium text-text-primary">{item.name}</span>
{item.required && (
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">
{t('createTool.toolInput.required', { ns: 'tools' })}
</span>
)}
</div>
<div className="text-text-tertiary">{item.type}</div>
</div>
</td>
<td>
{isImageParameter
? (
<div className={cn(
'flex h-9 min-h-[56px] cursor-default items-center gap-1 bg-transparent px-3 py-2',
)}
>
<div className="grow truncate text-[13px] leading-[18px] text-text-secondary">
{t('createTool.toolInput.methodParameter', { ns: 'tools' })}
</div>
</div>
)
: (
<MethodSelector
value={item.form}
onChange={value => onParameterChange('form', value, index)}
/>
)}
</td>
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
<input
type="text"
className="w-full appearance-none bg-transparent text-[13px] font-normal leading-[18px] text-text-secondary caret-primary-600 outline-none placeholder:text-text-quaternary"
placeholder={t('createTool.toolInput.descriptionPlaceholder', { ns: 'tools' })!}
value={item.description}
onChange={e => onParameterChange('description', e.target.value, index)}
/>
</td>
</tr>
)
}
const ToolInputTable: FC<ToolInputTableProps> = ({ parameters, onParameterChange }) => {
const { t } = useTranslation()
return (
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
<table className="w-full text-xs font-normal leading-[18px] text-text-secondary">
<thead className="uppercase text-text-tertiary">
<tr className="border-b border-divider-regular">
<th className="w-[156px] p-2 pl-3 font-medium">
{t('createTool.toolInput.name', { ns: 'tools' })}
</th>
<th className="w-[102px] p-2 pl-3 font-medium">
{t('createTool.toolInput.method', { ns: 'tools' })}
</th>
<th className="p-2 pl-3 font-medium">
{t('createTool.toolInput.description', { ns: 'tools' })}
</th>
</tr>
</thead>
<tbody>
{parameters.map((item, index) => (
<ParameterRow
key={item.name}
item={item}
index={index}
onParameterChange={onParameterChange}
/>
))}
</tbody>
</table>
</div>
)
}
export default React.memo(ToolInputTable)

View File

@ -1,88 +0,0 @@
'use client'
import type { FC } from 'react'
import type { WorkflowToolProviderOutputParameter } from '@/app/components/tools/types'
import { RiErrorWarningLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
type ToolOutputTableProps = {
parameters: WorkflowToolProviderOutputParameter[]
isReserved: (name: string) => boolean
}
type OutputRowProps = {
item: WorkflowToolProviderOutputParameter
isReserved: (name: string) => boolean
}
const OutputRow: FC<OutputRowProps> = ({ item, isReserved }) => {
const { t } = useTranslation()
const showDuplicateWarning = !item.reserved && isReserved(item.name)
return (
<tr className="border-b border-divider-regular last:border-0">
<td className="max-w-[156px] p-2 pl-3">
<div className="text-[13px] leading-[18px]">
<div title={item.name} className="flex items-center">
<span className="truncate font-medium text-text-primary">{item.name}</span>
{item.reserved && (
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">
{t('createTool.toolOutput.reserved', { ns: 'tools' })}
</span>
)}
{showDuplicateWarning && (
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('createTool.toolOutput.reservedParameterDuplicateTip', { ns: 'tools' })}
</div>
)}
>
<RiErrorWarningLine className="h-3 w-3 text-text-warning-secondary" />
</Tooltip>
)}
</div>
<div className="text-text-tertiary">{item.type}</div>
</div>
</td>
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
<span className="text-[13px] font-normal leading-[18px] text-text-secondary">
{item.description}
</span>
</td>
</tr>
)
}
const ToolOutputTable: FC<ToolOutputTableProps> = ({ parameters, isReserved }) => {
const { t } = useTranslation()
return (
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
<table className="w-full text-xs font-normal leading-[18px] text-text-secondary">
<thead className="uppercase text-text-tertiary">
<tr className="border-b border-divider-regular">
<th className="w-[156px] p-2 pl-3 font-medium">
{t('createTool.name', { ns: 'tools' })}
</th>
<th className="p-2 pl-3 font-medium">
{t('createTool.toolOutput.description', { ns: 'tools' })}
</th>
</tr>
</thead>
<tbody>
{parameters.map(item => (
<OutputRow
key={item.name}
item={item}
isReserved={isReserved}
/>
))}
</tbody>
</table>
</div>
)
}
export default React.memo(ToolOutputTable)

View File

@ -1,18 +1,22 @@
'use client'
import type { Emoji } from '@/app/components/tools/types'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import { RiArrowRightUpLine, RiHammerLine } from '@remixicon/react'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import Indicator from '@/app/components/header/indicator'
import WorkflowToolModal from '@/app/components/tools/workflow-tool'
import { useAppContext } from '@/context/app-context'
import { createWorkflowToolProvider, fetchWorkflowToolDetailByAppID, saveWorkflowToolProvider } from '@/service/tools'
import { useInvalidateAllWorkflowTools } from '@/service/use-tools'
import { cn } from '@/utils/classnames'
import { useConfigureButton } from './hooks/use-configure-button'
import Divider from '../../base/divider'
type Props = {
disabled: boolean
@ -29,99 +33,6 @@ type Props = {
disabledReason?: string
}
type UnpublishedCardProps = {
disabled: boolean
isManager: boolean
onConfigureClick: () => void
}
const UnpublishedCard = ({ disabled, isManager, onConfigureClick }: UnpublishedCardProps) => {
const { t } = useTranslation()
const handleClick = () => {
if (!disabled && isManager)
onConfigureClick()
}
return (
<div
className="flex items-center justify-start gap-2 p-2 pl-2.5"
onClick={handleClick}
>
<RiHammerLine className={cn('relative h-4 w-4 text-text-secondary', !disabled && isManager && 'group-hover:text-text-accent')} />
<div
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
className={cn('system-sm-medium shrink grow basis-0 truncate text-text-secondary', !disabled && isManager && 'group-hover:text-text-accent')}
>
{t('common.workflowAsTool', { ns: 'workflow' })}
</div>
<span className="system-2xs-medium-uppercase shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 text-text-tertiary">
{t('common.configureRequired', { ns: 'workflow' })}
</span>
</div>
)
}
type NonManagerCardProps = Record<string, never>
const NonManagerCard = (_props: NonManagerCardProps) => {
const { t } = useTranslation()
return (
<div className="flex items-center justify-start gap-2 p-2 pl-2.5">
<RiHammerLine className="h-4 w-4 text-text-tertiary" />
<div
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
className="system-sm-medium shrink grow basis-0 truncate text-text-tertiary"
>
{t('common.workflowAsTool', { ns: 'workflow' })}
</div>
</div>
)
}
type PublishedActionsProps = {
disabled: boolean
isManager: boolean
outdated: boolean
onConfigureClick: () => void
onManageClick: () => void
}
const PublishedActions = ({ disabled, isManager, outdated, onConfigureClick, onManageClick }: PublishedActionsProps) => {
const { t } = useTranslation()
return (
<div className="border-t-[0.5px] border-divider-regular px-2.5 py-2">
<div className="flex justify-between gap-x-2">
<Button
size="small"
className="w-[140px]"
onClick={onConfigureClick}
disabled={!isManager || disabled}
>
{t('common.configure', { ns: 'workflow' })}
{outdated && <Indicator className="ml-1" color="yellow" />}
</Button>
<Button
size="small"
className="w-[140px]"
onClick={onManageClick}
disabled={disabled}
>
{t('common.manageInTools', { ns: 'workflow' })}
<RiArrowRightUpLine className="ml-1 h-4 w-4" />
</Button>
</div>
{outdated && (
<div className="mt-1 text-xs leading-[18px] text-text-warning">
{t('common.workflowAsToolTip', { ns: 'workflow' })}
</div>
)}
</div>
)
}
const WorkflowToolConfigureButton = ({
disabled,
published,
@ -138,96 +49,229 @@ const WorkflowToolConfigureButton = ({
}: Props) => {
const { t } = useTranslation()
const router = useRouter()
const [showModal, setShowModal] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [detail, setDetail] = useState<WorkflowToolProviderResponse>()
const { isCurrentWorkspaceManager } = useAppContext()
const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools()
const {
showModal,
isLoading,
outdated,
payload,
openModal,
closeModal,
handleCreate,
handleUpdate,
} = useConfigureButton({
published,
detailNeedUpdate,
workflowAppId,
icon,
name,
description,
inputs,
outputs,
handlePublish,
onRefreshData,
})
const outdated = useMemo(() => {
if (!detail)
return false
if (detail.tool.parameters.length !== inputs?.length) {
return true
}
else {
for (const item of inputs || []) {
const param = detail.tool.parameters.find(toolParam => toolParam.name === item.variable)
if (!param) {
return true
}
else if (param.required !== item.required) {
return true
}
else {
if (item.type === 'paragraph' && param.type !== 'string')
return true
if (item.type === 'text-input' && param.type !== 'string')
return true
}
}
}
return false
}, [detail, inputs])
const handleUnpublishedClick = () => {
if (!disabled)
openModal()
}
const handleManageClick = () => {
router.push('/tools?category=workflow')
}
const cardClassName = cn(
'group rounded-lg bg-background-section-burn transition-colors',
disabled || !isCurrentWorkspaceManager ? 'cursor-not-allowed opacity-60 shadow-xs' : 'cursor-pointer',
!disabled && !published && isCurrentWorkspaceManager && 'hover:bg-state-accent-hover',
)
const renderCardContent = () => {
if (!isCurrentWorkspaceManager)
return <NonManagerCard />
const payload = useMemo(() => {
let parameters: WorkflowToolProviderParameter[] = []
let outputParameters: WorkflowToolProviderOutputParameter[] = []
if (!published) {
return (
<UnpublishedCard
disabled={disabled}
isManager={isCurrentWorkspaceManager}
onConfigureClick={handleUnpublishedClick}
/>
)
parameters = (inputs || []).map((item) => {
return {
name: item.variable,
description: '',
form: 'llm',
required: item.required,
type: item.type,
}
})
outputParameters = (outputs || []).map((item) => {
return {
name: item.variable,
description: '',
type: item.value_type,
}
})
}
else if (detail && detail.tool) {
parameters = (inputs || []).map((item) => {
return {
name: item.variable,
required: item.required,
type: item.type === 'paragraph' ? 'string' : item.type,
description: detail.tool.parameters.find(param => param.name === item.variable)?.llm_description || '',
form: detail.tool.parameters.find(param => param.name === item.variable)?.form || 'llm',
}
})
outputParameters = (outputs || []).map((item) => {
const found = detail.tool.output_schema?.properties?.[item.variable]
return {
name: item.variable,
description: found ? found.description : '',
type: item.value_type,
}
})
}
return {
icon: detail?.icon || icon,
label: detail?.label || name,
name: detail?.name || '',
description: detail?.description || description,
parameters,
outputParameters,
labels: detail?.tool?.labels || [],
privacy_policy: detail?.privacy_policy || '',
...(published
? {
workflow_tool_id: detail?.workflow_tool_id,
}
: {
workflow_app_id: workflowAppId,
}),
}
}, [detail, published, workflowAppId, icon, name, description, inputs])
return (
<div
className="flex items-center justify-start gap-2 p-2 pl-2.5"
onClick={openModal}
>
<RiHammerLine className="relative h-4 w-4 text-text-secondary" />
<div
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
className="system-sm-medium shrink grow basis-0 truncate text-text-secondary"
>
{t('common.workflowAsTool', { ns: 'workflow' })}
</div>
</div>
)
const getDetail = useCallback(async (workflowAppId: string) => {
setIsLoading(true)
const res = await fetchWorkflowToolDetailByAppID(workflowAppId)
setDetail(res)
setIsLoading(false)
}, [])
useEffect(() => {
if (published)
getDetail(workflowAppId)
}, [getDetail, published, workflowAppId])
useEffect(() => {
if (detailNeedUpdate)
getDetail(workflowAppId)
}, [detailNeedUpdate, getDetail, workflowAppId])
const createHandle = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => {
try {
await createWorkflowToolProvider(data)
invalidateAllWorkflowTools()
onRefreshData?.()
getDetail(workflowAppId)
Toast.notify({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
})
setShowModal(false)
}
catch (e) {
Toast.notify({ type: 'error', message: (e as Error).message })
}
}
const showContent = !published || !isLoading
const updateWorkflowToolProvider = async (data: WorkflowToolProviderRequest & Partial<{
workflow_app_id: string
workflow_tool_id: string
}>) => {
try {
await handlePublish()
await saveWorkflowToolProvider(data)
onRefreshData?.()
invalidateAllWorkflowTools()
getDetail(workflowAppId)
Toast.notify({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
})
setShowModal(false)
}
catch (e) {
Toast.notify({ type: 'error', message: (e as Error).message })
}
}
return (
<>
<Divider type="horizontal" className="h-px bg-divider-subtle" />
{showContent && (
<div className={cardClassName}>
{renderCardContent()}
{(!published || !isLoading) && (
<div className={cn(
'group rounded-lg bg-background-section-burn transition-colors',
disabled || !isCurrentWorkspaceManager ? 'cursor-not-allowed opacity-60 shadow-xs' : 'cursor-pointer',
!disabled && !published && isCurrentWorkspaceManager && 'hover:bg-state-accent-hover',
)}
>
{isCurrentWorkspaceManager
? (
<div
className="flex items-center justify-start gap-2 p-2 pl-2.5"
onClick={() => !disabled && !published && setShowModal(true)}
>
<RiHammerLine className={cn('relative h-4 w-4 text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} />
<div
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
className={cn('system-sm-medium shrink grow basis-0 truncate text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')}
>
{t('common.workflowAsTool', { ns: 'workflow' })}
</div>
{!published && (
<span className="system-2xs-medium-uppercase shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 text-text-tertiary">
{t('common.configureRequired', { ns: 'workflow' })}
</span>
)}
</div>
)
: (
<div
className="flex items-center justify-start gap-2 p-2 pl-2.5"
>
<RiHammerLine className="h-4 w-4 text-text-tertiary" />
<div
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
className="system-sm-medium shrink grow basis-0 truncate text-text-tertiary"
>
{t('common.workflowAsTool', { ns: 'workflow' })}
</div>
</div>
)}
{disabledReason && (
<div className="mt-1 px-2.5 pb-2 text-xs leading-[18px] text-text-tertiary">
{disabledReason}
</div>
)}
{published && (
<PublishedActions
disabled={disabled}
isManager={isCurrentWorkspaceManager}
outdated={outdated}
onConfigureClick={openModal}
onManageClick={handleManageClick}
/>
<div className="border-t-[0.5px] border-divider-regular px-2.5 py-2">
<div className="flex justify-between gap-x-2">
<Button
size="small"
className="w-[140px]"
onClick={() => setShowModal(true)}
disabled={!isCurrentWorkspaceManager || disabled}
>
{t('common.configure', { ns: 'workflow' })}
{outdated && <Indicator className="ml-1" color="yellow" />}
</Button>
<Button
size="small"
className="w-[140px]"
onClick={() => router.push('/tools?category=workflow')}
disabled={disabled}
>
{t('common.manageInTools', { ns: 'workflow' })}
<RiArrowRightUpLine className="ml-1 h-4 w-4" />
</Button>
</div>
{outdated && (
<div className="mt-1 text-xs leading-[18px] text-text-warning">
{t('common.workflowAsToolTip', { ns: 'workflow' })}
</div>
)}
</div>
)}
</div>
)}
@ -236,13 +280,12 @@ const WorkflowToolConfigureButton = ({
<WorkflowToolModal
isAdd={!published}
payload={payload}
onHide={closeModal}
onCreate={handleCreate}
onSave={handleUpdate}
onHide={() => setShowModal(false)}
onCreate={createHandle}
onSave={updateWorkflowToolProvider}
/>
)}
</>
)
}
export default WorkflowToolConfigureButton

View File

@ -1,222 +0,0 @@
'use client'
import type { Emoji, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import { useBoolean } from 'ahooks'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { useInvalidateAllWorkflowTools } from '@/service/use-tools'
import {
useCreateWorkflowTool,
useInvalidateWorkflowToolDetail,
useUpdateWorkflowTool,
useWorkflowToolDetail,
} from './use-workflow-tool'
export type ConfigureButtonProps = {
published: boolean
detailNeedUpdate?: boolean
workflowAppId: string
icon: Emoji
name: string
description: string
inputs?: InputVar[]
outputs?: Variable[]
handlePublish: (params?: PublishWorkflowParams) => Promise<void>
onRefreshData?: () => void
}
// Type for parameter building context
type ParameterBuildContext = {
inputs: InputVar[] | undefined
outputs: Variable[] | undefined
detail: WorkflowToolProviderResponse | undefined
published: boolean
}
/**
* Check if tool parameters are outdated compared to workflow inputs
*/
function checkOutdated(detail: WorkflowToolProviderResponse | undefined, inputs: InputVar[] | undefined): boolean {
if (!detail)
return false
const toolParams = detail.tool.parameters
const inputList = inputs ?? []
if (toolParams.length !== inputList.length)
return true
return inputList.some((item) => {
const param = toolParams.find(p => p.name === item.variable)
if (!param || param.required !== item.required)
return true
const isTextType = item.type === 'paragraph' || item.type === 'text-input'
return isTextType && param.type !== 'string'
})
}
/**
* Build input parameters based on context
*/
function buildInputParameters(ctx: ParameterBuildContext): WorkflowToolProviderParameter[] {
const inputList = ctx.inputs ?? []
if (!ctx.published || !ctx.detail?.tool) {
return inputList.map(item => ({
name: item.variable,
description: '',
form: 'llm',
required: item.required,
type: item.type,
}))
}
const existingParams = ctx.detail.tool.parameters
return inputList.map((item) => {
const existing = existingParams.find(p => p.name === item.variable)
return {
name: item.variable,
required: item.required,
type: item.type === 'paragraph' ? 'string' : item.type,
description: existing?.llm_description ?? '',
form: existing?.form ?? 'llm',
}
})
}
/**
* Build output parameters
*/
function buildOutputParameters(outputs: Variable[] | undefined, detail?: WorkflowToolProviderResponse) {
return (outputs ?? []).map((item) => {
const found = detail?.tool.output_schema?.properties?.[item.variable]
return {
name: item.variable,
description: found?.description ?? '',
type: item.value_type,
}
})
}
/**
* Custom hook for managing configure button state and logic
*/
export const useConfigureButton = ({
published,
detailNeedUpdate,
workflowAppId,
icon,
name,
description,
inputs,
outputs,
handlePublish,
onRefreshData,
}: ConfigureButtonProps) => {
const { t } = useTranslation()
const [showModal, { setTrue: openModal, setFalse: closeModal }] = useBoolean(false)
// Data fetching with React Query
const {
data: detail,
isLoading,
refetch: refetchDetail,
} = useWorkflowToolDetail(workflowAppId, published)
// Refetch detail when external updates occur
useEffect(() => {
if (detailNeedUpdate)
refetchDetail()
}, [detailNeedUpdate, refetchDetail])
// Mutations
const { mutateAsync: createTool } = useCreateWorkflowTool()
const { mutateAsync: updateTool } = useUpdateWorkflowTool()
const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools()
const invalidateDetail = useInvalidateWorkflowToolDetail()
// Check if parameters are outdated
const outdated = useMemo(
() => checkOutdated(detail, inputs),
[detail, inputs],
)
// Build payload for modal
const payload = useMemo(() => {
const ctx: ParameterBuildContext = { inputs, outputs, detail, published }
const parameters = buildInputParameters(ctx)
const outputParameters = buildOutputParameters(outputs, detail)
return {
icon: detail?.icon ?? icon,
label: detail?.label ?? name,
name: detail?.name ?? '',
description: detail?.description ?? description,
parameters,
outputParameters,
labels: detail?.tool?.labels ?? [],
privacy_policy: detail?.privacy_policy ?? '',
tool: detail?.tool,
...(published
? { workflow_tool_id: detail?.workflow_tool_id }
: { workflow_app_id: workflowAppId }),
}
}, [detail, published, workflowAppId, icon, name, description, inputs, outputs])
// Common cache invalidation logic
const invalidateCaches = useCallback(() => {
invalidateAllWorkflowTools()
invalidateDetail(workflowAppId)
onRefreshData?.()
refetchDetail()
}, [invalidateAllWorkflowTools, invalidateDetail, workflowAppId, onRefreshData, refetchDetail])
// Common success handler
const handleSuccess = useCallback(() => {
Toast.notify({ type: 'success', message: t('api.actionSuccess', { ns: 'common' }) })
closeModal()
}, [t, closeModal])
// Handler for creating new workflow tool
const handleCreate = useCallback(async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => {
try {
await createTool(data)
invalidateCaches()
handleSuccess()
}
catch (e) {
Toast.notify({ type: 'error', message: (e as Error).message })
}
}, [createTool, invalidateCaches, handleSuccess])
// Handler for updating workflow tool
const handleUpdate = useCallback(async (data: WorkflowToolProviderRequest & Partial<{
workflow_app_id: string
workflow_tool_id: string
}>) => {
try {
await handlePublish()
await updateTool(data)
invalidateCaches()
handleSuccess()
}
catch (e) {
Toast.notify({ type: 'error', message: (e as Error).message })
}
}, [handlePublish, updateTool, invalidateCaches, handleSuccess])
return {
showModal,
isLoading,
detail,
outdated,
payload,
openModal,
closeModal,
handleCreate,
handleUpdate,
}
}

View File

@ -1,62 +0,0 @@
'use client'
import { useBoolean } from 'ahooks'
import { useCallback, useMemo, useState } from 'react'
export type ModalStateResult = {
isOpen: boolean
open: () => void
close: () => void
toggle: () => void
}
/**
* Simple hook for managing modal open/close state
*/
export const useModalState = (initialState = false): ModalStateResult => {
const [isOpen, { setTrue: open, setFalse: close, toggle }] = useBoolean(initialState)
return { isOpen, open, close, toggle }
}
type ModalActions = {
isOpen: boolean
open: () => void
close: () => void
}
/**
* Hook for managing multiple modal states
* Uses a single useState to avoid violating Rules of Hooks
*/
export const useMultiModalState = <T extends string>(modalNames: T[]) => {
// Use a single state object to track all modal open states
const [openStates, setOpenStates] = useState<Record<T, boolean>>(() =>
modalNames.reduce((acc, name) => {
acc[name] = false
return acc
}, {} as Record<T, boolean>),
)
// Create memoized modal accessors with open/close callbacks
const modals = useMemo(() => {
return modalNames.reduce((acc, name) => {
acc[name] = {
isOpen: openStates[name] ?? false,
open: () => setOpenStates(prev => ({ ...prev, [name]: true })),
close: () => setOpenStates(prev => ({ ...prev, [name]: false })),
}
return acc
}, {} as Record<T, ModalActions>)
}, [modalNames, openStates])
// Helper to close all modals
const closeAll = useCallback(() => {
setOpenStates(prev =>
modalNames.reduce((acc, name) => {
acc[name] = false
return acc
}, { ...prev } as Record<T, boolean>),
)
}, [modalNames])
return { modals, closeAll }
}

View File

@ -1,240 +0,0 @@
'use client'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '@/app/components/tools/types'
import { produce } from 'immer'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { VarType } from '@/app/components/workflow/types'
import { buildWorkflowOutputParameters } from '../utils'
export type WorkflowToolFormPayload = {
icon: Emoji
label: string
name: string
description: string
parameters: WorkflowToolProviderParameter[]
outputParameters?: WorkflowToolProviderOutputParameter[] | null
labels: string[]
privacy_policy: string
workflow_app_id?: string
workflow_tool_id?: string
tool?: {
output_schema?: WorkflowToolProviderOutputSchema | null
}
}
export type UseWorkflowToolFormProps = {
payload: WorkflowToolFormPayload
isAdd?: boolean
onCreate?: (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => void
onSave?: (data: WorkflowToolProviderRequest & Partial<{
workflow_app_id: string
workflow_tool_id: string
}>) => void
}
type FormState = {
emoji: Emoji
label: string
name: string
description: string
parameters: WorkflowToolProviderParameter[]
labels: string[]
privacyPolicy: string
}
/**
* Validate tool name format (alphanumeric and underscores only)
*/
const isNameValid = (name: string): boolean => {
if (name === '')
return true
return /^\w+$/.test(name)
}
/**
* Custom hook for managing workflow tool form state and logic
*/
export const useWorkflowToolForm = ({
payload,
isAdd,
onCreate,
onSave,
}: UseWorkflowToolFormProps) => {
const { t } = useTranslation()
// Form state
const [formState, setFormState] = useState<FormState>({
emoji: payload.icon,
label: payload.label,
name: payload.name,
description: payload.description,
parameters: payload.parameters,
labels: payload.labels,
privacyPolicy: payload.privacy_policy,
})
// Computed output parameters (from payload.outputParameters or derived from tool.output_schema)
const outputParameters = useMemo<WorkflowToolProviderOutputParameter[]>(
() => buildWorkflowOutputParameters(payload.outputParameters ?? null, payload.tool?.output_schema ?? null),
[payload.outputParameters, payload.tool?.output_schema],
)
// Reserved output parameters (text, files, json)
const reservedOutputParameters = useMemo<WorkflowToolProviderOutputParameter[]>(() => [
{
name: 'text',
description: t('nodes.tool.outputVars.text', { ns: 'workflow' }),
type: VarType.string,
reserved: true,
},
{
name: 'files',
description: t('nodes.tool.outputVars.files.title', { ns: 'workflow' }),
type: VarType.arrayFile,
reserved: true,
},
{
name: 'json',
description: t('nodes.tool.outputVars.json', { ns: 'workflow' }),
type: VarType.arrayObject,
reserved: true,
},
], [t])
// Check if output parameter name conflicts with reserved names
const isOutputParameterReserved = useCallback((name: string) => {
return reservedOutputParameters.some(p => p.name === name)
}, [reservedOutputParameters])
// State update handlers
const setEmoji = useCallback((emoji: Emoji) => {
setFormState(prev => ({ ...prev, emoji }))
}, [])
const setLabel = useCallback((label: string) => {
setFormState(prev => ({ ...prev, label }))
}, [])
const setName = useCallback((name: string) => {
setFormState(prev => ({ ...prev, name }))
}, [])
const setDescription = useCallback((description: string) => {
setFormState(prev => ({ ...prev, description }))
}, [])
const setLabels = useCallback((labels: string[]) => {
setFormState(prev => ({ ...prev, labels }))
}, [])
const setPrivacyPolicy = useCallback((privacyPolicy: string) => {
setFormState(prev => ({ ...prev, privacyPolicy }))
}, [])
// Handle parameter change (description or form/method)
const handleParameterChange = useCallback((key: 'description' | 'form', value: string, index: number) => {
setFormState((prev) => {
const newParameters = produce(prev.parameters, (draft) => {
if (key === 'description')
draft[index].description = value
else
draft[index].form = value
})
return { ...prev, parameters: newParameters }
})
}, [])
// Validate form and show error toast if invalid
const validateForm = useCallback((): boolean => {
if (!formState.label) {
Toast.notify({
type: 'error',
message: t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.name', { ns: 'tools' }) }),
})
return false
}
if (!formState.name) {
Toast.notify({
type: 'error',
message: t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.nameForToolCall', { ns: 'tools' }) }),
})
return false
}
if (!isNameValid(formState.name)) {
Toast.notify({
type: 'error',
message: t('createTool.nameForToolCall', { ns: 'tools' }) + t('createTool.nameForToolCallTip', { ns: 'tools' }),
})
return false
}
return true
}, [formState.label, formState.name, t])
// Build request params for API
const buildRequestParams = useCallback((): WorkflowToolProviderRequest => ({
name: formState.name,
description: formState.description,
icon: formState.emoji,
label: formState.label,
parameters: formState.parameters.map(item => ({
name: item.name,
description: item.description,
form: item.form,
})),
labels: formState.labels,
privacy_policy: formState.privacyPolicy,
}), [formState])
// Submit form
const onConfirm = useCallback(() => {
if (!validateForm())
return
const requestParams = buildRequestParams()
if (isAdd) {
onCreate?.({
...requestParams,
workflow_app_id: payload.workflow_app_id!,
})
}
else {
onSave?.({
...requestParams,
workflow_tool_id: payload.workflow_tool_id,
})
}
}, [validateForm, buildRequestParams, isAdd, onCreate, onSave, payload.workflow_app_id, payload.workflow_tool_id])
return {
// Form state
emoji: formState.emoji,
label: formState.label,
name: formState.name,
description: formState.description,
parameters: formState.parameters,
labels: formState.labels,
privacyPolicy: formState.privacyPolicy,
// Computed values
outputParameters,
reservedOutputParameters,
allOutputParameters: [...reservedOutputParameters, ...outputParameters],
isNameValid: isNameValid(formState.name),
// Handlers
setEmoji,
setLabel,
setName,
setDescription,
setLabels,
setPrivacyPolicy,
handleParameterChange,
isOutputParameterReserved,
onConfirm,
}
}

View File

@ -1,70 +0,0 @@
import type { WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types'
import {
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { get, post } from '@/service/base'
const NAME_SPACE = 'workflow-tool'
// Query key factory for workflow tool detail
const workflowToolDetailKey = (appId: string) => [NAME_SPACE, 'detail', appId]
/**
* Fetch workflow tool detail by app ID
*/
export const useWorkflowToolDetail = (appId: string, enabled = true) => {
return useQuery<WorkflowToolProviderResponse>({
queryKey: workflowToolDetailKey(appId),
queryFn: () => get<WorkflowToolProviderResponse>(`/workspaces/current/tool-provider/workflow/detail?workflow_app_id=${appId}`),
enabled: enabled && !!appId,
})
}
/**
* Invalidate workflow tool detail cache
*/
export const useInvalidateWorkflowToolDetail = () => {
const queryClient = useQueryClient()
return (appId: string) => {
queryClient.invalidateQueries({
queryKey: workflowToolDetailKey(appId),
})
}
}
type CreateWorkflowToolPayload = WorkflowToolProviderRequest & { workflow_app_id: string }
/**
* Create workflow tool provider mutation
*/
export const useCreateWorkflowTool = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'create'],
mutationFn: (payload: CreateWorkflowToolPayload) => {
return post('/workspaces/current/tool-provider/workflow/create', {
body: payload,
})
},
})
}
type UpdateWorkflowToolPayload = WorkflowToolProviderRequest & Partial<{
workflow_app_id: string
workflow_tool_id: string
}>
/**
* Update workflow tool provider mutation
*/
export const useUpdateWorkflowTool = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'update'],
mutationFn: (payload: UpdateWorkflowToolPayload) => {
return post('/workspaces/current/tool-provider/workflow/update', {
body: payload,
})
},
})
}

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,10 @@
'use client'
import type { FC } from 'react'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
import type { WorkflowToolFormPayload } from './hooks/use-workflow-tool-form'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
import { RiErrorWarningLine } from '@remixicon/react'
import { produce } from 'immer'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
@ -10,34 +12,18 @@ import Drawer from '@/app/components/base/drawer-plus'
import EmojiPicker from '@/app/components/base/emoji-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import LabelSelector from '@/app/components/tools/labels/selector'
import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal'
import MethodSelector from '@/app/components/tools/workflow-tool/method-selector'
import { VarType } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import ToolInputTable from './components/tool-input-table'
import ToolOutputTable from './components/tool-output-table'
import { useModalState } from './hooks/use-modal-state'
import { useWorkflowToolForm } from './hooks/use-workflow-tool-form'
export type WorkflowToolModalPayload = {
icon: Emoji
label: string
name: string
description: string
parameters: WorkflowToolProviderParameter[]
outputParameters: WorkflowToolProviderOutputParameter[]
labels: string[]
privacy_policy: string
tool?: {
output_schema?: WorkflowToolProviderOutputSchema
}
workflow_tool_id?: string
workflow_app_id?: string
}
import { buildWorkflowOutputParameters } from './utils'
type Props = {
isAdd?: boolean
payload: WorkflowToolFormPayload
payload: any
onHide: () => void
onRemove?: () => void
onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void
@ -46,64 +32,7 @@ type Props = {
workflow_tool_id: string
}>) => void
}
// Form field wrapper component
type FormFieldProps = {
label: string
required?: boolean
tooltip?: string
children: React.ReactNode
}
const FormField: FC<FormFieldProps> = ({ label, required, tooltip, children }) => (
<div>
<div className="system-sm-medium flex items-center py-2 text-text-primary">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
{tooltip && (
<Tooltip popupContent={<div className="w-[180px]">{tooltip}</div>} />
)}
</div>
{children}
</div>
)
// Footer actions component
type FooterActionsProps = {
isAdd?: boolean
onRemove?: () => void
onHide: () => void
onSaveClick: () => void
}
const FooterActions: FC<FooterActionsProps> = ({ isAdd, onRemove, onHide, onSaveClick }) => {
const { t } = useTranslation()
const showDeleteButton = !isAdd && onRemove
return (
<div className={cn(
showDeleteButton ? 'justify-between' : 'justify-end',
'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4',
)}
>
{showDeleteButton && (
<Button variant="warning" onClick={onRemove}>
{t('operation.delete', { ns: 'common' })}
</Button>
)}
<div className="flex space-x-2">
<Button onClick={onHide}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button variant="primary" onClick={onSaveClick}>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</div>
)
}
// Main component
// Add and Edit
const WorkflowToolAsModal: FC<Props> = ({
isAdd,
payload,
@ -114,24 +43,108 @@ const WorkflowToolAsModal: FC<Props> = ({
}) => {
const { t } = useTranslation()
// Modal states
const emojiPicker = useModalState(false)
const confirmModal = useModalState(false)
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false)
const [emoji, setEmoji] = useState<Emoji>(payload.icon)
const [label, setLabel] = useState<string>(payload.label)
const [name, setName] = useState(payload.name)
const [description, setDescription] = useState(payload.description)
const [parameters, setParameters] = useState<WorkflowToolProviderParameter[]>(payload.parameters)
const rawOutputParameters = payload.outputParameters
const outputSchema = payload.tool?.output_schema
const outputParameters = useMemo<WorkflowToolProviderOutputParameter[]>(() => buildWorkflowOutputParameters(rawOutputParameters, outputSchema), [rawOutputParameters, outputSchema])
const reservedOutputParameters: WorkflowToolProviderOutputParameter[] = [
{
name: 'text',
description: t('nodes.tool.outputVars.text', { ns: 'workflow' }),
type: VarType.string,
reserved: true,
},
{
name: 'files',
description: t('nodes.tool.outputVars.files.title', { ns: 'workflow' }),
type: VarType.arrayFile,
reserved: true,
},
{
name: 'json',
description: t('nodes.tool.outputVars.json', { ns: 'workflow' }),
type: VarType.arrayObject,
reserved: true,
},
]
// Form state and logic
const form = useWorkflowToolForm({
payload,
isAdd,
onCreate,
onSave,
})
const handleParameterChange = (key: string, value: any, index: number) => {
const newData = produce(parameters, (draft: WorkflowToolProviderParameter[]) => {
if (key === 'description')
draft[index].description = value
else
draft[index].form = value
})
setParameters(newData)
}
const [labels, setLabels] = useState<string[]>(payload.labels)
const handleLabelSelect = (value: string[]) => {
setLabels(value)
}
const [privacyPolicy, setPrivacyPolicy] = useState(payload.privacy_policy)
const [showModal, setShowModal] = useState(false)
// Handle save button click
const handleSaveClick = () => {
if (isAdd)
form.onConfirm()
else
confirmModal.open()
const isNameValid = (name: string) => {
// when the user has not input anything, no need for a warning
if (name === '')
return true
return /^\w+$/.test(name)
}
const isOutputParameterReserved = (name: string) => {
return reservedOutputParameters.find(p => p.name === name)
}
const onConfirm = () => {
let errorMessage = ''
if (!label)
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.name', { ns: 'tools' }) })
if (!name)
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.nameForToolCall', { ns: 'tools' }) })
if (!isNameValid(name))
errorMessage = t('createTool.nameForToolCall', { ns: 'tools' }) + t('createTool.nameForToolCallTip', { ns: 'tools' })
if (errorMessage) {
Toast.notify({
type: 'error',
message: errorMessage,
})
return
}
const requestParams = {
name,
description,
icon: emoji,
label,
parameters: parameters.map(item => ({
name: item.name,
description: item.description,
form: item.form,
})),
labels,
privacy_policy: privacyPolicy,
}
if (!isAdd) {
onSave?.({
...requestParams,
workflow_tool_id: payload.workflow_tool_id,
})
}
else {
onCreate?.({
...requestParams,
workflow_app_id: payload.workflow_app_id,
})
}
}
return (
@ -147,119 +160,217 @@ const WorkflowToolAsModal: FC<Props> = ({
body={(
<div className="flex h-full flex-col">
<div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3">
{/* Name & Icon */}
<FormField label={t('createTool.name', { ns: 'tools' })} required>
{/* name & icon */}
<div>
<div className="system-sm-medium py-2 text-text-primary">
{t('createTool.name', { ns: 'tools' })}
{' '}
<span className="ml-1 text-red-500">*</span>
</div>
<div className="flex items-center justify-between gap-3">
<AppIcon
size="large"
onClick={emojiPicker.open}
className="cursor-pointer"
iconType="emoji"
icon={form.emoji.content}
background={form.emoji.background}
/>
<AppIcon size="large" onClick={() => { setShowEmojiPicker(true) }} className="cursor-pointer" iconType="emoji" icon={emoji.content} background={emoji.background} />
<Input
className="h-10 grow"
placeholder={t('createTool.toolNamePlaceHolder', { ns: 'tools' })!}
value={form.label}
onChange={e => form.setLabel(e.target.value)}
value={label}
onChange={e => setLabel(e.target.value)}
/>
</div>
</div>
{/* name for tool call */}
<div>
<div className="system-sm-medium flex items-center py-2 text-text-primary">
{t('createTool.nameForToolCall', { ns: 'tools' })}
{' '}
<span className="ml-1 text-red-500">*</span>
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })}
</div>
)}
/>
</div>
</FormField>
{/* Name for Tool Call */}
<FormField
label={t('createTool.nameForToolCall', { ns: 'tools' })}
required
tooltip={t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })}
>
<Input
className="h-10"
placeholder={t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })!}
value={form.name}
onChange={e => form.setName(e.target.value)}
value={name}
onChange={e => setName(e.target.value)}
/>
{!form.isNameValid && (
<div className="text-xs leading-[18px] text-red-500">
{t('createTool.nameForToolCallTip', { ns: 'tools' })}
</div>
{!isNameValid(name) && (
<div className="text-xs leading-[18px] text-red-500">{t('createTool.nameForToolCallTip', { ns: 'tools' })}</div>
)}
</FormField>
{/* Description */}
<FormField label={t('createTool.description', { ns: 'tools' })}>
</div>
{/* description */}
<div>
<div className="system-sm-medium py-2 text-text-primary">{t('createTool.description', { ns: 'tools' })}</div>
<Textarea
placeholder={t('createTool.descriptionPlaceholder', { ns: 'tools' }) || ''}
value={form.description}
onChange={e => form.setDescription(e.target.value)}
value={description}
onChange={e => setDescription(e.target.value)}
/>
</FormField>
{/* Tool Input */}
<FormField label={t('createTool.toolInput.title', { ns: 'tools' })}>
<ToolInputTable
parameters={form.parameters}
onParameterChange={form.handleParameterChange}
/>
</FormField>
{/* Tool Output */}
<FormField label={t('createTool.toolOutput.title', { ns: 'tools' })}>
<ToolOutputTable
parameters={form.allOutputParameters}
isReserved={form.isOutputParameterReserved}
/>
</FormField>
</div>
{/* Tool Input */}
<div>
<div className="system-sm-medium py-2 text-text-primary">{t('createTool.toolInput.title', { ns: 'tools' })}</div>
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
<table className="w-full text-xs font-normal leading-[18px] text-text-secondary">
<thead className="uppercase text-text-tertiary">
<tr className="border-b border-divider-regular">
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.toolInput.name', { ns: 'tools' })}</th>
<th className="w-[102px] p-2 pl-3 font-medium">{t('createTool.toolInput.method', { ns: 'tools' })}</th>
<th className="p-2 pl-3 font-medium">{t('createTool.toolInput.description', { ns: 'tools' })}</th>
</tr>
</thead>
<tbody>
{parameters.map((item, index) => (
<tr key={index} className="border-b border-divider-regular last:border-0">
<td className="max-w-[156px] p-2 pl-3">
<div className="text-[13px] leading-[18px]">
<div title={item.name} className="flex">
<span className="truncate font-medium text-text-primary">{item.name}</span>
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.required ? t('createTool.toolInput.required', { ns: 'tools' }) : ''}</span>
</div>
<div className="text-text-tertiary">{item.type}</div>
</div>
</td>
<td>
{item.name === '__image' && (
<div className={cn(
'flex h-9 min-h-[56px] cursor-default items-center gap-1 bg-transparent px-3 py-2',
)}
>
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
{t('createTool.toolInput.methodParameter', { ns: 'tools' })}
</div>
</div>
)}
{item.name !== '__image' && (
<MethodSelector value={item.form} onChange={value => handleParameterChange('form', value, index)} />
)}
</td>
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
<input
type="text"
className="w-full appearance-none bg-transparent text-[13px] font-normal leading-[18px] text-text-secondary caret-primary-600 outline-none placeholder:text-text-quaternary"
placeholder={t('createTool.toolInput.descriptionPlaceholder', { ns: 'tools' })!}
value={item.description}
onChange={e => handleParameterChange('description', e.target.value, index)}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Tool Output */}
<div>
<div className="system-sm-medium py-2 text-text-primary">{t('createTool.toolOutput.title', { ns: 'tools' })}</div>
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
<table className="w-full text-xs font-normal leading-[18px] text-text-secondary">
<thead className="uppercase text-text-tertiary">
<tr className="border-b border-divider-regular">
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.name', { ns: 'tools' })}</th>
<th className="p-2 pl-3 font-medium">{t('createTool.toolOutput.description', { ns: 'tools' })}</th>
</tr>
</thead>
<tbody>
{[...reservedOutputParameters, ...outputParameters].map((item, index) => (
<tr key={index} className="border-b border-divider-regular last:border-0">
<td className="max-w-[156px] p-2 pl-3">
<div className="text-[13px] leading-[18px]">
<div title={item.name} className="flex items-center">
<span className="truncate font-medium text-text-primary">{item.name}</span>
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.reserved ? t('createTool.toolOutput.reserved', { ns: 'tools' }) : ''}</span>
{
!item.reserved && isOutputParameterReserved(item.name)
? (
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('createTool.toolOutput.reservedParameterDuplicateTip', { ns: 'tools' })}
</div>
)}
>
<RiErrorWarningLine className="h-3 w-3 text-text-warning-secondary" />
</Tooltip>
)
: null
}
</div>
<div className="text-text-tertiary">{item.type}</div>
</div>
</td>
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
<span className="text-[13px] font-normal leading-[18px] text-text-secondary">{item.description}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Tags */}
<FormField label={t('createTool.toolInput.label', { ns: 'tools' })}>
<LabelSelector value={form.labels} onChange={form.setLabels} />
</FormField>
<div>
<div className="system-sm-medium py-2 text-text-primary">{t('createTool.toolInput.label', { ns: 'tools' })}</div>
<LabelSelector value={labels} onChange={handleLabelSelect} />
</div>
{/* Privacy Policy */}
<FormField label={t('createTool.privacyPolicy', { ns: 'tools' })}>
<div>
<div className="system-sm-medium py-2 text-text-primary">{t('createTool.privacyPolicy', { ns: 'tools' })}</div>
<Input
className="h-10"
value={form.privacyPolicy}
onChange={e => form.setPrivacyPolicy(e.target.value)}
value={privacyPolicy}
onChange={e => setPrivacyPolicy(e.target.value)}
placeholder={t('createTool.privacyPolicyPlaceholder', { ns: 'tools' }) || ''}
/>
</FormField>
</div>
</div>
<div className={cn((!isAdd && onRemove) ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')}>
{!isAdd && onRemove && (
<Button variant="warning" onClick={onRemove}>{t('operation.delete', { ns: 'common' })}</Button>
)}
<div className="flex space-x-2 ">
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button
variant="primary"
onClick={() => {
if (isAdd)
onConfirm()
else
setShowModal(true)
}}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</div>
<FooterActions
isAdd={isAdd}
onRemove={onRemove}
onHide={onHide}
onSaveClick={handleSaveClick}
/>
</div>
)}
isShowMask={true}
clickOutsideNotOpen={true}
/>
{/* Emoji Picker Modal */}
{emojiPicker.isOpen && (
{showEmojiPicker && (
<EmojiPicker
onSelect={(icon, icon_background) => {
form.setEmoji({ content: icon, background: icon_background })
emojiPicker.close()
setEmoji({ content: icon, background: icon_background })
setShowEmojiPicker(false)
}}
onClose={() => {
setShowEmojiPicker(false)
}}
onClose={emojiPicker.close}
/>
)}
{/* Confirm Modal */}
{confirmModal.isOpen && (
{showModal && (
<ConfirmModal
show={confirmModal.isOpen}
onClose={confirmModal.close}
onConfirm={form.onConfirm}
show={showModal}
onClose={() => setShowModal(false)}
onConfirm={onConfirm}
/>
)}
</>
)
}
export default React.memo(WorkflowToolAsModal)

View File

@ -12,7 +12,7 @@ import Button from '@/app/components/base/button'
import Tooltip from '@/app/components/base/tooltip'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { SchemaModal } from '@/app/components/plugins/plugin-detail-panel/tool-selector/components'
import SchemaModal from '@/app/components/plugins/plugin-detail-panel/tool-selector/schema-modal'
import FormInputItem from '@/app/components/workflow/nodes/_base/components/form-input-item'
type Props = {

View File

@ -174,7 +174,7 @@ const useConfig = (id: string, payload: ToolNodeType) => {
draft.tool_configurations = getConfiguredValue(
tool_configurations,
toolSettingSchema,
) as ToolVarInputs
)
}
if (
!draft.tool_parameters
@ -183,7 +183,7 @@ const useConfig = (id: string, payload: ToolNodeType) => {
draft.tool_parameters = getConfiguredValue(
tool_parameters,
toolInputVarSchema,
) as ToolVarInputs
)
}
})
return inputsWithDefaultValue

View File

@ -12,7 +12,7 @@ import Button from '@/app/components/base/button'
import Tooltip from '@/app/components/base/tooltip'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { SchemaModal } from '@/app/components/plugins/plugin-detail-panel/tool-selector/components'
import SchemaModal from '@/app/components/plugins/plugin-detail-panel/tool-selector/schema-modal'
import FormInputItem from '@/app/components/workflow/nodes/_base/components/form-input-item'
type Props = {

View File

@ -10,6 +10,7 @@ import { DatasetAttr } from '@/types/feature'
import { cn } from '@/utils/classnames'
import { ToastProvider } from './components/base/toast'
import BrowserInitializer from './components/browser-initializer'
import { AgentationLoader } from './components/devtools/agentation/loader'
import { ReactScanLoader } from './components/devtools/react-scan/loader'
import { I18nServerProvider } from './components/provider/i18n-server'
import { PWAProvider } from './components/provider/serwist'
@ -121,6 +122,7 @@ const LocaleLayout = async ({
</ThemeProvider>
</JotaiProvider>
<RoutePrefixHandle />
<AgentationLoader />
</PWAProvider>
</body>
</html>

View File

@ -1787,6 +1787,14 @@
"count": 1
}
},
"app/components/datasets/documents/detail/completed/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 6
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/datasets/documents/detail/completed/new-child-segment.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -2156,6 +2164,11 @@
"count": 3
}
},
"app/components/header/account-setting/model-provider-page/utils.ts": {
"ts/no-explicit-any": {
"count": 5
}
},
"app/components/header/account-setting/plugin-page/utils.ts": {
"ts/no-explicit-any": {
"count": 4
@ -2285,6 +2298,11 @@
"count": 8
}
},
"app/components/plugins/plugin-detail-panel/app-selector/index.tsx": {
"ts/no-explicit-any": {
"count": 5
}
},
"app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -2353,6 +2371,26 @@
"count": 2
}
},
"app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": {
"ts/no-explicit-any": {
"count": 15
}
},
"app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx": {
"ts/no-explicit-any": {
"count": 24
}
},
"app/components/plugins/plugin-detail-panel/tool-selector/tool-credentials-form.tsx": {
"ts/no-explicit-any": {
"count": 3
}
},
"app/components/plugins/plugin-detail-panel/tool-selector/tool-item.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
"ts/no-explicit-any": {
"count": 5
@ -2683,6 +2721,21 @@
"count": 3
}
},
"app/components/tools/types.ts": {
"ts/no-explicit-any": {
"count": 4
}
},
"app/components/tools/utils/to-form-schema.ts": {
"ts/no-explicit-any": {
"count": 15
}
},
"app/components/tools/workflow-tool/index.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/workflow-app/components/workflow-children.tsx": {
"no-console": {
"count": 1
@ -4282,6 +4335,11 @@
"count": 3
}
},
"service/tools.ts": {
"ts/no-explicit-any": {
"count": 2
}
},
"service/use-apps.ts": {
"ts/no-explicit-any": {
"count": 1

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "حصة",
"modelProvider.card.quotaExhausted": "نفدت الحصة",
"modelProvider.card.removeKey": "إزالة مفتاح API",
"modelProvider.card.tip": "تدعم أرصدة الرسائل نماذج من {{modelNames}}. ستعطى الأولوية للحصة المدفوعة. سيتم استخدام الحصة المجانية بعد نفاد الحصة المدفوعة.",
"modelProvider.card.tip": "تدعم أرصدة الرسائل نماذج من OpenAI. ستعطى الأولوية للحصة المدفوعة. سيتم استخدام الحصة المجانية بعد نفاد الحصة المدفوعة.",
"modelProvider.card.tokens": "رموز",
"modelProvider.collapse": "طي",
"modelProvider.config": "تكوين",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "KONTINGENT",
"modelProvider.card.quotaExhausted": "Kontingent erschöpft",
"modelProvider.card.removeKey": "API-Schlüssel entfernen",
"modelProvider.card.tip": "Nachrichtenguthaben unterstützen Modelle von {{modelNames}}. Der bezahlten Kontingent wird Vorrang gegeben. Das kostenlose Kontingent wird nach dem Verbrauch des bezahlten Kontingents verwendet.",
"modelProvider.card.tip": "Nachrichtenguthaben unterstützen Modelle von OpenAI. Der bezahlten Kontingent wird Vorrang gegeben. Das kostenlose Kontingent wird nach dem Verbrauch des bezahlten Kontingents verwendet.",
"modelProvider.card.tokens": "Token",
"modelProvider.collapse": "Einklappen",
"modelProvider.config": "Konfigurieren",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "QUOTA",
"modelProvider.card.quotaExhausted": "Quota exhausted",
"modelProvider.card.removeKey": "Remove API Key",
"modelProvider.card.tip": "Message Credits supports models from {{modelNames}}. Priority will be given to the paid quota. The free quota will be used after the paid quota is exhausted.",
"modelProvider.card.tip": "Message Credits supports models from OpenAI. Priority will be given to the paid quota. The free quota will be used after the paid quota is exhausted.",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "Collapse",
"modelProvider.config": "Config",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "CUOTA",
"modelProvider.card.quotaExhausted": "Cuota agotada",
"modelProvider.card.removeKey": "Eliminar CLAVE API",
"modelProvider.card.tip": "Créditos de mensajes admite modelos de {{modelNames}}. Se dará prioridad a la cuota pagada. La cuota gratuita se utilizará después de que se agote la cuota pagada.",
"modelProvider.card.tip": "Créditos de mensajes admite modelos de OpenAI. Se dará prioridad a la cuota pagada. La cuota gratuita se utilizará después de que se agote la cuota pagada.",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "Colapsar",
"modelProvider.config": "Configurar",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "سهمیه",
"modelProvider.card.quotaExhausted": "سهمیه تمام شده",
"modelProvider.card.removeKey": "حذف کلید API",
"modelProvider.card.tip": "اعتبار پیام از مدل‌های {{modelNames}} پشتیبانی می‌کند. اولویت به سهمیه پرداخت شده داده می‌شود. سهمیه رایگان پس از اتمام سهمیه پرداخت شده استفاده خواهد شد.",
"modelProvider.card.tip": "اعتبار پیام از مدل‌های OpenAI پشتیبانی می‌کند. اولویت به سهمیه پرداخت شده داده می‌شود. سهمیه رایگان پس از اتمام سهمیه پرداخت شده استفاده خواهد شد.",
"modelProvider.card.tokens": "توکن‌ها",
"modelProvider.collapse": "جمع کردن",
"modelProvider.config": "پیکربندی",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "QUOTA",
"modelProvider.card.quotaExhausted": "Quota épuisé",
"modelProvider.card.removeKey": "Supprimer la clé API",
"modelProvider.card.tip": "Les crédits de messages prennent en charge les modèles de {{modelNames}}. La priorité sera donnée au quota payant. Le quota gratuit sera utilisé après épuisement du quota payant.",
"modelProvider.card.tip": "Les crédits de messages prennent en charge les modèles d'OpenAI. La priorité sera donnée au quota payant. Le quota gratuit sera utilisé après épuisement du quota payant.",
"modelProvider.card.tokens": "Jetons",
"modelProvider.collapse": "Effondrer",
"modelProvider.config": "Configuration",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "कोटा",
"modelProvider.card.quotaExhausted": "कोटा समाप्त",
"modelProvider.card.removeKey": "API कुंजी निकालें",
"modelProvider.card.tip": "संदेश क्रेडिट {{modelNames}} के मॉडल का समर्थन करते हैं। भुगतान किए गए कोटा को प्राथमिकता दी जाएगी। भुगतान किए गए कोटा के समाप्त होने के बाद मुफ्त कोटा का उपयोग किया जाएगा।",
"modelProvider.card.tip": "संदेश क्रेडिट OpenAI के मॉडल का समर्थन करते हैं। भुगतान किए गए कोटा को प्राथमिकता दी जाएगी। भुगतान किए गए कोटा के समाप्त होने के बाद मुफ्त कोटा का उपयोग किया जाएगा।",
"modelProvider.card.tokens": "टोकन",
"modelProvider.collapse": "संक्षिप्त करें",
"modelProvider.config": "कॉन्फ़िग",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "KUOTA",
"modelProvider.card.quotaExhausted": "Kuota habis",
"modelProvider.card.removeKey": "Menghapus Kunci API",
"modelProvider.card.tip": "Kredit pesan mendukung model dari {{modelNames}}. Prioritas akan diberikan pada kuota yang dibayarkan. Kuota gratis akan digunakan setelah kuota yang dibayarkan habis.",
"modelProvider.card.tip": "Kredit pesan mendukung model dari OpenAI. Prioritas akan diberikan pada kuota yang dibayarkan. Kuota gratis akan digunakan setelah kuota yang dibayarkan habis.",
"modelProvider.card.tokens": "Token",
"modelProvider.collapse": "Roboh",
"modelProvider.config": "Konfigurasi",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "QUOTA",
"modelProvider.card.quotaExhausted": "Quota esaurita",
"modelProvider.card.removeKey": "Rimuovi API Key",
"modelProvider.card.tip": "I crediti di messaggi supportano modelli di {{modelNames}}. Verrà data priorità alla quota pagata. La quota gratuita sarà utilizzata dopo l'esaurimento della quota pagata.",
"modelProvider.card.tip": "I crediti di messaggi supportano modelli di OpenAI. Verrà data priorità alla quota pagata. La quota gratuita sarà utilizzata dopo l'esaurimento della quota pagata.",
"modelProvider.card.tokens": "Token",
"modelProvider.collapse": "Comprimi",
"modelProvider.config": "Configura",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "クォータ",
"modelProvider.card.quotaExhausted": "クォータが使い果たされました",
"modelProvider.card.removeKey": "API キーを削除",
"modelProvider.card.tip": "メッセージ枠は{{modelNames}}のモデルを使用することをサポートしています。無料枠は有料枠が使い果たされた後に消費されます。",
"modelProvider.card.tip": "メッセージ枠はOpenAIのモデルを使用することをサポートしています。無料枠は有料枠が使い果たされた後に消費されます。",
"modelProvider.card.tokens": "トークン",
"modelProvider.collapse": "折り畳み",
"modelProvider.config": "設定",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "할당량",
"modelProvider.card.quotaExhausted": "할당량이 다 사용되었습니다",
"modelProvider.card.removeKey": "API 키 제거",
"modelProvider.card.tip": "메시지 크레딧은 {{modelNames}}의 모델을 지원합니다. 유료 할당량에 우선순위가 부여됩니다. 무료 할당량은 유료 할당량이 소진된 후 사용됩니다.",
"modelProvider.card.tip": "메시지 크레딧은 OpenAI의 모델을 지원합니다. 유료 할당량에 우선순위가 부여됩니다. 무료 할당량은 유료 할당량이 소진된 후 사용됩니다.",
"modelProvider.card.tokens": "토큰",
"modelProvider.collapse": "축소",
"modelProvider.config": "설정",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "LIMIT",
"modelProvider.card.quotaExhausted": "Wyczerpany limit",
"modelProvider.card.removeKey": "Usuń klucz API",
"modelProvider.card.tip": "Kredyty wiadomości obsługują modele od {{modelNames}}. Priorytet zostanie nadany płatnemu limitowi. Darmowy limit zostanie użyty po wyczerpaniu płatnego limitu.",
"modelProvider.card.tip": "Kredyty wiadomości obsługują modele od OpenAI. Priorytet zostanie nadany płatnemu limitowi. Darmowy limit zostanie użyty po wyczerpaniu płatnego limitu.",
"modelProvider.card.tokens": "Tokeny",
"modelProvider.collapse": "Zwiń",
"modelProvider.config": "Konfiguracja",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "QUOTA",
"modelProvider.card.quotaExhausted": "Quota esgotada",
"modelProvider.card.removeKey": "Remover Chave da API",
"modelProvider.card.tip": "Créditos de mensagens suportam modelos de {{modelNames}}. A prioridade será dada à quota paga. A quota gratuita será usada após a quota paga ser esgotada.",
"modelProvider.card.tip": "Créditos de mensagens suportam modelos do OpenAI. A prioridade será dada à quota paga. A quota gratuita será usada após a quota paga ser esgotada.",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "Recolher",
"modelProvider.config": "Configuração",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "COTĂ",
"modelProvider.card.quotaExhausted": "Cotă epuizată",
"modelProvider.card.removeKey": "Elimină cheia API",
"modelProvider.card.tip": "Creditele de mesaje acceptă modele de la {{modelNames}}. Prioritate va fi acordată cotei plătite. Cota gratuită va fi utilizată după epuizarea cotei plătite.",
"modelProvider.card.tip": "Creditele de mesaje acceptă modele de la OpenAI. Prioritate va fi acordată cotei plătite. Cota gratuită va fi utilizată după epuizarea cotei plătite.",
"modelProvider.card.tokens": "Jetoane",
"modelProvider.collapse": "Restrânge",
"modelProvider.config": "Configurare",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "КВОТА",
"modelProvider.card.quotaExhausted": "Квота исчерпана",
"modelProvider.card.removeKey": "Удалить API-ключ",
"modelProvider.card.tip": "Кредиты сообщений поддерживают модели от {{modelNames}}. Приоритет будет отдаваться платной квоте. Бесплатная квота будет использоваться после исчерпания платной квоты.",
"modelProvider.card.tip": "Кредиты сообщений поддерживают модели от OpenAI. Приоритет будет отдаваться платной квоте. Бесплатная квота будет использоваться после исчерпания платной квоты.",
"modelProvider.card.tokens": "Токены",
"modelProvider.collapse": "Свернуть",
"modelProvider.config": "Настройка",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "KVOTE",
"modelProvider.card.quotaExhausted": "Kvote porabljene",
"modelProvider.card.removeKey": "Odstrani API ključ",
"modelProvider.card.tip": "Krediti za sporočila podpirajo modele od {{modelNames}}. Prednostno se bo uporabila plačana kvota. Brezplačna kvota se bo uporabila, ko bo plačana kvota porabljena.",
"modelProvider.card.tip": "Krediti za sporočila podpirajo modele od OpenAI. Prednostno se bo uporabila plačana kvota. Brezplačna kvota se bo uporabila, ko bo plačana kvota porabljena.",
"modelProvider.card.tokens": "Žetoni",
"modelProvider.collapse": "Strni",
"modelProvider.config": "Konfiguracija",

Some files were not shown because too many files have changed in this diff Show More