mirror of
https://github.com/langgenius/dify.git
synced 2026-01-28 15:56:00 +08:00
Compare commits
2 Commits
refactor/t
...
feat/add-a
| Author | SHA1 | Date | |
|---|---|---|---|
| 21f1187f82 | |||
| 2eca49e099 |
@ -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.
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"""
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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"}
|
||||
@ -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
|
||||
39
api/tests/unit_tests/controllers/console/test_setup.py
Normal file
39
api/tests/unit_tests/controllers/console/test_setup.py
Normal 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"],
|
||||
)
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
@ -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'
|
||||
|
||||
@ -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`
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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'
|
||||
@ -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
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
7
web/app/components/devtools/agentation/agentation.tsx
Normal file
7
web/app/components/devtools/agentation/agentation.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Agentation as AgentationComponent } from 'agentation'
|
||||
|
||||
export const Agentation = () => {
|
||||
return <AgentationComponent />
|
||||
}
|
||||
21
web/app/components/devtools/agentation/loader.tsx
Normal file
21
web/app/components/devtools/agentation/loader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -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 = {
|
||||
|
||||
@ -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'
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -10,6 +10,5 @@ export const usePluginInstalledCheck = (providerName = '') => {
|
||||
return {
|
||||
inMarketPlace: !!manifest,
|
||||
manifest: manifest?.data.plugin,
|
||||
pluginID,
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
||||
@ -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()}
|
||||
/>
|
||||
)}
|
||||
@ -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) }) })
|
||||
@ -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
|
||||
@ -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', () => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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[]
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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)
|
||||
@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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
@ -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)
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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": "تكوين",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "پیکربندی",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "कॉन्फ़िग",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "設定",
|
||||
|
||||
@ -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": "설정",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Настройка",
|
||||
|
||||
@ -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
Reference in New Issue
Block a user