From c4a650480f303347c193cf51fa53bfa204b6058a Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:10:18 +0800 Subject: [PATCH 01/60] fix(api): reject duplicate OpenAPI schema names in Flask-RESTX specs Pydantic models were registered into each Flask-RESTX namespace by bare class name, so two different types with the same name silently overwrote each other and corrupted the generated @dify/contracts types (e.g. the conversation `ModelConfig` was replaced by the LLM-generator one). - `_register_json_schema` now tracks the source type and schema of every component and raises `SchemaNameCollisionError` when a name (compared the way client generators normalize it, so `JSONValue`/`JsonValue` count as one) is reused by another type or by the same type with a different shape. - Nested `$defs` are named by a custom generator that keeps names stable across schemas; `SCHEMA_NAME_OVERRIDES` gives third-party/core types that share a class name (graphon I18nObject, plugin dependency Github/Marketplace/Package, knowledge FileInfo) unique names. - Renamed controller-local duplicates (ChatMessageExplorePayload, RagPipelineDraftWorkflowRunPayload, RagPipelineWorkflowRunQuery, HumanInputFormPreviewSubmitPayload, AppMessageFeedbackPayload, WebhookTriggerQuery, DatasourceContentPreviewPayload, SnippetPublishWorkflowPayload, SnippetWorkflowRunQuery, ConversationModelConfig, Any/Recursive/ShallowJSONValue, CompletionParamValue) and deduplicated identical response models. - Response-only models are no longer also registered in validation mode. - Regenerated contracts; the log ModelInfo component now owns its prop type instead of borrowing the overwritten generated `ModelConfig`. Co-Authored-By: Claude Opus 5.5 --- api/controllers/common/fields.py | 8 +- api/controllers/common/schema.py | 114 +++++- api/controllers/console/app/annotation.py | 5 - api/controllers/console/app/app.py | 4 - api/controllers/console/app/audio.py | 5 +- api/controllers/console/app/message.py | 23 +- api/controllers/console/app/workflow.py | 16 +- .../console/app/workflow_trigger.py | 10 +- .../console/datasets/datasets_document.py | 7 +- .../datasource_content_preview.py | 10 +- .../rag_pipeline/rag_pipeline_workflow.py | 22 +- api/controllers/console/explore/completion.py | 10 +- .../console/explore/recommended_app.py | 2 +- api/controllers/console/snippets/payloads.py | 4 +- .../console/snippets/snippet_workflow.py | 14 +- api/controllers/inner_api/app/dsl.py | 2 +- api/controllers/service_api/app/annotation.py | 2 - .../web/human_input_file_upload.py | 5 +- api/fields/app_export_fields.py | 5 - api/fields/conversation_fields.py | 44 +-- api/fields/message_fields.py | 4 +- api/fields/workflow_draft_variable_fields.py | 8 +- .../entities/knowledge_retrieval_inner.py | 4 +- .../controllers/common/test_schema.py | 103 ++++- .../controllers/console/app/test_app_apis.py | 8 +- .../console/app/test_message_api.py | 16 +- .../console/app/test_workflow_trigger_api.py | 2 +- .../test_datasource_content_preview.py | 8 +- .../test_rag_pipeline_workflow.py | 6 +- oxlint-suppressions.json | 2 +- .../generated/api/console/agent/types.gen.ts | 53 ++- .../generated/api/console/agent/zod.gen.ts | 55 ++- .../generated/api/console/apps/types.gen.ts | 129 +++--- .../generated/api/console/apps/zod.gen.ts | 373 ++++++++++-------- .../api/console/datasets/types.gen.ts | 4 +- .../generated/api/console/datasets/zod.gen.ts | 4 +- .../generated/api/console/form/types.gen.ts | 7 +- .../generated/api/console/form/zod.gen.ts | 19 +- .../api/console/installed-apps/types.gen.ts | 33 +- .../api/console/installed-apps/zod.gen.ts | 33 +- .../generated/api/console/rag/types.gen.ts | 35 +- .../generated/api/console/rag/zod.gen.ts | 48 +-- .../api/console/snippets/types.gen.ts | 21 +- .../generated/api/console/snippets/zod.gen.ts | 26 +- .../api/console/workspaces/types.gen.ts | 152 +++---- .../api/console/workspaces/zod.gen.ts | 203 +++++----- .../generated/api/openapi/types.gen.ts | 36 +- .../generated/api/openapi/zod.gen.ts | 54 +-- .../generated/api/service/types.gen.ts | 34 +- .../generated/api/service/zod.gen.ts | 78 ++-- .../contracts/generated/api/web/types.gen.ts | 38 +- .../contracts/generated/api/web/zod.gen.ts | 80 ++-- web/app/components/app/log/model-info.tsx | 9 +- 53 files changed, 1107 insertions(+), 890 deletions(-) delete mode 100644 api/fields/app_export_fields.py diff --git a/api/controllers/common/fields.py b/api/controllers/common/fields.py index 7cc0dabc5a8e5d..590e45eccad028 100644 --- a/api/controllers/common/fields.py +++ b/api/controllers/common/fields.py @@ -8,7 +8,7 @@ from graphon.file import helpers as file_helpers from models.model import IconType -type JSONValue = str | int | float | bool | None | dict[str, Any] | list[Any] +type ShallowJSONValue = str | int | float | bool | None | dict[str, Any] | list[Any] type JSONObject = dict[str, Any] URLString = Annotated[str, WithJsonSchema({"format": "url", "type": "string"})] UUIDString = Annotated[str, WithJsonSchema({"format": "uuid", "type": "string"})] @@ -335,8 +335,8 @@ class SimpleResultResponse(ResponseModel): result: str = Field(description="Operation result.") -class GeneratedAppResponse(RootModel[JSONValue]): - root: JSONValue +class GeneratedAppResponse(RootModel[ShallowJSONValue]): + root: ShallowJSONValue class EventStreamResponse(RootModel[str]): @@ -360,7 +360,7 @@ class AudioBinaryResponse(RootModel[bytes]): class AudioTranscriptResponse(ResponseModel): - text: str + text: str = Field(description="Transcribed text from audio") class ValidationResultResponse(ResponseModel): diff --git a/api/controllers/common/schema.py b/api/controllers/common/schema.py index 0fb2f3c5884334..1ad9f043427303 100644 --- a/api/controllers/common/schema.py +++ b/api/controllers/common/schema.py @@ -6,13 +6,16 @@ `register_schema_models` emit resolvable OpenAPI 3 references. """ +import re from collections.abc import Iterable, Mapping from enum import StrEnum -from typing import Any, Literal, NotRequired, Protocol, TypedDict +from typing import Any, Literal, NotRequired, Protocol, TypedDict, override +from weakref import WeakKeyDictionary from flask import request from flask_restx import Namespace from pydantic import BaseModel, TypeAdapter +from pydantic.json_schema import CoreModeRef, DefsRef, GenerateJsonSchema DEFAULT_REF_TEMPLATE_OPENAPI_3_0 = "#/components/schemas/{model}" @@ -51,14 +54,96 @@ def to_dict(self, flat: bool = True) -> dict[str, str]: ... def getlist(self, key: str) -> list[str]: ... -def _register_json_schema(namespace: Namespace, name: str, schema: dict) -> None: - """Register a JSON schema and promote any nested Pydantic `$defs`.""" +class SchemaNameCollisionError(ValueError): + """Raised when two different schemas would be published under the same OpenAPI component name.""" + + +# Pydantic names `$defs` after the bare class name and only qualifies them when two +# same-named types meet inside one schema, so the same type can surface under different +# names depending on which model pulled it in. Types that share a class name with +# another documented type get a fixed, unique component name here instead. +SCHEMA_NAME_OVERRIDES: Mapping[str, str] = { + "graphon.model_runtime.entities.common_entities.I18nObject": "ModelRuntimeI18nObject", + "services.knowledge.entities.knowledge_entities.FileInfo": "KnowledgeFileInfo", + "core.plugin.entities.plugin.PluginDependency.Github": "PluginDependencyGithub", + "core.plugin.entities.plugin.PluginDependency.Marketplace": "PluginDependencyMarketplace", + "core.plugin.entities.plugin.PluginDependency.Package": "PluginDependencyPackage", + "core.plugin.entities.bundle.PluginBundleDependency.Github": "PluginBundleDependencyGithub", + "core.plugin.entities.bundle.PluginBundleDependency.Marketplace": "PluginBundleDependencyMarketplace", + "core.plugin.entities.bundle.PluginBundleDependency.Package": "PluginBundleDependencyPackage", +} + +_REF_ID_SUFFIX = re.compile(r":\d+(?=$|[\[\],])") +_MODULE_PREFIX = re.compile(r"(?:[^.[\],]+\.)+([^.[\],]+)") + +# Normalized component name -> (component name, fully qualified source type, schema), per namespace. +_registered_schemas: WeakKeyDictionary[Namespace, dict[str, tuple[str, str, dict]]] = WeakKeyDictionary() +_NON_ALPHANUMERIC = re.compile(r"[^a-z0-9]") + + +def _normalized_schema_name(name: str) -> str: + """Fold names that client code generators turn into the same identifier (`JSONValue` / `JsonValue`).""" + + return _NON_ALPHANUMERIC.sub("", name.lower()) + + +class _OpenApiJsonSchemaGenerator(GenerateJsonSchema): + """Emit stable component names and remember which type produced each of them.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.schema_sources: dict[str, str] = {} + + @override + def get_defs_ref(self, core_mode_ref: CoreModeRef) -> DefsRef: + core_ref, _mode = core_mode_ref + source = _REF_ID_SUFFIX.sub("", core_ref) + name = SCHEMA_NAME_OVERRIDES.get(source) or self.normalize_name(_MODULE_PREFIX.sub(r"\1", source)) + existing_source = self.schema_sources.setdefault(name, source) + if existing_source != source: + raise SchemaNameCollisionError( + f"OpenAPI schema name {name!r} is produced by both {existing_source} and {source}; " + "rename one of them or add it to SCHEMA_NAME_OVERRIDES." + ) + defs_ref = DefsRef(name) + self._prioritized_defsref_choices[defs_ref] = [defs_ref] + return defs_ref + + +def _register_json_schema(namespace: Namespace, name: str, schema: dict, sources: Mapping[str, str]) -> None: + """Register a JSON schema and promote any nested Pydantic `$defs`. + + `sources` maps each component name to the type that produced it. Flask-RESTX keeps + one flat model namespace, so a second registration under an existing name would + silently replace the first. Re-registering the same type with the same schema is a + no-op; a different type, or the same type with a different shape (for example a + response model also registered in validation mode), raises instead. + """ nested_definitions = schema.get("$defs") schema_to_register = dict(schema) if isinstance(nested_definitions, dict): schema_to_register.pop("$defs") + source = sources.get(name, name) + registered = _registered_schemas.setdefault(namespace, {}) + normalized_name = _normalized_schema_name(name) + existing = registered.get(normalized_name) + if existing is not None and existing != (name, source, schema_to_register): + existing_name, existing_source, _ = existing + if existing_name != name: + reason = f"collides with {existing_name!r} from {existing_source} once normalized by client generators" + elif existing_source != source: + reason = f"is produced by both {existing_source} and {source}" + else: + reason = f"is registered by {source} with two different shapes (validation vs serialization mode?)" + raise SchemaNameCollisionError( + f"OpenAPI schema name {name!r} in namespace {namespace.name!r} {reason}; give each shape its own " + "class name, add it to SCHEMA_NAME_OVERRIDES, or register response models only with " + "register_response_schema_models." + ) + registered[normalized_name] = (name, source, schema_to_register) + namespace.schema_model(name, schema_to_register) if not isinstance(nested_definitions, dict): @@ -66,24 +151,27 @@ def _register_json_schema(namespace: Namespace, name: str, schema: dict) -> None for nested_name, nested_schema in nested_definitions.items(): if isinstance(nested_schema, dict): - _register_json_schema(namespace, nested_name, nested_schema) + _register_json_schema(namespace, nested_name, nested_schema, sources) JsonSchemaMode = Literal["validation", "serialization"] -def _register_schema_model(namespace: Namespace, model: type[BaseModel], *, mode: JsonSchemaMode) -> None: +def _register_type_schema(namespace: Namespace, source_type: type, *, mode: JsonSchemaMode) -> None: + generator = _OpenApiJsonSchemaGenerator(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0) + schema = generator.generate(TypeAdapter(source_type).core_schema, mode=mode) _register_json_schema( namespace, - model.__name__, - model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0, mode=mode), + source_type.__name__, + schema, + {**generator.schema_sources, source_type.__name__: f"{source_type.__module__}.{source_type.__qualname__}"}, ) def register_schema_model(namespace: Namespace, model: type[BaseModel]) -> None: """Register a BaseModel and its nested component schemas for OpenAPI documentation.""" - _register_schema_model(namespace, model, mode="validation") + _register_type_schema(namespace, model, mode="validation") def register_schema_models(namespace: Namespace, *models: type[BaseModel]) -> None: @@ -96,7 +184,7 @@ def register_schema_models(namespace: Namespace, *models: type[BaseModel]) -> No def register_response_schema_model(namespace: Namespace, model: type[BaseModel]) -> None: """Register a BaseModel using its serialized response shape.""" - _register_schema_model(namespace, model, mode="serialization") + _register_type_schema(namespace, model, mode="serialization") def register_response_schema_models(namespace: Namespace, *models: type[BaseModel]) -> None: @@ -119,11 +207,7 @@ def get_or_create_model(model_name: str, field_def): def register_enum_models(namespace: Namespace, *models: type[StrEnum]) -> None: """Register multiple StrEnum with a namespace.""" for model in models: - _register_json_schema( - namespace, - model.__name__, - TypeAdapter(model).json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0), - ) + _register_type_schema(namespace, model, mode="validation") def query_params_from_model(model: type[BaseModel]) -> dict[str, QueryParamDoc]: @@ -334,6 +418,8 @@ def _nullable_property_schema(property_schema: Mapping[str, Any]) -> Mapping[str __all__ = [ "DEFAULT_REF_TEMPLATE_OPENAPI_3_0", + "SCHEMA_NAME_OVERRIDES", + "SchemaNameCollisionError", "get_or_create_model", "query_params_from_model", "query_params_from_request", diff --git a/api/controllers/console/app/annotation.py b/api/controllers/console/app/annotation.py index 897ba51d58ee69..0be2c5474e95ad 100644 --- a/api/controllers/console/app/annotation.py +++ b/api/controllers/console/app/annotation.py @@ -140,11 +140,6 @@ class AnnotationBatchImportResponse(ResponseModel): register_schema_models( console_ns, - Annotation, - AnnotationList, - AnnotationExportList, - AnnotationHitHistory, - AnnotationHitHistoryList, AnnotationReplyPayload, AnnotationSettingUpdatePayload, AnnotationListQuery, diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 63975480757109..8e3faec0f3faa7 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -32,7 +32,6 @@ from extensions.ext_application_services import application_services from fields.app_fields import ( AppDetail, - AppDetailSiteResponse, AppDetailWithSite, AppExportResponse, AppImportResponse, @@ -41,7 +40,6 @@ AppPartial, AppTraceResponse, DeletedTool, - ModelConfigPartial, RecentAppListResponse, RecentAppResponse, Tag, @@ -269,8 +267,6 @@ def validate_tracing_provider(cls, value: str | None, info) -> str | None: AppTracePayload, Tag, WorkflowPartial, - ModelConfigPartial, - AppDetailSiteResponse, DeletedTool, AppExportResponse, Segmentation, diff --git a/api/controllers/console/app/audio.py b/api/controllers/console/app/audio.py index b5c6e6598d5f3c..65c14b57ed30de 100644 --- a/api/controllers/console/app/audio.py +++ b/api/controllers/console/app/audio.py @@ -9,6 +9,7 @@ from werkzeug.exceptions import HTTPException, InternalServerError import services +from controllers.common.fields import AudioTranscriptResponse from controllers.common.rbac import AgentId, PlainApp, RBACCheck, enforce_rbac_checks from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns @@ -79,10 +80,6 @@ class TextToSpeechVoiceQuery(BaseModel): language: str = Field(..., description="Language code") -class AudioTranscriptResponse(ResponseModel): - text: str = Field(description="Transcribed text from audio") - - class AgentAudioTranscriptFormPayload(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index e2587abd70b556..2c52db1de20f05 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -45,6 +45,7 @@ MessageDetail as BaseMessageDetailResponse, ) from fields.conversation_fields import MessageResponseSource +from fields.message_fields import SuggestedQuestionsResponse from graphon.model_runtime.errors.invoke import InvokeError from libs.external_api import error_responses from libs.helper import dump_response, uuid_value @@ -87,7 +88,7 @@ def validate_uuid(cls, value: str | None) -> str | None: return uuid_value(value) -class MessageFeedbackPayload(_MessageFeedbackPayloadBase): +class AppMessageFeedbackPayload(_MessageFeedbackPayloadBase): message_id: str = Field(..., description="Message ID") @field_validator("message_id") @@ -100,10 +101,6 @@ class AnnotationCountResponse(ResponseModel): count: int = Field(description="Number of annotations") -class SuggestedQuestionsResponse(ResponseModel): - data: list[str] = Field(description="Suggested question") - - class MessageDetailResponse(BaseMessageDetailResponse): extra_contents: list[ExecutionExtraContentDomainModel] = Field(default_factory=list) @@ -117,7 +114,7 @@ class MessageInfiniteScrollPaginationResponse(ResponseModel): register_schema_models( console_ns, ChatMessagesQuery, - MessageFeedbackPayload, + AppMessageFeedbackPayload, ) register_response_schema_models( console_ns, @@ -198,7 +195,7 @@ class MessageFeedbackApi(Resource): @console_ns.doc("create_message_feedback") @console_ns.doc(description="Create or update message feedback (like/dislike)") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__]) + @console_ns.expect(console_ns.models[AppMessageFeedbackPayload.__name__]) @console_ns.response(200, "Feedback updated successfully", console_ns.models[SimpleResultResponse.__name__]) @console_ns.response(404, "Message not found") @setup_required @@ -207,8 +204,8 @@ class MessageFeedbackApi(Resource): @with_current_user @with_session @get_app_model - @model_validate(MessageFeedbackPayload) - def post(self, req_data: MessageFeedbackPayload, session: Session, current_user: Account, app_model: App): + @model_validate(AppMessageFeedbackPayload) + def post(self, req_data: AppMessageFeedbackPayload, session: Session, current_user: Account, app_model: App): return _update_message_feedback( args=req_data, session=session, @@ -223,7 +220,7 @@ class AgentMessageFeedbackApi(Resource): @console_ns.doc("create_agent_message_feedback") @console_ns.doc(description="Create or update Agent App message feedback") @console_ns.doc(params={"agent_id": "Agent ID"}) - @console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__]) + @console_ns.expect(console_ns.models[AppMessageFeedbackPayload.__name__]) @console_ns.response(200, "Feedback updated successfully", console_ns.models[SimpleResultResponse.__name__]) @console_ns.response(404, "Agent or message not found") @setup_required @@ -233,10 +230,10 @@ class AgentMessageFeedbackApi(Resource): @with_current_user @with_current_tenant_id @with_session - @model_validate(MessageFeedbackPayload) + @model_validate(AppMessageFeedbackPayload) def post( self, - req_data: MessageFeedbackPayload, + req_data: AppMessageFeedbackPayload, session: Session, current_tenant_id: str, current_user: Account, @@ -476,7 +473,7 @@ def _list_chat_messages( def _update_message_feedback( *, - args: MessageFeedbackPayload, + args: AppMessageFeedbackPayload, session: Session, current_user: Account, app_model: App, diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 733f75e4417783..4fbc0425bf1c3f 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -971,7 +971,7 @@ class HumanInputFormPreviewPayload(BaseModel): ) -class HumanInputFormSubmitPayload(BaseModel): +class HumanInputFormPreviewSubmitPayload(BaseModel): form_inputs: dict[str, Any] = Field( ..., description="Values the user provides for the form's own fields", @@ -994,7 +994,7 @@ class HumanInputDeliveryTestPayload(BaseModel): register_schema_models( console_ns, HumanInputFormPreviewPayload, - HumanInputFormSubmitPayload, + HumanInputFormPreviewSubmitPayload, HumanInputDeliveryTestPayload, ) @@ -1036,7 +1036,7 @@ class AdvancedChatDraftHumanInputFormRunApi(Resource): @console_ns.doc("submit_advanced_chat_draft_human_input_form") @console_ns.doc(description="Submit human input form preview for advanced chat workflow") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) - @console_ns.expect(console_ns.models[HumanInputFormSubmitPayload.__name__]) + @console_ns.expect(console_ns.models[HumanInputFormPreviewSubmitPayload.__name__]) @console_ns.response( 200, "Human input form submission result", @@ -1049,8 +1049,8 @@ class AdvancedChatDraftHumanInputFormRunApi(Resource): @get_app_model(mode=[AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required - @model_validate(HumanInputFormSubmitPayload) - def post(self, args: HumanInputFormSubmitPayload, current_user: Account, app_model: App, node_id: str): + @model_validate(HumanInputFormPreviewSubmitPayload) + def post(self, args: HumanInputFormPreviewSubmitPayload, current_user: Account, app_model: App, node_id: str): """ Submit human input form preview """ @@ -1104,7 +1104,7 @@ class WorkflowDraftHumanInputFormRunApi(Resource): @console_ns.doc("submit_workflow_draft_human_input_form") @console_ns.doc(description="Submit human input form preview for workflow") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) - @console_ns.expect(console_ns.models[HumanInputFormSubmitPayload.__name__]) + @console_ns.expect(console_ns.models[HumanInputFormPreviewSubmitPayload.__name__]) @console_ns.response( 200, "Human input form submission result", @@ -1117,8 +1117,8 @@ class WorkflowDraftHumanInputFormRunApi(Resource): @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required - @model_validate(HumanInputFormSubmitPayload) - def post(self, args: HumanInputFormSubmitPayload, current_user: Account, app_model: App, node_id: str): + @model_validate(HumanInputFormPreviewSubmitPayload) + def post(self, args: HumanInputFormPreviewSubmitPayload, current_user: Account, app_model: App, node_id: str): """ Submit human input form preview """ diff --git a/api/controllers/console/app/workflow_trigger.py b/api/controllers/console/app/workflow_trigger.py index 1b9d1d0ebf174c..ab684a6d8f0925 100644 --- a/api/controllers/console/app/workflow_trigger.py +++ b/api/controllers/console/app/workflow_trigger.py @@ -32,7 +32,7 @@ logger = logging.getLogger(__name__) -class Parser(BaseModel): +class WebhookTriggerQuery(BaseModel): node_id: str @@ -82,7 +82,7 @@ def _normalize_string_fields(cls, value: object) -> str: register_schema_models( console_ns, - Parser, + WebhookTriggerQuery, ParserEnable, WorkflowTriggerResponse, WorkflowTriggerListResponse, @@ -94,7 +94,7 @@ def _normalize_string_fields(cls, value: object) -> str: class WebhookTriggerApi(Resource): """Webhook Trigger API""" - @console_ns.doc(params=query_params_from_model(Parser)) + @console_ns.doc(params=query_params_from_model(WebhookTriggerQuery)) @setup_required @login_required @account_initialization_required @@ -102,8 +102,8 @@ class WebhookTriggerApi(Resource): @rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())) @with_session(write=False) @get_app_model(mode=AppMode.WORKFLOW) - @model_validate(Parser) - def get(self, req_data: Parser, session: Session, app_model: App): + @model_validate(WebhookTriggerQuery) + def get(self, req_data: WebhookTriggerQuery, session: Session, app_model: App): """Get webhook trigger for a node""" node_id = req_data.node_id diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py index 0fd1e2225f24ce..2b3007e67ac7fa 100644 --- a/api/controllers/console/datasets/datasets_document.py +++ b/api/controllers/console/datasets/datasets_document.py @@ -19,6 +19,7 @@ ProviderNotInitializeError, ProviderQuotaExceededError, ) +from controllers.console.datasets.datasets import IndexingEstimateResponse from controllers.console.datasets.error import ( ArchivedDocumentImmutableError, DatasetAccessDeniedRequestError, @@ -192,12 +193,6 @@ class DocumentWithSegmentsListResponse(ResponseModel): page: int -class IndexingEstimateResponse(IndexingEstimate): - tokens: int - total_price: float | int - currency: str - - def _serialize_indexing_estimate(estimate: IndexingEstimate) -> tuple[dict[str, object], int]: return ( IndexingEstimateResponse( diff --git a/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py b/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py index af4993d9d51513..048f5cf858db96 100644 --- a/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py +++ b/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py @@ -17,26 +17,26 @@ from services.rag_pipeline.rag_pipeline import RagPipelineService -class Parser(BaseModel): +class DatasourceContentPreviewPayload(BaseModel): inputs: dict[str, Any] datasource_type: str credential_id: str | None = None -register_schema_models(console_ns, Parser) +register_schema_models(console_ns, DatasourceContentPreviewPayload) @console_ns.route("/rag/pipelines//workflows/published/datasource/nodes//preview") class DataSourceContentPreviewApi(Resource): - @console_ns.expect(console_ns.models[Parser.__name__]) + @console_ns.expect(console_ns.models[DatasourceContentPreviewPayload.__name__]) @console_ns.response(200, "Success") @setup_required @login_required @account_initialization_required @get_rag_pipeline @with_current_user - @model_validate(Parser) - def post(self, req_data: Parser, current_user: Account, pipeline: Pipeline, node_id: str): + @model_validate(DatasourceContentPreviewPayload) + def post(self, req_data: DatasourceContentPreviewPayload, current_user: Account, pipeline: Pipeline, node_id: str): """ Run datasource content preview """ diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py index c02db46f393bb2..96813c213130b8 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -107,14 +107,14 @@ class DatasourceNodeRunPayload(BaseModel): credential_id: str | None = None -class DraftWorkflowRunPayload(BaseModel): +class RagPipelineDraftWorkflowRunPayload(BaseModel): inputs: dict[str, Any] datasource_type: str datasource_info_list: list[dict[str, Any]] start_node_id: str -class PublishedWorkflowRunPayload(DraftWorkflowRunPayload): +class PublishedWorkflowRunPayload(RagPipelineDraftWorkflowRunPayload): is_preview: bool = False response_mode: Literal["streaming", "blocking"] = "streaming" original_document_id: str | None = None @@ -124,7 +124,7 @@ class NodeIdQuery(BaseModel): node_id: str -class WorkflowRunQuery(BaseModel): +class RagPipelineWorkflowRunQuery(BaseModel): last_id: UUIDStrOrEmpty | None = None limit: int = Field(default=20, ge=1, le=100) @@ -173,13 +173,13 @@ class RagPipelineStepParametersResponse(ResponseModel): NodeRunPayload, NodeRunRequiredPayload, DatasourceNodeRunPayload, - DraftWorkflowRunPayload, + RagPipelineDraftWorkflowRunPayload, PublishedWorkflowRunPayload, DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload, NodeIdQuery, - WorkflowRunQuery, + RagPipelineWorkflowRunQuery, DatasourceVariablesPayload, RagPipelineRecommendedPluginQuery, ) @@ -374,7 +374,7 @@ def post(self, req_data: NodeRunPayload, current_user: Account, pipeline: Pipeli @console_ns.route("/rag/pipelines//workflows/draft/run") class DraftRagPipelineRunApi(Resource): @error_responses(InvokeRateLimitHttpError, PipelineNotFoundError) - @console_ns.expect(console_ns.models[DraftWorkflowRunPayload.__name__]) + @console_ns.expect(console_ns.models[RagPipelineDraftWorkflowRunPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__]) @setup_required @login_required @@ -383,8 +383,10 @@ class DraftRagPipelineRunApi(Resource): @rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetByPipeline())) @with_current_user @with_session - @model_validate(DraftWorkflowRunPayload) - def post(self, req_data: DraftWorkflowRunPayload, session: Session, current_user: Account, pipeline_id: UUID): + @model_validate(RagPipelineDraftWorkflowRunPayload) + def post( + self, req_data: RagPipelineDraftWorkflowRunPayload, session: Session, current_user: Account, pipeline_id: UUID + ): """ Run draft workflow """ @@ -939,7 +941,7 @@ def get(self, req_data: NodeIdQuery, pipeline: Pipeline): @console_ns.route("/rag/pipelines//workflow-runs") class RagPipelineWorkflowRunListApi(Resource): - @console_ns.doc(params=query_params_from_model(WorkflowRunQuery)) + @console_ns.doc(params=query_params_from_model(RagPipelineWorkflowRunQuery)) @console_ns.response( 200, "Workflow runs retrieved successfully", @@ -954,7 +956,7 @@ def get(self, session: Session, pipeline: Pipeline): """ Get workflow run list """ - query = WorkflowRunQuery.model_validate( + query = RagPipelineWorkflowRunQuery.model_validate( { "last_id": request.args.get("last_id"), "limit": request.args.get("limit", type=int, default=20), diff --git a/api/controllers/console/explore/completion.py b/api/controllers/console/explore/completion.py index b8a1e1b76b81a2..87b80345f7bad2 100644 --- a/api/controllers/console/explore/completion.py +++ b/api/controllers/console/explore/completion.py @@ -54,7 +54,7 @@ class CompletionMessageExplorePayload(BaseModel): retriever_from: str = Field(default="explore_app") -class ChatMessagePayload(BaseModel): +class ChatMessageExplorePayload(BaseModel): inputs: dict[str, Any] query: str files: list[dict[str, Any]] | None = Field(default=None) @@ -77,7 +77,7 @@ def normalize_uuid(cls, value: str | UUID | None) -> str | None: raise ValueError("must be a valid UUID") from exc -register_schema_models(console_ns, CompletionMessageExplorePayload, ChatMessagePayload) +register_schema_models(console_ns, CompletionMessageExplorePayload, ChatMessageExplorePayload) register_response_schema_models(console_ns, SimpleResultResponse) @@ -185,14 +185,14 @@ class ChatApi(Resource): ProviderNotInitializeError, ProviderQuotaExceededError, ) - @console_ns.expect(console_ns.models[ChatMessagePayload.__name__]) + @console_ns.expect(console_ns.models[ChatMessageExplorePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success") @console_account_admission() @get_installed_app - @model_validate(ChatMessagePayload) + @model_validate(ChatMessageExplorePayload) def post( self, - req_data: ChatMessagePayload, + req_data: ChatMessageExplorePayload, request_context: RequestContext, installed_app: InstalledAppRef, ) -> Response: diff --git a/api/controllers/console/explore/recommended_app.py b/api/controllers/console/explore/recommended_app.py index b09b7e2ca8ce79..c5adb9e86aca72 100644 --- a/api/controllers/console/explore/recommended_app.py +++ b/api/controllers/console/explore/recommended_app.py @@ -12,7 +12,7 @@ from controllers.console.flask_admission import console_account_admission from controllers.console.wraps import model_validate from extensions.ext_application_services import application_services -from fields.app_export_fields import AppExportResponse +from fields.app_fields import AppExportResponse from fields.base import ResponseModel from libs.external_api import error_responses from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY diff --git a/api/controllers/console/snippets/payloads.py b/api/controllers/console/snippets/payloads.py index 86b7735de6fffd..4bd3e061c8db5d 100644 --- a/api/controllers/console/snippets/payloads.py +++ b/api/controllers/console/snippets/payloads.py @@ -110,7 +110,7 @@ class SnippetWorkflowListQuery(BaseModel): limit: int = Field(default=10, ge=1, le=100) -class WorkflowRunQuery(BaseModel): +class SnippetWorkflowRunQuery(BaseModel): """Query parameters for workflow runs.""" last_id: str | None = None @@ -144,7 +144,7 @@ class SnippetLoopNodeRunPayload(BaseModel): inputs: dict[str, Any] | None = Field(default=None) -class PublishWorkflowPayload(BaseModel): +class SnippetPublishWorkflowPayload(BaseModel): """Payload for publishing snippet workflow.""" knowledge_base_setting: dict[str, Any] | None = Field(default=None) diff --git a/api/controllers/console/snippets/snippet_workflow.py b/api/controllers/console/snippets/snippet_workflow.py index 6c837abd7d74a0..fdaa90f6f097e6 100644 --- a/api/controllers/console/snippets/snippet_workflow.py +++ b/api/controllers/console/snippets/snippet_workflow.py @@ -26,14 +26,14 @@ WorkflowRestoreResponse, ) from controllers.console.snippets.payloads import ( - PublishWorkflowPayload, SnippetDraftNodeRunPayload, SnippetDraftRunPayload, SnippetDraftSyncPayload, SnippetIterationNodeRunPayload, SnippetLoopNodeRunPayload, + SnippetPublishWorkflowPayload, SnippetWorkflowListQuery, - WorkflowRunQuery, + SnippetWorkflowRunQuery, ) from controllers.console.wraps import ( RBACPermission, @@ -102,9 +102,9 @@ class SnippetWorkflowPaginationResponse(BaseModel): SnippetIterationNodeRunPayload, SnippetLoopNodeRunPayload, SnippetWorkflowListQuery, - WorkflowRunQuery, + SnippetWorkflowRunQuery, WorkflowUpdatePayload, - PublishWorkflowPayload, + SnippetPublishWorkflowPayload, ) register_response_schema_models( console_ns, @@ -288,7 +288,7 @@ def get(self, session: Session, snippet: CustomizedSnippet): return response @console_ns.doc("publish_snippet_workflow") - @console_ns.expect(console_ns.models.get(PublishWorkflowPayload.__name__)) + @console_ns.expect(console_ns.models.get(SnippetPublishWorkflowPayload.__name__)) @console_ns.response(200, "Workflow published successfully", console_ns.models[WorkflowPublishResponse.__name__]) @console_ns.response(400, "No draft workflow found") @setup_required @@ -514,7 +514,7 @@ def delete(self, snippet: CustomizedSnippet, workflow_id: str): @console_ns.route("/snippets//workflow-runs") class SnippetWorkflowRunsApi(Resource): @console_ns.doc("list_snippet_workflow_runs") - @console_ns.doc(params=query_params_from_model(WorkflowRunQuery)) + @console_ns.doc(params=query_params_from_model(SnippetWorkflowRunQuery)) @console_ns.response( 200, "Workflow runs retrieved successfully", @@ -527,7 +527,7 @@ class SnippetWorkflowRunsApi(Resource): @with_session(write=False) def get(self, session: Session, snippet: CustomizedSnippet): """List workflow runs for snippet.""" - query = WorkflowRunQuery.model_validate( + query = SnippetWorkflowRunQuery.model_validate( { "last_id": request.args.get("last_id"), "limit": request.args.get("limit", type=int, default=20), diff --git a/api/controllers/inner_api/app/dsl.py b/api/controllers/inner_api/app/dsl.py index 301a754f2bbfe8..4169a7320b46eb 100644 --- a/api/controllers/inner_api/app/dsl.py +++ b/api/controllers/inner_api/app/dsl.py @@ -24,7 +24,7 @@ from controllers.inner_api.wraps import enterprise_inner_api_only from core.logging.context import get_request_id, get_trace_id from extensions.ext_application_services import application_services -from fields.app_export_fields import AppExportResponse +from fields.app_fields import AppExportResponse from fields.base import ResponseModel from libs.helper import dump_response from services.entities.app_entities import AppExportOptions diff --git a/api/controllers/service_api/app/annotation.py b/api/controllers/service_api/app/annotation.py index 0d4317541898c1..da26ad37f0d335 100644 --- a/api/controllers/service_api/app/annotation.py +++ b/api/controllers/service_api/app/annotation.py @@ -64,8 +64,6 @@ class AnnotationListQuery(BaseModel): AnnotationCreatePayload, AnnotationReplyActionPayload, AnnotationListQuery, - Annotation, - AnnotationList, ) register_response_schema_models( service_api_ns, diff --git a/api/controllers/web/human_input_file_upload.py b/api/controllers/web/human_input_file_upload.py index 5befd9a2f7ef19..3966f9fee3905e 100644 --- a/api/controllers/web/human_input_file_upload.py +++ b/api/controllers/web/human_input_file_upload.py @@ -28,7 +28,7 @@ TooManyFilesError, UnsupportedFileTypeError, ) -from controllers.common.schema import JsonResponseWithStatus, register_schema_models +from controllers.common.schema import JsonResponseWithStatus, register_response_schema_models, register_schema_models from controllers.web import web_ns from extensions.ext_application_services import application_services from fields.file_fields import FileResponse, FileWithSignedUrl @@ -80,7 +80,8 @@ class HumanInputFileUploadFormPayload(BaseModel): url: HttpUrl | None = Field(default=None, description="Remote file URL") -register_schema_models(web_ns, HumanInputFileUploadFormPayload, FileResponse, FileWithSignedUrl) +register_schema_models(web_ns, HumanInputFileUploadFormPayload) +register_response_schema_models(web_ns, FileResponse, FileWithSignedUrl) def _extract_hitl_upload_token() -> str: diff --git a/api/fields/app_export_fields.py b/api/fields/app_export_fields.py deleted file mode 100644 index 91322b8cca8a81..00000000000000 --- a/api/fields/app_export_fields.py +++ /dev/null @@ -1,5 +0,0 @@ -from fields.base import ResponseModel - - -class AppExportResponse(ResponseModel): - data: str diff --git a/api/fields/conversation_fields.py b/api/fields/conversation_fields.py index bb016b9ca53b22..02d3e898cbe337 100644 --- a/api/fields/conversation_fields.py +++ b/api/fields/conversation_fields.py @@ -24,7 +24,7 @@ Message as MessageModel, ) -type JSONValue = Any +type AnyJSONValue = Any UUIDString = Annotated[str, WithJsonSchema({"format": "uuid", "type": "string"})] Int64 = Annotated[int, WithJsonSchema({"format": "int64", "type": "integer"})] URLString = Annotated[str, WithJsonSchema({"format": "url", "type": "string"})] @@ -155,7 +155,7 @@ def _normalize_transfer_method(cls, value: object) -> str: class SimpleConversation(ResponseModel): id: UUIDString name: str - inputs: dict[str, JSONValue] + inputs: dict[str, AnyJSONValue] status: str introduction: str | None = None created_at: Int64 | None = None @@ -163,7 +163,7 @@ class SimpleConversation(ResponseModel): @field_validator("inputs", mode="before") @classmethod - def _normalize_inputs(cls, value: JSONValue) -> JSONValue: + def _normalize_inputs(cls, value: AnyJSONValue) -> AnyJSONValue: return format_files_contained(value) @field_validator("created_at", "updated_at", mode="before") @@ -233,7 +233,7 @@ class AgentThought(ResponseModel): thought: str | None = None answer: str | None = None tool: str | None = None - tool_labels: JSONValue = Field(description="Labels for tools used.") + tool_labels: AnyJSONValue = Field(description="Labels for tools used.") tool_input: str | None = None created_at: int | None = None observation: str | None = None @@ -254,9 +254,9 @@ def _fallback_chain_id(self): class MessageDetail(ResponseModel): id: str conversation_id: str - inputs: dict[str, JSONValue] + inputs: dict[str, AnyJSONValue] query: str - message: JSONValue + message: AnyJSONValue message_tokens: int answer: str = Field(validation_alias="re_sign_file_url_answer") answer_tokens: int @@ -271,14 +271,14 @@ class MessageDetail(ResponseModel): created_at: int | None = None agent_thoughts: list[AgentThought] message_files: list[MessageFile] - metadata: JSONValue = Field(validation_alias="message_metadata_dict") + metadata: AnyJSONValue = Field(validation_alias="message_metadata_dict") status: str error: str | None = None parent_message_id: str | None = None @field_validator("inputs", mode="before") @classmethod - def _normalize_inputs(cls, value: JSONValue) -> JSONValue: + def _normalize_inputs(cls, value: AnyJSONValue) -> AnyJSONValue: return format_files_contained(value) @field_validator("created_at", mode="before") @@ -299,17 +299,17 @@ class StatusCount(ResponseModel): paused: int -class ModelConfig(ResponseModel): +class ConversationModelConfig(ResponseModel): opening_statement: str | None = None - suggested_questions: JSONValue | None = Field(default=None) - model: JSONValue | None = Field(default=None) - user_input_form: JSONValue | None = Field(default=None) + suggested_questions: AnyJSONValue | None = Field(default=None) + model: AnyJSONValue | None = Field(default=None) + user_input_form: AnyJSONValue | None = Field(default=None) pre_prompt: str | None = None - agent_mode: JSONValue | None = Field(default=None) + agent_mode: AnyJSONValue | None = Field(default=None) class SimpleModelConfig(ResponseModel): - model: JSONValue | None = Field( + model: AnyJSONValue | None = Field( default=None, validation_alias="model_dict", ) @@ -317,19 +317,19 @@ class SimpleModelConfig(ResponseModel): class SimpleMessageDetail(ResponseModel): - inputs: dict[str, JSONValue] + inputs: dict[str, AnyJSONValue] query: str message: str answer: str @field_validator("inputs", mode="before") @classmethod - def _normalize_inputs(cls, value: JSONValue) -> JSONValue: + def _normalize_inputs(cls, value: AnyJSONValue) -> AnyJSONValue: return format_files_contained(value) @field_validator("message", mode="before") @classmethod - def _normalize_message(cls, value: JSONValue) -> str: + def _normalize_message(cls, value: AnyJSONValue) -> str: return message_text(value) @@ -371,7 +371,7 @@ class ConversationMessageDetail(ResponseModel): from_end_user_id: str | None = None from_account_id: str | None = None created_at: int | None = None - model_config_: ModelConfig | None = Field(default=None, alias="model_config") + model_config_: ConversationModelConfig | None = Field(default=None, alias="model_config") message: MessageDetail | None = Field(default=None, validation_alias="first_message") @field_validator("created_at", mode="before") @@ -424,7 +424,7 @@ class ConversationDetail(ResponseModel): updated_at: int | None = None annotated: bool introduction: str | None = None - model_config_: ModelConfig | None = Field(default=None, alias="model_config") + model_config_: ConversationModelConfig | None = Field(default=None, alias="model_config") message_count: int user_feedback_stats: FeedbackStat | None = None admin_feedback_stats: FeedbackStat | None = None @@ -435,7 +435,7 @@ def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: return to_timestamp(value) -def format_files_contained(value: JSONValue) -> JSONValue: +def format_files_contained(value: AnyJSONValue) -> AnyJSONValue: if isinstance(value, File): # Response payloads must preserve legacy file keys like `related_id`/`url` # while still exposing the new graph-layer `reference` field. @@ -447,7 +447,7 @@ def format_files_contained(value: JSONValue) -> JSONValue: return value -def message_text(value: JSONValue) -> str: +def message_text(value: AnyJSONValue) -> str: if isinstance(value, list) and value: first = value[0] if isinstance(first, dict): @@ -457,7 +457,7 @@ def message_text(value: JSONValue) -> str: return "" -def extract_model_config(value: object | None) -> dict[str, JSONValue]: +def extract_model_config(value: object | None) -> dict[str, AnyJSONValue]: if value is None: return {} if isinstance(value, dict): diff --git a/api/fields/message_fields.py b/api/fields/message_fields.py index 8bb91dee3d77b6..6e3f6b396415a1 100644 --- a/api/fields/message_fields.py +++ b/api/fields/message_fields.py @@ -9,11 +9,11 @@ from core.entities.execution_extra_content import ExecutionExtraContentDomainModel from fields.base import ResponseModel -from fields.conversation_fields import AgentThought, JSONValue, MessageFile +from fields.conversation_fields import AgentThought, AnyJSONValue, MessageFile from graphon.file import File from libs.helper import to_timestamp -type JSONValueType = JSONValue +type JSONValueType = AnyJSONValue UUIDString = Annotated[str, WithJsonSchema({"format": "uuid", "type": "string"})] Int64 = Annotated[int, WithJsonSchema({"format": "int64", "type": "integer"})] FloatNumber = Annotated[float, WithJsonSchema({"format": "float", "type": "number"})] diff --git a/api/fields/workflow_draft_variable_fields.py b/api/fields/workflow_draft_variable_fields.py index 354082edc1935a..ba3b754a078060 100644 --- a/api/fields/workflow_draft_variable_fields.py +++ b/api/fields/workflow_draft_variable_fields.py @@ -10,10 +10,10 @@ from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment from models.workflow import WorkflowDraftVariable -type JSONValue = str | int | float | bool | dict[str, "JSONValue"] | list["JSONValue"] | None +type RecursiveJSONValue = str | int | float | bool | dict[str, "RecursiveJSONValue"] | list["RecursiveJSONValue"] | None -def _convert_values_to_json_serializable_object(value: Segment) -> JSONValue: +def _convert_values_to_json_serializable_object(value: Segment) -> RecursiveJSONValue: match value: case FileSegment(): return value.value.model_dump() @@ -25,7 +25,7 @@ def _convert_values_to_json_serializable_object(value: Segment) -> JSONValue: return value.value -def _serialize_var_value(variable: WorkflowDraftVariable) -> JSONValue: +def _serialize_var_value(variable: WorkflowDraftVariable) -> RecursiveJSONValue: value = variable.get_value() # Create a copy to avoid mutating the model's cached deserialized value. value = value.model_copy(deep=True) @@ -98,7 +98,7 @@ def _from_workflow_draft_variable(cls, value: Any) -> Any: class WorkflowDraftVariableResponse(WorkflowDraftVariableWithoutValueResponse): - value: JSONValue + value: RecursiveJSONValue full_content: WorkflowDraftVariableFullContentResponse | None @model_validator(mode="before") diff --git a/api/services/entities/knowledge_retrieval_inner.py b/api/services/entities/knowledge_retrieval_inner.py index aaf686da44c8f0..8d47aace3d54da 100644 --- a/api/services/entities/knowledge_retrieval_inner.py +++ b/api/services/entities/knowledge_retrieval_inner.py @@ -23,7 +23,7 @@ from fields.base import ResponseModel type JsonScalar = str | int | float | bool | None -type JsonValue = JsonScalar | list[JsonScalar] | dict[str, JsonScalar] +type CompletionParamValue = JsonScalar | list[JsonScalar] | dict[str, JsonScalar] type MetadataValue = str | list[str] | int | float | None @@ -47,7 +47,7 @@ class InnerKnowledgeRetrieveModelConfig(BaseModel): provider: str = Field(min_length=1) name: str = Field(min_length=1) mode: str = Field(min_length=1) - completion_params: dict[str, JsonValue] = Field(default_factory=dict) + completion_params: dict[str, CompletionParamValue] = Field(default_factory=dict) class InnerKnowledgeRetrieveRerankingModelConfig(BaseModel): diff --git a/api/tests/unit_tests/controllers/common/test_schema.py b/api/tests/unit_tests/controllers/common/test_schema.py index 0dd56fabaa6d00..1e2302cf29c91d 100644 --- a/api/tests/unit_tests/controllers/common/test_schema.py +++ b/api/tests/unit_tests/controllers/common/test_schema.py @@ -8,7 +8,7 @@ import pytest from flask import Flask from flask_restx import Namespace, fields -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, create_model class UserModel(BaseModel): @@ -127,6 +127,107 @@ def test_register_schema_model_promotes_nested_pydantic_definitions(): assert called_schemas["ChildModel"] == parent_schema["$defs"]["ChildModel"] +def _make_same_named_model(fields: dict[str, type]) -> type[BaseModel]: + """Build a model whose qualified name differs from UserModel but whose bare name matches.""" + + return create_model( + "UserModel", + __module__="other_module", + **{name: (annotation, ...) for name, annotation in fields.items()}, + ) + + +def test_register_schema_model_allows_reregistering_the_same_model(): + from controllers.common.schema import register_schema_model + + namespace = Namespace("test") + + register_schema_model(namespace, UserModel) + register_schema_model(namespace, UserModel) + + assert list(namespace.models) == ["UserModel"] + + +def test_register_schema_model_rejects_different_model_with_same_name(): + from controllers.common.schema import SchemaNameCollisionError, register_schema_model + + namespace = Namespace("test") + register_schema_model(namespace, UserModel) + + with pytest.raises(SchemaNameCollisionError, match="'UserModel'.*produced by both"): + register_schema_model(namespace, _make_same_named_model({"email": str})) + + +def test_register_schema_model_rejects_nested_definition_with_same_name(): + from controllers.common.schema import SchemaNameCollisionError, register_schema_model + + other_child = create_model("ChildModel", __module__="other_module", other=(int, ...)) + other_parent = create_model("OtherParentModel", child=(other_child, ...)) + namespace = Namespace("test") + register_schema_model(namespace, ParentModel) + + with pytest.raises(SchemaNameCollisionError, match="'ChildModel'"): + register_schema_model(namespace, other_parent) + + +def test_register_schema_model_rejects_same_named_types_inside_one_schema(): + from controllers.common.schema import SchemaNameCollisionError, register_schema_model + + other_child = create_model("ChildModel", __module__="other_module", other=(int, ...)) + both_children = create_model("BothChildrenModel", first=(ChildModel, ...), second=(other_child, ...)) + + with pytest.raises(SchemaNameCollisionError, match="'ChildModel'"): + register_schema_model(Namespace("test"), both_children) + + +def test_register_schema_model_rejects_model_registered_in_both_modes_with_different_shapes(): + from controllers.common.schema import ( + SchemaNameCollisionError, + register_response_schema_model, + register_schema_model, + ) + + namespace = Namespace("test") + register_response_schema_model(namespace, ResponseAliasModel) + + with pytest.raises(SchemaNameCollisionError, match="two different shapes"): + register_schema_model(namespace, ResponseAliasModel) + + +def test_register_schema_model_rejects_names_that_collide_after_normalization(): + from controllers.common.schema import SchemaNameCollisionError, register_schema_model + + namespace = Namespace("test") + register_schema_model(namespace, create_model("JSONPayload", value=(str, ...))) + + with pytest.raises(SchemaNameCollisionError, match="once normalized"): + register_schema_model(namespace, create_model("JsonPayload", value=(str, ...))) + + +def test_register_schema_model_keeps_namespaces_independent(): + from controllers.common.schema import register_schema_model + + register_schema_model(Namespace("first"), UserModel) + register_schema_model(Namespace("second"), _make_same_named_model({"email": str})) + + +def test_register_schema_model_applies_schema_name_overrides(monkeypatch: pytest.MonkeyPatch): + from controllers.common import schema as schema_module + + monkeypatch.setattr( + schema_module, + "SCHEMA_NAME_OVERRIDES", + {f"{ChildModel.__module__}.{ChildModel.__qualname__}": "RenamedChildModel"}, + ) + namespace = Namespace("test") + + schema_module.register_schema_model(namespace, ParentModel) + + assert set(namespace.models) == {"ParentModel", "RenamedChildModel"} + parent_schema = namespace.models["ParentModel"].__schema__ + assert parent_schema["properties"]["child"]["$ref"] == "#/components/schemas/RenamedChildModel" + + def test_register_schema_models_registers_multiple_models(): from controllers.common.schema import register_schema_models diff --git a/api/tests/unit_tests/controllers/console/app/test_app_apis.py b/api/tests/unit_tests/controllers/console/app/test_app_apis.py index bb9ed018407d24..d371be2ec66299 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_apis.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_apis.py @@ -68,7 +68,7 @@ WorkflowDraftVariableUpdatePayload, ) from controllers.console.app.workflow_statistic import WorkflowStatisticQuery -from controllers.console.app.workflow_trigger import Parser, ParserEnable +from controllers.console.app.workflow_trigger import ParserEnable, WebhookTriggerQuery from machinery.context import RequestContext from models import App, Site from models.account import Account, AccountStatus @@ -784,7 +784,7 @@ def _patch_statistic_time_range(monkeypatch: pytest.MonkeyPatch) -> None: class TestWorkflowTriggerEndpoints: def test_webhook_trigger_payload(self): - payload = Parser(node_id="node-1") + payload = WebhookTriggerQuery(node_id="node-1") assert payload.node_id == "node-1" enable_payload = ParserEnable(trigger_id="trigger-1", enable_trigger=True) @@ -808,7 +808,7 @@ def test_webhook_trigger_api_get( sqlite_session.commit() with database_app.test_request_context("/?node_id=node-1"): - result = method(api, Parser(node_id="node-1"), sqlite_session, app_model=_make_app()) + result = method(api, WebhookTriggerQuery(node_id="node-1"), sqlite_session, app_model=_make_app()) assert isinstance(result, dict) assert {"id", "webhook_id", "webhook_url", "webhook_debug_url", "node_id", "created_at"} <= set(result.keys()) @@ -844,7 +844,7 @@ def test_multiple_payload_types(self): annotation_module.AnnotationReplyPayload( score_threshold=0.5, embedding_provider_name="openai", embedding_model_name="text-embedding-3-small" ), - message_module.MessageFeedbackPayload(message_id=str(uuid.uuid4()), rating="like"), + message_module.AppMessageFeedbackPayload(message_id=str(uuid.uuid4()), rating="like"), statistic_module.StatisticTimeRangeQuery(start="2024-01-01"), ] assert len(payloads) == 3 diff --git a/api/tests/unit_tests/controllers/console/app/test_message_api.py b/api/tests/unit_tests/controllers/console/app/test_message_api.py index d21973425cad3b..e442b0c398cdaa 100644 --- a/api/tests/unit_tests/controllers/console/app/test_message_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_message_api.py @@ -115,7 +115,7 @@ def test_app_message_routes_pass_injected_session( ) == {"data": []} assert unwrap(message_module.MessageFeedbackApi.post)( message_module.MessageFeedbackApi(), - message_module.MessageFeedbackPayload(message_id=message_id, rating="like"), + message_module.AppMessageFeedbackPayload(message_id=message_id, rating="like"), session, current_user, app_model, @@ -148,7 +148,7 @@ def test_update_message_feedback_commits_injected_session(app: Flask, sqlite_ses with app.test_request_context(json={"message_id": message_id, "rating": "like", "content": "helpful"}): result = message_module._update_message_feedback( - args=message_module.MessageFeedbackPayload( + args=message_module.AppMessageFeedbackPayload( message_id=message_id, rating="like", content="helpful", @@ -273,8 +273,8 @@ def test_chat_messages_query_empty_first_id(app: Flask, monkeypatch: pytest.Monk def test_message_feedback_payload_valid_like(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - """Test MessageFeedbackPayload with like rating.""" - payload = message_module.MessageFeedbackPayload( + """Test AppMessageFeedbackPayload with like rating.""" + payload = message_module.AppMessageFeedbackPayload( message_id="550e8400-e29b-41d4-a716-446655440000", rating="like", content="Good answer", @@ -284,8 +284,8 @@ def test_message_feedback_payload_valid_like(app: Flask, monkeypatch: pytest.Mon def test_message_feedback_payload_valid_dislike(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - """Test MessageFeedbackPayload with dislike rating.""" - payload = message_module.MessageFeedbackPayload( + """Test AppMessageFeedbackPayload with dislike rating.""" + payload = message_module.AppMessageFeedbackPayload( message_id="550e8400-e29b-41d4-a716-446655440000", rating="dislike", ) @@ -293,8 +293,8 @@ def test_message_feedback_payload_valid_dislike(app: Flask, monkeypatch: pytest. def test_message_feedback_payload_no_rating(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - """Test MessageFeedbackPayload without rating.""" - payload = message_module.MessageFeedbackPayload(message_id="550e8400-e29b-41d4-a716-446655440000") + """Test AppMessageFeedbackPayload without rating.""" + payload = message_module.AppMessageFeedbackPayload(message_id="550e8400-e29b-41d4-a716-446655440000") assert payload.rating is None diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_trigger_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_trigger_api.py index 1aaceaff3eb17d..1d931b0be533e7 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_trigger_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_trigger_api.py @@ -48,7 +48,7 @@ def _persist_app_trigger( def test_parser_models_validate(): - parser = workflow_trigger_module.Parser(node_id="node-1") + parser = workflow_trigger_module.WebhookTriggerQuery(node_id="node-1") enable_parser = workflow_trigger_module.ParserEnable( trigger_id="550e8400-e29b-41d4-a716-446655440000", enable_trigger=True ) diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_content_preview.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_content_preview.py index daae29fd75ce2e..6db24797f4aca9 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_content_preview.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_content_preview.py @@ -7,7 +7,7 @@ from controllers.console import console_ns from controllers.console.datasets.rag_pipeline.datasource_content_preview import ( DataSourceContentPreviewApi, - Parser, + DatasourceContentPreviewPayload, ) from models import Account from models.dataset import Pipeline @@ -50,7 +50,7 @@ def test_post_success(self, app: Flask): return_value=service_instance, ), ): - req_data = Parser.model_validate(payload) + req_data = DatasourceContentPreviewPayload.model_validate(payload) response, status = method(api, req_data, account, pipeline, node_id) service_instance.run_datasource_node_preview.assert_called_once_with( @@ -83,7 +83,7 @@ def test_post_invalid_payload(self, app: Flask): patch.object(type(console_ns), "payload", payload), ): with pytest.raises(ValueError): - method(api, Parser.model_validate(payload), account, pipeline, "node-1") + method(api, DatasourceContentPreviewPayload.model_validate(payload), account, pipeline, "node-1") def test_post_without_credential_id(self, app: Flask): api = DataSourceContentPreviewApi() @@ -109,7 +109,7 @@ def test_post_without_credential_id(self, app: Flask): return_value=service_instance, ), ): - req_data = Parser.model_validate(payload) + req_data = DatasourceContentPreviewPayload.model_validate(payload) response, status = method(api, req_data, account, pipeline, "node-1") service_instance.run_datasource_node_preview.assert_called_once() diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py index 8794c7d7b6af4a..6f5b00a0153673 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py @@ -17,9 +17,9 @@ from controllers.console.datasets.rag_pipeline import rag_pipeline_workflow as module from controllers.console.datasets.rag_pipeline.rag_pipeline_workflow import ( - DraftWorkflowRunPayload, NodeIdQuery, PublishedWorkflowRunPayload, + RagPipelineDraftWorkflowRunPayload, RagPipelineRecommendedPluginQuery, WorkflowListQuery, WorkflowUpdatePayload, @@ -404,7 +404,7 @@ def test_rag_pipeline_run_uses_sqlite_session( patch.object(module.helper, "compact_generate_response", return_value={"ok": True}), ): req_data = ( - DraftWorkflowRunPayload.model_validate(payload) + RagPipelineDraftWorkflowRunPayload.model_validate(payload) if api_type is module.DraftRagPipelineRunApi else PublishedWorkflowRunPayload.model_validate(payload) ) @@ -434,7 +434,7 @@ def test_rag_pipeline_run_translates_rate_limit( handler = unwrap_all(api.post) pipeline = _pipeline() req_data = ( - DraftWorkflowRunPayload.model_validate(payload) + RagPipelineDraftWorkflowRunPayload.model_validate(payload) if api_type is module.DraftRagPipelineRunApi else PublishedWorkflowRunPayload.model_validate(payload) ) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 5617d5905b2463..41243788e1fe81 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4423,4 +4423,4 @@ "count": 2 } } -} +} \ No newline at end of file diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 71720dff30e7e8..d01e6f92cb825a 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -427,7 +427,7 @@ export type AgentAppFeaturesPayload = { text_to_speech?: AgentTextToSpeechFeatureConfig | null } -export type MessageFeedbackPayload = { +export type AppMessageFeedbackPayload = { content?: string | null message_id: string rating?: 'dislike' | 'like' | null @@ -470,12 +470,12 @@ export type MessageDetailResponse = { from_source: string id: string inputs: { - [key: string]: JsonValue + [key: string]: AnyJsonValue } - message: JsonValue + message: AnyJsonValue message_files: Array message_tokens: number - metadata: JsonValue + metadata: AnyJsonValue parent_message_id?: string | null provider_response_latency: number query: string @@ -1048,7 +1048,7 @@ export type AgentThought = { thought?: string | null tool?: string | null tool_input?: string | null - tool_labels: JsonValue + tool_labels: AnyJsonValue } export type ConversationAnnotation = { @@ -1081,16 +1081,7 @@ export type Feedback = { rating: string } -export type JsonValue = - | string - | number - | number - | boolean - | { - [key: string]: unknown - } - | Array - | null +export type AnyJsonValue = unknown export type MessageFile = { belongs_to?: string | null @@ -1238,7 +1229,7 @@ export type AppDatasetConfigsResponse = { export type AppEnabledExternalDataToolResponse = { config: { - [key: string]: JsonValue2 + [key: string]: JsonValue } enabled: true icon?: string @@ -1251,7 +1242,7 @@ export type AppEnabledExternalDataToolResponse = { export type AppDisabledExternalDataToolResponse = { config?: { - [key: string]: JsonValue2 + [key: string]: JsonValue } enabled: false icon?: string @@ -1273,7 +1264,7 @@ export type AppFileUploadResponse = { export type AppModelSelectionResponse = { completion_params?: { - [key: string]: JsonValue2 + [key: string]: JsonValue } mode?: LlmMode | '' name?: string @@ -1289,9 +1280,9 @@ export type PromptType = 'advanced' | 'simple' export type AppSensitiveWordAvoidanceResponse = { config?: { - [key: string]: JsonValue2 + [key: string]: JsonValue } - configs?: Array + configs?: Array enabled: boolean type?: string [key: string]: unknown @@ -1628,7 +1619,7 @@ export type HumanInputFormSubmissionData = { node_title: string rendered_content: string submitted_data?: { - [key: string]: JsonValue2 + [key: string]: JsonValue } | null } @@ -1706,7 +1697,7 @@ export type AppProviderAgentToolResponse = { tool_label?: string tool_name: string tool_parameters: { - [key: string]: JsonValue2 + [key: string]: JsonValue } [key: string]: unknown } @@ -1718,28 +1709,28 @@ export type AppLegacyDatasetToolResponse = { export type AppLegacyGoogleSearchToolResponse = { google_search: { - [key: string]: JsonValue2 + [key: string]: JsonValue } [key: string]: unknown } export type AppLegacyWebReaderToolResponse = { web_reader: { - [key: string]: JsonValue2 + [key: string]: JsonValue } [key: string]: unknown } export type AppLegacyWikipediaToolResponse = { wikipedia: { - [key: string]: JsonValue2 + [key: string]: JsonValue } [key: string]: unknown } export type AppLegacyCurrentDatetimeToolResponse = { current_datetime: { - [key: string]: JsonValue2 + [key: string]: JsonValue } [key: string]: unknown } @@ -1795,7 +1786,7 @@ export type AppWeightsResponse = { [key: string]: unknown } -export type JsonValue2 = unknown +export type JsonValue = unknown export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' @@ -1815,9 +1806,9 @@ export type AppUserInputFormConfigResponse = { allowed_file_types?: Array allowed_file_upload_methods?: Array config?: { - [key: string]: JsonValue2 + [key: string]: JsonValue } - default?: JsonValue2 + default?: JsonValue description?: string enabled?: boolean hide?: boolean @@ -1826,7 +1817,7 @@ export type AppUserInputFormConfigResponse = { json_schema?: | string | { - [key: string]: JsonValue2 + [key: string]: JsonValue } | null label: string @@ -3565,7 +3556,7 @@ export type PostAgentByAgentIdFeaturesResponse = PostAgentByAgentIdFeaturesResponses[keyof PostAgentByAgentIdFeaturesResponses] export type PostAgentByAgentIdFeedbacksData = { - body: MessageFeedbackPayload + body: AppMessageFeedbackPayload path: { agent_id: string } diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 241aab2cb979ba..cd6352f8de0096 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -438,9 +438,9 @@ export const zAgentDebugConversationRefreshResponse = z.object({ }) /** - * MessageFeedbackPayload + * AppMessageFeedbackPayload */ -export const zMessageFeedbackPayload = z.object({ +export const zAppMessageFeedbackPayload = z.object({ content: z.string().nullish(), message_id: z.string(), rating: z.enum(['dislike', 'like']).nullish(), @@ -998,16 +998,7 @@ export const zAgentLogListResponse = z.object({ total: z.int(), }) -export const zJsonValue = z - .union([ - z.string(), - z.int(), - z.number(), - z.boolean(), - z.record(z.string(), z.unknown()), - z.array(z.unknown()), - ]) - .nullable() +export const zAnyJsonValue = z.unknown() /** * AgentThought @@ -1024,7 +1015,7 @@ export const zAgentThought = z.object({ thought: z.string().nullish(), tool: z.string().nullish(), tool_input: z.string().nullish(), - tool_labels: zJsonValue, + tool_labels: zAnyJsonValue, }) /** @@ -1780,13 +1771,13 @@ export const zAppRerankingModelResponse = z.object({ reranking_provider_name: z.string().optional(), }) -export const zJsonValue2 = z.unknown() +export const zJsonValue = z.unknown() /** * AppEnabledExternalDataToolResponse */ export const zAppEnabledExternalDataToolResponse = z.object({ - config: z.record(z.string(), zJsonValue2), + config: z.record(z.string(), zJsonValue), enabled: z.literal(true), icon: z.string().optional(), icon_background: z.string().optional(), @@ -1799,7 +1790,7 @@ export const zAppEnabledExternalDataToolResponse = z.object({ * AppDisabledExternalDataToolResponse */ export const zAppDisabledExternalDataToolResponse = z.object({ - config: z.record(z.string(), zJsonValue2).optional(), + config: z.record(z.string(), zJsonValue).optional(), enabled: z.literal(false), icon: z.string().optional(), icon_background: z.string().optional(), @@ -1812,8 +1803,8 @@ export const zAppDisabledExternalDataToolResponse = z.object({ * AppSensitiveWordAvoidanceResponse */ export const zAppSensitiveWordAvoidanceResponse = z.object({ - config: z.record(z.string(), zJsonValue2).optional(), - configs: z.array(zJsonValue2).optional(), + config: z.record(z.string(), zJsonValue).optional(), + configs: z.array(zJsonValue).optional(), enabled: z.boolean(), type: z.string().optional(), }) @@ -1827,35 +1818,35 @@ export const zHumanInputFormSubmissionData = z.object({ node_id: z.string(), node_title: z.string(), rendered_content: z.string(), - submitted_data: z.record(z.string(), zJsonValue2).nullish(), + submitted_data: z.record(z.string(), zJsonValue).nullish(), }) /** * AppLegacyGoogleSearchToolResponse */ export const zAppLegacyGoogleSearchToolResponse = z.object({ - google_search: z.record(z.string(), zJsonValue2), + google_search: z.record(z.string(), zJsonValue), }) /** * AppLegacyWebReaderToolResponse */ export const zAppLegacyWebReaderToolResponse = z.object({ - web_reader: z.record(z.string(), zJsonValue2), + web_reader: z.record(z.string(), zJsonValue), }) /** * AppLegacyWikipediaToolResponse */ export const zAppLegacyWikipediaToolResponse = z.object({ - wikipedia: z.record(z.string(), zJsonValue2), + wikipedia: z.record(z.string(), zJsonValue), }) /** * AppLegacyCurrentDatetimeToolResponse */ export const zAppLegacyCurrentDatetimeToolResponse = z.object({ - current_datetime: z.record(z.string(), zJsonValue2), + current_datetime: z.record(z.string(), zJsonValue), }) /** @@ -1906,7 +1897,7 @@ export const zLlmMode = z.enum(['chat', 'completion']) * AppModelSelectionResponse */ export const zAppModelSelectionResponse = z.object({ - completion_params: z.record(z.string(), zJsonValue2).optional(), + completion_params: z.record(z.string(), zJsonValue).optional(), mode: z.union([zLlmMode, z.literal('')]).optional(), name: z.string().optional(), provider: z.string().optional(), @@ -1992,14 +1983,14 @@ export const zAppUserInputFormConfigResponse = z.object({ allowed_file_extensions: z.array(z.string()).optional(), allowed_file_types: z.array(zFileType).optional(), allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), - config: z.record(z.string(), zJsonValue2).optional(), - default: zJsonValue2.optional(), + config: z.record(z.string(), zJsonValue).optional(), + default: zJsonValue.optional(), description: z.string().optional(), enabled: z.boolean().optional(), hide: z.boolean().optional(), icon: z.string().nullish(), icon_background: z.string().nullish(), - json_schema: z.union([z.string(), z.record(z.string(), zJsonValue2)]).nullish(), + json_schema: z.union([z.string(), z.record(z.string(), zJsonValue)]).nullish(), label: z.string(), max_length: z.int().nullish(), options: z.array(z.string()).optional(), @@ -2478,7 +2469,7 @@ export const zAppProviderAgentToolResponse = z.object({ provider_type: zToolProviderType, tool_label: z.string().optional(), tool_name: z.string(), - tool_parameters: z.record(z.string(), zJsonValue2), + tool_parameters: z.record(z.string(), zJsonValue), }) /** @@ -3369,11 +3360,11 @@ export const zMessageDetailResponse = z.object({ from_end_user_id: z.string().nullish(), from_source: z.string(), id: z.string(), - inputs: z.record(z.string(), zJsonValue), - message: zJsonValue, + inputs: z.record(z.string(), zAnyJsonValue), + message: zAnyJsonValue, message_files: z.array(zMessageFile), message_tokens: z.int(), - metadata: zJsonValue, + metadata: zAnyJsonValue, parent_message_id: z.string().nullish(), provider_response_latency: z.number(), query: z.string(), @@ -4033,7 +4024,7 @@ export const zPostAgentByAgentIdFeaturesPath = z.object({ */ export const zPostAgentByAgentIdFeaturesResponse = zSimpleResultResponse -export const zPostAgentByAgentIdFeedbacksBody = zMessageFeedbackPayload +export const zPostAgentByAgentIdFeedbacksBody = zAppMessageFeedbackPayload export const zPostAgentByAgentIdFeedbacksPath = z.object({ agent_id: z.uuid(), diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 168a6e6ee05306..93c47d621aa83e 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -190,7 +190,7 @@ export type HumanInputFormPreviewResponse = { } } -export type HumanInputFormSubmitPayload = { +export type HumanInputFormPreviewSubmitPayload = { action: string form_inputs: { [key: string]: unknown @@ -210,7 +210,7 @@ export type IterationNodeRunPayload = { } | null } -export type GeneratedAppResponse = JsonValue +export type GeneratedAppResponse = ShallowJsonValue export type LoopNodeRunPayload = { inputs?: { @@ -527,7 +527,7 @@ export type ConversationDetail = { id: string introduction?: string | null message_count: number - model_config?: ModelConfig | null + model_config?: ConversationModelConfig | null status: string updated_at?: number | null user_feedback_stats?: FeedbackStat | null @@ -567,7 +567,7 @@ export type ConversationMessageDetail = { from_source: string id: string message?: MessageDetail | null - model_config?: ModelConfig | null + model_config?: ConversationModelConfig | null status: string } @@ -632,7 +632,7 @@ export type RosterAgentPackageExportFailedError = ErrorResponse & { status: 500 } -export type MessageFeedbackPayload = { +export type AppMessageFeedbackPayload = { content?: string | null message_id: string rating?: 'dislike' | 'like' | null @@ -660,12 +660,12 @@ export type MessageDetailResponse = { from_source: string id: string inputs: { - [key: string]: JsonValue + [key: string]: AnyJsonValue } - message: JsonValue + message: AnyJsonValue message_files: Array message_tokens: number - metadata: JsonValue + metadata: AnyJsonValue parent_message_id?: string | null provider_response_latency: number query: string @@ -1317,14 +1317,12 @@ export type WorkflowTriggerRunErrorResponse = { } export type DraftWorkflowRunPayload = { - datasource_info_list: Array<{ + files?: Array<{ [key: string]: unknown - }> - datasource_type: string + }> | null inputs: { [key: string]: unknown } - start_node_id: string } export type WorkflowRunSnapshotView = { @@ -1390,7 +1388,7 @@ export type WorkflowDraftVariableResponse = { name: string selector: Array type: string - value: JsonValue + value: RecursiveJsonValue value_type: string visible: boolean } @@ -1401,9 +1399,6 @@ export type WorkflowDraftVariableUpdatePayload = { } export type PublishWorkflowPayload = { - knowledge_base_setting?: { - [key: string]: unknown - } | null marked_comment?: string | null marked_name?: string | null } @@ -1601,7 +1596,7 @@ export type DslImportWarning = { export type PluginDependency = { current_identifier?: string | null type: PluginDependencyType - value: Github | Marketplace | Package + value: PluginDependencyGithub | PluginDependencyMarketplace | PluginDependencyPackage } export type RecentAppResponse = { @@ -1639,7 +1634,7 @@ export type AdvancedChatWorkflowRunForListResponse = { version?: string | null } -export type JsonValue = +export type ShallowJsonValue = | string | number | number @@ -1763,13 +1758,13 @@ export type FeedbackStat = { like: number } -export type ModelConfig = { - completion_params?: { - [key: string]: unknown - } - mode: LlmMode - name: string - provider: string +export type ConversationModelConfig = { + agent_mode?: AnyJsonValue | null + model?: AnyJsonValue | null + opening_statement?: string | null + pre_prompt?: string | null + suggested_questions?: AnyJsonValue | null + user_input_form?: AnyJsonValue | null } export type Conversation = { @@ -1805,12 +1800,12 @@ export type MessageDetail = { from_source: string id: string inputs: { - [key: string]: JsonValue + [key: string]: AnyJsonValue } - message: JsonValue + message: AnyJsonValue message_files: Array message_tokens: number - metadata: JsonValue + metadata: AnyJsonValue parent_message_id?: string | null provider_response_latency: number query: string @@ -1840,7 +1835,7 @@ export type AgentThought = { thought?: string | null tool?: string | null tool_input?: string | null - tool_labels: JsonValue + tool_labels: AnyJsonValue } export type ConversationAnnotation = { @@ -1873,6 +1868,8 @@ export type Feedback = { rating: string } +export type AnyJsonValue = unknown + export type MessageFile = { belongs_to?: string | null filename: string @@ -2550,6 +2547,17 @@ export type WorkflowDraftVariableFullContentResponse = { value_type: string } +export type RecursiveJsonValue = + | string + | number + | number + | boolean + | { + [key: string]: RecursiveJsonValue + } + | Array + | null + export type ModelConfigPartial = { created_at?: number | null created_by?: string | null @@ -2615,7 +2623,7 @@ export type AppDatasetConfigsResponse = { export type AppEnabledExternalDataToolResponse = { config: { - [key: string]: JsonValue2 + [key: string]: JsonValue } enabled: true icon?: string @@ -2628,7 +2636,7 @@ export type AppEnabledExternalDataToolResponse = { export type AppDisabledExternalDataToolResponse = { config?: { - [key: string]: JsonValue2 + [key: string]: JsonValue } enabled: false icon?: string @@ -2650,7 +2658,7 @@ export type AppFileUploadResponse = { export type AppModelSelectionResponse = { completion_params?: { - [key: string]: JsonValue2 + [key: string]: JsonValue } mode?: LlmMode | '' name?: string @@ -2666,9 +2674,9 @@ export type PromptType = 'advanced' | 'simple' export type AppSensitiveWordAvoidanceResponse = { config?: { - [key: string]: JsonValue2 + [key: string]: JsonValue } - configs?: Array + configs?: Array enabled: boolean type?: string [key: string]: unknown @@ -2727,19 +2735,19 @@ export type CustomizeTokenStrategy = 'allow' | 'must' | 'not_allow' | 'uuid' export type PluginDependencyType = 'github' | 'marketplace' | 'package' -export type Github = { +export type PluginDependencyGithub = { github_plugin_unique_identifier: string package: string repo: string version: string } -export type Marketplace = { +export type PluginDependencyMarketplace = { marketplace_plugin_unique_identifier: string version?: string | null } -export type Package = { +export type PluginDependencyPackage = { plugin_unique_identifier: string version?: string | null } @@ -2769,7 +2777,7 @@ export type AgentToolCallResponse = { } export type SimpleModelConfig = { - model?: JsonValue | null + model?: AnyJsonValue | null pre_prompt?: string | null } @@ -2780,12 +2788,10 @@ export type StatusCount = { success: number } -export type LlmMode = 'chat' | 'completion' - export type SimpleMessageDetail = { answer: string inputs: { - [key: string]: JsonValue + [key: string]: AnyJsonValue } message: string query: string @@ -2819,7 +2825,7 @@ export type HumanInputFormSubmissionData = { node_title: string rendered_content: string submitted_data?: { - [key: string]: JsonValue2 + [key: string]: JsonValue } | null } @@ -3356,7 +3362,7 @@ export type AppProviderAgentToolResponse = { tool_label?: string tool_name: string tool_parameters: { - [key: string]: JsonValue2 + [key: string]: JsonValue } [key: string]: unknown } @@ -3368,28 +3374,28 @@ export type AppLegacyDatasetToolResponse = { export type AppLegacyGoogleSearchToolResponse = { google_search: { - [key: string]: JsonValue2 + [key: string]: JsonValue } [key: string]: unknown } export type AppLegacyWebReaderToolResponse = { web_reader: { - [key: string]: JsonValue2 + [key: string]: JsonValue } [key: string]: unknown } export type AppLegacyWikipediaToolResponse = { wikipedia: { - [key: string]: JsonValue2 + [key: string]: JsonValue } [key: string]: unknown } export type AppLegacyCurrentDatetimeToolResponse = { current_datetime: { - [key: string]: JsonValue2 + [key: string]: JsonValue } [key: string]: unknown } @@ -3443,7 +3449,7 @@ export type AppWeightsResponse = { [key: string]: unknown } -export type JsonValue2 = unknown +export type JsonValue = unknown export type AppImageUploadResponse = { detail?: 'high' | 'low' | null @@ -3452,14 +3458,16 @@ export type AppImageUploadResponse = { transfer_methods?: Array } +export type LlmMode = 'chat' | 'completion' + export type AppUserInputFormConfigResponse = { allowed_file_extensions?: Array allowed_file_types?: Array allowed_file_upload_methods?: Array config?: { - [key: string]: JsonValue2 + [key: string]: JsonValue } - default?: JsonValue2 + default?: JsonValue description?: string enabled?: boolean hide?: boolean @@ -3468,7 +3476,7 @@ export type AppUserInputFormConfigResponse = { json_schema?: | string | { - [key: string]: JsonValue2 + [key: string]: JsonValue } | null label: string @@ -4265,7 +4273,7 @@ export type RecentAppListResponseWritable = { data: Array } -export type GeneratedAppResponseWritable = JsonValue +export type GeneratedAppResponseWritable = ShallowJsonValue export type WorkflowCommentBasicListWritable = { data: Array @@ -4413,6 +4421,17 @@ export type WorkflowCommentReplyWritable = { id: string } +export type RecursiveJsonValueWritable = + | string + | number + | number + | boolean + | { + [key: string]: RecursiveJsonValueWritable + } + | Array + | null + export type AppConfigJsonValueWritable = | string | number @@ -4799,7 +4818,7 @@ export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdForm PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponses] export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunData = { - body: HumanInputFormSubmitPayload + body: HumanInputFormPreviewSubmitPayload path: { app_id: string node_id: string @@ -6184,7 +6203,7 @@ export type GetAppsByAppIdExportResponse = GetAppsByAppIdExportResponses[keyof GetAppsByAppIdExportResponses] export type PostAppsByAppIdFeedbacksData = { - body: MessageFeedbackPayload + body: AppMessageFeedbackPayload path: { app_id: string } @@ -7976,7 +7995,7 @@ export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewRespo PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponses[keyof PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponses] export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunData = { - body: HumanInputFormSubmitPayload + body: HumanInputFormPreviewSubmitPayload path: { app_id: string node_id: string diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 180496d352ce0b..39464d30201bec 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -163,9 +163,9 @@ export const zHumanInputFormPreviewResponse = z.object({ }) /** - * HumanInputFormSubmitPayload + * HumanInputFormPreviewSubmitPayload */ -export const zHumanInputFormSubmitPayload = z.object({ +export const zHumanInputFormPreviewSubmitPayload = z.object({ action: z.string(), form_inputs: z.record(z.string(), z.unknown()), inputs: z.record(z.string(), z.unknown()), @@ -627,9 +627,9 @@ export const zRosterAgentPackageExportFailedError = zErrorResponse.and( ) /** - * MessageFeedbackPayload + * AppMessageFeedbackPayload */ -export const zMessageFeedbackPayload = z.object({ +export const zAppMessageFeedbackPayload = z.object({ content: z.string().nullish(), message_id: z.string(), rating: z.enum(['dislike', 'like']).nullish(), @@ -1177,10 +1177,8 @@ export const zWorkflowTriggerRunErrorResponse = z.object({ * DraftWorkflowRunPayload */ export const zDraftWorkflowRunPayload = z.object({ - datasource_info_list: z.array(z.record(z.string(), z.unknown())), - datasource_type: z.string(), + files: z.array(z.record(z.string(), z.unknown())).nullish(), inputs: z.record(z.string(), z.unknown()), - start_node_id: z.string(), }) /** @@ -1248,11 +1246,8 @@ export const zWorkflowDraftVariableUpdatePayload = z.object({ /** * PublishWorkflowPayload - * - * Payload for publishing snippet workflow. */ export const zPublishWorkflowPayload = z.object({ - knowledge_base_setting: z.record(z.string(), z.unknown()).nullish(), marked_comment: z.string().max(100).nullish(), marked_name: z.string().max(20).nullish(), }) @@ -1506,7 +1501,7 @@ export const zRecentAppListResponse = z.object({ data: z.array(zRecentAppResponse), }) -export const zJsonValue = z +export const zShallowJsonValue = z .union([ z.string(), z.int(), @@ -1520,7 +1515,7 @@ export const zJsonValue = z /** * GeneratedAppResponse */ -export const zGeneratedAppResponse = zJsonValue +export const zGeneratedAppResponse = zShallowJsonValue /** * AgentConfigVersionResponse @@ -1744,6 +1739,39 @@ export const zPaginatedConversationVariableResponse = z.object({ total: z.int(), }) +export const zAnyJsonValue = z.unknown() + +/** + * ConversationModelConfig + */ +export const zConversationModelConfig = z.object({ + agent_mode: zAnyJsonValue.nullish(), + model: zAnyJsonValue.nullish(), + opening_statement: z.string().nullish(), + pre_prompt: z.string().nullish(), + suggested_questions: zAnyJsonValue.nullish(), + user_input_form: zAnyJsonValue.nullish(), +}) + +/** + * ConversationDetail + */ +export const zConversationDetail = z.object({ + admin_feedback_stats: zFeedbackStat.nullish(), + annotated: z.boolean(), + created_at: z.int().nullish(), + from_account_id: z.string().nullish(), + from_end_user_id: z.string().nullish(), + from_source: z.string(), + id: z.string(), + introduction: z.string().nullish(), + message_count: z.int(), + model_config: zConversationModelConfig.nullish(), + status: z.string(), + updated_at: z.int().nullish(), + user_feedback_stats: zFeedbackStat.nullish(), +}) + /** * AgentThought */ @@ -1759,7 +1787,7 @@ export const zAgentThought = z.object({ thought: z.string().nullish(), tool: z.string().nullish(), tool_input: z.string().nullish(), - tool_labels: zJsonValue, + tool_labels: zAnyJsonValue, }) /** @@ -2633,6 +2661,20 @@ export const zWorkflowDraftVariableFullContentResponse = z.object({ value_type: z.string(), }) +export const zRecursiveJsonValue = z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record( + z.string(), + z.lazy((): any => zRecursiveJsonValue), + ), + z.array(z.lazy((): any => zRecursiveJsonValue)), + ]) + .nullable() + /** * WorkflowDraftVariableResponse */ @@ -2645,7 +2687,7 @@ export const zWorkflowDraftVariableResponse = z.object({ name: z.string(), selector: z.array(z.string()), type: z.string(), - value: zJsonValue, + value: zRecursiveJsonValue, value_type: z.string(), visible: z.boolean(), }) @@ -2734,7 +2776,7 @@ export const zPluginDependencyType = z.enum(['github', 'marketplace', 'package'] /** * Github */ -export const zGithub = z.object({ +export const zPluginDependencyGithub = z.object({ github_plugin_unique_identifier: z.string(), package: z.string(), repo: z.string(), @@ -2744,7 +2786,7 @@ export const zGithub = z.object({ /** * Marketplace */ -export const zMarketplace = z.object({ +export const zPluginDependencyMarketplace = z.object({ marketplace_plugin_unique_identifier: z.string(), version: z.string().nullish(), }) @@ -2752,7 +2794,7 @@ export const zMarketplace = z.object({ /** * Package */ -export const zPackage = z.object({ +export const zPluginDependencyPackage = z.object({ plugin_unique_identifier: z.string(), version: z.string().nullish(), }) @@ -2763,7 +2805,7 @@ export const zPackage = z.object({ export const zPluginDependency = z.object({ current_identifier: z.string().nullish(), type: zPluginDependencyType, - value: z.union([zGithub, zMarketplace, zPackage]), + value: z.union([zPluginDependencyGithub, zPluginDependencyMarketplace, zPluginDependencyPackage]), }) /** @@ -2837,7 +2879,7 @@ export const zAgentLogResponse = z.object({ * SimpleModelConfig */ export const zSimpleModelConfig = z.object({ - model: zJsonValue.nullish(), + model: zAnyJsonValue.nullish(), pre_prompt: z.string().nullish(), }) @@ -2886,48 +2928,12 @@ export const zConversationWithSummaryPagination = z.object({ total: z.int(), }) -/** - * LLMMode - * - * Enum class for large language model mode. - */ -export const zLlmMode = z.enum(['chat', 'completion']) - -/** - * ModelConfig - */ -export const zModelConfig = z.object({ - completion_params: z.record(z.string(), z.unknown()).optional(), - mode: zLlmMode, - name: z.string(), - provider: z.string(), -}) - -/** - * ConversationDetail - */ -export const zConversationDetail = z.object({ - admin_feedback_stats: zFeedbackStat.nullish(), - annotated: z.boolean(), - created_at: z.int().nullish(), - from_account_id: z.string().nullish(), - from_end_user_id: z.string().nullish(), - from_source: z.string(), - id: z.string(), - introduction: z.string().nullish(), - message_count: z.int(), - model_config: zModelConfig.nullish(), - status: z.string(), - updated_at: z.int().nullish(), - user_feedback_stats: zFeedbackStat.nullish(), -}) - /** * SimpleMessageDetail */ export const zSimpleMessageDetail = z.object({ answer: z.string(), - inputs: z.record(z.string(), zJsonValue), + inputs: z.record(z.string(), zAnyJsonValue), message: z.string(), query: z.string(), }) @@ -3021,11 +3027,11 @@ export const zMessageDetail = z.object({ from_end_user_id: z.string().nullish(), from_source: z.string(), id: z.string(), - inputs: z.record(z.string(), zJsonValue), - message: zJsonValue, + inputs: z.record(z.string(), zAnyJsonValue), + message: zAnyJsonValue, message_files: z.array(zMessageFile), message_tokens: z.int(), - metadata: zJsonValue, + metadata: zAnyJsonValue, parent_message_id: z.string().nullish(), provider_response_latency: z.number(), query: z.string(), @@ -3043,7 +3049,7 @@ export const zConversationMessageDetail = z.object({ from_source: z.string(), id: z.string(), message: zMessageDetail.nullish(), - model_config: zModelConfig.nullish(), + model_config: zConversationModelConfig.nullish(), status: z.string(), }) @@ -3170,18 +3176,6 @@ export const zAppCompletionPromptPayload = z */ export const zAppEmptyDatasetCollectionPayload = z.record(z.string(), z.never()) -/** - * AppMetadataModelPayload - */ -export const zAppMetadataModelPayload = z - .object({ - completion_params: z.record(z.string(), zAppConfigJsonValue).optional(), - mode: z.union([zLlmMode, z.literal('')]).optional(), - name: z.string().optional(), - provider: z.string().optional(), - }) - .catchall(z.json()) - /** * RerankMode */ @@ -3276,29 +3270,6 @@ export const zAppFileUploadPayload = z }) .catchall(z.json()) -/** - * AppSuggestedQuestionsModelPayload - */ -export const zAppSuggestedQuestionsModelPayload = z - .object({ - completion_params: z.record(z.string(), zAppConfigJsonValue).optional(), - mode: z.union([zLlmMode, z.literal('')]).optional(), - name: z.string(), - provider: z.string(), - }) - .catchall(z.json()) - -/** - * AppSuggestedQuestionsPayload - */ -export const zAppSuggestedQuestionsPayload = z - .object({ - enabled: z.boolean().nullish(), - model: zAppSuggestedQuestionsModelPayload.optional(), - prompt: z.string().optional(), - }) - .catchall(z.json()) - /** * WorkflowRunForLogResponse */ @@ -3705,13 +3676,13 @@ export const zAppRerankingModelResponse = z.object({ reranking_provider_name: z.string().optional(), }) -export const zJsonValue2 = z.unknown() +export const zJsonValue = z.unknown() /** * AppEnabledExternalDataToolResponse */ export const zAppEnabledExternalDataToolResponse = z.object({ - config: z.record(z.string(), zJsonValue2), + config: z.record(z.string(), zJsonValue), enabled: z.literal(true), icon: z.string().optional(), icon_background: z.string().optional(), @@ -3724,7 +3695,7 @@ export const zAppEnabledExternalDataToolResponse = z.object({ * AppDisabledExternalDataToolResponse */ export const zAppDisabledExternalDataToolResponse = z.object({ - config: z.record(z.string(), zJsonValue2).optional(), + config: z.record(z.string(), zJsonValue).optional(), enabled: z.literal(false), icon: z.string().optional(), icon_background: z.string().optional(), @@ -3733,11 +3704,90 @@ export const zAppDisabledExternalDataToolResponse = z.object({ variable: z.string().optional(), }) +/** + * AppSensitiveWordAvoidanceResponse + */ +export const zAppSensitiveWordAvoidanceResponse = z.object({ + config: z.record(z.string(), zJsonValue).optional(), + configs: z.array(zJsonValue).optional(), + enabled: z.boolean(), + type: z.string().optional(), +}) + +/** + * HumanInputFormSubmissionData + */ +export const zHumanInputFormSubmissionData = z.object({ + action_id: z.string(), + action_text: z.string(), + node_id: z.string(), + node_title: z.string(), + rendered_content: z.string(), + submitted_data: z.record(z.string(), zJsonValue).nullish(), +}) + +/** + * AppLegacyGoogleSearchToolResponse + */ +export const zAppLegacyGoogleSearchToolResponse = z.object({ + google_search: z.record(z.string(), zJsonValue), +}) + +/** + * AppLegacyWebReaderToolResponse + */ +export const zAppLegacyWebReaderToolResponse = z.object({ + web_reader: z.record(z.string(), zJsonValue), +}) + +/** + * AppLegacyWikipediaToolResponse + */ +export const zAppLegacyWikipediaToolResponse = z.object({ + wikipedia: z.record(z.string(), zJsonValue), +}) + +/** + * AppLegacyCurrentDatetimeToolResponse + */ +export const zAppLegacyCurrentDatetimeToolResponse = z.object({ + current_datetime: z.record(z.string(), zJsonValue), +}) + +/** + * AppImageUploadResponse + */ +export const zAppImageUploadResponse = z.object({ + detail: z.enum(['high', 'low']).nullish(), + enabled: z.boolean().optional(), + number_limits: z.int().optional(), + transfer_methods: z.array(zFileTransferMethod).optional(), +}) + +/** + * AppFileUploadResponse + */ +export const zAppFileUploadResponse = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + enabled: z.boolean().optional(), + image: zAppImageUploadResponse.optional(), + number_limits: z.int().optional(), +}) + +/** + * LLMMode + * + * Enum class for large language model mode. + */ +export const zLlmMode = z.enum(['chat', 'completion']) + /** * AppModelSelectionResponse */ export const zAppModelSelectionResponse = z.object({ - completion_params: z.record(z.string(), zJsonValue2).optional(), + completion_params: z.record(z.string(), zJsonValue).optional(), mode: z.union([zLlmMode, z.literal('')]).optional(), name: z.string().optional(), provider: z.string().optional(), @@ -3798,16 +3848,6 @@ export const zAppPagination = z.object({ total: z.int(), }) -/** - * AppSensitiveWordAvoidanceResponse - */ -export const zAppSensitiveWordAvoidanceResponse = z.object({ - config: z.record(z.string(), zJsonValue2).optional(), - configs: z.array(zJsonValue2).optional(), - enabled: z.boolean(), - type: z.string().optional(), -}) - /** * AppSuggestedQuestionsAfterAnswerResponse */ @@ -3818,66 +3858,39 @@ export const zAppSuggestedQuestionsAfterAnswerResponse = z.object({ }) /** - * HumanInputFormSubmissionData - */ -export const zHumanInputFormSubmissionData = z.object({ - action_id: z.string(), - action_text: z.string(), - node_id: z.string(), - node_title: z.string(), - rendered_content: z.string(), - submitted_data: z.record(z.string(), zJsonValue2).nullish(), -}) - -/** - * AppLegacyGoogleSearchToolResponse - */ -export const zAppLegacyGoogleSearchToolResponse = z.object({ - google_search: z.record(z.string(), zJsonValue2), -}) - -/** - * AppLegacyWebReaderToolResponse - */ -export const zAppLegacyWebReaderToolResponse = z.object({ - web_reader: z.record(z.string(), zJsonValue2), -}) - -/** - * AppLegacyWikipediaToolResponse - */ -export const zAppLegacyWikipediaToolResponse = z.object({ - wikipedia: z.record(z.string(), zJsonValue2), -}) - -/** - * AppLegacyCurrentDatetimeToolResponse + * AppMetadataModelPayload */ -export const zAppLegacyCurrentDatetimeToolResponse = z.object({ - current_datetime: z.record(z.string(), zJsonValue2), -}) +export const zAppMetadataModelPayload = z + .object({ + completion_params: z.record(z.string(), zAppConfigJsonValue).optional(), + mode: z.union([zLlmMode, z.literal('')]).optional(), + name: z.string().optional(), + provider: z.string().optional(), + }) + .catchall(z.json()) /** - * AppImageUploadResponse + * AppSuggestedQuestionsModelPayload */ -export const zAppImageUploadResponse = z.object({ - detail: z.enum(['high', 'low']).nullish(), - enabled: z.boolean().optional(), - number_limits: z.int().optional(), - transfer_methods: z.array(zFileTransferMethod).optional(), -}) +export const zAppSuggestedQuestionsModelPayload = z + .object({ + completion_params: z.record(z.string(), zAppConfigJsonValue).optional(), + mode: z.union([zLlmMode, z.literal('')]).optional(), + name: z.string(), + provider: z.string(), + }) + .catchall(z.json()) /** - * AppFileUploadResponse + * AppSuggestedQuestionsPayload */ -export const zAppFileUploadResponse = z.object({ - allowed_file_extensions: z.array(z.string()).optional(), - allowed_file_types: z.array(zFileType).optional(), - allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), - enabled: z.boolean().optional(), - image: zAppImageUploadResponse.optional(), - number_limits: z.int().optional(), -}) +export const zAppSuggestedQuestionsPayload = z + .object({ + enabled: z.boolean().nullish(), + model: zAppSuggestedQuestionsModelPayload.optional(), + prompt: z.string().optional(), + }) + .catchall(z.json()) /** * AppUserInputFormConfigResponse @@ -3886,14 +3899,14 @@ export const zAppUserInputFormConfigResponse = z.object({ allowed_file_extensions: z.array(z.string()).optional(), allowed_file_types: z.array(zFileType).optional(), allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), - config: z.record(z.string(), zJsonValue2).optional(), - default: zJsonValue2.optional(), + config: z.record(z.string(), zJsonValue).optional(), + default: zJsonValue.optional(), description: z.string().optional(), enabled: z.boolean().optional(), hide: z.boolean().optional(), icon: z.string().nullish(), icon_background: z.string().nullish(), - json_schema: z.union([z.string(), z.record(z.string(), zJsonValue2)]).nullish(), + json_schema: z.union([z.string(), z.record(z.string(), zJsonValue)]).nullish(), label: z.string(), max_length: z.int().nullish(), options: z.array(z.string()).optional(), @@ -4650,7 +4663,7 @@ export const zAppProviderAgentToolResponse = z.object({ provider_type: zToolProviderType, tool_label: z.string().optional(), tool_name: z.string(), - tool_parameters: z.record(z.string(), zJsonValue2), + tool_parameters: z.record(z.string(), zJsonValue), }) /** @@ -5546,11 +5559,11 @@ export const zMessageDetailResponse = z.object({ from_end_user_id: z.string().nullish(), from_source: z.string(), id: z.string(), - inputs: z.record(z.string(), zJsonValue), - message: zJsonValue, + inputs: z.record(z.string(), zAnyJsonValue), + message: zAnyJsonValue, message_files: z.array(zMessageFile), message_tokens: z.int(), - metadata: zJsonValue, + metadata: zAnyJsonValue, parent_message_id: z.string().nullish(), provider_response_latency: z.number(), query: z.string(), @@ -5736,7 +5749,7 @@ export const zComposerSavePayload = z.object({ /** * GeneratedAppResponse */ -export const zGeneratedAppResponseWritable = zJsonValue +export const zGeneratedAppResponseWritable = zShallowJsonValue /** * AppPartial @@ -5966,6 +5979,20 @@ export const zWorkflowCommentDetailWritable = z.object({ updated_at: z.int().nullish(), }) +export const zRecursiveJsonValueWritable = z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record( + z.string(), + z.lazy((): any => zRecursiveJsonValueWritable), + ), + z.array(z.lazy((): any => zRecursiveJsonValueWritable)), + ]) + .nullable() + export const zAppConfigJsonValueWritable = z.json().nullable() /** @@ -6173,7 +6200,7 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFo zHumanInputFormPreviewResponse export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunBody = - zHumanInputFormSubmitPayload + zHumanInputFormPreviewSubmitPayload export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunPath = z.object({ @@ -6835,7 +6862,7 @@ export const zGetAppsByAppIdExportResponse = z.union([ z.custom((value) => value instanceof Blob || value instanceof File), ]) -export const zPostAppsByAppIdFeedbacksBody = zMessageFeedbackPayload +export const zPostAppsByAppIdFeedbacksBody = zAppMessageFeedbackPayload export const zPostAppsByAppIdFeedbacksPath = z.object({ app_id: z.uuid(), @@ -7670,7 +7697,7 @@ export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewRes zHumanInputFormPreviewResponse export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunBody = - zHumanInputFormSubmitPayload + zHumanInputFormPreviewSubmitPayload export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunPath = z.object({ app_id: z.uuid(), diff --git a/packages/contracts/generated/api/console/datasets/types.gen.ts b/packages/contracts/generated/api/console/datasets/types.gen.ts index 3e315a18f90702..3477f2fd7b755b 100644 --- a/packages/contracts/generated/api/console/datasets/types.gen.ts +++ b/packages/contracts/generated/api/console/datasets/types.gen.ts @@ -995,7 +995,7 @@ export type DatasetWeightedScoreResponse = { export type InfoList = { data_source_type: 'notion_import' | 'upload_file' | 'website_crawl' - file_info_list?: FileInfo | null + file_info_list?: KnowledgeFileInfo | null notion_info_list?: Array | null website_info_list?: WebsiteInfo | null } @@ -1111,7 +1111,7 @@ export type DatasetVectorSettingResponse = { vector_weight?: number | null } -export type FileInfo = { +export type KnowledgeFileInfo = { file_ids: Array } diff --git a/packages/contracts/generated/api/console/datasets/zod.gen.ts b/packages/contracts/generated/api/console/datasets/zod.gen.ts index 1d9b1507296bbd..01740c16159a79 100644 --- a/packages/contracts/generated/api/console/datasets/zod.gen.ts +++ b/packages/contracts/generated/api/console/datasets/zod.gen.ts @@ -1329,7 +1329,7 @@ export const zDatasetListResponse = z.object({ /** * FileInfo */ -export const zFileInfo = z.object({ +export const zKnowledgeFileInfo = z.object({ file_ids: z.array(z.string()), }) @@ -1650,7 +1650,7 @@ export const zNotionInfo = z.object({ */ export const zInfoList = z.object({ data_source_type: z.enum(['notion_import', 'upload_file', 'website_crawl']), - file_info_list: zFileInfo.nullish(), + file_info_list: zKnowledgeFileInfo.nullish(), notion_info_list: z.array(zNotionInfo).nullish(), website_info_list: zWebsiteInfo.nullish(), }) diff --git a/packages/contracts/generated/api/console/form/types.gen.ts b/packages/contracts/generated/api/console/form/types.gen.ts index f4ab4ef7046b42..761d5ee325ea68 100644 --- a/packages/contracts/generated/api/console/form/types.gen.ts +++ b/packages/contracts/generated/api/console/form/types.gen.ts @@ -25,11 +25,8 @@ export type ErrorResponse = { export type HumanInputFormSubmitPayload = { action: string - form_inputs: { - [key: string]: unknown - } inputs: { - [key: string]: unknown + [key: string]: JsonValue } } @@ -47,6 +44,8 @@ export type HumanInputFormExpiredError = ErrorResponse & { status: 412 } +export type JsonValue = unknown + export type GetFormHumanInputByFormTokenData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/form/zod.gen.ts b/packages/contracts/generated/api/console/form/zod.gen.ts index fde67017e0f2a7..18724fa0f55163 100644 --- a/packages/contracts/generated/api/console/form/zod.gen.ts +++ b/packages/contracts/generated/api/console/form/zod.gen.ts @@ -35,15 +35,6 @@ export const zHumanInputFormSubmittedError = zErrorResponse.and( }), ) -/** - * HumanInputFormSubmitPayload - */ -export const zHumanInputFormSubmitPayload = z.object({ - action: z.string(), - form_inputs: z.record(z.string(), z.unknown()), - inputs: z.record(z.string(), z.unknown()), -}) - /** * ConsoleHumanInputFormSubmitResponse */ @@ -69,6 +60,16 @@ export const zHumanInputFormExpiredError = zErrorResponse.and( }), ) +export const zJsonValue = z.unknown() + +/** + * HumanInputFormSubmitPayload + */ +export const zHumanInputFormSubmitPayload = z.object({ + action: z.string(), + inputs: z.record(z.string(), zJsonValue), +}) + export const zGetFormHumanInputByFormTokenPath = z.object({ form_token: z.string(), }) diff --git a/packages/contracts/generated/api/console/installed-apps/types.gen.ts b/packages/contracts/generated/api/console/installed-apps/types.gen.ts index 22eb23077c3582..18626ea053945e 100644 --- a/packages/contracts/generated/api/console/installed-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/types.gen.ts @@ -122,19 +122,16 @@ export type UnsupportedAudioTypeError = ErrorResponse & { status: 415 } -export type ChatMessagePayload = { +export type ChatMessageExplorePayload = { conversation_id?: string | null - draft_type?: 'debug_build' | 'draft' - files?: Array | null - inputs: { + files?: Array<{ [key: string]: unknown - } - model_config?: { + }> | null + inputs: { [key: string]: unknown } parent_message_id?: string | null query: string - response_mode?: 'blocking' | 'streaming' retriever_from?: string } @@ -203,7 +200,7 @@ export type SimpleConversation = { created_at?: number | null id: string inputs: { - [key: string]: JsonValue + [key: string]: AnyJsonValue } introduction?: string | null name: string @@ -238,7 +235,6 @@ export type MessageCursorNotFoundError = ErrorResponse & { export type MessageFeedbackPayload = { content?: string | null - message_id: string rating?: 'dislike' | 'like' | null } @@ -677,16 +673,7 @@ export type InstalledAppInfoResponse = { use_icon_as_answer_icon: boolean } -export type JsonValue = - | string - | number - | number - | boolean - | { - [key: string]: unknown - } - | Array - | null +export type AnyJsonValue = unknown export type ExploreMessageListItem = { agent_thoughts: Array @@ -758,7 +745,7 @@ export type AgentThought = { thought?: string | null tool?: string | null tool_input?: string | null - tool_labels: JsonValue + tool_labels: AnyJsonValue } export type HumanInputContent = { @@ -838,7 +825,7 @@ export type HumanInputFormSubmissionData = { node_title: string rendered_content: string submitted_data?: { - [key: string]: JsonValue2 + [key: string]: JsonValue } | null } @@ -864,7 +851,7 @@ export type FormInputConfig = type: 'file-list' } & FileListInputConfig) -export type JsonValue2 = unknown +export type JsonValue = unknown export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' @@ -1089,7 +1076,7 @@ export type PostInstalledAppsByInstalledAppIdAudioToTextResponse = PostInstalledAppsByInstalledAppIdAudioToTextResponses[keyof PostInstalledAppsByInstalledAppIdAudioToTextResponses] export type PostInstalledAppsByInstalledAppIdChatMessagesData = { - body: ChatMessagePayload + body: ChatMessageExplorePayload path: { installed_app_id: string } diff --git a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts index e129bf478b0a42..cb87804d153735 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -233,18 +233,15 @@ export const zUnsupportedAudioTypeError = zErrorResponse.and( ) /** - * ChatMessagePayload + * ChatMessageExplorePayload */ -export const zChatMessagePayload = z.object({ +export const zChatMessageExplorePayload = z.object({ conversation_id: z.string().nullish(), - draft_type: z.enum(['debug_build', 'draft']).optional().default('draft'), - files: z.array(z.unknown()).nullish(), + files: z.array(z.record(z.string(), z.unknown())).nullish(), inputs: z.record(z.string(), z.unknown()), - model_config: z.record(z.string(), z.unknown()).optional(), parent_message_id: z.string().nullish(), query: z.string(), - response_mode: z.enum(['blocking', 'streaming']).optional().default('blocking'), - retriever_from: z.string().optional().default('dev'), + retriever_from: z.string().optional().default('explore_app'), }) /** @@ -390,7 +387,6 @@ export const zMessageCursorNotFoundError = zErrorResponse.and( */ export const zMessageFeedbackPayload = z.object({ content: z.string().nullish(), - message_id: z.string(), rating: z.enum(['dislike', 'like']).nullish(), }) @@ -551,16 +547,7 @@ export const zNotWorkflowAppError = zErrorResponse.and( }), ) -export const zJsonValue = z - .union([ - z.string(), - z.int(), - z.number(), - z.boolean(), - z.record(z.string(), z.unknown()), - z.array(z.unknown()), - ]) - .nullable() +export const zAnyJsonValue = z.unknown() /** * SimpleConversation @@ -568,7 +555,7 @@ export const zJsonValue = z export const zSimpleConversation = z.object({ created_at: z.int().nullish(), id: z.uuid(), - inputs: z.record(z.string(), zJsonValue), + inputs: z.record(z.string(), zAnyJsonValue), introduction: z.string().nullish(), name: z.string(), status: z.string(), @@ -1007,7 +994,7 @@ export const zAgentThought = z.object({ thought: z.string().nullish(), tool: z.string().nullish(), tool_input: z.string().nullish(), - tool_labels: zJsonValue, + tool_labels: zAnyJsonValue, }) /** @@ -1089,7 +1076,7 @@ export const zRetrieverResource = z.object({ */ export const zExecutionContentType = z.enum(['human_input']) -export const zJsonValue2 = z.unknown() +export const zJsonValue = z.unknown() /** * HumanInputFormSubmissionData @@ -1100,7 +1087,7 @@ export const zHumanInputFormSubmissionData = z.object({ node_id: z.string(), node_title: z.string(), rendered_content: z.string(), - submitted_data: z.record(z.string(), zJsonValue2).nullish(), + submitted_data: z.record(z.string(), zJsonValue).nullish(), }) /** @@ -1397,7 +1384,7 @@ export const zPostInstalledAppsByInstalledAppIdAudioToTextPath = z.object({ */ export const zPostInstalledAppsByInstalledAppIdAudioToTextResponse = zAudioTranscriptResponse -export const zPostInstalledAppsByInstalledAppIdChatMessagesBody = zChatMessagePayload +export const zPostInstalledAppsByInstalledAppIdChatMessagesBody = zChatMessageExplorePayload export const zPostInstalledAppsByInstalledAppIdChatMessagesPath = z.object({ installed_app_id: z.uuid(), diff --git a/packages/contracts/generated/api/console/rag/types.gen.ts b/packages/contracts/generated/api/console/rag/types.gen.ts index 212b3db7d7d198..5cb1b46195aaac 100644 --- a/packages/contracts/generated/api/console/rag/types.gen.ts +++ b/packages/contracts/generated/api/console/rag/types.gen.ts @@ -302,7 +302,7 @@ export type RagPipelineStepParametersResponse = { variables: unknown } -export type DraftWorkflowRunPayload = { +export type RagPipelineDraftWorkflowRunPayload = { datasource_info_list: Array<{ [key: string]: unknown }> @@ -332,7 +332,7 @@ export type WorkflowDraftVariableResponse = { name: string selector: Array type: string - value: JsonValue + value: RecursiveJsonValue value_type: string visible: boolean } @@ -347,7 +347,7 @@ export type RagPipelineWorkflowPublishResponse = { result: string } -export type Parser = { +export type DatasourceContentPreviewPayload = { credential_id?: string | null datasource_type: string inputs: { @@ -450,7 +450,7 @@ export type RagPipelineDatasourceProviderResponse = { export type PluginDependency = { current_identifier?: string | null type: PluginDependencyType - value: Github | Marketplace | Package + value: PluginDependencyGithub | PluginDependencyMarketplace | PluginDependencyPackage } export type WorkflowRunForListResponse = { @@ -545,15 +545,15 @@ export type WorkflowDraftVariableFullContentResponse = { value_type: string } -export type JsonValue = +export type RecursiveJsonValue = | string | number | number | boolean | { - [key: string]: unknown + [key: string]: RecursiveJsonValue } - | Array + | Array | null export type DatasetRerankingModelResponse = { @@ -577,19 +577,19 @@ export type DatasourceProviderEntityWithPlugin = { export type PluginDependencyType = 'github' | 'marketplace' | 'package' -export type Github = { +export type PluginDependencyGithub = { github_plugin_unique_identifier: string package: string repo: string version: string } -export type Marketplace = { +export type PluginDependencyMarketplace = { marketplace_plugin_unique_identifier: string version?: string | null } -export type Package = { +export type PluginDependencyPackage = { plugin_unique_identifier: string version?: string | null } @@ -629,9 +629,9 @@ export type DatasourceEntity = { export type DatasourceProviderIdentity = { author: string - description: CoreToolsEntitiesCommonEntitiesI18nObject + description: I18nObject icon: string - label: CoreToolsEntitiesCommonEntitiesI18nObject + label: I18nObject name: string tags?: Array | null } @@ -715,13 +715,6 @@ export type DatasourceParameter = { type: DatasourceParameterType } -export type CoreToolsEntitiesCommonEntitiesI18nObject = { - en_US: string - ja_JP?: string | null - pt_BR?: string | null - zh_Hans?: string | null -} - export type ToolLabelEnum = | 'business' | 'design' @@ -1627,7 +1620,7 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersRespons GetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponses] export type PostRagPipelinesByPipelineIdWorkflowsDraftRunData = { - body: DraftWorkflowRunPayload + body: RagPipelineDraftWorkflowRunPayload path: { pipeline_id: string } @@ -1876,7 +1869,7 @@ export type PostRagPipelinesByPipelineIdWorkflowsPublishResponse = PostRagPipelinesByPipelineIdWorkflowsPublishResponses[keyof PostRagPipelinesByPipelineIdWorkflowsPublishResponses] export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewData = { - body: Parser + body: DatasourceContentPreviewPayload path: { node_id: string pipeline_id: string diff --git a/packages/contracts/generated/api/console/rag/zod.gen.ts b/packages/contracts/generated/api/console/rag/zod.gen.ts index 0471efcfa57780..6b6e56ea9018f6 100644 --- a/packages/contracts/generated/api/console/rag/zod.gen.ts +++ b/packages/contracts/generated/api/console/rag/zod.gen.ts @@ -202,9 +202,9 @@ export const zRagPipelineStepParametersResponse = z.object({ }) /** - * DraftWorkflowRunPayload + * RagPipelineDraftWorkflowRunPayload */ -export const zDraftWorkflowRunPayload = z.object({ +export const zRagPipelineDraftWorkflowRunPayload = z.object({ datasource_info_list: z.array(z.record(z.string(), z.unknown())), datasource_type: z.string(), inputs: z.record(z.string(), z.unknown()), @@ -240,9 +240,9 @@ export const zRagPipelineWorkflowPublishResponse = z.object({ }) /** - * Parser + * DatasourceContentPreviewPayload */ -export const zParser = z.object({ +export const zDatasourceContentPreviewPayload = z.object({ credential_id: z.string().nullish(), datasource_type: z.string(), inputs: z.record(z.string(), z.unknown()), @@ -598,14 +598,17 @@ export const zWorkflowDraftVariableFullContentResponse = z.object({ value_type: z.string(), }) -export const zJsonValue = z +export const zRecursiveJsonValue = z .union([ z.string(), z.int(), z.number(), z.boolean(), - z.record(z.string(), z.unknown()), - z.array(z.unknown()), + z.record( + z.string(), + z.lazy((): any => zRecursiveJsonValue), + ), + z.array(z.lazy((): any => zRecursiveJsonValue)), ]) .nullable() @@ -621,7 +624,7 @@ export const zWorkflowDraftVariableResponse = z.object({ name: z.string(), selector: z.array(z.string()), type: z.string(), - value: zJsonValue, + value: zRecursiveJsonValue, value_type: z.string(), visible: z.boolean(), }) @@ -649,7 +652,7 @@ export const zPluginDependencyType = z.enum(['github', 'marketplace', 'package'] /** * Github */ -export const zGithub = z.object({ +export const zPluginDependencyGithub = z.object({ github_plugin_unique_identifier: z.string(), package: z.string(), repo: z.string(), @@ -659,7 +662,7 @@ export const zGithub = z.object({ /** * Marketplace */ -export const zMarketplace = z.object({ +export const zPluginDependencyMarketplace = z.object({ marketplace_plugin_unique_identifier: z.string(), version: z.string().nullish(), }) @@ -667,7 +670,7 @@ export const zMarketplace = z.object({ /** * Package */ -export const zPackage = z.object({ +export const zPluginDependencyPackage = z.object({ plugin_unique_identifier: z.string(), version: z.string().nullish(), }) @@ -678,7 +681,7 @@ export const zPackage = z.object({ export const zPluginDependency = z.object({ current_identifier: z.string().nullish(), type: zPluginDependencyType, - value: z.union([zGithub, zMarketplace, zPackage]), + value: z.union([zPluginDependencyGithub, zPluginDependencyMarketplace, zPluginDependencyPackage]), }) /** @@ -878,18 +881,6 @@ export const zDatasourceIdentity = z.object({ provider: z.string(), }) -/** - * I18nObject - * - * Model class for i18n object. - */ -export const zCoreToolsEntitiesCommonEntitiesI18nObject = z.object({ - en_US: z.string(), - ja_JP: z.string().nullish(), - pt_BR: z.string().nullish(), - zh_Hans: z.string().nullish(), -}) - /** * ToolLabelEnum */ @@ -918,9 +909,9 @@ export const zToolLabelEnum = z.enum([ */ export const zDatasourceProviderIdentity = z.object({ author: z.string(), - description: zCoreToolsEntitiesCommonEntitiesI18nObject, + description: zI18nObject, icon: z.string(), - label: zCoreToolsEntitiesCommonEntitiesI18nObject, + label: zI18nObject, name: z.string(), tags: z.array(zToolLabelEnum).nullish().default([]), }) @@ -1422,7 +1413,8 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersQuery export const zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponse = zRagPipelineStepParametersResponse -export const zPostRagPipelinesByPipelineIdWorkflowsDraftRunBody = zDraftWorkflowRunPayload +export const zPostRagPipelinesByPipelineIdWorkflowsDraftRunBody = + zRagPipelineDraftWorkflowRunPayload export const zPostRagPipelinesByPipelineIdWorkflowsDraftRunPath = z.object({ pipeline_id: z.uuid(), @@ -1531,7 +1523,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsPublishResponse = zRagPipelineWorkflowPublishResponse export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewBody = - zParser + zDatasourceContentPreviewPayload export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewPath = z.object({ diff --git a/packages/contracts/generated/api/console/snippets/types.gen.ts b/packages/contracts/generated/api/console/snippets/types.gen.ts index b60a71d2991af6..fa03cb5a41a19a 100644 --- a/packages/contracts/generated/api/console/snippets/types.gen.ts +++ b/packages/contracts/generated/api/console/snippets/types.gen.ts @@ -131,7 +131,7 @@ export type SnippetIterationNodeRunPayload = { } | null } -export type GeneratedAppResponse = JsonValue +export type GeneratedAppResponse = ShallowJsonValue export type SnippetLoopNodeRunPayload = { inputs?: { @@ -301,7 +301,7 @@ export type WorkflowDraftVariableResponse = { name: string selector: Array type: string - value: JsonValue + value: RecursiveJsonValue value_type: string visible: boolean } @@ -311,7 +311,7 @@ export type WorkflowDraftVariableUpdatePayload = { value?: unknown | null } -export type PublishWorkflowPayload = { +export type SnippetPublishWorkflowPayload = { knowledge_base_setting?: { [key: string]: unknown } | null @@ -403,7 +403,7 @@ export type EnvironmentVariableItemResponse = { visible: boolean } -export type JsonValue = +export type ShallowJsonValue = | string | number | number @@ -601,6 +601,17 @@ export type WorkflowDraftVariableFullContentResponse = { value_type: string } +export type RecursiveJsonValue = + | string + | number + | number + | boolean + | { + [key: string]: RecursiveJsonValue + } + | Array + | null + export type AgentScope = 'roster' | 'workflow_only' export type AgentSource = 'agent_app' | 'imported' | 'roster' | 'system' | 'workflow' @@ -2014,7 +2025,7 @@ export type GetSnippetsBySnippetIdWorkflowsPublishResponse = GetSnippetsBySnippetIdWorkflowsPublishResponses[keyof GetSnippetsBySnippetIdWorkflowsPublishResponses] export type PostSnippetsBySnippetIdWorkflowsPublishData = { - body: PublishWorkflowPayload + body: SnippetPublishWorkflowPayload path: { snippet_id: string } diff --git a/packages/contracts/generated/api/console/snippets/zod.gen.ts b/packages/contracts/generated/api/console/snippets/zod.gen.ts index e7fb74729fd0e5..7981ab88b37c2d 100644 --- a/packages/contracts/generated/api/console/snippets/zod.gen.ts +++ b/packages/contracts/generated/api/console/snippets/zod.gen.ts @@ -223,11 +223,11 @@ export const zWorkflowDraftVariableUpdatePayload = z.object({ }) /** - * PublishWorkflowPayload + * SnippetPublishWorkflowPayload * * Payload for publishing snippet workflow. */ -export const zPublishWorkflowPayload = z.object({ +export const zSnippetPublishWorkflowPayload = z.object({ knowledge_base_setting: z.record(z.string(), z.unknown()).nullish(), marked_comment: z.string().max(100).nullish(), marked_name: z.string().max(20).nullish(), @@ -451,7 +451,7 @@ export const zEnvironmentVariableListResponse = z.object({ items: z.array(zEnvironmentVariableItemResponse), }) -export const zJsonValue = z +export const zShallowJsonValue = z .union([ z.string(), z.int(), @@ -465,7 +465,7 @@ export const zJsonValue = z /** * GeneratedAppResponse */ -export const zGeneratedAppResponse = zJsonValue +export const zGeneratedAppResponse = zShallowJsonValue /** * AgentConfigSnapshotSummaryResponse @@ -626,6 +626,20 @@ export const zWorkflowDraftVariableFullContentResponse = z.object({ value_type: z.string(), }) +export const zRecursiveJsonValue = z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record( + z.string(), + z.lazy((): any => zRecursiveJsonValue), + ), + z.array(z.lazy((): any => zRecursiveJsonValue)), + ]) + .nullable() + /** * WorkflowDraftVariableResponse */ @@ -638,7 +652,7 @@ export const zWorkflowDraftVariableResponse = z.object({ name: z.string(), selector: z.array(z.string()), type: z.string(), - value: zJsonValue, + value: zRecursiveJsonValue, value_type: z.string(), visible: z.boolean(), }) @@ -2137,7 +2151,7 @@ export const zGetSnippetsBySnippetIdWorkflowsPublishPath = z.object({ */ export const zGetSnippetsBySnippetIdWorkflowsPublishResponse = zSnippetWorkflowResponse -export const zPostSnippetsBySnippetIdWorkflowsPublishBody = zPublishWorkflowPayload +export const zPostSnippetsBySnippetIdWorkflowsPublishBody = zSnippetPublishWorkflowPayload export const zPostSnippetsBySnippetIdWorkflowsPublishPath = z.object({ snippet_id: z.uuid(), diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 7a93e7704c48b5..6bb4bf1a2e66de 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -1516,7 +1516,7 @@ export type DslImportWarning = { export type PluginDependency = { current_identifier?: string | null type: PluginDependencyType - value: Github | Marketplace | Package + value: PluginDependencyGithub | PluginDependencyMarketplace | PluginDependencyPackage } export type AccountWithRoleResponse = { @@ -1586,11 +1586,11 @@ export type ProviderResponse = { background?: string | null configurate_methods: Array custom_configuration: CustomConfigurationResponse - description?: I18nObject | null + description?: ModelRuntimeI18nObject | null help?: ProviderHelpEntity | null - icon_small?: I18nObject | null - icon_small_dark?: I18nObject | null - label: I18nObject + icon_small?: ModelRuntimeI18nObject | null + icon_small_dark?: ModelRuntimeI18nObject | null + label: ModelRuntimeI18nObject model_credential_schema?: ModelCredentialSchema | null preferred_provider_type: ProviderType provider: string @@ -1603,11 +1603,11 @@ export type ProviderResponse = { export type ModelProviderSummaryResponse = { configurate_methods: Array custom_configuration: ModelProviderCustomConfigurationSummaryResponse - description?: I18nObject | null - icon_small?: I18nObject | null - icon_small_dark?: I18nObject | null + description?: ModelRuntimeI18nObject | null + icon_small?: ModelRuntimeI18nObject | null + icon_small_dark?: ModelRuntimeI18nObject | null is_configured: boolean - label: I18nObject + label: ModelRuntimeI18nObject plugin_id: string preferred_provider_type: ProviderType provider: string @@ -1629,7 +1629,7 @@ export type ModelWithProviderEntityResponse = { features?: Array | null fetch_from: FetchFrom has_invalid_load_balancing_configs?: boolean - label: I18nObject + label: ModelRuntimeI18nObject load_balancing_enabled?: boolean model: string model_properties: { @@ -1661,8 +1661,8 @@ export type ModelLoadBalancingResponse = { export type ParameterRule = { default?: unknown | null - help?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null - label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + help?: ModelRuntimeI18nObject | null + label: ModelRuntimeI18nObject max?: number | null min?: number | null name: string @@ -1674,9 +1674,9 @@ export type ParameterRule = { } export type ProviderWithModelsResponse = { - icon_small?: I18nObject | null - icon_small_dark?: I18nObject | null - label: I18nObject + icon_small?: ModelRuntimeI18nObject | null + icon_small_dark?: ModelRuntimeI18nObject | null + label: ModelRuntimeI18nObject models: Array provider: string status: CustomConfigurationStatus @@ -1713,11 +1713,11 @@ export type PluginDeclaration = { category: PluginCategory created_at: string datasource?: DatasourceProviderEntity | null - description: CoreToolsEntitiesCommonEntitiesI18nObject + description: I18nObject endpoint?: EndpointProviderDeclaration | null icon: string icon_dark?: string | null - label: CoreToolsEntitiesCommonEntitiesI18nObject + label: I18nObject meta: Meta model?: ProviderEntity | null name: string @@ -1802,7 +1802,10 @@ export type TenantPluginInstallPermission = 'admins' | 'everyone' | 'noone' export type PluginBundleDependency = { type: PluginBundleDependencyType - value: Github | Marketplace | Package + value: + | PluginBundleDependencyGithub + | PluginBundleDependencyMarketplace + | PluginBundleDependencyPackage } export type PluginVerification = { @@ -1812,7 +1815,7 @@ export type PluginVerification = { export type PluginCategoryBuiltinToolProviderResponse = { allow_delete: boolean author: string - description: CoreToolsEntitiesCommonEntitiesI18nObject + description: I18nObject icon: | string | { @@ -1826,7 +1829,7 @@ export type PluginCategoryBuiltinToolProviderResponse = { | null id: string is_team_authorization: boolean - label: CoreToolsEntitiesCommonEntitiesI18nObject + label: I18nObject labels: Array name: string plugin_id: string | null @@ -2209,10 +2212,10 @@ export type TenantInfoResponse = { export type AgentStrategyProviderIdentity = { author: string - description: CoreToolsEntitiesCommonEntitiesI18nObject + description: I18nObject icon: string icon_dark?: string | null - label: CoreToolsEntitiesCommonEntitiesI18nObject + label: I18nObject name: string tags?: Array | null } @@ -2230,27 +2233,27 @@ export type AgentStrategyEntity = { export type PluginDependencyType = 'github' | 'marketplace' | 'package' -export type Github = { +export type PluginDependencyGithub = { github_plugin_unique_identifier: string package: string repo: string version: string } -export type Marketplace = { +export type PluginDependencyMarketplace = { marketplace_plugin_unique_identifier: string version?: string | null } -export type Package = { +export type PluginDependencyPackage = { plugin_unique_identifier: string version?: string | null } export type SimpleProviderEntityResponse = { - icon_small?: I18nObject | null - icon_small_dark?: I18nObject | null - label: I18nObject + icon_small?: ModelRuntimeI18nObject | null + icon_small_dark?: ModelRuntimeI18nObject | null + label: ModelRuntimeI18nObject models?: Array provider: string provider_name?: string @@ -2274,9 +2277,14 @@ export type CustomConfigurationResponse = { status: CustomConfigurationStatus } +export type ModelRuntimeI18nObject = { + en_US: string + zh_Hans?: string | null +} + export type ProviderHelpEntity = { - title: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject - url: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + title: ModelRuntimeI18nObject + url: ModelRuntimeI18nObject } export type ModelCredentialSchema = { @@ -2358,11 +2366,6 @@ export type ModelLoadBalancingConfigResponse = { ttl: number } -export type GraphonModelRuntimeEntitiesCommonEntitiesI18nObject = { - en_US: string - zh_Hans?: string | null -} - export type ParameterType = 'boolean' | 'float' | 'int' | 'string' | 'text' export type ProviderModelWithStatusEntity = { @@ -2370,7 +2373,7 @@ export type ProviderModelWithStatusEntity = { features?: Array | null fetch_from: FetchFrom has_invalid_load_balancing_configs?: boolean - label: I18nObject + label: ModelRuntimeI18nObject load_balancing_enabled?: boolean model: string model_properties: { @@ -2406,13 +2409,6 @@ export type DatasourceProviderEntity = { provider_type: DatasourceProviderType } -export type CoreToolsEntitiesCommonEntitiesI18nObject = { - en_US: string - ja_JP?: string | null - pt_BR?: string | null - zh_Hans?: string | null -} - export type EndpointProviderDeclaration = { endpoints?: Array | null settings?: Array @@ -2421,11 +2417,11 @@ export type EndpointProviderDeclaration = { export type ProviderEntity = { background?: string | null configurate_methods: Array - description?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null + description?: ModelRuntimeI18nObject | null help?: ProviderHelpEntity | null - icon_small?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null - icon_small_dark?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null - label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + icon_small?: ModelRuntimeI18nObject | null + icon_small_dark?: ModelRuntimeI18nObject | null + label: ModelRuntimeI18nObject model_credential_schema?: ModelCredentialSchema | null models?: Array position?: { @@ -2486,13 +2482,13 @@ export type PluginDeclarationResponse = { datasource?: { [key: string]: unknown } | null - description: CoreToolsEntitiesCommonEntitiesI18nObject + description: I18nObject endpoint?: { [key: string]: unknown } | null icon: string icon_dark?: string | null - label: CoreToolsEntitiesCommonEntitiesI18nObject + label: I18nObject meta: { [key: string]: unknown } @@ -2518,12 +2514,30 @@ export type PluginDeclarationResponse = { export type PluginBundleDependencyType = 'github' | 'marketplace' | 'package' +export type PluginBundleDependencyGithub = { + packages: string + release: string + repo: string + repo_address: string +} + +export type PluginBundleDependencyMarketplace = { + organization: string + plugin: string + version: string +} + +export type PluginBundleDependencyPackage = { + manifest: PluginDeclaration + unique_identifier: string +} + export type AuthorizedCategory = 'community' | 'langgenius' | 'partner' export type PluginCategoryBuiltinToolResponse = { author: string - description: CoreToolsEntitiesCommonEntitiesI18nObject - label: CoreToolsEntitiesCommonEntitiesI18nObject + description: I18nObject + label: I18nObject labels: Array name: string output_schema: { @@ -2711,7 +2725,7 @@ export type AiModelEntityResponse = { deprecated?: boolean features?: Array | null fetch_from: FetchFrom - label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + label: ModelRuntimeI18nObject model: string model_properties: { [key in ModelPropertyKey]?: unknown @@ -2760,10 +2774,10 @@ export type CustomModelConfiguration = { export type CredentialFormSchema = { default?: string | null - label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + label: ModelRuntimeI18nObject max_length?: number options?: Array | null - placeholder?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null + placeholder?: ModelRuntimeI18nObject | null required?: boolean show_on?: Array type: FormType @@ -2771,8 +2785,8 @@ export type CredentialFormSchema = { } export type FieldModelSchema = { - label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject - placeholder?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null + label: ModelRuntimeI18nObject + placeholder?: ModelRuntimeI18nObject | null } export type ProviderQuotaType = 'free' | 'paid' | 'trial' @@ -2788,9 +2802,9 @@ export type QuotaConfiguration = { export type DatasourceProviderIdentity = { author: string - description: CoreToolsEntitiesCommonEntitiesI18nObject + description: I18nObject icon: string - label: CoreToolsEntitiesCommonEntitiesI18nObject + label: I18nObject name: string tags?: Array | null } @@ -2811,7 +2825,7 @@ export type AiModelEntity = { deprecated?: boolean features?: Array | null fetch_from: FetchFrom - label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + label: ModelRuntimeI18nObject model: string model_properties: { [key in ModelPropertyKey]?: unknown @@ -2831,16 +2845,16 @@ export type Permission = { export type ToolProviderIdentity = { author: string - description: CoreToolsEntitiesCommonEntitiesI18nObject + description: I18nObject icon: string icon_dark?: string | null - label: CoreToolsEntitiesCommonEntitiesI18nObject + label: I18nObject name: string tags?: Array | null } export type EventEntity = { - description: CoreToolsEntitiesCommonEntitiesI18nObject + description: I18nObject identity: EventIdentity output_schema?: { [key: string]: unknown @@ -2850,10 +2864,10 @@ export type EventEntity = { export type TriggerProviderIdentity = { author: string - description: CoreToolsEntitiesCommonEntitiesI18nObject + description: I18nObject icon?: string | null icon_dark?: string | null - label: CoreToolsEntitiesCommonEntitiesI18nObject + label: I18nObject name: string tags?: Array } @@ -2861,11 +2875,11 @@ export type TriggerProviderIdentity = { export type ProviderEntityResponse = { background?: string | null configurate_methods: Array - description?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null + description?: ModelRuntimeI18nObject | null help?: ProviderHelpEntity | null - icon_small?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null - icon_small_dark?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null - label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + icon_small?: ModelRuntimeI18nObject | null + icon_small_dark?: ModelRuntimeI18nObject | null + label: ModelRuntimeI18nObject model_credential_schema?: ModelCredentialSchema | null models?: Array position?: { @@ -2967,7 +2981,7 @@ export type EndpointProviderConfigScope = | 'workflow' export type FormOption = { - label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + label: ModelRuntimeI18nObject show_on?: Array value: string } diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 7ff7c43cd1673b..55541f341f82b8 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -2348,7 +2348,7 @@ export const zPluginDependencyType = z.enum(['github', 'marketplace', 'package'] /** * Github */ -export const zGithub = z.object({ +export const zPluginDependencyGithub = z.object({ github_plugin_unique_identifier: z.string(), package: z.string(), repo: z.string(), @@ -2358,7 +2358,7 @@ export const zGithub = z.object({ /** * Marketplace */ -export const zMarketplace = z.object({ +export const zPluginDependencyMarketplace = z.object({ marketplace_plugin_unique_identifier: z.string(), version: z.string().nullish(), }) @@ -2366,7 +2366,7 @@ export const zMarketplace = z.object({ /** * Package */ -export const zPackage = z.object({ +export const zPluginDependencyPackage = z.object({ plugin_unique_identifier: z.string(), version: z.string().nullish(), }) @@ -2377,7 +2377,7 @@ export const zPackage = z.object({ export const zPluginDependency = z.object({ current_identifier: z.string().nullish(), type: zPluginDependencyType, - value: z.union([zGithub, zMarketplace, zPackage]), + value: z.union([zPluginDependencyGithub, zPluginDependencyMarketplace, zPluginDependencyPackage]), }) /** @@ -2394,6 +2394,26 @@ export const zSnippetDependencyCheckResponse = z.object({ */ export const zConfigurateMethod = z.enum(['customizable-model', 'predefined-model']) +/** + * I18nObject + * + * Model class for i18n object. + */ +export const zModelRuntimeI18nObject = z.object({ + en_US: z.string(), + zh_Hans: z.string().nullish(), +}) + +/** + * ProviderHelpEntity + * + * Model class for provider help. + */ +export const zProviderHelpEntity = z.object({ + title: zModelRuntimeI18nObject, + url: zModelRuntimeI18nObject, +}) + /** * ProviderType */ @@ -2513,26 +2533,6 @@ export const zModelCredentialResponse = z.object({ load_balancing: zModelLoadBalancingResponse, }) -/** - * I18nObject - * - * Model class for i18n object. - */ -export const zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject = z.object({ - en_US: z.string(), - zh_Hans: z.string().nullish(), -}) - -/** - * ProviderHelpEntity - * - * Model class for provider help. - */ -export const zProviderHelpEntity = z.object({ - title: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, - url: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, -}) - /** * ParameterType * @@ -2547,8 +2547,8 @@ export const zParameterType = z.enum(['boolean', 'float', 'int', 'string', 'text */ export const zParameterRule = z.object({ default: z.unknown().nullish(), - help: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), - label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + help: zModelRuntimeI18nObject.nullish(), + label: zModelRuntimeI18nObject, max: z.number().nullish(), min: z.number().nullish(), name: z.string(), @@ -2576,7 +2576,7 @@ export const zProviderModelWithStatusEntity = z.object({ features: z.array(zModelFeature).nullish(), fetch_from: zFetchFrom, has_invalid_load_balancing_configs: z.boolean().optional().default(false), - label: zI18nObject, + label: zModelRuntimeI18nObject, load_balancing_enabled: z.boolean().optional().default(false), model: z.string(), model_properties: z.record(z.string(), z.unknown()), @@ -2597,9 +2597,9 @@ export const zCustomConfigurationStatus = z.enum(['active', 'no-configure']) * Model class for provider with models response. */ export const zProviderWithModelsResponse = z.object({ - icon_small: zI18nObject.nullish(), - icon_small_dark: zI18nObject.nullish(), - label: zI18nObject, + icon_small: zModelRuntimeI18nObject.nullish(), + icon_small_dark: zModelRuntimeI18nObject.nullish(), + label: zModelRuntimeI18nObject, models: z.array(zProviderModelWithStatusEntity), provider: z.string(), status: zCustomConfigurationStatus, @@ -2633,11 +2633,11 @@ export const zModelProviderCustomConfigurationSummaryResponse = z.object({ export const zModelProviderSummaryResponse = z.object({ configurate_methods: z.array(zConfigurateMethod), custom_configuration: zModelProviderCustomConfigurationSummaryResponse, - description: zI18nObject.nullish(), - icon_small: zI18nObject.nullish(), - icon_small_dark: zI18nObject.nullish(), + description: zModelRuntimeI18nObject.nullish(), + icon_small: zModelRuntimeI18nObject.nullish(), + icon_small_dark: zModelRuntimeI18nObject.nullish(), is_configured: z.boolean(), - label: zI18nObject, + label: zModelRuntimeI18nObject, plugin_id: z.string(), preferred_provider_type: zProviderType, provider: z.string(), @@ -2713,18 +2713,6 @@ export const zPluginCategory = z.enum([ 'trigger', ]) -/** - * I18nObject - * - * Model class for i18n object. - */ -export const zCoreToolsEntitiesCommonEntitiesI18nObject = z.object({ - en_US: z.string(), - ja_JP: z.string().nullish(), - pt_BR: z.string().nullish(), - zh_Hans: z.string().nullish(), -}) - /** * Plugins */ @@ -2796,17 +2784,23 @@ export const zPluginTaskResponse = z.object({ export const zPluginBundleDependencyType = z.enum(['github', 'marketplace', 'package']) /** - * PluginBundleDependency + * Github */ -export const zPluginBundleDependency = z.object({ - type: zPluginBundleDependencyType, - value: z.union([zGithub, zMarketplace, zPackage]), +export const zPluginBundleDependencyGithub = z.object({ + packages: z.string(), + release: z.string(), + repo: z.string(), + repo_address: z.string(), }) /** - * PluginBundleUploadResponse + * Marketplace */ -export const zPluginBundleUploadResponse = z.array(zPluginBundleDependency) +export const zPluginBundleDependencyMarketplace = z.object({ + organization: z.string(), + plugin: z.string(), + version: z.string(), +}) /** * AuthorizedCategory @@ -2827,8 +2821,8 @@ export const zPluginVerification = z.object({ */ export const zPluginCategoryBuiltinToolResponse = z.object({ author: z.string(), - description: zCoreToolsEntitiesCommonEntitiesI18nObject, - label: zCoreToolsEntitiesCommonEntitiesI18nObject, + description: zI18nObject, + label: zI18nObject, labels: z.array(z.string()), name: z.string(), output_schema: z.record(z.string(), z.unknown()), @@ -2841,12 +2835,12 @@ export const zPluginCategoryBuiltinToolResponse = z.object({ export const zPluginCategoryBuiltinToolProviderResponse = z.object({ allow_delete: z.boolean(), author: z.string(), - description: zCoreToolsEntitiesCommonEntitiesI18nObject, + description: zI18nObject, icon: z.union([z.string(), z.record(z.string(), z.string())]), icon_dark: z.union([z.string(), z.record(z.string(), z.string())]).nullable(), id: z.string(), is_team_authorization: z.boolean(), - label: zCoreToolsEntitiesCommonEntitiesI18nObject, + label: zI18nObject, labels: z.array(z.string()), name: z.string(), plugin_id: z.string().nullable(), @@ -3186,10 +3180,10 @@ export const zToolLabelEnum = z.enum([ */ export const zAgentStrategyProviderIdentity = z.object({ author: z.string(), - description: zCoreToolsEntitiesCommonEntitiesI18nObject, + description: zI18nObject, icon: z.string(), icon_dark: z.string().nullish(), - label: zCoreToolsEntitiesCommonEntitiesI18nObject, + label: zI18nObject, name: z.string(), tags: z.array(zToolLabelEnum).nullish().default([]), }) @@ -3274,8 +3268,8 @@ export const zCustomConfigurationResponse = z.object({ * FieldModelSchema */ export const zFieldModelSchema = z.object({ - label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, - placeholder: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), + label: zModelRuntimeI18nObject, + placeholder: zModelRuntimeI18nObject.nullish(), }) /** @@ -3288,9 +3282,9 @@ export const zProviderQuotaType = z.enum(['free', 'paid', 'trial']) */ export const zDatasourceProviderIdentity = z.object({ author: z.string(), - description: zCoreToolsEntitiesCommonEntitiesI18nObject, + description: zI18nObject, icon: z.string(), - label: zCoreToolsEntitiesCommonEntitiesI18nObject, + label: zI18nObject, name: z.string(), tags: z.array(zToolLabelEnum).nullish().default([]), }) @@ -3345,10 +3339,10 @@ export const zEndpointProviderDeclaration = z.object({ */ export const zToolProviderIdentity = z.object({ author: z.string(), - description: zCoreToolsEntitiesCommonEntitiesI18nObject, + description: zI18nObject, icon: z.string(), icon_dark: z.string().nullish(), - label: zCoreToolsEntitiesCommonEntitiesI18nObject, + label: zI18nObject, name: z.string(), tags: z.array(zToolLabelEnum).nullish().default([]), }) @@ -3370,10 +3364,10 @@ export const zToolProviderEntity = z.object({ */ export const zTriggerProviderIdentity = z.object({ author: z.string(), - description: zCoreToolsEntitiesCommonEntitiesI18nObject, + description: zI18nObject, icon: z.string().nullish(), icon_dark: z.string().nullish(), - label: zCoreToolsEntitiesCommonEntitiesI18nObject, + label: zI18nObject, name: z.string(), tags: z.array(z.string()).optional(), }) @@ -3472,7 +3466,7 @@ export const zAiModelEntityResponse = z.object({ deprecated: z.boolean().optional().default(false), features: z.array(zModelFeature).nullish(), fetch_from: zFetchFrom, - label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + label: zModelRuntimeI18nObject, model: z.string(), model_properties: z.record(z.string(), z.unknown()), model_type: zModelType, @@ -3486,9 +3480,9 @@ export const zAiModelEntityResponse = z.object({ * Simple provider entity response. */ export const zSimpleProviderEntityResponse = z.object({ - icon_small: zI18nObject.nullish(), - icon_small_dark: zI18nObject.nullish(), - label: zI18nObject, + icon_small: zModelRuntimeI18nObject.nullish(), + icon_small_dark: zModelRuntimeI18nObject.nullish(), + label: zModelRuntimeI18nObject, models: z.array(zAiModelEntityResponse).optional().default([]), provider: z.string(), provider_name: z.string().optional().default(''), @@ -3524,7 +3518,7 @@ export const zModelWithProviderEntityResponse = z.object({ features: z.array(zModelFeature).nullish(), fetch_from: zFetchFrom, has_invalid_load_balancing_configs: z.boolean().optional().default(false), - label: zI18nObject, + label: zModelRuntimeI18nObject, load_balancing_enabled: z.boolean().optional().default(false), model: z.string(), model_properties: z.record(z.string(), z.unknown()), @@ -3643,7 +3637,7 @@ export const zFormShowOnObject = z.object({ * Model class for form option. */ export const zFormOption = z.object({ - label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + label: zModelRuntimeI18nObject, show_on: z.array(zFormShowOnObject).optional().default([]), value: z.string(), }) @@ -3662,10 +3656,10 @@ export const zFormType = z.enum(['radio', 'secret-input', 'select', 'switch', 't */ export const zCredentialFormSchema = z.object({ default: z.string().nullish(), - label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + label: zModelRuntimeI18nObject, max_length: z.int().optional().default(0), options: z.array(zFormOption).nullish(), - placeholder: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), + placeholder: zModelRuntimeI18nObject.nullish(), required: z.boolean().optional().default(true), show_on: z.array(zFormShowOnObject).optional().default([]), type: zFormType, @@ -3699,11 +3693,11 @@ export const zProviderCredentialSchema = z.object({ export const zProviderEntityResponse = z.object({ background: z.string().nullish(), configurate_methods: z.array(zConfigurateMethod), - description: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), + description: zModelRuntimeI18nObject.nullish(), help: zProviderHelpEntity.nullish(), - icon_small: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), - icon_small_dark: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), - label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + icon_small: zModelRuntimeI18nObject.nullish(), + icon_small_dark: zModelRuntimeI18nObject.nullish(), + label: zModelRuntimeI18nObject, model_credential_schema: zModelCredentialSchema.nullish(), models: z.array(zAiModelEntityResponse).optional().default([]), position: z.record(z.string(), z.array(z.string())).nullish().default({}), @@ -3722,11 +3716,11 @@ export const zPluginDeclarationResponse = z.object({ category: zPluginCategory, created_at: z.iso.datetime(), datasource: z.record(z.string(), z.unknown()).nullish(), - description: zCoreToolsEntitiesCommonEntitiesI18nObject, + description: zI18nObject, endpoint: z.record(z.string(), z.unknown()).nullish(), icon: z.string(), icon_dark: z.string().nullish(), - label: zCoreToolsEntitiesCommonEntitiesI18nObject, + label: zI18nObject, meta: z.record(z.string(), z.unknown()), model: zProviderEntityResponse.nullish(), name: z.string(), @@ -3846,11 +3840,11 @@ export const zProviderResponse = z.object({ background: z.string().nullish(), configurate_methods: z.array(zConfigurateMethod), custom_configuration: zCustomConfigurationResponse, - description: zI18nObject.nullish(), + description: zModelRuntimeI18nObject.nullish(), help: zProviderHelpEntity.nullish(), - icon_small: zI18nObject.nullish(), - icon_small_dark: zI18nObject.nullish(), - label: zI18nObject, + icon_small: zModelRuntimeI18nObject.nullish(), + icon_small_dark: zModelRuntimeI18nObject.nullish(), + label: zModelRuntimeI18nObject, model_credential_schema: zModelCredentialSchema.nullish(), preferred_provider_type: zProviderType, provider: z.string(), @@ -3891,7 +3885,7 @@ export const zAiModelEntity = z.object({ deprecated: z.boolean().optional().default(false), features: z.array(zModelFeature).nullish(), fetch_from: zFetchFrom, - label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + label: zModelRuntimeI18nObject, model: z.string(), model_properties: z.record(z.string(), z.unknown()), model_type: zModelType, @@ -3911,11 +3905,11 @@ export const zAiModelEntity = z.object({ export const zProviderEntity = z.object({ background: z.string().nullish(), configurate_methods: z.array(zConfigurateMethod), - description: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), + description: zModelRuntimeI18nObject.nullish(), help: zProviderHelpEntity.nullish(), - icon_small: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), - icon_small_dark: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), - label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + icon_small: zModelRuntimeI18nObject.nullish(), + icon_small_dark: zModelRuntimeI18nObject.nullish(), + label: zModelRuntimeI18nObject, model_credential_schema: zModelCredentialSchema.nullish(), models: z.array(zAiModelEntity).optional(), position: z.record(z.string(), z.array(z.string())).nullish().default({}), @@ -4290,7 +4284,7 @@ export const zAgentProviderListResponse = z.array(zAgentProviderResponse) * The configuration of an event */ export const zEventEntity = z.object({ - description: zCoreToolsEntitiesCommonEntitiesI18nObject, + description: zI18nObject, identity: zEventIdentity, output_schema: z.record(z.string(), z.unknown()).nullish(), parameters: z.array(zEventParameter).optional(), @@ -4320,11 +4314,11 @@ export const zPluginDeclaration = z.object({ category: zPluginCategory, created_at: z.iso.datetime(), datasource: zDatasourceProviderEntity.nullish(), - description: zCoreToolsEntitiesCommonEntitiesI18nObject, + description: zI18nObject, endpoint: zEndpointProviderDeclaration.nullish(), icon: z.string(), icon_dark: z.string().nullish(), - label: zCoreToolsEntitiesCommonEntitiesI18nObject, + label: zI18nObject, meta: zMeta, model: zProviderEntity.nullish(), name: z.string().regex(/^[a-z0-9_-]{1,128}$/), @@ -4384,6 +4378,31 @@ export const zPluginListResponse = z.object({ total: z.int(), }) +/** + * Package + */ +export const zPluginBundleDependencyPackage = z.object({ + manifest: zPluginDeclaration, + unique_identifier: z.string(), +}) + +/** + * PluginBundleDependency + */ +export const zPluginBundleDependency = z.object({ + type: zPluginBundleDependencyType, + value: z.union([ + zPluginBundleDependencyGithub, + zPluginBundleDependencyMarketplace, + zPluginBundleDependencyPackage, + ]), +}) + +/** + * PluginBundleUploadResponse + */ +export const zPluginBundleUploadResponse = z.array(zPluginBundleDependency) + /** * AccountWithRoleResponse */ diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index 4bc6577937b8b8..6a9fbd6325bea5 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -281,13 +281,6 @@ export type FormSubmitResponse = { [key: string]: never } -export type Github = { - github_plugin_unique_identifier: string - package: string - repo: string - version: string -} - export type HealthResponse = { ok: boolean } @@ -333,11 +326,6 @@ export type ImportStatus = 'completed' | 'completed-with-warnings' | 'failed' | export type JsonValue = unknown -export type Marketplace = { - marketplace_plugin_unique_identifier: string - version?: string | null -} - export type MemberActionResponse = { result?: 'success' } @@ -436,11 +424,6 @@ export type OpenApiFormSubmitPayload = { } } -export type Package = { - plugin_unique_identifier: string - version?: string | null -} - export type PermittedExternalAppsListQuery = { limit?: number mode?: SupportedAppType | null @@ -460,7 +443,24 @@ export type PermittedExternalAppsListResponse = { export type PluginDependency = { current_identifier?: string | null type: PluginDependencyType - value: Github | Marketplace | Package + value: PluginDependencyGithub | PluginDependencyMarketplace | PluginDependencyPackage +} + +export type PluginDependencyGithub = { + github_plugin_unique_identifier: string + package: string + repo: string + version: string +} + +export type PluginDependencyMarketplace = { + marketplace_plugin_unique_identifier: string + version?: string | null +} + +export type PluginDependencyPackage = { + plugin_unique_identifier: string + version?: string | null } export type PluginDependencyType = 'github' | 'marketplace' | 'package' diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 2e4b22e2c8332d..7a74bde9cd4737 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -353,16 +353,6 @@ export const zFileUploadPayload = z.object({ */ export const zFormSubmitResponse = z.record(z.string(), z.never()) -/** - * Github - */ -export const zGithub = z.object({ - github_plugin_unique_identifier: z.string(), - package: z.string(), - repo: z.string(), - version: z.string(), -}) - /** * HealthResponse * @@ -447,14 +437,6 @@ export const zImport = z.object({ export const zJsonValue = z.unknown() -/** - * Marketplace - */ -export const zMarketplace = z.object({ - marketplace_plugin_unique_identifier: z.string(), - version: z.string().nullish(), -}) - /** * MemberActionResponse */ @@ -582,14 +564,6 @@ export const zOpenApiFormSubmitPayload = z.object({ inputs: z.record(z.string(), zJsonValue), }) -/** - * Package - */ -export const zPackage = z.object({ - plugin_unique_identifier: z.string(), - version: z.string().nullish(), -}) - /** * PermittedExternalAppsListResponse */ @@ -602,6 +576,32 @@ export const zPermittedExternalAppsListResponse = z.object({ total: z.int(), }) +/** + * Github + */ +export const zPluginDependencyGithub = z.object({ + github_plugin_unique_identifier: z.string(), + package: z.string(), + repo: z.string(), + version: z.string(), +}) + +/** + * Marketplace + */ +export const zPluginDependencyMarketplace = z.object({ + marketplace_plugin_unique_identifier: z.string(), + version: z.string().nullish(), +}) + +/** + * Package + */ +export const zPluginDependencyPackage = z.object({ + plugin_unique_identifier: z.string(), + version: z.string().nullish(), +}) + /** * PluginDependencyType */ @@ -613,7 +613,7 @@ export const zPluginDependencyType = z.enum(['github', 'marketplace', 'package'] export const zPluginDependency = z.object({ current_identifier: z.string().nullish(), type: zPluginDependencyType, - value: z.union([zGithub, zMarketplace, zPackage]), + value: z.union([zPluginDependencyGithub, zPluginDependencyMarketplace, zPluginDependencyPackage]), }) /** diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index b535b50d7aebda..fcb5b2b0674854 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -21,7 +21,7 @@ export type AgentThought = { thought?: string | null tool?: string | null tool_input?: string | null - tool_labels: JsonValue + tool_labels: AnyJsonValue } export type Annotation = { @@ -68,6 +68,8 @@ export type AnnotationReplyActionPayload = { score_threshold: number } +export type AnyJsonValue = unknown + export type AppFeedbackListResponse = { data: Array } @@ -1279,21 +1281,21 @@ export type HumanInputFormSubmissionData = { node_title: string rendered_content: string submitted_data?: { - [key: string]: JsonValue2 + [key: string]: JsonValue } | null } export type HumanInputFormSubmitPayload = { action: string inputs: { - [key: string]: JsonValue2 + [key: string]: JsonValue } } export type HumanInputFormSubmitPayloadWithUser = { action: string inputs: { - [key: string]: JsonValue2 + [key: string]: JsonValue } user: string } @@ -1307,11 +1309,6 @@ export type HumanInputFormSubmittedError = ErrorResponse & { status: 412 } -export type I18nObject = { - en_US: string - zh_Hans?: string | null -} - export type IndexInfoResponse = { api_version: string server_version: string @@ -1337,11 +1334,9 @@ export type JsonObject = { [key: string]: unknown } -export type JsonValue = unknown - export type JsonValueType = unknown -export type JsonValue2 = unknown +export type JsonValue = unknown export type KnowledgeTagListResponse = Array @@ -1466,6 +1461,11 @@ export type ModelPropertyKey = | 'voices' | 'word_limit' +export type ModelRuntimeI18nObject = { + en_US: string + zh_Hans?: string | null +} + export type ModelStatus = | 'active' | 'credential-removed' @@ -1923,7 +1923,7 @@ export type ProviderModelWithStatusEntity = { features?: Array | null fetch_from: FetchFrom has_invalid_load_balancing_configs?: boolean - label: I18nObject + label: ModelRuntimeI18nObject load_balancing_enabled?: boolean model: string model_properties: { @@ -1953,9 +1953,9 @@ export type ProviderWithModelsListResponse = { } export type ProviderWithModelsResponse = { - icon_small?: I18nObject | null - icon_small_dark?: I18nObject | null - label: I18nObject + icon_small?: ModelRuntimeI18nObject | null + icon_small_dark?: ModelRuntimeI18nObject | null + label: ModelRuntimeI18nObject models: Array provider: string status: CustomConfigurationStatus @@ -2152,7 +2152,7 @@ export type SimpleConversation = { created_at?: number | null id: string inputs: { - [key: string]: JsonValue + [key: string]: AnyJsonValue } introduction?: string | null name: string diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index 0f9503a04c32ea..e93dc52b427e0a 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -67,6 +67,26 @@ export const zAnnotationReplyActionPayload = z.object({ score_threshold: z.number(), }) +export const zAnyJsonValue = z.unknown() + +/** + * AgentThought + */ +export const zAgentThought = z.object({ + answer: z.string().nullish(), + chain_id: z.string().nullish(), + created_at: z.int().nullish(), + files: z.array(z.string()), + id: z.string(), + message_id: z.string(), + observation: z.string().nullish(), + position: z.int(), + thought: z.string().nullish(), + tool: z.string().nullish(), + tool_input: z.string().nullish(), + tool_labels: zAnyJsonValue, +}) + /** * AppFeedbackResponse */ @@ -1451,16 +1471,6 @@ export const zHumanInputFormSubmittedError = zErrorResponse.and( }), ) -/** - * I18nObject - * - * Model class for i18n object. - */ -export const zI18nObject = z.object({ - en_US: z.string(), - zh_Hans: z.string().nullish(), -}) - /** * IndexInfoResponse */ @@ -1574,29 +1584,9 @@ export const zChatBlockingResponse = z.discriminatedUnion('event', [ zChatPausedBlockingResponse.extend({ event: z.literal('workflow_paused') }), ]) -export const zJsonValue = z.unknown() - -/** - * AgentThought - */ -export const zAgentThought = z.object({ - answer: z.string().nullish(), - chain_id: z.string().nullish(), - created_at: z.int().nullish(), - files: z.array(z.string()), - id: z.string(), - message_id: z.string(), - observation: z.string().nullish(), - position: z.int(), - thought: z.string().nullish(), - tool: z.string().nullish(), - tool_input: z.string().nullish(), - tool_labels: zJsonValue, -}) - export const zJsonValueType = z.unknown() -export const zJsonValue2 = z.unknown() +export const zJsonValue = z.unknown() /** * HumanInputFormSubmissionData @@ -1607,7 +1597,7 @@ export const zHumanInputFormSubmissionData = z.object({ node_id: z.string(), node_title: z.string(), rendered_content: z.string(), - submitted_data: z.record(z.string(), zJsonValue2).nullish(), + submitted_data: z.record(z.string(), zJsonValue).nullish(), }) /** @@ -1615,7 +1605,7 @@ export const zHumanInputFormSubmissionData = z.object({ */ export const zHumanInputFormSubmitPayload = z.object({ action: z.string(), - inputs: z.record(z.string(), zJsonValue2), + inputs: z.record(z.string(), zJsonValue), }) /** @@ -1623,7 +1613,7 @@ export const zHumanInputFormSubmitPayload = z.object({ */ export const zHumanInputFormSubmitPayloadWithUser = z.object({ action: z.string(), - inputs: z.record(z.string(), zJsonValue2), + inputs: z.record(z.string(), zJsonValue), user: z.string(), }) @@ -1784,6 +1774,16 @@ export const zModelPropertyKey = z.enum([ 'word_limit', ]) +/** + * I18nObject + * + * Model class for i18n object. + */ +export const zModelRuntimeI18nObject = z.object({ + en_US: z.string(), + zh_Hans: z.string().nullish(), +}) + /** * ModelStatus * @@ -1992,7 +1992,7 @@ export const zProviderModelWithStatusEntity = z.object({ features: z.array(zModelFeature).nullish(), fetch_from: zFetchFrom, has_invalid_load_balancing_configs: z.boolean().optional().default(false), - label: zI18nObject, + label: zModelRuntimeI18nObject, load_balancing_enabled: z.boolean().optional().default(false), model: z.string(), model_properties: z.record(z.string(), z.unknown()), @@ -2042,9 +2042,9 @@ export const zProviderQuotaExceededError = zErrorResponse.and( * Model class for provider with models response. */ export const zProviderWithModelsResponse = z.object({ - icon_small: zI18nObject.nullish(), - icon_small_dark: zI18nObject.nullish(), - label: zI18nObject, + icon_small: zModelRuntimeI18nObject.nullish(), + icon_small_dark: zModelRuntimeI18nObject.nullish(), + label: zModelRuntimeI18nObject, models: z.array(zProviderModelWithStatusEntity), provider: z.string(), status: zCustomConfigurationStatus, @@ -2301,7 +2301,7 @@ export const zSimpleAccountResponse = z.object({ export const zSimpleConversation = z.object({ created_at: z.int().nullish(), id: z.uuid(), - inputs: z.record(z.string(), zJsonValue), + inputs: z.record(z.string(), zAnyJsonValue), introduction: z.string().nullish(), name: z.string(), status: z.string(), diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index e0f67332c0d02a..8cfa2e0e530c8f 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -39,9 +39,11 @@ export type AgentThought = { thought?: string | null tool?: string | null tool_input?: string | null - tool_labels: JsonValue + tool_labels: AnyJsonValue } +export type AnyJsonValue = unknown + export type AppAccessModeQuery = { appCode?: string | null appId?: string | null @@ -306,7 +308,7 @@ export type FormInputConfig = type: 'file-list' } & FileListInputConfig) -export type GeneratedAppResponse = JsonValue +export type GeneratedAppResponse = ShallowJsonValue export type HumanInputContent = { form_definition?: HumanInputFormDefinition | null @@ -358,14 +360,14 @@ export type HumanInputFormSubmissionData = { node_title: string rendered_content: string submitted_data?: { - [key: string]: JsonValue2 + [key: string]: JsonValue } | null } export type HumanInputFormSubmitPayload = { action: string inputs: { - [key: string]: JsonValue2 + [key: string]: JsonValue } } @@ -408,20 +410,9 @@ export type InvalidUploadTokenError = ErrorResponse & { status: 403 } -export type JsonValue = - | string - | number - | number - | boolean - | { - [key: string]: unknown - } - | Array - | null - export type JsonValueType = unknown -export type JsonValue2 = unknown +export type JsonValue = unknown export type LicenseStatus = 'active' | 'expired' | 'expiring' | 'inactive' | 'lost' | 'none' @@ -992,11 +983,22 @@ export type SelectInputConfig = { type?: 'select' } +export type ShallowJsonValue = + | string + | number + | number + | boolean + | { + [key: string]: unknown + } + | Array + | null + export type SimpleConversation = { created_at?: number | null id: string inputs: { - [key: string]: JsonValue + [key: string]: AnyJsonValue } introduction?: string | null name: string @@ -1248,7 +1250,7 @@ export type WorkflowRunPayload = { } } -export type GeneratedAppResponseWritable = JsonValue +export type GeneratedAppResponseWritable = ShallowJsonValue export type HumanInputFormSubmitResponseWritable = { [key: string]: unknown diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index 24a2a4a27a368c..a886560a00e0fe 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -24,6 +24,26 @@ export const zAccessTokenResultResponse = z.object({ result: z.string(), }) +export const zAnyJsonValue = z.unknown() + +/** + * AgentThought + */ +export const zAgentThought = z.object({ + answer: z.string().nullish(), + chain_id: z.string().nullish(), + created_at: z.int().nullish(), + files: z.array(z.string()), + id: z.string(), + message_id: z.string(), + observation: z.string().nullish(), + position: z.int(), + thought: z.string().nullish(), + tool: z.string().nullish(), + tool_input: z.string().nullish(), + tool_labels: zAnyJsonValue, +}) + /** * AppAccessModeQuery */ @@ -563,43 +583,9 @@ export const zInvalidUploadTokenError = zErrorResponse.and( }), ) -export const zJsonValue = z - .union([ - z.string(), - z.int(), - z.number(), - z.boolean(), - z.record(z.string(), z.unknown()), - z.array(z.unknown()), - ]) - .nullable() - -/** - * AgentThought - */ -export const zAgentThought = z.object({ - answer: z.string().nullish(), - chain_id: z.string().nullish(), - created_at: z.int().nullish(), - files: z.array(z.string()), - id: z.string(), - message_id: z.string(), - observation: z.string().nullish(), - position: z.int(), - thought: z.string().nullish(), - tool: z.string().nullish(), - tool_input: z.string().nullish(), - tool_labels: zJsonValue, -}) - -/** - * GeneratedAppResponse - */ -export const zGeneratedAppResponse = zJsonValue - export const zJsonValueType = z.unknown() -export const zJsonValue2 = z.unknown() +export const zJsonValue = z.unknown() /** * HumanInputFormSubmissionData @@ -610,7 +596,7 @@ export const zHumanInputFormSubmissionData = z.object({ node_id: z.string(), node_title: z.string(), rendered_content: z.string(), - submitted_data: z.record(z.string(), zJsonValue2).nullish(), + submitted_data: z.record(z.string(), zJsonValue).nullish(), }) /** @@ -618,7 +604,7 @@ export const zHumanInputFormSubmissionData = z.object({ */ export const zHumanInputFormSubmitPayload = z.object({ action: z.string(), - inputs: z.record(z.string(), zJsonValue2), + inputs: z.record(z.string(), zJsonValue), }) /** @@ -1026,13 +1012,29 @@ export const zSavedMessageListQuery = z.object({ limit: z.int().gte(1).lte(100).optional().default(20), }) +export const zShallowJsonValue = z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + ]) + .nullable() + +/** + * GeneratedAppResponse + */ +export const zGeneratedAppResponse = zShallowJsonValue + /** * SimpleConversation */ export const zSimpleConversation = z.object({ created_at: z.int().nullish(), id: z.uuid(), - inputs: z.record(z.string(), zJsonValue), + inputs: z.record(z.string(), zAnyJsonValue), introduction: z.string().nullish(), name: z.string(), status: z.string(), @@ -1861,7 +1863,7 @@ export const zWorkflowRunPayload = z.object({ /** * GeneratedAppResponse */ -export const zGeneratedAppResponseWritable = zJsonValue +export const zGeneratedAppResponseWritable = zShallowJsonValue /** * HumanInputFormSubmitResponse diff --git a/web/app/components/app/log/model-info.tsx b/web/app/components/app/log/model-info.tsx index fff5f044670b88..d49ab71baa55e1 100644 --- a/web/app/components/app/log/model-info.tsx +++ b/web/app/components/app/log/model-info.tsx @@ -1,5 +1,4 @@ 'use client' -import type { ModelConfig } from '@dify/contracts/api/console/apps/types.gen' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' @@ -19,8 +18,14 @@ const PARAM_MAP = { frequency_penalty: 'Frequency Penalty', } +type LogModelConfig = { + name: string + provider: string + completion_params?: Record +} + type Props = Readonly<{ - model: Pick + model: LogModelConfig }> const ModelInfo: FC = ({ model }) => { From 8f6d92949aa5dcdd9235945bb77faba2f70617b0 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:19:27 +0800 Subject: [PATCH 02/60] refactor(web): migrate log queries to the generated console contract Replace the forwarding hooks in service/use-log.ts with direct consoleQuery calls at each call site and type the log pages with the generated contract instead of handwritten DTOs. - Conversation list/detail, workflow logs, pause details and annotation count now use consoleQuery queryOptions; use-log.ts is removed. - Untyped JSON fields (model_config.model, user_input_form, details) are narrowed at the boundary in list-utils instead of being read as any. - Read the conversation introduction from the top-level field the API actually returns, and skip null backstage input URLs. Co-Authored-By: Claude Opus 5.5 --- oxlint-suppressions.json | 10 +- .../app/annotation/__tests__/filter.spec.tsx | 51 +++-- web/app/components/app/annotation/filter.tsx | 9 +- .../app/log/__tests__/filter.spec.tsx | 33 ++-- .../app/log/__tests__/index.spec.tsx | 60 ++++-- .../app/log/__tests__/list.spec.tsx | 53 +++-- web/app/components/app/log/filter.tsx | 16 +- web/app/components/app/log/index.tsx | 35 ++-- web/app/components/app/log/list-utils.ts | 62 ++++-- web/app/components/app/log/list.tsx | 120 +++++++----- .../workflow-log/__tests__/filter.spec.tsx | 2 +- .../app/workflow-log/__tests__/index.spec.tsx | 99 +++++----- .../app/workflow-log/__tests__/list.spec.tsx | 23 ++- .../components/app/workflow-log/filter.tsx | 2 +- web/app/components/app/workflow-log/index.tsx | 20 +- web/app/components/app/workflow-log/list.tsx | 59 +++--- .../workflow/run/__tests__/status.spec.tsx | 43 +++-- web/app/components/workflow/run/status.tsx | 17 +- web/models/log.ts | 182 +----------------- web/service/use-log.ts | 120 ------------ 20 files changed, 436 insertions(+), 580 deletions(-) delete mode 100644 web/service/use-log.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 41243788e1fe81..0609dba28903e2 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -474,7 +474,7 @@ "count": 2 }, "typescript/no-explicit-any": { - "count": 5 + "count": 3 } }, "web/app/components/app/log/var-panel.tsx": { @@ -4255,14 +4255,6 @@ "count": 1 } }, - "web/service/use-log.ts": { - "@tanstack/query/prefer-query-options": { - "count": 7 - }, - "no-restricted-imports": { - "count": 1 - } - }, "web/service/use-models.ts": { "@tanstack/query/prefer-query-options": { "count": 3 diff --git a/web/app/components/app/annotation/__tests__/filter.spec.tsx b/web/app/components/app/annotation/__tests__/filter.spec.tsx index efc9bb1eb88e82..9733c5ddd4f662 100644 --- a/web/app/components/app/annotation/__tests__/filter.spec.tsx +++ b/web/app/components/app/annotation/__tests__/filter.spec.tsx @@ -1,16 +1,33 @@ import type { AnnotationCountResponse } from '@dify/contracts/api/console/apps/types.gen' import type { UseQueryResult } from '@tanstack/react-query' -import type { Mock } from 'vite-plus/test' import type { QueryParam } from '../filter' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import * as useLogModule from '@/service/use-log' import Filter from '../filter' -vi.mock('@/service/use-log') - -const mockUseAnnotationsCount = useLogModule.useAnnotationsCount as Mock +const mockUseQuery = vi.fn() + +vi.mock('@tanstack/react-query', async (importOriginal) => ({ + ...(await importOriginal()), + useQuery: (options: unknown) => mockUseQuery(options), +})) + +vi.mock('@/service/console', () => ({ + consoleQuery: { + apps: { + byAppId: { + annotations: { + count: { + get: { + queryOptions: (options: unknown) => options, + }, + }, + }, + }, + }, + }, +})) // ============================================================================ // Test Utilities @@ -65,7 +82,7 @@ describe('Filter', () => { describe('Rendering', () => { it('should render nothing when data is loading', () => { // Arrange - mockUseAnnotationsCount.mockReturnValue( + mockUseQuery.mockReturnValue( createMockQueryResult({ isLoading: true }), ) @@ -82,7 +99,7 @@ describe('Filter', () => { it('should render nothing when data is undefined', () => { // Arrange - mockUseAnnotationsCount.mockReturnValue( + mockUseQuery.mockReturnValue( createMockQueryResult({ data: undefined, isLoading: false }), ) @@ -99,7 +116,7 @@ describe('Filter', () => { it('should render filter and children when data is available', () => { // Arrange - mockUseAnnotationsCount.mockReturnValue( + mockUseQuery.mockReturnValue( createMockQueryResult({ data: { count: 20 }, isLoading: false, @@ -123,9 +140,9 @@ describe('Filter', () => { // Props Tests (REQUIRED) // -------------------------------------------------------------------------- describe('Props', () => { - it('should call useAnnotationsCount with appId', () => { + it('should query the annotations count for appId', () => { // Arrange - mockUseAnnotationsCount.mockReturnValue( + mockUseQuery.mockReturnValue( createMockQueryResult({ data: { count: 10 }, isLoading: false, @@ -140,12 +157,12 @@ describe('Filter', () => { ) // Assert - expect(mockUseAnnotationsCount).toHaveBeenCalledWith(appId) + expect(mockUseQuery).toHaveBeenCalledWith({ input: { params: { app_id: appId } } }) }) it('should display keyword value in input', () => { // Arrange - mockUseAnnotationsCount.mockReturnValue( + mockUseQuery.mockReturnValue( createMockQueryResult({ data: { count: 10 }, isLoading: false, @@ -173,7 +190,7 @@ describe('Filter', () => { describe('User Interactions', () => { it('should call setQueryParams when typing in search input', () => { // Arrange - mockUseAnnotationsCount.mockReturnValue( + mockUseQuery.mockReturnValue( createMockQueryResult({ data: { count: 20 }, isLoading: false, @@ -198,7 +215,7 @@ describe('Filter', () => { it('should call setQueryParams with empty keyword when clearing input', () => { // Arrange - mockUseAnnotationsCount.mockReturnValue( + mockUseQuery.mockReturnValue( createMockQueryResult({ data: { count: 20 }, isLoading: false, @@ -227,7 +244,7 @@ describe('Filter', () => { describe('Edge Cases', () => { it('should handle empty keyword in queryParams', () => { // Arrange - mockUseAnnotationsCount.mockReturnValue( + mockUseQuery.mockReturnValue( createMockQueryResult({ data: { count: 5 }, isLoading: false, @@ -247,7 +264,7 @@ describe('Filter', () => { it('should handle undefined keyword in queryParams', () => { // Arrange - mockUseAnnotationsCount.mockReturnValue( + mockUseQuery.mockReturnValue( createMockQueryResult({ data: { count: 5 }, isLoading: false, @@ -267,7 +284,7 @@ describe('Filter', () => { it('should handle zero count', () => { // Arrange - mockUseAnnotationsCount.mockReturnValue( + mockUseQuery.mockReturnValue( createMockQueryResult({ data: { count: 0 }, isLoading: false, diff --git a/web/app/components/app/annotation/filter.tsx b/web/app/components/app/annotation/filter.tsx index 7fd53304c528cd..f7b0c541dc0f31 100644 --- a/web/app/components/app/annotation/filter.tsx +++ b/web/app/components/app/annotation/filter.tsx @@ -1,9 +1,10 @@ 'use client' import type { FC } from 'react' +import { skipToken, useQuery } from '@tanstack/react-query' import * as React from 'react' import { useTranslation } from 'react-i18next' import { SearchInput } from '@/app/components/base/search-input' -import { useAnnotationsCount } from '@/service/use-log' +import { consoleQuery } from '@/service/console' export type QueryParam = { keyword?: string @@ -17,7 +18,11 @@ type IFilterProps = { } const Filter: FC = ({ appId, queryParams, setQueryParams, children }) => { - const { data, isLoading } = useAnnotationsCount(appId) + const { data, isLoading } = useQuery( + consoleQuery.apps.byAppId.annotations.count.get.queryOptions({ + input: appId ? { params: { app_id: appId } } : skipToken, + }), + ) const { t } = useTranslation(['common']) if (isLoading || !data) return null return ( diff --git a/web/app/components/app/log/__tests__/filter.spec.tsx b/web/app/components/app/log/__tests__/filter.spec.tsx index fda389b312f779..f47249346dcb09 100644 --- a/web/app/components/app/log/__tests__/filter.spec.tsx +++ b/web/app/components/app/log/__tests__/filter.spec.tsx @@ -27,19 +27,30 @@ const render = (ui: ReactElement) => { queryFn: () => new Promise(() => {}), }) } else seedFeatures(queryClient, { billing: { subscription: { plan: scenario.planType } } }) + const annotationsCountQueryKey = consoleQuery.apps.byAppId.annotations.count.get.queryKey({ + input: { params: { app_id: 'test-app-id' } }, + }) + if (mockAnnotationsCountLoading) { + void queryClient.query({ + queryKey: annotationsCountQueryKey, + queryFn: () => new Promise(() => {}), + }) + } else if (mockAnnotationsCountData) { + queryClient.setQueryData(annotationsCountQueryKey, mockAnnotationsCountData) + } else { + void queryClient + .query({ + queryKey: annotationsCountQueryKey, + queryFn: () => Promise.reject(new Error('annotations count unavailable')), + }) + .catch(() => {}) + } return renderWithConsoleQuery(ui, { queryClient, systemFeatures: { deployment_edition: scenario.deploymentEdition }, }) } -vi.mock('@/service/use-log', () => ({ - useAnnotationsCount: () => ({ - data: mockAnnotationsCountData, - isLoading: mockAnnotationsCountLoading, - }), -})) - vi.mock('@/app/components/base/chip', async () => { const { useState } = await import('react') @@ -350,7 +361,7 @@ describe('Filter', () => { it('should display annotated status with count when annotation_status is annotated', () => { const propsWithAnnotation = { ...defaultProps, - queryParams: { ...defaultQueryParams, annotation_status: 'annotated' }, + queryParams: { ...defaultQueryParams, annotation_status: 'annotated' as const }, } render() @@ -363,7 +374,7 @@ describe('Filter', () => { it('should display not_annotated status when annotation_status is not_annotated', () => { const propsWithNotAnnotated = { ...defaultProps, - queryParams: { ...defaultQueryParams, annotation_status: 'not_annotated' }, + queryParams: { ...defaultQueryParams, annotation_status: 'not_annotated' as const }, } render() @@ -394,7 +405,7 @@ describe('Filter', () => { const propsWithSort = { ...defaultProps, isChatMode: true, - queryParams: { ...defaultQueryParams, sort_by: 'created_at' }, + queryParams: { ...defaultQueryParams, sort_by: 'created_at' as const }, } render() @@ -408,7 +419,7 @@ describe('Filter', () => { const propsWithDescSort = { ...defaultProps, isChatMode: true, - queryParams: { ...defaultQueryParams, sort_by: '-created_at' }, + queryParams: { ...defaultQueryParams, sort_by: '-created_at' as const }, } render() diff --git a/web/app/components/app/log/__tests__/index.spec.tsx b/web/app/components/app/log/__tests__/index.spec.tsx index 99f9894307c2e4..3e0d9749f22cdf 100644 --- a/web/app/components/app/log/__tests__/index.spec.tsx +++ b/web/app/components/app/log/__tests__/index.spec.tsx @@ -35,13 +35,31 @@ vi.mock('@/next/navigation', () => ({ }), })) -vi.mock('@/service/use-log', () => ({ - useChatConversations: (...args: unknown[]) => mockUseChatConversations(...args), - useCompletionConversations: (...args: unknown[]) => mockUseCompletionConversations(...args), - useAnnotationsCount: () => ({ - data: { count: 0 }, - isLoading: false, - }), +vi.mock('@/service/console', () => { + const tagged = (operation: string) => ({ + queryOptions: (options: Record) => ({ ...options, operation }), + }) + return { + consoleQuery: { + apps: { + byAppId: { + chatConversations: { get: tagged('chatConversations') }, + completionConversations: { get: tagged('completionConversations') }, + annotations: { count: { get: tagged('annotationsCount') } }, + }, + }, + }, + } +}) + +vi.mock('@tanstack/react-query', async (importOriginal) => ({ + ...(await importOriginal()), + useQuery: ({ operation, ...options }: { operation?: string }) => { + if (operation === 'chatConversations') return mockUseChatConversations(options) + if (operation === 'completionConversations') return mockUseCompletionConversations(options) + if (operation === 'annotationsCount') return { data: { count: 0 }, isLoading: false } + return { data: undefined } + }, })) vi.mock('../cloud-sandbox-retention', async (importOriginal) => { @@ -102,7 +120,7 @@ describe('Logs', () => { expect(mockUseChatConversations).toHaveBeenCalledWith( expect.objectContaining({ - appId: 'app-1', + input: expect.objectContaining({ params: { app_id: 'app-1' } }), }), ) expect(screen.getByRole('heading', { name: /(?:^|\.)title(?=$|:)/ })).toBeInTheDocument() @@ -133,7 +151,7 @@ describe('Logs', () => { expect(mockUseCompletionConversations).toHaveBeenCalledWith( expect.objectContaining({ - appId: 'app-2', + input: expect.objectContaining({ params: { app_id: 'app-2' } }), }), ) expect(screen.getByText('empty-logs')).toBeInTheDocument() @@ -188,9 +206,11 @@ describe('Logs', () => { ).toBeInTheDocument() expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ - params: expect.objectContaining({ - start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm'), - end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'), + input: expect.objectContaining({ + query: expect.objectContaining({ + start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm'), + end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'), + }), }), }), ) @@ -213,9 +233,11 @@ describe('Logs', () => { await user.click(await screen.findByText(/appLog\.filter\.period\.allTime/)) expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ - params: expect.not.objectContaining({ - start: expect.anything(), - end: expect.anything(), + input: expect.objectContaining({ + query: expect.not.objectContaining({ + start: expect.anything(), + end: expect.anything(), + }), }), }), ) @@ -234,9 +256,11 @@ describe('Logs', () => { ).toBeInTheDocument() expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ - params: expect.objectContaining({ - start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'), - end: expect.any(String), + input: expect.objectContaining({ + query: expect.objectContaining({ + start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'), + end: expect.any(String), + }), }), }), ) diff --git a/web/app/components/app/log/__tests__/list.spec.tsx b/web/app/components/app/log/__tests__/list.spec.tsx index 58fa91eb8efd47..c893f3f205dc77 100644 --- a/web/app/components/app/log/__tests__/list.spec.tsx +++ b/web/app/components/app/log/__tests__/list.spec.tsx @@ -35,15 +35,20 @@ vi.mock('@/hooks/use-breakpoints', () => ({ }, })) -vi.mock('@/service/use-log', () => ({ - useChatConversationDetail: () => ({ - data: mockChatConversationDetail, - }), - useCompletionConversationDetail: () => ({ - data: mockCompletionConversationDetail, - refetch: mockCompletionRefetch, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useQuery: (options: Parameters[0]) => { + const [path] = options.queryKey as [string[]] + if (Array.isArray(path) && path.includes('byConversationId')) { + if (path.includes('chatConversations')) return { data: mockChatConversationDetail } + return { data: mockCompletionConversationDetail, refetch: mockCompletionRefetch } + } + return actual.useQuery(options) + }, + } +}) vi.mock('@/service/log', () => ({ fetchChatMessages: (...args: unknown[]) => mockFetchChatMessages(...args), @@ -60,7 +65,9 @@ vi.mock('@/app/components/base/loading-placeholder', () => ({ })) vi.mock('@/app/components/app/log/model-info', () => ({ - default: ({ model }: { model: string }) =>
{model}
, + default: ({ model }: { model: { name: string } }) => ( +
{model.name}
+ ), })) vi.mock('@/app/components/app/log/var-panel', () => ({ @@ -401,11 +408,9 @@ describe('ConversationList', () => { mockChatConversationDetail = { id: 'conversation-1', created_at: 1710000000, + introduction: 'Hello there', model_config: { - model: 'gpt-4o', - configs: { - introduction: 'Hello there', - }, + model: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} }, user_input_form: [], }, message: { @@ -443,11 +448,9 @@ describe('ConversationList', () => { mockChatConversationDetail = { id: 'conversation-1', created_at: 1710000000, + introduction: 'Hello there', model_config: { - model: 'gpt-4o', - configs: { - introduction: 'Hello there', - }, + model: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} }, user_input_form: [ { query: { @@ -525,11 +528,9 @@ describe('ConversationList', () => { mockChatConversationDetail = { id: 'conversation-1', created_at: 1710000000, + introduction: 'Hello there', model_config: { - model: 'gpt-4o', - configs: { - introduction: 'Hello there', - }, + model: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} }, user_input_form: [], }, message: { @@ -554,7 +555,7 @@ describe('ConversationList', () => { id: 'conversation-1', created_at: 1710000000, model_config: { - model: 'gpt-4o-mini', + model: { provider: 'openai', name: 'gpt-4o-mini', mode: 'chat', completion_params: {} }, user_input_form: [ { query: { @@ -672,11 +673,9 @@ describe('ConversationList', () => { mockChatConversationDetail = { id: 'conversation-1', created_at: 1710000000, + introduction: 'Hello there', model_config: { - model: 'gpt-4o', - configs: { - introduction: 'Hello there', - }, + model: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} }, user_input_form: [ { query: { diff --git a/web/app/components/app/log/filter.tsx b/web/app/components/app/log/filter.tsx index d75e54078c512e..49b6d6c9ef631d 100644 --- a/web/app/components/app/log/filter.tsx +++ b/web/app/components/app/log/filter.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react' import type { QueryParam } from './index' import type { I18nKeysByPrefix } from '@/types/i18n' import { RiCalendarLine } from '@remixicon/react' +import { skipToken, useQuery } from '@tanstack/react-query' import dayjs from 'dayjs' import quarterOfYear from 'dayjs/plugin/quarterOfYear' import * as React from 'react' @@ -10,7 +11,7 @@ import { useTranslation } from 'react-i18next' import Chip from '@/app/components/base/chip' import { SearchInput } from '@/app/components/base/search-input' import Sort from '@/app/components/base/sort' -import { useAnnotationsCount } from '@/service/use-log' +import { consoleQuery } from '@/service/console' import { CLOUD_SANDBOX_CLEARED_TIME_PERIOD, CLOUD_SANDBOX_TIME_PERIOD_KEYS, @@ -50,7 +51,11 @@ const Filter: FC = ({ queryParams, setQueryParams, }: IFilterProps) => { - const { data, isLoading } = useAnnotationsCount(appId) + const { data, isLoading } = useQuery( + consoleQuery.apps.byAppId.annotations.count.get.queryOptions({ + input: appId ? { params: { app_id: appId } } : skipToken, + }), + ) const { t } = useTranslation(['appLog', 'common']) const planState = useCloudSandboxPlanStatus() const isTimePeriodRestricted = isLogTimePeriodRestricted(planState) @@ -86,7 +91,10 @@ const Filter: FC = ({ showLeftIcon={false} value={queryParams.annotation_status || 'all'} onSelect={(item) => { - setQueryParams({ ...queryParams, annotation_status: item.value as string }) + setQueryParams({ + ...queryParams, + annotation_status: item.value as QueryParam['annotation_status'], + }) }} onClear={() => setQueryParams({ ...queryParams, annotation_status: 'all' })} items={[ @@ -123,7 +131,7 @@ const Filter: FC = ({ }, ]} onSelect={(value) => { - setQueryParams({ ...queryParams, sort_by: value as string }) + setQueryParams({ ...queryParams, sort_by: value as QueryParam['sort_by'] }) }} /> diff --git a/web/app/components/app/log/index.tsx b/web/app/components/app/log/index.tsx index 6584fe1acb27c6..a307f0eb1fd9e8 100644 --- a/web/app/components/app/log/index.tsx +++ b/web/app/components/app/log/index.tsx @@ -1,8 +1,12 @@ 'use client' -import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' +import type { + AppDetailWithSite, + GetAppsByAppIdChatConversationsData, +} from '@dify/contracts/api/console/apps/types.gen' import type { FC } from 'react' import { Pagination } from '@langgenius/dify-ui/pagination' +import { skipToken, useQuery } from '@tanstack/react-query' import { useDebounce } from 'ahooks' import dayjs from 'dayjs' import { omit } from 'es-toolkit/object' @@ -13,7 +17,7 @@ import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { APP_PAGE_LIMIT } from '@/config' import { useDocLink } from '@/context/i18n' import { usePathname, useRouter, useSearchParams } from '@/next/navigation' -import { useChatConversations, useCompletionConversations } from '@/service/use-log' +import { consoleQuery } from '@/service/console' import { AppModeEnum } from '@/types/app' import PageTitle from '../log-annotation/page-title' import { @@ -30,11 +34,13 @@ type ILogsProps = { appDetail: AppDetailWithSite } +type ConversationsQuery = NonNullable + export type QueryParam = { period: string - annotation_status?: string + annotation_status?: ConversationsQuery['annotation_status'] keyword?: string - sort_by?: string + sort_by?: ConversationsQuery['sort_by'] } const defaultQueryParams: QueryParam = { @@ -115,16 +121,17 @@ const Logs: FC = ({ appDetail }) => { } // When the details are obtained, proceed to the next request - const { data: chatConversations, refetch: mutateChatList } = useChatConversations({ - appId: isChatMode ? appDetail.id : '', - params: query, - }) - - const { data: completionConversations, refetch: mutateCompletionList } = - useCompletionConversations({ - appId: !isChatMode ? appDetail.id : '', - params: query, - }) + const { data: chatConversations, refetch: mutateChatList } = useQuery( + consoleQuery.apps.byAppId.chatConversations.get.queryOptions({ + input: isChatMode ? { params: { app_id: appDetail.id }, query } : skipToken, + }), + ) + + const { data: completionConversations, refetch: mutateCompletionList } = useQuery( + consoleQuery.apps.byAppId.completionConversations.get.queryOptions({ + input: !isChatMode ? { params: { app_id: appDetail.id }, query } : skipToken, + }), + ) const total = isChatMode ? chatConversations?.total : completionConversations?.total const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1 diff --git a/web/app/components/app/log/list-utils.ts b/web/app/components/app/log/list-utils.ts index c776ca9345ce03..63a3c6bcfd1b4d 100644 --- a/web/app/components/app/log/list-utils.ts +++ b/web/app/components/app/log/list-utils.ts @@ -1,11 +1,12 @@ import type { ChatItemInTree } from '../../base/chat/types' -import type { IChatItem } from '@/app/components/base/chat/chat/type' +import type { FeedbackType, IChatItem } from '@/app/components/base/chat/chat/type' import type { Annotation, ChatMessage } from '@/models/log' import type { FileResponse } from '@/types/workflow' import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import { get } from 'es-toolkit/compat' +import { isPlainObject } from 'es-toolkit/predicate' import { buildChatItemTree, getThreadMessages } from '@/app/components/base/chat/utils' import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' @@ -15,26 +16,27 @@ dayjs.extend(timezone) type LogDetailMessage = { answer?: string | number | null - inputs?: Record - message_files?: Array<{ url?: string }> + inputs?: Record + message_files?: Array<{ url?: string | null }> query?: string } -type UserInputFormItem = Record - type ConversationLogDetail = { - from_account_name?: string - from_end_user_session_id?: string - message?: LogDetailMessage + from_account_name?: string | null + from_end_user_session_id?: string | null + message?: LogDetailMessage | null message_count?: number model_config?: { - user_input_form?: UserInputFormItem[] - } + // Untyped JSON in the console API response. + user_input_form?: unknown + } | null name?: string } -const getUserInputVariable = (item: UserInputFormItem) => { - const variable = Object.values(item)[0]?.variable +const getUserInputVariable = (item: unknown) => { + if (!isPlainObject(item)) return undefined + const field = Object.values(item)[0] + const variable = isPlainObject(field) ? field.variable : undefined return typeof variable === 'string' ? variable : undefined } @@ -305,10 +307,12 @@ export const getConversationRowValues = ({ noOutputLabel: string }) => { const endUser = log.from_end_user_session_id || log.from_account_name - const leftValue = + const rawLeftValue: unknown = get(log, isChatMode ? 'name' : 'message.inputs.query') || (!isChatMode ? get(log, 'message.query') || get(log, 'message.inputs.default_input') : '') || '' + const leftValue = + typeof rawLeftValue === 'string' || typeof rawLeftValue === 'number' ? rawLeftValue : '' const rightValue = get(log, isChatMode ? 'message_count' : 'message.answer') return { @@ -323,8 +327,11 @@ export const getConversationRowValues = ({ export const getDetailVarList = ( detail: ConversationLogDetail, varValues: Record, -) => - detail.model_config?.user_input_form?.flatMap((item) => { +) => { + const userInputForm = detail.model_config?.user_input_form + if (!Array.isArray(userInputForm)) return [] + + return userInputForm.flatMap((item) => { const variable = getUserInputVariable(item) if (!variable) return [] @@ -339,7 +346,8 @@ export const getDetailVarList = ( value, }, ] - }) || [] + }) +} export const getCompletionMessageFiles = (detail: ConversationLogDetail, isChatMode: boolean) => { const messageFiles = detail.message?.message_files @@ -348,3 +356,25 @@ export const getCompletionMessageFiles = (detail: ConversationLogDetail, isChatM return messageFiles.flatMap((item) => (item.url ? [item.url] : [])) } + +// `model_config.model` is untyped JSON in the console API response. +export const getLogModelInfo = (model: unknown) => { + if (!isPlainObject(model)) return undefined + const { name, provider, completion_params } = model + if (typeof name !== 'string' || typeof provider !== 'string') return undefined + + return { + name, + provider, + completion_params: isPlainObject(completion_params) ? completion_params : undefined, + } +} + +export const getAdminFeedback = ( + feedbacks: Array<{ content?: string | null; from_source: string; rating: string }>, +): FeedbackType | undefined => { + const feedback = feedbacks.find((item) => item.from_source === 'admin') + if (feedback?.rating !== 'like' && feedback?.rating !== 'dislike') return undefined + + return { rating: feedback.rating, content: feedback.content } +} diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 6265ceced0d920..737308696b4a6f 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -1,6 +1,14 @@ 'use client' -import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' +import type { + AppDetailWithSite, + Conversation, + ConversationDetail, + ConversationMessageDetail, + ConversationPagination, + ConversationWithSummary, + ConversationWithSummaryPagination, +} from '@dify/contracts/api/console/apps/types.gen' import type { FC } from 'react' import type { ChatItemInTree } from '../../base/chat/types' import type { @@ -9,14 +17,7 @@ import type { IChatItem, SubmitAnnotationFunc, } from '@/app/components/base/chat/chat/type' -import type { - ChatConversationGeneralDetail, - ChatConversationsResponse, - ChatMessagesRequest, - CompletionConversationGeneralDetail, - CompletionConversationsResponse, - LogAnnotation, -} from '@/models/log' +import type { ChatMessagesRequest, LogAnnotation } from '@/models/log' import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline' import { cn } from '@langgenius/dify-ui/cn' import { @@ -33,7 +34,7 @@ import { IconButton } from '@langgenius/dify-ui/icon-button' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiCloseLine, RiEditFill } from '@remixicon/react' -import { useQuery } from '@tanstack/react-query' +import { skipToken, useQuery } from '@tanstack/react-query' import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' @@ -54,13 +55,13 @@ import { toast } from '@/app/notifications' import { userProfileQueryOptions } from '@/features/account-profile/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' +import { consoleQuery } from '@/service/console' import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks, } from '@/service/log' import { AppSourceType } from '@/service/share' -import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log' import { AppModeEnum } from '@/types/app' import PromptLogModal from '../../base/prompt-log-modal' import { @@ -68,10 +69,12 @@ import { applyAnnotationEdited, applyAnnotationRemoved, buildChatThreadState, + getAdminFeedback, getCompletionMessageFiles, getConversationRowValues, getDetailVarList, getFormattedChatList, + getLogModelInfo, getThreadChatItems, isNearTopLoadMore, mergePaginatedChatItems, @@ -79,14 +82,14 @@ import { } from './list-utils' import VarPanel from './var-panel' -type ConversationListItem = ChatConversationGeneralDetail | CompletionConversationGeneralDetail +type ConversationListItem = ConversationWithSummary | Conversation type ConversationSelection = ConversationListItem | { id: string; isPlaceholder?: true } dayjs.extend(utc) dayjs.extend(timezone) type IConversationList = { - logs?: ChatConversationsResponse | CompletionConversationsResponse + logs?: ConversationWithSummaryPagination | ConversationPagination appDetail: AppDetailWithSite onRefresh: () => void } @@ -157,7 +160,7 @@ const statusTdRender = (statusCount: StatusCount) => { type IDetailPanel = { appDetail: AppDetailWithSite - detail: any + detail: ConversationDetail | ConversationMessageDetail onClose: () => void onFeedback: FeedbackFunc onSubmitAnnotation: SubmitAnnotationFunc @@ -247,12 +250,12 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { const nextThreadState = buildChatThreadState({ allChatItems, hasMore, - introduction: detail?.model_config?.configs?.introduction, + introduction: 'introduction' in detail ? (detail.introduction ?? undefined) : undefined, }) setChatItemTree(nextThreadState.chatItemTree) setThreadChatItems(nextThreadState.threadChatItems) if (nextThreadState.oldestAnswerId) oldestAnswerIdRef.current = nextThreadState.oldestAnswerId - }, [allChatItems, hasMore, detail?.model_config?.configs?.introduction]) + }, [allChatItems, hasMore, detail]) const switchSibling = useCallback( (siblingMessageId: string) => { @@ -412,6 +415,8 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { const varList = getDetailVarList(detail, varValues) const message_files = getCompletionMessageFiles(detail, isChatMode) + const completionMessage = 'message' in detail ? detail.message : undefined + const modelInfo = getLogModelInfo(detail.model_config?.model) const [width, setWidth] = useState(0) const ref = useRef(null) @@ -449,15 +454,17 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { )} {!isChatMode && (
- {formatTime( - detail.created_at, - t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, - )} + {detail.created_at + ? formatTime( + detail.created_at, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + ) + : defaultValue}
)}
- {!isAdvanced && } + {!isAdvanced && modelInfo && }
{!isChatMode ? ( -
-
-
- {t(($) => $['table.header.output'], { ns: 'appLog' })} + completionMessage && ( +
+
+
+ {t(($) => $['table.header.output'], { ns: 'appLog' })} +
+
-
{ + onFeedback(completionMessage.id, feedback).catch(noop) }} - >
+ isShowTextToSpeech + siteInfo={null} + />
- item.from_source === 'admin')} - onFeedback={(feedback) => { - onFeedback(detail.message.id, feedback).catch(noop) - }} - isShowTextToSpeech - siteInfo={null} - /> -
+ ) ) : threadChatItems.length < MIN_ITEMS_FOR_SCROLL_LOADING ? (
= ({ onClose, }) => { // Text Generator App Session Details Including Message List - const { data: conversationDetail, refetch: conversationDetailMutate } = - useCompletionConversationDetail(appDetail.id, conversationId) + const { data: conversationDetail, refetch: conversationDetailMutate } = useQuery( + consoleQuery.apps.byAppId.completionConversations.byConversationId.get.queryOptions({ + input: conversationId + ? { params: { app_id: appDetail.id, conversation_id: conversationId } } + : skipToken, + }), + ) const { t } = useTranslation(['appLog', 'common']) const handleFeedback = async (mid: string, { rating, content }: FeedbackType) => { @@ -687,7 +701,13 @@ const ChatConversationDetailComp: FC = ({ conversationId, onClose, }) => { - const { data: conversationDetail } = useChatConversationDetail(appDetail.id, conversationId) + const { data: conversationDetail } = useQuery( + consoleQuery.apps.byAppId.chatConversations.byConversationId.get.queryOptions({ + input: conversationId + ? { params: { app_id: appDetail.id, conversation_id: conversationId } } + : skipToken, + }), + ) const { t } = useTranslation(['appLog', 'common']) const handleFeedback = async (mid: string, { rating, content }: FeedbackType) => { diff --git a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx index 0585e1bdb6ea81..130cf5a563d92f 100644 --- a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx @@ -214,7 +214,7 @@ describe('Filter', () => { ])('should display correct label for %s status', (statusValue, expectedLabel) => { render( , ) diff --git a/web/app/components/app/workflow-log/__tests__/index.spec.tsx b/web/app/components/app/workflow-log/__tests__/index.spec.tsx index fdebf47c65bf7f..ec120c93763d12 100644 --- a/web/app/components/app/workflow-log/__tests__/index.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/index.spec.tsx @@ -1,4 +1,10 @@ -import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' +import type { + AppDetailWithSite, + GetAppsByAppIdWorkflowAppLogsData, + WorkflowAppLogPaginationResponse, + WorkflowAppLogPartialResponse, + WorkflowRunForLogResponse, +} from '@dify/contracts/api/console/apps/types.gen' import type { UseQueryResult } from '@tanstack/react-query' /** * Logs Container Component Tests @@ -15,17 +21,14 @@ import type { UseQueryResult } from '@tanstack/react-query' * - detail.spec.tsx * - trigger-by-display.spec.tsx */ -import type { MockedFunction } from 'vite-plus/test' import type { CloudSandboxPlanState } from '../../log/cloud-sandbox-retention' import type { ILogsProps } from '../index' -import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log' import type { AppModeEnum } from '@/types/app' import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import dayjs from 'dayjs' import { APP_PAGE_LIMIT } from '@/config' import { WorkflowRunTriggeredFrom } from '@/models/log' -import * as useLogModule from '@/service/use-log' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { render } from '@/test/console/render' import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app' @@ -41,7 +44,26 @@ const mockPlanState = vi.hoisted(() => ({ value: 'unrestricted' as CloudSandboxPlanState, })) -vi.mock('@/service/use-log') +type WorkflowLogsInput = { + params: { app_id: string } + query?: GetAppsByAppIdWorkflowAppLogsData['query'] +} + +const mockedUseWorkflowLogs = + vi.fn<(input: WorkflowLogsInput) => UseQueryResult>() + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useQuery: (options: Parameters[0]) => { + const [path, meta] = options.queryKey as [string[], { input?: WorkflowLogsInput }?] + if (Array.isArray(path) && path.includes('workflowAppLogs') && meta?.input) + return mockedUseWorkflowLogs(meta.input) + return actual.useQuery(options) + }, + } +}) vi.mock('../../log/cloud-sandbox-retention', async (importOriginal) => { const actual = await importOriginal() @@ -106,10 +128,6 @@ vi.mock('@/app/components/workflow/context', () => ({ WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })) -const mockedUseWorkflowLogs = useLogModule.useWorkflowLogs as MockedFunction< - typeof useLogModule.useWorkflowLogs -> - // ============================================================================ // Test Utilities // ============================================================================ @@ -180,14 +198,14 @@ const createMockApp = (overrides: Partial = {}): AppDetailWit ...overrides, }) -const createMockWorkflowRun = (overrides: Partial = {}): WorkflowRunDetail => ({ +const createMockWorkflowRun = ( + overrides: Partial = {}, +): WorkflowRunForLogResponse => ({ id: 'run-1', version: '1.0.0', status: 'succeeded', elapsed_time: 1.234, total_tokens: 100, - total_price: 0.001, - currency: 'USD', total_steps: 5, finished_at: Date.now(), triggered_from: WorkflowRunTriggeredFrom.APP_RUN, @@ -195,8 +213,8 @@ const createMockWorkflowRun = (overrides: Partial = {}): Work }) const createMockWorkflowLog = ( - overrides: Partial = {}, -): WorkflowAppLogDetail => ({ + overrides: Partial = {}, +): WorkflowAppLogPartialResponse => ({ id: 'log-1', workflow_run: createMockWorkflowRun(), created_from: 'web-app', @@ -211,9 +229,9 @@ const createMockWorkflowLog = ( }) const createMockLogsResponse = ( - data: WorkflowAppLogDetail[] = [], + data: WorkflowAppLogPartialResponse[] = [], total = data.length, -): WorkflowLogsResponse => ({ +): WorkflowAppLogPaginationResponse => ({ data, has_more: data.length < total, limit: APP_PAGE_LIMIT, @@ -225,14 +243,9 @@ const createMockLogsResponse = ( // Type-safe Mock Helper // ============================================================================ -type WorkflowLogsParams = { - appId: string - params?: Record -} - -const getMockCallParams = (): WorkflowLogsParams | undefined => { - const lastCall = mockedUseWorkflowLogs.mock.calls.at(-1) - return lastCall?.[0] +const getMockCallParams = () => { + const input = mockedUseWorkflowLogs.mock.calls.at(-1)?.[0] + return input && { appId: input.params.app_id, params: input.query } } // ============================================================================ @@ -256,7 +269,7 @@ describe('Logs Container', () => { it('should render title and subtitle', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -272,7 +285,7 @@ describe('Logs Container', () => { it('should render Filter component', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -293,7 +306,7 @@ describe('Logs Container', () => { it('should show loading spinner when data is undefined', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: undefined, isLoading: true, }), @@ -309,7 +322,7 @@ describe('Logs Container', () => { it('should not show loading spinner when data is available', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([createMockWorkflowLog()], 1), }), ) @@ -329,7 +342,7 @@ describe('Logs Container', () => { it('should render empty element when total is 0', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -350,7 +363,7 @@ describe('Logs Container', () => { it('should call useWorkflowLogs with correct appId and default params', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -373,7 +386,7 @@ describe('Logs Container', () => { it('should include date filters for non-allTime periods', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -390,7 +403,7 @@ describe('Logs Container', () => { it('should not include status param when status is all', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -412,7 +425,7 @@ describe('Logs Container', () => { // Arrange const user = userEvent.setup() mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -436,7 +449,7 @@ describe('Logs Container', () => { // Arrange const user = userEvent.setup() mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -459,7 +472,7 @@ describe('Logs Container', () => { const user = userEvent.setup() mockPlanState.value = 'sandbox' mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -482,7 +495,7 @@ describe('Logs Container', () => { // Arrange const user = userEvent.setup() mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -510,7 +523,7 @@ describe('Logs Container', () => { it('should not render pagination when total is less than limit', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([createMockWorkflowLog()], 1), }), ) @@ -529,7 +542,7 @@ describe('Logs Container', () => { ) mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse(logs, APP_PAGE_LIMIT + 10), }), ) @@ -549,7 +562,7 @@ describe('Logs Container', () => { it('should render List component when data is available', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([createMockWorkflowLog()], 1), }), ) @@ -564,7 +577,7 @@ describe('Logs Container', () => { it('should display log data in table', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse( [ createMockWorkflowLog({ @@ -607,7 +620,7 @@ describe('Logs Container', () => { it('should handle different app modes', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([createMockWorkflowLog()], 1), }), ) @@ -624,7 +637,7 @@ describe('Logs Container', () => { it('should handle error state from useWorkflowLogs', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: undefined, error: new Error('Failed to fetch'), }), @@ -640,7 +653,7 @@ describe('Logs Container', () => { it('should handle app with different ID', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) diff --git a/web/app/components/app/workflow-log/__tests__/list.spec.tsx b/web/app/components/app/workflow-log/__tests__/list.spec.tsx index 3a672ae8e9ede9..12daedc89b1459 100644 --- a/web/app/components/app/workflow-log/__tests__/list.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/list.spec.tsx @@ -1,5 +1,9 @@ -import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' -import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log' +import type { + AppDetailWithSite, + WorkflowAppLogPaginationResponse, + WorkflowAppLogPartialResponse, + WorkflowRunForLogResponse, +} from '@dify/contracts/api/console/apps/types.gen' /** * WorkflowAppLogList Component Tests * @@ -99,14 +103,14 @@ const createMockApp = (overrides: Partial = {}): AppDetailWit ...overrides, }) -const createMockWorkflowRun = (overrides: Partial = {}): WorkflowRunDetail => ({ +const createMockWorkflowRun = ( + overrides: Partial = {}, +): WorkflowRunForLogResponse => ({ id: 'run-1', version: '1.0.0', status: 'succeeded', elapsed_time: 1.234, total_tokens: 100, - total_price: 0.001, - currency: 'USD', total_steps: 5, finished_at: Date.now(), triggered_from: WorkflowRunTriggeredFrom.APP_RUN, @@ -114,8 +118,9 @@ const createMockWorkflowRun = (overrides: Partial = {}): Work }) const createMockWorkflowLog = ( - overrides: Partial = {}, -): WorkflowAppLogDetail => ({ + // read_at is not declared by the console response yet; the list still honours it. + overrides: Partial & { read_at?: number } = {}, +): WorkflowAppLogPartialResponse => ({ id: 'log-1', workflow_run: createMockWorkflowRun(), created_from: 'web-app', @@ -130,9 +135,9 @@ const createMockWorkflowLog = ( }) const createMockLogsResponse = ( - data: WorkflowAppLogDetail[] = [], + data: WorkflowAppLogPartialResponse[] = [], total = data.length, -): WorkflowLogsResponse => ({ +): WorkflowAppLogPaginationResponse => ({ data, has_more: data.length < total, limit: APP_PAGE_LIMIT, diff --git a/web/app/components/app/workflow-log/filter.tsx b/web/app/components/app/workflow-log/filter.tsx index 99d6ee7ab9ca33..28a626b99b042c 100644 --- a/web/app/components/app/workflow-log/filter.tsx +++ b/web/app/components/app/workflow-log/filter.tsx @@ -54,7 +54,7 @@ const Filter: FC = ({ queryParams, setQueryParams }: IFilterProps) { - setQueryParams({ ...queryParams, status: item.value as string }) + setQueryParams({ ...queryParams, status: item.value as QueryParam['status'] }) trackEvent('workflow_log_filter_status_selected', { workflow_log_filter_status: item.value as string, }) diff --git a/web/app/components/app/workflow-log/index.tsx b/web/app/components/app/workflow-log/index.tsx index 8b28ddf80737ff..168a3adc73c93f 100644 --- a/web/app/components/app/workflow-log/index.tsx +++ b/web/app/components/app/workflow-log/index.tsx @@ -1,6 +1,9 @@ 'use client' -import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' +import type { + AppDetailWithSite, + GetAppsByAppIdWorkflowAppLogsData, +} from '@dify/contracts/api/console/apps/types.gen' import type { FC } from 'react' import { Pagination } from '@langgenius/dify-ui/pagination' import { useQuery } from '@tanstack/react-query' @@ -16,7 +19,7 @@ import EmptyElement from '@/app/components/app/log/empty-element' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { APP_PAGE_LIMIT } from '@/config' import { userProfileQueryOptions } from '@/features/account-profile/client' -import { useWorkflowLogs } from '@/service/use-log' +import { consoleQuery } from '@/service/console' import PageTitle from '../log-annotation/page-title' import { ArchivedLogsNotice } from '../log/archived-logs-notice' import { shouldShowArchivedLogsNotice } from '../log/archived-logs-notice-utils' @@ -36,9 +39,11 @@ export type ILogsProps = { appDetail: AppDetailWithSite } +type WorkflowLogStatus = NonNullable['status'] + export type QueryParam = { period: string - status?: string + status?: WorkflowLogStatus | 'all' keyword?: string } @@ -81,10 +86,11 @@ const Logs: FC = ({ appDetail }) => { ...omit(requestQueryParams, ['period', 'status']), } - const { data: workflowLogs, refetch: mutate } = useWorkflowLogs({ - appId: appDetail.id, - params: query, - }) + const { data: workflowLogs, refetch: mutate } = useQuery( + consoleQuery.apps.byAppId.workflowAppLogs.get.queryOptions({ + input: { params: { app_id: appDetail.id }, query }, + }), + ) const total = workflowLogs?.total const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1 const showArchivedLogsNotice = shouldShowArchivedLogsNotice( diff --git a/web/app/components/app/workflow-log/list.tsx b/web/app/components/app/workflow-log/list.tsx index cb190f8b711938..5b66a2c863eb2c 100644 --- a/web/app/components/app/workflow-log/list.tsx +++ b/web/app/components/app/workflow-log/list.tsx @@ -1,12 +1,12 @@ 'use client' -import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' -import type { FC } from 'react' import type { - WorkflowAppLogDetail, - WorkflowLogsResponse, - WorkflowRunTriggeredFrom, -} from '@/models/log' + AppDetailWithSite, + WorkflowAppLogPaginationResponse, + WorkflowAppLogPartialResponse, +} from '@dify/contracts/api/console/apps/types.gen' +import type { FC } from 'react' +import type { WorkflowLogDetails, WorkflowRunTriggeredFrom } from '@/models/log' import { ArrowDownIcon } from '@heroicons/react/24/outline' import { cn } from '@langgenius/dify-ui/cn' import { @@ -30,7 +30,7 @@ import DetailPanel from './detail' import TriggerByDisplay from './trigger-by-display' type ILogs = { - logs?: WorkflowLogsResponse + logs?: WorkflowAppLogPaginationResponse appDetail?: AppDetailWithSite onRefresh: () => void } @@ -45,9 +45,9 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { const isMobile = media === MediaType.mobile const [showDrawer, setShowDrawer] = useState(false) - const [currentLog, setCurrentLog] = useState() + const [currentLog, setCurrentLog] = useState() const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') - const [localLogs, setLocalLogs] = useState(logs?.data || []) + const [localLogs, setLocalLogs] = useState(logs?.data || []) useEffect(() => { if (!logs?.data) { @@ -56,7 +56,7 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { } const sortedLogs = [...logs.data].sort((a, b) => { - const result = a.created_at - b.created_at + const result = (a.created_at ?? 0) - (b.created_at ?? 0) return sortOrder === 'asc' ? result : -result }) @@ -69,7 +69,7 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { const isWorkflow = appDetail?.mode === AppModeEnum.WORKFLOW - const statusTdRender = (status: string) => { + const statusTdRender = (status?: string | null) => { if (status === 'succeeded') { return (
@@ -209,7 +209,7 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { - {localLogs.map((log: WorkflowAppLogDetail) => { + {localLogs.map((log: WorkflowAppLogPartialResponse) => { const endUser = log.created_by_end_user ? log.created_by_end_user.session_id : log.created_by_account @@ -230,7 +230,8 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { }} > - {!log.read_at && ( + {/* The console workflow-log response does not declare read_at yet. */} + {!('read_at' in log && log.read_at) && (
@@ -246,21 +247,21 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { setShowDrawer(true) }} > - {formatTime( - log.created_at, - t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, - )} + {log.created_at + ? formatTime( + log.created_at, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + ) + : defaultValue} - {statusTdRender(log.workflow_run.status)} + {statusTdRender(log.workflow_run?.status)} -
- {`${log.workflow_run.elapsed_time.toFixed(3)}s`} +
+ {`${(log.workflow_run?.elapsed_time ?? 0).toFixed(3)}s`}
- {log.workflow_run.total_tokens} + {log.workflow_run?.total_tokens}
= ({ logs, appDetail, onRefresh }) => { {isWorkflow && ( )} @@ -292,10 +295,10 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { diff --git a/web/app/components/workflow/run/__tests__/status.spec.tsx b/web/app/components/workflow/run/__tests__/status.spec.tsx index 3c1a890970435e..c49571a241296d 100644 --- a/web/app/components/workflow/run/__tests__/status.spec.tsx +++ b/web/app/components/workflow/run/__tests__/status.spec.tsx @@ -1,11 +1,12 @@ -import type { WorkflowPausedDetailsResponse } from '@/models/log' +import type { WorkflowPauseDetailsResponse } from '@dify/contracts/api/console/workflow/types.gen' +import { skipToken } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import { cloneElement, isValidElement } from 'react' import { createDocLinkMock, resolveDocLink } from '../../__tests__/i18n' import Status from '../status' const mockDocLink = createDocLinkMock() -const mockUseWorkflowPausedDetails = vi.fn() +const mockUseQuery = vi.fn() vi.mock('react-i18next', async () => { const { withSelectorKey, withSelectorKeyProps } = await import('@/test/i18n-mock') @@ -57,14 +58,28 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => mockDocLink, })) -vi.mock('@/service/use-log', () => ({ - useWorkflowPausedDetails: (params: { workflowRunId: string; enabled?: boolean }) => - mockUseWorkflowPausedDetails(params), +vi.mock('@tanstack/react-query', async (importOriginal) => ({ + ...(await importOriginal()), + useQuery: (options: unknown) => mockUseQuery(options), +})) + +vi.mock('@/service/console', () => ({ + consoleQuery: { + workflow: { + byWorkflowRunId: { + pauseDetails: { + get: { + queryOptions: (options: unknown) => options, + }, + }, + }, + }, + }, })) const createPausedDetails = ( - overrides: Partial = {}, -): WorkflowPausedDetailsResponse => ({ + overrides: Partial = {}, +): WorkflowPauseDetailsResponse => ({ paused_at: '2026-03-18T00:00:00Z', paused_nodes: [], ...overrides, @@ -73,7 +88,7 @@ const createPausedDetails = ( describe('Status', () => { beforeEach(() => { vi.clearAllMocks() - mockUseWorkflowPausedDetails.mockReturnValue({ data: undefined }) + mockUseQuery.mockReturnValue({ data: undefined }) }) it('renders the scheduled status before execution', () => { @@ -87,10 +102,7 @@ describe('Status', () => { expect(screen.getByText('appLog.status.running')).toBeInTheDocument() expect(document.querySelectorAll('.bg-text-quaternary')).toHaveLength(2) - expect(mockUseWorkflowPausedDetails).toHaveBeenCalledWith({ - workflowRunId: 'run-1', - enabled: false, - }) + expect(mockUseQuery).toHaveBeenCalledWith({ input: skipToken }) }) it('renders the listening label when the run is waiting for input', () => { @@ -170,14 +182,13 @@ describe('Status', () => { expect(screen.getByText('appLog.status.paused')).toBeInTheDocument() expect(screen.getByText('workflowHumanInput.nodes.humanInput.log.reason')).toBeInTheDocument() expect(document.querySelectorAll('.bg-text-quaternary')).toHaveLength(3) - expect(mockUseWorkflowPausedDetails).toHaveBeenCalledWith({ - workflowRunId: 'run-3', - enabled: true, + expect(mockUseQuery).toHaveBeenCalledWith({ + input: { params: { workflow_run_id: 'run-3' } }, }) }) it('renders paused human-input reasons and backstage URLs', () => { - mockUseWorkflowPausedDetails.mockReturnValue({ + mockUseQuery.mockReturnValue({ data: createPausedDetails({ paused_nodes: [ { diff --git a/web/app/components/workflow/run/status.tsx b/web/app/components/workflow/run/status.tsx index 64eb0793a1a5cc..69704b988176e9 100644 --- a/web/app/components/workflow/run/status.tsx +++ b/web/app/components/workflow/run/status.tsx @@ -2,11 +2,12 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { StatusDot } from '@langgenius/dify-ui/status-dot' +import { skipToken, useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' import StatusContainer from '@/app/components/workflow/run/status-container' import { useDocLink } from '@/context/i18n' -import { useWorkflowPausedDetails } from '@/service/use-log' +import { consoleQuery } from '@/service/console' type ResultProps = { status: string @@ -31,10 +32,14 @@ const StatusPanel: FC = ({ }) => { const { t } = useTranslation(['runLog', 'workflow', 'appLog', 'workflowHumanInput']) const docLink = useDocLink() - const { data: pausedDetails } = useWorkflowPausedDetails({ - workflowRunId: workflowRunId || '', - enabled: status === 'paused', - }) + const { data: pausedDetails } = useQuery( + consoleQuery.workflow.byWorkflowRunId.pauseDetails.get.queryOptions({ + input: + status === 'paused' && workflowRunId + ? { params: { workflow_run_id: workflowRunId } } + : skipToken, + }), + ) const pausedReasons = useMemo(() => { const reasons: string[] = [] @@ -55,7 +60,7 @@ const StatusPanel: FC = ({ const hasHumanInputNode = paused_nodes.some((node) => node.pause_type.type === 'human_input') if (hasHumanInputNode) { paused_nodes.forEach((node) => { - if (node.pause_type.type === 'human_input') { + if (node.pause_type.type === 'human_input' && node.pause_type.backstage_input_url) { inputURLs.push(node.pause_type.backstage_input_url) } }) diff --git a/web/models/log.ts b/web/models/log.ts index f94a11d7875eca..460bcb9460aeb1 100644 --- a/web/models/log.ts +++ b/web/models/log.ts @@ -3,35 +3,6 @@ import type { Metadata, ThoughtItem } from '@/app/components/base/chat/chat/type import type { Edge, Node } from '@/app/components/workflow/types' import type { VisionFile } from '@/types/app' -type CompletionParamsType = { - max_tokens: number - temperature: number - top_p: number - stop: string[] - presence_penalty: number - frequency_penalty: number -} - -type LogModelConfig = { - name: string - provider: string - completion_params: CompletionParamsType -} - -type ModelConfigDetail = { - introduction: string - prompt_template: string - prompt_variables: Array<{ - key: string - name: string - description: string - type: string | number - default: string - options: string[] - }> - completion_params: CompletionParamsType -} - export type LogAnnotation = { id: string content: string @@ -84,98 +55,6 @@ type MessageContent = { parent_message_id: string | null } -export type CompletionConversationGeneralDetail = { - id: string - status: 'normal' | 'finished' | 'paused' - from_source: 'api' | 'console' - from_end_user_id: string - from_end_user_session_id: string - from_account_id: string - read_at: Date - created_at: number - updated_at: number - annotation: Annotation - user_feedback_stats: { - like: number - dislike: number - } - admin_feedback_stats: { - like: number - dislike: number - } - model_config: { - provider: string - model_id: string - configs: Pick - } - message: Pick -} - -export type CompletionConversationFullDetailResponse = { - id: string - status: 'normal' | 'finished' - from_source: 'api' | 'console' - from_end_user_id: string - from_account_id: string - // read_at: Date - created_at: number - model_config: { - provider: string - model_id: string - configs: ModelConfigDetail - } - message: MessageContent -} - -export type CompletionConversationsResponse = { - data: Array - has_more: boolean - limit: number - total: number - page: number -} - -export type CompletionConversationsRequest = { - keyword: string - start: string - end: string - annotation_status: string - page: number - limit: number // The default value is 20 and the range is 1-100 -} - -export type ChatConversationGeneralDetail = Omit< - CompletionConversationGeneralDetail, - 'message' | 'annotation' -> & { - summary: string - message_count: number - annotated: boolean -} - -export type ChatConversationsResponse = { - data: Array - has_more: boolean - limit: number - total: number - page: number -} - -export type ChatConversationsRequest = CompletionConversationsRequest & { message_count: number } - -export type ChatConversationFullDetailResponse = Omit< - CompletionConversationGeneralDetail, - 'message' | 'model_config' -> & { - message_count: number - model_config: { - provider: string - model_id: string - configs: ModelConfigDetail - model: LogModelConfig - } -} - export type ChatMessagesRequest = { conversation_id: string first_id?: string @@ -230,30 +109,10 @@ export type TriggerMetadata = { icon_dark?: string | null } -type WorkflowLogDetails = { +export type WorkflowLogDetails = { trigger_metadata?: TriggerMetadata } -export type WorkflowRunDetail = { - id: string - version: string - status: - | 'scheduled' - | 'running' - | 'succeeded' - | 'failed' - | 'stopped' - | 'partial-succeeded' - | 'paused' - error?: string - triggered_from?: WorkflowRunTriggeredFrom - elapsed_time: number - total_tokens: number - total_price: number - currency: string - total_steps: number - finished_at: number -} type AccountInfo = { id: string name: string @@ -265,24 +124,6 @@ type EndUserInfo = { is_anonymous: boolean session_id: string } -export type WorkflowAppLogDetail = { - id: string - workflow_run: WorkflowRunDetail - details?: WorkflowLogDetails - created_from: 'service-api' | 'web-app' | 'explore' - created_by_role: 'account' | 'end_user' - created_by_account?: AccountInfo - created_by_end_user?: EndUserInfo - created_at: number - read_at?: number -} -export type WorkflowLogsResponse = { - data: Array - has_more: boolean - limit: number - total: number - page: number -} export type WorkflowRunDetailResponse = { id: string version: string @@ -371,24 +212,3 @@ export type AgentLogDetailResponse = { iterations: AgentIteration[] files: AgentLogFile[] } - -type PauseType = - | { - type: 'human_input' - form_id: string - backstage_input_url: string - } - | { - type: 'breakpoint' - } - -type PauseDetail = { - node_id: string - node_title: string - pause_type: PauseType -} - -export type WorkflowPausedDetailsResponse = { - paused_at: string - paused_nodes: PauseDetail[] -} diff --git a/web/service/use-log.ts b/web/service/use-log.ts deleted file mode 100644 index 65a201861a8611..00000000000000 --- a/web/service/use-log.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { - ChatConversationFullDetailResponse, - ChatConversationsRequest, - ChatConversationsResponse, - CompletionConversationFullDetailResponse, - CompletionConversationsRequest, - CompletionConversationsResponse, - WorkflowLogsResponse, - WorkflowPausedDetailsResponse, -} from '@/models/log' -import { useQuery } from '@tanstack/react-query' -import { consoleClient } from '@/service/console' -import { get } from './base' - -const NAME_SPACE = 'log' - -// ============ Annotations Count ============ - -export const useAnnotationsCount = (appId: string) => { - return useQuery({ - queryKey: [NAME_SPACE, 'annotations-count', appId], - queryFn: () => - consoleClient.apps.byAppId.annotations.count.get({ - params: { - app_id: appId, - }, - }), - enabled: !!appId, - }) -} - -// ============ Chat Conversations ============ - -type ChatConversationsParams = { - appId: string - params?: Partial -} - -export const useChatConversations = ({ appId, params }: ChatConversationsParams) => { - return useQuery({ - queryKey: [NAME_SPACE, 'chat-conversations', appId, params], - queryFn: () => get(`/apps/${appId}/chat-conversations`, { params }), - enabled: !!appId, - }) -} - -// ============ Completion Conversations ============ - -type CompletionConversationsParams = { - appId: string - params?: Partial -} - -export const useCompletionConversations = ({ appId, params }: CompletionConversationsParams) => { - return useQuery({ - queryKey: [NAME_SPACE, 'completion-conversations', appId, params], - queryFn: () => - get(`/apps/${appId}/completion-conversations`, { params }), - enabled: !!appId, - }) -} - -// ============ Chat Conversation Detail ============ - -export const useChatConversationDetail = (appId?: string, conversationId?: string) => { - return useQuery({ - queryKey: [NAME_SPACE, 'chat-conversation-detail', appId, conversationId], - queryFn: () => - get( - `/apps/${appId}/chat-conversations/${conversationId}`, - ), - enabled: !!appId && !!conversationId, - }) -} - -// ============ Completion Conversation Detail ============ - -export const useCompletionConversationDetail = (appId?: string, conversationId?: string) => { - return useQuery({ - queryKey: [NAME_SPACE, 'completion-conversation-detail', appId, conversationId], - queryFn: () => - get( - `/apps/${appId}/completion-conversations/${conversationId}`, - ), - enabled: !!appId && !!conversationId, - }) -} - -// ============ Workflow Logs ============ - -type WorkflowLogsParams = { - appId: string - params?: Record -} - -export const useWorkflowLogs = ({ appId, params }: WorkflowLogsParams) => { - return useQuery({ - queryKey: [NAME_SPACE, 'workflow-logs', appId, params], - queryFn: () => get(`/apps/${appId}/workflow-app-logs`, { params }), - enabled: !!appId, - }) -} - -// ============ Workflow Pause Details ============ - -type WorkflowPausedDetailsParams = { - workflowRunId: string - enabled?: boolean -} - -export const useWorkflowPausedDetails = ({ - workflowRunId, - enabled = true, -}: WorkflowPausedDetailsParams) => { - return useQuery({ - queryKey: [NAME_SPACE, 'workflow-paused-details', workflowRunId], - queryFn: () => get(`/workflow/${workflowRunId}/pause-details`), - enabled: enabled && !!workflowRunId, - }) -} From e592a2bf35eb02d5c4790fcd8357e9ebdacecf78 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:22:51 +0000 Subject: [PATCH 03/60] [autofix.ci] apply automated fixes --- api/openapi/markdown/console-openapi.md | 397 +++++++++++++++--------- api/openapi/markdown/openapi-openapi.md | 48 +-- api/openapi/markdown/service-openapi.md | 42 +-- api/openapi/markdown/web-openapi.md | 20 +- 4 files changed, 305 insertions(+), 202 deletions(-) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 7e7b809021354b..8c4e4ade000b65 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -1185,7 +1185,7 @@ Create or update Agent App message feedback | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [MessageFeedbackPayload](#messagefeedbackpayload)
| +| Yes | **application/json**: [AppMessageFeedbackPayload](#appmessagefeedbackpayload)
| #### Responses @@ -1975,7 +1975,7 @@ Submit human input form preview for advanced chat workflow | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [HumanInputFormSubmitPayload](#humaninputformsubmitpayload)
| +| Yes | **application/json**: [HumanInputFormPreviewSubmitPayload](#humaninputformpreviewsubmitpayload)
| #### Responses @@ -3031,7 +3031,7 @@ Create or update message feedback (like/dislike) | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [MessageFeedbackPayload](#messagefeedbackpayload)
| +| Yes | **application/json**: [AppMessageFeedbackPayload](#appmessagefeedbackpayload)
| #### Responses @@ -4473,7 +4473,7 @@ Submit human input form preview for workflow | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [HumanInputFormSubmitPayload](#humaninputformsubmitpayload)
| +| Yes | **application/json**: [HumanInputFormPreviewSubmitPayload](#humaninputformpreviewsubmitpayload)
| #### Responses @@ -7406,7 +7406,7 @@ Request body: | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [ChatMessagePayload](#chatmessagepayload)
| +| Yes | **application/json**: [ChatMessageExplorePayload](#chatmessageexplorepayload)
| #### Responses @@ -8889,7 +8889,7 @@ Update account-level Step-by-step Tour state | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [DraftWorkflowRunPayload](#draftworkflowrunpayload)
| +| Yes | **application/json**: [RagPipelineDraftWorkflowRunPayload](#ragpipelinedraftworkflowrunpayload)
| #### Responses @@ -9068,7 +9068,7 @@ Update account-level Step-by-step Tour state | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [Parser](#parser)
| +| Yes | **application/json**: [DatasourceContentPreviewPayload](#datasourcecontentpreviewpayload)
| #### Responses @@ -10075,7 +10075,7 @@ Reset a draft workflow variable to its default value (snippet scope) | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [PublishWorkflowPayload](#publishworkflowpayload)
| +| Yes | **application/json**: [SnippetPublishWorkflowPayload](#snippetpublishworkflowpayload)
| #### Responses @@ -14820,7 +14820,7 @@ Model class for AI model. | deprecated | boolean | | No | | features | [ [ModelFeature](#modelfeature) ] | | No | | fetch_from | [FetchFrom](#fetchfrom) | | Yes | -| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | Yes | | model | string | | Yes | | model_properties | object | | Yes | | model_type | [ModelType](#modeltype) | | Yes | @@ -14834,7 +14834,7 @@ Model class for AI model. | deprecated | boolean | | No | | features | [ [ModelFeature](#modelfeature) ] | | No | | fetch_from | [FetchFrom](#fetchfrom) | | Yes | -| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | Yes | | model | string | | Yes | | model_properties | object | | Yes | | model_type | [ModelType](#modeltype) | | Yes | @@ -17029,10 +17029,10 @@ Inherits from ToolProviderIdentity, without any additional fields. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | author | string | The author of the tool | Yes | -| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The description of the tool | Yes | +| description | [I18nObject](#i18nobject) | The description of the tool | Yes | | icon | string | The icon of the tool | Yes | | icon_dark | string | The dark icon of the tool | No | -| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The label of the tool | Yes | +| label | [I18nObject](#i18nobject) | The label of the tool | Yes | | name | string | The name of the tool | Yes | | tags | [ [ToolLabelEnum](#toollabelenum) ] | The tags of the tool | No | @@ -17079,7 +17079,7 @@ Legacy Chat App model config used only for follow-up question generation. | thought | string | | No | | tool | string | | No | | tool_input | string | | No | -| tool_labels | [JSONValue](#jsonvalue) | Labels for tools used. | Yes | +| tool_labels | [AnyJSONValue](#anyjsonvalue) | Labels for tools used. | Yes | #### AgentTokenUsageStatisticResponse @@ -17283,6 +17283,12 @@ Auth Token is invalid or account already activated, please check again. | ---- | ---- | ----------- | -------- | | score_threshold | number | Score threshold | Yes | +#### AnyJSONValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AnyJSONValue | | | | + #### ApiBaseUrlResponse | Name | Type | Description | Required | @@ -18149,6 +18155,14 @@ Publication state of a Dify app exposed as an MCP server; only ACTIVE servers ac | ---- | ---- | ----------- | -------- | | AppMCPServerStatus | string | Publication state of a Dify app exposed as an MCP server; only ACTIVE servers accept MCP calls. | | +#### AppMessageFeedbackPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | Optional text feedback providing additional detail. | No | +| message_id | string | Message ID | Yes | +| rating | string,
**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | + #### AppMetadataConditionPayload | Name | Type | Description | Required | @@ -18726,7 +18740,7 @@ Audio size exceeded. {message} | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| text | string | | Yes | +| text | string | Transcribed text from audio | Yes | #### AuthenticationFailedError @@ -19014,6 +19028,17 @@ You cannot transfer ownership to yourself. | sort_by | string,
**Available values:** "-created_at", "-updated_at", "created_at", "updated_at",
**Default:** -updated_at | Sort field and direction
*Enum:* `"-created_at"`, `"-updated_at"`, `"created_at"`, `"updated_at"` | No | | start | string | Start date (YYYY-MM-DD HH:MM) | No | +#### ChatMessageExplorePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | | No | +| files | [ object ] | | No | +| inputs | object | | Yes | +| parent_message_id | string | | No | +| query | string | | Yes | +| retriever_from | string,
**Default:** explore_app | | No | + #### ChatMessagePayload | Name | Type | Description | Required | @@ -19442,7 +19467,7 @@ The conversation cursor is not in the current list. Refresh the list and try aga | id | string | | Yes | | introduction | string | | No | | message_count | integer | | Yes | -| model_config | [ModelConfig](#modelconfig) | | No | +| model_config | [ConversationModelConfig](#conversationmodelconfig) | | No | | status | string | | Yes | | updated_at | integer | | No | | user_feedback_stats | [FeedbackStat](#feedbackstat) | | No | @@ -19481,9 +19506,20 @@ The conversation has no message from which to generate a name. | from_source | string | | Yes | | id | string | | Yes | | message | [MessageDetail](#messagedetail) | | No | -| model_config | [ModelConfig](#modelconfig) | | No | +| model_config | [ConversationModelConfig](#conversationmodelconfig) | | No | | status | string | | Yes | +#### ConversationModelConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_mode | [AnyJSONValue](#anyjsonvalue) | | No | +| model | [AnyJSONValue](#anyjsonvalue) | | No | +| opening_statement | string | | No | +| pre_prompt | string | | No | +| suggested_questions | [AnyJSONValue](#anyjsonvalue) | | No | +| user_input_form | [AnyJSONValue](#anyjsonvalue) | | No | + #### ConversationNameRequiredError A name is required when automatic naming is disabled. @@ -19661,10 +19697,10 @@ Model class for credential form schema. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | default | string | | No | -| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | Yes | | max_length | integer | | No | | options | [ [FormOption](#formoption) ] | | No | -| placeholder | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | +| placeholder | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | | required | boolean,
**Default:** true | | No | | show_on | [ [FormShowOnObject](#formshowonobject) ],
**Default:** | | No | | type | [FormType](#formtype) | | Yes | @@ -20257,6 +20293,14 @@ The dataset name already exists. Please modify your dataset name. | vector_setting | [DatasetVectorSettingResponse](#datasetvectorsettingresponse) | Semantic search weight settings. | No | | weight_type | string | | No | +#### DatasourceContentPreviewPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | No | +| datasource_type | string | | Yes | +| inputs | object | | Yes | + #### DatasourceCredentialDeletePayload | Name | Type | Description | Required | @@ -20439,9 +20483,9 @@ Datasource provider entity | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | author | string | The author of the tool | Yes | -| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The description of the tool | Yes | +| description | [I18nObject](#i18nobject) | The description of the tool | Yes | | icon | string | The icon of the tool | Yes | -| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The label of the tool | Yes | +| label | [I18nObject](#i18nobject) | The label of the tool | Yes | | name | string | The name of the tool | Yes | | tags | [ [ToolLabelEnum](#toollabelenum) ] | The tags of the tool | No | @@ -20846,10 +20890,8 @@ Workflow graph might have been modified, please refresh and resubmit. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| datasource_info_list | [ object ] | | Yes | -| datasource_type | string | | Yes | +| files | [ object ] | | No | | inputs | object | | Yes | -| start_node_id | string | | Yes | #### DraftWorkflowSyncPayload @@ -21364,7 +21406,7 @@ The configuration of an event | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The description of the event | Yes | +| description | [I18nObject](#i18nobject) | The description of the event | Yes | | identity | [EventIdentity](#eventidentity) | | Yes | | output_schema | object | The output schema that this event produces | No | | parameters | [ [EventParameter](#eventparameter) ] | The parameters of the event | No | @@ -21609,8 +21651,8 @@ Enum class for fetch from. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | -| placeholder | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | Yes | +| placeholder | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | #### FileExtensionBlockedError @@ -21624,7 +21666,10 @@ The file extension is blocked for security reasons. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| file_ids | [ string ] | | Yes | +| extension | string | | Yes | +| filename | string | | Yes | +| mimetype | string | | Yes | +| size | integer | | Yes | #### FileInputConfig @@ -21780,7 +21825,7 @@ Model class for form option. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | Yes | | show_on | [ [FormShowOnObject](#formshowonobject) ],
**Default:** | | No | | value | string | | Yes | @@ -21819,15 +21864,6 @@ Enum class for form type. | ---- | ---- | ----------- | -------- | | GeneratorResponse | | | | -#### Github - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| github_plugin_unique_identifier | string | | Yes | -| package | string | | Yes | -| repo | string | | Yes | -| version | string | | Yes | - #### HitTestingChildChunk | Name | Type | Description | Required | @@ -21978,6 +22014,14 @@ Enum class for form type. | node_title | string | | Yes | | resolved_default_values | object | | No | +#### HumanInputFormPreviewSubmitPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action | string | Selected action ID | Yes | +| form_inputs | object | Values the user provides for the form's own fields | Yes | +| inputs | object | Values used to fill missing upstream variables referenced in form_content | Yes | + #### HumanInputFormSubmissionData | Name | Type | Description | Required | @@ -21993,9 +22037,8 @@ Enum class for form type. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| action | string | Selected action ID | Yes | -| form_inputs | object | Values the user provides for the form's own fields | Yes | -| inputs | object | Values used to fill missing upstream variables referenced in form_content | Yes | +| action | string | ID of the action button the recipient selected. Must match one of the `id` values from the form's `user_actions` list. | Yes | +| inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes | #### HumanInputFormSubmitResponse @@ -22124,7 +22167,7 @@ Knowledge indexing estimate failed: {message} | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | data_source_type | string,
**Available values:** "notion_import", "upload_file", "website_crawl" | *Enum:* `"notion_import"`, `"upload_file"`, `"website_crawl"` | Yes | -| file_info_list | [FileInfo](#fileinfo) | | No | +| file_info_list | [KnowledgeFileInfo](#knowledgefileinfo) | | No | | notion_info_list | [ [NotionInfo](#notioninfo) ] | | No | | website_info_list | [WebsiteInfo](#websiteinfo) | | No | @@ -22365,12 +22408,6 @@ This invitation was sent to another account. Please sign in with the invited acc | ---- | ---- | ----------- | -------- | | inputs | object | | No | -#### JSONValue - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| JSONValue | string
integer
number
boolean
object
[ object ] | | | - #### JSONValueType | Name | Type | Description | Required | @@ -22407,6 +22444,12 @@ This invitation was sent to another account. Please sign in with the invited acc | retrieval_model | [RetrievalModel](#retrievalmodel) | Retrieval model configuration. Controls how chunks are searched and ranked in this knowledge base. | No | | summary_index_setting | object | Summary index configuration. | No | +#### KnowledgeFileInfo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| file_ids | [ string ] | | Yes | + #### KnowledgePipeline | Name | Type | Description | Required | @@ -22620,13 +22663,6 @@ Unable to create account because the licensed seats limit was exceeded | parameters | object | Server parameters configuration | Yes | | status | [AppMCPServerStatus](#appmcpserverstatus) | Server status | No | -#### Marketplace - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| marketplace_plugin_unique_identifier | string | | Yes | -| version | string | | No | - #### MaxKeysExceededError The API key limit has been reached. @@ -22758,10 +22794,10 @@ The message cursor is not in this conversation. Refresh the list and try again. | from_source | string | | Yes | | id | string | | Yes | | inputs | object | | Yes | -| message | [JSONValue](#jsonvalue) | | Yes | +| message | [AnyJSONValue](#anyjsonvalue) | | Yes | | message_files | [ [MessageFile](#messagefile) ] | | Yes | | message_tokens | integer | | Yes | -| metadata | [JSONValue](#jsonvalue) | | Yes | +| metadata | [AnyJSONValue](#anyjsonvalue) | | Yes | | parent_message_id | string | | No | | provider_response_latency | number | | Yes | | query | string | | Yes | @@ -22787,10 +22823,10 @@ The message cursor is not in this conversation. Refresh the list and try again. | from_source | string | | Yes | | id | string | | Yes | | inputs | object | | Yes | -| message | [JSONValue](#jsonvalue) | | Yes | +| message | [AnyJSONValue](#anyjsonvalue) | | Yes | | message_files | [ [MessageFile](#messagefile) ] | | Yes | | message_tokens | integer | | Yes | -| metadata | [JSONValue](#jsonvalue) | | Yes | +| metadata | [AnyJSONValue](#anyjsonvalue) | | Yes | | parent_message_id | string | | No | | provider_response_latency | number | | Yes | | query | string | | Yes | @@ -22802,7 +22838,6 @@ The message cursor is not in this conversation. Refresh the list and try again. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | content | string | Optional text feedback providing additional detail. | No | -| message_id | string | Message ID | Yes | | rating | string,
**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | #### MessageFeedbackRatingRequiredError @@ -23066,11 +23101,11 @@ Fields required to render the collapsed model-provider list. | ---- | ---- | ----------- | -------- | | configurate_methods | [ [ConfigurateMethod](#configuratemethod) ] | | Yes | | custom_configuration | [ModelProviderCustomConfigurationSummaryResponse](#modelprovidercustomconfigurationsummaryresponse) | | Yes | -| description | [I18nObject](#i18nobject) | | No | -| icon_small | [I18nObject](#i18nobject) | | No | -| icon_small_dark | [I18nObject](#i18nobject) | | No | +| description | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | +| icon_small | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | +| icon_small_dark | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | | is_configured | boolean | | Yes | -| label | [I18nObject](#i18nobject) | | Yes | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | Yes | | plugin_id | string | | Yes | | preferred_provider_type | [ProviderType](#providertype) | | Yes | | provider | string | | Yes | @@ -23083,6 +23118,15 @@ Fields required to render the collapsed model-provider list. | ---- | ---- | ----------- | -------- | | enabled | boolean | | Yes | +#### ModelRuntimeI18nObject + +Model class for i18n object. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| en_US | string | | Yes | +| zh_Hans | string | | No | + #### ModelSelectorScope | Name | Type | Description | Required | @@ -23115,7 +23159,7 @@ Model with provider entity. | features | [ [ModelFeature](#modelfeature) ] | | No | | fetch_from | [FetchFrom](#fetchfrom) | Where the model definition comes from. `predefined-model` for built-in models, `customizable-model` for user-configured models. | Yes | | has_invalid_load_balancing_configs | boolean | | No | -| label | [I18nObject](#i18nobject) | Localized display name of the model. | Yes | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | Localized display name of the model. | Yes | | load_balancing_enabled | boolean | | No | | model | string | | Yes | | model_properties | object | | Yes | @@ -23597,13 +23641,6 @@ Too many owner transfer emails have been sent. Please try again in {minutes} min | ---- | ---- | ----------- | -------- | | OwnerTransferRateLimitExceededError | | Too many owner transfer emails have been sent. Please try again in {minutes} minutes. | | -#### Package - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| plugin_unique_identifier | string | | Yes | -| version | string | | No | - #### PaginatedConversationVariableResponse | Name | Type | Description | Required | @@ -23647,8 +23684,8 @@ Model class for parameter rule. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | default | | | No | -| help | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | -| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| help | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | Yes | | max | number | | No | | min | number | | No | | name | string | | Yes | @@ -23683,14 +23720,6 @@ Enum class for parameter type. | text_to_speech | { **"autoPlay"**: string, **"enabled"**: boolean, **"language"**: string, **"voice"**: string } | | Yes | | user_input_form | [ object ] | | Yes | -#### Parser - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| credential_id | string | | No | -| datasource_type | string | | Yes | -| inputs | object | | Yes | - #### ParserAsset | Name | Type | Description | Required | @@ -24175,7 +24204,31 @@ Plaintext secret values are not allowed in Agent config. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | type | [PluginBundleDependencyType](#pluginbundledependencytype) | | Yes | -| value | [Github](#github)
[Marketplace](#marketplace)
[Package](#package) | | Yes | +| value | [PluginBundleDependencyGithub](#pluginbundledependencygithub)
[PluginBundleDependencyMarketplace](#pluginbundledependencymarketplace)
[PluginBundleDependencyPackage](#pluginbundledependencypackage) | | Yes | + +#### PluginBundleDependencyGithub + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| packages | string | | Yes | +| release | string | | Yes | +| repo | string | | Yes | +| repo_address | string | | Yes | + +#### PluginBundleDependencyMarketplace + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| organization | string | | Yes | +| plugin | string | | Yes | +| version | string | | Yes | + +#### PluginBundleDependencyPackage + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| manifest | [PluginDeclaration](#plugindeclaration) | | Yes | +| unique_identifier | string | | Yes | #### PluginBundleDependencyType @@ -24201,12 +24254,12 @@ Plaintext secret values are not allowed in Agent config. | ---- | ---- | ----------- | -------- | | allow_delete | boolean | | Yes | | author | string | | Yes | -| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| description | [I18nObject](#i18nobject) | | Yes | | icon | string
object | | Yes | | icon_dark | string
object | | Yes | | id | string | | Yes | | is_team_authorization | boolean | | Yes | -| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| label | [I18nObject](#i18nobject) | | Yes | | labels | [ string ] | | Yes | | name | string | | Yes | | plugin_id | string | | Yes | @@ -24220,8 +24273,8 @@ Plaintext secret values are not allowed in Agent config. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | author | string | | Yes | -| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | -| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| description | [I18nObject](#i18nobject) | | Yes | +| label | [I18nObject](#i18nobject) | | Yes | | labels | [ string ] | | Yes | | name | string | | Yes | | output_schema | object | | Yes | @@ -24283,11 +24336,11 @@ Plaintext secret values are not allowed in Agent config. | category | [PluginCategory](#plugincategory) | | Yes | | created_at | dateTime | | Yes | | datasource | [DatasourceProviderEntity](#datasourceproviderentity) | | No | -| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| description | [I18nObject](#i18nobject) | | Yes | | endpoint | [EndpointProviderDeclaration](#endpointproviderdeclaration) | | No | | icon | string | | Yes | | icon_dark | string | | No | -| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| label | [I18nObject](#i18nobject) | | Yes | | meta | [Meta](#meta) | | Yes | | model | [ProviderEntity](#providerentity) | | No | | name | string | | Yes | @@ -24309,11 +24362,11 @@ Plaintext secret values are not allowed in Agent config. | category | [PluginCategory](#plugincategory) | | Yes | | created_at | dateTime | | Yes | | datasource | object | | No | -| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| description | [I18nObject](#i18nobject) | | Yes | | endpoint | object | | No | | icon | string | | Yes | | icon_dark | string | | No | -| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| label | [I18nObject](#i18nobject) | | Yes | | meta | object | | Yes | | model | [ProviderEntityResponse](#providerentityresponse) | | No | | name | string | | Yes | @@ -24340,7 +24393,30 @@ Plaintext secret values are not allowed in Agent config. | ---- | ---- | ----------- | -------- | | current_identifier | string | | No | | type | [PluginDependencyType](#plugindependencytype) | | Yes | -| value | [Github](#github)
[Marketplace](#marketplace)
[Package](#package) | | Yes | +| value | [PluginDependencyGithub](#plugindependencygithub)
[PluginDependencyMarketplace](#plugindependencymarketplace)
[PluginDependencyPackage](#plugindependencypackage) | | Yes | + +#### PluginDependencyGithub + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| github_plugin_unique_identifier | string | | Yes | +| package | string | | Yes | +| repo | string | | Yes | +| version | string | | Yes | + +#### PluginDependencyMarketplace + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| marketplace_plugin_unique_identifier | string | | Yes | +| version | string | | No | + +#### PluginDependencyPackage + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| plugin_unique_identifier | string | | Yes | +| version | string | | No | #### PluginDependencyType @@ -24722,11 +24798,11 @@ is empty when no alias exists. | ---- | ---- | ----------- | -------- | | background | string | | No | | configurate_methods | [ [ConfigurateMethod](#configuratemethod) ] | | Yes | -| description | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | +| description | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | | help | [ProviderHelpEntity](#providerhelpentity) | | No | -| icon_small | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | -| icon_small_dark | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | -| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| icon_small | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | +| icon_small_dark | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | Yes | | model_credential_schema | [ModelCredentialSchema](#modelcredentialschema) | | No | | models | [ [AIModelEntity](#aimodelentity) ] | | No | | position | object | | No | @@ -24743,11 +24819,11 @@ Runtime provider response with codegen-safe model pricing schemas. | ---- | ---- | ----------- | -------- | | background | string | | No | | configurate_methods | [ [ConfigurateMethod](#configuratemethod) ] | | Yes | -| description | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | +| description | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | | help | [ProviderHelpEntity](#providerhelpentity) | | No | -| icon_small | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | -| icon_small_dark | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | -| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| icon_small | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | +| icon_small_dark | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | Yes | | model_credential_schema | [ModelCredentialSchema](#modelcredentialschema) | | No | | models | [ [AIModelEntityResponse](#aimodelentityresponse) ],
**Default:** | | No | | position | object | | No | @@ -24762,8 +24838,8 @@ Model class for provider help. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| title | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | -| url | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| title | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | Yes | +| url | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | Yes | #### ProviderModelListResponse @@ -24781,7 +24857,7 @@ Model class for model response. | features | [ [ModelFeature](#modelfeature) ] | | No | | fetch_from | [FetchFrom](#fetchfrom) | Where the model definition comes from. `predefined-model` for built-in models, `customizable-model` for user-configured models. | Yes | | has_invalid_load_balancing_configs | boolean | | No | -| label | [I18nObject](#i18nobject) | Localized display name of the model. | Yes | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | Localized display name of the model. | Yes | | load_balancing_enabled | boolean | | No | | model | string | | Yes | | model_properties | object | | Yes | @@ -24841,11 +24917,11 @@ Model class for provider response. | background | string | | No | | configurate_methods | [ [ConfigurateMethod](#configuratemethod) ] | | Yes | | custom_configuration | [CustomConfigurationResponse](#customconfigurationresponse) | | Yes | -| description | [I18nObject](#i18nobject) | | No | +| description | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | | help | [ProviderHelpEntity](#providerhelpentity) | | No | -| icon_small | [I18nObject](#i18nobject) | | No | -| icon_small_dark | [I18nObject](#i18nobject) | | No | -| label | [I18nObject](#i18nobject) | | Yes | +| icon_small | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | +| icon_small_dark | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | Yes | | model_credential_schema | [ModelCredentialSchema](#modelcredentialschema) | | No | | preferred_provider_type | [ProviderType](#providertype) | | Yes | | provider | string | | Yes | @@ -24866,9 +24942,9 @@ Model class for provider with models response. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| icon_small | [I18nObject](#i18nobject) | | No | -| icon_small_dark | [I18nObject](#i18nobject) | | No | -| label | [I18nObject](#i18nobject) | Localized display name of the provider. | Yes | +| icon_small | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | +| icon_small_dark | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | Localized display name of the provider. | Yes | | models | [ [ProviderModelWithStatusEntity](#providermodelwithstatusentity) ] | | Yes | | provider | string | | Yes | | status | [CustomConfigurationStatus](#customconfigurationstatus) | Provider status. `active` when credentials are configured and valid. | Yes | @@ -24876,11 +24952,8 @@ Model class for provider with models response. #### PublishWorkflowPayload -Payload for publishing snippet workflow. - | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| knowledge_base_setting | object | | No | | marked_comment | string | | No | | marked_name | string | | No | @@ -24983,6 +25056,15 @@ Resource types understood by access policies. | plugin_unique_identifier | string | | Yes | | provider | string | | Yes | +#### RagPipelineDraftWorkflowRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| datasource_info_list | [ object ] | | Yes | +| datasource_type | string | | Yes | +| inputs | object | | Yes | +| start_node_id | string | | Yes | + #### RagPipelineImportCheckDependenciesResponse | Name | Type | Description | Required | @@ -25040,6 +25122,13 @@ Resource types understood by access policies. | created_at | integer | | Yes | | result | string | | Yes | +#### RagPipelineWorkflowRunQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| last_id | string | | No | +| limit | integer,
**Default:** 20 | | No | + #### RagPipelineWorkflowSyncResponse | Name | Type | Description | Required | @@ -25147,6 +25236,12 @@ Recommended app not found. | ---- | ---- | ----------- | -------- | | language | string,
**Default:** en-US | Language code for recommended app localization | No | +#### RecursiveJSONValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| RecursiveJSONValue | string
integer
number
boolean
object
[ [RecursiveJSONValue](#recursivejsonvalue) ] | | | + #### RedirectResponse | Name | Type | Description | Required | @@ -25742,6 +25837,12 @@ The Roster Agent package exceeds the size limit. | output_variable_name | string | | Yes | | type | string | | No | +#### ShallowJSONValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ShallowJSONValue | string
integer
number
boolean
object
[ object ] | | | + #### SimpleAccount | Name | Type | Description | Required | @@ -25804,7 +25905,7 @@ The Roster Agent package exceeds the size limit. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| model | [JSONValue](#jsonvalue) | | No | +| model | [AnyJSONValue](#anyjsonvalue) | | No | | pre_prompt | string | | No | #### SimpleProviderEntityResponse @@ -25813,9 +25914,9 @@ Simple provider entity response. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| icon_small | [I18nObject](#i18nobject) | | No | -| icon_small_dark | [I18nObject](#i18nobject) | | No | -| label | [I18nObject](#i18nobject) | | Yes | +| icon_small | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | +| icon_small_dark | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | Yes | | models | [ [AIModelEntityResponse](#aimodelentityresponse) ],
**Default:** | | No | | provider | string | | Yes | | provider_name | string | | No | @@ -26376,6 +26477,16 @@ Payload for running a loop node in snippet draft workflow. | page | integer | | Yes | | total | integer | | Yes | +#### SnippetPublishWorkflowPayload + +Payload for publishing snippet workflow. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| knowledge_base_setting | object | | No | +| marked_comment | string | | No | +| marked_name | string | | No | + #### SnippetResponse | Name | Type | Description | Required | @@ -26459,6 +26570,15 @@ Query parameters for listing snippet published workflows. | version | string | | Yes | | version_number | integer | | No | +#### SnippetWorkflowRunQuery + +Query parameters for workflow runs. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| last_id | string | | No | +| limit | integer,
**Default:** 20 | | No | + #### SpeechToTextDisabledError Speech to text is disabled. @@ -27097,10 +27217,10 @@ removes TOOLS_SELECTOR from PluginParameterType | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | author | string | The author of the tool | Yes | -| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The description of the tool | Yes | +| description | [I18nObject](#i18nobject) | The description of the tool | Yes | | icon | string | The icon of the tool | Yes | | icon_dark | string | The dark icon of the tool | No | -| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The label of the tool | Yes | +| label | [I18nObject](#i18nobject) | The label of the tool | Yes | | name | string | The name of the tool | Yes | | tags | [ [ToolLabelEnum](#toollabelenum) ] | The tags of the tool | No | @@ -27519,10 +27639,10 @@ The identity of the trigger provider | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | author | string | The author of the trigger provider | Yes | -| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The description of the trigger provider | Yes | +| description | [I18nObject](#i18nobject) | The description of the trigger provider | Yes | | icon | string | The icon of the trigger provider | No | | icon_dark | string | The dark icon of the trigger provider | No | -| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | The label of the trigger provider | Yes | +| label | [I18nObject](#i18nobject) | The label of the trigger provider | Yes | | name | string | The name of the trigger provider | Yes | | tags | [ string ] | The tags of the trigger provider | No | @@ -27813,6 +27933,12 @@ The app access service is unavailable. Try again later. | ---- | ---- | ----------- | -------- | | protocol | [SSOProtocol](#ssoprotocol) | | Yes | +#### WebhookTriggerQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| node_id | string | | Yes | + #### WebhookTriggerResponse | Name | Type | Description | Required | @@ -28216,7 +28342,7 @@ How a workflow node is bound to an Agent. | name | string | | Yes | | selector | [ string ] | | Yes | | type | string | | Yes | -| value | [JSONValue](#jsonvalue) | | Yes | +| value | [RecursiveJSONValue](#recursivejsonvalue) | | Yes | | value_type | string | | Yes | | visible | boolean | | Yes | @@ -28869,15 +28995,6 @@ Workflow run not found. | files | [ object
object
object
object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | | inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes | -#### WorkflowRunQuery - -Query parameters for workflow runs. - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| last_id | string | | No | -| limit | integer,
**Default:** 20 | | No | - #### WorkflowRunRequest | Name | Type | Description | Required | @@ -29207,26 +29324,6 @@ Workflow tool configuration | page | integer | | No | | reverse | boolean | | No | -#### core__tools__entities__common_entities__I18nObject - -Model class for i18n object. - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| en_US | string | | Yes | -| ja_JP | string | | No | -| pt_BR | string | | No | -| zh_Hans | string | | No | - -#### graphon__model_runtime__entities__common_entities__I18nObject - -Model class for i18n object. - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| en_US | string | | Yes | -| zh_Hans | string | | No | - ## FastOpenAPI Preview (OpenAPI 3.1) ### Dify API (FastOpenAPI PoC) diff --git a/api/openapi/markdown/openapi-openapi.md b/api/openapi/markdown/openapi-openapi.md index 7b071d31b37d67..d511431ea4dd31 100644 --- a/api/openapi/markdown/openapi-openapi.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -951,15 +951,6 @@ than an under-annotated open object. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -#### Github - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| github_plugin_unique_identifier | string | | Yes | -| package | string | | Yes | -| repo | string | | Yes | -| version | string | | Yes | - #### HealthResponse Liveness payload for `GET /openapi/v1/_health` — no auth required. @@ -1015,13 +1006,6 @@ A next step the caller can hand straight to `call --input `. | ---- | ---- | ----------- | -------- | | JsonValue | | | | -#### Marketplace - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| marketplace_plugin_unique_identifier | string | | Yes | -| version | string | | No | - #### MemberActionResponse | Name | Type | Description | Required | @@ -1106,13 +1090,6 @@ The console payload plus local file parts; `_files.merge_files` sets them on `in | files | object | Local files keyed by the form's file input name, same convention as the run ops' `files` | No | | inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes | -#### Package - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| plugin_unique_identifier | string | | Yes | -| version | string | | No | - #### PermittedExternalAppsListQuery Strict (extra='forbid'). @@ -1141,7 +1118,30 @@ Strict (extra='forbid'). | ---- | ---- | ----------- | -------- | | current_identifier | string | | No | | type | [PluginDependencyType](#plugindependencytype) | | Yes | -| value | [Github](#github)
[Marketplace](#marketplace)
[Package](#package) | | Yes | +| value | [PluginDependencyGithub](#plugindependencygithub)
[PluginDependencyMarketplace](#plugindependencymarketplace)
[PluginDependencyPackage](#plugindependencypackage) | | Yes | + +#### PluginDependencyGithub + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| github_plugin_unique_identifier | string | | Yes | +| package | string | | Yes | +| repo | string | | Yes | +| version | string | | Yes | + +#### PluginDependencyMarketplace + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| marketplace_plugin_unique_identifier | string | | Yes | +| version | string | | No | + +#### PluginDependencyPackage + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| plugin_unique_identifier | string | | Yes | +| version | string | | No | #### PluginDependencyType diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index fa30f98a51c48d..0f236bb5d06c39 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -2428,7 +2428,7 @@ Agent has not been published. Please publish the Agent before using the API. | thought | string | | No | | tool | string | | No | | tool_input | string | | No | -| tool_labels | [JSONValue](#jsonvalue) | Labels for tools used. | Yes | +| tool_labels | [AnyJSONValue](#anyjsonvalue) | Labels for tools used. | Yes | #### Annotation @@ -2488,6 +2488,12 @@ Agent has not been published. Please publish the Agent before using the API. | embedding_provider_name | string | Name of the embedding model provider. | Yes | | score_threshold | float | Minimum similarity score for an annotation to be considered a match. Higher values require closer matches. | Yes | +#### AnyJSONValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AnyJSONValue | | | | + #### AppFeedbackListResponse | Name | Type | Description | Required | @@ -2560,7 +2566,7 @@ Audio size exceeded. {message} | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| text | string | | Yes | +| text | string | Transcribed text from audio | Yes | #### BinaryFileResponse @@ -3769,15 +3775,6 @@ This form has already been submitted by another user, form_id={form_id} | ---- | ---- | ----------- | -------- | | HumanInputFormSubmittedError | | This form has already been submitted by another user, form_id={form_id} | | -#### I18nObject - -Model class for i18n object. - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| en_US | string | | Yes | -| zh_Hans | string | | No | - #### IndexInfoResponse | Name | Type | Description | Required | @@ -3814,12 +3811,6 @@ The metadata content is incorrect. Please check and verify. | ---- | ---- | ----------- | -------- | | JSONObject | object | | | -#### JSONValue - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| JSONValue | | | | - #### JSONValueType | Name | Type | Description | Required | @@ -3979,6 +3970,15 @@ Enum class for model property key. | ---- | ---- | ----------- | -------- | | ModelPropertyKey | string | Enum class for model property key. | | +#### ModelRuntimeI18nObject + +Model class for i18n object. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| en_US | string | | Yes | +| zh_Hans | string | | No | + #### ModelStatus Enum class for model status. @@ -4167,7 +4167,7 @@ Model class for model response. | features | [ [ModelFeature](#modelfeature) ] | | No | | fetch_from | [FetchFrom](#fetchfrom) | Where the model definition comes from. `predefined-model` for built-in models, `customizable-model` for user-configured models. | Yes | | has_invalid_load_balancing_configs | boolean | | No | -| label | [I18nObject](#i18nobject) | Localized display name of the model. | Yes | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | Localized display name of the model. | Yes | | load_balancing_enabled | boolean | | No | | model | string | | Yes | | model_properties | object | | Yes | @@ -4210,9 +4210,9 @@ Model class for provider with models response. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| icon_small | [I18nObject](#i18nobject) | | No | -| icon_small_dark | [I18nObject](#i18nobject) | | No | -| label | [I18nObject](#i18nobject) | Localized display name of the provider. | Yes | +| icon_small | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | +| icon_small_dark | [ModelRuntimeI18nObject](#modelruntimei18nobject) | | No | +| label | [ModelRuntimeI18nObject](#modelruntimei18nobject) | Localized display name of the provider. | Yes | | models | [ [ProviderModelWithStatusEntity](#providermodelwithstatusentity) ] | | Yes | | provider | string | | Yes | | status | [CustomConfigurationStatus](#customconfigurationstatus) | Provider status. `active` when credentials are configured and valid. | Yes | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index b5531af9717e11..8a7d6fa1a28452 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1016,7 +1016,13 @@ Agent has not been published. Please publish the Agent before using the web app. | thought | string | | No | | tool | string | | No | | tool_input | string | | No | -| tool_labels | [JSONValue](#jsonvalue) | Labels for tools used. | Yes | +| tool_labels | [AnyJSONValue](#anyjsonvalue) | Labels for tools used. | Yes | + +#### AnyJSONValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AnyJSONValue | | | | #### AppAccessModeQuery @@ -1490,12 +1496,6 @@ Upload token is invalid or expired. | ---- | ---- | ----------- | -------- | | InvalidUploadTokenError | | Upload token is invalid or expired. | | -#### JSONValue - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| JSONValue | string
integer
number
boolean
object
[ object ] | | | - #### JSONValueType | Name | Type | Description | Required | @@ -1873,6 +1873,12 @@ The remote file URL is not allowed. | output_variable_name | string | | Yes | | type | string | | No | +#### ShallowJSONValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ShallowJSONValue | string
integer
number
boolean
object
[ object ] | | | + #### SimpleConversation | Name | Type | Description | Required | From ce6c9d181afc30d2fa16aa3f55a1ebf3c6de699b Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:29:00 +0800 Subject: [PATCH 04/60] refactor(web): migrate annotation requests to the generated console contract Call consoleClient directly for annotation settings, list, CRUD, export, hit histories and job status, and type annotation items with the generated contract. Only the multipart batch import stays on the handwritten request layer. The bulk delete endpoint now documents its repeated `annotation_id` query, and the console client sends it as repeated keys: without them the API receives no IDs and clears every annotation of the app. Co-Authored-By: Claude Opus 5.5 --- api/controllers/console/app/annotation.py | 8 ++ oxlint-suppressions.json | 18 +-- .../generated/api/console/apps/orpc.gen.ts | 8 +- .../generated/api/console/apps/types.gen.ts | 4 +- .../generated/api/console/apps/zod.gen.ts | 4 + .../app/annotation/__tests__/index.spec.tsx | 104 ++++++++++-------- .../app/annotation/__tests__/list.spec.tsx | 4 +- .../__tests__/index.spec.tsx | 27 ++++- .../batch-add-annotation-modal/index.tsx | 13 +-- .../__tests__/index.spec.tsx | 16 ++- .../edit-annotation-modal/index.tsx | 11 +- .../header-opts/__tests__/index.spec.tsx | 16 ++- .../app/annotation/header-opts/index.tsx | 27 ++--- web/app/components/app/annotation/index.tsx | 59 +++++----- web/app/components/app/annotation/list.tsx | 16 ++- web/app/components/app/annotation/type.ts | 20 +--- .../__tests__/index.spec.tsx | 18 ++- .../view-annotation-modal/index.tsx | 43 +++++--- .../app/log/__tests__/list.spec.tsx | 17 ++- web/app/components/app/log/list.tsx | 7 +- .../chat/answer/__tests__/operation.spec.tsx | 7 +- .../base/chat/chat/answer/operation.tsx | 1 - .../__tests__/annotation-ctrl-button.spec.tsx | 51 +++------ .../__tests__/use-annotation-config.spec.ts | 28 +++-- .../annotation-ctrl-button.tsx | 13 +-- .../annotation-reply/use-annotation-config.ts | 10 +- web/service/annotation.ts | 104 +++--------------- web/service/console/client.spec.ts | 11 ++ web/service/console/openapi-url.ts | 1 + 29 files changed, 324 insertions(+), 342 deletions(-) diff --git a/api/controllers/console/app/annotation.py b/api/controllers/console/app/annotation.py index 0be2c5474e95ad..a304ef7b27bc6e 100644 --- a/api/controllers/console/app/annotation.py +++ b/api/controllers/console/app/annotation.py @@ -76,6 +76,13 @@ class AnnotationListQuery(BaseModel): keyword: str = Field(default="", description="Search keyword") +class AnnotationBatchDeleteQuery(BaseModel): + annotation_id: list[str] = Field( + default_factory=list, + description="Annotation IDs to delete; omitting it clears all annotations of the app", + ) + + class CreateAnnotationPayload(BaseModel): message_id: str | None = Field(default=None, description="Message ID") question: str | None = Field(default=None, description="Question text") @@ -344,6 +351,7 @@ def post(self, req_data: CreateAnnotationPayload, session: Session, app_id: UUID @account_initialization_required @edit_permission_required @rbac_permission_required(RBACCheck(RBACPermission.APP_CREATE_AND_MANAGEMENT, PlainApp())) + @console_ns.doc(params=query_params_from_model(AnnotationBatchDeleteQuery)) @console_ns.response(204, "Annotations deleted successfully") @with_session def delete(self, session: Session, app_id: UUID): diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 0609dba28903e2..85a29439506cbb 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -132,25 +132,17 @@ "count": 1 } }, - "web/app/components/app/annotation/header-opts/index.tsx": { - "typescript/no-explicit-any": { - "count": 1 - } - }, "web/app/components/app/annotation/index.tsx": { "eslint-react/set-state-in-effect": { "count": 1 }, "typescript/no-explicit-any": { - "count": 5 + "count": 1 } }, "web/app/components/app/annotation/view-annotation-modal/index.tsx": { "eslint-react/set-state-in-effect": { "count": 5 - }, - "typescript/no-explicit-any": { - "count": 1 } }, "web/app/components/app/app-publisher/__tests__/features-wrapper.spec.tsx": { @@ -796,11 +788,6 @@ "count": 3 } }, - "web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts": { - "typescript/no-explicit-any": { - "count": 2 - } - }, "web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx": { "dify/require-disable-directive-description": { "count": 1 @@ -4086,9 +4073,6 @@ "web/service/annotation.ts": { "no-restricted-imports": { "count": 1 - }, - "typescript/no-explicit-any": { - "count": 3 } }, "web/service/apps.ts": { diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index c42f6ce83a1237..e8dfca8fb6697f 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -63,6 +63,7 @@ import { zDeleteAppsByAppIdAnnotationsByAnnotationIdPath, zDeleteAppsByAppIdAnnotationsByAnnotationIdResponse, zDeleteAppsByAppIdAnnotationsPath, + zDeleteAppsByAppIdAnnotationsQuery, zDeleteAppsByAppIdAnnotationsResponse, zDeleteAppsByAppIdChatConversationsByConversationIdPath, zDeleteAppsByAppIdChatConversationsByConversationIdResponse, @@ -1542,7 +1543,12 @@ export const delete4 = oc successStatus: 204, tags: ['console'], }) - .input(z.object({ params: zDeleteAppsByAppIdAnnotationsPath })) + .input( + z.object({ + params: zDeleteAppsByAppIdAnnotationsPath, + query: zDeleteAppsByAppIdAnnotationsQuery.optional(), + }), + ) .output(zDeleteAppsByAppIdAnnotationsResponse) /** diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 93c47d621aa83e..23d0600605b99b 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -5471,7 +5471,9 @@ export type DeleteAppsByAppIdAnnotationsData = { path: { app_id: string } - query?: never + query?: { + annotation_id?: Array + } url: '/apps/{app_id}/annotations' } diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 39464d30201bec..6fb39ce919d47d 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -6545,6 +6545,10 @@ export const zDeleteAppsByAppIdAnnotationsPath = z.object({ app_id: z.uuid(), }) +export const zDeleteAppsByAppIdAnnotationsQuery = z.object({ + annotation_id: z.array(z.string()).optional(), +}) + /** * Annotations deleted successfully */ diff --git a/web/app/components/app/annotation/__tests__/index.spec.tsx b/web/app/components/app/annotation/__tests__/index.spec.tsx index b919d82bd650d2..b45c60ab155d7f 100644 --- a/web/app/components/app/annotation/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/__tests__/index.spec.tsx @@ -1,20 +1,8 @@ import type { ReactElement } from 'react' -import type { Mock } from 'vite-plus/test' import type { AnnotationItem } from '../type' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { toast } from '@/app/notifications' -import { - addAnnotation, - delAnnotation, - delAnnotations, - editAnnotation, - fetchAnnotationConfig, - fetchAnnotationList, - queryAnnotationJobStatus, - updateAnnotationScore, - updateAnnotationStatus, -} from '@/service/annotation' import { renderWithConsoleQuery } from '@/test/console/query-data' import { createAppDetailFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' @@ -31,18 +19,43 @@ vi.mock('ahooks', () => ({ useDebounce: (value: any) => value, })) -vi.mock('@/service/annotation', () => ({ +const mocks = vi.hoisted(() => ({ addAnnotation: vi.fn(), delAnnotation: vi.fn(), delAnnotations: vi.fn(), - fetchAnnotationConfig: vi.fn(), editAnnotation: vi.fn(), + fetchAnnotationConfig: vi.fn(), fetchAnnotationList: vi.fn(), queryAnnotationJobStatus: vi.fn(), updateAnnotationScore: vi.fn(), updateAnnotationStatus: vi.fn(), })) +vi.mock('@/service/annotation', () => ({ + updateAnnotationStatus: mocks.updateAnnotationStatus, +})) + +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + apps: { + byAppId: { + annotationSetting: { get: mocks.fetchAnnotationConfig }, + annotationReply: { + byAction: { status: { byJobId: { get: mocks.queryAnnotationJobStatus } } }, + }, + annotationSettings: { byAnnotationSettingId: { post: mocks.updateAnnotationScore } }, + annotations: { + get: mocks.fetchAnnotationList, + post: mocks.addAnnotation, + delete: mocks.delAnnotations, + byAnnotationId: { delete: mocks.delAnnotation, post: mocks.editAnnotation }, + }, + }, + }, + }, +})) + vi.mock('../filter', () => ({ default: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -169,15 +182,15 @@ vi.spyOn(toast, 'info').mockImplementation((message, options) => { mockNotify({ type: 'info', message, ...options }) return 'toast-info-id' }) -const addAnnotationMock = addAnnotation as Mock -const delAnnotationMock = delAnnotation as Mock -const delAnnotationsMock = delAnnotations as Mock -const editAnnotationMock = editAnnotation as Mock -const fetchAnnotationConfigMock = fetchAnnotationConfig as Mock -const fetchAnnotationListMock = fetchAnnotationList as Mock -const queryAnnotationJobStatusMock = queryAnnotationJobStatus as Mock -const updateAnnotationScoreMock = updateAnnotationScore as Mock -const updateAnnotationStatusMock = updateAnnotationStatus as Mock +const addAnnotationMock = mocks.addAnnotation +const delAnnotationMock = mocks.delAnnotation +const delAnnotationsMock = mocks.delAnnotations +const editAnnotationMock = mocks.editAnnotation +const fetchAnnotationConfigMock = mocks.fetchAnnotationConfig +const fetchAnnotationListMock = mocks.fetchAnnotationList +const queryAnnotationJobStatusMock = mocks.queryAnnotationJobStatus +const updateAnnotationScoreMock = mocks.updateAnnotationScore +const updateAnnotationStatusMock = mocks.updateAnnotationStatus const appDetail = createAppDetailFixture({ id: 'app-id', @@ -232,13 +245,10 @@ describe('Annotation', () => { 'https://docs.example.com/use-dify/monitor/annotation-reply', ) expect(await screen.findByTestId('empty-element')).toBeInTheDocument() - expect(fetchAnnotationListMock).toHaveBeenCalledWith( - appDetail.id, - expect.objectContaining({ - page: 1, - keyword: '', - }), - ) + expect(fetchAnnotationListMock).toHaveBeenCalledWith({ + params: { app_id: appDetail.id }, + query: expect.objectContaining({ page: 1, keyword: '' }), + }) }) it('should handle annotation creation and refresh list data', async () => { @@ -252,9 +262,9 @@ describe('Annotation', () => { fireEvent.click(screen.getByTestId('trigger-add')) await waitFor(() => { - expect(addAnnotationMock).toHaveBeenCalledWith(appDetail.id, { - question: 'new question', - answer: 'new answer', + expect(addAnnotationMock).toHaveBeenCalledWith({ + params: { app_id: appDetail.id }, + body: { question: 'new question', answer: 'new answer' }, }) expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -300,7 +310,10 @@ describe('Annotation', () => { await latestListProps.onBatchDelete() }) await waitFor(() => { - expect(delAnnotationsMock).toHaveBeenCalledWith(appDetail.id, [annotation.id]) + expect(delAnnotationsMock).toHaveBeenCalledWith({ + params: { app_id: appDetail.id }, + query: { annotation_id: [annotation.id] }, + }) expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ type: 'success', @@ -316,7 +329,9 @@ describe('Annotation', () => { fireEvent.click(screen.getByTestId('view-modal-remove')) }) await waitFor(() => { - expect(delAnnotationMock).toHaveBeenCalledWith(appDetail.id, annotation.id) + expect(delAnnotationMock).toHaveBeenCalledWith({ + params: { app_id: appDetail.id, annotation_id: annotation.id }, + }) }) }) @@ -402,11 +417,9 @@ describe('Annotation', () => { }), 0.5, ) - expect(queryAnnotationJobStatusMock).toHaveBeenCalledWith( - appDetail.id, - AnnotationEnableStatus.disable, - 'job-1', - ) + expect(queryAnnotationJobStatusMock).toHaveBeenCalledWith({ + params: { app_id: appDetail.id, action: AnnotationEnableStatus.disable, job_id: 'job-1' }, + }) expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false') }) }) @@ -440,7 +453,10 @@ describe('Annotation', () => { }, 0.7, ) - expect(updateAnnotationScoreMock).toHaveBeenCalledWith(appDetail.id, 'config-id', 0.7) + expect(updateAnnotationScoreMock).toHaveBeenCalledWith({ + params: { app_id: appDetail.id, annotation_setting_id: 'config-id' }, + body: { score_threshold: 0.7 }, + }) expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ message: 'common.api.actionSuccess', @@ -462,9 +478,9 @@ describe('Annotation', () => { fireEvent.click(screen.getByTestId('view-modal-save')) await waitFor(() => { - expect(editAnnotationMock).toHaveBeenCalledWith(appDetail.id, annotation.id, { - question: 'Edited question', - answer: 'Edited answer', + expect(editAnnotationMock).toHaveBeenCalledWith({ + params: { app_id: appDetail.id, annotation_id: annotation.id }, + body: { question: 'Edited question', answer: 'Edited answer' }, }) }) diff --git a/web/app/components/app/annotation/__tests__/list.spec.tsx b/web/app/components/app/annotation/__tests__/list.spec.tsx index 764651a96339fd..14b40c3776711d 100644 --- a/web/app/components/app/annotation/__tests__/list.spec.tsx +++ b/web/app/components/app/annotation/__tests__/list.spec.tsx @@ -52,7 +52,7 @@ describe('List', () => { />, ) - fireEvent.click(screen.getByText(item.question)) + fireEvent.click(screen.getByText(item.question!)) expect(onView).toHaveBeenCalledWith(item) expect(mockFormatTime).toHaveBeenCalledWith(item.created_at, 'appLog.dateTimeFormat') @@ -113,7 +113,7 @@ describe('List', () => { />, ) - const row = screen.getByText(item.question).closest('tr') as HTMLTableRowElement + const row = screen.getByText(item.question!).closest('tr') as HTMLTableRowElement const actionButtons = within(row).getAllByRole('button') fireEvent.click(actionButtons[1]!) diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx index 22744957f9c714..fc8a631e18ee47 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx @@ -1,19 +1,34 @@ import type { ReactElement } from 'react' -import type { Mock } from 'vite-plus/test' import type { IBatchModalProps } from '../index' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation' import { renderWithConsoleQuery } from '@/test/console/query-data' import BatchModal, { ProcessStatus } from '../index' let annotationQuota = { size: 0, limit: 10 } -vi.mock('@/service/annotation', () => ({ +const mocks = vi.hoisted(() => ({ annotationBatchImport: vi.fn(), checkAnnotationBatchImportProgress: vi.fn(), })) +vi.mock('@/service/annotation', () => ({ + annotationBatchImport: mocks.annotationBatchImport, +})) + +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + apps: { + byAppId: { + annotations: { + batchImportStatus: { byJobId: { get: mocks.checkAnnotationBatchImportProgress } }, + }, + }, + }, + }, +})) + vi.mock('../csv-downloader', () => ({ default: () =>
, })) @@ -53,8 +68,8 @@ vi.mock('@/app/notifications', () => ({ info: (message: string) => mockNotify({ type: 'info', message }), }, })) -const annotationBatchImportMock = annotationBatchImport as Mock -const checkAnnotationBatchImportProgressMock = checkAnnotationBatchImportProgress as Mock +const annotationBatchImportMock = mocks.annotationBatchImport +const checkAnnotationBatchImportProgressMock = mocks.checkAnnotationBatchImportProgress const renderComponent = (props: Partial = {}) => { const mergedProps: IBatchModalProps = { @@ -141,7 +156,7 @@ describe('BatchModal', () => { expect(annotationBatchImportMock).toHaveBeenCalledTimes(1) }) - const formData = annotationBatchImportMock.mock.calls[0]![0].body as FormData + const formData = annotationBatchImportMock.mock.calls[0]![1] as FormData expect(formData.get('file')).toBe(lastUploadedFile) await waitFor(() => { diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx index dc6b732ffb8fb7..dc3872a5d05e88 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx @@ -11,8 +11,8 @@ import { useTranslation } from 'react-i18next' import AnnotationFull from '@/app/components/billing/annotation-full' import { toast } from '@/app/notifications' import { deploymentEditionAtom } from '@/features/system-features/state' -import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation' -import { consoleQuery } from '@/service/console' +import { annotationBatchImport } from '@/service/annotation' +import { consoleClient, consoleQuery } from '@/service/console' import CSVDownloader from './csv-downloader' import CSVUploader from './csv-uploader' @@ -59,7 +59,9 @@ const BatchModal: FC = ({ appId, isShow, onCancel, onAdded }) const [importStatus, setImportStatus] = useState() const checkProcess = async (jobID: string) => { try { - const res = await checkAnnotationBatchImportProgress({ jobID, appId }) + const res = await consoleClient.apps.byAppId.annotations.batchImportStatus.byJobId.get({ + params: { app_id: appId, job_id: jobID }, + }) setImportStatus(res.job_status) if (res.job_status === ProcessStatus.WAITING || res.job_status === ProcessStatus.PROCESSING) setTimeout(() => checkProcess(res.job_id), 2500) @@ -81,10 +83,7 @@ const BatchModal: FC = ({ appId, isShow, onCancel, onAdded }) const formData = new FormData() formData.append('file', csv) try { - const res = await annotationBatchImport({ - url: `/apps/${appId}/annotations/batch-import`, - body: formData, - }) + const res = await annotationBatchImport(appId, formData) setImportStatus(res.job_status) checkProcess(res.job_id) } catch (e: any) { diff --git a/web/app/components/app/annotation/edit-annotation-modal/__tests__/index.spec.tsx b/web/app/components/app/annotation/edit-annotation-modal/__tests__/index.spec.tsx index 4030169a51717e..edabfefcaef920 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/__tests__/index.spec.tsx @@ -8,8 +8,10 @@ const { mockEditAnnotation } = vi.hoisted(() => ({ })) // Mock only external dependencies -vi.mock('@/service/annotation', () => ({ - editAnnotation: mockEditAnnotation, +vi.mock('@/service/console', () => ({ + consoleClient: { + apps: { byAppId: { annotations: { byAnnotationId: { post: mockEditAnnotation } } } }, + }, })) vi.mock('@/hooks/use-timestamp', () => ({ @@ -197,7 +199,6 @@ describe('EditAnnotationModal', () => { const props = { ...defaultProps, annotationId: 'test-annotation-id', - messageId: 'test-message-id', onEdited: mockOnEdited, } const user = userEvent.setup() @@ -217,10 +218,9 @@ describe('EditAnnotationModal', () => { await user.click(saveButton) // Assert - expect(mockEditAnnotation).toHaveBeenCalledWith('test-app-id', 'test-annotation-id', { - message_id: 'test-message-id', - question: 'Modified query', - answer: 'Test answer', + expect(mockEditAnnotation).toHaveBeenCalledWith({ + params: { app_id: 'test-app-id', annotation_id: 'test-annotation-id' }, + body: { question: 'Modified query', answer: 'Test answer' }, }) }) }) @@ -413,7 +413,6 @@ describe('EditAnnotationModal', () => { const props = { ...defaultProps, annotationId: 'test-annotation-id', - messageId: 'test-message-id', onEdited: mockOnEdited, } const user = userEvent.setup() @@ -453,7 +452,6 @@ describe('EditAnnotationModal', () => { const props = { ...defaultProps, annotationId: 'test-annotation-id', - messageId: 'test-message-id', onEdited: mockOnEdited, } const user = userEvent.setup() diff --git a/web/app/components/app/annotation/edit-annotation-modal/index.tsx b/web/app/components/app/annotation/edit-annotation-modal/index.tsx index 00f3d94bde5902..2adee68ad5ac24 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/index.tsx @@ -26,14 +26,13 @@ import { useRef } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' import useTimestamp from '@/hooks/use-timestamp' -import { editAnnotation } from '@/service/annotation' +import { consoleClient } from '@/service/console' import EditItem, { EditItemType } from './edit-item' type Props = Readonly<{ isShow: boolean onHide: () => void appId: string - messageId?: string annotationId: string query: string answer: string @@ -50,7 +49,6 @@ const EditAnnotationModal: FC = ({ answer, onEdited, appId, - messageId, annotationId, createdAt, onRemove, @@ -64,10 +62,9 @@ const EditAnnotationModal: FC = ({ if (type === EditItemType.Query) postQuery = editedContent else postAnswer = editedContent try { - await editAnnotation(appId, annotationId, { - message_id: messageId, - question: postQuery, - answer: postAnswer, + await consoleClient.apps.byAppId.annotations.byAnnotationId.post({ + params: { app_id: appId, annotation_id: annotationId }, + body: { question: postQuery, answer: postAnswer }, }) onEdited(postQuery, postAnswer) diff --git a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx index 840c71a1b53f26..de3aa10e516cce 100644 --- a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx @@ -7,7 +7,6 @@ import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useLocale } from '#i18n' import { LanguagesSupported } from '@/i18n/language' -import { fetchExportAnnotationList } from '@/service/annotation' import { renderWithConsoleQuery as render } from '@/test/console/query-data' import HeaderOptions from '../index' @@ -22,8 +21,15 @@ vi.mock('react-papaparse', () => ({ jsonToCSV: (data: unknown) => mockJsonToCSV(data), })) -vi.mock('@/service/annotation', () => ({ - fetchExportAnnotationList: vi.fn(), +const { mockedFetchAnnotations } = vi.hoisted(() => ({ + mockedFetchAnnotations: vi.fn(), +})) + +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + apps: { byAppId: { annotations: { export: { get: mockedFetchAnnotations } } } }, + }, })) const { mockedRequest } = vi.hoisted(() => ({ @@ -104,8 +110,6 @@ const mockAnnotations: AnnotationItemBasic[] = [ }, ] -const mockedFetchAnnotations = vi.mocked(fetchExportAnnotationList) - describe('HeaderOptions', () => { const originalUrl = window.location.href @@ -128,7 +132,7 @@ describe('HeaderOptions', () => { renderComponent() await waitFor(() => { - expect(mockedFetchAnnotations).toHaveBeenCalledWith('test-app-id') + expect(mockedFetchAnnotations).toHaveBeenCalledWith({ params: { app_id: 'test-app-id' } }) }) await expandExportMenu(user) diff --git a/web/app/components/app/annotation/header-opts/index.tsx b/web/app/components/app/annotation/header-opts/index.tsx index 882c03b91b0e40..01b3cc6c645a6d 100644 --- a/web/app/components/app/annotation/header-opts/index.tsx +++ b/web/app/components/app/annotation/header-opts/index.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import type { AnnotationItemBasic } from '../type' +import type { AnnotationItem, AnnotationItemBasic } from '../type' import { AlertDialog } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { @@ -19,8 +19,7 @@ import { useTranslation } from 'react-i18next' import { jsonToCSV } from 'react-papaparse' import { useLocale } from '#i18n' import { LanguagesSupported } from '@/i18n/language' -import { fetchExportAnnotationList } from '@/service/annotation' -import { consoleQuery } from '@/service/console' +import { consoleClient, consoleQuery } from '@/service/console' import { downloadBlob } from '@/utils/download' import AddAnnotationModal from '../add-annotation-modal' import BatchAddModal from '../batch-add-annotation-modal' @@ -37,27 +36,27 @@ type Props = Readonly<{ }> type OperationsMenuProps = { - list: AnnotationItemBasic[] + list: AnnotationItem[] onBulkImport: () => void onClearAll: () => void onExportJsonl: () => void } -const buildAnnotationJsonlRecords = (list: AnnotationItemBasic[]) => - list.map((item: AnnotationItemBasic) => { - return `{"messages": [{"role": "system", "content": ""}, {"role": "user", "content": ${JSON.stringify(item.question)}}, {"role": "assistant", "content": ${JSON.stringify(item.answer)}}]}` +const buildAnnotationJsonlRecords = (list: AnnotationItem[]) => + list.map((item) => { + return `{"messages": [{"role": "system", "content": ""}, {"role": "user", "content": ${JSON.stringify(item.question ?? '')}}, {"role": "assistant", "content": ${JSON.stringify(item.answer ?? '')}}]}` }) -const downloadAnnotationJsonl = (list: AnnotationItemBasic[], locale: string) => { +const downloadAnnotationJsonl = (list: AnnotationItem[], locale: string) => { const content = buildAnnotationJsonlRecords(list).join('\n') const file = new Blob([content], { type: 'application/jsonl' }) downloadBlob({ data: file, fileName: `annotations-${locale}.jsonl` }) } -const downloadAnnotationCsv = (list: AnnotationItemBasic[], locale: string) => { +const downloadAnnotationCsv = (list: AnnotationItem[], locale: string) => { const content = jsonToCSV([ locale !== LanguagesSupported[1] ? CSV_HEADER_QA_EN : CSV_HEADER_QA_CN, - ...list.map((item) => [item.question, item.answer]), + ...list.map((item) => [item.question ?? '', item.answer ?? '']), ]) const file = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8;' }) downloadBlob({ data: file, fileName: `annotations-${locale}.csv` }) @@ -131,11 +130,13 @@ const OperationsMenu: FC = ({ const HeaderOptions: FC = ({ appId, onAdd, onAdded, controlUpdateList }) => { const { t } = useTranslation(['appAnnotation', 'common']) const locale = useLocale() - const [list, setList] = useState([]) + const [list, setList] = useState([]) const fetchList = React.useCallback(async () => { - const { data }: any = await fetchExportAnnotationList(appId) - setList(data as AnnotationItemBasic[]) + const { data } = await consoleClient.apps.byAppId.annotations.export.get({ + params: { app_id: appId }, + }) + setList(data) }, [appId]) useEffect(() => { diff --git a/web/app/components/app/annotation/index.tsx b/web/app/components/app/annotation/index.tsx index 148e57122eca55..9a38dd785eff36 100644 --- a/web/app/components/app/annotation/index.tsx +++ b/web/app/components/app/annotation/index.tsx @@ -23,18 +23,8 @@ import { toast } from '@/app/notifications' import { APP_PAGE_LIMIT } from '@/config' import { useDocLink } from '@/context/i18n' import { deploymentEditionAtom } from '@/features/system-features/state' -import { - addAnnotation, - delAnnotation, - delAnnotations, - fetchAnnotationConfig as doFetchAnnotationConfig, - editAnnotation, - fetchAnnotationList, - queryAnnotationJobStatus, - updateAnnotationScore, - updateAnnotationStatus, -} from '@/service/annotation' -import { consoleQuery } from '@/service/console' +import { updateAnnotationStatus } from '@/service/annotation' +import { consoleClient, consoleQuery } from '@/service/console' import { AppModeEnum } from '@/types/app' import { sleep } from '@/utils' import PageTitle from '../log-annotation/page-title' @@ -87,7 +77,9 @@ const Annotation: FC = (props) => { const debouncedQueryParams = useDebounce(queryParams, { wait: 500 }) const fetchAnnotationConfig = async () => { - const res = await doFetchAnnotationConfig(appDetail.id) + const res = await consoleClient.apps.byAppId.annotationSetting.get({ + params: { app_id: appDetail.id }, + }) setAnnotationConfig(res as AnnotationReplyConfig) return (res as AnnotationReplyConfig).id } @@ -98,7 +90,9 @@ const Annotation: FC = (props) => { const ensureJobCompleted = async (jobId: string, status: AnnotationEnableStatus) => { while (true) { - const res: any = await queryAnnotationJobStatus(appDetail.id, status, jobId) + const res = await consoleClient.apps.byAppId.annotationReply.byAction.status.byJobId.get({ + params: { app_id: appDetail.id, action: status, job_id: jobId }, + }) if (res.job_status === JobStatus.completed) break await sleep(2000) } @@ -107,12 +101,11 @@ const Annotation: FC = (props) => { const fetchList = async (page = 1) => { setIsLoading(true) try { - const { data, total }: any = await fetchAnnotationList(appDetail.id, { - page, - limit, - keyword: debouncedQueryParams.keyword || '', + const { data, total } = await consoleClient.apps.byAppId.annotations.get({ + params: { app_id: appDetail.id }, + query: { page, limit, keyword: debouncedQueryParams.keyword || '' }, }) - setList(data as AnnotationItem[]) + setList(data) setTotal(total) } finally { setIsLoading(false) @@ -124,14 +117,19 @@ const Annotation: FC = (props) => { }, [currPage, limit, debouncedQueryParams]) const handleAdd = async (payload: AnnotationItemBasic) => { - await addAnnotation(appDetail.id, payload) + await consoleClient.apps.byAppId.annotations.post({ + params: { app_id: appDetail.id }, + body: payload, + }) toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) fetchList() setControlUpdateList(Date.now()) } const handleRemove = async (id: string) => { - await delAnnotation(appDetail.id, id) + await consoleClient.apps.byAppId.annotations.byAnnotationId.delete({ + params: { app_id: appDetail.id, annotation_id: id }, + }) toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) fetchList() setControlUpdateList(Date.now()) @@ -139,7 +137,10 @@ const Annotation: FC = (props) => { const handleBatchDelete = async () => { try { - await delAnnotations(appDetail.id, selectedIds) + await consoleClient.apps.byAppId.annotations.delete({ + params: { app_id: appDetail.id }, + query: { annotation_id: selectedIds }, + }) toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) fetchList() setControlUpdateList(Date.now()) @@ -157,7 +158,10 @@ const Annotation: FC = (props) => { const handleSave = async (question: string, answer: string) => { if (!currItem) return - await editAnnotation(appDetail.id, currItem.id, { question, answer }) + await consoleClient.apps.byAppId.annotations.byAnnotationId.post({ + params: { app_id: appDetail.id, annotation_id: currItem.id }, + body: { question, answer }, + }) toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) fetchList() setControlUpdateList(Date.now()) @@ -208,7 +212,7 @@ const Annotation: FC = (props) => { } setIsShowEdit(true) } else { - const { job_id: jobId }: any = await updateAnnotationStatus( + const { job_id: jobId } = await updateAnnotationStatus( appDetail.id, AnnotationEnableStatus.disable, annotationConfig?.embedding_model, @@ -320,7 +324,7 @@ const Annotation: FC = (props) => { embeddingModel.embedding_provider_name !== annotationConfig?.embedding_model?.embedding_provider_name ) { - const { job_id: jobId }: any = await updateAnnotationStatus( + const { job_id: jobId } = await updateAnnotationStatus( appDetail.id, AnnotationEnableStatus.enable, embeddingModel, @@ -330,7 +334,10 @@ const Annotation: FC = (props) => { } const annotationId = await fetchAnnotationConfig() if (score !== annotationConfig?.score_threshold) - await updateAnnotationScore(appDetail.id, annotationId, score) + await consoleClient.apps.byAppId.annotationSettings.byAnnotationSettingId.post({ + params: { app_id: appDetail.id, annotation_setting_id: annotationId! }, + body: { score_threshold: score }, + }) await fetchAnnotationConfig() toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) diff --git a/web/app/components/app/annotation/list.tsx b/web/app/components/app/annotation/list.tsx index e3310f22e1f511..491f5e497b733c 100644 --- a/web/app/components/app/annotation/list.tsx +++ b/web/app/components/app/annotation/list.tsx @@ -60,10 +60,10 @@ function AnnotationTableRow({
- + {item.question} - + {item.answer} {formattedCreatedAt} @@ -160,10 +160,14 @@ export function List({ $.dateTimeFormat, { ns: 'appLog' }) as string, - )} + formattedCreatedAt={ + item.created_at + ? formatTime( + item.created_at, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + ) + : '-' + } appId={appId} onView={onView} onRemoved={onRemoved} diff --git a/web/app/components/app/annotation/type.ts b/web/app/components/app/annotation/type.ts index 160b971cbff051..832c42450b97ec 100644 --- a/web/app/components/app/annotation/type.ts +++ b/web/app/components/app/annotation/type.ts @@ -1,16 +1,12 @@ +import type { Annotation, AnnotationHitHistory } from '@dify/contracts/api/console/apps/types.gen' + export type AnnotationItemBasic = { message_id?: string question: string answer: string } -export type AnnotationItem = { - id: string - question: string - answer: string - created_at: number - hit_count: number -} +export type AnnotationItem = Annotation export type AnnotationCreateResponse = AnnotationItem & { account?: { @@ -18,15 +14,7 @@ export type AnnotationCreateResponse = AnnotationItem & { } } -export type HitHistoryItem = { - id: string - question: string - match: string - response: string - source: string - score: number - created_at: number -} +export type HitHistoryItem = AnnotationHitHistory export type EmbeddingModelConfig = { embedding_provider_name: string diff --git a/web/app/components/app/annotation/view-annotation-modal/__tests__/index.spec.tsx b/web/app/components/app/annotation/view-annotation-modal/__tests__/index.spec.tsx index 7c1cbb63f1b6f8..7b23991c5e4124 100644 --- a/web/app/components/app/annotation/view-annotation-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/view-annotation-modal/__tests__/index.spec.tsx @@ -1,9 +1,7 @@ -import type { Mock } from 'vite-plus/test' import type { AnnotationItem, HitHistoryItem } from '../../type' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import { fetchHitHistoryList } from '@/service/annotation' import ViewAnnotationModal from '../index' const mockFormatTime = vi.fn(() => 'formatted-time') @@ -14,8 +12,18 @@ vi.mock('@/hooks/use-timestamp', () => ({ }), })) -vi.mock('@/service/annotation', () => ({ - fetchHitHistoryList: vi.fn(), +const { fetchHitHistoryListMock } = vi.hoisted(() => ({ + fetchHitHistoryListMock: vi.fn(), +})) + +vi.mock('@/service/console', () => ({ + consoleClient: { + apps: { + byAppId: { + annotations: { byAnnotationId: { hitHistories: { get: fetchHitHistoryListMock } } }, + }, + }, + }, })) vi.mock('../../edit-annotation-modal/edit-item', () => { @@ -45,8 +53,6 @@ vi.mock('../../edit-annotation-modal/edit-item', () => { } }) -const fetchHitHistoryListMock = fetchHitHistoryList as Mock - const createAnnotationItem = (overrides: Partial = {}): AnnotationItem => ({ id: overrides.id ?? 'annotation-id', question: overrides.question ?? 'question', diff --git a/web/app/components/app/annotation/view-annotation-modal/index.tsx b/web/app/components/app/annotation/view-annotation-modal/index.tsx index 4127d11e2987a0..3a65b2a98836c3 100644 --- a/web/app/components/app/annotation/view-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/view-annotation-modal/index.tsx @@ -30,7 +30,7 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { APP_PAGE_LIMIT } from '@/config' import useTimestamp from '@/hooks/use-timestamp' -import { fetchHitHistoryList } from '@/service/annotation' +import { consoleClient } from '@/service/console' import EditItem, { EditItemType } from '../edit-annotation-modal/edit-item' import HitHistoryNoData from './hit-history-no-data' @@ -51,7 +51,9 @@ const TabType = { type TabType = (typeof TabType)[keyof typeof TabType] const ViewAnnotationModal: FC = ({ appId, isShow, onHide, item, onSave, onRemove }) => { - const { id, question, answer, created_at: createdAt } = item + const { id, created_at: createdAt } = item + const question = item.question ?? '' + const answer = item.answer ?? '' const [newQuestion, setNewQuery] = useState(question) const [newAnswer, setNewAnswer] = useState(answer) const { t } = useTranslation(['appAnnotation', 'appDebug', 'appLog', 'common']) @@ -72,11 +74,12 @@ const ViewAnnotationModal: FC = ({ appId, isShow, onHide, item, onSave, o const fetchHitHistory = async (page = 1) => { try { - const { data, total }: any = await fetchHitHistoryList(appId, id, { - page, - limit: 10, - }) - setHitHistoryList(data as HitHistoryItem[]) + const { data, total } = + await consoleClient.apps.byAppId.annotations.byAnnotationId.hitHistories.get({ + params: { app_id: appId, annotation_id: id }, + query: { page, limit: 10 }, + }) + setHitHistoryList(data) setTotal(total) } catch {} } @@ -158,29 +161,31 @@ const ViewAnnotationModal: FC = ({ appId, isShow, onHide, item, onSave, o > {item.question} {item.match} {item.response} {item.source} {item.score ? item.score.toFixed(2) : '-'} - {formatTime( - item.created_at, - t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, - )} + {item.created_at + ? formatTime( + item.created_at, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + ) + : '-'} ))} @@ -305,10 +310,12 @@ const ViewAnnotationModal: FC = ({ appId, isShow, onHide, item, onSave, o
{t(($) => $['editModal.createdAt'], { ns: 'appAnnotation' })}   - {formatTime( - createdAt, - t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, - )} + {createdAt + ? formatTime( + createdAt, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + ) + : '-'}
)} diff --git a/web/app/components/app/log/__tests__/list.spec.tsx b/web/app/components/app/log/__tests__/list.spec.tsx index c893f3f205dc77..403f7c70b5acb1 100644 --- a/web/app/components/app/log/__tests__/list.spec.tsx +++ b/web/app/components/app/log/__tests__/list.spec.tsx @@ -56,8 +56,17 @@ vi.mock('@/service/log', () => ({ updateLogMessageAnnotations: (...args: unknown[]) => mockUpdateLogMessageAnnotations(...args), })) -vi.mock('@/service/annotation', () => ({ - delAnnotation: (...args: unknown[]) => mockDelAnnotation(...args), +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + apps: { + byAppId: { + annotations: { + byAnnotationId: { delete: (...args: unknown[]) => mockDelAnnotation(...args) }, + }, + }, + }, + }, })) vi.mock('@/app/components/base/loading-placeholder', () => ({ @@ -726,7 +735,9 @@ describe('ConversationList', () => { fireEvent.click(screen.getByText('chat-remove-annotation')) await waitFor(() => { - expect(mockDelAnnotation).toHaveBeenCalledWith('app-1', 'annotation-2') + expect(mockDelAnnotation).toHaveBeenCalledWith({ + params: { app_id: 'app-1', annotation_id: 'annotation-2' }, + }) }) const scrollableDiv = document.getElementById('scrollableDiv') as HTMLDivElement diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 737308696b4a6f..e84736da3b1d31 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -55,7 +55,7 @@ import { toast } from '@/app/notifications' import { userProfileQueryOptions } from '@/features/account-profile/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' -import { consoleQuery } from '@/service/console' +import { consoleClient, consoleQuery } from '@/service/console' import { fetchChatMessages, updateLogMessageAnnotations, @@ -284,8 +284,9 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { try { if (annotation?.id) { - const { delAnnotation } = await import('@/service/annotation') - await delAnnotation(appDetail.id, annotation.id) + await consoleClient.apps.byAppId.annotations.byAnnotationId.delete({ + params: { app_id: appDetail.id, annotation_id: annotation.id }, + }) } setAllChatItems(applyAnnotationRemoved(allChatItems, index)) diff --git a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx index 6139fbf46146bf..f3b6c3cf856470 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx @@ -32,8 +32,9 @@ vi.mock('@/context/modal-context', () => ({ }), })) -vi.mock('@/service/annotation', () => ({ - addAnnotation: mockAddAnnotation, +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { apps: { byAppId: { annotations: { post: mockAddAnnotation } } } }, })) vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({ @@ -242,7 +243,7 @@ describe('Operation', () => { mockContextValue.showRegenerate = false mockContextValue.onOpenLog = undefined - mockAddAnnotation.mockResolvedValue({ id: 'ann-new', account: { name: 'Test User' } }) + mockAddAnnotation.mockResolvedValue({ id: 'ann-new' }) }) describe('feedback submission sessions', () => { diff --git a/web/app/components/base/chat/chat/answer/operation.tsx b/web/app/components/base/chat/chat/answer/operation.tsx index a7dbdec3947aa6..40503fcc970bde 100644 --- a/web/app/components/base/chat/chat/answer/operation.tsx +++ b/web/app/components/base/chat/chat/answer/operation.tsx @@ -526,7 +526,6 @@ function Operation({ onAnnotationEdited?.(editedQuery, editedAnswer, index) } appId={config?.appId || ''} - messageId={id} annotationId={annotation.id} createdAt={annotation?.created_at} onRemove={() => onAnnotationRemoved?.(index)} diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/annotation-ctrl-button.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/annotation-ctrl-button.spec.tsx index b4387ed2087961..3fc0384d590b84 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/annotation-ctrl-button.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/annotation-ctrl-button.spec.tsx @@ -12,13 +12,15 @@ vi.mock('@/context/modal-context', () => ({ let mockAnnotatedResponseUsage = 5 -const mockAddAnnotation = vi.fn().mockResolvedValue({ - id: 'annotation-1', - account: { name: 'Test User' }, -}) - -vi.mock('@/service/annotation', () => ({ - addAnnotation: (...args: unknown[]) => mockAddAnnotation(...args), +const mockAddAnnotation = vi.fn().mockResolvedValue({ id: 'annotation-1' }) + +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + apps: { + byAppId: { annotations: { post: (...args: unknown[]) => mockAddAnnotation(...args) } }, + }, + }, })) vi.mock('@/app/notifications', () => ({ @@ -128,12 +130,11 @@ describe('AnnotationCtrlButton', () => { fireEvent.click(screen.getByRole('button', { name: 'appDebug.feature.annotation.add' })) await waitFor(() => { - expect(mockAddAnnotation).toHaveBeenCalledWith('test-app', { - message_id: 'msg-1', - question: 'test query', - answer: 'test answer', + expect(mockAddAnnotation).toHaveBeenCalledWith({ + params: { app_id: 'test-app' }, + body: { message_id: 'msg-1', question: 'test query', answer: 'test answer' }, }) - expect(onAdded).toHaveBeenCalledWith('annotation-1', 'Test User') + expect(onAdded).toHaveBeenCalledWith('annotation-1', '') }) }) @@ -156,30 +157,4 @@ describe('AnnotationCtrlButton', () => { expect(mockSetShowAnnotationFullModal).toHaveBeenCalled() expect(mockAddAnnotation).not.toHaveBeenCalled() }) - - it('should fallback author name to empty string when account name is missing', async () => { - const onAdded = vi.fn() - mockAddAnnotation.mockResolvedValueOnce({ - id: 'annotation-2', - account: undefined, - }) - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'appDebug.feature.annotation.add' })) - - await waitFor(() => { - expect(onAdded).toHaveBeenCalledWith('annotation-2', '') - }) - }) }) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/use-annotation-config.spec.ts b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/use-annotation-config.spec.ts index ec76acd16facb9..a8d0a18222a962 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/use-annotation-config.spec.ts +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/use-annotation-config.spec.ts @@ -1,15 +1,29 @@ import type { AnnotationReplyConfig } from '@/models/debug' import { act } from '@testing-library/react' -import { queryAnnotationJobStatus, updateAnnotationStatus } from '@/service/annotation' +import { updateAnnotationStatus } from '@/service/annotation' import { renderHookWithConsoleQuery } from '@/test/console/query-data' import { sleep } from '@/utils' import useAnnotationConfig from '../use-annotation-config' let mockIsAnnotationFull = false +const { queryAnnotationJobStatus } = vi.hoisted(() => ({ + queryAnnotationJobStatus: vi.fn().mockResolvedValue({ job_status: 'completed' }), +})) + vi.mock('@/service/annotation', () => ({ updateAnnotationStatus: vi.fn().mockResolvedValue({ job_id: 'test-job-id' }), - queryAnnotationJobStatus: vi.fn().mockResolvedValue({ job_status: 'completed' }), +})) + +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + apps: { + byAppId: { + annotationReply: { byAction: { status: { byJobId: { get: queryAnnotationJobStatus } } } }, + }, + }, + }, })) vi.mock('@/utils', () => ({ @@ -328,16 +342,12 @@ describe('useAnnotationConfig', () => { it('should poll job status until completed when enabling annotation', async () => { const setAnnotationConfig = vi.fn() - const queryJobStatusMock = vi.mocked(queryAnnotationJobStatus) + const queryJobStatusMock = queryAnnotationJobStatus const sleepMock = vi.mocked(sleep) queryJobStatusMock - .mockResolvedValueOnce({ job_status: 'pending' } as unknown as Awaited< - ReturnType - >) - .mockResolvedValueOnce({ job_status: 'completed' } as unknown as Awaited< - ReturnType - >) + .mockResolvedValueOnce({ job_status: 'pending' }) + .mockResolvedValueOnce({ job_status: 'completed' }) const { result } = renderHook(() => useAnnotationConfig({ diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx index af1203900ef5ce..05f7e7c035d73a 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx @@ -10,8 +10,7 @@ import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' import { useModalContext } from '@/context/modal-context' import { deploymentEditionAtom } from '@/features/system-features/state' -import { addAnnotation } from '@/service/annotation' -import { consoleQuery } from '@/service/console' +import { consoleClient, consoleQuery } from '@/service/console' type Props = Readonly<{ appId: string @@ -54,13 +53,13 @@ const AnnotationCtrlButton: FC = ({ setShowAnnotationFullModal() return } - const res = await addAnnotation(appId, { - message_id: messageId, - question: query, - answer, + const res = await consoleClient.apps.byAppId.annotations.post({ + params: { app_id: appId }, + body: { message_id: messageId, question: query, answer }, }) toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' }) as string) - onAdded(res.id, res.account?.name ?? '') + // The create response does not include the author. + onAdded(res.id, '') } return ( <> diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts b/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts index 58e4b238979936..9b8a7af5f0a25a 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts @@ -8,8 +8,8 @@ import { useState } from 'react' import { AnnotationEnableStatus, JobStatus } from '@/app/components/app/annotation/type' import { ANNOTATION_DEFAULT } from '@/config' import { deploymentEditionAtom } from '@/features/system-features/state' -import { queryAnnotationJobStatus, updateAnnotationStatus } from '@/service/annotation' -import { consoleQuery } from '@/service/console' +import { updateAnnotationStatus } from '@/service/annotation' +import { consoleClient, consoleQuery } from '@/service/console' import { sleep } from '@/utils' type Params = { @@ -48,7 +48,9 @@ const useAnnotationConfig = ({ appId, annotationConfig, setAnnotationConfig }: P const ensureJobCompleted = async (jobId: string, status: AnnotationEnableStatus) => { let isCompleted = false while (!isCompleted) { - const res: any = await queryAnnotationJobStatus(appId, status, jobId) + const res = await consoleClient.apps.byAppId.annotationReply.byAction.status.byJobId.get({ + params: { app_id: appId, action: status, job_id: jobId }, + }) isCompleted = res.job_status === JobStatus.completed if (isCompleted) break @@ -59,7 +61,7 @@ const useAnnotationConfig = ({ appId, annotationConfig, setAnnotationConfig }: P const handleEnableAnnotation = async (embeddingModel: EmbeddingModelConfig, score?: number) => { if (!annotationConfig.enabled && (isAnnotationQuotaUnavailable || isAnnotationFull)) return - const { job_id: jobId }: any = await updateAnnotationStatus( + const { job_id: jobId } = await updateAnnotationStatus( appId, AnnotationEnableStatus.enable, embeddingModel, diff --git a/web/service/annotation.ts b/web/service/annotation.ts index fe7a8dcb7e73d9..c54e41f6f4d15d 100644 --- a/web/service/annotation.ts +++ b/web/service/annotation.ts @@ -1,105 +1,31 @@ import type { - AnnotationCreateResponse, AnnotationEnableStatus, - AnnotationItemBasic, EmbeddingModelConfig, } from '@/app/components/app/annotation/type' import { ANNOTATION_DEFAULT } from '@/config' -import { del, get, post } from './base' +import { post } from './base' +import { consoleClient } from './console' -export const fetchAnnotationConfig = (appId: string) => { - return get(`apps/${appId}/annotation-setting`) -} export const updateAnnotationStatus = ( appId: string, action: AnnotationEnableStatus, embeddingModel?: EmbeddingModelConfig, score?: number, -) => { - let body: any = { - score_threshold: score ?? ANNOTATION_DEFAULT.score_threshold, - } - if (embeddingModel) { - body = { - ...body, - ...embeddingModel, - } - } - - return post(`apps/${appId}/annotation-reply/${action}`, { - body, - }) -} - -export const updateAnnotationScore = (appId: string, settingId: string, score: number) => { - return post(`apps/${appId}/annotation-settings/${settingId}`, { - body: { score_threshold: score }, +) => + consoleClient.apps.byAppId.annotationReply.byAction.post({ + params: { app_id: appId, action }, + body: { + score_threshold: score ?? ANNOTATION_DEFAULT.score_threshold, + // Disabling does not use the embedding model; the API still requires the fields. + embedding_provider_name: embeddingModel?.embedding_provider_name ?? '', + embedding_model_name: embeddingModel?.embedding_model_name ?? '', + }, }) -} - -export const queryAnnotationJobStatus = ( - appId: string, - action: AnnotationEnableStatus, - jobId: string, -) => { - return get(`apps/${appId}/annotation-reply/${action}/status/${jobId}`) -} - -export const fetchAnnotationList = (appId: string, params: Record) => { - return get(`apps/${appId}/annotations`, { params }) -} - -export const fetchExportAnnotationList = (appId: string) => { - return get(`apps/${appId}/annotations/export`) -} -export const addAnnotation = (appId: string, body: AnnotationItemBasic) => { - return post(`apps/${appId}/annotations`, { body }) -} - -export const annotationBatchImport = ({ - url, - body, -}: { - url: string - body: FormData -}): Promise<{ job_id: string; job_status: string }> => { - return post<{ job_id: string; job_status: string }>( - url, +// Multipart uploads are not described by the generated contract yet. +export const annotationBatchImport = (appId: string, body: FormData) => + post<{ job_id: string; job_status: string }>( + `/apps/${appId}/annotations/batch-import`, { body }, { bodyStringify: false, deleteContentType: true }, ) -} - -export const checkAnnotationBatchImportProgress = ({ - jobID, - appId, -}: { - jobID: string - appId: string -}): Promise<{ job_id: string; job_status: string }> => { - return get<{ job_id: string; job_status: string }>( - `/apps/${appId}/annotations/batch-import-status/${jobID}`, - ) -} - -export const editAnnotation = (appId: string, annotationId: string, body: AnnotationItemBasic) => { - return post(`apps/${appId}/annotations/${annotationId}`, { body }) -} - -export const delAnnotation = (appId: string, annotationId: string) => { - return del(`apps/${appId}/annotations/${annotationId}`) -} - -export const delAnnotations = (appId: string, annotationIds: string[]) => { - const params = annotationIds.map((id) => `annotation_id=${id}`).join('&') - return del(`/apps/${appId}/annotations?${params}`) -} - -export const fetchHitHistoryList = ( - appId: string, - annotationId: string, - params: Record, -) => { - return get(`apps/${appId}/annotations/${annotationId}/hit-histories`, { params }) -} diff --git a/web/service/console/client.spec.ts b/web/service/console/client.spec.ts index a9a7123ed6ed79..9f985b6819176e 100644 --- a/web/service/console/client.spec.ts +++ b/web/service/console/client.spec.ts @@ -457,6 +457,17 @@ describe('normalizeConsoleOpenAPIURL', () => { expect(searchParams.getAll('tags')).toEqual(['search', 'rag']) expect(searchParams.has('tags[0]')).toBe(false) }) + + // Without repeated keys the API sees no IDs and clears every annotation of the app. + it('should serialize annotation batch delete IDs as repeated params', () => { + const url = normalizeConsoleOpenAPIURL( + 'https://example.com/console/api/apps/app-1/annotations?annotation_id%5B0%5D=a-1&annotation_id%5B1%5D=a-2', + ) + const searchParams = new URL(url).searchParams + + expect(searchParams.getAll('annotation_id')).toEqual(['a-1', 'a-2']) + expect(searchParams.has('annotation_id[0]')).toBe(false) + }) }) // Scenario: oRPC query defaults own shared Agent detail fetch behavior. diff --git a/web/service/console/openapi-url.ts b/web/service/console/openapi-url.ts index 6ab094868a5251..e6dea280c7cb55 100644 --- a/web/service/console/openapi-url.ts +++ b/web/service/console/openapi-url.ts @@ -5,6 +5,7 @@ const repeatedQueryArrayRules = [ { path: /\/agent\/[^/]+\/logs\/[^/]+\/messages$/, fields: ['sources', 'statuses'] }, { path: /\/apps$/, fields: ['tag_ids', 'creator_ids'] }, { path: /\/apps\/starred$/, fields: ['tag_ids', 'creator_ids'] }, + { path: /\/apps\/[^/]+\/annotations$/, fields: ['annotation_id'] }, { path: /\/datasets$/, fields: ['ids', 'tag_ids'] }, { path: /\/datasets\/[^/]+\/documents\/[^/]+\/segment\/[^/]+$/, fields: ['segment_id'] }, { path: /\/datasets\/[^/]+\/documents\/[^/]+\/segments$/, fields: ['segment_id', 'status'] }, From bfe2915439d87be4e40d9ec7ae29801590d330f6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:33:51 +0000 Subject: [PATCH 05/60] [autofix.ci] apply automated fixes --- api/openapi/markdown/console-openapi.md | 1 + 1 file changed, 1 insertion(+) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 8c4e4ade000b65..febb1e60e063bc 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -2465,6 +2465,7 @@ Update annotation settings for an app | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string (uuid) | +| annotation_id | query | Annotation IDs to delete; omitting it clears all annotations of the app | No | [ string ] | #### Responses From be521675479eb296049dd1fd05856d7a06163e01 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:38:23 +0800 Subject: [PATCH 06/60] refactor(web): migrate app log messages and agent logs to the console contract Chat messages, feedback, log annotations and agent log detail now call consoleClient directly. The log chat formatter and agent log modal read the generated response types; untyped JSON fields are narrowed where they enter the chat UI types. The agent log response schema now matches what the service returns: `tool_label` is a name or i18n labels, `tool_icon` a URL or emoji icon. The never-sent `meta.error` is no longer read, and a missing annotation account no longer crashes the log detail. Co-Authored-By: Claude Opus 5.5 --- api/controllers/console/app/agent.py | 4 +- oxlint-suppressions.json | 10 -- .../generated/api/console/apps/types.gen.ts | 13 +- .../generated/api/console/apps/zod.gen.ts | 4 +- .../app/log/__tests__/list-utils.spec.ts | 50 +++++- .../app/log/__tests__/list.spec.tsx | 36 ++-- web/app/components/app/log/list-utils.ts | 167 ++++++++++++------ web/app/components/app/log/list.tsx | 45 +++-- .../agent-log-modal/__tests__/detail.spec.tsx | 52 ++++-- .../agent-log-modal/__tests__/index.spec.tsx | 33 ++-- .../__tests__/iteration.spec.tsx | 4 + .../__tests__/tracing.spec.tsx | 11 +- .../base/agent-log-modal/detail.tsx | 37 ++-- .../base/agent-log-modal/index.stories.tsx | 2 +- .../base/agent-log-modal/tool-call.tsx | 15 +- web/models/log.ts | 124 +------------ web/service/log.ts | 55 +----- 17 files changed, 319 insertions(+), 343 deletions(-) diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index 619d7da6371c3e..3d213ab1fffbbb 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -48,11 +48,11 @@ class AgentToolCallResponse(ResponseModel): error: str | None = None time_cost: float | int tool_name: str - tool_label: str + tool_label: str | dict[str, str] = Field(description="Tool name or its i18n labels keyed by locale") tool_input: dict[str, Any] tool_output: dict[str, Any] tool_parameters: dict[str, Any] - tool_icon: Any = Field(default=None) + tool_icon: str | dict[str, str] | None = Field(default=None, description="Icon URL or emoji icon") class AgentIterationLogResponse(ResponseModel): diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 85a29439506cbb..0fe03d0b3d37ba 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -441,11 +441,6 @@ "count": 5 } }, - "web/app/components/app/log/__tests__/list-utils.spec.ts": { - "typescript/no-explicit-any": { - "count": 2 - } - }, "web/app/components/app/log/__tests__/list.spec.tsx": { "typescript/no-explicit-any": { "count": 9 @@ -3991,11 +3986,6 @@ "count": 3 } }, - "web/models/log.ts": { - "typescript/no-explicit-any": { - "count": 6 - } - }, "web/models/pipeline.ts": { "typescript/no-explicit-any": { "count": 6 diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 23d0600605b99b..b4a4a7b0947119 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -2762,11 +2762,20 @@ export type AgentToolCallResponse = { error?: string | null status: string time_cost: number | number - tool_icon?: unknown + tool_icon?: + | string + | { + [key: string]: string + } + | null tool_input: { [key: string]: unknown } - tool_label: string + tool_label: + | string + | { + [key: string]: string + } tool_name: string tool_output: { [key: string]: unknown diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 6fb39ce919d47d..56ad63267c0368 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -2846,9 +2846,9 @@ export const zAgentToolCallResponse = z.object({ error: z.string().nullish(), status: z.string(), time_cost: z.union([z.number(), z.int()]), - tool_icon: z.unknown().optional(), + tool_icon: z.union([z.string(), z.record(z.string(), z.string())]).nullish(), tool_input: z.record(z.string(), z.unknown()), - tool_label: z.string(), + tool_label: z.union([z.string(), z.record(z.string(), z.string())]), tool_name: z.string(), tool_output: z.record(z.string(), z.unknown()), tool_parameters: z.record(z.string(), z.unknown()), diff --git a/web/app/components/app/log/__tests__/list-utils.spec.ts b/web/app/components/app/log/__tests__/list-utils.spec.ts index 837b05db0015fa..e27d04c2e47bf0 100644 --- a/web/app/components/app/log/__tests__/list-utils.spec.ts +++ b/web/app/components/app/log/__tests__/list-utils.spec.ts @@ -1,3 +1,4 @@ +import type { MessageDetailResponse } from '@dify/contracts/api/console/apps/types.gen' import type { IChatItem } from '@/app/components/base/chat/chat/type' import { applyAnnotationAdded, @@ -22,11 +23,30 @@ const createChatItems = (): IChatItem[] => { id: 'answer-2', content: 'reply', isAnswer: true, parentMessageId: 'question-2' }, ] as IChatItem[] +const createMessage = (overrides: Partial): MessageDetailResponse => ({ + id: 'message', + conversation_id: 'conversation', + inputs: {}, + query: '', + answer: '', + answer_tokens: 0, + message_tokens: 0, + message: [], + message_files: [], + agent_thoughts: [], + feedbacks: [], + metadata: {}, + from_source: 'console', + provider_response_latency: 0, + status: 'normal', + ...overrides, +}) + describe('log list utils', () => { it('should format chat messages into paired question and answer items', () => { const items = getFormattedChatList( [ - { + createMessage({ id: 'message-1', inputs: { query: 'hello' }, query: 'hello', @@ -36,11 +56,23 @@ describe('log list utils', () => { message_tokens: 2, message: [{ role: 'user', text: 'hello' }], message_files: [ - { belongs_to: 'assistant', id: 'file-1' }, - { belongs_to: 'user', id: 'file-2' }, + { + belongs_to: 'assistant', + id: 'file-1', + filename: '', + transfer_method: 'local_file', + type: 'image', + }, + { + belongs_to: 'user', + id: 'file-2', + filename: '', + transfer_method: 'local_file', + type: 'image', + }, ], - }, - ] as any, + }), + ], 'conversation-1', 'UTC', 'YYYY-MM-DD', @@ -67,7 +99,7 @@ describe('log list utils', () => { it('should preserve feedback and annotation hit history when formatting chat items', () => { const items = getFormattedChatList( [ - { + createMessage({ id: 'message-2', inputs: { default_input: 'fallback prompt' }, query: '', @@ -82,11 +114,11 @@ describe('log list utils', () => { ], annotation_hit_history: { annotation_id: 'annotation-1', - annotation_create_account: {}, + annotation_create_account: null, created_at: 123, }, - }, - ] as any, + }), + ], 'conversation-2', 'UTC', 'YYYY-MM-DD', diff --git a/web/app/components/app/log/__tests__/list.spec.tsx b/web/app/components/app/log/__tests__/list.spec.tsx index 403f7c70b5acb1..092979258ec814 100644 --- a/web/app/components/app/log/__tests__/list.spec.tsx +++ b/web/app/components/app/log/__tests__/list.spec.tsx @@ -1,3 +1,4 @@ +import type { MessageDetailResponse } from '@dify/contracts/api/console/apps/types.gen' import type { ReactNode } from 'react' import type { IChatItem } from '@/app/components/base/chat/chat/type' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -50,18 +51,15 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { } }) -vi.mock('@/service/log', () => ({ - fetchChatMessages: (...args: unknown[]) => mockFetchChatMessages(...args), - updateLogMessageFeedbacks: (...args: unknown[]) => mockUpdateLogMessageFeedbacks(...args), - updateLogMessageAnnotations: (...args: unknown[]) => mockUpdateLogMessageAnnotations(...args), -})) - vi.mock('@/service/console', async (importOriginal) => ({ ...(await importOriginal()), consoleClient: { apps: { byAppId: { + chatMessages: { get: (...args: unknown[]) => mockFetchChatMessages(...args) }, + feedbacks: { post: (...args: unknown[]) => mockUpdateLogMessageFeedbacks(...args) }, annotations: { + post: (...args: unknown[]) => mockUpdateLogMessageAnnotations(...args), byAnnotationId: { delete: (...args: unknown[]) => mockDelAnnotation(...args) }, }, }, @@ -302,8 +300,17 @@ const createCompletionLogs = () => ({ ], }) -const createChatMessage = (id: string, overrides: Record = {}) => ({ +const createChatMessage = ( + id: string, + overrides: Partial = {}, +): MessageDetailResponse => ({ id, + conversation_id: 'conversation-1', + agent_thoughts: [], + metadata: {}, + from_source: 'console', + provider_response_latency: 0, + status: 'normal', answer: `Assistant reply ${id}`, query: `Question ${id}`, created_at: 1710000000, @@ -315,7 +322,7 @@ const createChatMessage = (id: string, overrides: Record = {}) message_files: [], answer_tokens: 10, message_tokens: 5, - parent_message_id: undefined, + parent_message_id: null, ...overrides, }) @@ -498,11 +505,8 @@ describe('ConversationList', () => { await waitFor(() => { expect(mockFetchChatMessages).toHaveBeenCalledWith({ - url: '/apps/app-1/chat-messages', - params: { - conversation_id: 'conversation-1', - limit: 10, - }, + params: { app_id: 'app-1' }, + query: { conversation_id: 'conversation-1', limit: 10 }, }) }) @@ -517,7 +521,7 @@ describe('ConversationList', () => { await waitFor(() => { expect(mockUpdateLogMessageFeedbacks).toHaveBeenCalledWith({ - url: '/apps/app-1/feedbacks', + params: { app_id: 'app-1' }, body: { message_id: 'message-1', rating: 'like', @@ -602,7 +606,7 @@ describe('ConversationList', () => { await waitFor(() => { expect(mockUpdateLogMessageFeedbacks).toHaveBeenCalledWith({ - url: '/apps/app-1/feedbacks', + params: { app_id: 'app-1' }, body: { message_id: 'message-1', rating: 'like', @@ -706,7 +710,7 @@ describe('ConversationList', () => { annotation: { id: 'annotation-1', content: 'Annotated answer', - account: { name: 'Admin' }, + account: { id: 'account-1', name: 'Admin', email: 'admin@example.com' }, }, }), createChatMessage('message-2', { parent_message_id: 'message-1' }), diff --git a/web/app/components/app/log/list-utils.ts b/web/app/components/app/log/list-utils.ts index 63a3c6bcfd1b4d..131bb39f172a8e 100644 --- a/web/app/components/app/log/list-utils.ts +++ b/web/app/components/app/log/list-utils.ts @@ -1,6 +1,12 @@ +import type { MessageDetailResponse } from '@dify/contracts/api/console/apps/types.gen' import type { ChatItemInTree } from '../../base/chat/types' -import type { FeedbackType, IChatItem } from '@/app/components/base/chat/chat/type' -import type { Annotation, ChatMessage } from '@/models/log' +import type { + CitationItem, + FeedbackType, + IChatItem, + ThoughtItem, +} from '@/app/components/base/chat/chat/type' +import type { Annotation } from '@/models/log' import type { FileResponse } from '@/types/workflow' import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' @@ -10,6 +16,7 @@ import { isPlainObject } from 'es-toolkit/predicate' import { buildChatItemTree, getThreadMessages } from '@/app/components/base/chat/utils' import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' +import { TransferMethod } from '@/types/app' dayjs.extend(utc) dayjs.extend(timezone) @@ -41,94 +48,150 @@ const getUserInputVariable = (item: unknown) => { return typeof variable === 'string' ? variable : undefined } -const toFileResponse = (file: NonNullable[number]): FileResponse => ({ +type LogMessage = MessageDetailResponse + +const isTransferMethod = (value: string): value is TransferMethod => + (Object.values(TransferMethod) as string[]).includes(value) + +const toFileResponse = (file: LogMessage['message_files'][number]): FileResponse => ({ related_id: file.id ?? file.upload_file_id, extension: '', filename: '', size: 0, mime_type: '', - transfer_method: file.transfer_method, + transfer_method: isTransferMethod(file.transfer_method) + ? file.transfer_method + : TransferMethod.local_file, type: file.type, - url: file.url, - upload_file_id: file.upload_file_id, - remote_url: file.url, + url: file.url ?? '', + upload_file_id: file.upload_file_id ?? '', + remote_url: file.url ?? '', }) +const toText = (value: unknown) => (typeof value === 'string' && value ? value : undefined) + +// `inputs` values are untyped JSON in the console API response. +export const getStringInputs = (inputs: LogMessage['inputs']) => + Object.fromEntries( + Object.entries(inputs).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ) + +const toFeedback = (feedback?: LogMessage['feedbacks'][number]): FeedbackType | undefined => + feedback?.rating === 'like' || feedback?.rating === 'dislike' + ? { rating: feedback.rating, content: feedback.content } + : undefined + +const toThoughtItems = (message: LogMessage): ThoughtItem[] => + message.agent_thoughts.map((thought) => ({ + id: thought.id, + tool: thought.tool ?? '', + thought: thought.thought ?? '', + answer: thought.answer ?? undefined, + tool_input: thought.tool_input ?? '', + // `tool_labels` is untyped JSON in the console API response. + tool_labels: isPlainObject(thought.tool_labels) + ? (thought.tool_labels as ThoughtItem['tool_labels']) + : undefined, + message_id: thought.message_id, + conversation_id: message.conversation_id, + observation: thought.observation ?? '', + position: thought.position, + files: thought.files, + })) + +// `message` and `metadata` are untyped JSON in the console API response. +const getPromptLog = (message: LogMessage) => + (Array.isArray(message.message) ? message.message : []) as NonNullable + +const getCitations = (message: LogMessage) => + isPlainObject(message.metadata) && Array.isArray(message.metadata.retriever_resources) + ? (message.metadata.retriever_resources as CitationItem[]) + : undefined + +const getAnnotation = (message: LogMessage): Annotation | undefined => { + if (message.annotation_hit_history) { + return { + id: message.annotation_hit_history.annotation_id, + authorName: message.annotation_hit_history.annotation_create_account?.name || 'N/A', + created_at: message.annotation_hit_history.created_at ?? undefined, + } + } + + if (message.annotation) { + const { account } = message.annotation + return { + id: message.annotation.id, + authorName: account?.name || 'N/A', + logAnnotation: { + id: message.annotation.id, + content: message.annotation.content, + account: { id: account?.id ?? '', name: account?.name ?? '', email: account?.email ?? '' }, + created_at: message.annotation.created_at ?? 0, + }, + created_at: 0, + } + } + + return undefined +} + export const getFormattedChatList = ( - messages: ChatMessage[], + messages: LogMessage[], conversationId: string, timezone: string, format: string, ) => { const newChatList: IChatItem[] = [] - messages.forEach((item: ChatMessage) => { - const questionFiles = item.message_files?.filter((file) => file.belongs_to === 'user') || [] + messages.forEach((item) => { + const files = getProcessedFilesFromResponse(item.message_files.map(toFileResponse)) + const questionFiles = getProcessedFilesFromResponse( + item.message_files.filter((file) => file.belongs_to === 'user').map(toFileResponse), + ) newChatList.push({ id: `question-${item.id}`, - content: item.inputs.query || item.inputs.default_input || item.query, + content: toText(item.inputs.query) || toText(item.inputs.default_input) || item.query, isAnswer: false, - message_files: getProcessedFilesFromResponse(questionFiles.map(toFileResponse)), + message_files: questionFiles, parentMessageId: item.parent_message_id || undefined, }) - const answerFiles = item.message_files?.filter((file) => file.belongs_to === 'assistant') || [] + const answerFiles = getProcessedFilesFromResponse( + item.message_files.filter((file) => file.belongs_to === 'assistant').map(toFileResponse), + ) + const promptLog = getPromptLog(item) newChatList.push({ id: item.id, content: item.answer, - agent_thoughts: addFileInfos( - item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, - item.message_files, + agent_thoughts: addFileInfos(sortAgentSorts(toThoughtItems(item)), files), + feedback: toFeedback(item.feedbacks.find((feedback) => feedback.from_source === 'user')), + adminFeedback: toFeedback( + item.feedbacks.find((feedback) => feedback.from_source === 'admin'), ), - feedback: item.feedbacks?.find((feedback) => feedback.from_source === 'user'), - adminFeedback: item.feedbacks?.find((feedback) => feedback.from_source === 'admin'), feedbackDisabled: false, isAnswer: true, - message_files: getProcessedFilesFromResponse(answerFiles.map(toFileResponse)), + message_files: answerFiles, log: [ - ...(item.message ?? []), - ...(item.message?.[item.message.length - 1]?.role !== 'assistant' - ? [ - { - role: 'assistant', - text: item.answer, - files: item.message_files?.filter((file) => file.belongs_to === 'assistant') || [], - }, - ] + ...promptLog, + ...(promptLog.at(-1)?.role !== 'assistant' + ? [{ role: 'assistant', text: item.answer, files: answerFiles }] : []), - ] as IChatItem['log'], - workflow_run_id: item.workflow_run_id, + ], + workflow_run_id: item.workflow_run_id ?? undefined, conversationId, input: { inputs: item.inputs, query: item.query, }, more: { - time: dayjs.unix(item.created_at).tz(timezone).format(format), + time: item.created_at ? dayjs.unix(item.created_at).tz(timezone).format(format) : '', tokens: item.answer_tokens + item.message_tokens, latency: (item.provider_response_latency ?? 0).toFixed(2), }, - citation: item.metadata?.retriever_resources, - annotation: (() => { - if (item.annotation_hit_history) { - return { - id: item.annotation_hit_history.annotation_id, - authorName: item.annotation_hit_history.annotation_create_account?.name || 'N/A', - created_at: item.annotation_hit_history.created_at, - } - } - - if (item.annotation) { - return { - id: item.annotation.id, - authorName: item.annotation.account.name, - logAnnotation: item.annotation, - created_at: 0, - } - } - - return undefined - })(), + citation: getCitations(item), + annotation: getAnnotation(item), parentMessageId: `question-${item.id}`, }) }) diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index e84736da3b1d31..7f999174cb2a14 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -8,6 +8,7 @@ import type { ConversationPagination, ConversationWithSummary, ConversationWithSummaryPagination, + GetAppsByAppIdChatMessagesData, } from '@dify/contracts/api/console/apps/types.gen' import type { FC } from 'react' import type { ChatItemInTree } from '../../base/chat/types' @@ -17,7 +18,7 @@ import type { IChatItem, SubmitAnnotationFunc, } from '@/app/components/base/chat/chat/type' -import type { ChatMessagesRequest, LogAnnotation } from '@/models/log' +import type { LogAnnotation } from '@/models/log' import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline' import { cn } from '@langgenius/dify-ui/cn' import { @@ -56,11 +57,6 @@ import { userProfileQueryOptions } from '@/features/account-profile/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' import { consoleClient, consoleQuery } from '@/service/console' -import { - fetchChatMessages, - updateLogMessageAnnotations, - updateLogMessageFeedbacks, -} from '@/service/log' import { AppSourceType } from '@/service/share' import { AppModeEnum } from '@/types/app' import PromptLogModal from '../../base/prompt-log-modal' @@ -75,6 +71,7 @@ import { getDetailVarList, getFormattedChatList, getLogModelInfo, + getStringInputs, getThreadChatItems, isNearTopLoadMore, mergePaginatedChatItems, @@ -205,22 +202,22 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { try { isLoadingRef.current = true - const params: ChatMessagesRequest = { + const params: GetAppsByAppIdChatMessagesData['query'] = { conversation_id: detail.id, limit: 10, } // Use ref for pagination anchor to avoid stale closure issues if (oldestAnswerIdRef.current) params.first_id = oldestAnswerIdRef.current - const messageRes = await fetchChatMessages({ - url: `/apps/${appDetail.id}/chat-messages`, - params, + const messageRes = await consoleClient.apps.byAppId.chatMessages.get({ + params: { app_id: appDetail.id }, + query: params, }) // Ignore stale responses if (currentRequestId !== requestIdRef.current || controller.signal.aborted) return if (messageRes.data.length > 0) { - const varValues = messageRes.data.at(-1)!.inputs + const varValues = getStringInputs(messageRes.data.at(-1)!.inputs) setVarValues(varValues) } setHasMore(messageRes.has_more) @@ -331,7 +328,7 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { setIsLoading(true) try { - const params: ChatMessagesRequest = { + const params: GetAppsByAppIdChatMessagesData['query'] = { conversation_id: detail.id, limit: 10, } @@ -341,9 +338,9 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { params.first_id = oldestAnswerIdRef.current } - const messageRes = await fetchChatMessages({ - url: `/apps/${appDetail.id}/chat-messages`, - params, + const messageRes = await consoleClient.apps.byAppId.chatMessages.get({ + params: { app_id: appDetail.id }, + query: params, }) if (!messageRes.data || messageRes.data.length === 0) { @@ -353,7 +350,7 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { } if (messageRes.data.length > 0) { - const varValues = messageRes.data.at(-1)!.inputs + const varValues = getStringInputs(messageRes.data.at(-1)!.inputs) setVarValues(varValues) } @@ -652,8 +649,8 @@ const CompletionConversationDetailComp: FC = ({ const { t } = useTranslation(['appLog', 'common']) const handleFeedback = async (mid: string, { rating, content }: FeedbackType) => { - await updateLogMessageFeedbacks({ - url: `/apps/${appDetail.id}/feedbacks`, + await consoleClient.apps.byAppId.feedbacks.post({ + params: { app_id: appDetail.id }, body: { message_id: mid, rating, content: content ?? undefined }, }) conversationDetailMutate() @@ -661,8 +658,8 @@ const CompletionConversationDetailComp: FC = ({ const handleAnnotation = async (mid: string, value: string): Promise => { try { - await updateLogMessageAnnotations({ - url: `/apps/${appDetail.id}/annotations`, + await consoleClient.apps.byAppId.annotations.post({ + params: { app_id: appDetail.id }, body: { message_id: mid, content: value }, }) conversationDetailMutate() @@ -712,16 +709,16 @@ const ChatConversationDetailComp: FC = ({ const { t } = useTranslation(['appLog', 'common']) const handleFeedback = async (mid: string, { rating, content }: FeedbackType) => { - await updateLogMessageFeedbacks({ - url: `/apps/${appDetail.id}/feedbacks`, + await consoleClient.apps.byAppId.feedbacks.post({ + params: { app_id: appDetail.id }, body: { message_id: mid, rating, content: content ?? undefined }, }) } const handleAnnotation = async (mid: string, value: string): Promise => { try { - await updateLogMessageAnnotations({ - url: `/apps/${appDetail.id}/annotations`, + await consoleClient.apps.byAppId.annotations.post({ + params: { app_id: appDetail.id }, body: { message_id: mid, content: value }, }) toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) diff --git a/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx index eb677b3a45895b..ce8b091e4ee0ee 100644 --- a/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx @@ -3,7 +3,6 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type' import type { AgentLogDetailResponse } from '@/models/log' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { createHttpError } from '@/service/http-error' -import { fetchAgentLogDetail } from '@/service/log' import AgentLogDetail from '../detail' const { mockToast } = vi.hoisted(() => { @@ -19,10 +18,14 @@ const { mockToast } = vi.hoisted(() => { return { mockToast } }) -vi.mock('@/service/log', () => ({ +const { fetchAgentLogDetail } = vi.hoisted(() => ({ fetchAgentLogDetail: vi.fn(), })) +vi.mock('@/service/console', () => ({ + consoleClient: { apps: { byAppId: { agent: { logs: { get: fetchAgentLogDetail } } } } }, +})) + vi.mock('@/app/notifications', () => ({ toast: mockToast, })) @@ -100,6 +103,10 @@ const createMockResponse = ( tool_name: 'tool1', status: 'success', tool_icon: null, + time_cost: 0, + tool_input: {}, + tool_output: {}, + tool_parameters: {}, tool_label: { 'en-US': 'Tool 1' }, }, ], @@ -136,7 +143,7 @@ describe('AgentLogDetail', () => { describe('Rendering', () => { it('should show loading indicator while fetching data', async () => { - vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + fetchAgentLogDetail.mockReturnValue(new Promise(() => {})) renderComponent() @@ -144,7 +151,7 @@ describe('AgentLogDetail', () => { }) it('should display result panel after data loads', async () => { - vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + fetchAgentLogDetail.mockResolvedValue(createMockResponse()) await renderAndWaitForData() @@ -153,23 +160,20 @@ describe('AgentLogDetail', () => { }) it('should call fetchAgentLogDetail with correct params', async () => { - vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + fetchAgentLogDetail.mockResolvedValue(createMockResponse()) await renderAndWaitForData() expect(fetchAgentLogDetail).toHaveBeenCalledWith({ - appID: 'app-id', - params: { - conversation_id: 'conv-id', - message_id: 'msg-id', - }, + params: { app_id: 'app-id' }, + query: { conversation_id: 'conv-id', message_id: 'msg-id' }, }) }) }) describe('Props', () => { it('should default to DETAIL tab when activeTab is not provided', async () => { - vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + fetchAgentLogDetail.mockResolvedValue(createMockResponse()) await renderAndWaitForData() @@ -178,7 +182,7 @@ describe('AgentLogDetail', () => { }) it('should show TRACING tab when activeTab is TRACING', async () => { - vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + fetchAgentLogDetail.mockResolvedValue(createMockResponse()) await renderAndWaitForData({ activeTab: 'TRACING' }) @@ -189,7 +193,7 @@ describe('AgentLogDetail', () => { describe('User Interactions', () => { it('should switch to TRACING tab when clicked', async () => { - vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + fetchAgentLogDetail.mockResolvedValue(createMockResponse()) await renderAndWaitForData() @@ -205,7 +209,7 @@ describe('AgentLogDetail', () => { }) it('should switch back to DETAIL tab after switching to TRACING', async () => { - vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + fetchAgentLogDetail.mockResolvedValue(createMockResponse()) await renderAndWaitForData() @@ -226,7 +230,7 @@ describe('AgentLogDetail', () => { describe('Edge Cases', () => { it('should notify on API error', async () => { - vi.mocked(fetchAgentLogDetail).mockRejectedValue( + fetchAgentLogDetail.mockRejectedValue( createHttpError(new Response(null, { status: 500 }), { message: 'API Error' }), ) @@ -238,7 +242,7 @@ describe('AgentLogDetail', () => { }) it('should stop loading after API error', async () => { - vi.mocked(fetchAgentLogDetail).mockRejectedValue(new Error('Network failure')) + fetchAgentLogDetail.mockRejectedValue(new Error('Network failure')) renderComponent() @@ -248,7 +252,7 @@ describe('AgentLogDetail', () => { }) it('should handle response with empty iterations', async () => { - vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse({ iterations: [] })) + fetchAgentLogDetail.mockResolvedValue(createMockResponse({ iterations: [] })) await renderAndWaitForData() }) @@ -267,12 +271,20 @@ describe('AgentLogDetail', () => { tool_name: 'tool1', status: 'success', tool_icon: null, + time_cost: 0, + tool_input: {}, + tool_output: {}, + tool_parameters: {}, tool_label: { 'en-US': 'Tool 1' }, }, { tool_name: 'tool2', status: 'success', tool_icon: null, + time_cost: 0, + tool_input: {}, + tool_output: {}, + tool_parameters: {}, tool_label: { 'en-US': 'Tool 2' }, }, ], @@ -288,13 +300,17 @@ describe('AgentLogDetail', () => { tool_name: 'tool1', status: 'success', tool_icon: null, + time_cost: 0, + tool_input: {}, + tool_output: {}, + tool_parameters: {}, tool_label: { 'en-US': 'Tool 1' }, }, ], }, ], }) - vi.mocked(fetchAgentLogDetail).mockResolvedValue(response) + fetchAgentLogDetail.mockResolvedValue(response) await renderAndWaitForData() diff --git a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx index 28d814de454276..c0816744d31e13 100644 --- a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx @@ -1,7 +1,6 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { useClickAway } from 'ahooks' -import { fetchAgentLogDetail } from '@/service/log' import AgentLogModal from '../index' const { mockToast } = vi.hoisted(() => { @@ -17,10 +16,14 @@ const { mockToast } = vi.hoisted(() => { return { mockToast } }) -vi.mock('@/service/log', () => ({ +const { fetchAgentLogDetail } = vi.hoisted(() => ({ fetchAgentLogDetail: vi.fn(), })) +vi.mock('@/service/console', () => ({ + consoleClient: { apps: { byAppId: { agent: { logs: { get: fetchAgentLogDetail } } } } }, +})) + vi.mock('@/app/notifications', () => ({ toast: mockToast, })) @@ -86,7 +89,7 @@ const mockProps = { describe('AgentLogModal', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(fetchAgentLogDetail).mockResolvedValue({ + fetchAgentLogDetail.mockResolvedValue({ meta: { status: 'succeeded', executor: 'User', @@ -146,7 +149,7 @@ describe('AgentLogModal', () => { }) it('should render the floating modal through a dialog portal', () => { - vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + fetchAgentLogDetail.mockReturnValue(new Promise(() => {})) const { container } = render() @@ -157,7 +160,7 @@ describe('AgentLogModal', () => { }) it('should call onCancel when close button is clicked', () => { - vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + fetchAgentLogDetail.mockReturnValue(new Promise(() => {})) render() @@ -168,7 +171,7 @@ describe('AgentLogModal', () => { }) it('should call onCancel when clicking away', () => { - vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + fetchAgentLogDetail.mockReturnValue(new Promise(() => {})) let clickAwayHandler!: (event: Event) => void vi.mocked(useClickAway).mockImplementation((callback) => { @@ -182,7 +185,7 @@ describe('AgentLogModal', () => { }) it('should ignore click-away before mounted state is set', () => { - vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + fetchAgentLogDetail.mockReturnValue(new Promise(() => {})) let invoked = false vi.mocked(useClickAway).mockImplementation((callback) => { if (!invoked) { @@ -197,7 +200,7 @@ describe('AgentLogModal', () => { }) it('should not use click-away to close the floating dialog', () => { - vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + fetchAgentLogDetail.mockReturnValue(new Promise(() => {})) let clickAwayHandler!: (event: Event) => void vi.mocked(useClickAway).mockImplementation((callback) => { @@ -228,15 +231,13 @@ describe('AgentLogModal', () => { const pending = new Promise>>((resolve) => { resolveOldResponse = resolve }) - vi.mocked(fetchAgentLogDetail) - .mockResolvedValue(response) - .mockImplementationOnce(() => pending) + fetchAgentLogDetail.mockResolvedValue(response).mockImplementationOnce(() => pending) const { rerender } = render() rerender() await screen.findByText('New executor') expect(fetchAgentLogDetail).toHaveBeenLastCalledWith({ - appID: 'new-app', - params: { conversation_id: mockLog.conversationId, message_id: mockLog.id }, + params: { app_id: 'new-app' }, + query: { conversation_id: mockLog.conversationId, message_id: mockLog.id }, }) await act(async () => { resolveOldResponse({ ...response, meta: { ...response.meta, executor: 'Old executor' } }) @@ -263,8 +264,8 @@ describe('AgentLogModal', () => { expect(screen.getByRole('progressbar')).toBeInTheDocument() await waitFor(() => expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()) expect(fetchAgentLogDetail).toHaveBeenLastCalledWith({ - appID: 'app-id', - params: { conversation_id: mockLog.conversationId, message_id: 'next-message' }, + params: { app_id: 'app-id' }, + query: { conversation_id: mockLog.conversationId, message_id: 'next-message' }, }) }) @@ -273,7 +274,7 @@ describe('AgentLogModal', () => { const pending = new Promise>>((_, reject) => { rejectOldResponse = reject }) - vi.mocked(fetchAgentLogDetail).mockReturnValueOnce(pending) + fetchAgentLogDetail.mockReturnValueOnce(pending) const { rerender } = render() rerender() await screen.findByText('User') diff --git a/web/app/components/base/agent-log-modal/__tests__/iteration.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/iteration.spec.tsx index f124d4856a6513..3e841d76363c5b 100644 --- a/web/app/components/base/agent-log-modal/__tests__/iteration.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/iteration.spec.tsx @@ -26,6 +26,10 @@ const mockIterationInfo: AgentIteration = { tool_name: 'test_tool', tool_label: { en: 'Test Tool', 'en-US': 'Test Tool' }, tool_icon: null, + time_cost: 0, + tool_input: {}, + tool_output: {}, + tool_parameters: {}, }, ], tool_raw: { diff --git a/web/app/components/base/agent-log-modal/__tests__/tracing.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/tracing.spec.tsx index c38caef38614ee..4d744ebf256d3c 100644 --- a/web/app/components/base/agent-log-modal/__tests__/tracing.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/tracing.spec.tsx @@ -22,7 +22,16 @@ const createIteration = (thought: string, tokens: number): AgentIteration => ({ thought, tokens, tool_calls: [ - { tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }, + { + tool_name: 'tool1', + status: 'success', + tool_icon: null, + tool_label: { 'en-US': 'Tool 1' }, + time_cost: 0, + tool_input: {}, + tool_output: {}, + tool_parameters: {}, + }, ], tool_raw: { inputs: '', outputs: '' }, }) diff --git a/web/app/components/base/agent-log-modal/detail.tsx b/web/app/components/base/agent-log-modal/detail.tsx index 2db2606eddb2d9..8c078660d6e95f 100644 --- a/web/app/components/base/agent-log-modal/detail.tsx +++ b/web/app/components/base/agent-log-modal/detail.tsx @@ -9,7 +9,7 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { toast } from '@/app/notifications' -import { fetchAgentLogDetail } from '@/service/log' +import { consoleClient } from '@/service/console' import ResultPanel from './result' import TracingPanel from './tracing' @@ -94,21 +94,23 @@ function AgentLogContent({ useEffect(() => { let active = true - fetchAgentLogDetail({ - appID: appId, - params: { conversation_id: conversationID, message_id: messageID }, - }).then( - (data) => { - if (!active) return - setRunDetail(data) - setLoading(false) - }, - (error) => { - if (!active) return - toast.error(error instanceof Error ? error.message : String(error)) - setLoading(false) - }, - ) + consoleClient.apps.byAppId.agent.logs + .get({ + params: { app_id: appId }, + query: { conversation_id: conversationID, message_id: messageID }, + }) + .then( + (data) => { + if (!active) return + setRunDetail(data) + setLoading(false) + }, + (error) => { + if (!active) return + toast.error(error instanceof Error ? error.message : String(error)) + setLoading(false) + }, + ) return () => { active = false } @@ -131,8 +133,7 @@ function AgentLogContent({ inputs={log.input} outputs={log.content} status={runDetail.meta.status} - error={runDetail.meta.error} - elapsed_time={runDetail.meta.elapsed_time} + elapsed_time={runDetail.meta.elapsed_time ?? undefined} total_tokens={runDetail.meta.total_tokens} created_at={runDetail.meta.start_time} created_by={runDetail.meta.executor} diff --git a/web/app/components/base/agent-log-modal/index.stories.tsx b/web/app/components/base/agent-log-modal/index.stories.tsx index fbf8874471cf24..21c2779a2616a4 100644 --- a/web/app/components/base/agent-log-modal/index.stories.tsx +++ b/web/app/components/base/agent-log-modal/index.stories.tsx @@ -14,7 +14,6 @@ const MOCK_RESPONSE: AgentLogDetailResponse = { total_tokens: 2589, agent_mode: 'ReACT', iterations: 2, - error: undefined, }, iterations: [ { @@ -33,6 +32,7 @@ const MOCK_RESPONSE: AgentLogDetailResponse = { 'en-US': 'Revenue Search', }, time_cost: 1.8, + tool_parameters: {}, }, ], tool_raw: { diff --git a/web/app/components/base/agent-log-modal/tool-call.tsx b/web/app/components/base/agent-log-modal/tool-call.tsx index 04d354289a13c7..1aab26d4e91db5 100644 --- a/web/app/components/base/agent-log-modal/tool-call.tsx +++ b/web/app/components/base/agent-log-modal/tool-call.tsx @@ -10,8 +10,15 @@ import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/ import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { BlockEnum } from '@/app/components/workflow/types' +const getToolIcon = (icon: ToolCall['tool_icon']) => { + if (typeof icon === 'string') return icon + if (icon?.content !== undefined && icon.background !== undefined) + return { content: icon.content, background: icon.background } + return undefined +} + type Props = Readonly<{ - toolCall: ToolCall + toolCall: Partial & Pick isLLM: boolean isFinal?: boolean tokens?: number @@ -31,7 +38,9 @@ const ToolCallItem: FC = ({ const locale = useLocale() const toolName = isLLM ? 'LLM' - : toolCall.tool_label[locale] || toolCall.tool_label[locale.replaceAll('-', '_')] + : typeof toolCall.tool_label === 'string' + ? toolCall.tool_label + : toolCall.tool_label?.[locale] || toolCall.tool_label?.[locale.replaceAll('-', '_')] const getTime = (time: number) => { if (time < 1) return `${(time * 1000).toFixed(3)} ms` @@ -73,7 +82,7 @@ const ToolCallItem: FC = ({
- message: { role: string; text: string; files?: VisionFile[] }[] - message_tokens: number - answer_tokens: number - answer: string - provider_response_latency: number - created_at: number - annotation: LogAnnotation - annotation_hit_history: { - annotation_id: string - annotation_create_account: { - id: string - name: string - email: string - } - created_at: number - } - feedbacks: Array<{ - rating: 'like' | 'dislike' | null - content: string | null - from_source?: 'admin' | 'user' - from_end_user_id?: string - }> - message_files: VisionFile[] - metadata: Metadata - agent_thoughts: ThoughtItem[] - workflow_run_id: string - parent_message_id: string | null -} - -export type ChatMessagesRequest = { - conversation_id: string - first_id?: string - limit: number -} -export type ChatMessage = MessageContent - -export type ChatMessagesResponse = { - data: Array - has_more: boolean - limit: number -} - export type MessageRating = 'like' | 'dislike' | null -export type LogMessageFeedbacksRequest = { - message_id: string - rating: MessageRating - content?: string -} - -export type LogMessageFeedbacksResponse = { - result: 'success' | 'error' -} - -export type LogMessageAnnotationsRequest = Omit - -export type LogMessageAnnotationsResponse = LogMessageFeedbacksResponse - export const WorkflowRunTriggeredFrom = { DEBUGGING: 'debugging', APP_RUN: 'app-run', @@ -159,56 +101,6 @@ export type WorkflowRunDetailResponse = { exceptions_count?: number } -type AgentLogMeta = { - status: string - executor: string - start_time: string - elapsed_time: number - total_tokens: number - agent_mode: string - iterations: number - error?: string -} - -export type ToolCall = { - status: string - error?: string | null - time_cost?: number - tool_icon: any - tool_input?: any - tool_output?: any - tool_name?: string - tool_label?: any - tool_parameters?: any -} - -export type AgentIteration = { - created_at: string - files: string[] - thought: string - tokens: number - tool_calls: ToolCall[] - tool_raw: { - inputs: string - outputs: string - } -} - -type AgentLogFile = { - id: string - type: string - url: string - name: string - belongs_to: string -} - -export type AgentLogDetailRequest = { - conversation_id: string - message_id: string -} - -export type AgentLogDetailResponse = { - meta: AgentLogMeta - iterations: AgentIteration[] - files: AgentLogFile[] -} +export type AgentLogDetailResponse = AgentLogResponse +export type AgentIteration = AgentIterationLogResponse +export type ToolCall = AgentToolCallResponse diff --git a/web/service/log.ts b/web/service/log.ts index 32fdf19138475f..1595b5489234ef 100644 --- a/web/service/log.ts +++ b/web/service/log.ts @@ -1,47 +1,6 @@ -import type { - AgentLogDetailRequest, - AgentLogDetailResponse, - ChatMessagesRequest, - ChatMessagesResponse, - LogMessageAnnotationsRequest, - LogMessageAnnotationsResponse, - LogMessageFeedbacksRequest, - LogMessageFeedbacksResponse, - WorkflowRunDetailResponse, -} from '@/models/log' +import type { WorkflowRunDetailResponse } from '@/models/log' import type { NodeTracingListResponse } from '@/types/workflow' -import { get, post } from './base' - -// (Chat Application) Message list in one session -export const fetchChatMessages = ({ - url, - params, -}: { - url: string - params: ChatMessagesRequest -}): Promise => { - return get(url, { params }) -} - -export const updateLogMessageFeedbacks = ({ - url, - body, -}: { - url: string - body: LogMessageFeedbacksRequest -}): Promise => { - return post(url, { body }) -} - -export const updateLogMessageAnnotations = ({ - url, - body, -}: { - url: string - body: LogMessageAnnotationsRequest -}): Promise => { - return post(url, { body }) -} +import { get } from './base' export const fetchRunDetail = (url: string): Promise => { return get(url) @@ -50,13 +9,3 @@ export const fetchRunDetail = (url: string): Promise export const fetchTracingList = ({ url }: { url: string }): Promise => { return get(url) } - -export const fetchAgentLogDetail = ({ - appID, - params, -}: { - appID: string - params: AgentLogDetailRequest -}): Promise => { - return get(`/apps/${appID}/agent/logs`, { params }) -} From b289d8161f92ca04d16467318a108f0b56281e58 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:42:07 +0000 Subject: [PATCH 07/60] [autofix.ci] apply automated fixes --- api/openapi/markdown/console-openapi.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index febb1e60e063bc..65f67badfb68cd 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -17105,9 +17105,9 @@ Legacy Chat App model config used only for follow-up question generation. | error | string | | No | | status | string | | Yes | | time_cost | number
integer | | Yes | -| tool_icon | | | No | +| tool_icon | string
object | Icon URL or emoji icon | No | | tool_input | object | | Yes | -| tool_label | string | | Yes | +| tool_label | string
object | Tool name or its i18n labels keyed by locale | Yes | | tool_name | string | | Yes | | tool_output | object | | Yes | | tool_parameters | object | | Yes | From 66910779ccf6fa1c144e0936dd797f74430f92ac Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:46:22 +0800 Subject: [PATCH 08/60] refactor(web): migrate model provider credentials to the console contract Remove the forwarding hooks in service/use-models.ts. Credential queries and mutations use consoleQuery/consoleClient where they are consumed; useAuthService keeps owning the provider-vs-model dispatch. The load balancing modal normalizes the generated response into its form types at one boundary. The model credential query is skipped without a model instead of requesting `model=undefined`. Co-Authored-By: Claude Opus 5.5 --- oxlint-suppressions.json | 8 - .../hooks/__tests__/use-auth-service.spec.tsx | 270 ++++++++++-------- .../hooks/__tests__/use-auth.spec.tsx | 19 +- .../model-auth/hooks/use-auth-service.ts | 103 ++++--- .../model-auth/hooks/use-auth.ts | 9 +- .../load-balancing-credential-save.spec.tsx | 10 +- .../model-load-balancing-modal.spec.tsx | 45 ++- .../use-activate-credential.spec.tsx | 22 +- .../use-activate-credential.ts | 14 +- .../model-load-balancing-modal.tsx | 69 +++-- .../plugin-detail-panel/model-list.tsx | 9 +- web/service/__tests__/use-models.spec.tsx | 76 ----- web/service/use-models.ts | 172 ----------- 13 files changed, 361 insertions(+), 465 deletions(-) delete mode 100644 web/service/__tests__/use-models.spec.tsx delete mode 100644 web/service/use-models.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 0fe03d0b3d37ba..f7842ebfb3eca0 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4229,14 +4229,6 @@ "count": 1 } }, - "web/service/use-models.ts": { - "@tanstack/query/prefer-query-options": { - "count": 3 - }, - "no-restricted-imports": { - "count": 1 - } - }, "web/service/use-pipeline.ts": { "@tanstack/query/prefer-query-options": { "count": 8 diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth-service.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth-service.spec.tsx index d5bc1a5604740c..560bc3c4a21316 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth-service.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth-service.spec.tsx @@ -1,150 +1,170 @@ import type { CustomModel } from '../../../declarations' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { renderHook } from '@testing-library/react' +import { QueryClient, QueryClientProvider, skipToken } from '@tanstack/react-query' +import { act, renderHook } from '@testing-library/react' import { ModelTypeEnum } from '../../../declarations' import { useAuthService, useGetCredential } from '../use-auth-service' -vi.mock('@/service/use-models', () => ({ - useGetProviderCredential: vi.fn(), - useGetModelCredential: vi.fn(), - useAddProviderCredential: vi.fn(), - useEditProviderCredential: vi.fn(), - useDeleteProviderCredential: vi.fn(), - useActiveProviderCredential: vi.fn(), - useAddModelCredential: vi.fn(), - useEditModelCredential: vi.fn(), - useDeleteModelCredential: vi.fn(), - useActiveModelCredential: vi.fn(), +const mocks = vi.hoisted(() => ({ + providerCredentialOptions: vi.fn(), + modelCredentialOptions: vi.fn(), + addProviderCredential: vi.fn(), + editProviderCredential: vi.fn(), + deleteProviderCredential: vi.fn(), + switchProviderCredential: vi.fn(), + addModelCredential: vi.fn(), + editModelCredential: vi.fn(), + deleteModelCredential: vi.fn(), + switchModelCredential: vi.fn(), })) -const { - useGetProviderCredential, - useGetModelCredential, - useAddProviderCredential, - useEditProviderCredential, - useDeleteProviderCredential, - useActiveProviderCredential, - useAddModelCredential, - useEditModelCredential, - useDeleteModelCredential, - useActiveModelCredential, -} = await import('@/service/use-models') +const { createQueryOptions } = vi.hoisted(() => ({ + createQueryOptions: + (name: string, fetcher: (input: unknown) => void) => + ({ input }: { input: unknown }) => { + fetcher(input) + return { + queryKey: [name, input], + queryFn: typeof input === 'symbol' ? input : () => ({ source: name }), + } + }, +})) + +vi.mock('@/service/console', () => ({ + consoleQuery: { + workspaces: { + current: { + modelProviders: { + byProvider: { + credentials: { + get: { + queryOptions: createQueryOptions('provider', mocks.providerCredentialOptions), + }, + }, + models: { + credentials: { + get: { queryOptions: createQueryOptions('model', mocks.modelCredentialOptions) }, + }, + }, + }, + }, + }, + }, + }, + consoleClient: { + workspaces: { + current: { + modelProviders: { + byProvider: { + credentials: { + post: mocks.addProviderCredential, + put: mocks.editProviderCredential, + delete: mocks.deleteProviderCredential, + switch: { post: mocks.switchProviderCredential }, + }, + models: { + credentials: { + post: mocks.addModelCredential, + put: mocks.editModelCredential, + delete: mocks.deleteModelCredential, + switch: { post: mocks.switchModelCredential }, + }, + }, + }, + }, + }, + }, + }, +})) + +// The services are selected at runtime, so call sites pass a payload of either shape. +type CredentialService = (payload: unknown) => Promise describe('useAuthService hooks', () => { let queryClient: QueryClient const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ) + const model: CustomModel = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } beforeEach(() => { vi.clearAllMocks() queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - - const mockMutationReturn = { mutateAsync: vi.fn() } - vi.mocked(useAddProviderCredential).mockReturnValue( - mockMutationReturn as unknown as ReturnType, - ) - vi.mocked(useEditProviderCredential).mockReturnValue( - mockMutationReturn as unknown as ReturnType, - ) - vi.mocked(useDeleteProviderCredential).mockReturnValue( - mockMutationReturn as unknown as ReturnType, - ) - vi.mocked(useActiveProviderCredential).mockReturnValue( - mockMutationReturn as unknown as ReturnType, - ) - vi.mocked(useAddModelCredential).mockReturnValue( - mockMutationReturn as unknown as ReturnType, - ) - vi.mocked(useEditModelCredential).mockReturnValue( - mockMutationReturn as unknown as ReturnType, - ) - vi.mocked(useDeleteModelCredential).mockReturnValue( - mockMutationReturn as unknown as ReturnType, - ) - vi.mocked(useActiveModelCredential).mockReturnValue( - mockMutationReturn as unknown as ReturnType, - ) }) - it('useGetCredential selects correct source and params', () => { - const mockData = { data: 'test' } - vi.mocked(useGetProviderCredential).mockReturnValue( - mockData as unknown as ReturnType, - ) - vi.mocked(useGetModelCredential).mockReturnValue( - mockData as unknown as ReturnType, - ) - - // Provider case - const { result: providerRes } = renderHook( - () => useGetCredential('openai', false, 'cred-123'), - { wrapper }, - ) - expect(useGetProviderCredential).toHaveBeenCalledWith(true, 'openai', 'cred-123') - expect(providerRes.current).toBe(mockData) - - // Model case - const mockModel = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } as CustomModel - const { result: modelRes } = renderHook( - () => useGetCredential('openai', true, 'cred-123', mockModel, 'src'), - { wrapper }, - ) - expect(useGetModelCredential).toHaveBeenCalledWith( - true, - 'openai', - 'cred-123', - 'gpt-4', - ModelTypeEnum.textGeneration, - 'src', - ) - expect(modelRes.current).toBe(mockData) - - // Early return cases + it('should query the provider credential only when a credential id is given', () => { + renderHook(() => useGetCredential('openai', false, 'cred-123'), { wrapper }) + expect(mocks.providerCredentialOptions).toHaveBeenCalledWith({ + params: { provider: 'openai' }, + query: { credential_id: 'cred-123' }, + }) + renderHook(() => useGetCredential('openai', false), { wrapper }) - expect(useGetProviderCredential).toHaveBeenCalledWith(false, 'openai', undefined) - - // Branch: isModelCredential true but no id/model - renderHook(() => useGetCredential('openai', true), { wrapper }) - expect(useGetModelCredential).toHaveBeenCalledWith( - false, - 'openai', - undefined, - undefined, - undefined, - undefined, - ) + expect(mocks.providerCredentialOptions).toHaveBeenLastCalledWith(skipToken) }) - it('useAuthService provides correct services for provider and model', () => { + it('should query the model credential only when a model is given', () => { + renderHook(() => useGetCredential('openai', true, 'cred-123', model, 'custom-model'), { + wrapper, + }) + expect(mocks.modelCredentialOptions).toHaveBeenCalledWith({ + params: { provider: 'openai' }, + query: { + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + config_from: 'custom-model', + credential_id: 'cred-123', + }, + }) + + renderHook(() => useGetCredential('openai', true, 'cred-123'), { wrapper }) + expect(mocks.modelCredentialOptions).toHaveBeenLastCalledWith(skipToken) + }) + + it('should send provider credential changes to the provider endpoints', async () => { const { result } = renderHook(() => useAuthService('openai'), { wrapper }) + const credential = { credential_id: 'cred-1', name: 'Main', credentials: { key: 'v' } } + + await act(async () => { + await (result.current.getAddCredentialService(false) as CredentialService)(credential) + await (result.current.getEditCredentialService(false) as CredentialService)(credential) + await (result.current.getDeleteCredentialService(false) as CredentialService)({ + credential_id: 'cred-1', + }) + await (result.current.getActiveCredentialService(false) as CredentialService)({ + credential_id: 'cred-1', + }) + }) + + const params = { provider: 'openai' } + expect(mocks.addProviderCredential).toHaveBeenCalledWith({ params, body: credential }) + expect(mocks.editProviderCredential).toHaveBeenCalledWith({ params, body: credential }) + expect(mocks.deleteProviderCredential).toHaveBeenCalledWith({ + params, + body: { credential_id: 'cred-1' }, + }) + expect(mocks.switchProviderCredential).toHaveBeenCalledWith({ + params, + body: { credential_id: 'cred-1' }, + }) + }) + + it('should send model credential changes to the model endpoints', async () => { + const { result } = renderHook(() => useAuthService('openai'), { wrapper }) + const credential = { ...model, credential_id: 'cred-1', credentials: { key: 'v' } } + const ref = { ...model, credential_id: 'cred-1' } + + await act(async () => { + await (result.current.getAddCredentialService(true) as CredentialService)(credential) + await (result.current.getEditCredentialService(true) as CredentialService)(credential) + await (result.current.getDeleteCredentialService(true) as CredentialService)(ref) + await (result.current.getActiveCredentialService(true) as CredentialService)(ref) + }) - // Provider services - expect(result.current.getAddCredentialService(false)).toBe( - vi.mocked(useAddProviderCredential).mock.results[0]!.value.mutateAsync, - ) - expect(result.current.getEditCredentialService(false)).toBe( - vi.mocked(useEditProviderCredential).mock.results[0]!.value.mutateAsync, - ) - expect(result.current.getDeleteCredentialService(false)).toBe( - vi.mocked(useDeleteProviderCredential).mock.results[0]!.value.mutateAsync, - ) - expect(result.current.getActiveCredentialService(false)).toBe( - vi.mocked(useActiveProviderCredential).mock.results[0]!.value.mutateAsync, - ) - - // Model services - expect(result.current.getAddCredentialService(true)).toBe( - vi.mocked(useAddModelCredential).mock.results[0]!.value.mutateAsync, - ) - expect(result.current.getEditCredentialService(true)).toBe( - vi.mocked(useEditModelCredential).mock.results[0]!.value.mutateAsync, - ) - expect(result.current.getDeleteCredentialService(true)).toBe( - vi.mocked(useDeleteModelCredential).mock.results[0]!.value.mutateAsync, - ) - expect(result.current.getActiveCredentialService(true)).toBe( - vi.mocked(useActiveModelCredential).mock.results[0]!.value.mutateAsync, - ) + const params = { provider: 'openai' } + expect(mocks.addModelCredential).toHaveBeenCalledWith({ params, body: credential }) + expect(mocks.editModelCredential).toHaveBeenCalledWith({ params, body: credential }) + // The model credential delete endpoint takes its fields as query parameters. + expect(mocks.deleteModelCredential).toHaveBeenCalledWith({ params, query: ref }) + expect(mocks.switchModelCredential).toHaveBeenCalledWith({ params, body: ref }) }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth.spec.tsx index 14484fd2f4c3c2..d5fdd8b3c8d984 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth.spec.tsx @@ -38,8 +38,19 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useRefreshModel: () => ({ handleRefreshModel: mockHandleRefreshModel }), })) -vi.mock('@/service/use-models', () => ({ - useDeleteModel: () => ({ mutateAsync: mockDeleteModelService }), +vi.mock('@/service/console', () => ({ + consoleQuery: { + workspaces: { + current: { + modelProviders: { byProvider: { models: { delete: { mutationOptions: () => ({}) } } } }, + }, + }, + }, +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => ({ + ...(await importOriginal()), + useMutation: () => ({ mutateAsync: mockDeleteModelService }), })) vi.mock('../use-auth-service', () => ({ @@ -204,8 +215,8 @@ describe('useAuth', () => { }) expect(mockDeleteModelService).toHaveBeenCalledWith({ - model: 'gpt-4', - model_type: ModelTypeEnum.textGeneration, + params: { provider: 'openai' }, + query: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration }, }) expect(onRemove).toHaveBeenCalledWith('') }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth-service.ts b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth-service.ts index a2dfc4f1b13c0f..7498cdde304155 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth-service.ts +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth-service.ts @@ -1,17 +1,14 @@ -import type { CustomModel } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + CustomModel, + ModelCredentialPayload, + ModelTypeEnum, + ProviderCredential, +} from '@/app/components/header/account-setting/model-provider-page/declarations' +import { skipToken, useMutation, useQuery } from '@tanstack/react-query' import { useCallback } from 'react' -import { - useActiveModelCredential, - useActiveProviderCredential, - useAddModelCredential, - useAddProviderCredential, - useDeleteModelCredential, - useDeleteProviderCredential, - useEditModelCredential, - useEditProviderCredential, - useGetModelCredential, - useGetProviderCredential, -} from '@/service/use-models' +import { consoleClient, consoleQuery } from '@/service/console' + +type ModelCredentialRef = { credential_id: string; model: string; model_type: ModelTypeEnum } export const useGetCredential = ( provider: string, @@ -20,32 +17,70 @@ export const useGetCredential = ( model?: CustomModel, configFrom?: string, ) => { - const providerData = useGetProviderCredential( - !isModelCredential && !!credentialId, - provider, - credentialId, - ) - const modelData = useGetModelCredential( - !!isModelCredential && (!!credentialId || !!model), - provider, - credentialId, - model?.model, - model?.model_type, - configFrom, - ) + const modelProvider = consoleQuery.workspaces.current.modelProviders.byProvider + const providerData = useQuery({ + ...modelProvider.credentials.get.queryOptions({ + input: + !isModelCredential && credentialId + ? { params: { provider }, query: { credential_id: credentialId } } + : skipToken, + }), + staleTime: 0, + gcTime: 0, + }) + const modelData = useQuery({ + ...modelProvider.models.credentials.get.queryOptions({ + input: + isModelCredential && model + ? { + params: { provider }, + query: { + model: model.model, + model_type: model.model_type, + config_from: configFrom, + credential_id: credentialId, + }, + } + : skipToken, + }), + staleTime: 0, + gcTime: 0, + }) return isModelCredential ? modelData : providerData } export const useAuthService = (provider: string) => { - const { mutateAsync: addProviderCredential } = useAddProviderCredential(provider) - const { mutateAsync: editProviderCredential } = useEditProviderCredential(provider) - const { mutateAsync: deleteProviderCredential } = useDeleteProviderCredential(provider) - const { mutateAsync: activeProviderCredential } = useActiveProviderCredential(provider) + const client = consoleClient.workspaces.current.modelProviders.byProvider + const params = { provider } + + const { mutateAsync: addProviderCredential } = useMutation({ + mutationFn: (body: ProviderCredential) => client.credentials.post({ params, body }), + }) + const { mutateAsync: editProviderCredential } = useMutation({ + mutationFn: (body: ProviderCredential) => client.credentials.put({ params, body }), + }) + const { mutateAsync: deleteProviderCredential } = useMutation({ + mutationFn: (body: { credential_id: string }) => client.credentials.delete({ params, body }), + }) + const { mutateAsync: activeProviderCredential } = useMutation({ + mutationFn: (body: { credential_id: string }) => + client.credentials.switch.post({ params, body }), + }) - const { mutateAsync: addModelCredential } = useAddModelCredential(provider) - const { mutateAsync: activeModelCredential } = useActiveModelCredential(provider) - const { mutateAsync: deleteModelCredential } = useDeleteModelCredential(provider) - const { mutateAsync: editModelCredential } = useEditModelCredential(provider) + const { mutateAsync: addModelCredential } = useMutation({ + mutationFn: (body: ModelCredentialPayload) => client.models.credentials.post({ params, body }), + }) + const { mutateAsync: activeModelCredential } = useMutation({ + mutationFn: (body: ModelCredentialRef) => + client.models.credentials.switch.post({ params, body }), + }) + const { mutateAsync: deleteModelCredential } = useMutation({ + mutationFn: (query: ModelCredentialRef) => client.models.credentials.delete({ params, query }), + }) + const { mutateAsync: editModelCredential } = useMutation({ + mutationFn: (body: ModelCredentialPayload & { credential_id: string }) => + client.models.credentials.put({ params, body }), + }) const getAddCredentialService = useCallback( (isModel: boolean) => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.ts b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.ts index e8fc5ec14981cf..bf4fc3e02512e3 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.ts +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.ts @@ -6,6 +6,7 @@ import type { ModelModalModeEnum, ModelProvider, } from '../../declarations' +import { useMutation } from '@tanstack/react-query' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { @@ -13,7 +14,7 @@ import { useRefreshModel, } from '@/app/components/header/account-setting/model-provider-page/hooks' import { toast } from '@/app/notifications' -import { useDeleteModel } from '@/service/use-models' +import { consoleQuery } from '@/service/console' import { useAuthService } from './use-auth-service' type ProviderCredentialOperationPayload = { @@ -45,7 +46,9 @@ export const useAuth = ( getEditCredentialService, getAddCredentialService, } = useAuthService(provider.provider) - const { mutateAsync: deleteModelService } = useDeleteModel(provider.provider) + const { mutateAsync: deleteModelService } = useMutation( + consoleQuery.workspaces.current.modelProviders.byProvider.models.delete.mutationOptions(), + ) const handleOpenModelModal = useModelModalHandler() const { handleRefreshModel } = useRefreshModel() const pendingOperationCredentialId = useRef(null) @@ -156,7 +159,7 @@ export const useAuth = ( model: pendingOperationModel.current.model, model_type: pendingOperationModel.current.model_type, } - await deleteModelService(payload) + await deleteModelService({ params: { provider: provider.provider }, query: payload }) } handleRefreshModel(provider, undefined, true) onRemove?.(pendingOperationCredentialId.current ?? '') diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/load-balancing-credential-save.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/load-balancing-credential-save.spec.tsx index f670d5c28e588e..0b22a003b232e0 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/load-balancing-credential-save.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/load-balancing-credential-save.spec.tsx @@ -22,10 +22,16 @@ import ModelLoadBalancingConfigs from '../model-load-balancing-configs' const { mockPost } = vi.hoisted(() => ({ mockPost: vi.fn() })) +// Requests go through the console client, whose browser transport uses `request`. vi.mock('@/service/base', async (importOriginal) => ({ ...(await importOriginal()), - get: vi.fn().mockResolvedValue({ credentials: {} }), - post: mockPost, + request: async (_url: string, _init: RequestInit, { request }: { request: Request }) => { + if (request.method !== 'POST') return Response.json({ credentials: {} }) + mockPost(new URL(request.url).pathname.replace(/^\/console\/api/, ''), { + body: await request.json(), + }) + return Response.json({ result: 'success' }) + }, })) vi.mock('../../hooks', async (importOriginal) => ({ diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx index 93ef2603728b77..975b56312eaaca 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx @@ -72,17 +72,41 @@ vi.mock('@/app/notifications', async (importOriginal) => { } }) -vi.mock('@/service/use-models', () => ({ - useGetModelCredential: () => ({ - isLoading: false, - data: mockCredentialData, - refetch: mockRefetch, - }), - useUpdateModelLoadBalancingConfig: () => ({ - mutateAsync: mockMutateAsync, - }), +vi.mock('@/service/console', () => ({ + consoleQuery: { + workspaces: { + current: { + modelProviders: { + byProvider: { + models: { + credentials: { get: { queryOptions: () => ({ operation: 'model-credential' }) } }, + post: { mutationOptions: () => ({ operation: 'update-load-balancing' }) }, + }, + }, + }, + }, + }, + }, })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useQuery: (options: { operation?: string }) => + options.operation === 'model-credential' + ? { isLoading: false, data: mockCredentialData, refetch: mockRefetch } + : actual.useQuery(options as Parameters[0]), + useMutation: (options: { operation?: string }) => + options.operation === 'update-load-balancing' + ? { + mutateAsync: ({ params, body }: { params: { provider: string }; body: unknown }) => + mockMutateAsync(body, params), + } + : actual.useMutation(options as Parameters[0]), + } +}) + vi.mock('../../model-auth/hooks/use-auth', () => ({ useAuth: () => ({ doingAction: false, @@ -274,6 +298,9 @@ describe('ModelLoadBalancingModal', () => { await waitFor(() => { expect(mockRefetch).toHaveBeenCalled() + expect(mockMutateAsync).toHaveBeenCalledWith(expect.anything(), { + provider: 'test-provider', + }) expect(mockMutateAsync).toHaveBeenCalled() const payload = mockMutateAsync.mock.calls[0]![0] as { load_balancing: { configs: Array<{ credentials: { api_key: string } }> } diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx index 045e54c04b7b33..b74cfda3071b87 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx @@ -8,11 +8,21 @@ const mockUpdateModelProviders = vi.fn() const mockUpdateModelList = vi.fn() let mockIsPending = false -vi.mock('@/service/use-models', () => ({ - useActiveProviderCredential: () => ({ - mutate: mockMutate, - isPending: mockIsPending, - }), +vi.mock('@/service/console', () => ({ + consoleQuery: { + workspaces: { + current: { + modelProviders: { + byProvider: { credentials: { switch: { post: { mutationOptions: () => ({}) } } } }, + }, + }, + }, + }, +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => ({ + ...(await importOriginal()), + useMutation: () => ({ mutate: mockMutate, isPending: mockIsPending }), })) vi.mock('../../../hooks', () => ({ @@ -85,7 +95,7 @@ describe('useActivateCredential', () => { expect(result.current.selectedCredentialId).toBe('cred-2') expect(mockMutate).toHaveBeenCalledWith( - { credential_id: 'cred-2' }, + { params: { provider: 'langgenius/openai/openai' }, body: { credential_id: 'cred-2' } }, expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function), diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts index 3c01efea48d100..7aed0a3cd6aeea 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts @@ -1,15 +1,18 @@ import type { Credential, ModelProvider } from '../../declarations' +import { useMutation } from '@tanstack/react-query' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' -import { useActiveProviderCredential } from '@/service/use-models' +import { consoleQuery } from '@/service/console' import { useUpdateModelList, useUpdateModelProviders } from '../../hooks' export function useActivateCredential(provider: ModelProvider) { const { t } = useTranslation(['common']) const updateModelProviders = useUpdateModelProviders() const updateModelList = useUpdateModelList() - const { mutate, isPending } = useActiveProviderCredential(provider.provider) + const { mutate, isPending } = useMutation( + consoleQuery.workspaces.current.modelProviders.byProvider.credentials.switch.post.mutationOptions(), + ) const [optimisticId, setOptimisticId] = useState() const currentId = provider.custom_configuration.current_credential_id const selectedCredentialId = optimisticId ?? currentId @@ -25,7 +28,10 @@ export function useActivateCredential(provider: ModelProvider) { if (credential.credential_id === selectedIdRef.current) return setOptimisticId(credential.credential_id) mutate( - { credential_id: credential.credential_id }, + { + params: { provider: provider.provider }, + body: { credential_id: credential.credential_id }, + }, { onSuccess: () => { updateModelProviders() @@ -38,7 +44,7 @@ export function useActivateCredential(provider: ModelProvider) { }, ) }, - [mutate, t, updateModelProviders, updateModelList], + [mutate, provider.provider, t, updateModelProviders, updateModelList], ) return { selectedCredentialId, diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx index 95f1d9691bf158..71e51612b26df6 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx @@ -1,6 +1,7 @@ import type { Credential, CustomConfigurationModelFixedFields, + ModelCredential, ModelItem, ModelLoadBalancingConfig, ModelLoadBalancingConfigEntry, @@ -17,12 +18,13 @@ import { import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { useMutation, useQuery } from '@tanstack/react-query' import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { SwitchCredentialInLoadBalancing } from '@/app/components/header/account-setting/model-provider-page/model-auth' import { toast } from '@/app/notifications' -import { useGetModelCredential, useUpdateModelLoadBalancingConfig } from '@/service/use-models' +import { consoleQuery } from '@/service/console' import { ConfigurationMethodEnum, FormTypeEnum } from '../declarations' import { useRefreshModel } from '../hooks' import { useAuth } from '../model-auth/hooks/use-auth' @@ -60,15 +62,41 @@ const ModelLoadBalancingModal = ({ const [loading, setLoading] = useState(false) const providerFormSchemaPredefined = configurateMethod === ConfigurationMethodEnum.predefinedModel const configFrom = providerFormSchemaPredefined ? 'predefined-model' : 'custom-model' - const { isLoading, data, refetch } = useGetModelCredential( - true, - provider.provider, - credential?.credential_id, - model.model, - model.model_type, - configFrom, + const { isLoading, data, refetch } = useQuery({ + ...consoleQuery.workspaces.current.modelProviders.byProvider.models.credentials.get.queryOptions( + { + input: { + params: { provider: provider.provider }, + query: { + model: model.model, + model_type: model.model_type, + config_from: configFrom, + credential_id: credential?.credential_id, + }, + }, + }, + ), + staleTime: 0, + gcTime: 0, + }) + const modelCredential = useMemo( + () => + data && { + ...data, + current_credential_id: data.current_credential_id ?? undefined, + current_credential_name: data.current_credential_name ?? undefined, + load_balancing: { + enabled: data.load_balancing.enabled, + configs: data.load_balancing.configs.map((config) => ({ + ...config, + credential_id: config.credential_id ?? undefined, + // Credential values are untyped JSON form values in the console API response. + credentials: config.credentials as ModelLoadBalancingConfigEntry['credentials'], + })), + }, + }, + [data], ) - const modelCredential = data const { load_balancing, current_credential_id, available_credentials, current_credential_name } = modelCredential ?? {} const originalConfig = load_balancing @@ -129,8 +157,8 @@ const ModelLoadBalancingModal = ({ }, [extendedSecretFormSchemas, originalConfigMap], ) - const { mutateAsync: updateModelLoadBalancingConfig } = useUpdateModelLoadBalancingConfig( - provider.provider, + const { mutateAsync: updateModelLoadBalancingConfig } = useMutation( + consoleQuery.workspaces.current.modelProviders.byProvider.models.post.mutationOptions(), ) const initialCustomModelCredential = useMemo(() => { if (!current_credential_id) return undefined @@ -147,14 +175,17 @@ const ModelLoadBalancingModal = ({ try { setLoading(true) const res = await updateModelLoadBalancingConfig({ - credential_id: customModelCredential?.credential_id || current_credential_id, - config_from: configFrom, - model: model.model, - model_type: model.model_type, - load_balancing: { - ...draftConfig, - configs: draftConfig!.configs.map(encodeConfigEntrySecretValues), - enabled: Boolean(draftConfig?.enabled), + params: { provider: provider.provider }, + body: { + credential_id: customModelCredential?.credential_id || current_credential_id, + config_from: configFrom, + model: model.model, + model_type: model.model_type, + load_balancing: { + ...draftConfig, + configs: draftConfig!.configs.map(encodeConfigEntrySecretValues), + enabled: Boolean(draftConfig?.enabled), + }, }, }) if (res.result === 'success') { diff --git a/web/app/components/plugins/plugin-detail-panel/model-list.tsx b/web/app/components/plugins/plugin-detail-panel/model-list.tsx index 7f4c42c271c8e3..4b166bb5c6b47c 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-list.tsx @@ -1,9 +1,10 @@ import type { PluginDetail } from '@/app/components/plugins/types' +import { useQuery } from '@tanstack/react-query' import * as React from 'react' import { useTranslation } from 'react-i18next' import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon' import ModelName from '@/app/components/header/account-setting/model-provider-page/model-name' -import { useModelProviderModelList } from '@/service/use-models' +import { consoleQuery } from '@/service/console' type Props = Readonly<{ detail: PluginDetail @@ -11,8 +12,10 @@ type Props = Readonly<{ const ModelList = ({ detail }: Props) => { const { t } = useTranslation(['plugin']) - const { data: res } = useModelProviderModelList( - `${detail.plugin_id}/${detail.declaration.model.provider}`, + const { data: res } = useQuery( + consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryOptions({ + input: { params: { provider: `${detail.plugin_id}/${detail.declaration.model.provider}` } }, + }), ) if (!res) return null diff --git a/web/service/__tests__/use-models.spec.tsx b/web/service/__tests__/use-models.spec.tsx deleted file mode 100644 index 29638e20bc3f6d..00000000000000 --- a/web/service/__tests__/use-models.spec.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import type { ReactNode } from 'react' -import type { ProviderCredential } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { renderHook, waitFor } from '@testing-library/react' -import { useGetProviderCredential } from '../use-models' - -const mockGet = vi.hoisted(() => vi.fn()) - -vi.mock('../base', () => ({ - del: vi.fn(), - get: mockGet, - post: vi.fn(), - put: vi.fn(), -})) - -const createWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: 1000 * 60 * 5, - }, - }, - }) - - return ({ children }: { children: ReactNode }) => ( - {children} - ) -} - -describe('useGetProviderCredential', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should refetch credential details after the editor is reopened', async () => { - const previousCredential: ProviderCredential = { - credential_id: 'credential-id', - name: 'Tongyi', - credentials: { - api_key: '[__HIDDEN__]', - endpoint: 'https://old.example.com', - enable_request_metadata: false, - }, - } - const updatedCredential: ProviderCredential = { - ...previousCredential, - credentials: { - api_key: '[__HIDDEN__]', - endpoint: 'https://new.example.com', - enable_request_metadata: true, - }, - } - mockGet.mockResolvedValueOnce(previousCredential).mockResolvedValueOnce(updatedCredential) - const wrapper = createWrapper() - - const firstEditor = renderHook( - () => useGetProviderCredential(true, 'langgenius/tongyi/tongyi', 'credential-id'), - { wrapper }, - ) - await waitFor(() => { - expect(firstEditor.result.current.data).toEqual(previousCredential) - }) - firstEditor.unmount() - - const reopenedEditor = renderHook( - () => useGetProviderCredential(true, 'langgenius/tongyi/tongyi', 'credential-id'), - { wrapper }, - ) - - await waitFor(() => { - expect(mockGet).toHaveBeenCalledTimes(2) - expect(reopenedEditor.result.current.data).toEqual(updatedCredential) - }) - }) -}) diff --git a/web/service/use-models.ts b/web/service/use-models.ts deleted file mode 100644 index 97dd930d4dec1d..00000000000000 --- a/web/service/use-models.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { - ModelCredential, - ModelCredentialPayload, - ModelItem, - ModelLoadBalancingConfig, - ModelTypeEnum, - ProviderCredential, -} from '@/app/components/header/account-setting/model-provider-page/declarations' -import { - useMutation, - useQuery, - // useQueryClient, -} from '@tanstack/react-query' -import { del, get, post, put } from './base' - -const NAME_SPACE = 'models' - -export const useModelProviderModelList = (provider: string) => { - return useQuery({ - queryKey: [NAME_SPACE, 'model-list', provider], - queryFn: () => - get<{ data: ModelItem[] }>(`/workspaces/current/model-providers/${provider}/models`), - }) -} - -export const useGetProviderCredential = ( - enabled: boolean, - provider: string, - credentialId?: string, -) => { - return useQuery({ - enabled, - queryKey: [NAME_SPACE, 'model-list', provider, credentialId], - queryFn: () => - get( - `/workspaces/current/model-providers/${provider}/credentials${credentialId ? `?credential_id=${credentialId}` : ''}`, - ), - staleTime: 0, - gcTime: 0, - }) -} - -export const useAddProviderCredential = (provider: string) => { - return useMutation({ - mutationFn: (data: ProviderCredential) => - post<{ result: string }>(`/workspaces/current/model-providers/${provider}/credentials`, { - body: data, - }), - }) -} - -export const useEditProviderCredential = (provider: string) => { - return useMutation({ - mutationFn: (data: ProviderCredential) => - put<{ result: string }>(`/workspaces/current/model-providers/${provider}/credentials`, { - body: data, - }), - }) -} - -export const useDeleteProviderCredential = (provider: string) => { - return useMutation({ - mutationFn: (data: { credential_id: string }) => - del<{ result: string }>(`/workspaces/current/model-providers/${provider}/credentials`, { - body: data, - }), - }) -} - -export const useActiveProviderCredential = (provider: string) => { - return useMutation({ - mutationFn: (data: { credential_id: string; model?: string; model_type?: ModelTypeEnum }) => - post<{ result: string }>( - `/workspaces/current/model-providers/${provider}/credentials/switch`, - { - body: data, - }, - ), - }) -} - -export const useGetModelCredential = ( - enabled: boolean, - provider: string, - credentialId?: string, - model?: string, - modelType?: string, - configFrom?: string, -) => { - return useQuery({ - enabled, - queryKey: [NAME_SPACE, 'model-list', provider, model, modelType, credentialId, configFrom], - queryFn: () => - get( - `/workspaces/current/model-providers/${provider}/models/credentials?model=${model}&model_type=${modelType}&config_from=${configFrom}${credentialId ? `&credential_id=${credentialId}` : ''}`, - ), - staleTime: 0, - gcTime: 0, - }) -} - -export const useAddModelCredential = (provider: string) => { - return useMutation({ - mutationFn: (data: ModelCredentialPayload) => - post<{ result: string }>( - `/workspaces/current/model-providers/${provider}/models/credentials`, - { - body: data, - }, - ), - }) -} - -export const useEditModelCredential = (provider: string) => { - return useMutation({ - mutationFn: (data: ModelCredentialPayload) => - put<{ result: string }>( - `/workspaces/current/model-providers/${provider}/models/credentials`, - { - body: data, - }, - ), - }) -} - -export const useDeleteModelCredential = (provider: string) => { - return useMutation({ - mutationFn: (data: { credential_id: string; model: string; model_type: ModelTypeEnum }) => - del<{ result: string }>( - `/workspaces/current/model-providers/${provider}/models/credentials`, - { - params: data, - }, - ), - }) -} - -export const useDeleteModel = (provider: string) => { - return useMutation({ - mutationFn: (data: { model: string; model_type: ModelTypeEnum }) => - del<{ result: string }>(`/workspaces/current/model-providers/${provider}/models`, { - params: data, - }), - }) -} - -export const useActiveModelCredential = (provider: string) => { - return useMutation({ - mutationFn: (data: { credential_id: string; model: string; model_type: ModelTypeEnum }) => - post<{ result: string }>( - `/workspaces/current/model-providers/${provider}/models/credentials/switch`, - { - body: data, - }, - ), - }) -} - -export const useUpdateModelLoadBalancingConfig = (provider: string) => { - return useMutation({ - mutationFn: (data: { - config_from: string - model: string - model_type: ModelTypeEnum - load_balancing: ModelLoadBalancingConfig - credential_id?: string - }) => - post<{ result: string }>(`/workspaces/current/model-providers/${provider}/models`, { - body: data, - }), - }) -} From fdf6377f4476422c391aa399e85726a0d8afcd49 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:02:17 +0800 Subject: [PATCH 09/60] refactor(web): migrate publish, notion import, hit testing and workspace permission Move the remaining calls of service/apps.ts, knowledge/use-import.ts, knowledge/use-hit-testing.ts and use-workspace.ts to the console contract at their call sites, and delete those modules. - Hit testing types now alias the generated response; the record never had a `content` copy of the segment, and the query has no tsne position. - Notion pre-import pages are normalized once in the query `select`; top-level pages without a parent are treated as `root`. - RETRIEVE_METHOD drops `invertedIndex`, which the API never accepts or returns. Co-Authored-By: Claude Opus 5.5 --- oxlint-suppressions.json | 26 ------ .../app-publisher/__tests__/index.spec.tsx | 17 ++-- .../use-marketplace-publish.ts | 6 +- .../__tests__/base.spec.tsx | 82 ++++++++++--------- .../base/notion-page-selector/base.tsx | 49 +++++++++-- .../common/retrieval-method-info/index.tsx | 1 - .../create/embedding-process/rule-detail.tsx | 1 - .../hit-testing/__tests__/index.spec.tsx | 70 ++++++++-------- .../__tests__/chunk-detail-dialog.spec.tsx | 32 ++------ .../__tests__/result-dialogs.browser.spec.tsx | 37 +++------ .../__tests__/result-item-external.spec.tsx | 4 +- .../components/__tests__/result-item.spec.tsx | 32 ++------ .../components/result-item-external.tsx | 4 +- .../components/result-item-meta.tsx | 2 +- .../components/datasets/hit-testing/index.tsx | 27 ++++-- .../members-page/__tests__/index.spec.tsx | 1 + .../__tests__/invite-button.spec.tsx | 23 ++++-- .../members-page/invite-button.tsx | 11 ++- .../invite-modal/__tests__/index.spec.tsx | 1 + web/models/datasets.ts | 80 +++--------------- web/service/apps.ts | 15 ---- web/service/knowledge/use-hit-testing.ts | 30 ------- web/service/knowledge/use-import.ts | 37 --------- web/service/use-workspace.ts | 14 ---- web/test/fixtures/hit-testing.ts | 60 ++++++++++++++ web/types/app.ts | 2 - 26 files changed, 272 insertions(+), 392 deletions(-) delete mode 100644 web/service/apps.ts delete mode 100644 web/service/knowledge/use-hit-testing.ts delete mode 100644 web/service/knowledge/use-import.ts delete mode 100644 web/service/use-workspace.ts create mode 100644 web/test/fixtures/hit-testing.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index f7842ebfb3eca0..e7131648c9279e 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4065,11 +4065,6 @@ "count": 1 } }, - "web/service/apps.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "web/service/base.spec.ts": { "dify/require-disable-directive-description": { "count": 1 @@ -4146,19 +4141,6 @@ "count": 1 } }, - "web/service/knowledge/use-hit-testing.ts": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/service/knowledge/use-import.ts": { - "@tanstack/query/prefer-query-options": { - "count": 2 - }, - "no-restricted-imports": { - "count": 1 - } - }, "web/service/knowledge/use-metadata.ts": { "@tanstack/query/prefer-query-options": { "count": 8 @@ -4300,14 +4282,6 @@ "count": 3 } }, - "web/service/use-workspace.ts": { - "@tanstack/query/prefer-query-options": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "web/service/webapp-auth.ts": { "no-restricted-globals": { "count": 6 diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index dcb0abf3421cbb..a74e51ebbf6595 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -97,10 +97,6 @@ vi.mock('@/service/access-control/use-app-access-control', () => ({ const mockPublishToCreatorsPlatform = vi.fn() const mockCreateWorkflowToolProvider = vi.fn() -vi.mock('@/service/apps', () => ({ - publishToCreatorsPlatform: (...args: unknown[]) => mockPublishToCreatorsPlatform(...args), -})) - vi.mock('@/service/tools', () => ({ createWorkflowToolProvider: (...args: unknown[]) => mockCreateWorkflowToolProvider(...args), saveWorkflowToolProvider: vi.fn(), @@ -972,8 +968,13 @@ describe('AppPublisher', () => { }) it('should show marketplace button and open redirect URL on success', async () => { - mockPublishToCreatorsPlatform.mockResolvedValue({ - redirect_url: 'https://marketplace.example.com/publish?code=abc', + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = new Request(input, init) + if (new URL(request.url).pathname.endsWith('/apps/app-1/publish-to-creators-platform')) { + mockPublishToCreatorsPlatform(request.method) + return Response.json({ redirect_url: 'https://marketplace.example.com/publish?code=abc' }) + } + throw new Error(`Unexpected request: ${request.method} ${request.url}`) }) const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null) @@ -994,7 +995,7 @@ describe('AppPublisher', () => { fireEvent.click(screen.getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/)) await waitFor(() => { - expect(mockPublishToCreatorsPlatform).toHaveBeenCalledWith({ appID: 'app-1' }) + expect(mockPublishToCreatorsPlatform).toHaveBeenCalledWith('POST') expect(windowOpenSpy).toHaveBeenCalledWith( 'https://marketplace.example.com/publish?code=abc', '_blank', @@ -1005,7 +1006,7 @@ describe('AppPublisher', () => { }) it('should show toast error when publish to marketplace fails', async () => { - mockPublishToCreatorsPlatform.mockRejectedValue(new Error('network error')) + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network error')) renderWithConsoleQuery( , diff --git a/web/app/components/app/app-publisher/publisher-content/use-marketplace-publish.ts b/web/app/components/app/app-publisher/publisher-content/use-marketplace-publish.ts index 0d09f969ae1c4a..f6a32a9005718a 100644 --- a/web/app/components/app/app-publisher/publisher-content/use-marketplace-publish.ts +++ b/web/app/components/app/app-publisher/publisher-content/use-marketplace-publish.ts @@ -1,7 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' -import { publishToCreatorsPlatform } from '@/service/apps' +import { consoleClient } from '@/service/console' export function useMarketplacePublish(appId?: string) { const { t } = useTranslation(['workflow']) @@ -12,7 +12,9 @@ export function useMarketplacePublish(appId?: string) { setIsPublishing(true) try { - const response = await publishToCreatorsPlatform({ appID: appId }) + const response = await consoleClient.apps.byAppId.publishToCreatorsPlatform.post({ + params: { app_id: appId }, + }) if (response.redirect_url) window.open(response.redirect_url, '_blank') } catch { toast.error(t(($) => $['common.publishToMarketplaceFailed'], { ns: 'workflow' })) diff --git a/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx b/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx index 8975198bbc84c1..f5ce1df6354b7d 100644 --- a/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx +++ b/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx @@ -4,17 +4,20 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' -import { - useInvalidPreImportNotionPages, - usePreImportNotionPages, -} from '@/service/knowledge/use-import' +import { consoleQuery } from '@/service/console' import NotionPageSelector from '../base' vi.mock('@tanstack/react-virtual') -vi.mock('@/service/knowledge/use-import', () => ({ +const { usePreImportNotionPages, mockInvalidateQueries } = vi.hoisted(() => ({ usePreImportNotionPages: vi.fn(), - useInvalidPreImportNotionPages: vi.fn(), + mockInvalidateQueries: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => ({ + ...(await importOriginal()), + useQuery: () => usePreImportNotionPages(), + useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }), })) const mockSetSettingsDestination = vi.fn() @@ -101,23 +104,20 @@ const createPreImportResult = ({ notionInfo?: DataSourceNotionWorkspace[] isFetching?: boolean isError?: boolean -} = {}) => - ({ - data: { notion_info: notionInfo }, - isFetching, - isError, - }) as ReturnType +} = {}) => ({ + // The query selects the normalized workspaces. + data: notionInfo, + isFetching, + isError, +}) describe('NotionPageSelector Base', () => { - const mockInvalidPreImportNotionPages = vi.fn() - beforeEach(() => { vi.clearAllMocks() - vi.mocked(useInvalidPreImportNotionPages).mockReturnValue(mockInvalidPreImportNotionPages) }) it('should render loading state when pages are being fetched', () => { - vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult({ isFetching: true })) + usePreImportNotionPages.mockReturnValue(createPreImportResult({ isFetching: true })) render() @@ -125,7 +125,7 @@ describe('NotionPageSelector Base', () => { }) it('should render connector and open settings when fetch fails', async () => { - vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult({ isError: true })) + usePreImportNotionPages.mockReturnValue(createPreImportResult({ isError: true })) const user = userEvent.setup() render() @@ -137,7 +137,7 @@ describe('NotionPageSelector Base', () => { }) it('should render page selector and allow selecting a page tree', async () => { - vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult()) + usePreImportNotionPages.mockReturnValue(createPreImportResult()) const handleSelect = vi.fn() const user = userEvent.setup() @@ -160,7 +160,7 @@ describe('NotionPageSelector Base', () => { }) it('should keep bound pages disabled and selected by default', async () => { - vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult()) + usePreImportNotionPages.mockReturnValue(createPreImportResult()) const handleSelect = vi.fn() const user = userEvent.setup() render() @@ -172,7 +172,7 @@ describe('NotionPageSelector Base', () => { }) it('should filter and clear search results from search input actions', async () => { - vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult()) + usePreImportNotionPages.mockReturnValue(createPreImportResult()) const user = userEvent.setup() render() @@ -185,7 +185,7 @@ describe('NotionPageSelector Base', () => { }) it('should switch credential and reset selection when choosing a different workspace', async () => { - vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult()) + usePreImportNotionPages.mockReturnValue(createPreImportResult()) const handleSelect = vi.fn() const onSelectCredential = vi.fn() const user = userEvent.setup() @@ -203,16 +203,17 @@ describe('NotionPageSelector Base', () => { const item2 = screen.getByRole('option', { name: /Workspace 2/ }) await user.click(item2) - expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ - datasetId: 'dataset-1', - credentialId: 'c2', + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.notion.preImport.pages.get.queryKey({ + input: { query: { dataset_id: 'dataset-1', credential_id: 'c2' } }, + }), }) expect(handleSelect).toHaveBeenCalledWith([]) expect(onSelectCredential).toHaveBeenLastCalledWith('c2') }) it('should open settings when configuration action in header is clicked', async () => { - vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult()) + usePreImportNotionPages.mockReturnValue(createPreImportResult()) const user = userEvent.setup() render() @@ -223,7 +224,7 @@ describe('NotionPageSelector Base', () => { }) it('should preview a page and call onPreview when callback is provided', async () => { - vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult()) + usePreImportNotionPages.mockReturnValue(createPreImportResult()) const onPreview = vi.fn() const user = userEvent.setup() render( @@ -243,7 +244,7 @@ describe('NotionPageSelector Base', () => { }) it('should handle preview click without onPreview callback', async () => { - vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult()) + usePreImportNotionPages.mockReturnValue(createPreImportResult()) const user = userEvent.setup() render() await user.click(screen.getByTestId('notion-page-preview-root-1')) @@ -251,7 +252,7 @@ describe('NotionPageSelector Base', () => { }) it('should call onSelectCredential with current credential on initial render', () => { - vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult()) + usePreImportNotionPages.mockReturnValue(createPreImportResult()) const onSelectCredential = vi.fn() render( { }) it('should fallback to first credential when current credential is removed in error mode', async () => { - vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult({ isError: true })) + usePreImportNotionPages.mockReturnValue(createPreImportResult({ isError: true })) const onSelect = vi.fn() const onSelectCredential = vi.fn() const { rerender } = render( @@ -287,9 +288,10 @@ describe('NotionPageSelector Base', () => { ) await waitFor(() => { - expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ - datasetId: 'dataset-fallback', - credentialId: 'c3', + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.notion.preImport.pages.get.queryKey({ + input: { query: { dataset_id: 'dataset-fallback', credential_id: 'c3' } }, + }), }) expect(onSelect).toHaveBeenCalledWith([]) expect(onSelectCredential).toHaveBeenLastCalledWith('c3') @@ -297,7 +299,7 @@ describe('NotionPageSelector Base', () => { }) it('should update selected page state when controlled value changes', () => { - vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult()) + usePreImportNotionPages.mockReturnValue(createPreImportResult()) const { rerender } = render( { }) it('should hide preview actions when canPreview is false', () => { - vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult()) + usePreImportNotionPages.mockReturnValue(createPreImportResult()) render( { }) it('should handle undefined data gracefully during loading', () => { - vi.mocked(usePreImportNotionPages).mockReturnValue({ + usePreImportNotionPages.mockReturnValue({ data: undefined, isFetching: true, isError: false, @@ -339,7 +341,7 @@ describe('NotionPageSelector Base', () => { }) it('should handle credential with empty id', () => { - vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult()) + usePreImportNotionPages.mockReturnValue(createPreImportResult()) const onSelectCredential = vi.fn() render( { expect(onSelectCredential).toHaveBeenCalledWith('') }) - it('should render empty page selector when notion_info is empty', () => { - vi.mocked(usePreImportNotionPages).mockReturnValue({ - data: { notion_info: undefined }, + it('should render empty page selector when there are no workspaces', () => { + usePreImportNotionPages.mockReturnValue({ + data: [], isFetching: false, isError: false, } as unknown as ReturnType) @@ -362,7 +364,7 @@ describe('NotionPageSelector Base', () => { }) it('should run credential effect fallback when onSelectCredential is not provided', () => { - vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult()) + usePreImportNotionPages.mockReturnValue(createPreImportResult()) const { rerender } = render( , ) diff --git a/web/app/components/base/notion-page-selector/base.tsx b/web/app/components/base/notion-page-selector/base.tsx index c0762bb0cef5c5..74b09ce656083c 100644 --- a/web/app/components/base/notion-page-selector/base.tsx +++ b/web/app/components/base/notion-page-selector/base.tsx @@ -1,3 +1,4 @@ +import type { NotionIntegrateInfoListResponse } from '@dify/contracts/api/console/notion/types.gen' import type { DataSourceCredential } from '../../header/account-setting/data-source-page-new/types' import type { NotionCredential } from './credential-selector' import type { @@ -5,6 +6,7 @@ import type { DataSourceNotionWorkspace, NotionPage, } from '@/models/common' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQueryState } from 'nuqs' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -13,16 +15,30 @@ import { settingsQueryParamName, settingsQueryParser, } from '@/app/components/header/account-setting/query-params' -import { - useInvalidPreImportNotionPages, - usePreImportNotionPages, -} from '@/service/knowledge/use-import' +import { consoleQuery } from '@/service/console' import Header from '../../datasets/create/website/base/header' import NotionConnector from '../notion-connector' import WorkspaceSelector from './credential-selector' import PageSelector from './page-selector' import SearchInput from './search-input' +// Top-level pages have no parent in the API response; the page tree calls them `root`. +const toNotionWorkspaces = ({ notion_info }: NotionIntegrateInfoListResponse) => + notion_info.map((workspace): DataSourceNotionWorkspace => ({ + ...workspace, + workspace_id: workspace.workspace_id ?? '', + workspace_name: workspace.workspace_name ?? '', + pages: workspace.pages.map((page) => ({ + ...page, + parent_id: page.parent_id ?? 'root', + page_icon: page.page_icon && { + type: page.page_icon.type ?? null, + url: page.page_icon.url ?? null, + emoji: page.page_icon.emoji ?? null, + }, + })), + })) + type NotionPageSelectorProps = { value?: string[] onSelect: (selectedPages: NotionPage[]) => void @@ -48,7 +64,16 @@ const NotionPageSelector = ({ const [searchValue, setSearchValue] = useState('') const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser) - const invalidPreImportNotionPages = useInvalidPreImportNotionPages() + const queryClient = useQueryClient() + const invalidPreImportNotionPages = useCallback( + ({ datasetId, credentialId }: { datasetId: string; credentialId: string }) => + queryClient.invalidateQueries({ + queryKey: consoleQuery.notion.preImport.pages.get.queryKey({ + input: { query: { dataset_id: datasetId, credential_id: credentialId } }, + }), + }), + [queryClient], + ) const notionCredentials = useMemo((): NotionCredential[] => { return credentialList.map((item) => { @@ -99,14 +124,20 @@ const NotionPageSelector = ({ data: notionsPages, isFetching: isFetchingNotionPages, isError: isFetchingNotionPagesError, - } = usePreImportNotionPages({ datasetId, credentialId: currentCredentialId }) - const notionPages = notionsPages?.notion_info?.flatMap((workspace) => workspace.pages) || [] + } = useQuery( + consoleQuery.notion.preImport.pages.get.queryOptions({ + input: { query: { dataset_id: datasetId, credential_id: currentCredentialId } }, + retry: 0, + select: toNotionWorkspaces, + }), + ) + const notionPages = notionsPages?.flatMap((workspace) => workspace.pages) || [] const pagesMapAndSelectedPagesId: [DataSourceNotionPageMap, Set, Set] = useMemo(() => { const selectedPagesId = new Set() const boundPagesId = new Set() - const notionWorkspaces = notionsPages?.notion_info || [] + const notionWorkspaces = notionsPages || [] const pagesMap = notionWorkspaces.reduce( (prev: DataSourceNotionPageMap, cur: DataSourceNotionWorkspace) => { cur.pages.forEach((page) => { @@ -125,7 +156,7 @@ const NotionPageSelector = ({ {}, ) return [pagesMap, selectedPagesId, boundPagesId] - }, [notionsPages?.notion_info]) + }, [notionsPages]) const defaultSelectedPagesId = useMemo(() => { return [...Array.from(pagesMapAndSelectedPagesId[1]), ...(value || [])] diff --git a/web/app/components/datasets/common/retrieval-method-info/index.tsx b/web/app/components/datasets/common/retrieval-method-info/index.tsx index 0b342ca52913ac..9be100b20baaa7 100644 --- a/web/app/components/datasets/common/retrieval-method-info/index.tsx +++ b/web/app/components/datasets/common/retrieval-method-info/index.tsx @@ -8,7 +8,6 @@ export const getIcon = (type: RETRIEVE_METHOD) => { [RETRIEVE_METHOD.semantic]: retrievalIcon.vector, [RETRIEVE_METHOD.fullText]: retrievalIcon.fullText, [RETRIEVE_METHOD.hybrid]: retrievalIcon.hybrid, - [RETRIEVE_METHOD.invertedIndex]: retrievalIcon.vector, [RETRIEVE_METHOD.keywordSearch]: retrievalIcon.vector, }[type] || retrievalIcon.vector ) diff --git a/web/app/components/datasets/create/embedding-process/rule-detail.tsx b/web/app/components/datasets/create/embedding-process/rule-detail.tsx index b0d3d722946688..cee66e0bfb3509 100644 --- a/web/app/components/datasets/create/embedding-process/rule-detail.tsx +++ b/web/app/components/datasets/create/embedding-process/rule-detail.tsx @@ -26,7 +26,6 @@ const RETRIEVAL_ICON_MAP: Partial> = { [RETRIEVE_METHOD.fullText]: retrievalIcon.fullText, [RETRIEVE_METHOD.hybrid]: retrievalIcon.hybrid, [RETRIEVE_METHOD.semantic]: retrievalIcon.vector, - [RETRIEVE_METHOD.invertedIndex]: retrievalIcon.fullText, [RETRIEVE_METHOD.keywordSearch]: retrievalIcon.fullText, } diff --git a/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx index a4e1d82f81bbaf..62e402a73917e3 100644 --- a/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx +++ b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx @@ -9,6 +9,7 @@ import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vite-plus/test' import { seedAccountProfileQuery } from '@/test/console/account-profile' import { render } from '@/test/console/render' +import { createHitTestingRecordFixture } from '@/test/fixtures/hit-testing' import { RETRIEVE_METHOD } from '@/types/app' import HitTestingPage from '../index' @@ -160,15 +161,10 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ })), })) -vi.mock('@/service/knowledge/use-hit-testing', () => ({ - useHitTesting: vi.fn(() => ({ - mutateAsync: mockHitTestingMutateAsync, - isLoading: false, - })), - useExternalKnowledgeBaseHitTesting: vi.fn(() => ({ - mutateAsync: mockExternalHitTestingMutateAsync, - isLoading: false, - })), +// The page owns one mutation per retrieval kind, told apart by mutation key. +const { mockHitTestingState, mockExternalHitTestingState } = vi.hoisted(() => ({ + mockHitTestingState: vi.fn(), + mockExternalHitTestingState: vi.fn(), })) // Mock breakpoints hook @@ -339,12 +335,8 @@ const createMockSegment = (overrides = {}) => ({ }) const createMockHitTesting = (overrides = {}): HitTesting => ({ + ...createHitTestingRecordFixture(), segment: createMockSegment() as HitTesting['segment'], - content: createMockSegment() as HitTesting['content'], - score: 0.85, - tsne_position: { x: 0.5, y: 0.5 }, - child_chunks: null, - files: [], ...overrides, }) @@ -400,17 +392,14 @@ describe('HitTestingPage', () => { refetch: mockRecordsRefetch, isLoading: false, } as unknown as ReturnType) - - const { useHitTesting, useExternalKnowledgeBaseHitTesting } = - await import('@/service/knowledge/use-hit-testing') - vi.mocked(useHitTesting).mockReturnValue({ + vi.mocked(mockHitTestingState).mockReturnValue({ mutateAsync: mockHitTestingMutateAsync, isLoading: false, - } as unknown as ReturnType) - vi.mocked(useExternalKnowledgeBaseHitTesting).mockReturnValue({ + }) + vi.mocked(mockExternalHitTestingState).mockReturnValue({ mutateAsync: mockExternalHitTestingMutateAsync, isLoading: false, - } as unknown as ReturnType) + }) const useBreakpoints = await import('@/hooks/use-breakpoints') vi.mocked(useBreakpoints.default).mockReturnValue( @@ -458,19 +447,17 @@ describe('HitTestingPage', () => { 'announces loading and failure in the same status region for %s retrieval', async (provider) => { mockDataset.provider = provider - const { useHitTesting, useExternalKnowledgeBaseHitTesting } = - await import('@/service/knowledge/use-hit-testing') const setRetrievalState = (state: { isPending: boolean; isError: boolean }) => { if (provider === 'external') { - vi.mocked(useExternalKnowledgeBaseHitTesting).mockReturnValue({ + vi.mocked(mockExternalHitTestingState).mockReturnValue({ mutateAsync: mockExternalHitTestingMutateAsync, ...state, - } as unknown as ReturnType) + }) } else { - vi.mocked(useHitTesting).mockReturnValue({ + vi.mocked(mockHitTestingState).mockReturnValue({ mutateAsync: mockHitTestingMutateAsync, ...state, - } as unknown as ReturnType) + }) } } const { rerender } = renderWithProviders() @@ -495,9 +482,8 @@ describe('HitTestingPage', () => { const user = userEvent.setup() const { default: useBreakpoints } = await import('@/hooks/use-breakpoints') vi.mocked(useBreakpoints).mockReturnValue('mobile' as ReturnType) - const { useHitTesting } = await import('@/service/knowledge/use-hit-testing') const response: HitTestingResponse = { - query: { content: 'Test query', tsne_position: { x: 0, y: 0 } }, + query: { content: 'Test query' }, records: [createMockHitTesting()], } mockHitTestingMutateAsync.mockImplementationOnce(async (_params, options) => { @@ -509,21 +495,21 @@ describe('HitTestingPage', () => { await user.click(screen.getByRole('button', { name: /input.testing/ })) expect(await screen.findByRole('dialog', { name: '1 Retrieved Chunks' })).toBeInTheDocument() - vi.mocked(useHitTesting).mockReturnValue({ + vi.mocked(mockHitTestingState).mockReturnValue({ mutateAsync: mockHitTestingMutateAsync, isPending: true, isError: false, - } as unknown as ReturnType) + }) rerender() const pendingDialog = screen.getByRole('dialog', { name: 'datasetHitTesting.title' }) expect(within(pendingDialog).queryByText('1 Retrieved Chunks')).not.toBeInTheDocument() - vi.mocked(useHitTesting).mockReturnValue({ + vi.mocked(mockHitTestingState).mockReturnValue({ mutateAsync: mockHitTestingMutateAsync, isPending: false, isError: true, - } as unknown as ReturnType) + }) rerender() const failedDialog = screen.getByRole('dialog', { name: 'datasetHitTesting.title' }) @@ -533,11 +519,11 @@ describe('HitTestingPage', () => { await user.click(within(failedDialog).getByRole('button', { name: /operation.close/ })) await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) - vi.mocked(useHitTesting).mockReturnValue({ + vi.mocked(mockHitTestingState).mockReturnValue({ mutateAsync: mockHitTestingMutateAsync, isPending: false, isError: false, - } as unknown as ReturnType) + }) mockHitTestingMutateAsync.mockImplementationOnce(async (_params, options) => { const emptyResponse = { ...response, records: [] } options?.onSuccess?.(emptyResponse) @@ -551,7 +537,7 @@ describe('HitTestingPage', () => { it('announces zero retrieved chunks after an empty response', async () => { const user = userEvent.setup() const response: HitTestingResponse = { - query: { content: 'No match', tsne_position: { x: 0, y: 0 } }, + query: { content: 'No match' }, records: [], } mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { @@ -621,7 +607,7 @@ describe('HitTestingPage', () => { it('saves retrieval settings, submits a query, refreshes history, and renders results', async () => { const user = userEvent.setup() const response: HitTestingResponse = { - query: { content: 'Test query', tsne_position: { x: 0, y: 0 } }, + query: { content: 'Test query' }, records: [createMockHitTesting()], } mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { @@ -709,6 +695,16 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + useMutation: (options: { mutationKey: [string, string] }) => { + if (options.mutationKey[0] === 'hit-testing') + return mockHitTestingState() ?? { mutateAsync: mockHitTestingMutateAsync, isPending: false } + return ( + mockExternalHitTestingState() ?? { + mutateAsync: mockExternalHitTestingMutateAsync, + isPending: false, + } + ) + }, useQuery: (options: { queryKey: OperationKey< 'query', diff --git a/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-dialog.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-dialog.spec.tsx index 3d22bc2483aa1b..a3d5c35b06995a 100644 --- a/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-dialog.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-dialog.spec.tsx @@ -2,41 +2,19 @@ import type { HitTesting } from '@/models/datasets' import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it } from 'vite-plus/test' +import { createHitTestingRecordFixture } from '@/test/fixtures/hit-testing' import { ResultItem } from '../result-item' function makePayload( - overrides: Partial> & { + overrides: Partial> & { segment?: Partial } = {}, ): HitTesting { - const segment: HitTesting['segment'] = { - id: 'segment-1', - position: 1, - word_count: 100, - content: 'Original content', - sign_content: '', - keywords: [], - document: { - id: 'document-1', - name: 'file.pdf', - data_source_type: 'upload_file', - doc_type: 'book', - }, - answer: '', - tokens: 20, - hit_count: 1, - index_node_hash: 'hash-1', - ...overrides.segment, - } - return { + return createHitTestingRecordFixture({ score: 0.85, - tsne_position: { x: 0, y: 0 }, - child_chunks: [], - files: [], ...overrides, - segment, - content: segment, - } + segment: { content: 'Original content', ...overrides.segment }, + }) } async function openDetail(payload: HitTesting) { diff --git a/web/app/components/datasets/hit-testing/components/__tests__/result-dialogs.browser.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/result-dialogs.browser.spec.tsx index 7e1e0bb3e102e2..54609ad7c49839 100644 --- a/web/app/components/datasets/hit-testing/components/__tests__/result-dialogs.browser.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/__tests__/result-dialogs.browser.spec.tsx @@ -1,37 +1,22 @@ import type { ExternalKnowledgeBaseHitTesting, HitTesting } from '@/models/datasets' import { page, userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' +import { createHitTestingRecordFixture } from '@/test/fixtures/hit-testing' import { ResultItem } from '../result-item' import { ResultItemExternal } from '../result-item-external' function createResult(overrides: Partial = {}): HitTesting { - const segment: HitTesting['segment'] = { - id: 'segment-1', - document: { - id: 'document-1', - name: 'handbook.pdf', - data_source_type: 'upload_file', - doc_type: 'book', - }, - content: 'Retrieval preview content', - sign_content: '', - position: 1, - word_count: 25, - tokens: 5, - keywords: [], - hit_count: 1, - index_node_hash: 'hash', - answer: '', - } - return { - segment, - content: segment, + return createHitTestingRecordFixture({ score: 0.9, - tsne_position: { x: 0, y: 0 }, - child_chunks: [], - files: [], ...overrides, - } + segment: { + content: 'Retrieval preview content', + word_count: 25, + tokens: 5, + document: { name: 'handbook.pdf' }, + ...overrides.segment, + }, + }) } function createImageUrl() { @@ -128,7 +113,7 @@ it('keeps external details scrollable and Close reachable on a narrow viewport t expect(closeBounds.right).toBeLessThanOrEqual(window.innerWidth) expect(closeBounds.top).toBeGreaterThanOrEqual(0) expect(closeBounds.bottom).toBeLessThanOrEqual(window.innerHeight) - const body = dialog.getByText(payload.content) + const body = dialog.getByText(payload.content!) const bodyElement = body.element() expect(bodyElement.scrollWidth).toBeLessThanOrEqual(bodyElement.clientWidth + 1) expect(bodyElement.scrollHeight).toBeGreaterThan(bodyElement.clientHeight) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/result-item-external.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/result-item-external.spec.tsx index d27bda2b55452c..730a63ec2bd7fe 100644 --- a/web/app/components/datasets/hit-testing/components/__tests__/result-item-external.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/__tests__/result-item-external.spec.tsx @@ -35,9 +35,9 @@ describe('ResultItemExternal', () => { expect(within(trigger).getByText('0.85')).toBeInTheDocument() expect(within(trigger).getByText('Test Document Title')).toBeInTheDocument() - await user.click(within(trigger).getByText(payload.content)) + await user.click(within(trigger).getByText(payload.content!)) const dialog = await screen.findByRole('dialog', { name: dialogName }) - expect(within(dialog).getByText(payload.content)).toBeInTheDocument() + expect(within(dialog).getByText(payload.content!)).toBeInTheDocument() expect(within(dialog).getByText('Chunk-03')).toBeInTheDocument() expect(within(dialog).getByText('0.85')).toBeInTheDocument() await user.click(within(dialog).getByRole('button', { name: 'common.operation.close' })) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx index 278523a5e31abf..b056d074c34109 100644 --- a/web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx @@ -2,41 +2,19 @@ import type { HitTesting } from '@/models/datasets' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it } from 'vite-plus/test' +import { createHitTestingRecordFixture } from '@/test/fixtures/hit-testing' import { ResultItem } from '../result-item' function makePayload( - overrides: Partial> & { + overrides: Partial> & { segment?: Partial } = {}, ): HitTesting { - const segment: HitTesting['segment'] = { - id: 'segment-1', - position: 1, - word_count: 100, - content: 'Preview content', - sign_content: '', - keywords: [], - document: { - id: 'document-1', - name: 'file.pdf', - data_source_type: 'upload_file', - doc_type: 'book', - }, - answer: '', - tokens: 20, - hit_count: 1, - index_node_hash: 'hash-1', - ...overrides.segment, - } - return { + return createHitTestingRecordFixture({ score: 0.95, - tsne_position: { x: 0, y: 0 }, - child_chunks: [], - files: [], ...overrides, - segment, - content: segment, - } + segment: { content: 'Preview content', ...overrides.segment }, + }) } const dialogName = 'datasetHitTesting.chunkDetail' diff --git a/web/app/components/datasets/hit-testing/components/result-item-external.tsx b/web/app/components/datasets/hit-testing/components/result-item-external.tsx index f3e0bb975f4df5..9f8b67d4047f31 100644 --- a/web/app/components/datasets/hit-testing/components/result-item-external.tsx +++ b/web/app/components/datasets/hit-testing/components/result-item-external.tsx @@ -22,7 +22,9 @@ type ResultItemExternalProps = { export const ResultItemExternal = memo(({ payload, positionId }: ResultItemExternalProps) => { const { t } = useTranslation(['common', 'datasetHitTesting']) const descriptionId = useId() - const { content, title, score } = payload + const content = payload.content ?? '' + const title = payload.title ?? '' + const score = payload.score ?? null return ( diff --git a/web/app/components/datasets/hit-testing/components/result-item-meta.tsx b/web/app/components/datasets/hit-testing/components/result-item-meta.tsx index a59117bb3e92d2..5320e12c88a264 100644 --- a/web/app/components/datasets/hit-testing/components/result-item-meta.tsx +++ b/web/app/components/datasets/hit-testing/components/result-item-meta.tsx @@ -9,7 +9,7 @@ type ResultItemMetaProps = { readonly labelPrefix: string readonly positionId: number readonly wordCount: number - readonly score: number + readonly score: number | null readonly className?: string } diff --git a/web/app/components/datasets/hit-testing/index.tsx b/web/app/components/datasets/hit-testing/index.tsx index 437d43fcb13dcb..0551f347bffcfb 100644 --- a/web/app/components/datasets/hit-testing/index.tsx +++ b/web/app/components/datasets/hit-testing/index.tsx @@ -3,9 +3,11 @@ import type { FC } from 'react' import type { ExternalKnowledgeBaseHitTesting, + ExternalKnowledgeBaseHitTestingRequest, ExternalKnowledgeBaseHitTestingResponse, HitTesting, HitTestingRecord, + HitTestingRequest, HitTestingResponse, Query, } from '@/models/datasets' @@ -20,7 +22,7 @@ import { DrawerViewport, } from '@langgenius/dify-ui/drawer' import { Pagination } from '@langgenius/dify-ui/pagination' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useBoolean } from 'ahooks' import { useAtomValue } from 'jotai' import * as React from 'react' @@ -34,11 +36,8 @@ import DatasetDetailContext from '@/context/dataset-detail' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { consoleClient } from '@/service/console' import { useDatasetTestingRecords } from '@/service/knowledge/use-dataset' -import { - useExternalKnowledgeBaseHitTesting, - useHitTesting, -} from '@/service/knowledge/use-hit-testing' import { getDatasetACLCapabilities } from '@/utils/permission' import { CardSkelton } from '../documents/detail/completed/skeleton/general-list-skeleton' import EmptyRecords from './components/empty-records' @@ -107,12 +106,26 @@ const HitTestingPage: FC = ({ datasetId }: Props) => { mutateAsync: hitTestingMutation, isPending: isHitTestingPending, isError: isHitTestingError, - } = useHitTesting(datasetId) + } = useMutation({ + mutationKey: ['hit-testing', datasetId], + mutationFn: (body: HitTestingRequest) => + consoleClient.datasets.byDatasetId.hitTesting.post({ + params: { dataset_id: datasetId }, + body, + }), + }) const { mutateAsync: externalKnowledgeBaseHitTestingMutation, isPending: isExternalKnowledgeBaseHitTestingPending, isError: isExternalKnowledgeBaseHitTestingError, - } = useExternalKnowledgeBaseHitTesting(datasetId) + } = useMutation({ + mutationKey: ['external-hit-testing', datasetId], + mutationFn: (body: ExternalKnowledgeBaseHitTestingRequest) => + consoleClient.datasets.byDatasetId.externalHitTesting.post({ + params: { dataset_id: datasetId }, + body, + }), + }) const isRetrievalLoading = isHitTestingPending || isExternalKnowledgeBaseHitTestingPending const result = isExternal ? externalHitResult : hitResult diff --git a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx index f9e14b93d39722..27376f89577af7 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx @@ -44,6 +44,7 @@ vi.mock('@/service/console', async (importOriginal) => { return { current: { summary: target.workspaces.current.summary, + permission: target.workspaces.current.permission, members: { inviteEmail: { post: { diff --git a/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx index 880b820d3d38f8..0aaa3892c39001 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx @@ -1,8 +1,8 @@ import type { ConsoleStateFixture } from '@/test/console/state-fixture' import { screen } from '@testing-library/react' import { vi } from 'vite-plus/test' -import { useWorkspacePermissions } from '@/service/use-workspace' -import { renderWithConsoleQuery } from '@/test/console/query-data' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { InviteButton } from '../invite-button' const mockConsoleStateReader = vi.hoisted(() => vi.fn()) @@ -14,9 +14,8 @@ vi.mock('@/context/workspace-state', async () => { })) }) -vi.mock('@/service/use-workspace') - describe('InviteButton', () => { + let queryClient = createConsoleQueryClient() const setupPermissions = ({ isFetching, allowInvite, @@ -24,14 +23,22 @@ describe('InviteButton', () => { isFetching: boolean allowInvite?: boolean }) => { - vi.mocked(useWorkspacePermissions).mockReturnValue({ - data: allowInvite === undefined ? null : { allow_member_invite: allowInvite }, - isFetching, - } as unknown as ReturnType) + queryClient = createConsoleQueryClient() + const { queryKey } = consoleQuery.workspaces.current.permission.get.queryOptions() + if (isFetching) { + void queryClient.query({ queryKey, queryFn: () => new Promise(() => {}) }) + return + } + queryClient.setQueryData(queryKey, { + workspace_id: 'workspace-id', + allow_member_invite: allowInvite ?? false, + allow_owner_transfer: false, + }) } const renderInviteButton = (brandingEnabled: boolean) => renderWithConsoleQuery(, { + queryClient, systemFeatures: { branding: { enabled: brandingEnabled } }, }) diff --git a/web/app/components/header/account-setting/members-page/invite-button.tsx b/web/app/components/header/account-setting/members-page/invite-button.tsx index 55f8d3b5c7db49..380058fd08d9b4 100644 --- a/web/app/components/header/account-setting/members-page/invite-button.tsx +++ b/web/app/components/header/account-setting/members-page/invite-button.tsx @@ -1,12 +1,12 @@ import type { ButtonProps } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { currentWorkspaceIdAtom } from '@/context/workspace-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { useWorkspacePermissions } from '@/service/use-workspace' +import { consoleQuery } from '@/service/console' type InviteButtonProps = Omit @@ -14,8 +14,11 @@ export function InviteButton(props: InviteButtonProps) { const { t } = useTranslation(['workspaceMembers']) const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = - useWorkspacePermissions(currentWorkspaceId, systemFeatures.branding.enabled) + const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = useQuery( + consoleQuery.workspaces.current.permission.get.queryOptions({ + enabled: systemFeatures.branding.enabled && !!currentWorkspaceId, + }), + ) if (systemFeatures.branding.enabled) { if (isFetchingWorkspacePermissions) { return diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx index 67d3a5b708b738..c045f89aeea633 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx @@ -38,6 +38,7 @@ vi.mock('@/service/console', async (importOriginal) => { current: { ...actual.consoleQuery.workspaces.current, summary: actual.consoleQuery.workspaces.current.summary, + permission: actual.consoleQuery.workspaces.current.permission, members: { ...actual.consoleQuery.workspaces.current.members, inviteEmail: { diff --git a/web/models/datasets.ts b/web/models/datasets.ts index e4c9f044536a96..fc378acb447cf9 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -1,3 +1,10 @@ +import type { + ExternalHitTestingRecordResponse, + ExternalHitTestingResponse, + HitTestingChildChunk as GeneratedHitTestingChildChunk, + HitTestingResponse as GeneratedHitTestingResponse, + HitTestingRecord as HitTestingRecordResponse, +} from '@dify/contracts/api/console/datasets/types.gen' import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen' import type { DataSourceNotionPage, DataSourceProvider } from './common' import type { DatasourceType } from './pipeline' @@ -611,53 +618,6 @@ export type HitTestingRecord = { queries: Query[] } -export type HitTestingChildChunk = { - id: string - content: string - position: number - score: number -} -export type HitTesting = { - segment: Segment - content: Segment - score: number - tsne_position: TsnePosition - child_chunks: HitTestingChildChunk[] | null - files: Attachment[] - summary?: string -} - -export type ExternalKnowledgeBaseHitTesting = { - content: string - title: string - score: number - metadata: { - 'x-amz-bedrock-kb-source-uri': string - 'x-amz-bedrock-kb-data-source-id': string - } -} - -type Segment = { - id: string - document: Document - content: string - sign_content: string - position: number - word_count: number - tokens: number - keywords: string[] - hit_count: number - index_node_hash: string - answer: string -} - -type Document = { - id: string - data_source_type: string - name: string - doc_type: DocType -} - export type HitTestingRecordsResponse = { data: HitTestingRecord[] has_more: boolean @@ -666,26 +626,6 @@ export type HitTestingRecordsResponse = { page: number } -type TsnePosition = { - x: number - y: number -} - -export type HitTestingResponse = { - query: { - content: string - tsne_position: TsnePosition - } - records: Array -} - -export type ExternalKnowledgeBaseHitTestingResponse = { - query: { - content: string - } - records: Array -} - export type RelatedApp = { id: string name: string @@ -855,3 +795,9 @@ export type ExternalKnowledgeBaseHitTestingRequest = { score_threshold_enabled: boolean } } + +export type HitTestingChildChunk = GeneratedHitTestingChildChunk +export type HitTesting = HitTestingRecordResponse +export type HitTestingResponse = GeneratedHitTestingResponse +export type ExternalKnowledgeBaseHitTesting = ExternalHitTestingRecordResponse +export type ExternalKnowledgeBaseHitTestingResponse = ExternalHitTestingResponse diff --git a/web/service/apps.ts b/web/service/apps.ts deleted file mode 100644 index 2a723b29af690a..00000000000000 --- a/web/service/apps.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { post } from './base' - -type PublishToCreatorsPlatformResponse = { - redirect_url: string -} - -export const publishToCreatorsPlatform = ({ - appID, -}: { - appID: string -}): Promise => { - return post(`apps/${appID}/publish-to-creators-platform`, { - body: {}, - }) -} diff --git a/web/service/knowledge/use-hit-testing.ts b/web/service/knowledge/use-hit-testing.ts deleted file mode 100644 index 94667a912c71f2..00000000000000 --- a/web/service/knowledge/use-hit-testing.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { - ExternalKnowledgeBaseHitTestingRequest, - ExternalKnowledgeBaseHitTestingResponse, - HitTestingRequest, - HitTestingResponse, -} from '@/models/datasets' -import { useMutation } from '@tanstack/react-query' -import { post } from '../base' - -const NAME_SPACE = 'hit-testing' - -export const useHitTesting = (datasetId: string) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'hit-testing', datasetId], - mutationFn: (params: HitTestingRequest) => - post(`/datasets/${datasetId}/hit-testing`, { - body: params, - }), - }) -} - -export const useExternalKnowledgeBaseHitTesting = (datasetId: string) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'external-knowledge-base-hit-testing', datasetId], - mutationFn: (params: ExternalKnowledgeBaseHitTestingRequest) => - post(`/datasets/${datasetId}/external-hit-testing`, { - body: params, - }), - }) -} diff --git a/web/service/knowledge/use-import.ts b/web/service/knowledge/use-import.ts deleted file mode 100644 index 4194f79e970f8f..00000000000000 --- a/web/service/knowledge/use-import.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { DataSourceNotionWorkspace } from '@/models/common' -import { useQuery, useQueryClient } from '@tanstack/react-query' -import { get } from '../base' - -type PreImportNotionPagesParams = { - datasetId: string - credentialId: string -} - -const PRE_IMPORT_NOTION_PAGES_QUERY_KEY = 'notion-pre-import-pages' - -export const usePreImportNotionPages = ({ - datasetId, - credentialId, -}: PreImportNotionPagesParams) => { - return useQuery({ - queryKey: [PRE_IMPORT_NOTION_PAGES_QUERY_KEY, datasetId, credentialId], - queryFn: async () => { - return get<{ notion_info: DataSourceNotionWorkspace[] }>('/notion/pre-import/pages', { - params: { - dataset_id: datasetId, - credential_id: credentialId, - }, - }) - }, - retry: 0, - }) -} - -export const useInvalidPreImportNotionPages = () => { - const queryClient = useQueryClient() - return ({ datasetId, credentialId }: PreImportNotionPagesParams) => { - queryClient.invalidateQueries({ - queryKey: [PRE_IMPORT_NOTION_PAGES_QUERY_KEY, datasetId, credentialId], - }) - } -} diff --git a/web/service/use-workspace.ts b/web/service/use-workspace.ts deleted file mode 100644 index 996a7a6f3a92f3..00000000000000 --- a/web/service/use-workspace.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { GetWorkspacesCurrentPermissionResponse } from '@dify/contracts/api/console/workspaces/types.gen' -import { useQuery } from '@tanstack/react-query' -import { get } from './base' - -export function useWorkspacePermissions( - workspaceId: GetWorkspacesCurrentPermissionResponse['workspace_id'], - enabled: boolean, -) { - return useQuery({ - queryKey: ['workspace-permissions', workspaceId], - queryFn: () => get('/workspaces/current/permission'), - enabled: enabled && !!workspaceId, - }) -} diff --git a/web/test/fixtures/hit-testing.ts b/web/test/fixtures/hit-testing.ts new file mode 100644 index 00000000000000..9de3ffb1c9907b --- /dev/null +++ b/web/test/fixtures/hit-testing.ts @@ -0,0 +1,60 @@ +import type { + HitTestingRecord, + HitTestingSegment, +} from '@dify/contracts/api/console/datasets/types.gen' + +type HitTestingSegmentOverrides = Partial> & { + document?: Partial +} + +export const createHitTestingSegmentFixture = ({ + document, + ...overrides +}: HitTestingSegmentOverrides = {}): HitTestingSegment => ({ + id: 'segment-1', + position: 1, + document_id: 'document-1', + content: 'Original content', + sign_content: '', + answer: '', + word_count: 100, + tokens: 20, + keywords: [], + index_node_id: null, + index_node_hash: 'hash-1', + hit_count: 1, + enabled: true, + disabled_at: null, + disabled_by: null, + status: 'completed', + created_by: 'user-1', + created_at: 1710000000, + indexing_at: null, + completed_at: null, + error: null, + stopped_at: null, + ...overrides, + document: { + id: 'document-1', + name: 'file.pdf', + data_source_type: 'upload_file', + doc_type: 'book', + doc_metadata: null, + ...document, + }, +}) + +export const createHitTestingRecordFixture = ({ + segment, + ...overrides +}: Partial> & { + segment?: HitTestingSegmentOverrides +} = {}): HitTestingRecord => ({ + score: 0.85, + tsne_position: null, + child_chunks: [], + files: [], + summary: null, + ...overrides, + segment: createHitTestingSegmentFixture(segment), +}) diff --git a/web/types/app.ts b/web/types/app.ts index 165870b935c2f7..72bba15d3610e5 100644 --- a/web/types/app.ts +++ b/web/types/app.ts @@ -36,13 +36,11 @@ export type RETRIEVE_METHOD = | 'semantic_search' | 'full_text_search' | 'hybrid_search' - | 'invertedIndex' | 'keyword_search' export const RETRIEVE_METHOD = { semantic: 'semantic_search' as RETRIEVE_METHOD, fullText: 'full_text_search' as RETRIEVE_METHOD, hybrid: 'hybrid_search' as RETRIEVE_METHOD, - invertedIndex: 'invertedIndex' as RETRIEVE_METHOD, keywordSearch: 'keyword_search' as RETRIEVE_METHOD, } as const From 50e8aa83d44d9b4bdcb824a141ea46ec24ffd62a Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:24:42 +0800 Subject: [PATCH 10/60] refactor(web): migrate workspace members and RBAC roles to the console contract Remove service/access-control/use-{member,workspace}-roles.ts and useMembers. Consumers use consoleQuery directly; the role and member invalidations those hooks owned now live in query-policies. Backend contract fixes this depended on: - RBAC role list/create/update/copy and role-member list now document their query parameters and request bodies, and the member list and member role endpoints document `language`; previously the generated client could not send them. - Member `roles` is typed as `{id, name}` summaries, which is what the endpoint returns, instead of a free-form map. The web previously typed them as full roles and as ISO timestamps; both were wrong. A shared role-list options factory normalizes the loosely typed role DTOs into the UI role type. test/console/operation-hooks lets specs answer console operations by path while other queries stay real. Co-Authored-By: Claude Opus 5.5 --- api/controllers/console/workspace/members.py | 9 +- .../console/workspace/rbac/members.py | 3 + .../console/workspace/rbac/roles.py | 22 ++++ .../console/workspace/rbac/schemas.py | 2 +- api/fields/member_fields.py | 7 +- oxlint-suppressions.json | 18 +-- .../generated/api/console/apps/types.gen.ts | 13 +- .../generated/api/console/apps/zod.gen.ts | 58 +++++---- .../api/console/workspaces/orpc.gen.ts | 56 +++++++- .../api/console/workspaces/types.gen.ts | 68 +++++++--- .../api/console/workspaces/zod.gen.ts | 121 ++++++++++++++---- .../add-access-subject-menu.tsx | 2 +- .../settings-modal/__tests__/index.spec.tsx | 13 +- .../dataset-config/settings-modal/index.tsx | 5 +- .../apps/__tests__/creators-filter.spec.tsx | 33 ++--- .../components/apps/__tests__/list.spec.tsx | 53 +++++--- web/app/components/apps/creators-filter.tsx | 8 +- .../settings/form/__tests__/index.spec.tsx | 60 +++++---- .../__tests__/basic-info-section.spec.tsx | 8 +- .../hooks/__tests__/use-form-state.spec.ts | 60 +++++---- .../settings/form/hooks/use-form-state.ts | 5 +- .../__tests__/index.spec.tsx | 12 +- .../account-setting/__tests__/index.spec.tsx | 8 +- .../workspace-role-checkbox-list.spec.tsx | 20 ++- .../members-page/__tests__/index.spec.tsx | 85 ++++++------ .../__tests__/member-menu.spec.tsx | 41 +++--- .../__tests__/member-row.spec.tsx | 23 +++- .../__tests__/index.spec.tsx | 16 ++- .../account-setting/members-page/index.tsx | 19 ++- .../__tests__/focus.browser.spec.tsx | 34 +++-- .../invite-modal/__tests__/index.spec.tsx | 20 ++- .../__tests__/role-selector.spec.tsx | 16 ++- .../members-page/invite-modal/index.tsx | 5 +- .../invite-modal/role-selector.tsx | 15 ++- .../__tests__/index.spec.tsx | 38 +++--- .../member-details-modal/index.tsx | 12 +- .../members-page/member-menu.tsx | 26 ++-- .../__tests__/index.spec.tsx | 17 ++- .../__tests__/member-selector.spec.tsx | 28 ++-- .../member-selector.tsx | 5 +- .../permissions-page/__tests__/index.spec.tsx | 48 ++++--- .../account-setting/permissions-page/hooks.ts | 5 +- .../permissions-page/index.tsx | 21 +-- .../role-list/__tests__/index.spec.tsx | 47 +++---- .../role-list/__tests__/row-menu.spec.tsx | 55 ++++---- .../role-list/copy-members-confirm-dialog.tsx | 15 ++- .../permissions-page/role-list/row-menu.tsx | 30 +++-- .../workspace-role-checkbox-list.tsx | 34 ++--- .../workspace-role-list-query.ts | 64 +++++++++ .../snippet-list/__tests__/index.spec.tsx | 37 ++++-- .../__tests__/reflow.browser.spec.tsx | 45 ++++--- .../__tests__/snippet-card.spec.tsx | 65 +++++----- .../snippet-list/components/snippet-card.tsx | 8 +- .../recipient/__tests__/email-input.spec.tsx | 6 +- .../recipient/__tests__/member-list.spec.tsx | 6 +- .../__tests__/member-selector.spec.tsx | 6 +- .../delivery-method/recipient/index.tsx | 8 +- .../delivery-method/test-email-sender.tsx | 8 +- web/models/common.ts | 13 +- .../access-control/use-member-roles.ts | 38 ------ .../access-control/use-workspace-roles.ts | 119 ----------------- web/service/console/query-policies.ts | 82 ++++++++++++ web/service/use-common.ts | 19 --- web/test/console/operation-hooks.ts | 44 +++++++ 64 files changed, 1134 insertions(+), 753 deletions(-) create mode 100644 web/app/components/header/account-setting/workspace-role-list-query.ts delete mode 100644 web/service/access-control/use-member-roles.ts delete mode 100644 web/service/access-control/use-workspace-roles.ts create mode 100644 web/test/console/operation-hooks.ts diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index f32e64ad59ac1e..6226af4051a4ea 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -12,7 +12,12 @@ from controllers.common.errors import ForbiddenError from controllers.common.fields import SimpleResultDataResponse, SimpleResultResponse, VerificationTokenResponse from controllers.common.rbac import rbac_language -from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models +from controllers.common.schema import ( + query_params_from_model, + register_enum_models, + register_response_schema_models, + register_schema_models, +) from controllers.console import console_ns from controllers.console.auth.error import ( CannotTransferOwnerToSelfError, @@ -32,6 +37,7 @@ MemberNotFoundError, MemberRoleAlreadyAssignedError, ) +from controllers.console.workspace.rbac.schemas import _AccessControlLanguageQuery from enums.account import TenantAccountRole from extensions.ext_application_services import application_services from fields.base import ResponseModel @@ -147,6 +153,7 @@ class MemberInviteErrorResponse(ResponseModel): class MemberListApi(Resource): """List all members of current tenant.""" + @console_ns.doc(params=query_params_from_model(_AccessControlLanguageQuery)) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountWithRoleListResponse.__name__]) @console_account_admission() def get(self, request_context: RequestContext): diff --git a/api/controllers/console/workspace/rbac/members.py b/api/controllers/console/workspace/rbac/members.py index 223070778e6ec7..d32dadaca2988b 100644 --- a/api/controllers/console/workspace/rbac/members.py +++ b/api/controllers/console/workspace/rbac/members.py @@ -6,9 +6,11 @@ from controllers.common.errors import InvalidArgumentError from controllers.common.rbac import rbac_language +from controllers.common.schema import query_params_from_model from controllers.console import console_ns from controllers.console.flask_admission import console_account_admission from controllers.console.workspace.rbac.schemas import ( + _AccessControlLanguageQuery, _ReplaceMemberRolesRequest, ) from extensions.ext_application_services import application_services @@ -46,6 +48,7 @@ def get(self, context: RequestContext): @console_ns.route("/workspaces/current/rbac/members//rbac-roles") class RBACMemberRolesApi(Resource): @console_account_admission() + @console_ns.doc(params=query_params_from_model(_AccessControlLanguageQuery)) @console_ns.response(200, "Success", console_ns.models[dto.MemberRolesResponse.__name__]) def get(self, context: RequestContext, member_id): return dump_response( diff --git a/api/controllers/console/workspace/rbac/roles.py b/api/controllers/console/workspace/rbac/roles.py index 955d06836c78f6..786e395b2bb179 100644 --- a/api/controllers/console/workspace/rbac/roles.py +++ b/api/controllers/console/workspace/rbac/roles.py @@ -5,11 +5,14 @@ from controllers.common.fields import SimpleResultResponse from controllers.common.rbac import RBACCheck, Workspace, rbac_language +from controllers.common.schema import query_params_from_model from controllers.console import console_ns from controllers.console.flask_admission import console_account_admission from controllers.console.workspace.rbac.schemas import ( CopyRoleParam, + _AccessControlLanguageQuery, _MembersInRoleList, + _PaginationQuery, _RBACRoleList, _RolesListQuery, _RoleUpsertRequest, @@ -35,6 +38,12 @@ def get(self, context: RequestContext): @console_ns.route("/workspaces/current/rbac/roles") class RBACRolesApi(Resource): @console_account_admission(rbac_checks=[RBACCheck(RBACPermission.WORKSPACE_ROLE_MANAGE, Workspace())]) + @console_ns.doc( + params={ + **query_params_from_model(_RolesListQuery), + **query_params_from_model(_AccessControlLanguageQuery), + } + ) @console_ns.response(200, "Success", console_ns.models[_RBACRoleList.__name__]) def get(self, context: RequestContext): req_data = _RolesListQuery.model_validate(request.args.to_dict(flat=True)) @@ -44,6 +53,8 @@ def get(self, context: RequestContext): return dump_response(_RBACRoleList, result) @console_account_admission(rbac_checks=[RBACCheck(RBACPermission.WORKSPACE_ROLE_MANAGE, Workspace())]) + @console_ns.doc(params=query_params_from_model(_AccessControlLanguageQuery)) + @console_ns.expect(console_ns.models[_RoleUpsertRequest.__name__]) @console_ns.response(201, "Role created", console_ns.models[dto.RBACRole.__name__]) def post(self, context: RequestContext): request = _RoleUpsertRequest.model_validate(console_ns.payload or {}) @@ -54,6 +65,7 @@ def post(self, context: RequestContext): @console_ns.route("/workspaces/current/rbac/roles/") class RBACRoleItemApi(Resource): @console_account_admission(rbac_checks=[RBACCheck(RBACPermission.WORKSPACE_ROLE_MANAGE, Workspace())]) + @console_ns.doc(params=query_params_from_model(_AccessControlLanguageQuery)) @console_ns.response(200, "Success", console_ns.models[dto.RBACRole.__name__]) def get(self, context: RequestContext, role_id): return dump_response( @@ -61,6 +73,8 @@ def get(self, context: RequestContext, role_id): ) @console_account_admission(rbac_checks=[RBACCheck(RBACPermission.WORKSPACE_ROLE_MANAGE, Workspace())]) + @console_ns.doc(params=query_params_from_model(_AccessControlLanguageQuery)) + @console_ns.expect(console_ns.models[_RoleUpsertRequest.__name__]) @console_ns.response(200, "Success", console_ns.models[dto.RBACRole.__name__]) def put(self, context: RequestContext, role_id): request = _RoleUpsertRequest.model_validate(console_ns.payload or {}) @@ -79,6 +93,8 @@ def delete(self, context: RequestContext, role_id): @console_ns.route("/workspaces/current/rbac/roles//copy") class RBACRoleCopyApi(Resource): @console_account_admission(rbac_checks=[RBACCheck(RBACPermission.WORKSPACE_ROLE_MANAGE, Workspace())]) + @console_ns.doc(params=query_params_from_model(_AccessControlLanguageQuery)) + @console_ns.expect(console_ns.models[CopyRoleParam.__name__]) @console_ns.response(201, "Role copied", console_ns.models[dto.RBACRole.__name__]) def post(self, context: RequestContext, role_id): request = CopyRoleParam.model_validate(console_ns.payload or {}) @@ -91,6 +107,12 @@ def post(self, context: RequestContext, role_id): @console_ns.route("/workspaces/current/rbac/roles//members") class ListMembersByRole(Resource): @console_account_admission() + @console_ns.doc( + params={ + **query_params_from_model(_PaginationQuery), + **query_params_from_model(_AccessControlLanguageQuery), + } + ) @console_ns.response(200, "Success", console_ns.models[_MembersInRoleList.__name__]) def get(self, context: RequestContext, role_id): return dump_response( diff --git a/api/controllers/console/workspace/rbac/schemas.py b/api/controllers/console/workspace/rbac/schemas.py index d276aaaacf1f7b..3a8c5c3ef590db 100644 --- a/api/controllers/console/workspace/rbac/schemas.py +++ b/api/controllers/console/workspace/rbac/schemas.py @@ -156,7 +156,7 @@ def _coerce_role_ids(cls, value: Any) -> list[str]: return value -register_schema_models(console_ns, _ReplaceMemberRolesRequest) +register_schema_models(console_ns, _ReplaceMemberRolesRequest, _RoleUpsertRequest, CopyRoleParam) def pagination_options() -> dto.ListOption: diff --git a/api/fields/member_fields.py b/api/fields/member_fields.py index 5108522af01400..5c81d9c5951b6c 100644 --- a/api/fields/member_fields.py +++ b/api/fields/member_fields.py @@ -41,6 +41,11 @@ def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: return to_timestamp(value) +class MemberRoleSummaryResponse(ResponseModel): + id: str + name: str + + class AccountWithRoleResponse(_AccountAvatarResponseMixin): id: str name: str @@ -49,7 +54,7 @@ class AccountWithRoleResponse(_AccountAvatarResponseMixin): last_active_at: int | None = None created_at: int | None = None role: str - roles: list[dict[str, str]] = Field(default_factory=list) + roles: list[MemberRoleSummaryResponse] = Field(default_factory=list) status: str @field_validator("last_login_at", "last_active_at", "created_at", mode="before") diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index e7131648c9279e..98661bfd159876 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4039,22 +4039,6 @@ "count": 1 } }, - "web/service/access-control/use-member-roles.ts": { - "@tanstack/query/prefer-query-options": { - "count": 2 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "web/service/access-control/use-workspace-roles.ts": { - "@tanstack/query/prefer-query-options": { - "count": 6 - }, - "no-restricted-imports": { - "count": 1 - } - }, "web/service/annotation.spec.ts": { "no-restricted-imports": { "count": 1 @@ -4192,7 +4176,7 @@ }, "web/service/use-common.ts": { "@tanstack/query/prefer-query-options": { - "count": 9 + "count": 8 }, "dify/require-disable-directive-description": { "count": 1 diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index b4a4a7b0947119..61cfe04f07098d 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -2198,9 +2198,7 @@ export type AccountWithRoleResponse = { last_login_at?: number | null name: string role: string - roles?: Array<{ - [key: string]: string - }> + roles?: Array status: string } @@ -3077,6 +3075,11 @@ export type WorkflowRunForLogResponse = { version?: string | null } +export type MemberRoleSummaryResponse = { + id: string + name: string +} + export type WorkflowFileUploadPayload = { allowed_file_extensions?: Array | null allowed_file_types?: Array | null @@ -4404,9 +4407,7 @@ export type AccountWithRoleResponseWritable = { last_login_at?: number | null name: string role: string - roles?: Array<{ - [key: string]: string - }> + roles?: Array status: string } diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 56ad63267c0368..296c66e579a5f0 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -2126,30 +2126,6 @@ export const zSandboxListResponse = z.object({ truncated: z.boolean().optional().default(false), }) -/** - * AccountWithRoleResponse - */ -export const zAccountWithRoleResponse = z.object({ - avatar: z.string().nullish(), - avatar_url: z.string().nullable(), - created_at: z.int().nullish(), - email: z.string(), - id: z.string(), - last_active_at: z.int().nullish(), - last_login_at: z.int().nullish(), - name: z.string(), - role: z.string(), - roles: z.array(z.record(z.string(), z.string())).optional(), - status: z.string(), -}) - -/** - * WorkflowCommentMentionUsersPayload - */ -export const zWorkflowCommentMentionUsersPayload = z.object({ - users: z.array(zAccountWithRoleResponse), -}) - /** * WorkflowCommentAccount */ @@ -3312,6 +3288,38 @@ export const zWorkflowAppLogPaginationResponse = z.object({ total: z.int(), }) +/** + * MemberRoleSummaryResponse + */ +export const zMemberRoleSummaryResponse = z.object({ + id: z.string(), + name: z.string(), +}) + +/** + * AccountWithRoleResponse + */ +export const zAccountWithRoleResponse = z.object({ + avatar: z.string().nullish(), + avatar_url: z.string().nullable(), + created_at: z.int().nullish(), + email: z.string(), + id: z.string(), + last_active_at: z.int().nullish(), + last_login_at: z.int().nullish(), + name: z.string(), + role: z.string(), + roles: z.array(zMemberRoleSummaryResponse).optional(), + status: z.string(), +}) + +/** + * WorkflowCommentMentionUsersPayload + */ +export const zWorkflowCommentMentionUsersPayload = z.object({ + users: z.array(zAccountWithRoleResponse), +}) + /** * WorkflowFeatureTogglePayload */ @@ -5891,7 +5899,7 @@ export const zAccountWithRoleResponseWritable = z.object({ last_login_at: z.int().nullish(), name: z.string(), role: z.string(), - roles: z.array(z.record(z.string(), z.string())).optional(), + roles: z.array(zMemberRoleSummaryResponse).optional(), status: z.string(), }) diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 02e30c33be121d..17306247ac6248 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -93,6 +93,7 @@ import { zGetWorkspacesCurrentEndpointsListPluginResponse, zGetWorkspacesCurrentEndpointsListQuery, zGetWorkspacesCurrentEndpointsListResponse, + zGetWorkspacesCurrentMembersQuery, zGetWorkspacesCurrentMembersResponse, zGetWorkspacesCurrentModelProvidersByProviderCheckoutUrlPath, zGetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponse, @@ -187,6 +188,7 @@ import { zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath, zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse, zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath, + zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesQuery, zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse, zGetWorkspacesCurrentRbacMyPermissionsResponse, zGetWorkspacesCurrentRbacRolePermissionsCatalogAgentResponse, @@ -194,9 +196,12 @@ import { zGetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse, zGetWorkspacesCurrentRbacRolePermissionsCatalogResponse, zGetWorkspacesCurrentRbacRolesByRoleIdMembersPath, + zGetWorkspacesCurrentRbacRolesByRoleIdMembersQuery, zGetWorkspacesCurrentRbacRolesByRoleIdMembersResponse, zGetWorkspacesCurrentRbacRolesByRoleIdPath, + zGetWorkspacesCurrentRbacRolesByRoleIdQuery, zGetWorkspacesCurrentRbacRolesByRoleIdResponse, + zGetWorkspacesCurrentRbacRolesQuery, zGetWorkspacesCurrentRbacRolesResponse, zGetWorkspacesCurrentRbacWorkspaceAgentsAccessPoliciesByPolicyIdMemberBindingsPath, zGetWorkspacesCurrentRbacWorkspaceAgentsAccessPoliciesByPolicyIdMemberBindingsResponse, @@ -409,8 +414,12 @@ import { zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyPath, zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse, zPostWorkspacesCurrentRbacAccessPoliciesResponse, + zPostWorkspacesCurrentRbacRolesBody, + zPostWorkspacesCurrentRbacRolesByRoleIdCopyBody, zPostWorkspacesCurrentRbacRolesByRoleIdCopyPath, + zPostWorkspacesCurrentRbacRolesByRoleIdCopyQuery, zPostWorkspacesCurrentRbacRolesByRoleIdCopyResponse, + zPostWorkspacesCurrentRbacRolesQuery, zPostWorkspacesCurrentRbacRolesResponse, zPostWorkspacesCurrentSkillsBody, zPostWorkspacesCurrentSkillsBySkillIdAssistMessagesBody, @@ -537,7 +546,9 @@ import { zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesBody, zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath, zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse, + zPutWorkspacesCurrentRbacRolesByRoleIdBody, zPutWorkspacesCurrentRbacRolesByRoleIdPath, + zPutWorkspacesCurrentRbacRolesByRoleIdQuery, zPutWorkspacesCurrentRbacRolesByRoleIdResponse, zPutWorkspacesCurrentRbacWorkspaceAgentsAccessPoliciesByPolicyIdBindingsBody, zPutWorkspacesCurrentRbacWorkspaceAgentsAccessPoliciesByPolicyIdBindingsPath, @@ -1262,6 +1273,7 @@ export const get12 = oc path: '/workspaces/current/members', tags: ['console'], }) + .input(z.object({ query: zGetWorkspacesCurrentMembersQuery.optional() })) .output(zGetWorkspacesCurrentMembersResponse) export const members = { @@ -3106,7 +3118,12 @@ export const get57 = oc path: '/workspaces/current/rbac/members/{member_id}/rbac-roles', tags: ['console'], }) - .input(z.object({ params: zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath })) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath, + query: zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesQuery.optional(), + }), + ) .output(zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse) export const put14 = oc @@ -3224,7 +3241,13 @@ export const post46 = oc successStatus: 201, tags: ['console'], }) - .input(z.object({ params: zPostWorkspacesCurrentRbacRolesByRoleIdCopyPath })) + .input( + z.object({ + body: zPostWorkspacesCurrentRbacRolesByRoleIdCopyBody, + params: zPostWorkspacesCurrentRbacRolesByRoleIdCopyPath, + query: zPostWorkspacesCurrentRbacRolesByRoleIdCopyQuery.optional(), + }), + ) .output(zPostWorkspacesCurrentRbacRolesByRoleIdCopyResponse) export const copy2 = { @@ -3239,7 +3262,12 @@ export const get63 = oc path: '/workspaces/current/rbac/roles/{role_id}/members', tags: ['console'], }) - .input(z.object({ params: zGetWorkspacesCurrentRbacRolesByRoleIdMembersPath })) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacRolesByRoleIdMembersPath, + query: zGetWorkspacesCurrentRbacRolesByRoleIdMembersQuery.optional(), + }), + ) .output(zGetWorkspacesCurrentRbacRolesByRoleIdMembersResponse) export const members3 = { @@ -3265,7 +3293,12 @@ export const get64 = oc path: '/workspaces/current/rbac/roles/{role_id}', tags: ['console'], }) - .input(z.object({ params: zGetWorkspacesCurrentRbacRolesByRoleIdPath })) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacRolesByRoleIdPath, + query: zGetWorkspacesCurrentRbacRolesByRoleIdQuery.optional(), + }), + ) .output(zGetWorkspacesCurrentRbacRolesByRoleIdResponse) export const put15 = oc @@ -3276,7 +3309,13 @@ export const put15 = oc path: '/workspaces/current/rbac/roles/{role_id}', tags: ['console'], }) - .input(z.object({ params: zPutWorkspacesCurrentRbacRolesByRoleIdPath })) + .input( + z.object({ + body: zPutWorkspacesCurrentRbacRolesByRoleIdBody, + params: zPutWorkspacesCurrentRbacRolesByRoleIdPath, + query: zPutWorkspacesCurrentRbacRolesByRoleIdQuery.optional(), + }), + ) .output(zPutWorkspacesCurrentRbacRolesByRoleIdResponse) export const byRoleId = { @@ -3295,6 +3334,7 @@ export const get65 = oc path: '/workspaces/current/rbac/roles', tags: ['console'], }) + .input(z.object({ query: zGetWorkspacesCurrentRbacRolesQuery.optional() })) .output(zGetWorkspacesCurrentRbacRolesResponse) export const post47 = oc @@ -3306,6 +3346,12 @@ export const post47 = oc successStatus: 201, tags: ['console'], }) + .input( + z.object({ + body: zPostWorkspacesCurrentRbacRolesBody, + query: zPostWorkspacesCurrentRbacRolesQuery.optional(), + }), + ) .output(zPostWorkspacesCurrentRbacRolesResponse) export const roles = { diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 6bb4bf1a2e66de..2a62eee92448ca 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -737,6 +737,12 @@ export type RbacRoleList = { pagination?: Pagination | null } +export type RoleUpsertRequest = { + description?: string + name: string + permission_keys?: Array +} + export type RbacRole = { category?: string description?: string @@ -749,6 +755,10 @@ export type RbacRole = { type: string } +export type CopyRoleParam = { + copy_member?: boolean +} + export type MembersInRoleList = { data?: Array pagination?: Pagination | null @@ -1529,9 +1539,7 @@ export type AccountWithRoleResponse = { last_login_at?: number | null name: string role: string - roles?: Array<{ - [key: string]: string - }> + roles?: Array status: string } @@ -2250,6 +2258,11 @@ export type PluginDependencyPackage = { version?: string | null } +export type MemberRoleSummaryResponse = { + id: string + name: string +} + export type SimpleProviderEntityResponse = { icon_small?: ModelRuntimeI18nObject | null icon_small_dark?: ModelRuntimeI18nObject | null @@ -3050,9 +3063,7 @@ export type AccountWithRoleResponseWritable = { last_login_at?: number | null name: string role: string - roles?: Array<{ - [key: string]: string - }> + roles?: Array status: string } @@ -3708,7 +3719,9 @@ export type PatchWorkspacesCurrentEndpointsByIdResponse = export type GetWorkspacesCurrentMembersData = { body?: never path?: never - query?: never + query?: { + language?: 'en' | 'ja' | 'zh' + } url: '/workspaces/current/members' } @@ -6116,7 +6129,9 @@ export type GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesData = { path: { member_id: string } - query?: never + query?: { + language?: 'en' | 'ja' | 'zh' + } url: '/workspaces/current/rbac/members/{member_id}/rbac-roles' } @@ -6265,7 +6280,13 @@ export type GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse = export type GetWorkspacesCurrentRbacRolesData = { body?: never path?: never - query?: never + query?: { + include_owner?: number + language?: 'en' | 'ja' | 'zh' + limit?: number + page?: number + reverse?: boolean + } url: '/workspaces/current/rbac/roles' } @@ -6284,9 +6305,11 @@ export type GetWorkspacesCurrentRbacRolesResponse = GetWorkspacesCurrentRbacRolesResponses[keyof GetWorkspacesCurrentRbacRolesResponses] export type PostWorkspacesCurrentRbacRolesData = { - body?: never + body: RoleUpsertRequest path?: never - query?: never + query?: { + language?: 'en' | 'ja' | 'zh' + } url: '/workspaces/current/rbac/roles' } @@ -6332,7 +6355,9 @@ export type GetWorkspacesCurrentRbacRolesByRoleIdData = { path: { role_id: string } - query?: never + query?: { + language?: 'en' | 'ja' | 'zh' + } url: '/workspaces/current/rbac/roles/{role_id}' } @@ -6351,11 +6376,13 @@ export type GetWorkspacesCurrentRbacRolesByRoleIdResponse = GetWorkspacesCurrentRbacRolesByRoleIdResponses[keyof GetWorkspacesCurrentRbacRolesByRoleIdResponses] export type PutWorkspacesCurrentRbacRolesByRoleIdData = { - body?: never + body: RoleUpsertRequest path: { role_id: string } - query?: never + query?: { + language?: 'en' | 'ja' | 'zh' + } url: '/workspaces/current/rbac/roles/{role_id}' } @@ -6374,11 +6401,13 @@ export type PutWorkspacesCurrentRbacRolesByRoleIdResponse = PutWorkspacesCurrentRbacRolesByRoleIdResponses[keyof PutWorkspacesCurrentRbacRolesByRoleIdResponses] export type PostWorkspacesCurrentRbacRolesByRoleIdCopyData = { - body?: never + body: CopyRoleParam path: { role_id: string } - query?: never + query?: { + language?: 'en' | 'ja' | 'zh' + } url: '/workspaces/current/rbac/roles/{role_id}/copy' } @@ -6401,7 +6430,12 @@ export type GetWorkspacesCurrentRbacRolesByRoleIdMembersData = { path: { role_id: string } - query?: never + query?: { + language?: 'en' | 'ja' | 'zh' + limit?: number + page?: number + reverse?: boolean + } url: '/workspaces/current/rbac/roles/{role_id}/members' } diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 55541f341f82b8..90cf9467deb154 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -674,6 +674,17 @@ export const zReplaceMemberRolesRequest = z.object({ role_ids: z.array(z.string()).optional().default([]), }) +/** + * _RoleUpsertRequest + * + * Accepts the payload sent by the Create/Edit Role dialog. + */ +export const zRoleUpsertRequest = z.object({ + description: z.string().optional().default(''), + name: z.string(), + permission_keys: z.array(z.string()).optional().default([]), +}) + /** * RBACRole */ @@ -697,6 +708,13 @@ export const zMemberRolesResponse = z.object({ roles: z.array(zRbacRole).optional(), }) +/** + * CopyRoleParam + */ +export const zCopyRoleParam = z.object({ + copy_member: z.boolean().optional().default(true), +}) + /** * _ReplaceBindingsRequest */ @@ -1331,30 +1349,6 @@ export const zSnippetImportResponse = z.object({ warnings: z.array(zDslImportWarning), }) -/** - * AccountWithRoleResponse - */ -export const zAccountWithRoleResponse = z.object({ - avatar: z.string().nullish(), - avatar_url: z.string().nullable(), - created_at: z.int().nullish(), - email: z.string(), - id: z.string(), - last_active_at: z.int().nullish(), - last_login_at: z.int().nullish(), - name: z.string(), - role: z.string(), - roles: z.array(z.record(z.string(), z.string())).optional(), - status: z.string(), -}) - -/** - * AccountWithRoleListResponse - */ -export const zAccountWithRoleListResponse = z.object({ - accounts: z.array(zAccountWithRoleResponse), -}) - /** * MemberInviteSuccessResponse */ @@ -2387,6 +2381,38 @@ export const zSnippetDependencyCheckResponse = z.object({ leaked_dependencies: z.array(zPluginDependency), }) +/** + * MemberRoleSummaryResponse + */ +export const zMemberRoleSummaryResponse = z.object({ + id: z.string(), + name: z.string(), +}) + +/** + * AccountWithRoleResponse + */ +export const zAccountWithRoleResponse = z.object({ + avatar: z.string().nullish(), + avatar_url: z.string().nullable(), + created_at: z.int().nullish(), + email: z.string(), + id: z.string(), + last_active_at: z.int().nullish(), + last_login_at: z.int().nullish(), + name: z.string(), + role: z.string(), + roles: z.array(zMemberRoleSummaryResponse).optional(), + status: z.string(), +}) + +/** + * AccountWithRoleListResponse + */ +export const zAccountWithRoleListResponse = z.object({ + accounts: z.array(zAccountWithRoleResponse), +}) + /** * ConfigurateMethod * @@ -4415,7 +4441,7 @@ export const zAccountWithRoleResponseWritable = z.object({ last_login_at: z.int().nullish(), name: z.string(), role: z.string(), - roles: z.array(z.record(z.string(), z.string())).optional(), + roles: z.array(zMemberRoleSummaryResponse).optional(), status: z.string(), }) @@ -4670,6 +4696,10 @@ export const zPatchWorkspacesCurrentEndpointsByIdPath = z.object({ */ export const zPatchWorkspacesCurrentEndpointsByIdResponse = zEndpointMutationResponse +export const zGetWorkspacesCurrentMembersQuery = z.object({ + language: z.enum(['en', 'ja', 'zh']).optional(), +}) + /** * Success */ @@ -5737,6 +5767,10 @@ export const zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath = z.object( member_id: z.uuid(), }) +export const zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesQuery = z.object({ + language: z.enum(['en', 'ja', 'zh']).optional(), +}) + /** * Success */ @@ -5780,11 +5814,25 @@ export const zGetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse = zPermi export const zGetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse = zPermissionCatalogResponse +export const zGetWorkspacesCurrentRbacRolesQuery = z.object({ + include_owner: z.int().gte(0).lte(1).optional().default(0), + language: z.enum(['en', 'ja', 'zh']).optional(), + limit: z.int().gte(1).lte(99999).optional(), + page: z.int().gte(1).optional(), + reverse: z.boolean().optional(), +}) + /** * Success */ export const zGetWorkspacesCurrentRbacRolesResponse = zRbacRoleList +export const zPostWorkspacesCurrentRbacRolesBody = zRoleUpsertRequest + +export const zPostWorkspacesCurrentRbacRolesQuery = z.object({ + language: z.enum(['en', 'ja', 'zh']).optional(), +}) + /** * Role created */ @@ -5803,24 +5851,40 @@ export const zGetWorkspacesCurrentRbacRolesByRoleIdPath = z.object({ role_id: z.uuid(), }) +export const zGetWorkspacesCurrentRbacRolesByRoleIdQuery = z.object({ + language: z.enum(['en', 'ja', 'zh']).optional(), +}) + /** * Success */ export const zGetWorkspacesCurrentRbacRolesByRoleIdResponse = zRbacRole +export const zPutWorkspacesCurrentRbacRolesByRoleIdBody = zRoleUpsertRequest + export const zPutWorkspacesCurrentRbacRolesByRoleIdPath = z.object({ role_id: z.uuid(), }) +export const zPutWorkspacesCurrentRbacRolesByRoleIdQuery = z.object({ + language: z.enum(['en', 'ja', 'zh']).optional(), +}) + /** * Success */ export const zPutWorkspacesCurrentRbacRolesByRoleIdResponse = zRbacRole +export const zPostWorkspacesCurrentRbacRolesByRoleIdCopyBody = zCopyRoleParam + export const zPostWorkspacesCurrentRbacRolesByRoleIdCopyPath = z.object({ role_id: z.uuid(), }) +export const zPostWorkspacesCurrentRbacRolesByRoleIdCopyQuery = z.object({ + language: z.enum(['en', 'ja', 'zh']).optional(), +}) + /** * Role copied */ @@ -5830,6 +5894,13 @@ export const zGetWorkspacesCurrentRbacRolesByRoleIdMembersPath = z.object({ role_id: z.uuid(), }) +export const zGetWorkspacesCurrentRbacRolesByRoleIdMembersQuery = z.object({ + language: z.enum(['en', 'ja', 'zh']).optional(), + limit: z.int().gte(1).lte(99999).optional(), + page: z.int().gte(1).optional(), + reverse: z.boolean().optional(), +}) + /** * Success */ diff --git a/web/app/components/access-rules-editor/add-access-subject-menu.tsx b/web/app/components/access-rules-editor/add-access-subject-menu.tsx index 597acfb2233f41..4b0facdd7e0733 100644 --- a/web/app/components/access-rules-editor/add-access-subject-menu.tsx +++ b/web/app/components/access-rules-editor/add-access-subject-menu.tsx @@ -47,7 +47,7 @@ function AddAccessSubjectMenuContent({ data: membersData, isPending: isLoading, error, - } = useQuery(consoleQuery.workspaces.current.members.get.queryOptions()) + } = useQuery(consoleQuery.workspaces.current.members.get.queryOptions({ input: {} })) const existingAccountIdSet = new Set(existingAccountIds) const members = membersData?.accounts ?? [] diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx index cff81dec09681c..e3924c15b88dff 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx @@ -17,7 +17,6 @@ import { } from '@/models/datasets' import { consoleQuery } from '@/service/console' import { updateDatasetSetting } from '@/service/datasets' -import { useMembers } from '@/service/use-common' import { seedAccountProfileQuery } from '@/test/console/account-profile' import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { createSystemFeaturesFixture } from '@/test/console/system-features' @@ -25,6 +24,10 @@ import { RETRIEVE_METHOD } from '@/types/app' import { DatasetACLPermission } from '@/utils/permission' import SettingsModal from '../index' +const { mockMembersQuery } = vi.hoisted(() => ({ + mockMembersQuery: vi.fn(), +})) + const toastMocks = vi.hoisted(() => ({ call: vi.fn(), dismiss: vi.fn(), @@ -81,7 +84,7 @@ vi.mock('@/service/datasets', () => ({ vi.mock('@/service/use-common', async () => ({ ...(await vi.importActual('@/service/use-common')), - useMembers: vi.fn(), + mockMembersQuery: vi.fn(), })) vi.mock('nuqs', async (importOriginal) => { @@ -113,7 +116,7 @@ vi.mock('@/app/components/datasets/settings/utils', () => ({ })) const mockUpdateDatasetSetting = updateDatasetSetting as MockedFunction -const mockUseMembers = useMembers as MockedFunction +const mockUseMembers = mockMembersQuery as MockedFunction const createRetrievalConfig = (overrides: Partial = {}): RetrievalConfig => ({ search_method: RETRIEVE_METHOD.semantic, @@ -244,7 +247,7 @@ describe('SettingsModal', () => { }, ], }, - } as ReturnType) + } as ReturnType) mockModelListQuery.mockImplementation((type: ModelTypeEnum) => { if (type === ModelTypeEnum.rerank) { return { @@ -694,6 +697,8 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { { params: GetWorkspacesCurrentModelsModelTypesByModelTypeData['path'] } > }) => { + if (options.queryKey[0].join('.') === 'console.workspaces.current.members.get') + return mockMembersQuery() if (!options.queryKey[0].includes('modelTypes')) return actual.useQuery(options) const args = options.queryKey[1].input?.params?.model_type diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index bee49a88883723..cc58cc807d29f5 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -31,7 +31,6 @@ import { useDocLink } from '@/context/i18n' import { DatasetPermission } from '@/models/datasets' import { consoleQuery } from '@/service/console' import { updateDatasetSetting } from '@/service/datasets' -import { useMembers } from '@/service/use-common' import { RetrievalChangeTip, RetrievalSection } from './retrieval-section' type SettingsModalProps = { @@ -86,7 +85,9 @@ const SettingsModal: FC = ({ currentDataset.partial_member_list || [], ) const [memberList, setMemberList] = useState([]) - const { data: membersData } = useMembers() + const { data: membersData } = useQuery( + consoleQuery.workspaces.current.members.get.queryOptions({ input: {} }), + ) const [indexMethod, setIndexMethod] = useState(currentDataset.indexing_technique) const [retrievalConfig, setRetrievalConfig] = useState( diff --git a/web/app/components/apps/__tests__/creators-filter.spec.tsx b/web/app/components/apps/__tests__/creators-filter.spec.tsx index 0cecea6cff92f2..44259dfe2e6c3c 100644 --- a/web/app/components/apps/__tests__/creators-filter.spec.tsx +++ b/web/app/components/apps/__tests__/creators-filter.spec.tsx @@ -5,6 +5,24 @@ import { createConsoleQueryWrapper } from '@/test/console/query-data' import { render as renderWithConsoleState } from '@/test/console/render' import CreatorsFilter from '../creators-filter' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.members.get': () => ({ + data: { + accounts: [ + { id: 'member-1', name: 'Zoe', avatar_url: null, status: 'active' }, + { id: 'member-2', name: 'Alice', avatar_url: null, status: 'active' }, + { id: 'member-3', name: 'Bob', avatar_url: null, status: 'active' }, + { id: 'member-4', name: 'Pending User', avatar_url: null, status: 'pending' }, + { id: 'member-5', name: 'Charlie', avatar_url: null, status: 'active' }, + { id: 'member-6', name: 'Robin', avatar_url: null, status: 'active' }, + ], + }, + }), + }) +}) + const mockOnChange = vi.hoisted(() => vi.fn()) const render = (ui: Parameters[0]) => @@ -17,21 +35,6 @@ const StatefulCreatorsFilter = ({ initialValue }: { initialValue: string[] }) => return } -vi.mock('@/service/use-common', () => ({ - useMembers: () => ({ - data: { - accounts: [ - { id: 'member-1', name: 'Zoe', avatar_url: null, status: 'active' }, - { id: 'member-2', name: 'Alice', avatar_url: null, status: 'active' }, - { id: 'member-3', name: 'Bob', avatar_url: null, status: 'active' }, - { id: 'member-4', name: 'Pending User', avatar_url: null, status: 'pending' }, - { id: 'member-5', name: 'Charlie', avatar_url: null, status: 'active' }, - { id: 'member-6', name: 'Robin', avatar_url: null, status: 'active' }, - ], - }, - }), -})) - describe('CreatorsFilter', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 1b81fd0f6fbcec..2c465a65ea48be 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -79,6 +79,18 @@ vi.mock('@/service/console', () => ({ }, }, consoleQuery: { + workspaces: { + current: { + members: { + get: { + queryOptions: (options: { input?: unknown }) => ({ + ...options, + queryKey: [['console', 'workspaces', 'current', 'members', 'get'], options], + }), + }, + }, + }, + }, account: { profile: { get: { @@ -125,17 +137,6 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('@/service/use-common', () => ({ - useMembers: () => ({ - data: { - accounts: [ - { id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' }, - { id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' }, - ], - }, - }), -})) - vi.mock('@/features/tag-management/components/tag-filter', () => ({ TagFilter: ({ value, @@ -234,16 +235,26 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useQuery: (options: { input?: unknown }) => - options.input - ? { - data: mockStarredIsLoading ? undefined : mockStarredAppData, - error: mockStarredError, - } - : { - data: mockSystemFeatures, - error: null, - }, + useQuery: (options: { input?: unknown; queryKey?: unknown[] }) => + Array.isArray(options.queryKey?.[0]) && + options.queryKey[0].join('.') === 'console.workspaces.current.members.get' + ? (() => ({ + data: { + accounts: [ + { id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' }, + { id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' }, + ], + }, + }))() + : options.input + ? { + data: mockStarredIsLoading ? undefined : mockStarredAppData, + error: mockStarredError, + } + : { + data: mockSystemFeatures, + error: null, + }, useSuspenseQuery: () => ({ data: mockSystemFeatures }), useInfiniteQuery: () => ({ data: mockServiceState.isLoading ? undefined : mockAppData, diff --git a/web/app/components/apps/creators-filter.tsx b/web/app/components/apps/creators-filter.tsx index 3cb382a60601da..d94d78b6ed81f7 100644 --- a/web/app/components/apps/creators-filter.tsx +++ b/web/app/components/apps/creators-filter.tsx @@ -25,11 +25,11 @@ import { createComboboxItems, } from '@langgenius/dify-ui/combobox' import { IconButton } from '@langgenius/dify-ui/icon-button' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { userProfileQueryOptions } from '@/features/account-profile/client' -import { useMembers } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import { getAvatarInitial } from '@/utils/avatar' type CreatorsFilterProps = { @@ -53,7 +53,9 @@ const CreatorsFilter = ({ value, onChange }: CreatorsFilterProps) => { ...userProfileQueryOptions(), select: (data) => data.profile.id, }) - const { data: membersData } = useMembers() + const { data: membersData } = useQuery( + consoleQuery.workspaces.current.members.get.queryOptions({ input: {} }), + ) const triggerRef = useRef(null) const creatorOptions = useMemo(() => { diff --git a/web/app/components/datasets/settings/form/__tests__/index.spec.tsx b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx index 00e85cf09dd130..a000740938e33b 100644 --- a/web/app/components/datasets/settings/form/__tests__/index.spec.tsx +++ b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx @@ -147,37 +147,6 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ useInvalidDatasetList: () => mockInvalidDatasetList, })) -vi.mock('@/service/use-common', () => ({ - useMembers: () => ({ - data: { - accounts: [ - { - id: 'user-1', - name: 'User 1', - email: 'user1@example.com', - role: 'owner', - avatar: '', - avatar_url: '', - last_login_at: '', - created_at: '', - status: 'active', - }, - { - id: 'user-2', - name: 'User 2', - email: 'user2@example.com', - role: 'admin', - avatar: '', - avatar_url: '', - last_login_at: '', - created_at: '', - status: 'active', - }, - ], - }, - }), -})) - vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useCurrentProviderAndModel: () => ({ currentProvider: undefined, currentModel: undefined }), useDefaultModel: () => ({ data: undefined, mutate: vi.fn(), isLoading: false }), @@ -549,6 +518,35 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { { params: GetWorkspacesCurrentModelsModelTypesByModelTypeData['path'] } > }) => { + if (options.queryKey[0].join('.') === 'console.workspaces.current.members.get') + return (() => ({ + data: { + accounts: [ + { + id: 'user-1', + name: 'User 1', + email: 'user1@example.com', + role: 'owner', + avatar: '', + avatar_url: '', + last_login_at: '', + created_at: '', + status: 'active', + }, + { + id: 'user-2', + name: 'User 2', + email: 'user2@example.com', + role: 'admin', + avatar: '', + avatar_url: '', + last_login_at: '', + created_at: '', + status: 'active', + }, + ], + }, + }))() if (!options.queryKey[0].includes('modelTypes')) return actual.useQuery(options) return { data: [], refetch: vi.fn(), isPending: false } }, diff --git a/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx b/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx index efa26a02b893f4..d8be7403ba6b9b 100644 --- a/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx +++ b/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx @@ -137,8 +137,8 @@ describe('BasicInfoSection', () => { roles: [], avatar: '', avatar_url: '', - last_login_at: '', - created_at: '', + last_login_at: null, + created_at: null, status: 'active', }, { @@ -149,8 +149,8 @@ describe('BasicInfoSection', () => { roles: [], avatar: '', avatar_url: '', - last_login_at: '', - created_at: '', + last_login_at: null, + created_at: null, status: 'active', }, ] diff --git a/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts b/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts index 57afe0a8d5e60f..52a469e779c87e 100644 --- a/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts +++ b/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts @@ -148,37 +148,6 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ useInvalidDatasetList: () => mockInvalidDatasetList, })) -vi.mock('@/service/use-common', () => ({ - useMembers: () => ({ - data: { - accounts: [ - { - id: 'user-1', - name: 'User 1', - email: 'user1@example.com', - role: 'owner', - avatar: '', - avatar_url: '', - last_login_at: '', - created_at: '', - status: 'active', - }, - { - id: 'user-2', - name: 'User 2', - email: 'user2@example.com', - role: 'admin', - avatar: '', - avatar_url: '', - last_login_at: '', - created_at: '', - status: 'active', - }, - ], - }, - }), -})) - vi.mock('@/app/components/datasets/common/check-rerank-model', async (importOriginal) => ({ ...(await importOriginal()), isReRankModelSelected: () => true, @@ -918,6 +887,35 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { { params: GetWorkspacesCurrentModelsModelTypesByModelTypeData['path'] } > }) => { + if (options.queryKey[0].join('.') === 'console.workspaces.current.members.get') + return (() => ({ + data: { + accounts: [ + { + id: 'user-1', + name: 'User 1', + email: 'user1@example.com', + role: 'owner', + avatar: '', + avatar_url: '', + last_login_at: '', + created_at: '', + status: 'active', + }, + { + id: 'user-2', + name: 'User 2', + email: 'user2@example.com', + role: 'admin', + avatar: '', + avatar_url: '', + last_login_at: '', + created_at: '', + status: 'active', + }, + ], + }, + }))() if (!options.queryKey[0].includes('modelTypes')) return actual.useQuery(options) return { data: [] } }, diff --git a/web/app/components/datasets/settings/form/hooks/use-form-state.ts b/web/app/components/datasets/settings/form/hooks/use-form-state.ts index ea36e4af0f3c6c..1ddf7d217d78c2 100644 --- a/web/app/components/datasets/settings/form/hooks/use-form-state.ts +++ b/web/app/components/datasets/settings/form/hooks/use-form-state.ts @@ -21,7 +21,6 @@ import { DatasetPermission } from '@/models/datasets' import { consoleQuery } from '@/service/console' import { updateDatasetSetting } from '@/service/datasets' import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' -import { useMembers } from '@/service/use-common' import { getDatasetACLCapabilities } from '@/utils/permission' import { checkShowMultiModalTip } from '../../utils' @@ -116,7 +115,9 @@ export const useFormState = () => { select: (response) => response.data, }), ) - const { data: membersData } = useMembers() + const { data: membersData } = useQuery( + consoleQuery.workspaces.current.members.get.queryOptions({ input: {} }), + ) const invalidDatasetList = useInvalidDatasetList() // Derive member list from API data diff --git a/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx b/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx index 6a7d578500b550..6e2c5e23169fa9 100644 --- a/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx +++ b/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx @@ -27,8 +27,8 @@ const memberList: Member[] = [ avatar_url: '', role: 'owner', roles: [], - last_login_at: '', - created_at: '', + last_login_at: null, + created_at: null, status: 'active', }, { @@ -39,8 +39,8 @@ const memberList: Member[] = [ avatar_url: '', role: 'admin', roles: [], - last_login_at: '', - created_at: '', + last_login_at: null, + created_at: null, status: 'active', }, { @@ -51,8 +51,8 @@ const memberList: Member[] = [ avatar_url: '', role: 'normal', roles: [], - last_login_at: '', - created_at: '', + last_login_at: null, + created_at: null, status: 'active', }, ] diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx index 2b3c21bdc81751..0b1194780afe42 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -9,6 +9,13 @@ import { renderWithConsoleQuery } from '@/test/console/query-data' import { ACCOUNT_SETTING_TAB } from '../constants' import AccountSetting from '../index' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.members.get': () => ({ data: { accounts: [] }, refetch: vi.fn() }), + }) +}) + let canReplaceLogo = true const mockFeatureFlags = vi.hoisted(() => ({ NEXT_PUBLIC_ENABLE_ACCESS_TOKEN: true })) @@ -79,7 +86,6 @@ vi.mock('@/service/use-common', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useMembers: vi.fn(() => ({ data: { accounts: [] }, refetch: vi.fn() })), } }) diff --git a/web/app/components/header/account-setting/__tests__/workspace-role-checkbox-list.spec.tsx b/web/app/components/header/account-setting/__tests__/workspace-role-checkbox-list.spec.tsx index ece5abc7512cc0..f33957a5916e4d 100644 --- a/web/app/components/header/account-setting/__tests__/workspace-role-checkbox-list.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/workspace-role-checkbox-list.spec.tsx @@ -1,10 +1,18 @@ import type { Role } from '@/models/access-control' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles' import WorkspaceRoleCheckboxList from '../workspace-role-checkbox-list' -vi.mock('@/service/access-control/use-workspace-roles') +const { mockWorkspaceRoleListHook } = vi.hoisted(() => ({ + mockWorkspaceRoleListHook: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.rbac.roles.get': () => mockWorkspaceRoleListHook(), + }) +}) const createRole = (overrides: Partial): Role => ({ id: 'role-1', @@ -27,7 +35,7 @@ const mockRoles = [ describe('WorkspaceRoleCheckboxList', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useWorkspaceRoleList).mockReturnValue({ + vi.mocked(mockWorkspaceRoleListHook).mockReturnValue({ data: { pages: [ { @@ -47,7 +55,7 @@ describe('WorkspaceRoleCheckboxList', () => { hasNextPage: false, isFetchingNextPage: false, fetchNextPage: vi.fn(), - } as unknown as ReturnType) + }) }) it('should render checkboxes when multiple roles are allowed', () => { @@ -156,7 +164,7 @@ describe('WorkspaceRoleCheckboxList', () => { }) it('should show legacy role descriptions when only one role is allowed', () => { - vi.mocked(useWorkspaceRoleList).mockReturnValue({ + vi.mocked(mockWorkspaceRoleListHook).mockReturnValue({ data: { pages: [ { @@ -181,7 +189,7 @@ describe('WorkspaceRoleCheckboxList', () => { hasNextPage: false, isFetchingNextPage: false, fetchNextPage: vi.fn(), - } as unknown as ReturnType) + }) render( ({ + mockMembersHook: vi.fn(), + mockWorkspaceRoleListHook: vi.fn(), + mockUpdateRolesOfMemberHook: vi.fn(), + }), +) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.members.get': () => mockMembersHook(), + 'workspaces.current.rbac.roles.get': () => mockWorkspaceRoleListHook(), + 'workspaces.current.rbac.members.byMemberId.rbacRoles.put': () => mockUpdateRolesOfMemberHook(), + }) +}) + let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'COMMUNITY' let memberFeatures: ConsoleQueryTestOptions['features'] = {} @@ -30,9 +44,6 @@ vi.mock('@/context/permission-state', async () => { }) vi.mock('@/hooks/use-format-time-from-now') -vi.mock('@/service/access-control/use-member-roles') -vi.mock('@/service/use-common') -vi.mock('@/service/access-control/use-workspace-roles') const { inviteMember } = vi.hoisted(() => ({ inviteMember: vi.fn() })) vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() @@ -45,7 +56,9 @@ vi.mock('@/service/console', async (importOriginal) => { current: { summary: target.workspaces.current.summary, permission: target.workspaces.current.permission, + rbac: target.workspaces.current.rbac, members: { + get: target.workspaces.current.members.get, inviteEmail: { post: { mutationOptions: ( @@ -188,9 +201,9 @@ describe('MembersPage', () => { avatar: '', avatar_url: '', role: 'owner', - last_active_at: '1731000000', - last_login_at: '1731000000', - created_at: '1731000000', + last_active_at: 1731000000, + last_login_at: 1731000000, + created_at: 1731000000, status: 'active', roles: [createRole({ id: 'owner-role', name: 'Owner', is_builtin: true, role_tag: 'owner' })], }, @@ -201,9 +214,9 @@ describe('MembersPage', () => { avatar: '', avatar_url: '', role: 'admin', - last_active_at: '1731000000', - last_login_at: '1731000000', - created_at: '1731000000', + last_active_at: 1731000000, + last_login_at: 1731000000, + created_at: 1731000000, status: 'active', roles: [createRole({ id: 'admin-role', name: 'Admin', is_builtin: true })], }, @@ -220,17 +233,17 @@ describe('MembersPage', () => { workspacePermissionKeys: ['workspace.member.manage'], } as unknown as ConsoleStateFixture) - vi.mocked(useMembers).mockReturnValue({ + vi.mocked(mockMembersHook).mockReturnValue({ data: { accounts: mockAccounts }, refetch: mockRefetch, - } as unknown as ReturnType) + }) mockUpdateRolesOfMember.mockImplementation((_payload, options) => { options?.onSuccess?.() return Promise.resolve() }) - vi.mocked(useUpdateRolesOfMember).mockReturnValue({ + vi.mocked(mockUpdateRolesOfMemberHook).mockReturnValue({ mutateAsync: mockUpdateRolesOfMember, - } as unknown as ReturnType) + }) inviteMember.mockResolvedValue({ result: 'success', @@ -239,14 +252,14 @@ describe('MembersPage', () => { { email: 'sent@example.com', status: 'success', url: 'http://invite/link' }, ], }) - vi.mocked(useWorkspaceRoleList).mockReturnValue({ + vi.mocked(mockWorkspaceRoleListHook).mockReturnValue({ data: { pages: [{ data: [createRole({ id: 'admin', name: 'Admin' })] }] }, isLoading: false, error: null, hasNextPage: false, isFetchingNextPage: false, fetchNextPage: vi.fn(), - } as unknown as ReturnType) + }) deploymentEdition = 'COMMUNITY' memberFeatures = { ...memberFeatures, is_allow_transfer_workspace: true } @@ -401,10 +414,10 @@ describe('MembersPage', () => { ...mockAccounts[1]!, status: 'pending', } - vi.mocked(useMembers).mockReturnValue({ + vi.mocked(mockMembersHook).mockReturnValue({ data: { accounts: [mockAccounts[0], pendingAccount] }, refetch: mockRefetch, - } as unknown as ReturnType) + }) renderMembersPage() @@ -476,7 +489,7 @@ describe('MembersPage', () => { isCurrentWorkspaceManager: true, workspacePermissionKeys: ['workspace.member.manage'], } as unknown as ConsoleStateFixture) - vi.mocked(useMembers).mockReturnValue({ + vi.mocked(mockMembersHook).mockReturnValue({ data: { accounts: [ mockAccounts[0], @@ -512,7 +525,7 @@ describe('MembersPage', () => { ], }, refetch: mockRefetch, - } as unknown as ReturnType) + }) renderMembersPage() @@ -526,13 +539,13 @@ describe('MembersPage', () => { it('should use created_at as fallback when last_active_at is empty', () => { const memberNoLastActive: Member = { ...mockAccounts[1]!, - last_active_at: '', - created_at: '1700000000', + last_active_at: null, + created_at: 1700000000, } - vi.mocked(useMembers).mockReturnValue({ + vi.mocked(mockMembersHook).mockReturnValue({ data: { accounts: [memberNoLastActive] }, refetch: mockRefetch, - } as unknown as ReturnType) + }) renderMembersPage() @@ -540,10 +553,10 @@ describe('MembersPage', () => { }) it('should not show plural s when only one account in billing layout', () => { - vi.mocked(useMembers).mockReturnValue({ + vi.mocked(mockMembersHook).mockReturnValue({ data: { accounts: [mockAccounts[0]] }, refetch: mockRefetch, - } as unknown as ReturnType) + }) deploymentEdition = 'CLOUD' memberFeatures = { billing: { subscription: { plan: 'sandbox' } }, @@ -557,10 +570,10 @@ describe('MembersPage', () => { }) it('should not show plural s when only one account in non-billing layout', () => { - vi.mocked(useMembers).mockReturnValue({ + vi.mocked(mockMembersHook).mockReturnValue({ data: { accounts: [mockAccounts[0]] }, refetch: mockRefetch, - } as unknown as ReturnType) + }) renderMembersPage() @@ -575,10 +588,10 @@ describe('MembersPage', () => { isCurrentWorkspaceOwner: false, isCurrentWorkspaceManager: false, } as unknown as ConsoleStateFixture) - vi.mocked(useMembers).mockReturnValue({ + vi.mocked(mockMembersHook).mockReturnValue({ data: { accounts: [{ ...mockAccounts[1], role: 'unknown_role' as Member['role'] }] }, refetch: mockRefetch, - } as unknown as ReturnType) + }) renderMembersPage() @@ -660,8 +673,8 @@ describe('MembersPage', () => { expect(mockUpdateRolesOfMember).toHaveBeenCalledWith( { - memberId: '2', - roleIds: ['role-next'], + params: { member_id: '2' }, + body: { role_ids: ['role-next'] }, }, expect.any(Object), ) @@ -687,8 +700,8 @@ describe('MembersPage', () => { expect(mockUpdateRolesOfMember).toHaveBeenCalledWith( { - memberId: '2', - roleIds: ['role-next', 'role-extra'], + params: { member_id: '2' }, + body: { role_ids: ['role-next', 'role-extra'] }, }, expect.any(Object), ) diff --git a/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx index 2660b6e7a68394..0cee4fc8c68a1b 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx @@ -3,15 +3,24 @@ import type { Member } from '@/models/common' import { QueryClient } from '@tanstack/react-query' import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useUpdateRolesOfMember } from '@/service/access-control/use-member-roles' -import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles' import { deleteMemberOrCancelInvitation } from '@/service/common' -import { commonQueryKeys } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import { renderWithConsoleQuery } from '@/test/console/query-data' import MemberMenu from '../member-menu' -vi.mock('@/service/access-control/use-member-roles') -vi.mock('@/service/access-control/use-workspace-roles') +const { mockWorkspaceRoleListHook, mockUpdateRolesOfMemberHook } = vi.hoisted(() => ({ + mockWorkspaceRoleListHook: vi.fn(), + mockUpdateRolesOfMemberHook: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.rbac.roles.get': () => mockWorkspaceRoleListHook(), + 'workspaces.current.rbac.members.byMemberId.rbacRoles.put': () => mockUpdateRolesOfMemberHook(), + }) +}) + vi.mock('@/service/common', () => ({ deleteMemberOrCancelInvitation: vi.fn(), })) @@ -47,9 +56,9 @@ const member: Member = { avatar_url: '', role: 'normal', roles: [roles[0]!], - last_active_at: '1731000000', - last_login_at: '1731000000', - created_at: '1731000000', + last_active_at: 1731000000, + last_login_at: 1731000000, + created_at: 1731000000, status: 'active', } @@ -73,10 +82,10 @@ describe('MemberMenu', () => { vi.clearAllMocks() mockUpdateRolesOfMember.mockResolvedValue(undefined) vi.mocked(deleteMemberOrCancelInvitation).mockResolvedValue({ result: 'success' }) - vi.mocked(useUpdateRolesOfMember).mockReturnValue({ + vi.mocked(mockUpdateRolesOfMemberHook).mockReturnValue({ mutateAsync: mockUpdateRolesOfMember, - } as unknown as ReturnType) - vi.mocked(useWorkspaceRoleList).mockReturnValue({ + }) + vi.mocked(mockWorkspaceRoleListHook).mockReturnValue({ data: { pages: [ { @@ -96,7 +105,7 @@ describe('MemberMenu', () => { hasNextPage: false, isFetchingNextPage: false, fetchNextPage: vi.fn(), - } as unknown as ReturnType) + }) }) it('should show edit role copy when multiple roles are disabled', async () => { @@ -146,8 +155,8 @@ describe('MemberMenu', () => { expect(mockUpdateRolesOfMember).toHaveBeenCalledWith( { - memberId: 'member-1', - roleIds: ['role-2'], + params: { member_id: 'member-1' }, + body: { role_ids: ['role-2'] }, }, expect.any(Object), ) @@ -156,7 +165,9 @@ describe('MemberMenu', () => { it('should require confirmation before removing a member', async () => { const user = userEvent.setup() const queryClient = createQueryClient() - const membersQueryKey = [...commonQueryKeys.members, 'en-US'] + const { queryKey: membersQueryKey } = consoleQuery.workspaces.current.members.get.queryOptions({ + input: { query: { language: 'en' } }, + }) queryClient.setQueryData(membersQueryKey, { accounts: [member] }) renderWithConsoleQuery(, { diff --git a/web/app/components/header/account-setting/members-page/__tests__/member-row.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/member-row.spec.tsx index ff3d8949b8da31..7a67759b3c5bd6 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/member-row.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/member-row.spec.tsx @@ -2,12 +2,21 @@ import type { Member } from '@/models/common' import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' -import { useUpdateRolesOfMember } from '@/service/access-control/use-member-roles' import { renderWithConsoleQuery } from '@/test/console/query-data' import MemberRow from '../member-row' +const { mockUpdateRolesOfMemberHook } = vi.hoisted(() => ({ + mockUpdateRolesOfMemberHook: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.rbac.members.byMemberId.rbacRoles.put': () => mockUpdateRolesOfMemberHook(), + }) +}) + vi.mock('@/hooks/use-format-time-from-now') -vi.mock('@/service/access-control/use-member-roles') const member: Member = { id: 'member-1', @@ -17,9 +26,9 @@ const member: Member = { avatar_url: '', role: 'normal', roles: [], - last_active_at: '1731000000', - last_login_at: '1731000000', - created_at: '1731000000', + last_active_at: 1731000000, + last_login_at: 1731000000, + created_at: 1731000000, status: 'active', } @@ -49,9 +58,9 @@ describe('MemberRow details entry points', () => { vi.mocked(useFormatTimeFromNow).mockReturnValue({ formatTimeFromNow: () => 'just now', }) - vi.mocked(useUpdateRolesOfMember).mockReturnValue({ + vi.mocked(mockUpdateRolesOfMemberHook).mockReturnValue({ mutateAsync: vi.fn(), - } as unknown as ReturnType) + }) }) it.each([ diff --git a/web/app/components/header/account-setting/members-page/assign-roles-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/assign-roles-modal/__tests__/index.spec.tsx index b9ba534e9d620b..be583d4e004494 100644 --- a/web/app/components/header/account-setting/members-page/assign-roles-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/assign-roles-modal/__tests__/index.spec.tsx @@ -1,10 +1,18 @@ import type { Role } from '@/models/access-control' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles' import AssignRolesModal from '../index' -vi.mock('@/service/access-control/use-workspace-roles') +const { mockWorkspaceRoleListHook } = vi.hoisted(() => ({ + mockWorkspaceRoleListHook: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.rbac.roles.get': () => mockWorkspaceRoleListHook(), + }) +}) const createRole = (overrides: Partial): Role => ({ id: 'role-1', @@ -27,7 +35,7 @@ const roles = [ describe('AssignRolesModal', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useWorkspaceRoleList).mockReturnValue({ + vi.mocked(mockWorkspaceRoleListHook).mockReturnValue({ data: { pages: [ { @@ -47,7 +55,7 @@ describe('AssignRolesModal', () => { hasNextPage: false, isFetchingNextPage: false, fetchNextPage: vi.fn(), - } as unknown as ReturnType) + }) }) describe('Role selection', () => { diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx index 7741dd73d740d0..f295c9c0a88e6e 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -3,7 +3,7 @@ import type { MemberInviteResponse } from '@dify/contracts/api/console/workspace import type { Role } from '@/models/access-control' import type { Member } from '@/models/common' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { useQuery, useSuspenseQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -16,9 +16,7 @@ import { currentWorkspaceAtom, isCurrentWorkspaceOwnerAtom } from '@/context/wor import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { getAccessControlTemplateLanguage, LanguagesSupported } from '@/i18n/language' -import { useUpdateRolesOfMember } from '@/service/access-control/use-member-roles' import { consoleQuery } from '@/service/console' -import { useMembers } from '@/service/use-common' import { hasPermission } from '@/utils/permission' import EditWorkspaceModal from './edit-workspace-modal' import { InviteModal } from './invite-modal' @@ -39,7 +37,9 @@ const MembersPage = () => { const currentWorkspace = useAtomValue(currentWorkspaceAtom) const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const { data, refetch } = useMembers(language) + const { data, refetch } = useQuery( + consoleQuery.workspaces.current.members.get.queryOptions({ input: { query: { language } } }), + ) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const [invitationResults, setInvitationResults] = useState< MemberInviteResponse['invitation_results'] | null @@ -80,7 +80,9 @@ const MembersPage = () => { setDetailsMember(null) }, []) - const { mutateAsync: updateRolesOfMember } = useUpdateRolesOfMember() + const { mutateAsync: updateRolesOfMember } = useMutation( + consoleQuery.workspaces.current.rbac.members.byMemberId.rbacRoles.put.mutationOptions(), + ) const handleAssignRolesSubmit = (roles: Role[]) => { const roleIds = systemFeatures.rbac_enabled @@ -88,10 +90,7 @@ const MembersPage = () => { : roles.slice(0, 1).map((role) => role.id) updateRolesOfMember( - { - memberId: detailsMember!.id, - roleIds, - }, + { params: { member_id: detailsMember!.id }, body: { role_ids: roleIds } }, { onSuccess: () => { toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) @@ -205,7 +204,7 @@ const MembersPage = () => { { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.rbac.roles.get': () => ({ + data: { + pages: [{ data: [{ id: 'admin', name: 'Admin', description: 'Manage members' }] }], + }, + isLoading: false, + error: null, + hasNextPage: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + }), + }) +}) + const { inviteMember } = vi.hoisted(() => ({ inviteMember: vi.fn() })) vi.mock('#i18n', () => ({ useLocale: () => 'en-US' })) -vi.mock('@/service/access-control/use-workspace-roles', () => ({ - useWorkspaceRoleList: () => ({ - data: { pages: [{ data: [{ id: 'admin', name: 'Admin', description: 'Manage members' }] }] }, - isLoading: false, - error: null, - hasNextPage: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - }), -})) let queryClient: QueryClient @@ -31,7 +36,12 @@ beforeEach(() => { workspacePermissionKeys: ['workspace.member.manage'], features: { workspace_members: { enabled: true, size: 1, limit: 10 } }, }).queryClient - queryClient.setQueryData([...commonQueryKeys.members, 'en'], { accounts: [] }) + queryClient.setQueryData( + consoleQuery.workspaces.current.members.get.queryOptions({ + input: { query: { language: 'en' } }, + }).queryKey, + { accounts: [] }, + ) vi.stubGlobal( 'fetch', vi.fn(async (input: RequestInfo | URL) => { diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx index c045f89aeea633..1472992364219b 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx @@ -4,19 +4,28 @@ import { QueryClient } from '@tanstack/react-query' import { act, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { vi } from 'vite-plus/test' -import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles' import { createHttpError } from '@/service/http-error' import { seedCurrentWorkspaceQuery } from '@/test/console/current-workspace' import { seedFeatures, seedSystemFeatures } from '@/test/console/query-data' import { QueryClientTestProvider } from '@/test/console/query-provider' import { InviteModal } from '../index' +const { mockWorkspaceRoleListHook } = vi.hoisted(() => ({ + mockWorkspaceRoleListHook: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.rbac.roles.get': () => mockWorkspaceRoleListHook(), + }) +}) + const { fetchFeatures, inviteMember } = vi.hoisted(() => ({ fetchFeatures: vi.fn(), inviteMember: vi.fn(), })) -vi.mock('@/service/access-control/use-workspace-roles') vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() const { workspaces } = await import('@dify/contracts/api/console/workspaces/orpc.gen') @@ -39,8 +48,9 @@ vi.mock('@/service/console', async (importOriginal) => { ...actual.consoleQuery.workspaces.current, summary: actual.consoleQuery.workspaces.current.summary, permission: actual.consoleQuery.workspaces.current.permission, + rbac: actual.consoleQuery.workspaces.current.rbac, members: { - ...actual.consoleQuery.workspaces.current.members, + get: actual.consoleQuery.workspaces.current.members.get, inviteEmail: { post: { mutationOptions: ( @@ -80,7 +90,7 @@ describe('InviteModal', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useWorkspaceRoleList).mockReturnValue({ + vi.mocked(mockWorkspaceRoleListHook).mockReturnValue({ data: { pages: [ { @@ -123,7 +133,7 @@ describe('InviteModal', () => { hasNextPage: false, isFetchingNextPage: false, fetchNextPage: vi.fn(), - } as unknown as ReturnType) + }) }) const renderModal = async ({ diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx index 36cd0f46ac809f..0a4153207338e7 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx @@ -2,10 +2,18 @@ import type { Role, RoleListResponse } from '@/models/access-control' import { act, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { vi } from 'vite-plus/test' -import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles' import { RoleSelector } from '../role-selector' -vi.mock('@/service/access-control/use-workspace-roles') +const { mockWorkspaceRoleListHook } = vi.hoisted(() => ({ + mockWorkspaceRoleListHook: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.rbac.roles.get': () => mockWorkspaceRoleListHook(), + }) +}) const createRole = (overrides: Partial): Role => ({ id: overrides.id ?? overrides.name ?? 'role-id', @@ -49,14 +57,14 @@ const mockUseWorkspaceRoleList = ({ hasNextPage?: boolean fetchNextPage?: () => void } = {}) => { - vi.mocked(useWorkspaceRoleList).mockReturnValue({ + vi.mocked(mockWorkspaceRoleListHook).mockReturnValue({ data: { pages: [createPage(data)], pageParams: [1] }, isLoading, error, hasNextPage, isFetchingNextPage: false, fetchNextPage, - } as unknown as ReturnType) + }) } const RoleSelectorWrapper = () => diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx index 1acee0fe48911f..1143467cb74658 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx @@ -21,7 +21,6 @@ import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' import { deploymentEditionAtom } from '@/features/system-features/state' import { consoleQuery } from '@/service/console' -import { commonQueryKeys } from '@/service/use-common' import { InviteButton } from '../invite-button' import { mergeEmailRecipients } from './email-recipients' import { EmailRecipientsField } from './email-recipients-field' @@ -175,7 +174,9 @@ export function InviteModal({ isEmailSetup, onSend }: InviteModalProps) { onSuccess: (response) => { // The request alone decides success; seat and member refreshes run in the background. void queryClient.invalidateQueries({ queryKey: consoleQuery.features.get.queryKey() }) - void queryClient.invalidateQueries({ queryKey: commonQueryKeys.members }) + void queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.members.get.key(), + }) onSend(response.invitation_results) actionsRef.current?.close() }, diff --git a/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx b/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx index 7dc6771f7977a3..f81d20f3bf10da 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx @@ -15,11 +15,12 @@ import { SelectTrigger, SelectValue, } from '@langgenius/dify-ui/select' +import { useInfiniteQuery } from '@tanstack/react-query' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' import { getAccessControlTemplateLanguage } from '@/i18n/language' -import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles' +import { workspaceRoleListInfiniteOptions } from '../../workspace-role-list-query' type RoleSelectorProps = { hasServerError?: boolean @@ -65,11 +66,13 @@ export function RoleSelector({ hasServerError = false, disabled = false }: RoleS hasNextPage, isFetchingNextPage, fetchNextPage, - } = useWorkspaceRoleList({ - page: 1, - limit: PAGE_SIZE, - language, - }) + } = useInfiniteQuery( + workspaceRoleListInfiniteOptions({ + page: 1, + limit: PAGE_SIZE, + language, + }), + ) useEffect(() => { const hasMore = hasNextPage ?? true diff --git a/web/app/components/header/account-setting/members-page/member-details-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/member-details-modal/__tests__/index.spec.tsx index 8987c29f9a3be5..e7e5bba4177d33 100644 --- a/web/app/components/header/account-setting/members-page/member-details-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/member-details-modal/__tests__/index.spec.tsx @@ -3,12 +3,20 @@ import type { Member } from '@/models/common' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { vi } from 'vite-plus/test' -import { useRolesOfMember } from '@/service/access-control/use-member-roles' -import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles' import MemberDetailsModal from '../index' -vi.mock('@/service/access-control/use-member-roles') -vi.mock('@/service/access-control/use-workspace-roles') +const { mockWorkspaceRoleListHook, mockRolesOfMemberHook } = vi.hoisted(() => ({ + mockWorkspaceRoleListHook: vi.fn(), + mockRolesOfMemberHook: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.rbac.roles.get': () => mockWorkspaceRoleListHook(), + 'workspaces.current.rbac.members.byMemberId.rbacRoles.get': () => mockRolesOfMemberHook(), + }) +}) const createRole = (overrides: Partial): Role => ({ id: 'role-1', @@ -31,23 +39,23 @@ const member: Member = { avatar_url: '', role: 'admin', roles: [createRole({ id: 'role-1', name: 'Custom role' })], - last_active_at: '1731000000', - last_login_at: '1731000000', - created_at: '1731000000', + last_active_at: 1731000000, + last_login_at: 1731000000, + created_at: 1731000000, status: 'active', } describe('MemberDetailsModal', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useRolesOfMember).mockReturnValue({ + vi.mocked(mockRolesOfMemberHook).mockReturnValue({ data: { account_id: member.id, roles: [createRole({ id: 'role-1', name: 'Custom role' })], }, isLoading: false, - } as unknown as ReturnType) - vi.mocked(useWorkspaceRoleList).mockReturnValue({ + }) + vi.mocked(mockWorkspaceRoleListHook).mockReturnValue({ data: { pages: [ { @@ -70,7 +78,7 @@ describe('MemberDetailsModal', () => { hasNextPage: false, isFetchingNextPage: false, fetchNextPage: vi.fn(), - } as unknown as ReturnType) + }) }) describe('Rendering', () => { @@ -114,10 +122,10 @@ describe('MemberDetailsModal', () => { }) it('should render role loading state without assigned role chips or count', () => { - vi.mocked(useRolesOfMember).mockReturnValue({ + vi.mocked(mockRolesOfMemberHook).mockReturnValue({ data: undefined, isLoading: true, - } as unknown as ReturnType) + }) render( { const user = userEvent.setup() const handleAssignSubmit = vi.fn() - vi.mocked(useRolesOfMember).mockReturnValue({ + vi.mocked(mockRolesOfMemberHook).mockReturnValue({ data: { account_id: member.id, roles: [ @@ -190,7 +198,7 @@ describe('MemberDetailsModal', () => { createRole({ id: 'role-2', name: 'Second role' }), ], }, - } as unknown as ReturnType) + }) render( getAccessControlTemplateLanguage(locale), [locale]) - const { data: rolesOfMember, isLoading: isLoadingRolesOfMember } = useRolesOfMember( - member.id, - language, + const { data: rolesOfMember, isLoading: isLoadingRolesOfMember } = useQuery( + consoleQuery.workspaces.current.rbac.members.byMemberId.rbacRoles.get.queryOptions({ + input: { params: { member_id: member.id }, query: { language } }, + select: (data) => ({ ...data, roles: (data.roles ?? []).map(toRole) }), + }), ) const [pendingRoles, setPendingRoles] = useState() diff --git a/web/app/components/header/account-setting/members-page/member-menu.tsx b/web/app/components/header/account-setting/members-page/member-menu.tsx index 635ab40b817c45..a076a85dcf48df 100644 --- a/web/app/components/header/account-setting/members-page/member-menu.tsx +++ b/web/app/components/header/account-setting/members-page/member-menu.tsx @@ -18,13 +18,13 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { IconButton } from '@langgenius/dify-ui/icon-button' -import { useQueryClient } from '@tanstack/react-query' -import { memo, useCallback, useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' -import { useUpdateRolesOfMember } from '@/service/access-control/use-member-roles' import { deleteMemberOrCancelInvitation } from '@/service/common' -import { commonQueryKeys } from '@/service/use-common' +import { consoleQuery } from '@/service/console' +import { createSelectedRolePlaceholder } from '../workspace-role-list-query' import AssignRolesModal from './assign-roles-modal' type MemberMenuProps = { @@ -53,7 +53,10 @@ const MemberMenu = ({ const canRemove = !isOwner && !isCurrentUser const showTransferOwnership = isOwner && canTransferOwnership - const selectedRoles = member.roles || [] + const selectedRoles = useMemo( + () => (member.roles ?? []).map(createSelectedRolePlaceholder), + [member.roles], + ) const memberName = member.name || member.email const assignRolesLabel = allowMultipleRoles ? t(($) => $['members.assignRoles'], { ns: 'workspaceMembers', defaultValue: 'Assign Roles' }) @@ -63,7 +66,9 @@ const MemberMenu = ({ setAssignModalOpen(true) }, []) - const { mutateAsync: updateRolesOfMember } = useUpdateRolesOfMember() + const { mutateAsync: updateRolesOfMember } = useMutation( + consoleQuery.workspaces.current.rbac.members.byMemberId.rbacRoles.put.mutationOptions(), + ) const handleAssignRolesSubmit = useCallback( (roles: Role[]) => { @@ -72,10 +77,7 @@ const MemberMenu = ({ : roles.slice(0, 1).map((role) => role.id) updateRolesOfMember( - { - memberId: member.id, - roleIds, - }, + { params: { member_id: member.id }, body: { role_ids: roleIds } }, { onSuccess: () => { toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) @@ -94,7 +96,9 @@ const MemberMenu = ({ setRemoving(true) try { await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` }) - void queryClient.invalidateQueries({ queryKey: commonQueryKeys.members }) + void queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.members.get.key(), + }) setRemoveConfirmOpen(false) } catch { } finally { diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx index 703fccb449276e..68bb3ea918ae89 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx @@ -3,11 +3,21 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { vi } from 'vite-plus/test' import { ownershipTransfer, sendOwnerEmail, verifyOwnerEmail } from '@/service/common' -import { useMembers } from '@/service/use-common' import { createAccountProfileQueryWrapper } from '@/test/console/account-profile' import { render } from '@/test/console/render' import TransferOwnershipModal from '../index' +const { mockMembersHook } = vi.hoisted(() => ({ + mockMembersHook: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.members.get': () => mockMembersHook(), + }) +}) + const toastMocks = vi.hoisted(() => ({ mockNotify: vi.fn(), })) @@ -22,7 +32,6 @@ vi.mock('@/context/workspace-state', async () => { }) vi.mock('@/service/common') -vi.mock('@/service/use-common') vi.mock('@/app/notifications', () => ({ default: { notify: (args: unknown) => toastMocks.mockNotify(args), @@ -55,9 +64,9 @@ describe('TransferOwnershipModal', () => { mockConsoleState.current = consoleState mockConsoleStateReader.mockReturnValue(consoleState) - vi.mocked(useMembers).mockReturnValue({ + vi.mocked(mockMembersHook).mockReturnValue({ data: { accounts: [] }, - } as unknown as ReturnType) + }) // Stub globalThis.location.reload (component calls globalThis.location.reload()) const mockReload = vi.fn() diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/member-selector.spec.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/member-selector.spec.tsx index 8ad3aec93e8426..222ce385d8592e 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/member-selector.spec.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/member-selector.spec.tsx @@ -1,10 +1,18 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { vi } from 'vite-plus/test' -import { useMembers } from '@/service/use-common' import MemberSelector from '../member-selector' -vi.mock('@/service/use-common') +const { mockMembersHook } = vi.hoisted(() => ({ + mockMembersHook: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.members.get': () => mockMembersHook(), + }) +}) const mockAccounts = [ { id: '1', name: 'John Doe', email: 'john@example.com', avatar_url: '' }, @@ -27,9 +35,9 @@ describe('MemberSelector', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useMembers).mockReturnValue({ + vi.mocked(mockMembersHook).mockReturnValue({ data: { accounts: mockAccounts }, - } as unknown as ReturnType) + }) }) it('should render placeholder when no value is selected', () => { @@ -106,8 +114,8 @@ describe('MemberSelector', () => { }) it('should handle missing data gracefully', () => { - vi.mocked(useMembers).mockReturnValue({ data: undefined } as unknown as ReturnType< - typeof useMembers + vi.mocked(mockMembersHook).mockReturnValue({ data: undefined } as unknown as ReturnType< + typeof mockMembersHook >) render() expect(getTrigger()).toBeInTheDocument() @@ -115,14 +123,14 @@ describe('MemberSelector', () => { it('should filter by email when account name is empty', async () => { const user = userEvent.setup() - vi.mocked(useMembers).mockReturnValue({ + vi.mocked(mockMembersHook).mockReturnValue({ data: { accounts: [ ...mockAccounts, { id: '4', name: '', email: 'noname@example.com', avatar_url: '' }, ], }, - } as unknown as ReturnType) + }) render() await user.click(getTrigger()) @@ -158,13 +166,13 @@ describe('MemberSelector', () => { it('should fall back to empty string for account with undefined email when searching', async () => { const user = userEvent.setup() - vi.mocked(useMembers).mockReturnValue({ + vi.mocked(mockMembersHook).mockReturnValue({ data: { accounts: [ { id: '1', name: 'John', email: undefined as unknown as string, avatar_url: '' }, ], }, - } as unknown as ReturnType) + }) render() await user.click(getTrigger()) diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx index f79fd972c1d76d..82df103d9d7890 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx @@ -3,10 +3,11 @@ import type { FC } from 'react' import { Avatar, AvatarFallback, AvatarImage } from '@langgenius/dify-ui/avatar' import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useQuery } from '@tanstack/react-query' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useMembers } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import { getAvatarInitial } from '@/utils/avatar' type Props = Readonly<{ @@ -21,7 +22,7 @@ const MemberSelector: FC = ({ value, onSelect, exclude = [] }) => { const [searchValue, setSearchValue] = useState('') const searchLabel = t(($) => $['operation.search'], { ns: 'common' }) - const { data } = useMembers() + const { data } = useQuery(consoleQuery.workspaces.current.members.get.queryOptions({ input: {} })) const currentValue = useMemo(() => { if (!data?.accounts || !value) return null diff --git a/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx index a18f10f5308f5b..d2e7604af4a48b 100644 --- a/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx @@ -1,14 +1,23 @@ import type { Role } from '@/models/access-control' import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { - useCreateWorkspaceRole, - useUpdateWorkspaceRole, -} from '@/service/access-control/use-workspace-roles' import { render } from '@/test/console/render' import { useRoleGroups } from '../hooks' import PermissionsPage from '../index' +const { mockCreateWorkspaceRoleHook, mockUpdateWorkspaceRoleHook } = vi.hoisted(() => ({ + mockCreateWorkspaceRoleHook: vi.fn(), + mockUpdateWorkspaceRoleHook: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.rbac.roles.post': () => mockCreateWorkspaceRoleHook(), + 'workspaces.current.rbac.roles.byRoleId.put': () => mockUpdateWorkspaceRoleHook(), + }) +}) + const mocks = vi.hoisted(() => ({ workspacePermissionKeys: [] as string[], createWorkspaceRole: vi.fn(), @@ -28,11 +37,6 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('@/service/access-control/use-workspace-roles', () => ({ - useCreateWorkspaceRole: vi.fn(), - useUpdateWorkspaceRole: vi.fn(), -})) - vi.mock('../hooks', () => ({ useRoleGroups: vi.fn(), })) @@ -154,14 +158,14 @@ describe('PermissionsPage', () => { hasNextPage: false, error: null, } as ReturnType) - vi.mocked(useCreateWorkspaceRole).mockReturnValue( + vi.mocked(mockCreateWorkspaceRoleHook).mockReturnValue( mockMutation(mocks.createWorkspaceRole) as unknown as ReturnType< - typeof useCreateWorkspaceRole + typeof mockCreateWorkspaceRoleHook >, ) - vi.mocked(useUpdateWorkspaceRole).mockReturnValue( + vi.mocked(mockUpdateWorkspaceRoleHook).mockReturnValue( mockMutation(mocks.updateWorkspaceRole) as unknown as ReturnType< - typeof useUpdateWorkspaceRole + typeof mockUpdateWorkspaceRoleHook >, ) }) @@ -202,9 +206,11 @@ describe('PermissionsPage', () => { expect(mocks.createWorkspaceRole).toHaveBeenCalledWith( { - name: 'create name', - description: 'create description', - permission_keys: ['workspace.member.manage'], + body: { + name: 'create name', + description: 'create description', + permission_keys: ['workspace.member.manage'], + }, }, expect.any(Object), ) @@ -221,10 +227,12 @@ describe('PermissionsPage', () => { expect(mocks.updateWorkspaceRole).toHaveBeenCalledWith( { - id: 'role-1', - name: 'edit name', - description: 'edit description', - permission_keys: ['workspace.member.manage'], + params: { role_id: 'role-1' }, + body: { + name: 'edit name', + description: 'edit description', + permission_keys: ['workspace.member.manage'], + }, }, expect.any(Object), ) diff --git a/web/app/components/header/account-setting/permissions-page/hooks.ts b/web/app/components/header/account-setting/permissions-page/hooks.ts index 19185eff73af20..19369b635e6190 100644 --- a/web/app/components/header/account-setting/permissions-page/hooks.ts +++ b/web/app/components/header/account-setting/permissions-page/hooks.ts @@ -1,6 +1,7 @@ import type { RoleListRequest } from '@/models/access-control' +import { useInfiniteQuery } from '@tanstack/react-query' import { useMemo } from 'react' -import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles' +import { workspaceRoleListInfiniteOptions } from '../workspace-role-list-query' import { formatRoleGroups } from './helpers' export const useRoleGroups = (params: RoleListRequest) => { @@ -11,7 +12,7 @@ export const useRoleGroups = (params: RoleListRequest) => { fetchNextPage, hasNextPage, error, - } = useWorkspaceRoleList(params) + } = useInfiniteQuery(workspaceRoleListInfiniteOptions(params)) const roleGroups = useMemo(() => formatRoleGroups(roleList), [roleList]) diff --git a/web/app/components/header/account-setting/permissions-page/index.tsx b/web/app/components/header/account-setting/permissions-page/index.tsx index f2753daffaabd8..3e81421d49174c 100644 --- a/web/app/components/header/account-setting/permissions-page/index.tsx +++ b/web/app/components/header/account-setting/permissions-page/index.tsx @@ -3,16 +3,14 @@ import type { RoleModalMode, submitRoleData } from './role-modal' import type { Role } from '@/models/access-control' import { Button } from '@langgenius/dify-ui/button' +import { useMutation } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { getAccessControlTemplateLanguage } from '@/i18n/language' -import { - useCreateWorkspaceRole, - useUpdateWorkspaceRole, -} from '@/service/access-control/use-workspace-roles' +import { consoleQuery } from '@/service/console' import { hasPermission } from '@/utils/permission' import { useRoleGroups } from './hooks' import RoleList from './role-list' @@ -47,8 +45,12 @@ const PermissionsPage = ({ containerRef }: PermissionsPageProps) => { language, }) - const { mutateAsync: createWorkspaceRole } = useCreateWorkspaceRole() - const { mutateAsync: updateWorkspaceRole } = useUpdateWorkspaceRole() + const { mutateAsync: createWorkspaceRole } = useMutation( + consoleQuery.workspaces.current.rbac.roles.post.mutationOptions(), + ) + const { mutateAsync: updateWorkspaceRole } = useMutation( + consoleQuery.workspaces.current.rbac.roles.byRoleId.put.mutationOptions(), + ) const openCreate = useCallback(() => { setModalState({ mode: 'create' }) @@ -71,7 +73,7 @@ const PermissionsPage = ({ containerRef }: PermissionsPageProps) => { const roleId = modalState?.role?.id ?? '' if (mode === 'create') { createWorkspaceRole( - { name, description, permission_keys: permissionKeys }, + { body: { name, description, permission_keys: permissionKeys } }, { onSuccess: () => { closeModal() @@ -80,7 +82,10 @@ const PermissionsPage = ({ containerRef }: PermissionsPageProps) => { ) } else if (mode === 'edit') { updateWorkspaceRole( - { id: roleId, name, description, permission_keys: permissionKeys }, + { + params: { role_id: roleId }, + body: { name, description, permission_keys: permissionKeys }, + }, { onSuccess: () => { closeModal() diff --git a/web/app/components/header/account-setting/permissions-page/role-list/__tests__/index.spec.tsx b/web/app/components/header/account-setting/permissions-page/role-list/__tests__/index.spec.tsx index fab8752fdb4b4b..6368cc767b966b 100644 --- a/web/app/components/header/account-setting/permissions-page/role-list/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-list/__tests__/index.spec.tsx @@ -14,28 +14,6 @@ vi.mock('@/context/workspace-state', async () => { })) }) -vi.mock('@/service/access-control/use-workspace-roles', () => ({ - useCopyWorkspaceRole: () => ({ - mutateAsync: vi.fn(), - }), - useDeleteWorkspaceRole: () => ({ - mutateAsync: vi.fn(), - isPending: false, - }), - useGetMembersOfRole: () => ({ - data: { - data: [], - pagination: { - total_count: 0, - per_page: 1, - current_page: 1, - total_pages: 1, - }, - }, - isPending: false, - }), -})) - const createRole = (overrides: Partial = {}): Role => ({ id: 'role-1', tenant_id: 'tenant-1', @@ -57,6 +35,31 @@ vi.mock('@/context/permission-state', async () => { })) }) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.rbac.roles.byRoleId.copy.post': () => ({ + mutateAsync: vi.fn(), + }), + 'workspaces.current.rbac.roles.byRoleId.delete': () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), + 'workspaces.current.rbac.roles.byRoleId.members.get': () => ({ + data: { + data: [], + pagination: { + total_count: 0, + per_page: 1, + current_page: 1, + total_pages: 1, + }, + }, + isPending: false, + }), + }) +}) + describe('RoleList', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx b/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx index 0e975884a67085..3bc03870d67a6a 100644 --- a/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx @@ -26,7 +26,7 @@ const membersOfRoleQueryMock = vi.hoisted(() => { } return { - useGetMembersOfRole: vi.fn(() => ({ + getMembersOfRole: vi.fn((_options?: unknown) => ({ data: response, isPending: false, })), @@ -40,18 +40,6 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('@/service/access-control/use-workspace-roles', () => ({ - useCopyWorkspaceRole: () => ({ - mutate: mockCopyRole, - isPending: false, - }), - useDeleteWorkspaceRole: () => ({ - mutateAsync: mockDeleteRole, - isPending: false, - }), - useGetMembersOfRole: membersOfRoleQueryMock.useGetMembersOfRole, -})) - vi.mock('@/app/notifications', () => ({ toast: { success: vi.fn(), @@ -86,6 +74,22 @@ const openMenu = async () => { } } +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.rbac.roles.byRoleId.copy.post': () => ({ + mutate: mockCopyRole, + isPending: false, + }), + 'workspaces.current.rbac.roles.byRoleId.delete': () => ({ + mutateAsync: mockDeleteRole, + isPending: false, + }), + 'workspaces.current.rbac.roles.byRoleId.members.get': (options: unknown) => + membersOfRoleQueryMock.getMembersOfRole(options), + }) +}) + describe('RowMenu', () => { beforeEach(() => { vi.clearAllMocks() @@ -227,20 +231,22 @@ describe('RowMenu', () => { expect(dialog).toHaveTextContent( 'permission.role.copyMembersDescription:{"name":"Editor","count":3}', ) - expect(membersOfRoleQueryMock.useGetMembersOfRole).toHaveBeenCalledWith({ - roleId: role.id, - page: 1, - limit: 9999, - }) + expect(membersOfRoleQueryMock.getMembersOfRole).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: [ + expect.any(Array), + expect.objectContaining({ + input: { params: { role_id: role.id }, query: { page: 1, limit: 9999 } }, + }), + ], + }), + ) await user.click(within(dialog).getByRole('button', { name: 'common.operation.skip' })) expect(mockCopyRole).toHaveBeenCalledTimes(1) expect(mockCopyRole).toHaveBeenCalledWith( - { - roleId: role.id, - copy_member: false, - }, + { params: { role_id: role.id }, body: { copy_member: false } }, expect.objectContaining({ onSuccess: expect.any(Function), }), @@ -266,10 +272,7 @@ describe('RowMenu', () => { expect(mockCopyRole).toHaveBeenCalledTimes(1) expect(mockCopyRole).toHaveBeenCalledWith( - { - roleId: role.id, - copy_member: true, - }, + { params: { role_id: role.id }, body: { copy_member: true } }, expect.objectContaining({ onSuccess: expect.any(Function), }), diff --git a/web/app/components/header/account-setting/permissions-page/role-list/copy-members-confirm-dialog.tsx b/web/app/components/header/account-setting/permissions-page/role-list/copy-members-confirm-dialog.tsx index 94416edefa9e86..6f597cb3d44dca 100644 --- a/web/app/components/header/account-setting/permissions-page/role-list/copy-members-confirm-dialog.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-list/copy-members-confirm-dialog.tsx @@ -9,8 +9,9 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' +import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import { useGetMembersOfRole } from '@/service/access-control/use-workspace-roles' +import { consoleQuery } from '@/service/console' type CopyMembersConfirmDialogProps = { role: Role @@ -26,12 +27,12 @@ export function CopyMembersConfirmDialog({ onDuplicate, }: CopyMembersConfirmDialogProps) { const { t } = useTranslation(['common', 'permission']) - const { data: membersOfRole, isPending: isLoadingMembersOfRole } = useGetMembersOfRole({ - roleId: role.id, - page: 1, - limit: 9999, - }) - const memberCount = membersOfRole?.pagination.total_count ?? 0 + const { data: membersOfRole, isPending: isLoadingMembersOfRole } = useQuery( + consoleQuery.workspaces.current.rbac.roles.byRoleId.members.get.queryOptions({ + input: { params: { role_id: role.id }, query: { page: 1, limit: 9999 } }, + }), + ) + const memberCount = membersOfRole?.pagination?.total_count ?? 0 const isLoadingMemberCount = isLoadingMembersOfRole && !membersOfRole const isActionDisabled = isCopyingRole || isLoadingMemberCount diff --git a/web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx b/web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx index 84eacc27321361..855c0d187da657 100644 --- a/web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx @@ -17,14 +17,12 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { IconButton } from '@langgenius/dify-ui/icon-button' +import { useMutation } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { workspacePermissionKeysAtom } from '@/context/permission-state' -import { - useCopyWorkspaceRole, - useDeleteWorkspaceRole, -} from '@/service/access-control/use-workspace-roles' +import { consoleQuery } from '@/service/console' import { hasPermission } from '@/utils/permission' import { CopyMembersConfirmDialog } from './copy-members-confirm-dialog' @@ -51,7 +49,9 @@ const RowMenu = ({ roleCategory, role, onView, onEdit }: RowMenuProps) => { onEdit?.(role) }, [canManageRoles, onEdit, role]) - const { mutate: copyRole, isPending: isCopyingRole } = useCopyWorkspaceRole() + const { mutate: copyRole, isPending: isCopyingRole } = useMutation( + consoleQuery.workspaces.current.rbac.roles.byRoleId.copy.post.mutationOptions(), + ) const openCopyMembersConfirm = useCallback(() => { if (!canManageRoles) return @@ -64,10 +64,7 @@ const RowMenu = ({ roleCategory, role, onView, onEdit }: RowMenuProps) => { if (!canManageRoles) return copyRole( - { - roleId: role.id, - copy_member: copyMember, - }, + { params: { role_id: role.id }, body: { copy_member: copyMember } }, { onSuccess: () => { setShowCopyMembersConfirm(false) @@ -78,7 +75,9 @@ const RowMenu = ({ roleCategory, role, onView, onEdit }: RowMenuProps) => { [canManageRoles, copyRole, role.id], ) - const { mutateAsync: deleteRole, isPending: isDeletingRole } = useDeleteWorkspaceRole() + const { mutateAsync: deleteRole, isPending: isDeletingRole } = useMutation( + consoleQuery.workspaces.current.rbac.roles.byRoleId.delete.mutationOptions(), + ) const openDeleteConfirm = useCallback(() => { if (!canManageRoles) return @@ -89,11 +88,14 @@ const RowMenu = ({ roleCategory, role, onView, onEdit }: RowMenuProps) => { const handleDelete = useCallback(() => { if (!canManageRoles) return - deleteRole(role.id, { - onSuccess: () => { - setShowDeleteConfirm(false) + deleteRole( + { params: { role_id: role.id } }, + { + onSuccess: () => { + setShowDeleteConfirm(false) + }, }, - }) + ) }, [canManageRoles, deleteRole, role.id]) const hasViewAction = roleCategory === 'global_system_default' diff --git a/web/app/components/header/account-setting/workspace-role-checkbox-list.tsx b/web/app/components/header/account-setting/workspace-role-checkbox-list.tsx index 1f8ce6bfe083b4..83c24488dd0972 100644 --- a/web/app/components/header/account-setting/workspace-role-checkbox-list.tsx +++ b/web/app/components/header/account-setting/workspace-role-checkbox-list.tsx @@ -11,12 +11,16 @@ import { ScrollAreaThumb, ScrollAreaViewport, } from '@langgenius/dify-ui/scroll-area' +import { useInfiniteQuery } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' import { SearchInput } from '@/app/components/base/search-input' import { getAccessControlTemplateLanguage } from '@/i18n/language' -import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles' +import { + createSelectedRolePlaceholder, + workspaceRoleListInfiniteOptions, +} from './workspace-role-list-query' type WorkspaceRoleCheckboxListProps = { selectedRoleIds: string[] @@ -38,18 +42,6 @@ const LEGACY_ROLE_DESCRIPTION_KEY_MAP = { type LegacyRoleKey = keyof typeof LEGACY_ROLE_DESCRIPTION_KEY_MAP -const createSelectedRolePlaceholder = (id: string): Role => ({ - id, - tenant_id: '', - type: 'workspace', - category: 'global_custom', - name: id, - description: '', - is_builtin: false, - permission_keys: [], - role_tag: '', -}) - const normalizeLegacyRoleKey = (value: string) => value.trim().toLowerCase() const isLegacyRoleKey = (value: string): value is LegacyRoleKey => @@ -83,12 +75,14 @@ const WorkspaceRoleCheckboxList = ({ hasNextPage, isFetchingNextPage, fetchNextPage, - } = useWorkspaceRoleList({ - page: 1, - limit: PAGE_SIZE, - include_owner: includeOwner ? 1 : 0, - language, - }) + } = useInfiniteQuery( + workspaceRoleListInfiniteOptions({ + page: 1, + limit: PAGE_SIZE, + include_owner: includeOwner ? 1 : 0, + language, + }), + ) useEffect(() => { const hasMore = hasNextPage ?? true @@ -131,7 +125,7 @@ const WorkspaceRoleCheckboxList = ({ if (selectedRoles) return selectedRoles return selectedRoleIds.map( - (roleId) => roleById.get(roleId) ?? createSelectedRolePlaceholder(roleId), + (roleId) => roleById.get(roleId) ?? createSelectedRolePlaceholder({ id: roleId }), ) }, [roleById, selectedRoleIds, selectedRoles]) diff --git a/web/app/components/header/account-setting/workspace-role-list-query.ts b/web/app/components/header/account-setting/workspace-role-list-query.ts new file mode 100644 index 00000000000000..848d4311253c6b --- /dev/null +++ b/web/app/components/header/account-setting/workspace-role-list-query.ts @@ -0,0 +1,64 @@ +import type { RbacRole, RbacRoleList } from '@dify/contracts/api/console/workspaces/types.gen' +import type { InfiniteData } from '@tanstack/react-query' +import type { Role, RoleListRequest, RoleListResponse } from '@/models/access-control' +import { consoleQuery } from '@/service/console' + +const ROLE_TYPES = new Set(['workspace', 'app', 'dataset']) +const ROLE_TAGS = new Set(['owner', 'admin']) + +// The API serializes every field but declares defaulted ones as optional strings. +export const toRole = (role: RbacRole): Role => ({ + id: role.id, + tenant_id: role.tenant_id ?? '', + type: ROLE_TYPES.has(role.type) ? (role.type as Role['type']) : 'workspace', + category: role.category === 'global_system_default' ? role.category : 'global_custom', + name: role.name, + description: role.description ?? '', + is_builtin: role.is_builtin ?? false, + permission_keys: role.permission_keys ?? [], + role_tag: + role.role_tag && ROLE_TAGS.has(role.role_tag) ? (role.role_tag as Role['role_tag']) : '', +}) + +// Stands in for a selected role whose full definition has not been loaded. +export const createSelectedRolePlaceholder = ({ + id, + name, +}: { + id: string + name?: string +}): Role => ({ + id, + tenant_id: '', + type: 'workspace', + category: 'global_custom', + name: name ?? id, + description: '', + is_builtin: false, + permission_keys: [], + role_tag: '', +}) + +const toRoleListPage = (page: RbacRoleList): RoleListResponse => ({ + data: (page.data ?? []).map(toRole), + pagination: { + total_count: page.pagination?.total_count ?? 0, + per_page: page.pagination?.per_page ?? 0, + current_page: page.pagination?.current_page ?? 0, + total_pages: page.pagination?.total_pages ?? 0, + }, +}) + +export const workspaceRoleListInfiniteOptions = ({ page = 1, ...query }: RoleListRequest) => + consoleQuery.workspaces.current.rbac.roles.get.infiniteOptions({ + input: (pageParam: number) => ({ query: { ...query, page: pageParam } }), + initialPageParam: page, + getNextPageParam: ({ pagination }) => { + const currentPage = pagination?.current_page ?? 0 + return currentPage < (pagination?.total_pages ?? 0) ? currentPage + 1 : undefined + }, + select: (data): InfiniteData => ({ + ...data, + pages: data.pages.map(toRoleListPage), + }), + }) diff --git a/web/app/components/snippet-list/__tests__/index.spec.tsx b/web/app/components/snippet-list/__tests__/index.spec.tsx index af15e86558e7e3..1131da3a687538 100644 --- a/web/app/components/snippet-list/__tests__/index.spec.tsx +++ b/web/app/components/snippet-list/__tests__/index.spec.tsx @@ -5,6 +5,20 @@ import { createConsoleQueryWrapper } from '@/test/console/query-data' import { renderWithNuqs } from '@/test/nuqs-testing' import SnippetList from '..' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.members.get': () => ({ + data: { + accounts: [ + { id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' }, + { id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' }, + ], + }, + }), + }) +}) + const mockUseInfiniteSnippetList = vi.hoisted(() => vi.fn()) const mockSetKeywords = vi.hoisted(() => vi.fn()) const mockSetTagIDs = vi.hoisted(() => vi.fn()) @@ -57,6 +71,18 @@ vi.mock('@/service/console', () => ({ systemFeatures: vi.fn(), }, consoleQuery: { + workspaces: { + current: { + members: { + get: { + queryOptions: (options: { input?: unknown }) => ({ + ...options, + queryKey: [['console', 'workspaces', 'current', 'members', 'get'], options], + }), + }, + }, + }, + }, account: { profile: { get: { @@ -99,17 +125,6 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('@/service/use-common', () => ({ - useMembers: () => ({ - data: { - accounts: [ - { id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' }, - { id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' }, - ], - }, - }), -})) - vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), useSearchParams: () => new URLSearchParams(''), diff --git a/web/app/components/snippet-list/__tests__/reflow.browser.spec.tsx b/web/app/components/snippet-list/__tests__/reflow.browser.spec.tsx index 42495c505f60d9..1f2fe3525f4224 100644 --- a/web/app/components/snippet-list/__tests__/reflow.browser.spec.tsx +++ b/web/app/components/snippet-list/__tests__/reflow.browser.spec.tsx @@ -6,6 +6,24 @@ import { render } from 'vitest-browser-react' import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture' import SnippetList from '..' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.members.get': () => ({ + data: { + accounts: [ + { + avatar_url: null, + id: 'creator-1', + name: 'Alice', + status: 'active', + }, + ], + }, + }), + }) +}) + const mockUseInfiniteSnippetList = vi.hoisted(() => vi.fn()) const mockQueryState = vi.hoisted(() => ({ creatorIDs: [] as string[], @@ -48,21 +66,6 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('@/service/use-common', () => ({ - useMembers: () => ({ - data: { - accounts: [ - { - avatar_url: null, - id: 'creator-1', - name: 'Alice', - status: 'active', - }, - ], - }, - }), -})) - vi.mock('@/features/account-profile/client', () => ({ userProfileQueryOptions: () => ({ queryFn: async () => ({ profile: { id: 'creator-1' } }), @@ -83,6 +86,18 @@ vi.mock('@/service/console', () => ({ }, }, consoleQuery: { + workspaces: { + current: { + members: { + get: { + queryOptions: (options: { input?: unknown }) => ({ + ...options, + queryKey: [['console', 'workspaces', 'current', 'members', 'get'], options], + }), + }, + }, + }, + }, tags: { get: { key: () => ['tags'], diff --git a/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx b/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx index 39d21fb41070c8..89333ed951deb3 100644 --- a/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx +++ b/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx @@ -3,6 +3,40 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { render } from '@/test/console/render' import SnippetCard from '../snippet-card' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.members.get': () => ({ + data: { + accounts: [ + { + id: 'creator-id', + name: 'Creator', + email: 'creator@example.com', + avatar: '', + avatar_url: null, + role: 'editor', + last_login_at: '', + created_at: '', + status: 'active', + }, + { + id: 'updater-id', + name: 'Updater', + email: 'updater@example.com', + avatar: '', + avatar_url: null, + role: 'editor', + last_login_at: '', + created_at: '', + status: 'active', + }, + ], + }, + }), + }) +}) + const { mockDeleteMutate, mockDownloadBlob, @@ -38,37 +72,6 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('@/service/use-common', () => ({ - useMembers: () => ({ - data: { - accounts: [ - { - id: 'creator-id', - name: 'Creator', - email: 'creator@example.com', - avatar: '', - avatar_url: null, - role: 'editor', - last_login_at: '', - created_at: '', - status: 'active', - }, - { - id: 'updater-id', - name: 'Updater', - email: 'updater@example.com', - avatar: '', - avatar_url: null, - role: 'editor', - last_login_at: '', - created_at: '', - status: 'active', - }, - ], - }, - }), -})) - vi.mock('@/service/use-snippets', () => ({ useDeleteSnippetMutation: () => ({ mutate: mockDeleteMutate, diff --git a/web/app/components/snippet-list/components/snippet-card.tsx b/web/app/components/snippet-list/components/snippet-card.tsx index 800ce8bdb10327..34c044356ea27e 100644 --- a/web/app/components/snippet-list/components/snippet-card.tsx +++ b/web/app/components/snippet-list/components/snippet-card.tsx @@ -1,5 +1,4 @@ 'use client' - import type { SnippetListItem } from '@/types/snippet' import { AlertDialog, @@ -17,6 +16,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' +import { useQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -29,7 +29,7 @@ import { toast } from '@/app/notifications' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { TagSelector } from '@/features/tag-management/components/tag-selector' import Link from '@/next/link' -import { useMembers } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import { useDeleteSnippetMutation, useExportSnippetMutation, @@ -54,7 +54,9 @@ const SnippetCard = ({ const { t } = useTranslation(['snippet', 'datasetDocuments']) const { t: tCommon } = useTranslation(['common']) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const { data: membersData } = useMembers() + const { data: membersData } = useQuery( + consoleQuery.workspaces.current.members.get.queryOptions({ input: {} }), + ) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const updateSnippetMutation = useUpdateSnippetMutation() diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/email-input.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/email-input.spec.tsx index b97bf21c2fe3c6..f6dc30c658be80 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/email-input.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/email-input.spec.tsx @@ -47,9 +47,9 @@ const createMember = (overrides: Partial): Member => ({ status: 'active', role: 'normal', roles: [], - created_at: '2026-01-01T00:00:00Z', - last_active_at: '2026-01-02T00:00:00Z', - last_login_at: '2026-01-03T00:00:00Z', + created_at: 1767225600, + last_active_at: 1767312000, + last_login_at: 1767398400, ...overrides, }) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-list.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-list.spec.tsx index 6efcae2b96425a..439ffbc00c57ad 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-list.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-list.spec.tsx @@ -13,9 +13,9 @@ const createMember = (overrides: Partial): Member => ({ status: 'active', role: 'normal', roles: [], - created_at: '2026-01-01T00:00:00Z', - last_active_at: '2026-01-02T00:00:00Z', - last_login_at: '2026-01-03T00:00:00Z', + created_at: 1767225600, + last_active_at: 1767312000, + last_login_at: 1767398400, ...overrides, }) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx index 98a230577bc0bd..d4f1e48dc51cbf 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/member-selector.spec.tsx @@ -40,9 +40,9 @@ const members: Member[] = [ status: 'active', role: 'normal', roles: [], - created_at: '2026-01-01T00:00:00Z', - last_active_at: '2026-01-02T00:00:00Z', - last_login_at: '2026-01-03T00:00:00Z', + created_at: 1767225600, + last_active_at: 1767312000, + last_login_at: 1767398400, }, ] diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx index 1b7e86ebc0484f..42550048df4f52 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx @@ -2,14 +2,14 @@ import type { RecipientData, Recipient as RecipientItem } from '../../../types' import { cn } from '@langgenius/dify-ui/cn' import { Switch } from '@langgenius/dify-ui/switch' import { RiGroupLine } from '@remixicon/react' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { produce } from 'immer' import { useAtomValue } from 'jotai' import { memo, useId } from 'react' import { useTranslation } from 'react-i18next' import { currentWorkspaceAtom } from '@/context/workspace-state' import { userProfileQueryOptions } from '@/features/account-profile/client' -import { useMembers } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import EmailInput from './email-input' import MemberSelector from './member-selector' @@ -28,7 +28,9 @@ const Recipient = ({ data, onChange }: Props) => { select: (data) => data.profile.email, }) const currentWorkspace = useAtomValue(currentWorkspaceAtom) - const { data: members } = useMembers() + const { data: members } = useQuery( + consoleQuery.workspaces.current.members.get.queryOptions({ input: {} }), + ) const accounts = members?.accounts || [] const handleMemberSelect = (id: string) => { diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx index cfb44e60f146c1..7d4e8776c9f658 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx @@ -6,7 +6,7 @@ import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dif import { IconButton } from '@langgenius/dify-ui/icon-button' import { Separator } from '@langgenius/dify-ui/separator' import { RiArrowRightSFill } from '@remixicon/react' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { noop, unionBy } from 'es-toolkit/compat' import { useAtomValue } from 'jotai' import { memo, useCallback, useMemo, useState } from 'react' @@ -25,7 +25,7 @@ import { toast } from '@/app/notifications' import { currentWorkspaceAtom } from '@/context/workspace-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import { useParams } from '@/next/navigation' -import { useMembers } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import { useTestEmailSender } from '@/service/use-workflow' import { getHumanInputFormDependencySelectors, isOutput } from '../../utils' import EmailInput from './recipient/email-input' @@ -151,7 +151,9 @@ const EmailSenderContent = ({ config?.recipients?.items && config?.recipients?.items.length > 0 - const { data: members } = useMembers() + const { data: members } = useQuery( + consoleQuery.workspaces.current.members.get.queryOptions({ input: {} }), + ) const accounts = members?.accounts || [] const generatedInputs = useMemo(() => { diff --git a/web/models/common.ts b/web/models/common.ts index c369667412474c..523703da211b25 100644 --- a/web/models/common.ts +++ b/web/models/common.ts @@ -1,6 +1,5 @@ -import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen' import type { AppConfigJsonValue } from '@dify/contracts/api/console/apps/types.gen' -import type { Role } from './access-control' +import type { AccountWithRoleResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { I18nText } from '@/i18n/language' import type { Model } from '@/types/app' @@ -17,15 +16,7 @@ export type InitValidateStatusResponse = { status: 'finished' | 'not_started' } -export type Member = Pick & { - avatar: string - last_login_at?: string - last_active_at?: string - created_at?: string - status: 'pending' | 'active' | 'banned' | 'closed' - role: 'owner' | 'admin' | 'editor' | 'normal' | 'dataset_operator' - roles: Role[] -} +export type Member = AccountWithRoleResponse export type DataSourceNotionPage = { page_icon: null | { diff --git a/web/service/access-control/use-member-roles.ts b/web/service/access-control/use-member-roles.ts deleted file mode 100644 index c7202da7923b8b..00000000000000 --- a/web/service/access-control/use-member-roles.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { AccessControlTemplateLanguage } from '@/i18n/language' -import type { RolesOfMemberResponse, UpdateRolesOfMemberRequest } from '@/models/access-control' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { get, put } from '../base' -import { commonQueryKeys } from '../use-common' - -const NAME_SPACE = 'rbac-member-roles' - -export const useRolesOfMember = (memberId: string, language: AccessControlTemplateLanguage) => { - return useQuery({ - queryKey: [NAME_SPACE, 'member-roles', memberId, language], - queryFn: () => - get(`/workspaces/current/rbac/members/${memberId}/rbac-roles`, { - params: { - language, - }, - }), - }) -} - -export const useUpdateRolesOfMember = () => { - const queryClient = useQueryClient() - - return useMutation({ - mutationKey: [NAME_SPACE, 'update-member-roles'], - mutationFn: (request: UpdateRolesOfMemberRequest) => { - const { memberId, roleIds } = request - return put(`/workspaces/current/rbac/members/${memberId}/rbac-roles`, { - body: { role_ids: roleIds }, - }) - }, - onSuccess: (_, { memberId }) => - Promise.all([ - queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'member-roles', memberId] }), - queryClient.invalidateQueries({ queryKey: commonQueryKeys.members }), - ]), - }) -} diff --git a/web/service/access-control/use-workspace-roles.ts b/web/service/access-control/use-workspace-roles.ts deleted file mode 100644 index c47485eaa120de..00000000000000 --- a/web/service/access-control/use-workspace-roles.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { - CopyWorkspaceRoleRequest, - CreateRoleRequest, - GetMembersOfRoleRequest, - GetMembersOfRoleResponse, - Role, - RoleListRequest, - RoleListResponse, - UpdateRolesRequest, -} from '@/models/access-control' -import type { CommonResponse } from '@/models/common' -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { del, get, post, put } from '../base' -import { commonQueryKeys } from '../use-common' - -const NAME_SPACE = 'rbac-role-management' - -export const useWorkspaceRoleList = (params: RoleListRequest) => { - const { page = 1, ...queryParams } = params - - return useInfiniteQuery({ - queryKey: [NAME_SPACE, 'workspace-role-list', queryParams], - queryFn: ({ pageParam }) => - get('/workspaces/current/rbac/roles', { - params: { - ...queryParams, - page: pageParam, - }, - }), - initialPageParam: page, - getNextPageParam: (lastPage) => { - const { current_page, total_pages } = lastPage.pagination - - if (current_page < total_pages) return current_page + 1 - - return undefined - }, - }) -} - -export const useCreateWorkspaceRole = () => { - const queryClient = useQueryClient() - - return useMutation({ - mutationKey: [NAME_SPACE, 'create-workspace-role'], - mutationFn: (data: CreateRoleRequest) => - post('/workspaces/current/rbac/roles', { - body: { ...data }, - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'workspace-role-list'] }) - }, - }) -} - -export const useUpdateWorkspaceRole = () => { - const queryClient = useQueryClient() - - return useMutation({ - mutationKey: [NAME_SPACE, 'update-workspace-role'], - mutationFn: (data: UpdateRolesRequest) => - put(`/workspaces/current/rbac/roles/${data.id}`, { - body: { ...data }, - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'workspace-role-list'] }) - }, - }) -} - -export const useDeleteWorkspaceRole = () => { - const queryClient = useQueryClient() - - return useMutation({ - mutationKey: [NAME_SPACE, 'delete-workspace-role'], - mutationFn: (id: string) => del(`/workspaces/current/rbac/roles/${id}`), - onSuccess: () => - Promise.all([ - queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'workspace-role-list'] }), - queryClient.invalidateQueries({ queryKey: commonQueryKeys.members }), - ]), - }) -} - -export const useCopyWorkspaceRole = () => { - const queryClient = useQueryClient() - - return useMutation({ - mutationKey: [NAME_SPACE, 'copy-workspace-role'], - mutationFn: ({ roleId, copy_member }: CopyWorkspaceRoleRequest) => - post(`/workspaces/current/rbac/roles/${roleId}/copy`, { - body: { copy_member }, - }), - onSuccess: (_role, variables) => { - const invalidations = [ - queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'workspace-role-list'] }), - ] - - if (variables.copy_member) - invalidations.push(queryClient.invalidateQueries({ queryKey: commonQueryKeys.members })) - - return Promise.all(invalidations) - }, - }) -} - -export const useGetMembersOfRole = (params: GetMembersOfRoleRequest) => { - const { roleId, ...paginationParams } = params - return useQuery({ - queryKey: [NAME_SPACE, 'members-of-role', roleId, paginationParams], - queryFn: () => - get(`/workspaces/current/rbac/roles/${roleId}/members`, { - params: { - ...paginationParams, - }, - }), - enabled: !!roleId, - }) -} diff --git a/web/service/console/query-policies.ts b/web/service/console/query-policies.ts index 29236c03f0687c..040ee00a42704e 100644 --- a/web/service/console/query-policies.ts +++ b/web/service/console/query-policies.ts @@ -241,7 +241,89 @@ export function createConsoleQuery(consoleClient: ConsoleClient) { }, }, }, + // Role writes refresh the role list; role assignment changes also refresh members. rbac: { + roles: { + post: { + mutationOptions: { + onSettled: (_data, error, _variables, _result, context) => + error + ? undefined + : context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.rbac.roles.get.key(), + }), + }, + }, + byRoleId: { + put: { + mutationOptions: { + onSettled: (_data, error, _variables, _result, context) => + error + ? undefined + : context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.rbac.roles.get.key(), + }), + }, + }, + delete: { + mutationOptions: { + onSettled: (_data, error, _variables, _result, context) => + error + ? undefined + : Promise.all([ + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.rbac.roles.get.key(), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.members.get.key(), + }), + ]), + }, + }, + copy: { + post: { + mutationOptions: { + onSettled: (_data, error, variables, _result, context) => + error + ? undefined + : Promise.all([ + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.rbac.roles.get.key(), + }), + variables.body?.copy_member && + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.members.get.key(), + }), + ]), + }, + }, + }, + }, + }, + members: { + byMemberId: { + rbacRoles: { + put: { + mutationOptions: { + onSettled: (_data, error, variables, _result, context) => + error + ? undefined + : Promise.all([ + context.client.invalidateQueries({ + queryKey: + consoleQuery.workspaces.current.rbac.members.byMemberId.rbacRoles.get.key( + { input: { params: variables.params } }, + ), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.members.get.key(), + }), + ]), + }, + }, + }, + }, + }, accessPolicies: { post: { mutationOptions: { diff --git a/web/service/use-common.ts b/web/service/use-common.ts index 330584efcba97f..7c4f2d3efee16a 100644 --- a/web/service/use-common.ts +++ b/web/service/use-common.ts @@ -3,12 +3,10 @@ import type { ModelProvider, ModelTypeEnum, } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { AccessControlTemplateLanguage } from '@/i18n/language' import type { CodeBasedExtension, CommonResponse, FileUploadConfigResponse, - Member, StructuredOutputRulesRequestBody, StructuredOutputRulesResponse, } from '@/models/common' @@ -25,7 +23,6 @@ const NAME_SPACE = 'common' export const commonQueryKeys = { fileUploadConfig: [NAME_SPACE, 'file-upload-config'] as const, - members: [NAME_SPACE, 'members'] as const, filePreview: (fileID: string) => [NAME_SPACE, 'file-preview', fileID] as const, schemaDefinitions: [NAME_SPACE, 'schema-type-definitions'] as const, modelProviders: [NAME_SPACE, 'model-providers'] as const, @@ -106,22 +103,6 @@ export const useMailRegister = () => { }) } -type MemberResponse = { - accounts: Member[] | null -} - -export const useMembers = (language?: AccessControlTemplateLanguage) => { - return useQuery({ - queryKey: [...commonQueryKeys.members, language], - queryFn: () => - get('/workspaces/current/members', { - params: { - language, - }, - }), - }) -} - type FilePreviewResponse = { content: string } diff --git a/web/test/console/operation-hooks.ts b/web/test/console/operation-hooks.ts new file mode 100644 index 00000000000000..2b9007eb236b6f --- /dev/null +++ b/web/test/console/operation-hooks.ts @@ -0,0 +1,44 @@ +import type * as ReactQuery from '@tanstack/react-query' + +type OperationOptions = { queryKey?: unknown; mutationKey?: unknown } +type OperationHandler = (options: never) => unknown +export type OperationRoutes = Partial> + +// Console operation keys start with ['console', ...path]; routes use the dotted path. +const getOperationPath = (key: unknown) => { + if (!Array.isArray(key) || !Array.isArray(key[0])) return undefined + const [prefix, ...path] = key[0] as string[] + return prefix === 'console' ? path.join('.') : undefined +} + +const route = (routes: OperationRoutes, key: unknown, options: OperationOptions) => { + const path = getOperationPath(key) + const handler = path ? routes[path] : undefined + return handler ? { handled: true, value: handler(options as never) } : { handled: false } +} + +/** + * Builds a `@tanstack/react-query` module mock that answers console operations from + * `routes` and leaves every other query to the real hooks. + * + * vi.mock('@tanstack/react-query', async (importOriginal) => + * (await import('@/test/console/operation-hooks')).mockConsoleOperationHooks( + * await importOriginal(), + * { 'workspaces.current.members.get': () => mockMembersResult() }, + * )) + */ +export const mockConsoleOperationHooks = (actual: typeof ReactQuery, routes: OperationRoutes) => ({ + ...actual, + useQuery: ((options: OperationOptions) => { + const result = route(routes, options.queryKey, options) + return result.handled ? result.value : actual.useQuery(options as never) + }) as typeof actual.useQuery, + useInfiniteQuery: ((options: OperationOptions) => { + const result = route(routes, options.queryKey, options) + return result.handled ? result.value : actual.useInfiniteQuery(options as never) + }) as typeof actual.useInfiniteQuery, + useMutation: ((options: OperationOptions) => { + const result = route(routes, options.mutationKey, options) + return result.handled ? result.value : actual.useMutation(options as never) + }) as typeof actual.useMutation, +}) From 7915c5e5ff8c5187625784939201a319609fbe40 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:28:48 +0000 Subject: [PATCH 11/60] [autofix.ci] apply automated fixes --- api/openapi/markdown/console-openapi.md | 73 ++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 65f67badfb68cd..af447963abaf45 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -11493,6 +11493,12 @@ Update a plugin endpoint | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /workspaces/current/members +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| language | query | Localized policy label language | No | string,
**Available values:** "en", "ja", "zh" | + #### Responses | Code | Description | Schema | @@ -13116,6 +13122,7 @@ Update a plugin endpoint | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| language | query | Localized policy label language | No | string,
**Available values:** "en", "ja", "zh" | | member_id | path | | Yes | string (uuid) | #### Responses @@ -13186,6 +13193,16 @@ Update a plugin endpoint | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /workspaces/current/rbac/roles +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| include_owner | query | | No | integer | +| language | query | Localized policy label language | No | string,
**Available values:** "en", "ja", "zh" | +| limit | query | | No | integer | +| page | query | | No | integer | +| reverse | query | | No | boolean | + #### Responses | Code | Description | Schema | @@ -13194,6 +13211,18 @@ Update a plugin endpoint | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [POST] /workspaces/current/rbac/roles +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| language | query | Localized policy label language | No | string,
**Available values:** "en", "ja", "zh" | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [_RoleUpsertRequest](#_roleupsertrequest)
| + #### Responses | Code | Description | Schema | @@ -13220,6 +13249,7 @@ Update a plugin endpoint | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| language | query | Localized policy label language | No | string,
**Available values:** "en", "ja", "zh" | | role_id | path | | Yes | string (uuid) | #### Responses @@ -13234,8 +13264,15 @@ Update a plugin endpoint | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| language | query | Localized policy label language | No | string,
**Available values:** "en", "ja", "zh" | | role_id | path | | Yes | string (uuid) | +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [_RoleUpsertRequest](#_roleupsertrequest)
| + #### Responses | Code | Description | Schema | @@ -13248,8 +13285,15 @@ Update a plugin endpoint | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| language | query | Localized policy label language | No | string,
**Available values:** "en", "ja", "zh" | | role_id | path | | Yes | string (uuid) | +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CopyRoleParam](#copyroleparam)
| + #### Responses | Code | Description | Schema | @@ -13262,6 +13306,10 @@ Update a plugin endpoint | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| language | query | Localized policy label language | No | string,
**Available values:** "en", "ja", "zh" | +| limit | query | | No | integer | +| page | query | | No | integer | +| reverse | query | | No | boolean | | role_id | path | | Yes | string (uuid) | #### Responses @@ -15119,7 +15167,7 @@ Account not link tenant. | last_login_at | integer | | No | | name | string | | Yes | | role | string | | Yes | -| roles | [ object ] | | No | +| roles | [ [MemberRoleSummaryResponse](#memberrolesummaryresponse) ] | | No | | status | string | | Yes | #### ActivateCheckQuery @@ -19640,6 +19688,12 @@ The conversation was not found for this account and app. | icon_type | [IconType](#icontype) | Icon type | No | | name | string | Name for the copied app | No | +#### CopyRoleParam + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| copy_member | boolean,
**Default:** true | | No | + #### CrawlFailedError {message} @@ -22749,6 +22803,13 @@ The member is not in the workspace. | ---- | ---- | ----------- | -------- | | MemberNotInTenantError | | The member is not in the workspace. | | +#### MemberRoleSummaryResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| name | string | | Yes | + #### MemberRoleUpdatePayload | Name | Type | Description | Required | @@ -29325,6 +29386,16 @@ Workflow tool configuration | page | integer | | No | | reverse | boolean | | No | +#### _RoleUpsertRequest + +Accepts the payload sent by the Create/Edit Role dialog. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| name | string | | Yes | +| permission_keys | [ string ],
**Default:** | | No | + ## FastOpenAPI Preview (OpenAPI 3.1) ### Dify API (FastOpenAPI PoC) From bdf872c81093bb78e11ef9fe422f0b1ca40bd747 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:41:54 +0800 Subject: [PATCH 12/60] refactor(web): migrate console auth, account and workspace calls in service/common Sign-in, setup/init, forgot/reset password, email-code login, invitation activation, account deletion, email change, ownership transfer, member removal, workspace rename, default model, model enable/disable and the compliance download now call the console contract directly. Only the web-app (public API) helpers and remote file upload remain in common.ts. The generated response types exposed unreachable branches: console login never returns an access token in the body, and email-code verification and password-reset requests raise errors instead of returning failure codes, so those branches are removed. Default model and parameter-rule responses are normalized where they enter the model provider UI types. Co-Authored-By: Claude Opus 5.5 --- oxlint-suppressions.json | 7 +- .../webapp-reset-password/page.tsx | 10 +- .../__tests__/email-change-modal.spec.tsx | 83 ++++--- .../account-page/email-change-modal.tsx | 26 +- .../(commonLayout)/delete-account/state.tsx | 14 +- .../__tests__/compliance.spec.tsx | 42 +++- .../header/account-dropdown/compliance.tsx | 8 +- .../__tests__/member-menu.spec.tsx | 29 ++- .../__tests__/index.spec.tsx | 26 +- .../edit-workspace-modal/index.tsx | 5 +- .../members-page/member-menu.tsx | 7 +- .../__tests__/index.spec.tsx | 106 +++++++-- .../transfer-ownership-modal/index.tsx | 16 +- .../__tests__/hooks.spec.ts | 28 +-- .../model-provider-page/declarations.ts | 12 +- .../model-provider-page/hooks.ts | 21 +- .../model-selector/__tests__/index.spec.tsx | 8 +- .../__tests__/model-list-item.spec.tsx | 41 +++- .../provider-added-card/model-list-item.tsx | 19 +- .../__tests__/index.spec.tsx | 3 + .../model-provider-page/utils.ts | 23 ++ .../ChangePasswordForm.spec.tsx | 18 +- .../forgot-password/ChangePasswordForm.tsx | 5 +- .../ForgotPasswordForm.spec.tsx | 35 +-- .../forgot-password/ForgotPasswordForm.tsx | 16 +- web/app/init/InitPasswordPopup.tsx | 7 +- web/app/install/installForm.spec.tsx | 36 +-- web/app/install/installForm.tsx | 13 +- .../reset-password/__tests__/page.spec.tsx | 24 +- .../check-code/__tests__/page.spec.tsx | 31 ++- web/app/reset-password/check-code/page.tsx | 8 +- web/app/reset-password/page.tsx | 6 +- .../set-password/__tests__/page.spec.tsx | 16 +- web/app/reset-password/set-password/page.tsx | 5 +- .../signin/check-code/__tests__/page.spec.tsx | 139 ++++++----- web/app/signin/check-code/page.tsx | 33 +-- .../__tests__/mail-and-code-auth.spec.tsx | 41 ++-- .../signin/components/mail-and-code-auth.tsx | 14 +- .../components/mail-and-password-auth.tsx | 13 +- .../invite-settings/__tests__/page.spec.tsx | 18 +- web/app/signin/invite-settings/page.tsx | 8 +- web/app/signin/normal-form.tsx | 10 +- web/service/common.spec.ts | 58 ----- web/service/common.ts | 225 +----------------- web/utils/completion-params.ts | 12 +- 45 files changed, 623 insertions(+), 702 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 98661bfd159876..21bb6a5ec492d5 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1814,11 +1814,6 @@ "count": 4 } }, - "web/app/components/header/account-setting/model-provider-page/hooks.ts": { - "@tanstack/query/prefer-query-options": { - "count": 1 - } - }, "web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 2 @@ -4067,7 +4062,7 @@ "count": 1 }, "typescript/no-explicit-any": { - "count": 4 + "count": 1 } }, "web/service/datasets.ts": { diff --git a/web/app/(shareLayout)/webapp-reset-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/page.tsx index 46451a85b29b56..04b0ca723fa2a7 100644 --- a/web/app/(shareLayout)/webapp-reset-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/page.tsx @@ -12,7 +12,7 @@ import { emailRegex } from '@/config' import useDocumentTitle from '@/hooks/use-document-title' import Link from '@/next/link' import { useRouter, useSearchParams } from '@/next/navigation' -import { sendResetPasswordCode } from '@/service/common' +import { consoleClient } from '@/service/console' export default function CheckCode() { const { t } = useTranslation(['login']) @@ -37,17 +37,13 @@ export default function CheckCode() { return } setIsLoading(true) - const res = await sendResetPasswordCode(email, locale) + const res = await consoleClient.forgotPassword.post({ body: { email, language: locale } }) if (res.result === 'success') { setCountdownLeftTime(`${COUNT_DOWN_TIME_MS}`) const params = new URLSearchParams(searchParams) - params.set('token', encodeURIComponent(res.data)) + params.set('token', encodeURIComponent(res.data ?? '')) params.set('email', encodeURIComponent(email)) router.push(`/webapp-reset-password/check-code?${params.toString()}`) - } else if (res.code === 'account_not_found') { - toast.error(t(($) => $['error.registrationNotAllowed'], { ns: 'login' })) - } else { - toast.error(res.data) } } catch (error) { console.error(error) diff --git a/web/app/account/(commonLayout)/account-page/__tests__/email-change-modal.spec.tsx b/web/app/account/(commonLayout)/account-page/__tests__/email-change-modal.spec.tsx index 7e6c2ceaa03985..c8c6c0489f7e89 100644 --- a/web/app/account/(commonLayout)/account-page/__tests__/email-change-modal.spec.tsx +++ b/web/app/account/(commonLayout)/account-page/__tests__/email-change-modal.spec.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { toast } from '@/app/notifications' -import { checkEmailExisted, resetEmail, sendVerifyCode, verifyEmail } from '@/service/common' +import { consoleClient } from '@/service/console' import { createHttpError } from '@/service/http-error' import EmailChangeModal from '../email-change-modal' @@ -11,11 +11,24 @@ vi.mock('@/app/notifications', () => ({ toast: { error: vi.fn() }, })) -vi.mock('@/service/common', () => ({ - checkEmailExisted: vi.fn(), - resetEmail: vi.fn(), - sendVerifyCode: vi.fn(), - verifyEmail: vi.fn(), +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + account: { + changeEmail: { + checkEmailUnique: { + post: vi.fn(), + }, + reset: { + post: vi.fn(), + }, + post: vi.fn(), + validity: { + post: vi.fn(), + }, + }, + }, + }, })) vi.mock('@/service/use-common', () => ({ @@ -25,14 +38,18 @@ vi.mock('@/service/use-common', () => ({ describe('EmailChangeModal', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(sendVerifyCode).mockResolvedValue({ result: 'success', data: 'email-token' }) - vi.mocked(verifyEmail).mockResolvedValue({ + vi.mocked(consoleClient.account.changeEmail.post).mockResolvedValue({ result: 'success', + data: 'email-token', + }) + vi.mocked(consoleClient.account.changeEmail.validity.post).mockResolvedValue({ email: 'old@example.com', is_valid: true, token: 'verified-token', }) - vi.mocked(checkEmailExisted).mockResolvedValue({ result: 'success' }) + vi.mocked(consoleClient.account.changeEmail.checkEmailUnique.post).mockResolvedValue({ + result: 'success', + }) }) it('submits the labeled verification fields with Enter through both email phases', async () => { @@ -46,10 +63,12 @@ describe('EmailChangeModal', () => { name: 'accountSettings.account.changeEmail.codeLabel', }) await user.type(originCode, '123456{Enter}') - expect(verifyEmail).toHaveBeenCalledWith({ - email: 'old@example.com', - code: '123456', - token: 'email-token', + expect(consoleClient.account.changeEmail.validity.post).toHaveBeenCalledWith({ + body: { + email: 'old@example.com', + code: '123456', + token: 'email-token', + }, }) const email = await screen.findByRole('textbox', { @@ -62,10 +81,12 @@ describe('EmailChangeModal', () => { ).toBeEnabled(), ) await user.type(email, '{Enter}') - expect(sendVerifyCode).toHaveBeenLastCalledWith({ - email: 'new@example.com', - phase: 'new_email', - token: 'verified-token', + expect(consoleClient.account.changeEmail.post).toHaveBeenLastCalledWith({ + body: { + email: 'new@example.com', + phase: 'new_email', + token: 'verified-token', + }, }) const newCode = await screen.findByRole('textbox', { @@ -74,16 +95,18 @@ describe('EmailChangeModal', () => { expect(newCode).toHaveValue('') await user.type(newCode, '654321{Enter}') await waitFor(() => - expect(resetEmail).toHaveBeenCalledWith({ - new_email: 'new@example.com', - token: 'verified-token', + expect(consoleClient.account.changeEmail.reset.post).toHaveBeenCalledWith({ + body: { + new_email: 'new@example.com', + token: 'verified-token', + }, }), ) expect(mockLogout).toHaveBeenCalledOnce() }) it('associates an unavailable-email error and clears it when the user edits the address', async () => { - vi.mocked(checkEmailExisted).mockRejectedValue( + vi.mocked(consoleClient.account.changeEmail.checkEmailUnique.post).mockRejectedValue( createHttpError(new Response(null, { status: 400 }), { code: 'email_already_in_use' }), ) const user = userEvent.setup() @@ -112,7 +135,7 @@ describe('EmailChangeModal', () => { }) it('shows the server message when sending a verification code fails', async () => { - vi.mocked(sendVerifyCode).mockRejectedValueOnce( + vi.mocked(consoleClient.account.changeEmail.post).mockRejectedValueOnce( createHttpError(new Response(null, { status: 429 }), { code: 'rate_limit_exceeded', message: 'Try again in one minute', @@ -130,12 +153,12 @@ describe('EmailChangeModal', () => { 'Error sending verification code: Try again in one minute', ), ) - expect(verifyEmail).not.toHaveBeenCalled() + expect(consoleClient.account.changeEmail.validity.post).not.toHaveBeenCalled() expect(mockLogout).not.toHaveBeenCalled() }) it('preserves the verification step after an HTTP error and allows another attempt', async () => { - vi.mocked(verifyEmail).mockRejectedValueOnce( + vi.mocked(consoleClient.account.changeEmail.validity.post).mockRejectedValueOnce( createHttpError(new Response(null, { status: 400 }), { code: 'invalid_code', message: 'Verification code has expired', @@ -161,7 +184,7 @@ describe('EmailChangeModal', () => { expect( screen.queryByRole('textbox', { name: 'accountSettings.account.changeEmail.emailLabel' }), ).not.toBeInTheDocument() - expect(resetEmail).not.toHaveBeenCalled() + expect(consoleClient.account.changeEmail.reset.post).not.toHaveBeenCalled() await user.type(originCode, '654321{Enter}') @@ -170,10 +193,12 @@ describe('EmailChangeModal', () => { name: 'accountSettings.account.changeEmail.emailLabel', }), ).toBeVisible() - expect(verifyEmail).toHaveBeenLastCalledWith({ - email: 'old@example.com', - code: '654321', - token: 'email-token', + expect(consoleClient.account.changeEmail.validity.post).toHaveBeenLastCalledWith({ + body: { + email: 'old@example.com', + code: '654321', + token: 'email-token', + }, }) }) }) diff --git a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx index 5eef71289d9503..e9d486952e5934 100644 --- a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx +++ b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx @@ -9,7 +9,7 @@ import { useDebounceFn } from 'ahooks' import { useCallback, useEffect, useRef, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' -import { checkEmailExisted, resetEmail, sendVerifyCode, verifyEmail } from '@/service/common' +import { consoleClient } from '@/service/console' import { useLogout } from '@/service/use-common' import { getErrorMessage } from '@/utils/error-message' @@ -67,10 +67,8 @@ const EmailChangeModal = ({ onClose, email }: Props) => { const sendEmail = async (email: string, isOrigin: boolean, token?: string) => { try { - const res = await sendVerifyCode({ - email, - phase: isOrigin ? 'old_email' : 'new_email', - token, + const res = await consoleClient.account.changeEmail.post({ + body: { email, phase: isOrigin ? 'old_email' : 'new_email', token }, }) startCount() if (res.data) setStepToken(res.data) @@ -86,10 +84,8 @@ const EmailChangeModal = ({ onClose, email }: Props) => { callback?: (token: string) => void, ) => { try { - const res = await verifyEmail({ - email, - code, - token, + const res = await consoleClient.account.changeEmail.validity.post({ + body: { email, code, token }, }) if (res.is_valid) { setStepToken(res.token) @@ -119,9 +115,10 @@ const EmailChangeModal = ({ onClose, email }: Props) => { const checkNewEmailExisted = async (email: string) => { setIsCheckingEmail(true) try { - await checkEmailExisted({ - email, - }) + await consoleClient.account.changeEmail.checkEmailUnique.post( + { body: { email } }, + { context: { silent: true } }, + ) if (latestEmailRef.current !== email) return setNewEmailExited(false) setUnAvailableEmail(false) @@ -172,9 +169,8 @@ const EmailChangeModal = ({ onClose, email }: Props) => { const { mutateAsync: logout } = useLogout() const updateEmail = async (lastToken: string) => { try { - await resetEmail({ - new_email: mail.trim(), - token: lastToken, + await consoleClient.account.changeEmail.reset.post({ + body: { new_email: mail.trim(), token: lastToken }, }) await logout() } catch (error) { diff --git a/web/app/account/(commonLayout)/delete-account/state.tsx b/web/app/account/(commonLayout)/delete-account/state.tsx index 20f78ca38df1b9..c29a285467bc3d 100644 --- a/web/app/account/(commonLayout)/delete-account/state.tsx +++ b/web/app/account/(commonLayout)/delete-account/state.tsx @@ -1,10 +1,6 @@ import { useMutation } from '@tanstack/react-query' import { create } from 'zustand' -import { - sendDeleteAccountCode, - submitDeleteAccountFeedback, - verifyDeleteAccountCode, -} from '@/service/common' +import { consoleClient } from '@/service/console' type State = { sendEmailToken: string @@ -20,7 +16,7 @@ export function useSendDeleteAccountEmail() { const updateEmailToken = useAccountDeleteStore((state) => state.setSendEmailToken) return useMutation({ mutationKey: ['delete-account'], - mutationFn: sendDeleteAccountCode, + mutationFn: () => consoleClient.account.delete.verify.get(), onSuccess: (ret) => { if (ret.result === 'success') updateEmailToken(ret.data) }, @@ -30,13 +26,15 @@ export function useSendDeleteAccountEmail() { export function useConfirmDeleteAccount() { return useMutation({ mutationKey: ['confirm-delete-account'], - mutationFn: verifyDeleteAccountCode, + mutationFn: (body: { code: string; token: string }) => + consoleClient.account.delete.post({ body }), }) } export function useDeleteAccountFeedback() { return useMutation({ mutationKey: ['delete-account-feedback'], - mutationFn: submitDeleteAccountFeedback, + mutationFn: (body: { feedback: string; email: string }) => + consoleClient.account.delete.feedback.post({ body }), }) } diff --git a/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx b/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx index 7aeb3adfc36e6a..8d2afb150be9b2 100644 --- a/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx +++ b/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx @@ -7,14 +7,23 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render as renderWithoutPricing, screen, waitFor } from '@testing-library/react' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { toast } from '@/app/notifications' -import { getDocDownloadUrl } from '@/service/common' +import { consoleClient } from '@/service/console' import { seedFeatures } from '@/test/console/query-data' import { downloadUrl } from '@/utils/download' import Compliance from '../compliance' const onPricingUrlUpdate = vi.hoisted(() => vi.fn()) -vi.mock('@/service/common', () => ({ getDocDownloadUrl: vi.fn() })) +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + compliance: { + download: { + get: vi.fn(), + }, + }, + }, +})) vi.mock('@/service/base', () => ({ request: vi.fn(() => new Promise(() => {})), sseGeneratorPost: vi.fn(), @@ -83,7 +92,9 @@ describe('Compliance', () => { it('keeps GDPR downloadable while plan-dependent documents wait for features', async () => { queryClient.clear() - vi.mocked(getDocDownloadUrl).mockResolvedValue({ url: 'https://example.com/gdpr.pdf' }) + vi.mocked(consoleClient.compliance.download.get).mockResolvedValue({ + url: 'https://example.com/gdpr.pdf', + }) openMenuAndRender() expect(screen.queryByText('common.compliance.soc2Type1')).not.toBeInTheDocument() expect(screen.queryByText('common.compliance.soc2Type2')).not.toBeInTheDocument() @@ -94,7 +105,12 @@ describe('Compliance', () => { ) expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() fireEvent.click(screen.getByText('common.compliance.gdpr')) - await waitFor(() => expect(getDocDownloadUrl).toHaveBeenCalledWith('GDPR')) + await waitFor(() => + expect(consoleClient.compliance.download.get).toHaveBeenCalledWith( + { query: { doc_name: 'GDPR' } }, + { context: { silent: true } }, + ), + ) expect(onPricingUrlUpdate).not.toHaveBeenCalled() }) @@ -150,7 +166,7 @@ describe('Compliance', () => { it('should trigger download mutation successfully', async () => { // Arrange const mockUrl = 'http://example.com/doc.pdf' - vi.mocked(getDocDownloadUrl).mockResolvedValue({ url: mockUrl }) + vi.mocked(consoleClient.compliance.download.get).mockResolvedValue({ url: mockUrl }) seedFeatures(queryClient, { billing: { subscription: { plan: 'team' } } }) // Act @@ -160,7 +176,7 @@ describe('Compliance', () => { // Assert await waitFor(() => { - expect(getDocDownloadUrl).toHaveBeenCalled() + expect(consoleClient.compliance.download.get).toHaveBeenCalled() expect(downloadUrl).toHaveBeenCalledWith({ url: mockUrl }) expect(toastSuccessSpy).toHaveBeenCalledWith('common.operation.downloadSuccess') }) @@ -168,7 +184,9 @@ describe('Compliance', () => { it('should handle download mutation error', async () => { // Arrange - vi.mocked(getDocDownloadUrl).mockRejectedValue(new Error('Download failed')) + vi.mocked(consoleClient.compliance.download.get).mockRejectedValue( + new Error('Download failed'), + ) seedFeatures(queryClient, { billing: { subscription: { plan: 'team' } } }) const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -179,7 +197,7 @@ describe('Compliance', () => { // Assert await waitFor(() => { - expect(getDocDownloadUrl).toHaveBeenCalled() + expect(consoleClient.compliance.download.get).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalledWith('common.operation.downloadFailed') }) expect(consoleSpy).toHaveBeenCalled() @@ -216,7 +234,7 @@ describe('Compliance', () => { it('should show spinner and guard against duplicate download when isPending is true', async () => { // Arrange let resolveDownload: (value: { url: string }) => void - vi.mocked(getDocDownloadUrl).mockImplementation( + vi.mocked(consoleClient.compliance.download.get).mockImplementation( () => new Promise((resolve) => { resolveDownload = resolve @@ -248,7 +266,7 @@ describe('Compliance', () => { it('should not call downloadCompliance again while pending', async () => { let resolveDownload: (value: { url: string }) => void - vi.mocked(getDocDownloadUrl).mockImplementation( + vi.mocked(consoleClient.compliance.download.get).mockImplementation( () => new Promise((resolve) => { resolveDownload = resolve @@ -267,7 +285,7 @@ describe('Compliance', () => { await waitFor( () => { expect(menuItem).toHaveAttribute('aria-disabled', 'true') - expect(getDocDownloadUrl).toHaveBeenCalledTimes(1) + expect(consoleClient.compliance.download.get).toHaveBeenCalledTimes(1) }, { timeout: 10000 }, ) @@ -283,7 +301,7 @@ describe('Compliance', () => { { timeout: 10000 }, ) // getDocDownloadUrl should still have only been called once - expect(getDocDownloadUrl).toHaveBeenCalledTimes(1) + expect(consoleClient.compliance.download.get).toHaveBeenCalledTimes(1) }, 20000) }) }) diff --git a/web/app/components/header/account-dropdown/compliance.tsx b/web/app/components/header/account-dropdown/compliance.tsx index 8e3be7096916bc..fb78893fba5127 100644 --- a/web/app/components/header/account-dropdown/compliance.tsx +++ b/web/app/components/header/account-dropdown/compliance.tsx @@ -22,8 +22,7 @@ import { settingsQueryParser, } from '@/app/components/header/account-setting/query-params' import { toast } from '@/app/notifications' -import { getDocDownloadUrl } from '@/service/common' -import { consoleQuery } from '@/service/console' +import { consoleClient, consoleQuery } from '@/service/console' import { downloadUrl } from '@/utils/download' import PremiumBadge from '../../base/premium-badge' import { MenuItemContent } from './menu-item-content' @@ -117,7 +116,10 @@ function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProp mutationKey: ['downloadCompliance', docName], mutationFn: async () => { try { - const ret = await getDocDownloadUrl(docName) + const ret = await consoleClient.compliance.download.get( + { query: { doc_name: docName } }, + { context: { silent: true } }, + ) downloadUrl({ url: ret.url }) toast.success(t(($) => $['operation.downloadSuccess'], { ns: 'common' })) } catch (error) { diff --git a/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx index 0cee4fc8c68a1b..b37176d71464cf 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx @@ -3,8 +3,7 @@ import type { Member } from '@/models/common' import { QueryClient } from '@tanstack/react-query' import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { deleteMemberOrCancelInvitation } from '@/service/common' -import { consoleQuery } from '@/service/console' +import { consoleClient, consoleQuery } from '@/service/console' import { renderWithConsoleQuery } from '@/test/console/query-data' import MemberMenu from '../member-menu' @@ -21,8 +20,19 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { }) }) -vi.mock('@/service/common', () => ({ - deleteMemberOrCancelInvitation: vi.fn(), +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + workspaces: { + current: { + members: { + byMemberId: { + delete: vi.fn(), + }, + }, + }, + }, + }, })) vi.mock('@/app/notifications', () => ({ toast: { @@ -81,7 +91,10 @@ describe('MemberMenu', () => { beforeEach(() => { vi.clearAllMocks() mockUpdateRolesOfMember.mockResolvedValue(undefined) - vi.mocked(deleteMemberOrCancelInvitation).mockResolvedValue({ result: 'success' }) + vi.mocked(consoleClient.workspaces.current.members.byMemberId.delete).mockResolvedValue({ + result: 'success', + tenant_id: 'tenant-1', + }) vi.mocked(mockUpdateRolesOfMemberHook).mockReturnValue({ mutateAsync: mockUpdateRolesOfMember, }) @@ -181,13 +194,13 @@ describe('MemberMenu', () => { name: /workspaceMembers\.members\.removeFromTeamConfirmTitle:\{"memberName":"Member User"\}/i, }) expect(dialog).toHaveTextContent('workspaceMembers.members.removeFromTeamConfirmDescription') - expect(deleteMemberOrCancelInvitation).not.toHaveBeenCalled() + expect(consoleClient.workspaces.current.members.byMemberId.delete).not.toHaveBeenCalled() await user.click(within(dialog).getByRole('button', { name: /common\.operation\.confirm/i })) await waitFor(() => { - expect(deleteMemberOrCancelInvitation).toHaveBeenCalledWith({ - url: '/workspaces/current/members/member-1', + expect(consoleClient.workspaces.current.members.byMemberId.delete).toHaveBeenCalledWith({ + params: { member_id: 'member-1' }, }) }) expect(queryClient.getQueryState(membersQueryKey)?.isInvalidated).toBe(true) diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx index 3c2e99d19a7fa4..ebaa050c66afb6 100644 --- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx @@ -2,7 +2,7 @@ import type { ConsoleStateFixture } from '@/test/console/state-fixture' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { vi } from 'vite-plus/test' -import { updateWorkspaceInfo } from '@/service/common' +import { consoleClient } from '@/service/console' import { render } from '@/test/console/render' import EditWorkspaceModal from '../index' @@ -21,7 +21,16 @@ vi.mock('@/context/workspace-state', async () => { return createWorkspaceStateModuleMock(() => mockConsoleState.current) }) -vi.mock('@/service/common') +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + workspaces: { + info: { + post: vi.fn(), + }, + }, + }, +})) vi.mock('@/app/notifications', () => ({ default: { notify: (args: unknown) => toastMocks.mockNotify(args), @@ -92,7 +101,7 @@ describe('EditWorkspaceModal', () => { assign: mockAssign, origin: 'http://localhost', }) - vi.mocked(updateWorkspaceInfo).mockResolvedValue({ + vi.mocked(consoleClient.workspaces.info.post).mockResolvedValue({ result: 'success', tenant: { id: 'workspace-id' }, }) @@ -105,8 +114,7 @@ describe('EditWorkspaceModal', () => { await user.click(getSaveButton()) await waitFor(() => { - expect(updateWorkspaceInfo).toHaveBeenCalledWith({ - url: '/workspaces/info', + expect(consoleClient.workspaces.info.post).toHaveBeenCalledWith({ body: { name: 'Renamed Workspace' }, }) expect(mockAssign).toHaveBeenCalledWith('http://localhost') @@ -118,7 +126,7 @@ describe('EditWorkspaceModal', () => { it('should expose the saving label and prevent duplicate form submission', async () => { const user = userEvent.setup() let rejectUpdate!: (reason: Error) => void - vi.mocked(updateWorkspaceInfo).mockImplementationOnce( + vi.mocked(consoleClient.workspaces.info.post).mockImplementationOnce( () => new Promise((_, reject) => { rejectUpdate = reject @@ -134,7 +142,7 @@ describe('EditWorkspaceModal', () => { const savingButton = screen.getByRole('button', { name: /operation\.saving/i }) expect(savingButton).toHaveAttribute('aria-disabled', 'true') fireEvent.submit(savingButton.closest('form')!) - expect(updateWorkspaceInfo).toHaveBeenCalledOnce() + expect(consoleClient.workspaces.info.post).toHaveBeenCalledOnce() rejectUpdate(new Error('update failed')) await waitFor(() => { @@ -145,7 +153,7 @@ describe('EditWorkspaceModal', () => { it('should show error toast when update fails', async () => { const user = userEvent.setup() - vi.mocked(updateWorkspaceInfo).mockRejectedValue(new Error('update failed')) + vi.mocked(consoleClient.workspaces.info.post).mockRejectedValue(new Error('update failed')) renderModal() @@ -193,7 +201,7 @@ describe('EditWorkspaceModal', () => { fireEvent.submit(form!) - expect(updateWorkspaceInfo).not.toHaveBeenCalled() + expect(consoleClient.workspaces.info.post).not.toHaveBeenCalled() expect(mockNotify).not.toHaveBeenCalled() }) diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx index 8fc680ee9876f0..de8e53c30d0ee9 100644 --- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx @@ -9,7 +9,7 @@ import { useId, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' import { currentWorkspaceAtom, isCurrentWorkspaceOwnerAtom } from '@/context/workspace-state' -import { updateWorkspaceInfo } from '@/service/common' +import { consoleClient } from '@/service/console' type IEditWorkspaceModalProps = { onCancel: () => void @@ -38,8 +38,7 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => { if (isSubmitting || isSaveUnavailable) return setIsSubmitting(true) try { - await updateWorkspaceInfo({ - url: '/workspaces/info', + await consoleClient.workspaces.info.post({ body: { name: normalizedName, }, diff --git a/web/app/components/header/account-setting/members-page/member-menu.tsx b/web/app/components/header/account-setting/members-page/member-menu.tsx index a076a85dcf48df..98b9a899bce73e 100644 --- a/web/app/components/header/account-setting/members-page/member-menu.tsx +++ b/web/app/components/header/account-setting/members-page/member-menu.tsx @@ -22,8 +22,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' -import { deleteMemberOrCancelInvitation } from '@/service/common' -import { consoleQuery } from '@/service/console' +import { consoleClient, consoleQuery } from '@/service/console' import { createSelectedRolePlaceholder } from '../workspace-role-list-query' import AssignRolesModal from './assign-roles-modal' @@ -95,7 +94,9 @@ const MemberMenu = ({ const handleRemove = useCallback(async () => { setRemoving(true) try { - await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` }) + await consoleClient.workspaces.current.members.byMemberId.delete({ + params: { member_id: member.id }, + }) void queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.members.get.key(), }) diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx index 68bb3ea918ae89..ed170a319060f3 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx @@ -2,7 +2,7 @@ import type { ConsoleStateFixture } from '@/test/console/state-fixture' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { vi } from 'vite-plus/test' -import { ownershipTransfer, sendOwnerEmail, verifyOwnerEmail } from '@/service/common' +import { consoleClient } from '@/service/console' import { createAccountProfileQueryWrapper } from '@/test/console/account-profile' import { render } from '@/test/console/render' import TransferOwnershipModal from '../index' @@ -31,7 +31,28 @@ vi.mock('@/context/workspace-state', async () => { return createWorkspaceStateModuleMock(() => mockConsoleState.current) }) -vi.mock('@/service/common') +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + workspaces: { + current: { + members: { + byMemberId: { + ownerTransfer: { + post: vi.fn(), + }, + }, + ownerTransferCheck: { + post: vi.fn(), + }, + sendOwnerTransferConfirmEmail: { + post: vi.fn(), + }, + }, + }, + }, + }, +})) vi.mock('@/app/notifications', () => ({ default: { notify: (args: unknown) => toastMocks.mockNotify(args), @@ -101,15 +122,21 @@ describe('TransferOwnershipModal', () => { isValid?: boolean token?: string } = {}) => { - vi.mocked(sendOwnerEmail).mockResolvedValue({ + vi.mocked( + consoleClient.workspaces.current.members.sendOwnerTransferConfirmEmail.post, + ).mockResolvedValue({ data: 'step-token', result: 'success', - } as unknown as Awaited>) - vi.mocked(verifyOwnerEmail).mockResolvedValue({ + } as unknown as Awaited< + ReturnType + >) + vi.mocked(consoleClient.workspaces.current.members.ownerTransferCheck.post).mockResolvedValue({ is_valid: isValid, token, result: 'success', - } as unknown as Awaited>) + } as unknown as Awaited< + ReturnType + >) } const goToTransferStep = async (user: ReturnType) => { @@ -129,9 +156,13 @@ describe('TransferOwnershipModal', () => { it('should complete ownership transfer flow through all steps', async () => { const user = userEvent.setup() mockEmailVerification() - vi.mocked(ownershipTransfer).mockResolvedValue({ + vi.mocked( + consoleClient.workspaces.current.members.byMemberId.ownerTransfer.post, + ).mockResolvedValue({ result: 'success', - } as unknown as Awaited>) + } as unknown as Awaited< + ReturnType + >) renderModal() await goToTransferStep(user) @@ -140,7 +171,12 @@ describe('TransferOwnershipModal', () => { await waitFor( () => { - expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' }) + expect( + consoleClient.workspaces.current.members.byMemberId.ownerTransfer.post, + ).toHaveBeenCalledWith({ + params: { member_id: 'new-owner-id' }, + body: { token: 'final-token' }, + }) expect(window.location.reload).toHaveBeenCalled() }, { timeout: 10000 }, @@ -149,10 +185,14 @@ describe('TransferOwnershipModal', () => { it('should handle timer countdown and resend', async () => { vi.useFakeTimers() - vi.mocked(sendOwnerEmail).mockResolvedValue({ + vi.mocked( + consoleClient.workspaces.current.members.sendOwnerTransferConfirmEmail.post, + ).mockResolvedValue({ data: 'token', result: 'success', - } as unknown as Awaited>) + } as unknown as Awaited< + ReturnType + >) renderModal() // Trigger the email send (which starts the timer) @@ -181,7 +221,9 @@ describe('TransferOwnershipModal', () => { await act(async () => { fireEvent.click(resendBtn) }) - expect(sendOwnerEmail).toHaveBeenCalledTimes(2) + expect( + consoleClient.workspaces.current.members.sendOwnerTransferConfirmEmail.post, + ).toHaveBeenCalledTimes(2) vi.useRealTimers() }) @@ -205,7 +247,9 @@ describe('TransferOwnershipModal', () => { it('should show error when verifying email throws an error', async () => { const user = userEvent.setup() mockEmailVerification() - vi.mocked(verifyOwnerEmail).mockRejectedValue(new Error('verification crash')) + vi.mocked(consoleClient.workspaces.current.members.ownerTransferCheck.post).mockRejectedValue( + new Error('verification crash'), + ) renderModal() await goToTransferStep(user) @@ -222,7 +266,9 @@ describe('TransferOwnershipModal', () => { it('should not show a modal-level toast and should stay on start step when sending verification email fails', async () => { const user = userEvent.setup() - vi.mocked(sendOwnerEmail).mockRejectedValue(new Error('network error')) + vi.mocked( + consoleClient.workspaces.current.members.sendOwnerTransferConfirmEmail.post, + ).mockRejectedValue(new Error('network error')) renderModal() await user.click( screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }), @@ -231,7 +277,9 @@ describe('TransferOwnershipModal', () => { // The base service layer surfaces the real backend error. The modal itself // must NOT show an additional toast (e.g. "Error sending verification code: undefined"). await waitFor(() => { - expect(sendOwnerEmail).toHaveBeenCalled() + expect( + consoleClient.workspaces.current.members.sendOwnerTransferConfirmEmail.post, + ).toHaveBeenCalled() }) expect(mockNotify).not.toHaveBeenCalled() // Should remain on the start step instead of advancing to the verify step. @@ -243,7 +291,9 @@ describe('TransferOwnershipModal', () => { it('should show error when ownership transfer fails', async () => { const user = userEvent.setup() mockEmailVerification() - vi.mocked(ownershipTransfer).mockRejectedValue(new Error('transfer failed')) + vi.mocked( + consoleClient.workspaces.current.members.byMemberId.ownerTransfer.post, + ).mockRejectedValue(new Error('transfer failed')) renderModal() await goToTransferStep(user) await selectNewOwnerAndSubmit(user) @@ -260,10 +310,14 @@ describe('TransferOwnershipModal', () => { it('should handle sendOwnerEmail returning null data', async () => { const user = userEvent.setup() - vi.mocked(sendOwnerEmail).mockResolvedValue({ + vi.mocked( + consoleClient.workspaces.current.members.sendOwnerTransferConfirmEmail.post, + ).mockResolvedValue({ data: null, result: 'success', - } as unknown as Awaited>) + } as unknown as Awaited< + ReturnType + >) renderModal() await user.click( @@ -278,7 +332,9 @@ describe('TransferOwnershipModal', () => { it('should swallow null rejection from sendOwnerEmail without showing a modal-level toast', async () => { const user = userEvent.setup() - vi.mocked(sendOwnerEmail).mockRejectedValue(null) + vi.mocked( + consoleClient.workspaces.current.members.sendOwnerTransferConfirmEmail.post, + ).mockRejectedValue(null) renderModal() await user.click( @@ -286,7 +342,9 @@ describe('TransferOwnershipModal', () => { ) await waitFor(() => { - expect(sendOwnerEmail).toHaveBeenCalled() + expect( + consoleClient.workspaces.current.members.sendOwnerTransferConfirmEmail.post, + ).toHaveBeenCalled() }) expect(mockNotify).not.toHaveBeenCalled() expect( @@ -297,7 +355,9 @@ describe('TransferOwnershipModal', () => { it('should show fallback error prefix when verifyOwnerEmail throws null', async () => { const user = userEvent.setup() mockEmailVerification() - vi.mocked(verifyOwnerEmail).mockRejectedValue(null) + vi.mocked(consoleClient.workspaces.current.members.ownerTransferCheck.post).mockRejectedValue( + null, + ) renderModal() await goToTransferStep(user) @@ -315,7 +375,9 @@ describe('TransferOwnershipModal', () => { it('should show fallback error prefix when ownershipTransfer throws null', async () => { const user = userEvent.setup() mockEmailVerification() - vi.mocked(ownershipTransfer).mockRejectedValue(null) + vi.mocked( + consoleClient.workspaces.current.members.byMemberId.ownerTransfer.post, + ).mockRejectedValue(null) renderModal() await goToTransferStep(user) diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx index e9437fbbfad42e..1177333c15528a 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx @@ -9,7 +9,7 @@ import { Trans, useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' import { currentWorkspaceAtom } from '@/context/workspace-state' import { userProfileQueryOptions } from '@/features/account-profile/client' -import { ownershipTransfer, sendOwnerEmail, verifyOwnerEmail } from '@/service/common' +import { consoleClient } from '@/service/console' import { getErrorMessage } from '@/utils/error-message' import MemberSelector from './member-selector' @@ -62,15 +62,16 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { ) } const sendEmail = async () => { - const res = await sendOwnerEmail({}) + const res = await consoleClient.workspaces.current.members.sendOwnerTransferConfirmEmail.post({ + body: {}, + }) startCount() if (res.data) setStepToken(res.data) } const verifyEmailAddress = async (code: string, token: string, callback?: () => void) => { try { - const res = await verifyOwnerEmail({ - code, - token, + const res = await consoleClient.workspaces.current.members.ownerTransferCheck.post({ + body: { code, token }, }) if (res.is_valid) { setStepToken(res.token) @@ -97,8 +98,9 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { const handleTransfer = async () => { setIsTransfer(true) try { - await ownershipTransfer(newOwner, { - token: stepToken, + await consoleClient.workspaces.current.members.byMemberId.ownerTransfer.post({ + params: { member_id: newOwner }, + body: { token: stepToken }, }) globalThis.location.reload() } catch (error) { diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts b/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts index 56eabeea9befcf..77a357bd54d945 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts @@ -9,7 +9,6 @@ import type { } from '../declarations' import { act, renderHook } from '@testing-library/react' import { useLocale } from '#i18n' -import { fetchDefaultModal } from '@/service/common' import { consoleQuery } from '@/service/console' import { ConfigurationMethodEnum, @@ -49,10 +48,6 @@ vi.mock('@tanstack/react-query', () => ({ })), })) -vi.mock('@/service/common', () => ({ - fetchDefaultModal: vi.fn(), -})) - vi.mock('@/service/use-common', () => ({ commonQueryKeys: { modelProviders: ['model-providers'], @@ -166,6 +161,9 @@ describe('hooks', () => { provider: { provider: 'openai', icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + label: { en_US: 'Provider', zh_Hans: 'Provider' }, + supported_model_types: [], + tenant_id: 'tenant-1', }, model, model_type: ModelTypeEnum.textGeneration, @@ -193,6 +191,9 @@ describe('hooks', () => { provider: { provider: 'anthropic', icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + label: { en_US: 'Provider', zh_Hans: 'Provider' }, + supported_model_types: [], + tenant_id: 'tenant-1', }, model: 'claude-3', model_type: ModelTypeEnum.textGeneration, @@ -290,15 +291,6 @@ describe('hooks', () => { expect(result.current.data).toEqual(mockDefaultModel) expect(result.current.isLoading).toBe(false) - - // Coverage for queryFn - const queryCall = (useQuery as Mock).mock.calls.find( - (call) => Array.isArray(call[0].queryKey) && call[0].queryKey[0] === 'default-model', - ) - if (queryCall) { - await queryCall[0].queryFn() - expect(fetchDefaultModal).toHaveBeenCalled() - } }) it('should return undefined when data is not available', () => { @@ -327,7 +319,9 @@ describe('hooks', () => { expect(useQuery).toHaveBeenCalledWith( expect.objectContaining({ enabled: false, - queryKey: ['default-model', ModelTypeEnum.textEmbedding], + queryKey: consoleQuery.workspaces.current.defaultModel.get.queryKey({ + input: { query: { model_type: ModelTypeEnum.textEmbedding } }, + }), }), ) expect(result.current.isLoading).toBe(false) @@ -686,7 +680,9 @@ describe('hooks', () => { }) expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ['default-model', ModelTypeEnum.textGeneration], + queryKey: consoleQuery.workspaces.current.defaultModel.get.queryKey({ + input: { query: { model_type: ModelTypeEnum.textGeneration } }, + }), }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/declarations.ts b/web/app/components/header/account-setting/model-provider-page/declarations.ts index 1a96f401d96f2e..3591b54701bfbd 100644 --- a/web/app/components/header/account-setting/model-provider-page/declarations.ts +++ b/web/app/components/header/account-setting/model-provider-page/declarations.ts @@ -1,3 +1,4 @@ +import type { GetWorkspacesCurrentDefaultModelResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { Field } from '@/app/components/workflow/nodes/llm/types' export type FormValue = Record @@ -264,14 +265,7 @@ export type ModelProvider = { allow_custom_token?: boolean } -export type DefaultModelResponse = { - model: string - model_type: ModelTypeEnum - provider: { - provider: string - icon_small: TypeWithI18N - } -} +export type DefaultModelResponse = NonNullable export type DefaultModel = { provider: string @@ -292,7 +286,7 @@ export type ModelParameterRule = { max?: number name: string precision?: number - required: false + required?: boolean type: string use_template?: string options?: string[] diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.ts b/web/app/components/header/account-setting/model-provider-page/hooks.ts index 66c633886f0365..79ba94e7b51caa 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.ts @@ -23,7 +23,6 @@ import { import { PluginCategoryEnum } from '@/app/components/plugins/types' import { useModalContextSelector } from '@/context/modal-context' import { getModelLanguage } from '@/i18n/metadata' -import { fetchDefaultModal } from '@/service/common' import { consoleQuery } from '@/service/console' import { commonQueryKeys, modelProviderDetailsQueryOptions } from '@/service/use-common' import { useExpandModelProviderList } from './atoms' @@ -95,14 +94,16 @@ export const useDefaultModel = ( type: ModelTypeEnum, { enabled = true }: ModelQueryOptions = {}, ) => { - const { data, refetch, isPending } = useQuery({ - queryKey: commonQueryKeys.defaultModel(type), - queryFn: () => fetchDefaultModal(`/workspaces/current/default-model?model_type=${type}`), - enabled, - }) + const { data, refetch, isPending } = useQuery( + consoleQuery.workspaces.current.defaultModel.get.queryOptions({ + input: { query: { model_type: type } }, + enabled, + }), + ) return { - data: data?.data, + // An unset default model comes back as null. + data: data?.data ?? undefined, mutate: refetch, isLoading: enabled && isPending, } @@ -208,7 +209,11 @@ export const useInvalidateDefaultModel = () => { return useCallback( (type: ModelTypeEnum) => { - queryClient.invalidateQueries({ queryKey: commonQueryKeys.defaultModel(type) }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.defaultModel.get.queryKey({ + input: { query: { model_type: type } }, + }), + }) }, [queryClient], ) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx index 90b2f07f2e9876..04a3c89a749868 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx @@ -263,7 +263,13 @@ describe('ModelSelector', () => { defaultModel={{ model: 'missing-model', model_type: ModelTypeEnum.textGeneration, - provider: { provider, icon_small: { en_US: '', zh_Hans: '' } }, + provider: { + provider, + icon_small: { en_US: '', zh_Hans: '' }, + label: { en_US: provider, zh_Hans: provider }, + supported_model_types: [], + tenant_id: 'tenant-1', + }, }} />, ) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx index c212fef3a991be..4888f0969a1cf7 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx @@ -1,7 +1,7 @@ import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen' import type { ModelItem, ModelProvider } from '../../declarations' import { fireEvent, screen, waitFor } from '@testing-library/react' -import { disableModel, enableModel } from '@/service/common' +import { consoleClient } from '@/service/console' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { render } from '@/test/console/render' import { ModelStatusEnum } from '../../declarations' @@ -28,9 +28,26 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('@/service/common', () => ({ - enableModel: vi.fn(), - disableModel: vi.fn(), +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + workspaces: { + current: { + modelProviders: { + byProvider: { + models: { + enable: { + patch: vi.fn(), + }, + disable: { + patch: vi.fn(), + }, + }, + }, + }, + }, + }, + }, })) vi.mock('../../hooks', () => ({ @@ -119,7 +136,9 @@ describe('ModelListItem', () => { await waitFor( () => { - expect(disableModel).toHaveBeenCalled() + expect( + consoleClient.workspaces.current.modelProviders.byProvider.models.disable.patch, + ).toHaveBeenCalled() expect(onChange).toHaveBeenCalledWith('test-provider') }, { timeout: 2000 }, @@ -142,7 +161,9 @@ describe('ModelListItem', () => { await waitFor( () => { - expect(enableModel).toHaveBeenCalled() + expect( + consoleClient.workspaces.current.modelProviders.byProvider.models.enable.patch, + ).toHaveBeenCalled() expect(onChange).toHaveBeenCalledWith('test-provider') }, { timeout: 2000 }, @@ -292,8 +313,12 @@ describe('ModelListItem', () => { expect(statusSwitch).toHaveAttribute('aria-disabled', 'true') fireEvent.click(statusSwitch) expect(statusSwitch).toHaveAttribute('aria-checked', 'false') - expect(enableModel).not.toHaveBeenCalled() - expect(disableModel).not.toHaveBeenCalled() + expect( + consoleClient.workspaces.current.modelProviders.byProvider.models.enable.patch, + ).not.toHaveBeenCalled() + expect( + consoleClient.workspaces.current.modelProviders.byProvider.models.disable.patch, + ).not.toHaveBeenCalled() }) // isConfigurable=true: hover class on row diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx index 272eb226a5a0a2..647dd9723ec442 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx @@ -11,8 +11,7 @@ import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { deploymentEditionAtom } from '@/features/system-features/state' -import { disableModel, enableModel } from '@/service/common' -import { consoleQuery } from '@/service/console' +import { consoleClient, consoleQuery } from '@/service/console' import { hasPermission } from '@/utils/permission' import { ModelStatusEnum } from '../declarations' import { useUpdateModelList } from '../hooks' @@ -67,15 +66,15 @@ const ModelListItem = ({ const toggleModelEnablingStatus = useCallback( async (enabled: boolean) => { if (enabled) - await enableModel( - `/workspaces/current/model-providers/${provider.provider}/models/enable`, - { model: model.model, model_type: model.model_type }, - ) + await consoleClient.workspaces.current.modelProviders.byProvider.models.enable.patch({ + params: { provider: provider.provider }, + body: { model: model.model, model_type: model.model_type }, + }) else - await disableModel( - `/workspaces/current/model-providers/${provider.provider}/models/disable`, - { model: model.model, model_type: model.model_type }, - ) + await consoleClient.workspaces.current.modelProviders.byProvider.models.disable.patch({ + params: { provider: provider.provider }, + body: { model: model.model, model_type: model.model_type }, + }) queryClient.invalidateQueries({ queryKey: modelProviderModelListQueryKey, diff --git a/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx index b39b4db0b44e1e..a8715ea616babe 100644 --- a/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx @@ -135,6 +135,9 @@ const mockModel: DefaultModelResponse = { provider: { provider: 'openai', icon_small: { en_US: '', zh_Hans: '' }, + label: { en_US: 'Provider', zh_Hans: 'Provider' }, + supported_model_types: [], + tenant_id: 'tenant-1', }, } diff --git a/web/app/components/header/account-setting/model-provider-page/utils.ts b/web/app/components/header/account-setting/model-provider-page/utils.ts index 76164b790e0857..368a712ee10ffa 100644 --- a/web/app/components/header/account-setting/model-provider-page/utils.ts +++ b/web/app/components/header/account-setting/model-provider-page/utils.ts @@ -4,12 +4,14 @@ import type { ModelStatus, ModelType, ModelWithProviderEntityResponse, + ParameterRule, } from '@dify/contracts/api/console/workspaces/types.gen' import type { StaticImageData } from 'next/image' import type { CredentialFormSchemaSelect, CredentialFormSchemaTextInput, ModelItem, + ModelParameterRule, TypeWithI18N, } from './declarations' import { ModelProviderQuotaGetPaid } from '@/types/model-provider' @@ -197,3 +199,24 @@ const normalizeModelProviderModel = (model: ModelWithProviderEntityResponse): Mo export const normalizeModelProviderModelsResponse = (response: { data: ModelWithProviderEntityResponse[] }): ModelItem[] => response.data.map(normalizeModelProviderModel) + +const isParameterDefault = (value: unknown): value is ModelParameterRule['default'] => + typeof value === 'number' || + typeof value === 'string' || + typeof value === 'boolean' || + (Array.isArray(value) && value.every((item) => typeof item === 'string')) + +export const normalizeModelParameterRule = (rule: ParameterRule): ModelParameterRule => ({ + name: rule.name, + type: rule.type, + label: normalizeModelLabel(rule.label), + help: rule.help ? normalizeModelLabel(rule.help) : undefined, + // `default` is untyped JSON in the console API response. + default: isParameterDefault(rule.default) ? rule.default : undefined, + min: rule.min ?? undefined, + max: rule.max ?? undefined, + precision: rule.precision ?? undefined, + required: rule.required, + use_template: rule.use_template ?? undefined, + options: rule.options, +}) diff --git a/web/app/forgot-password/ChangePasswordForm.spec.tsx b/web/app/forgot-password/ChangePasswordForm.spec.tsx index 83e640f8c6e08d..85cbabef25d09d 100644 --- a/web/app/forgot-password/ChangePasswordForm.spec.tsx +++ b/web/app/forgot-password/ChangePasswordForm.spec.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import useDocumentTitle from '@/hooks/use-document-title' -import { changePasswordWithToken } from '@/service/common' +import { consoleClient } from '@/service/console' import { useVerifyForgotPasswordToken } from '@/service/use-common' import ChangePasswordForm from './ChangePasswordForm' @@ -15,8 +15,15 @@ vi.mock('@/service/use-common', () => ({ useVerifyForgotPasswordToken: vi.fn(), })) -vi.mock('@/service/common', () => ({ - changePasswordWithToken: vi.fn(), +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + forgotPassword: { + resets: { + post: vi.fn(), + }, + }, + }, })) vi.mock('@/hooks/use-document-title', () => ({ @@ -28,7 +35,7 @@ vi.mock('@/utils/var', () => ({ basePath: '' })) type UseVerifyResult = ReturnType const mockUseVerify = vi.mocked(useVerifyForgotPasswordToken) -const mockChangePassword = vi.mocked(changePasswordWithToken) +const mockChangePassword = vi.mocked(consoleClient.forgotPassword.resets.post) const mockUseDocumentTitle = vi.mocked(useDocumentTitle) const VALID_PASSWORD = 'ValidPass123!' @@ -79,7 +86,6 @@ describe('ChangePasswordForm', () => { await waitFor(() => { expect(mockChangePassword).toHaveBeenCalledWith({ - url: '/forgot-password/resets', body: { token: T2, new_password: VALID_PASSWORD, @@ -165,7 +171,7 @@ describe('ChangePasswordForm', () => { it('prevents duplicate submissions while the password is changing', async () => { const user = userEvent.setup() let resolveRequest: - | ((value: Awaited>) => void) + | ((value: Awaited>) => void) | undefined mockChangePassword.mockReturnValue( new Promise((resolve) => { diff --git a/web/app/forgot-password/ChangePasswordForm.tsx b/web/app/forgot-password/ChangePasswordForm.tsx index 28f3fe5f77941a..2894613bf51544 100644 --- a/web/app/forgot-password/ChangePasswordForm.tsx +++ b/web/app/forgot-password/ChangePasswordForm.tsx @@ -17,7 +17,7 @@ import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { validPassword } from '@/config' import useDocumentTitle from '@/hooks/use-document-title' import { useSearchParams } from '@/next/navigation' -import { changePasswordWithToken } from '@/service/common' +import { consoleClient } from '@/service/console' import { useVerifyForgotPasswordToken } from '@/service/use-common' import { basePath } from '@/utils/var' @@ -56,8 +56,7 @@ const ChangePasswordForm = () => { setIsSubmitting(true) try { - await changePasswordWithToken({ - url: '/forgot-password/resets', + await consoleClient.forgotPassword.resets.post({ body: { token: resetToken, new_password: formValues.password, diff --git a/web/app/forgot-password/ForgotPasswordForm.spec.tsx b/web/app/forgot-password/ForgotPasswordForm.spec.tsx index b4dfe22a2d6477..6ccb45282804ff 100644 --- a/web/app/forgot-password/ForgotPasswordForm.spec.tsx +++ b/web/app/forgot-password/ForgotPasswordForm.spec.tsx @@ -1,18 +1,23 @@ -import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common' +import type { InitValidateStatusResponse } from '@/models/common' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import useDocumentTitle from '@/hooks/use-document-title' -import { - fetchInitValidateStatus, - fetchSetupStatus, - sendForgotPasswordEmail, -} from '@/service/common' +import { consoleClient } from '@/service/console' import ForgotPasswordForm from './ForgotPasswordForm' -vi.mock('@/service/common', () => ({ - fetchSetupStatus: vi.fn(), - fetchInitValidateStatus: vi.fn(), - sendForgotPasswordEmail: vi.fn(), +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + setup: { + get: vi.fn(), + }, + init: { + get: vi.fn(), + }, + forgotPassword: { + post: vi.fn(), + }, + }, })) vi.mock('@/hooks/use-document-title', () => ({ @@ -20,13 +25,13 @@ vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn(), })) -const mockFetchSetupStatus = vi.mocked(fetchSetupStatus) -const mockFetchInitValidateStatus = vi.mocked(fetchInitValidateStatus) -const mockSendForgotPasswordEmail = vi.mocked(sendForgotPasswordEmail) +const mockFetchSetupStatus = vi.mocked(consoleClient.setup.get) +const mockFetchInitValidateStatus = vi.mocked(consoleClient.init.get) +const mockSendForgotPasswordEmail = vi.mocked(consoleClient.forgotPassword.post) const mockUseDocumentTitle = vi.mocked(useDocumentTitle) const prepareLoadedState = () => { - mockFetchSetupStatus.mockResolvedValue({ step: 'not_started' } as SetupStatusResponse) + mockFetchSetupStatus.mockResolvedValue({ step: 'not_started' }) mockFetchInitValidateStatus.mockResolvedValue({ status: 'finished', } as InitValidateStatusResponse) @@ -90,7 +95,6 @@ describe('ForgotPasswordForm', () => { await waitFor(() => { expect(mockSendForgotPasswordEmail).toHaveBeenCalledWith({ - url: '/forgot-password', body: { email: 'test@example.com' }, }) }) @@ -115,7 +119,6 @@ describe('ForgotPasswordForm', () => { await waitFor(() => { expect(mockSendForgotPasswordEmail).toHaveBeenCalledWith({ - url: '/forgot-password', body: { email: 'test@example.com' }, }) }) diff --git a/web/app/forgot-password/ForgotPasswordForm.tsx b/web/app/forgot-password/ForgotPasswordForm.tsx index 38f1c2e557117c..ac89007fe7bc0e 100644 --- a/web/app/forgot-password/ForgotPasswordForm.tsx +++ b/web/app/forgot-password/ForgotPasswordForm.tsx @@ -1,5 +1,4 @@ 'use client' -import type { InitValidateStatusResponse } from '@/models/common' import { Button, buttonVariants } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Field, FieldError, FieldLabel } from '@langgenius/dify-ui/field' @@ -11,11 +10,7 @@ import * as z from 'zod' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import useDocumentTitle from '@/hooks/use-document-title' import Link from '@/next/link' -import { - fetchInitValidateStatus, - fetchSetupStatus, - sendForgotPasswordEmail, -} from '@/service/common' +import { consoleClient } from '@/service/console' import { basePath } from '@/utils/var' type ForgotPasswordFormValues = { @@ -43,10 +38,7 @@ const ForgotPasswordForm = () => { setIsSubmitting(true) try { - const res = await sendForgotPasswordEmail({ - url: '/forgot-password', - body: { email }, - }) + const res = await consoleClient.forgotPassword.post({ body: { email } }) if (res.result === 'success') setIsEmailSent(true) else console.error('Email verification failed') } catch (error) { @@ -57,8 +49,8 @@ const ForgotPasswordForm = () => { } useEffect(() => { - fetchSetupStatus().then(() => { - fetchInitValidateStatus().then((res: InitValidateStatusResponse) => { + consoleClient.setup.get().then(() => { + consoleClient.init.get().then((res) => { if (res.status === 'not_started') window.location.href = `${basePath}/init` }) diff --git a/web/app/init/InitPasswordPopup.tsx b/web/app/init/InitPasswordPopup.tsx index 266bb534418424..27cca751ddadca 100644 --- a/web/app/init/InitPasswordPopup.tsx +++ b/web/app/init/InitPasswordPopup.tsx @@ -1,5 +1,4 @@ 'use client' -import type { InitValidateStatusResponse } from '@/models/common' import { Button } from '@langgenius/dify-ui/button' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -7,7 +6,7 @@ import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { toast } from '@/app/notifications' import useDocumentTitle from '@/hooks/use-document-title' import { useRouter } from '@/next/navigation' -import { fetchInitValidateStatus, initValidate } from '@/service/common' +import { consoleClient } from '@/service/console' import { basePath } from '@/utils/var' const InitPasswordPopup = () => { @@ -23,7 +22,7 @@ const InitPasswordPopup = () => { const handleValidation = async () => { setLoading(true) try { - const response = await initValidate({ body: { password } }) + const response = await consoleClient.init.post({ body: { password } }) if (response.result === 'success') { setValidated(true) router.push('/install') // or render setup form @@ -37,7 +36,7 @@ const InitPasswordPopup = () => { } useEffect(() => { - fetchInitValidateStatus().then((res: InitValidateStatusResponse) => { + consoleClient.init.get().then((res) => { if (res.status === 'finished') window.location.href = `${basePath}/install` else setLoading(false) }) diff --git a/web/app/install/installForm.spec.tsx b/web/app/install/installForm.spec.tsx index 4c3fa08f4baf61..bad68f2d765e2d 100644 --- a/web/app/install/installForm.spec.tsx +++ b/web/app/install/installForm.spec.tsx @@ -1,8 +1,8 @@ import type { ReactElement } from 'react' -import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common' +import type { InitValidateStatusResponse } from '@/models/common' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { fetchInitValidateStatus, fetchSetupStatus, login, setup } from '@/service/common' +import { consoleClient } from '@/service/console' import { expectLoadingButton } from '@/test/button' import { renderWithConsoleQuery } from '@/test/console/query-data' import { encryptPassword } from '@/utils/encryption' @@ -21,20 +21,29 @@ vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn(), })) -vi.mock('@/service/common', () => ({ - fetchSetupStatus: vi.fn(), - fetchInitValidateStatus: vi.fn(), - setup: vi.fn(), - login: vi.fn(), +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + setup: { + get: vi.fn(), + post: vi.fn(), + }, + init: { + get: vi.fn(), + }, + login: { + post: vi.fn(), + }, + }, })) -const mockFetchSetupStatus = vi.mocked(fetchSetupStatus) -const mockFetchInitValidateStatus = vi.mocked(fetchInitValidateStatus) -const mockSetup = vi.mocked(setup) -const mockLogin = vi.mocked(login) +const mockFetchSetupStatus = vi.mocked(consoleClient.setup.get) +const mockFetchInitValidateStatus = vi.mocked(consoleClient.init.get) +const mockSetup = vi.mocked(consoleClient.setup.post) +const mockLogin = vi.mocked(consoleClient.login.post) const prepareLoadedState = () => { - mockFetchSetupStatus.mockResolvedValue({ step: 'not_started' } as SetupStatusResponse) + mockFetchSetupStatus.mockResolvedValue({ step: 'not_started' }) mockFetchInitValidateStatus.mockResolvedValue({ status: 'finished', } as InitValidateStatusResponse) @@ -161,7 +170,6 @@ describe('InstallForm', () => { await waitFor(() => { expect(mockLogin).toHaveBeenCalledWith({ - url: '/login', body: { email: 'admin@example.com', password: encryptPassword('Password123'), @@ -232,7 +240,7 @@ describe('InstallForm', () => { }) it('should redirect to sign in when setup is finished', async () => { - mockFetchSetupStatus.mockResolvedValue({ step: 'finished' } as SetupStatusResponse) + mockFetchSetupStatus.mockResolvedValue({ step: 'finished' }) render() diff --git a/web/app/install/installForm.tsx b/web/app/install/installForm.tsx index 77f644c14d718e..6bc92ac10d45c5 100644 --- a/web/app/install/installForm.tsx +++ b/web/app/install/installForm.tsx @@ -1,5 +1,4 @@ 'use client' -import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common' import { zPostSetupBody } from '@dify/contracts/api/console/setup/zod.gen' import { Button } from '@langgenius/dify-ui/button' import { @@ -24,8 +23,7 @@ import { LICENSE_LINK } from '@/constants/link' import useDocumentTitle from '@/hooks/use-document-title' import Link from '@/next/link' import { useRouter } from '@/next/navigation' -import { fetchInitValidateStatus, fetchSetupStatus, login, setup } from '@/service/common' -import { consoleQuery } from '@/service/console' +import { consoleClient, consoleQuery } from '@/service/console' import { encryptPassword as encodePassword } from '@/utils/encryption' const accountFormSchema = zPostSetupBody.pick({ email: true, name: true, password: true }).extend({ @@ -52,7 +50,7 @@ const InstallForm = () => { setIsSubmitting(true) try { // First, setup the admin account - await setup({ + await consoleClient.setup.post({ body: { ...value, language: i18n.language, @@ -60,8 +58,7 @@ const InstallForm = () => { }) // Then, automatically login with the same credentials - const loginRes = await login({ - url: '/login', + const loginRes = await consoleClient.login.post({ body: { email: value.email, password: encodePassword(value.password), @@ -82,11 +79,11 @@ const InstallForm = () => { } useEffect(() => { - fetchSetupStatus().then((res: SetupStatusResponse) => { + consoleClient.setup.get().then((res) => { if (res.step === 'finished') { push('/signin') } else { - fetchInitValidateStatus().then((res: InitValidateStatusResponse) => { + consoleClient.init.get().then((res) => { if (res.status === 'not_started') push('/init') }) } diff --git a/web/app/reset-password/__tests__/page.spec.tsx b/web/app/reset-password/__tests__/page.spec.tsx index a1aa6d94924823..dc78ef98173c9c 100644 --- a/web/app/reset-password/__tests__/page.spec.tsx +++ b/web/app/reset-password/__tests__/page.spec.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useLocale } from '#i18n' import { useRouter, useSearchParams } from '@/next/navigation' -import { sendResetPasswordCode } from '@/service/common' +import { consoleClient } from '@/service/console' import ResetPasswordPage from '../page' const mockPush = vi.fn() @@ -27,8 +27,13 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: vi.fn(), })) -vi.mock('@/service/common', () => ({ - sendResetPasswordCode: vi.fn(), +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + forgotPassword: { + post: vi.fn(), + }, + }, })) describe('ResetPasswordPage', () => { @@ -41,7 +46,10 @@ describe('ResetPasswordPage', () => { vi.mocked(useSearchParams).mockReturnValue( new URLSearchParams() as unknown as ReturnType, ) - vi.mocked(sendResetPasswordCode).mockResolvedValue({ result: 'success', data: 'reset-token' }) + vi.mocked(consoleClient.forgotPassword.post).mockResolvedValue({ + result: 'success', + data: 'reset-token', + }) }) it('labels the email field and submits it with Enter', async () => { @@ -54,7 +62,9 @@ describe('ResetPasswordPage', () => { await user.type(emailInput, 'user@example.com{Enter}') await waitFor(() => { - expect(sendResetPasswordCode).toHaveBeenCalledWith('user@example.com', 'en-US') + expect(consoleClient.forgotPassword.post).toHaveBeenCalledWith({ + body: { email: 'user@example.com', language: 'en-US' }, + }) }) expect(mockPush).toHaveBeenCalledWith( '/reset-password/check-code?token=reset-token&email=user%2540example.com', @@ -73,7 +83,7 @@ describe('ResetPasswordPage', () => { expect(emailInput).toHaveAttribute('aria-invalid', 'true') expect(emailInput).toHaveAccessibleDescription('login.error.emailInValid') expect(emailInput).toHaveFocus() - expect(sendResetPasswordCode).not.toHaveBeenCalled() + expect(consoleClient.forgotPassword.post).not.toHaveBeenCalled() await user.clear(emailInput) await user.click(screen.getByRole('button', { name: 'login.sendVerificationCode' })) @@ -84,6 +94,6 @@ describe('ResetPasswordPage', () => { expect(emailInput).toHaveAttribute('aria-invalid', 'true') expect(emailInput).toHaveAccessibleDescription('login.error.emailEmpty') expect(emailInput).toHaveFocus() - expect(sendResetPasswordCode).not.toHaveBeenCalled() + expect(consoleClient.forgotPassword.post).not.toHaveBeenCalled() }) }) diff --git a/web/app/reset-password/check-code/__tests__/page.spec.tsx b/web/app/reset-password/check-code/__tests__/page.spec.tsx index a1dd4bc6297560..352d2b23c85ab9 100644 --- a/web/app/reset-password/check-code/__tests__/page.spec.tsx +++ b/web/app/reset-password/check-code/__tests__/page.spec.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useLocale } from '#i18n' import { useRouter, useSearchParams } from '@/next/navigation' -import { verifyResetPasswordCode } from '@/service/common' +import { consoleClient } from '@/service/console' import CheckCodePage from '../page' const mockPush = vi.fn() @@ -25,9 +25,16 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: vi.fn(), })) -vi.mock('@/service/common', () => ({ - sendResetPasswordCode: vi.fn(), - verifyResetPasswordCode: vi.fn(), +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + forgotPassword: { + post: vi.fn(), + validity: { + post: vi.fn(), + }, + }, + }, })) describe('ResetPasswordCheckCodePage', () => { @@ -43,8 +50,8 @@ describe('ResetPasswordCheckCodePage', () => { token: 'email-token', }) as unknown as ReturnType, ) - vi.mocked(verifyResetPasswordCode).mockResolvedValue({ - result: 'success', + vi.mocked(consoleClient.forgotPassword.validity.post).mockResolvedValue({ + email: 'user@example.com', is_valid: true, token: 'verified-token', }) @@ -63,10 +70,12 @@ describe('ResetPasswordCheckCodePage', () => { await user.type(codeInput, '123456{Enter}') await waitFor(() => { - expect(verifyResetPasswordCode).toHaveBeenCalledWith({ - code: '123456', - email: 'user@example.com', - token: 'email-token', + expect(consoleClient.forgotPassword.validity.post).toHaveBeenCalledWith({ + body: { + code: '123456', + email: 'user@example.com', + token: 'email-token', + }, }) }) expect(mockPush).toHaveBeenCalledWith( @@ -87,6 +96,6 @@ describe('ResetPasswordCheckCodePage', () => { expect(codeInput).toHaveAttribute('aria-invalid', 'true') expect(codeInput).toHaveAccessibleDescription(error.textContent ?? '') expect(codeInput).toHaveFocus() - expect(verifyResetPasswordCode).not.toHaveBeenCalled() + expect(consoleClient.forgotPassword.validity.post).not.toHaveBeenCalled() }) }) diff --git a/web/app/reset-password/check-code/page.tsx b/web/app/reset-password/check-code/page.tsx index 55d988f992978a..cee7db31da3da8 100644 --- a/web/app/reset-password/check-code/page.tsx +++ b/web/app/reset-password/check-code/page.tsx @@ -10,7 +10,7 @@ import Countdown from '@/app/components/signin/countdown' import { toast } from '@/app/notifications' import useDocumentTitle from '@/hooks/use-document-title' import { useRouter, useSearchParams } from '@/next/navigation' -import { sendResetPasswordCode, verifyResetPasswordCode } from '@/service/common' +import { consoleClient } from '@/service/console' type CheckCodeFormValues = { code: string @@ -31,7 +31,7 @@ export default function CheckCode() { if (loading) return try { setLoading(true) - const ret = await verifyResetPasswordCode({ email, code, token }) + const ret = await consoleClient.forgotPassword.validity.post({ body: { email, code, token } }) if (ret.is_valid) { const params = new URLSearchParams(searchParams) params.set('token', encodeURIComponent(ret.token)) @@ -46,10 +46,10 @@ export default function CheckCode() { const resendCode = async () => { try { - const res = await sendResetPasswordCode(email, locale) + const res = await consoleClient.forgotPassword.post({ body: { email, language: locale } }) if (res.result === 'success') { const params = new URLSearchParams(searchParams) - params.set('token', encodeURIComponent(res.data)) + params.set('token', encodeURIComponent(res.data ?? '')) router.replace(`/reset-password/check-code?${params.toString()}`) } } catch (error) { diff --git a/web/app/reset-password/page.tsx b/web/app/reset-password/page.tsx index 7f28da755ef07b..62bb3f9c8827e6 100644 --- a/web/app/reset-password/page.tsx +++ b/web/app/reset-password/page.tsx @@ -11,7 +11,7 @@ import { emailRegex } from '@/config' import useDocumentTitle from '@/hooks/use-document-title' import Link from '@/next/link' import { useRouter, useSearchParams } from '@/next/navigation' -import { sendResetPasswordCode } from '@/service/common' +import { consoleClient } from '@/service/console' import { COUNT_DOWN_TIME_MS, useSetCountdownLeftTime } from '../components/signin/storage' export default function CheckCode() { @@ -28,11 +28,11 @@ export default function CheckCode() { if (loading) return try { setLoading(true) - const res = await sendResetPasswordCode(email, locale) + const res = await consoleClient.forgotPassword.post({ body: { email, language: locale } }) if (res.result === 'success') { setCountdownLeftTime(`${COUNT_DOWN_TIME_MS}`) const params = new URLSearchParams(searchParams) - params.set('token', encodeURIComponent(res.data)) + params.set('token', encodeURIComponent(res.data ?? '')) params.set('email', encodeURIComponent(email)) router.push(`/reset-password/check-code?${params.toString()}`) } else { diff --git a/web/app/reset-password/set-password/__tests__/page.spec.tsx b/web/app/reset-password/set-password/__tests__/page.spec.tsx index f8fdfbde7d7a62..59920c8f4a4e59 100644 --- a/web/app/reset-password/set-password/__tests__/page.spec.tsx +++ b/web/app/reset-password/set-password/__tests__/page.spec.tsx @@ -2,7 +2,7 @@ import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import useDocumentTitle from '@/hooks/use-document-title' import { useRouter, useSearchParams } from '@/next/navigation' -import { changePasswordWithToken } from '@/service/common' +import { consoleClient } from '@/service/console' import ChangePasswordForm from '../page' const countdownState = vi.hoisted(() => ({ @@ -29,8 +29,15 @@ vi.mock('@/next/link', () => ({ ), })) -vi.mock('@/service/common', () => ({ - changePasswordWithToken: vi.fn(), +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + forgotPassword: { + resets: { + post: vi.fn(), + }, + }, + }, })) vi.mock('@/hooks/use-document-title', () => ({ @@ -40,7 +47,7 @@ vi.mock('@/hooks/use-document-title', () => ({ const mockReplace = vi.fn() const mockUseRouter = vi.mocked(useRouter) const mockUseSearchParams = vi.mocked(useSearchParams) -const mockChangePasswordWithToken = vi.mocked(changePasswordWithToken) +const mockChangePasswordWithToken = vi.mocked(consoleClient.forgotPassword.resets.post) const mockUseDocumentTitle = vi.mocked(useDocumentTitle) const redirectUrl = '/apps?template-id=template-1&utm_source=dify_blog' @@ -108,7 +115,6 @@ describe('Reset Password Set Password Page', () => { await waitFor(() => { expect(mockChangePasswordWithToken).toHaveBeenCalledWith({ - url: '/forgot-password/resets', body: { token: 'reset-token', new_password: 'ValidPass123!', diff --git a/web/app/reset-password/set-password/page.tsx b/web/app/reset-password/set-password/page.tsx index d9f86543a9fda3..cf9833de5e867d 100644 --- a/web/app/reset-password/set-password/page.tsx +++ b/web/app/reset-password/set-password/page.tsx @@ -19,7 +19,7 @@ import { validPassword } from '@/config' import useDocumentTitle from '@/hooks/use-document-title' import Link from '@/next/link' import { useRouter, useSearchParams } from '@/next/navigation' -import { changePasswordWithToken } from '@/service/common' +import { consoleClient } from '@/service/console' type PasswordFormValues = { password: string @@ -75,8 +75,7 @@ const ChangePasswordForm = () => { if (isSubmitting) return setIsSubmitting(true) try { - await changePasswordWithToken({ - url: '/forgot-password/resets', + await consoleClient.forgotPassword.resets.post({ body: { token, new_password: formValues.password, diff --git a/web/app/signin/check-code/__tests__/page.spec.tsx b/web/app/signin/check-code/__tests__/page.spec.tsx index 39a42b8c9591ea..c8330f68010bbe 100644 --- a/web/app/signin/check-code/__tests__/page.spec.tsx +++ b/web/app/signin/check-code/__tests__/page.spec.tsx @@ -4,7 +4,7 @@ import { noop, QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { userProfileQueryOptions } from '@/features/account-profile/client' -import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common' +import { consoleClient } from '@/service/console' import { createHttpError } from '@/service/http-error' import { seedSystemFeatures } from '@/test/console/query-data' import CheckCode from '../page' @@ -76,9 +76,16 @@ vi.mock('@/next/navigation', () => ({ vi.mock('@/service/base', () => serviceBaseMocks) -vi.mock('@/service/common', () => ({ - emailLoginWithCode: vi.fn(), - sendEMailLoginCode: vi.fn(), +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + emailCodeLogin: { + validity: { + post: vi.fn(), + }, + post: vi.fn(), + }, + }, })) vi.mock('@/utils/encryption', () => ({ @@ -139,8 +146,10 @@ const accountProfile: GetAccountProfileResponse = { describe('CheckCode', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(emailLoginWithCode).mockReset().mockResolvedValue({ result: 'success' }) - vi.mocked(sendEMailLoginCode).mockReset() + vi.mocked(consoleClient.emailCodeLogin.validity.post) + .mockReset() + .mockResolvedValue({ result: 'success' }) + vi.mocked(consoleClient.emailCodeLogin.post).mockReset() turnstileWidgets.clear() navigationMocks.searchParams = new URLSearchParams({ email: 'user@example.com', @@ -225,18 +234,13 @@ describe('CheckCode', () => { expect(codeInput).toHaveAttribute('aria-invalid', 'true') expect(codeInput).toHaveAccessibleDescription('login.checkCode.invalidCode') expect(codeInput).toHaveFocus() - expect(emailLoginWithCode).not.toHaveBeenCalled() + expect(consoleClient.emailCodeLogin.validity.post).not.toHaveBeenCalled() }) it('keeps Community verification independent of Turnstile', async () => { const user = userEvent.setup() const queryClient = createQueryClient() - vi.mocked(emailLoginWithCode).mockResolvedValue({ - code: 'invalid_code', - data: '', - message: 'Invalid code', - result: 'fail', - }) + vi.mocked(consoleClient.emailCodeLogin.validity.post).mockResolvedValue({ result: 'fail' }) render( @@ -246,12 +250,14 @@ describe('CheckCode', () => { await user.type(screen.getByLabelText('login.checkCode.verificationCode'), '123456') await user.click(screen.getByRole('button', { name: 'login.checkCode.verify' })) - expect(emailLoginWithCode).toHaveBeenCalledWith({ - code: '123456', - email: 'user@example.com', - language: expect.any(String), - timezone: 'Asia/Singapore', - token: 'email-login-token', + expect(consoleClient.emailCodeLogin.validity.post).toHaveBeenCalledWith({ + body: { + code: '123456', + email: 'user@example.com', + language: expect.any(String), + timezone: 'Asia/Singapore', + token: 'email-login-token', + }, }) expect(turnstileMocks.render).not.toHaveBeenCalled() }) @@ -259,8 +265,11 @@ describe('CheckCode', () => { it('does not resend while verification is in progress', async () => { const user = userEvent.setup() const queryClient = createQueryClient() - const verificationRequest = createDeferred>>() - vi.mocked(emailLoginWithCode).mockReturnValue(verificationRequest.promise) + const verificationRequest = + createDeferred>>() + vi.mocked(consoleClient.emailCodeLogin.validity.post).mockReturnValue( + verificationRequest.promise, + ) render( @@ -270,22 +279,17 @@ describe('CheckCode', () => { await user.type(screen.getByLabelText('login.checkCode.verificationCode'), '123456') await user.click(screen.getByRole('button', { name: 'login.checkCode.verify' })) await waitFor(() => { - expect(emailLoginWithCode).toHaveBeenCalledOnce() + expect(consoleClient.emailCodeLogin.validity.post).toHaveBeenCalledOnce() }) const resendButton = screen.getByRole('button', { name: 'resend-code' }) expect(resendButton).toBeDisabled() resendButton.removeAttribute('disabled') fireEvent.click(resendButton) - expect(sendEMailLoginCode).not.toHaveBeenCalled() + expect(consoleClient.emailCodeLogin.post).not.toHaveBeenCalled() act(() => { - verificationRequest.resolve({ - code: 'invalid_code', - data: '', - message: 'Invalid code', - result: 'fail', - }) + verificationRequest.resolve({ result: 'fail' }) }) await waitFor(() => { expect(screen.getByRole('button', { name: 'login.checkCode.verify' })).toBeEnabled() @@ -295,8 +299,9 @@ describe('CheckCode', () => { it('does not verify while resend is in progress', async () => { const user = userEvent.setup() const queryClient = createQueryClient() - const resendRequest = createDeferred>>() - vi.mocked(sendEMailLoginCode).mockReturnValue(resendRequest.promise) + const resendRequest = + createDeferred>>() + vi.mocked(consoleClient.emailCodeLogin.post).mockReturnValue(resendRequest.promise) render( @@ -306,7 +311,7 @@ describe('CheckCode', () => { await user.type(screen.getByLabelText('login.checkCode.verificationCode'), '123456') await user.click(screen.getByRole('button', { name: 'resend-code' })) await waitFor(() => { - expect(sendEMailLoginCode).toHaveBeenCalledOnce() + expect(consoleClient.emailCodeLogin.post).toHaveBeenCalledOnce() }) const verifyButton = screen.getByRole('button', { name: 'login.checkCode.verify' }) @@ -314,7 +319,7 @@ describe('CheckCode', () => { const form = verifyButton.closest('form') if (!form) throw new Error('Verification form is missing') fireEvent.submit(form) - expect(emailLoginWithCode).not.toHaveBeenCalled() + expect(consoleClient.emailCodeLogin.validity.post).not.toHaveBeenCalled() act(() => { resendRequest.resolve({ data: '', result: 'fail' }) @@ -330,14 +335,9 @@ describe('CheckCode', () => { turnstileMocks.siteKey = 'cloud-site-key' const queryClient = createQueryClient() vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.mocked(emailLoginWithCode) + vi.mocked(consoleClient.emailCodeLogin.validity.post) .mockRejectedValueOnce(new Error('invalid verification code')) - .mockResolvedValueOnce({ - code: 'invalid_code', - data: '', - message: 'Invalid code', - result: 'fail', - }) + .mockResolvedValueOnce({ result: 'fail' }) render( @@ -365,11 +365,11 @@ describe('CheckCode', () => { await user.click(verifyButton) await waitFor(() => { - expect(emailLoginWithCode).toHaveBeenCalledWith( - expect.objectContaining({ + expect(consoleClient.emailCodeLogin.validity.post).toHaveBeenCalledWith({ + body: expect.objectContaining({ turnstile_token: 'signin_code_verify-token-1', }), - ) + }) }) expect(codeInput).toHaveValue('123456') await waitFor(() => { @@ -387,11 +387,11 @@ describe('CheckCode', () => { await user.click(verifyButton) await waitFor(() => { - expect(emailLoginWithCode).toHaveBeenLastCalledWith( - expect.objectContaining({ + expect(consoleClient.emailCodeLogin.validity.post).toHaveBeenLastCalledWith({ + body: expect.objectContaining({ turnstile_token: 'signin_code_verify-token-2', }), - ) + }) }) expect(codeInput).toHaveValue('123456') }) @@ -401,7 +401,10 @@ describe('CheckCode', () => { turnstileMocks.deploymentEdition = 'CLOUD' turnstileMocks.siteKey = 'cloud-site-key' const queryClient = createQueryClient() - vi.mocked(sendEMailLoginCode).mockResolvedValue({ result: 'success', data: 'new-login-token' }) + vi.mocked(consoleClient.emailCodeLogin.post).mockResolvedValue({ + result: 'success', + data: 'new-login-token', + }) render( @@ -434,11 +437,13 @@ describe('CheckCode', () => { await user.click(await screen.findByRole('button', { name: 'verify-turnstile-signin_code' })) await waitFor(() => { - expect(sendEMailLoginCode).toHaveBeenCalledWith( - 'user@example.com', - expect.any(String), - 'signin_code-token-2', - ) + expect(consoleClient.emailCodeLogin.post).toHaveBeenCalledWith({ + body: { + email: 'user@example.com', + language: expect.any(String), + turnstile_token: 'signin_code-token-2', + }, + }) }) await waitFor(() => { expect( @@ -456,7 +461,10 @@ describe('CheckCode', () => { turnstileMocks.deploymentEdition = 'CLOUD' turnstileMocks.siteKey = 'cloud-site-key' const queryClient = createQueryClient() - vi.mocked(sendEMailLoginCode).mockResolvedValue({ result: 'success', data: 'new-login-token' }) + vi.mocked(consoleClient.emailCodeLogin.post).mockResolvedValue({ + result: 'success', + data: 'new-login-token', + }) Object.defineProperty(window, 'turnstile', { configurable: true, value: undefined, @@ -491,11 +499,13 @@ describe('CheckCode', () => { await user.click(await screen.findByRole('button', { name: 'verify-turnstile-signin_code' })) await waitFor(() => { - expect(sendEMailLoginCode).toHaveBeenCalledWith( - 'user@example.com', - expect.any(String), - 'signin_code-token-1', - ) + expect(consoleClient.emailCodeLogin.post).toHaveBeenCalledWith({ + body: { + email: 'user@example.com', + language: expect.any(String), + turnstile_token: 'signin_code-token-1', + }, + }) }) }) @@ -503,7 +513,10 @@ describe('CheckCode', () => { const user = userEvent.setup() const queryClient = createQueryClient() turnstileMocks.siteKey = 'site-key-not-used-outside-cloud' - vi.mocked(sendEMailLoginCode).mockResolvedValue({ result: 'success', data: 'new-login-token' }) + vi.mocked(consoleClient.emailCodeLogin.post).mockResolvedValue({ + result: 'success', + data: 'new-login-token', + }) render( @@ -516,11 +529,9 @@ describe('CheckCode', () => { expect(resendButton).toBeEnabled() await user.click(resendButton) - expect(sendEMailLoginCode).toHaveBeenCalledWith( - 'user@example.com', - expect.any(String), - undefined, - ) + expect(consoleClient.emailCodeLogin.post).toHaveBeenCalledWith({ + body: { email: 'user@example.com', language: expect.any(String) }, + }) }) describe('Post-login profile bootstrap', () => { diff --git a/web/app/signin/check-code/page.tsx b/web/app/signin/check-code/page.tsx index b06e14fc3cdbc0..e78a63385427cd 100644 --- a/web/app/signin/check-code/page.tsx +++ b/web/app/signin/check-code/page.tsx @@ -10,13 +10,12 @@ import { useLocale } from '#i18n' import { trackEvent } from '@/app/components/base/amplitude' import Countdown from '@/app/components/signin/countdown' import { COUNT_DOWN_TIME_MS, useSetCountdownLeftTime } from '@/app/components/signin/storage' -import { toast } from '@/app/notifications' import { TURNSTILE_SITE_KEY } from '@/config' import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useDocumentTitle from '@/hooks/use-document-title' import { useRouter, useSearchParams } from '@/next/navigation' -import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common' +import { consoleClient } from '@/service/console' import { encryptVerificationCode } from '@/utils/encryption' import { replaceLoginRedirect } from '@/utils/login-redirect.client' import { getBrowserTimezone } from '@/utils/timezone' @@ -62,13 +61,15 @@ export default function CheckCode() { setLoading(true) shouldResetTurnstile = isTurnstileRequired - const ret = await emailLoginWithCode({ - email, - code: encryptVerificationCode(code), - token, - language, - timezone: getBrowserTimezone(), - ...(isTurnstileRequired ? { turnstile_token: verifyTurnstileToken } : {}), + const ret = await consoleClient.emailCodeLogin.validity.post({ + body: { + email, + code: encryptVerificationCode(code), + token, + language, + timezone: getBrowserTimezone(), + ...(isTurnstileRequired ? { turnstile_token: verifyTurnstileToken } : {}), + }, }) if (ret.result === 'success') { // Track login success event @@ -85,8 +86,6 @@ export default function CheckCode() { await queryClient.query(profileQueryOptions) replaceLoginRedirect(resolvePostLoginRedirect(searchParams), router.replace, basePath) } - } else { - toast.error(ret.data) } } catch (error) { console.error(error) @@ -106,11 +105,13 @@ export default function CheckCode() { const resendCode = async (turnstileToken?: string) => { setIsResending(true) try { - const ret = await sendEMailLoginCode( - email, - locale, - isTurnstileRequired ? turnstileToken : undefined, - ) + const ret = await consoleClient.emailCodeLogin.post({ + body: { + email, + language: locale, + ...(isTurnstileRequired ? { turnstile_token: turnstileToken } : {}), + }, + }) if (ret.result === 'success') { setCountdownLeftTime(`${COUNT_DOWN_TIME_MS}`) setCountdownGeneration((value) => value + 1) diff --git a/web/app/signin/components/__tests__/mail-and-code-auth.spec.tsx b/web/app/signin/components/__tests__/mail-and-code-auth.spec.tsx index 8eeacb304b0606..ba6cf9b1e93220 100644 --- a/web/app/signin/components/__tests__/mail-and-code-auth.spec.tsx +++ b/web/app/signin/components/__tests__/mail-and-code-auth.spec.tsx @@ -69,8 +69,13 @@ vi.mock('@/config', async () => { } }) -vi.mock('@/service/common', () => ({ - sendEMailLoginCode: (...args: unknown[]) => mocks.sendEMailLoginCode(...args), +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + emailCodeLogin: { + post: (...args: unknown[]) => mocks.sendEMailLoginCode(...args), + }, + }, })) describe('MailAndCodeAuth', () => { @@ -149,11 +154,9 @@ describe('MailAndCodeAuth', () => { await user.click(screen.getByRole('button', { name: 'login.signup.verifyMail' })) await waitFor(() => { - expect(mocks.sendEMailLoginCode).toHaveBeenCalledWith( - 'user@example.com', - 'en-US', - 'turnstile-token', - ) + expect(mocks.sendEMailLoginCode).toHaveBeenCalledWith({ + body: { email: 'user@example.com', language: 'en-US', turnstile_token: 'turnstile-token' }, + }) }) expect(mocks.push).toHaveBeenCalledWith(expect.stringContaining('/signin/check-code?')) }) @@ -177,11 +180,13 @@ describe('MailAndCodeAuth', () => { await user.click(continueButton) await waitFor(() => { - expect(mocks.sendEMailLoginCode).toHaveBeenCalledWith( - 'user@example.com', - 'en-US', - 'consumed-turnstile-token', - ) + expect(mocks.sendEMailLoginCode).toHaveBeenCalledWith({ + body: { + email: 'user@example.com', + language: 'en-US', + turnstile_token: 'consumed-turnstile-token', + }, + }) }) await waitFor(() => { expect(continueButton).toBeDisabled() @@ -195,11 +200,13 @@ describe('MailAndCodeAuth', () => { await user.click(continueButton) await waitFor(() => { - expect(mocks.sendEMailLoginCode).toHaveBeenLastCalledWith( - 'user@example.com', - 'en-US', - 'fresh-turnstile-token', - ) + expect(mocks.sendEMailLoginCode).toHaveBeenLastCalledWith({ + body: { + email: 'user@example.com', + language: 'en-US', + turnstile_token: 'fresh-turnstile-token', + }, + }) }) expect(mocks.push).toHaveBeenCalledWith(expect.stringContaining('/signin/check-code?')) }) diff --git a/web/app/signin/components/mail-and-code-auth.tsx b/web/app/signin/components/mail-and-code-auth.tsx index b57f56cbee2e82..3eb5d3de7ba355 100644 --- a/web/app/signin/components/mail-and-code-auth.tsx +++ b/web/app/signin/components/mail-and-code-auth.tsx @@ -10,7 +10,7 @@ import { COUNT_DOWN_TIME_MS, useSetCountdownLeftTime } from '@/app/components/si import { emailRegex, TURNSTILE_SITE_KEY } from '@/config' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useRouter, useSearchParams } from '@/next/navigation' -import { sendEMailLoginCode } from '@/service/common' +import { consoleClient } from '@/service/console' import Turnstile from './turnstile' type MailAndCodeAuthProps = { @@ -39,11 +39,13 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) { try { setLoading(true) shouldResetTurnstile = isTurnstileRequired - const ret = await sendEMailLoginCode( - email, - locale, - isTurnstileRequired ? turnstileToken : undefined, - ) + const ret = await consoleClient.emailCodeLogin.post({ + body: { + email, + language: locale, + ...(isTurnstileRequired ? { turnstile_token: turnstileToken } : {}), + }, + }) if (ret.result === 'success') { setCountdownLeftTime(`${COUNT_DOWN_TIME_MS}`) const params = new URLSearchParams(searchParams) diff --git a/web/app/signin/components/mail-and-password-auth.tsx b/web/app/signin/components/mail-and-password-auth.tsx index 48d890e2eaeccd..2aeee13575eb6b 100644 --- a/web/app/signin/components/mail-and-password-auth.tsx +++ b/web/app/signin/components/mail-and-password-auth.tsx @@ -13,9 +13,7 @@ import { toast } from '@/app/notifications' import { emailRegex } from '@/config' import Link from '@/next/link' import { useRouter, useSearchParams } from '@/next/navigation' -import { login } from '@/service/common' -import { consoleQuery } from '@/service/console' -import { setWebAppAccessToken } from '@/service/webapp-auth' +import { consoleClient, consoleQuery } from '@/service/console' import { encryptPassword } from '@/utils/encryption' import { replaceLoginRedirect } from '@/utils/login-redirect.client' import { basePath } from '@/utils/var' @@ -52,15 +50,8 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup }: MailAndP } if (isInvite) loginData.invite_token = decodeURIComponent(searchParams.get('invite_token') as string) - const res = await login({ - url: '/login', - body: loginData, - }) + const res = await consoleClient.login.post({ body: loginData }) if (res.result === 'success') { - if (res?.data?.access_token) { - // Track login success event - setWebAppAccessToken(res.data.access_token) - } trackEvent('user_login_success', { method: 'email_password', is_invite: isInvite, diff --git a/web/app/signin/invite-settings/__tests__/page.spec.tsx b/web/app/signin/invite-settings/__tests__/page.spec.tsx index ff98926162ee5d..9341dd85378236 100644 --- a/web/app/signin/invite-settings/__tests__/page.spec.tsx +++ b/web/app/signin/invite-settings/__tests__/page.spec.tsx @@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useLocale } from '#i18n' import { useRouter, useSearchParams } from '@/next/navigation' -import { activateMember } from '@/service/common' +import { consoleClient } from '@/service/console' import { useInvitationCheck } from '@/service/use-common' import { getBrowserTimezone } from '@/utils/timezone' import InviteSettingsPage from '../page' @@ -62,8 +62,13 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: vi.fn(), })) -vi.mock('@/service/common', () => ({ - activateMember: vi.fn(), +vi.mock('@/service/console', async (importOriginal) => ({ + ...(await importOriginal()), + consoleClient: { + activate: { + post: vi.fn(), + }, + }, })) vi.mock('@/service/use-common', () => ({ @@ -84,7 +89,7 @@ const mockRefetch = vi.fn() const mockUseLocale = useLocale as unknown as MockedFunction const mockUseRouter = useRouter as unknown as MockedFunction const mockUseSearchParams = useSearchParams as unknown as MockedFunction -const mockActivateMember = activateMember as unknown as MockedFunction +const mockActivateMember = vi.mocked(consoleClient.activate.post) const mockUseQuery = vi.mocked(useQuery) const mockUseInvitationCheck = useInvitationCheck as unknown as MockedFunction< typeof useInvitationCheck @@ -182,7 +187,6 @@ describe('InviteSettingsPage', () => { await waitFor(() => { expect(mockActivateMember).toHaveBeenCalledWith({ - url: '/activate', body: { token: 'invite-token', name: 'Invitee', @@ -204,7 +208,6 @@ describe('InviteSettingsPage', () => { await waitFor(() => { expect(mockActivateMember).toHaveBeenCalledWith({ - url: '/activate', body: { token: 'invite-token', name: 'Invitee', @@ -238,7 +241,6 @@ describe('InviteSettingsPage', () => { await waitFor(() => { expect(mockActivateMember).toHaveBeenCalledWith({ - url: '/activate', body: { token: 'invite-token', }, @@ -268,7 +270,6 @@ describe('InviteSettingsPage', () => { await waitFor(() => { expect(mockActivateMember).toHaveBeenCalledWith({ - url: '/activate', body: { token: 'invite-token', }, @@ -299,7 +300,6 @@ describe('InviteSettingsPage', () => { await waitFor(() => { expect(mockActivateMember).toHaveBeenCalledWith({ - url: '/activate', body: { token: 'invite-token', name: 'Invitee', diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx index 2882407050656e..8fd2090408e322 100644 --- a/web/app/signin/invite-settings/page.tsx +++ b/web/app/signin/invite-settings/page.tsx @@ -28,8 +28,7 @@ import { setLocaleOnClient } from '@/i18n/client' import { languages } from '@/i18n/language' import Link from '@/next/link' import { useRouter, useSearchParams } from '@/next/navigation' -import { activateMember } from '@/service/common' -import { consoleQuery } from '@/service/console' +import { consoleClient, consoleQuery } from '@/service/console' import { isUnauthorizedError } from '@/service/http-error' import { useInvitationCheck } from '@/service/use-common' import { replaceLoginRedirect } from '@/utils/login-redirect.client' @@ -156,10 +155,7 @@ export default function InviteSettingsPage() { : { token, } - const res = await activateMember({ - url: '/activate', - body, - }) + const res = await consoleClient.activate.post({ body }) if (res.result === 'success') { // Tokens are now stored in cookies by the backend if (requiresAccountSetup) await setLocaleOnClient(language!, false) diff --git a/web/app/signin/normal-form.tsx b/web/app/signin/normal-form.tsx index ec95dab3b73327..bf2ec7e84cd0cb 100644 --- a/web/app/signin/normal-form.tsx +++ b/web/app/signin/normal-form.tsx @@ -10,7 +10,7 @@ import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' import { useRouter, useSearchParams } from '@/next/navigation' -import { invitationCheck } from '@/service/common' +import { consoleClient } from '@/service/console' import { isUnauthorizedError } from '@/service/http-error' import { replaceLoginRedirect } from '@/utils/login-redirect.client' import { basePath } from '@/utils/var' @@ -58,13 +58,7 @@ function NormalForm() { isError: isInviteCheckError, } = useQuery({ queryKey: ['signin', 'invite-check', inviteToken], - queryFn: () => - invitationCheck({ - url: '/activate/check', - params: { - token: inviteToken, - }, - }), + queryFn: () => consoleClient.activate.check.get({ query: { token: inviteToken } }), enabled: isInviteLink, retry: false, refetchOnWindowFocus: false, diff --git a/web/service/common.spec.ts b/web/service/common.spec.ts index 97394bb2bd486e..26f1919c3aa775 100644 --- a/web/service/common.spec.ts +++ b/web/service/common.spec.ts @@ -8,7 +8,6 @@ import { REGISTRATION_SUCCESS_STORAGE_KEY, } from '@/app/components/base/amplitude/registration-session-state' import { clearPageLeaveGuards, registerPageLeaveGuard } from '@/utils/page-leave-guard' -import { emailLoginWithCode, sendEMailLoginCode } from './common' import { useLogout } from './use-common' const mocks = vi.hoisted(() => ({ @@ -34,63 +33,6 @@ vi.mock('@/utils/var', async (importOriginal) => ({ }, })) -describe('sendEMailLoginCode', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('includes the Turnstile token when provided', async () => { - await sendEMailLoginCode('user@example.com', 'en-US', 'turnstile-token') - - expect(mocks.post).toHaveBeenCalledWith('/email-code-login', { - body: { - email: 'user@example.com', - language: 'en-US', - turnstile_token: 'turnstile-token', - }, - }) - }) - - it('omits the Turnstile token when it is not provided', async () => { - await sendEMailLoginCode('user@example.com', 'en-US') - - expect(mocks.post).toHaveBeenCalledWith('/email-code-login', { - body: { - email: 'user@example.com', - language: 'en-US', - }, - }) - }) -}) - -describe('emailLoginWithCode', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('includes the verification-specific Turnstile token when provided', async () => { - await emailLoginWithCode({ - code: 'encrypted-code', - email: 'user@example.com', - language: 'en-US', - timezone: 'Asia/Singapore', - token: 'email-login-token', - turnstile_token: 'verify-turnstile-token', - }) - - expect(mocks.post).toHaveBeenCalledWith('/email-code-login/validity', { - body: { - code: 'encrypted-code', - email: 'user@example.com', - language: 'en-US', - timezone: 'Asia/Singapore', - token: 'email-login-token', - turnstile_token: 'verify-turnstile-token', - }, - }) - }) -}) - describe('useLogout', () => { const wrapper = ({ children }: { children: ReactNode }) => createElement(QueryClientProvider, { client: new QueryClient() }, children) diff --git a/web/service/common.ts b/web/service/common.ts index f02a8f26913f0d..937202cbcacecd 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -1,19 +1,5 @@ -import type { EmailCodeLoginPayload } from '@dify/contracts/api/console/email-code-login/types.gen' -import type { - PostWorkspacesInfoData, - PostWorkspacesInfoResponse, -} from '@dify/contracts/api/console/workspaces/types.gen' -import type { - DefaultModelResponse, - ModelParameterRule, - ModelTypeEnum, -} from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { - CommonResponse, - InitValidateStatusResponse, - SetupStatusResponse, -} from '@/models/common' -import { del, get, patch, post } from './base' +import type { CommonResponse } from '@/models/common' +import { post } from './base' type LoginSuccess = { result: 'success' @@ -26,15 +12,6 @@ type LoginFail = { message: string } type LoginResponse = LoginSuccess | LoginFail -export const login = ({ - url, - body, -}: { - url: string - body: Record -}): Promise => { - return post(url, { body }) -} export const webAppLogin = ({ url, body, @@ -45,133 +22,6 @@ export const webAppLogin = ({ return post(url, { body }, { isPublicAPI: true }) } -export const setup = ({ body }: { body: Record }): Promise => { - return post('/setup', { body }) -} - -export const initValidate = ({ body }: { body: Record }): Promise => { - return post('/init', { body }) -} - -export const fetchInitValidateStatus = (): Promise => { - return get('/init') -} - -export const fetchSetupStatus = (): Promise => { - return get('/setup') -} -export const deleteMemberOrCancelInvitation = ({ - url, -}: { - url: string -}): Promise => { - return del(url) -} - -export const sendOwnerEmail = (body: { - language?: string -}): Promise => - post( - '/workspaces/current/members/send-owner-transfer-confirm-email', - { body }, - ) - -export const verifyOwnerEmail = (body: { - code: string - token: string -}): Promise => - post( - '/workspaces/current/members/owner-transfer-check', - { body }, - ) - -export const ownershipTransfer = ( - memberID: string, - body: { token: string }, -): Promise => - post( - `/workspaces/current/members/${memberID}/owner-transfer`, - { body }, - ) - -export const updateWorkspaceInfo = ({ - url, - body, -}: { - url: PostWorkspacesInfoData['url'] - body: PostWorkspacesInfoData['body'] -}): Promise => { - return post(url, { body }) -} - -type InvitationCheckData = { - workspace_name: string - email: string - workspace_id: string - account_status?: string - requires_setup?: boolean -} - -type ActivateMemberBody = { - token: string - name?: string - interface_language?: string - timezone?: string -} - -export const invitationCheck = ({ - url, - params, -}: { - url: string - params: { workspace_id?: string; email?: string; token: string } -}): Promise => { - return get(url, { params }) -} - -export const activateMember = ({ - url, - body, -}: { - url: string - body: ActivateMemberBody -}): Promise => { - return post(url, { body }) -} - -export const fetchDefaultModal = (url: string): Promise<{ data: DefaultModelResponse }> => { - return get<{ data: DefaultModelResponse }>(url) -} - -export const fetchModelParameterRules = (url: string): Promise<{ data: ModelParameterRule[] }> => { - return get<{ data: ModelParameterRule[] }>(url) -} - -export const enableModel = ( - url: string, - body: { model: string; model_type: ModelTypeEnum }, -): Promise => patch(url, { body }) - -export const disableModel = ( - url: string, - body: { model: string; model_type: ModelTypeEnum }, -): Promise => patch(url, { body }) - -export const sendForgotPasswordEmail = ({ - url, - body, -}: { - url: string - body: { email: string } -}): Promise => - post(url, { body }) -export const changePasswordWithToken = ({ - url, - body, -}: { - url: string - body: { token: string; new_password: string; password_confirm: string } -}): Promise => post(url, { body }) export const changeWebAppPasswordWithToken = ({ url, body, @@ -193,37 +43,6 @@ export const uploadRemoteFileInfo = ( ) } -export const sendEMailLoginCode = ( - email: string, - language = 'en-US', - turnstileToken?: string, -): Promise => - post('/email-code-login', { - body: { - email, - language, - ...(turnstileToken === undefined ? {} : { turnstile_token: turnstileToken }), - }, - }) - -export const emailLoginWithCode = (data: EmailCodeLoginPayload): Promise => - post('/email-code-login/validity', { body: data }) - -export const sendResetPasswordCode = ( - email: string, - language = 'en-US', -): Promise => - post('/forgot-password', { - body: { email, language }, - }) - -export const verifyResetPasswordCode = (body: { - email: string - code: string - token: string -}): Promise => - post('/forgot-password/validity', { body }) - export const sendWebAppEMailLoginCode = ( email: string, language = 'en-US', @@ -262,46 +81,6 @@ export const verifyWebAppResetPasswordCode = (body: { { isPublicAPI: true }, ) -export const sendDeleteAccountCode = (): Promise => - get('/account/delete/verify') - -export const verifyDeleteAccountCode = (body: { - code: string - token: string -}): Promise => - post('/account/delete', { body }) - -export const submitDeleteAccountFeedback = (body: { - feedback: string - email: string -}): Promise => post('/account/delete/feedback', { body }) - -export const getDocDownloadUrl = (doc_name: string): Promise<{ url: string }> => - get<{ url: string }>('/compliance/download', { params: { doc_name } }, { silent: true }) - -export const sendVerifyCode = (body: { - email: string - phase: string - token?: string -}): Promise => - post('/account/change-email', { body }) - -export const verifyEmail = (body: { - email: string - code: string - token: string -}): Promise => - post( - '/account/change-email/validity', - { body }, - ) - -export const resetEmail = (body: { new_email: string; token: string }): Promise => - post('/account/change-email/reset', { body }) - -export const checkEmailExisted = (body: { email: string }): Promise => - post('/account/change-email/check-email-unique', { body }, { silent: true }) - export const getAvatar = async ({ avatar, }: { diff --git a/web/utils/completion-params.ts b/web/utils/completion-params.ts index 7e15021c31c0f7..86f7ed1ecf40ee 100644 --- a/web/utils/completion-params.ts +++ b/web/utils/completion-params.ts @@ -2,6 +2,7 @@ import type { FormValue, ModelParameterRule, } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { normalizeModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/utils' export const mergeValidCompletionParams = ( oldParams: FormValue | undefined, @@ -82,10 +83,13 @@ export const fetchModelParameterRulesForModel = async ( provider: string, modelId: string, ): Promise => { - const { fetchModelParameterRules } = await import('@/service/common') - const url = `/workspaces/current/model-providers/${provider}/models/parameter-rules?model=${modelId}` - const { data: parameterRules } = await fetchModelParameterRules(url) - return parameterRules ?? [] + const { consoleClient } = await import('@/service/console') + const { data: parameterRules } = + await consoleClient.workspaces.current.modelProviders.byProvider.models.parameterRules.get({ + params: { provider }, + query: { model: modelId }, + }) + return (parameterRules ?? []).map(normalizeModelParameterRule) } export const fetchAndMergeValidCompletionParams = async ( From c812d246191965f068715e4f72bd0882e17c2073 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:20:09 +0800 Subject: [PATCH 13/60] refactor(web): migrate service/use-common queries and mutations to the console contract File upload limits, signup mail flow, account init, invitation checks, file preview, schema definitions, code-based extensions, structured output generation, notion binding and model parameter rules now use consoleQuery at their call sites; use-common.ts keeps only logout and one documented exception. Backend contract fixes: - Code-based extension responses list their items instead of `Any`. - Structured output generation documents its `{output, error}` body. Notes: - The forgot-password token check is left on the handwritten request: the endpoint requires `email` and `code`, which the token-only link flow does not have, so that request cannot succeed as written. - Model provider details keep a feature-owned options factory with a documented cast; its credential schemas are untyped JSON in the API. Co-Authored-By: Claude Opus 5.5 --- api/controllers/console/app/generator.py | 12 +- api/controllers/console/extension.py | 8 +- .../controllers/console/test_extension.py | 2 +- oxlint-suppressions.json | 7 +- .../console/code-based-extension/types.gen.ts | 12 +- .../console/code-based-extension/zod.gen.ts | 25 ++- .../types.gen.ts | 7 +- .../zod.gen.ts | 9 +- web/app/activate/activateForm.tsx | 19 +- .../config-modal/__tests__/index.spec.tsx | 34 ++-- .../config-var/config-modal/index.tsx | 6 +- .../__tests__/use-configuration.spec.tsx | 16 +- .../configuration/hooks/use-configuration.ts | 5 +- .../external-data-tool-modal.spec.tsx | 63 +++--- .../tools/external-data-tool-modal.tsx | 7 +- .../chat-input-area/__tests__/index.spec.tsx | 5 +- .../__tests__/index.spec.tsx | 11 +- .../file-upload/__tests__/index.spec.tsx | 9 +- .../__tests__/setting-modal.spec.tsx | 9 +- .../image-upload/__tests__/index.spec.tsx | 9 +- .../moderation/__tests__/index.spec.tsx | 10 +- ...deration-setting-modal-submission.spec.tsx | 13 +- .../moderation-setting-modal.spec.tsx | 25 ++- .../new-feature-panel/moderation/index.tsx | 5 +- .../moderation/moderation-setting-modal.tsx | 10 +- .../__tests__/input-field-keyboard.spec.tsx | 9 +- .../hooks/__tests__/use-upload.spec.tsx | 23 ++- .../common/image-uploader/hooks/use-upload.ts | 5 +- .../__tests__/index.spec.tsx | 21 +- .../__tests__/index.spec.tsx | 21 +- .../hooks/__tests__/use-file-upload.spec.tsx | 10 +- .../file-uploader/hooks/use-file-upload.ts | 3 +- .../__tests__/index.spec.tsx | 15 +- .../__tests__/use-local-file-upload.spec.tsx | 22 +- .../documents/create-from-pipeline/index.tsx | 3 +- .../preview/__tests__/file-preview.spec.tsx | 17 +- .../preview/file-preview.tsx | 9 +- .../steps/__tests__/preview-panel.spec.tsx | 16 +- .../__tests__/csv-uploader.spec.tsx | 14 +- .../detail/batch-modal/csv-uploader.tsx | 5 +- .../__tests__/hooks.spec.ts | 14 +- .../model-provider-page/hooks.ts | 6 +- .../__tests__/index.spec.tsx | 23 ++- .../model-parameter-modal/index.tsx | 9 +- .../__tests__/popup-item.spec.tsx | 9 +- .../__tests__/index.spec.tsx | 5 +- .../__tests__/provider-card-actions.spec.tsx | 3 +- .../provider-added-card/index.tsx | 5 +- .../__tests__/index.spec.tsx | 6 +- .../provider-card-actions.tsx | 5 +- .../model-provider-page/utils.ts | 30 +++ .../__tests__/use-refresh-plugin-list.spec.ts | 3 +- .../hooks/use-refresh-plugin-list.tsx | 5 +- .../app-selector/__tests__/index.spec.tsx | 119 ++++++----- .../use-app-inputs-form-schema.spec.ts | 34 ++-- .../hooks/use-app-inputs-form-schema.ts | 3 +- .../__tests__/llm-params-panel.spec.tsx | 17 +- .../model-selector/llm-params-panel.tsx | 7 +- .../editor/form/__tests__/hooks.spec.ts | 11 +- .../editor/form/__tests__/index.spec.tsx | 19 +- .../panel/input-field/editor/form/hooks.ts | 5 +- .../panel/input-field/editor/form/index.tsx | 5 +- .../tools/provider/__tests__/detail.spec.tsx | 10 +- web/app/components/tools/provider/detail.tsx | 2 +- .../__tests__/file-support.spec.tsx | 22 +- .../_base/components/file-upload-setting.tsx | 5 +- .../variable/use-match-schema-type.ts | 21 +- .../data-source/__tests__/panel.spec.tsx | 5 + .../structure-output.browser.spec.tsx | 12 +- .../__tests__/json-schema-config.spec.tsx | 8 +- .../json-schema-generator/index.tsx | 17 +- .../__tests__/value-content-sections.spec.tsx | 20 +- .../signin/__tests__/one-more-step.spec.tsx | 20 +- .../invite-settings/__tests__/page.spec.tsx | 35 ++-- web/app/signin/invite-settings/page.tsx | 9 +- web/app/signin/one-more-step.tsx | 15 +- .../signup/check-code/__tests__/page.spec.tsx | 35 ++-- web/app/signup/check-code/page.tsx | 24 ++- web/app/signup/components/input-mail.spec.tsx | 26 ++- web/app/signup/components/input-mail.tsx | 14 +- .../set-password/__tests__/page.spec.tsx | 32 +-- web/app/signup/set-password/page.tsx | 22 +- .../advanced/content-moderation.tsx | 5 +- .../files/__tests__/index.spec.tsx | 125 ++++++------ .../orchestrate/files/upload-dialog.tsx | 5 +- .../model-config/__tests__/field.spec.tsx | 71 ++++--- .../skills/__tests__/index.spec.tsx | 185 +++++++++-------- .../orchestrate/skills/upload-dialog.tsx | 5 +- web/features/code-based-extension/query.ts | 20 ++ .../__tests__/detail-page-builder.spec.tsx | 5 +- .../skills/__tests__/detail-page.fixture.tsx | 3 + web/hooks/use-pay.tsx | 12 +- web/models/common.ts | 16 +- web/service/annotation.spec.ts | 11 +- web/service/use-common.ts | 191 +----------------- web/test/console/operation-hooks.ts | 21 ++ web/test/fixtures/file-upload-config.ts | 19 ++ 97 files changed, 1055 insertions(+), 867 deletions(-) create mode 100644 web/features/code-based-extension/query.ts create mode 100644 web/test/fixtures/file-upload-config.ts diff --git a/api/controllers/console/app/generator.py b/api/controllers/console/app/generator.py index eab25fc7e8fe2d..53ea0bc804772b 100644 --- a/api/controllers/console/app/generator.py +++ b/api/controllers/console/app/generator.py @@ -211,6 +211,11 @@ class GeneratorResponse(RootModel[Any]): root: Any +class StructuredOutputGenerateResponse(ResponseModel): + output: str = Field(description="Generated JSON Schema, empty when generation failed") + error: str = Field(description="Failure reason, empty on success") + + register_enum_models(console_ns, LLMMode) register_schema_models( console_ns, @@ -226,6 +231,7 @@ class GeneratorResponse(RootModel[Any]): register_response_schema_models( console_ns, GeneratorResponse, + StructuredOutputGenerateResponse, SimpleDataResponse, WorkflowGenerateResponse, WorkflowGenerateRejectedResponse, @@ -323,7 +329,11 @@ class RuleStructuredOutputGenerateApi(Resource): @console_ns.doc("generate_structured_output") @console_ns.doc(description="Generate structured output rules using LLM") @console_ns.expect(console_ns.models[RuleStructuredOutputPayload.__name__]) - @console_ns.response(200, "Structured output generated successfully", console_ns.models[GeneratorResponse.__name__]) + @console_ns.response( + 200, + "Structured output generated successfully", + console_ns.models[StructuredOutputGenerateResponse.__name__], + ) @console_ns.response(400, "Invalid request parameters") @setup_required @login_required diff --git a/api/controllers/console/extension.py b/api/controllers/console/extension.py index 2b064daf85578c..164202f9dc7e97 100644 --- a/api/controllers/console/extension.py +++ b/api/controllers/console/extension.py @@ -35,9 +35,15 @@ class APIBasedExtensionPayload(BaseModel): api_key: str = Field(description="API key for authentication") +class CodeBasedExtensionItemResponse(ResponseModel): + name: str + label: dict[str, Any] | None = None + form_schema: list[dict[str, Any]] | None = None + + class CodeBasedExtensionResponse(ResponseModel): module: str = Field(description="Module name") - data: Any = Field(description="Extension data") + data: list[CodeBasedExtensionItemResponse] = Field(description="Non-builtin extensions of the module") def _mask_api_key(api_key: str) -> str: diff --git a/api/tests/unit_tests/controllers/console/test_extension.py b/api/tests/unit_tests/controllers/console/test_extension.py index 93cb11d5cd2579..5abfed64232c79 100644 --- a/api/tests/unit_tests/controllers/console/test_extension.py +++ b/api/tests/unit_tests/controllers/console/test_extension.py @@ -120,7 +120,7 @@ def extensions_service() -> Iterator[MagicMock]: def test_code_based_extension_get_returns_service_data(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - service_result = [{"entrypoint": "main:agent"}] + service_result = [{"name": "custom_tool", "label": {"en-US": "Custom tool"}, "form_schema": []}] service_mock = MagicMock(return_value=service_result) monkeypatch.setattr( "controllers.console.extension.CodeBasedExtensionService.get_code_based_extension", diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 21bb6a5ec492d5..2a6461a252aee5 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4034,11 +4034,6 @@ "count": 1 } }, - "web/service/annotation.spec.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "web/service/annotation.ts": { "no-restricted-imports": { "count": 1 @@ -4171,7 +4166,7 @@ }, "web/service/use-common.ts": { "@tanstack/query/prefer-query-options": { - "count": 8 + "count": 1 }, "dify/require-disable-directive-description": { "count": 1 diff --git a/packages/contracts/generated/api/console/code-based-extension/types.gen.ts b/packages/contracts/generated/api/console/code-based-extension/types.gen.ts index ac7b79a9b32a57..379aeddd89c54f 100644 --- a/packages/contracts/generated/api/console/code-based-extension/types.gen.ts +++ b/packages/contracts/generated/api/console/code-based-extension/types.gen.ts @@ -5,7 +5,7 @@ export type ClientOptions = { } export type CodeBasedExtensionResponse = { - data: unknown + data: Array module: string } @@ -19,6 +19,16 @@ export type ErrorResponse = { status: number } +export type CodeBasedExtensionItemResponse = { + form_schema?: Array<{ + [key: string]: unknown + }> | null + label?: { + [key: string]: unknown + } | null + name: string +} + export type GetCodeBasedExtensionData = { body?: never path?: never diff --git a/packages/contracts/generated/api/console/code-based-extension/zod.gen.ts b/packages/contracts/generated/api/console/code-based-extension/zod.gen.ts index 4dfe9fa793e169..c6950da4f58e93 100644 --- a/packages/contracts/generated/api/console/code-based-extension/zod.gen.ts +++ b/packages/contracts/generated/api/console/code-based-extension/zod.gen.ts @@ -2,14 +2,6 @@ import * as z from 'zod' -/** - * CodeBasedExtensionResponse - */ -export const zCodeBasedExtensionResponse = z.object({ - data: z.unknown(), - module: z.string(), -}) - /** * ErrorResponse * @@ -26,6 +18,23 @@ export const zErrorResponse = z.object({ status: z.int(), }) +/** + * CodeBasedExtensionItemResponse + */ +export const zCodeBasedExtensionItemResponse = z.object({ + form_schema: z.array(z.record(z.string(), z.unknown())).nullish(), + label: z.record(z.string(), z.unknown()).nullish(), + name: z.string(), +}) + +/** + * CodeBasedExtensionResponse + */ +export const zCodeBasedExtensionResponse = z.object({ + data: z.array(zCodeBasedExtensionItemResponse), + module: z.string(), +}) + export const zGetCodeBasedExtensionQuery = z.object({ module: z.string(), }) diff --git a/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts b/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts index e2c3b371874b64..7b69be91cd00b1 100644 --- a/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts @@ -9,7 +9,10 @@ export type RuleStructuredOutputPayload = { model_config: ModelConfig } -export type GeneratorResponse = unknown +export type StructuredOutputGenerateResponse = { + error: string + output: string +} export type CompletionRequestError = ErrorResponse & { code: 'completion_request_error' @@ -73,7 +76,7 @@ export type PostRuleStructuredOutputGenerateError = PostRuleStructuredOutputGenerateErrors[keyof PostRuleStructuredOutputGenerateErrors] export type PostRuleStructuredOutputGenerateResponses = { - 200: GeneratorResponse + 200: StructuredOutputGenerateResponse } export type PostRuleStructuredOutputGenerateResponse = diff --git a/packages/contracts/generated/api/console/rule-structured-output-generate/zod.gen.ts b/packages/contracts/generated/api/console/rule-structured-output-generate/zod.gen.ts index b6014692261115..c0a0ad54867f95 100644 --- a/packages/contracts/generated/api/console/rule-structured-output-generate/zod.gen.ts +++ b/packages/contracts/generated/api/console/rule-structured-output-generate/zod.gen.ts @@ -3,9 +3,12 @@ import * as z from 'zod' /** - * GeneratorResponse + * StructuredOutputGenerateResponse */ -export const zGeneratorResponse = z.unknown() +export const zStructuredOutputGenerateResponse = z.object({ + error: z.string(), + output: z.string(), +}) /** * ErrorResponse @@ -101,4 +104,4 @@ export const zPostRuleStructuredOutputGenerateBody = zRuleStructuredOutputPayloa /** * Structured output generated successfully */ -export const zPostRuleStructuredOutputGenerateResponse = zGeneratorResponse +export const zPostRuleStructuredOutputGenerateResponse = zStructuredOutputGenerateResponse diff --git a/web/app/activate/activateForm.tsx b/web/app/activate/activateForm.tsx index 32e2c586a5cfff..c725cf2dd61583 100644 --- a/web/app/activate/activateForm.tsx +++ b/web/app/activate/activateForm.tsx @@ -1,12 +1,13 @@ 'use client' import { buttonVariants } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { skipToken, useQuery } from '@tanstack/react-query' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import useDocumentTitle from '@/hooks/use-document-title' import { useRouter, useSearchParams } from '@/next/navigation' -import { useInvitationCheck } from '@/service/use-common' +import { consoleQuery } from '@/service/console' const ActivateForm = () => { const router = useRouter() @@ -24,12 +25,11 @@ const ActivateForm = () => { token, }, } - const { data: checkRes } = useInvitationCheck( - { - ...checkParams.params, - token: token || undefined, - }, - true, + const { data: checkRes } = useQuery( + consoleQuery.activate.check.get.queryOptions({ + input: token ? { query: { ...checkParams.params, token } } : skipToken, + retry: false, + }), ) useDocumentTitle( checkRes?.is_valid === false @@ -40,9 +40,8 @@ const ActivateForm = () => { useEffect(() => { if (checkRes?.is_valid) { const params = new URLSearchParams(searchParams) - const { email, workspace_id } = checkRes.data - params.set('email', encodeURIComponent(email)) - params.set('workspace_id', encodeURIComponent(workspace_id)) + params.set('email', encodeURIComponent(checkRes.data?.email ?? '')) + params.set('workspace_id', encodeURIComponent(checkRes.data?.workspace_id ?? '')) params.set('invite_token', encodeURIComponent(token as string)) router.replace(`/signin?${params.toString()}`) } diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx index 07d7fbf2b736df..aaeb4c60c4d851 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx @@ -4,13 +4,14 @@ import userEvent from '@testing-library/user-event' import * as React from 'react' import { toast } from '@/app/components/app/configuration/toast' import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' -import { commonQueryKeys } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import { createConsoleQueryClient, renderWithConsoleQuery, seedAppDetail, } from '@/test/console/query-data' import { createAppDetailFixture } from '@/test/fixtures/app' +import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config' import { AppModeEnum, TransferMethod } from '@/types/app' import ConfigModal from '../index' @@ -19,11 +20,6 @@ vi.mock('@/next/navigation', async (importOriginal) => ({ useParams: () => ({ appId: 'app-1' }), })) -vi.mock('@/service/use-common', async () => ({ - ...(await vi.importActual('@/service/use-common')), - useFileUploadConfig: () => ({ data: undefined }), -})) - vi.mock('@monaco-editor/react', async () => ({ ...(await vi.importActual('@monaco-editor/react')), default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( @@ -38,6 +34,11 @@ vi.mock('@monaco-editor/react', async () => ({ const render = (ui: React.ReactElement) => { const queryClient = createConsoleQueryClient() seedAppDetail(queryClient, createAppDetailFixture({ id: 'app-1', mode: AppModeEnum.CHAT })) + // Upload limits stay pending unless a case seeds them. + void queryClient.query({ + queryKey: consoleQuery.files.upload.get.queryKey(), + queryFn: () => new Promise(() => {}), + }) return renderWithConsoleQuery(ui, { queryClient }) } @@ -393,15 +394,18 @@ describe('ConfigModal', () => { onConfirm={onConfirm} />, ) - queryClient.setQueryData(commonQueryKeys.fileUploadConfig, { - batch_count_limit: 5, - image_file_batch_limit: 10, - single_chunk_attachment_limit: 10, - attachment_image_file_size_limit: 2, - file_size_limit: 15, - file_upload_limit: 5, - workflow_file_upload_limit: 3, - }) + queryClient.setQueryData( + consoleQuery.files.upload.get.queryKey(), + createFileUploadConfigFixture({ + batch_count_limit: 5, + image_file_batch_limit: 10, + single_chunk_attachment_limit: 10, + attachment_image_file_size_limit: 2, + file_size_limit: 15, + file_upload_limit: 5, + workflow_file_upload_limit: 3, + }), + ) await user.click(screen.getByRole('button', { name: 'common.operation.save' })) diff --git a/web/app/components/app/configuration/config-var/config-modal/index.tsx b/web/app/components/app/configuration/config-var/config-modal/index.tsx index 8d585984a6c6dd..b057d30a59f4c8 100644 --- a/web/app/components/app/configuration/config-var/config-modal/index.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/index.tsx @@ -3,7 +3,6 @@ import type { ChangeEvent, FC } from 'react' import type { Item as SelectItem } from './type-select' import type { ConfigModalValidationError } from './utils' import type { InputVar, InputVarType, MoreInfo } from '@/app/components/workflow/types' -import type { FileUploadConfigResponse } from '@/models/common' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { skipToken, useQuery, useQueryClient } from '@tanstack/react-query' import * as React from 'react' @@ -14,7 +13,6 @@ import { toast } from '@/app/components/app/configuration/toast' import ConfigContext from '@/context/debug-configuration' import { useParams } from '@/next/navigation' import { consoleQuery } from '@/service/console' -import { commonQueryKeys } from '@/service/use-common' import { AppModeEnum } from '@/types/app' import { checkKeys, @@ -178,9 +176,7 @@ const ConfigModal: FC = ({ ) const handleConfirm = () => { - const fileUploadConfig = queryClient.getQueryData( - commonQueryKeys.fileUploadConfig, - ) + const fileUploadConfig = queryClient.getQueryData(consoleQuery.files.upload.get.queryKey()) const { errorMessage, errorField, moreInfo, payloadToSave } = validateConfigModalPayload({ tempPayload, payload, diff --git a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx index 4067aaece94cc4..d70a0aa446029a 100644 --- a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx +++ b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx @@ -16,6 +16,15 @@ import { AppModeEnum, ModelModeType } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import { useConfiguration } from '../use-configuration' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': () => ({ + data: undefined, + }), + }) +}) + let mockAppPermissionKeys: string[] = [AppACLPermission.Edit, AppACLPermission.ReleaseAndVersion] const createRouteAppDetail = (overrides: Partial = {}) => @@ -110,12 +119,6 @@ vi.mock('nuqs', async (importOriginal) => { return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } }) -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ - data: undefined, - }), -})) - vi.mock('@/hooks/use-breakpoints', () => ({ __esModule: true, default: () => 'desktop', @@ -191,6 +194,7 @@ vi.mock('@/service/console', async () => { ...actual, consoleQuery: { ...actual.consoleQuery, + files: actual.consoleQuery.files, account: actual.consoleQuery.account, apps: { byAppId: { diff --git a/web/app/components/app/configuration/hooks/use-configuration.ts b/web/app/components/app/configuration/hooks/use-configuration.ts index ba65d4a62b89da..151e82463a491b 100644 --- a/web/app/components/app/configuration/hooks/use-configuration.ts +++ b/web/app/components/app/configuration/hooks/use-configuration.ts @@ -4,6 +4,7 @@ import type { ConfigurationViewModel } from './configuration-view-model' import type { OnFeaturesChange } from '@/app/components/base/features/types' import type { Collection } from '@/app/components/tools/types' import type { Inputs, ModelConfig, PromptConfig, PromptVariable } from '@/models/debug' +import { useQuery } from '@tanstack/react-query' import { useBoolean } from 'ahooks' import { produce } from 'immer' import { useQueryState } from 'nuqs' @@ -24,7 +25,7 @@ import { } from '@/app/components/header/account-setting/query-params' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { PromptMode } from '@/models/debug' -import { useFileUploadConfig } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import { AppModeEnum, ModelModeType } from '@/types/app' import { supportFunctionCall } from '@/utils/tool-call' import { buildConfigurationFeaturesData, getConfigurationPublishingState } from '../utils' @@ -55,7 +56,7 @@ export const useConfiguration = (): ConfigurationViewModel => { serverLatestPublishedAt, updateModelConfig, } = useConfigurationAppContext() - const { data: fileUploadConfigResponse } = useFileUploadConfig() + const { data: fileUploadConfigResponse } = useQuery(consoleQuery.files.upload.get.queryOptions()) const [showAppConfigureFeaturesModal, setShowAppConfigureFeaturesModal] = useState(false) const [formattingChanged, setFormattingChanged] = useState(false) const [hasFetchedDetail, setHasFetchedDetail] = useState(false) diff --git a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx index 319744604744c4..e3e4a60fa09b41 100644 --- a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx +++ b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx @@ -2,6 +2,39 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { mockEmojiData, renderWithEmoji as render } from '@/test/emoji-picker' import ExternalDataToolModal from '../external-data-tool-modal' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'codeBasedExtension.get': () => ({ + data: { + data: [ + { + name: 'code-tool', + label: { + 'en-US': 'Code Provider', + 'zh-Hans': '代码提供方', + }, + form_schema: [ + { + variable: 'api_key', + default: 'default-key', + required: true, + type: 'text', + placeholder: '', + options: [], + label: { + 'en-US': 'API Key', + 'zh-Hans': '接口密钥', + }, + }, + ], + }, + ], + }, + }), + }) +}) + const mockToastError = vi.fn() let mockLocale = 'en-US' @@ -20,36 +53,6 @@ vi.mock('#i18n', async (importOriginal) => ({ useLocale: () => mockLocale, })) -vi.mock('@/service/use-common', () => ({ - useCodeBasedExtensions: () => ({ - data: { - data: [ - { - name: 'code-tool', - label: { - 'en-US': 'Code Provider', - 'zh-Hans': '代码提供方', - }, - form_schema: [ - { - variable: 'api_key', - default: 'default-key', - required: true, - type: 'text', - placeholder: '', - options: [], - label: { - 'en-US': 'API Key', - 'zh-Hans': '接口密钥', - }, - }, - ], - }, - ], - }, - }), -})) - vi.mock('@/app/components/base/app-icon', () => ({ default: ({ onClick }: { onClick: () => void }) => ( diff --git a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx index cfa2c4bff0ac77..2bfd2667422f93 100644 --- a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx +++ b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx @@ -11,6 +11,7 @@ import { SelectTrigger, SelectValue, } from '@langgenius/dify-ui/select' +import { useQuery } from '@tanstack/react-query' import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -25,7 +26,7 @@ import { } from '@/app/components/base/icon-picker' import { ApiBasedExtensionSelector } from '@/app/components/header/account-setting/api-based-extension-page/selector' import { useDocLink } from '@/context/i18n' -import { useCodeBasedExtensions } from '@/service/use-common' +import { codeBasedExtensionsQueryOptions } from '@/features/code-based-extension/query' import { buildProviders, formatExternalDataTool, @@ -49,7 +50,9 @@ const ExternalDataToolModal: FC = ({ const docLink = useDocLink() const locale = useLocale() const [localeData, setLocaleData] = useState(data.type ? data : { ...data, type: 'api' }) - const { data: codeBasedExtensionList } = useCodeBasedExtensions('external_data_tool') + const { data: codeBasedExtensionList } = useQuery( + codeBasedExtensionsQueryOptions('external_data_tool'), + ) const providers = buildProviders({ codeBasedExtensionList, diff --git a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx index 821e87a95f0946..ae656b60105b19 100644 --- a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx @@ -6,6 +6,7 @@ import userEvent from '@testing-library/user-event' import * as React from 'react' import { transcribeAudio } from '@/app/components/base/voice-input/api' import { startVoiceRecorder } from '@/app/components/base/voice-input/recorder' +import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config' import { TransferMethod } from '@/types/app' import ChatInputArea from '../index' @@ -174,7 +175,7 @@ vi.mock('@/next/navigation', () => ({ // --------------------------------------------------------------------------- const mockVisionConfig: FileUpload = { enabled: true, - fileUploadConfig: { + fileUploadConfig: createFileUploadConfigFixture({ image_file_size_limit: 10, file_size_limit: 10, audio_file_size_limit: 10, @@ -185,7 +186,7 @@ const mockVisionConfig: FileUpload = { single_chunk_attachment_limit: 0, attachment_image_file_size_limit: 0, file_upload_limit: 0, - }, + }), allowed_file_types: [], allowed_file_extensions: [], number_limits: 3, diff --git a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx index 159787b56a64d8..a66373148b05af 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx @@ -5,6 +5,13 @@ import { renderWithConsoleQuery } from '@/test/console/query-data' import { FeaturesProvider } from '../../context' import NewFeaturePanel from '../index' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'codeBasedExtension.get': () => ({ data: undefined }), + }) +}) + vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => '/app/test-app-id/configuration', @@ -62,10 +69,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec ModelSelector: () =>
Model Selector
, })) -vi.mock('@/service/use-common', () => ({ - useCodeBasedExtensions: () => ({ data: undefined }), -})) - const defaultFeatures: Features = { moreLikeThis: { enabled: false }, opening: { enabled: false }, diff --git a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/index.spec.tsx index c1a1a88e7171ea..198acd3b06d080 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/index.spec.tsx @@ -3,9 +3,12 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { FeaturesProvider } from '../../../context' import FileUpload from '../index' -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ data: undefined }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': () => ({ data: undefined }), + }) +}) const defaultFeatures: Features = { moreLikeThis: { enabled: false }, diff --git a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx index 68b18fe209a08b..0cdbab950cc6a5 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx @@ -4,9 +4,12 @@ import { TransferMethod } from '@/types/app' import { FeaturesProvider } from '../../../context' import FileUploadSettings from '../setting-modal' -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ data: undefined }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': () => ({ data: undefined }), + }) +}) const defaultFeatures: Features = { moreLikeThis: { enabled: false }, diff --git a/web/app/components/base/features/new-feature-panel/image-upload/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/image-upload/__tests__/index.spec.tsx index b86e1172484d84..810581eeff163f 100644 --- a/web/app/components/base/features/new-feature-panel/image-upload/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/image-upload/__tests__/index.spec.tsx @@ -3,9 +3,12 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { FeaturesProvider } from '../../../context' import ImageUpload from '../index' -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ data: undefined }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': () => ({ data: undefined }), + }) +}) const defaultFeatures: Features = { moreLikeThis: { enabled: false }, diff --git a/web/app/components/base/features/new-feature-panel/moderation/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/__tests__/index.spec.tsx index 90f2cda16ee205..043080402b4824 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/__tests__/index.spec.tsx @@ -13,9 +13,6 @@ vi.mock('@/context/modal-context', () => ({ setShowModerationSettingModal: mockSetShowModerationSettingModal, }), })) -vi.mock('@/service/use-common', () => ({ - useCodeBasedExtensions: () => ({ data: { data: mockCodeBasedExtensionData } }), -})) const defaultFeatures: Features = { moreLikeThis: { enabled: false }, @@ -41,6 +38,13 @@ const renderWithProvider = ( ) } +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'codeBasedExtension.get': () => ({ data: { data: mockCodeBasedExtensionData } }), + }) +}) + describe('Moderation', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal-submission.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal-submission.spec.tsx index d539f41148fa40..c9d4d4dbebedad 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal-submission.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal-submission.spec.tsx @@ -6,6 +6,14 @@ import userEvent from '@testing-library/user-event' import { renderWithConsoleQuery } from '@/test/console/query-data' import ModerationSettingModal from '../moderation-setting-modal' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'codeBasedExtension.get': () => ({ data: { data: [] } }), + 'workspaces.current.modelProviders.get': () => ({ data: { data: [] }, isPending: false }), + }) +}) + const { getExtensions, createExtension } = vi.hoisted(() => ({ getExtensions: vi.fn(), createExtension: vi.fn(), @@ -29,11 +37,6 @@ vi.mock('@/service/console', async (importOriginal) => { return { ...actual, consoleClient, consoleQuery: createConsoleQuery(consoleClient) } }) -vi.mock('@/service/use-common', () => ({ - useCodeBasedExtensions: () => ({ data: { data: [] } }), - useModelProviderDetails: () => ({ data: { data: [] }, isPending: false }), -})) - vi.mock('nuqs', async (importOriginal) => ({ ...(await importOriginal()), useQueryState: () => [null, vi.fn()], diff --git a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx index bec6a0864a9a77..c77f1d2819c6d1 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx @@ -50,14 +50,15 @@ let mockModelProvidersData: { refetch: vi.fn(), } -vi.mock('@/service/use-common', () => ({ - useCodeBasedExtensions: () => mockCodeBasedExtensions, - useModelProviderDetails: () => mockModelProvidersData, -})) - -vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({ - CustomConfigurationStatusEnum: { active: 'active' }, -})) +vi.mock( + '@/app/components/header/account-setting/model-provider-page/declarations', + async (importOriginal) => ({ + ...(await importOriginal< + typeof import('@/app/components/header/account-setting/model-provider-page/declarations') + >()), + CustomConfigurationStatusEnum: { active: 'active' }, + }), +) vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({ ApiBasedExtensionSelector: ({ onChange }: { value: string; onChange: (v: string) => void }) => ( @@ -79,6 +80,14 @@ const defaultData: ModerationConfig = { }, } +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'codeBasedExtension.get': () => mockCodeBasedExtensions, + 'workspaces.current.modelProviders.get': () => mockModelProvidersData, + }) +}) + describe('ModerationSettingModal', () => { const onSave = vi.fn() const renderModal = async (ui: React.ReactElement) => { diff --git a/web/app/components/base/features/new-feature-panel/moderation/index.tsx b/web/app/components/base/features/new-feature-panel/moderation/index.tsx index d979de50ca2b59..634e87649f7bf9 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/index.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/index.tsx @@ -1,6 +1,7 @@ import type { OnFeaturesChange } from '@/app/components/base/features/types' import { Button } from '@langgenius/dify-ui/button' import { RiEqualizer2Line } from '@remixicon/react' +import { useQuery } from '@tanstack/react-query' import { produce } from 'immer' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -9,7 +10,7 @@ import { useFeatures, useFeaturesStore } from '@/app/components/base/features/ho import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card' import { FeatureEnum } from '@/app/components/base/features/types' import { useModalContext } from '@/context/modal-context' -import { useCodeBasedExtensions } from '@/service/use-common' +import { codeBasedExtensionsQueryOptions } from '@/features/code-based-extension/query' type Props = Readonly<{ disabled?: boolean @@ -22,7 +23,7 @@ const Moderation = ({ disabled, onChange }: Props) => { const locale = useLocale() const featuresStore = useFeaturesStore() const moderation = useFeatures((s) => s.features.moderation) - const { data: codeBasedExtensionList } = useCodeBasedExtensions('moderation') + const { data: codeBasedExtensionList } = useQuery(codeBasedExtensionsQueryOptions('moderation')) const [isHovering, setIsHovering] = useState(false) const handleOpenModerationSettingModal = () => { diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx index cac0f95ebb57d4..6ba79b9ef6474d 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx @@ -6,20 +6,22 @@ import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { Separator } from '@langgenius/dify-ui/separator' import { Textarea } from '@langgenius/dify-ui/textarea' +import { useQuery } from '@tanstack/react-query' import { useQueryState } from 'nuqs' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' import { ApiBasedExtensionSelector } from '@/app/components/header/account-setting/api-based-extension-page/selector' import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { modelProviderDetailsQueryOptions } from '@/app/components/header/account-setting/model-provider-page/utils' import { settingsQueryParamName, settingsQueryParser, } from '@/app/components/header/account-setting/query-params' import { toast } from '@/app/notifications' import { useDocLink } from '@/context/i18n' +import { codeBasedExtensionsQueryOptions } from '@/features/code-based-extension/query' import { LanguagesSupported } from '@/i18n/language' -import { useCodeBasedExtensions, useModelProviderDetails } from '@/service/use-common' import FormGeneration from './form-generation' import ModerationContent from './moderation-content' @@ -60,7 +62,9 @@ const ModerationSettingModal: FC = ({ data, onCance const { t } = useTranslation(['appDebug', 'common', 'navigation']) const docLink = useDocLink() const locale = useLocale() - const { data: modelProviders, isPending: isLoading } = useModelProviderDetails() + const { data: modelProviders, isPending: isLoading } = useQuery( + modelProviderDetailsQueryOptions(), + ) const localeDataRef = useRef(data) const [localeData, setLocaleData] = useState(data) const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser) @@ -80,7 +84,7 @@ const ModerationSettingModal: FC = ({ data, onCance const handleOpenSettingsModal = () => { setSettingsDestination('provider') } - const { data: codeBasedExtensionList } = useCodeBasedExtensions('moderation') + const { data: codeBasedExtensionList } = useQuery(codeBasedExtensionsQueryOptions('moderation')) const openaiProvider = modelProviders?.data.find( (item) => item.provider === 'langgenius/openai/openai', ) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field-keyboard.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field-keyboard.spec.tsx index 300e8554f93728..2c3dad2fb71fa2 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field-keyboard.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field-keyboard.spec.tsx @@ -6,9 +6,12 @@ import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/ import { TransferMethod } from '@/types/app' import InputField from '../input-field' -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ data: undefined }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': () => ({ data: undefined }), + }) +}) it('lets the extension input commit its tag before the field shortcut saves', async () => { const user = userEvent.setup() diff --git a/web/app/components/datasets/common/image-uploader/hooks/__tests__/use-upload.spec.tsx b/web/app/components/datasets/common/image-uploader/hooks/__tests__/use-upload.spec.tsx index 2abf79ace6ba84..f25b0a7d9e164c 100644 --- a/web/app/components/datasets/common/image-uploader/hooks/__tests__/use-upload.spec.tsx +++ b/web/app/components/datasets/common/image-uploader/hooks/__tests__/use-upload.spec.tsx @@ -7,20 +7,23 @@ import { toast } from '@/app/notifications' import { FileContextProvider } from '../../store' import { useUpload } from '../use-upload' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': vi.fn(() => ({ + data: { + image_file_batch_limit: 10, + single_chunk_attachment_limit: 20, + attachment_image_file_size_limit: 15, + }, + })), + }) +}) + const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn(), })) -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: vi.fn(() => ({ - data: { - image_file_batch_limit: 10, - single_chunk_attachment_limit: 20, - attachment_image_file_size_limit: 15, - }, - })), -})) - vi.mock('@/app/notifications', () => ({ toast: { error: mockToastError, diff --git a/web/app/components/datasets/common/image-uploader/hooks/use-upload.ts b/web/app/components/datasets/common/image-uploader/hooks/use-upload.ts index 99986b8b93f8bd..65b17dabc2e4c6 100644 --- a/web/app/components/datasets/common/image-uploader/hooks/use-upload.ts +++ b/web/app/components/datasets/common/image-uploader/hooks/use-upload.ts @@ -1,11 +1,12 @@ import type { FileEntity, FileUploadConfig } from '../types' +import { useQuery } from '@tanstack/react-query' import { produce } from 'immer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { v4 as uuid4 } from 'uuid' import { fileUpload, getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils' import { toast } from '@/app/notifications' -import { useFileUploadConfig } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import { ACCEPT_TYPES } from '../constants' import { useFileStore } from '../store' import { getFileType, getFileUploadConfig, traverseFileEntry } from '../utils' @@ -19,7 +20,7 @@ export const useUpload = () => { const dragRef = useRef(null) const dropRef = useRef(null) - const { data: fileUploadConfigResponse } = useFileUploadConfig() + const { data: fileUploadConfigResponse } = useQuery(consoleQuery.files.upload.get.queryOptions()) const fileUploadConfig: FileUploadConfig = useMemo(() => { return getFileUploadConfig(fileUploadConfigResponse) diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/index.spec.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/index.spec.tsx index 96619e874ef37f..29e7c2dbfdacd8 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/__tests__/index.spec.tsx @@ -3,15 +3,18 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vite-plus/test' import ImageUploaderInChunkWrapper from '../index' -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: vi.fn(() => ({ - data: { - image_file_batch_limit: 10, - single_chunk_attachment_limit: 20, - attachment_image_file_size_limit: 15, - }, - })), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': vi.fn(() => ({ + data: { + image_file_batch_limit: 10, + single_chunk_attachment_limit: 20, + attachment_image_file_size_limit: 15, + }, + })), + }) +}) vi.mock('@/app/components/datasets/common/image-previewer', () => ({ default: ({ onClose }: { onClose: () => void }) => ( diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/index.spec.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/index.spec.tsx index 0daf4e9086e3e9..6f8b4b7633bbf1 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/__tests__/index.spec.tsx @@ -3,15 +3,18 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vite-plus/test' import ImageUploaderInRetrievalTestingWrapper from '../index' -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: vi.fn(() => ({ - data: { - image_file_batch_limit: 10, - single_chunk_attachment_limit: 20, - attachment_image_file_size_limit: 15, - }, - })), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': vi.fn(() => ({ + data: { + image_file_batch_limit: 10, + single_chunk_attachment_limit: 20, + attachment_image_file_size_limit: 15, + }, + })), + }) +}) vi.mock('@/app/components/datasets/common/image-previewer', () => ({ default: ({ onClose }: { onClose: () => void }) => ( diff --git a/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx b/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx index 570969458b003b..c05053fb3da6b9 100644 --- a/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx @@ -41,9 +41,6 @@ const supportedFormats = { allowed_extensions: ['pdf', 'docx', 'txt', 'md'], } -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ data: mockFileUploadConfig }), -})) vi.mock('@/i18n/language', () => ({ LanguagesSupported: ['en-US', 'zh-Hans'], })) @@ -72,6 +69,13 @@ const render = (ui: ReactElement) => systemFeatures: { deployment_edition: 'CLOUD' }, }) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': () => ({ data: mockFileUploadConfig }), + }) +}) + describe('useFileUpload', () => { const defaultOptions = { fileList: [] as FileItem[], diff --git a/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts b/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts index dbdff6af6da468..c3c3b79862afd5 100644 --- a/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts +++ b/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts @@ -11,7 +11,6 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { LanguagesSupported } from '@/i18n/language' import { upload } from '@/service/base' import { consoleQuery } from '@/service/console' -import { useFileUploadConfig } from '@/service/use-common' import { getFileExtension } from '@/utils/format' import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants' @@ -85,7 +84,7 @@ export const useFileUpload = ({ const hideUpload = !supportBatchUpload && fileList.length > 0 - const { data: fileUploadConfigResponse } = useFileUploadConfig() + const { data: fileUploadConfigResponse } = useQuery(consoleQuery.files.upload.get.queryOptions()) const { data: supportFileTypesResponse } = useQuery( consoleQuery.files.supportType.get.queryOptions(), ) diff --git a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx index 92c28d97a32e6f..29b8c57c277676 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx @@ -3,6 +3,15 @@ import { consoleQuery } from '@/service/console' import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import CreateFromPipeline from '../index' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': () => ({ + data: { file_size_limit: 15, batch_count_limit: 5 }, + }), + }) +}) + const mockPlan = { usage: { vectorSpace: 50 }, total: { vectorSpace: 100 }, @@ -89,12 +98,6 @@ vi.mock('@/next/navigation', () => ({ }), })) -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ - data: { file_size_limit: 15, batch_count_limit: 5 }, - }), -})) - vi.mock('@/service/use-pipeline', () => ({ usePublishedPipelineInfo: () => ({ data: { graph: { nodes: [] } }, diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx index c6a61851df3ab3..b0a1574eb50121 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx @@ -10,6 +10,19 @@ import { } from '@/test/console/query-data' import { PROGRESS_ERROR } from '../../constants' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': vi.fn(() => ({ + data: { + file_size_limit: 15, + batch_count_limit: 5, + file_upload_limit: 10, + }, + })), + }) +}) + const { mockNotify, mockToast } = vi.hoisted(() => { const mockNotify = vi.fn() const mockToast = Object.assign(mockNotify, { @@ -63,15 +76,6 @@ vi.mock('../../../store', () => ({ })) // Mock file upload config -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: vi.fn(() => ({ - data: { - file_size_limit: 15, - batch_count_limit: 5, - file_upload_limit: 10, - }, - })), -})) // Mock upload service const mockUpload = vi.fn() diff --git a/web/app/components/datasets/documents/create-from-pipeline/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/index.tsx index 60f4b51ccb4c3e..6b91bdae0cc647 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/index.tsx @@ -21,7 +21,6 @@ import { deploymentEditionAtom } from '@/features/system-features/state' import { DatasourceType } from '@/models/pipeline' import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/console' -import { useFileUploadConfig } from '@/service/use-common' import { usePublishedPipelineInfo } from '@/service/use-pipeline' import { getDatasetACLCapabilities } from '@/utils/permission' import { useDataSourceStore } from './data-source/store' @@ -78,7 +77,7 @@ const CreateFormPipeline = () => { const { data: pipelineInfo, isFetching: isFetchingPipelineInfo } = usePublishedPipelineInfo( pipelineId || '', ) - const { data: fileUploadConfigResponse } = useFileUploadConfig() + const { data: fileUploadConfigResponse } = useQuery(consoleQuery.files.upload.get.queryOptions()) const fileUploadConfig = useMemo( () => ({ diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/file-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/file-preview.spec.tsx index 445644c7e266f8..178d9e8f2d7073 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/file-preview.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/file-preview.spec.tsx @@ -6,13 +6,6 @@ import FilePreview from '../file-preview' const mockFileData = { content: 'file content here with some text' } let mockIsFetching = false -vi.mock('@/service/use-common', () => ({ - useFilePreview: () => ({ - data: mockIsFetching ? undefined : mockFileData, - isFetching: mockIsFetching, - }), -})) - vi.mock('../../../../common/document-file-icon', () => ({ default: () => , })) @@ -21,6 +14,16 @@ vi.mock('../loading', () => ({ default: () =>
, })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.byFileId.preview.get': () => ({ + data: mockIsFetching ? undefined : mockFileData, + isFetching: mockIsFetching, + }), + }) +}) + describe('FilePreview', () => { const defaultProps = { file: { diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.tsx index 89d09301305891..aff61aec0e8cd4 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.tsx @@ -1,10 +1,11 @@ 'use client' import type { CustomFile as File } from '@/models/datasets' import { RiCloseLine } from '@remixicon/react' +import { skipToken, useQuery } from '@tanstack/react-query' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useFilePreview } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import { formatFileSize, formatNumberAbbreviated } from '@/utils/format' import DocumentFileIcon from '../../../common/document-file-icon' import Loading from './loading' @@ -16,7 +17,11 @@ type FilePreviewProps = { const FilePreview = ({ file, hidePreview }: FilePreviewProps) => { const { t } = useTranslation(['datasetPipeline']) - const { data: fileData, isFetching } = useFilePreview(file.id || '') + const { data: fileData, isFetching } = useQuery( + consoleQuery.files.byFileId.preview.get.queryOptions({ + input: file.id ? { params: { file_id: file.id } } : skipToken, + }), + ) const fileName = useMemo(() => { if (!file) return '' diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx index 0eae8df4f6b19d..014f1ee1507c0e 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx @@ -13,6 +13,16 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { DatasourceType } from '@/models/pipeline' import { StepOnePreview, StepTwoPreview } from '../preview-panel' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.byFileId.preview.get': vi.fn(() => ({ + data: { content: 'Mock file content for testing' }, + isFetching: false, + })), + }) +}) + // Mock context hooks (底层依赖) vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: vi.fn((selector: (state: unknown) => unknown) => { @@ -28,12 +38,6 @@ vi.mock('@/context/dataset-detail', () => ({ })) // Mock API hooks (底层依赖) -vi.mock('@/service/use-common', () => ({ - useFilePreview: vi.fn(() => ({ - data: { content: 'Mock file content for testing' }, - isFetching: false, - })), -})) vi.mock('@/service/use-pipeline', () => ({ usePreviewOnlineDocument: vi.fn(() => ({ diff --git a/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx index d8de2327aa854a..061dcdb61b1c59 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx @@ -4,6 +4,15 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { Theme } from '@/types/app' import CSVUploader from '../csv-uploader' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': () => ({ + data: { file_size_limit: 15 }, + }), + }) +}) + // Mock upload service const mockUpload = vi.fn() vi.mock('@/service/base', () => ({ @@ -11,11 +20,6 @@ vi.mock('@/service/base', () => ({ })) // Mock useFileUploadConfig -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ - data: { file_size_limit: 15 }, - }), -})) // Mock useTheme vi.mock('@/hooks/use-theme', () => ({ diff --git a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx b/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx index ea879a192e7689..df0565031a2b75 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx @@ -4,6 +4,7 @@ import type { FileItem } from '@/models/datasets' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiDeleteBinLine } from '@remixicon/react' +import { useQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -12,7 +13,7 @@ import SimplePieChart from '@/app/components/base/simple-pie-chart' import { toast } from '@/app/notifications' import useTheme from '@/hooks/use-theme' import { upload } from '@/service/base' -import { useFileUploadConfig } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import { Theme } from '@/types/app' type Props = Readonly<{ @@ -25,7 +26,7 @@ const CSVUploader: FC = ({ file, updateFile }) => { const dropRef = useRef(null) const dragRef = useRef(null) const fileUploader = useRef(null) - const { data: fileUploadConfigResponse } = useFileUploadConfig() + const { data: fileUploadConfigResponse } = useQuery(consoleQuery.files.upload.get.queryOptions()) const fileUploadConfig = useMemo( () => ({ ...fileUploadConfigResponse, diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts b/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts index 77a357bd54d945..0ec65df363afeb 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts @@ -48,14 +48,6 @@ vi.mock('@tanstack/react-query', () => ({ })), })) -vi.mock('@/service/use-common', () => ({ - commonQueryKeys: { - modelProviders: ['model-providers'], - modelProviderDetails: ['model-provider-details'], - defaultModel: (type: string) => ['default-model', type], - }, -})) - vi.mock('@/context/modal-context', () => ({ useModalContextSelector: vi.fn((selector) => { const state = { setShowModelModal: vi.fn() } @@ -717,7 +709,7 @@ describe('hooks', () => { queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(), }) expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ['model-provider-details'], + queryKey: consoleQuery.workspaces.current.modelProviders.get.key(), }) }) @@ -1020,7 +1012,7 @@ describe('hooks', () => { queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(), }) expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ['model-provider-details'], + queryKey: consoleQuery.workspaces.current.modelProviders.get.key(), }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: getModelListQueryKey(ModelTypeEnum.textGeneration), @@ -1168,7 +1160,7 @@ describe('hooks', () => { queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(), }) expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ['model-provider-details'], + queryKey: consoleQuery.workspaces.current.modelProviders.get.key(), }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: getModelListQueryKey(ModelTypeEnum.textGeneration), diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.ts b/web/app/components/header/account-setting/model-provider-page/hooks.ts index 79ba94e7b51caa..e8e2d3fed01b61 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.ts @@ -24,9 +24,9 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types' import { useModalContextSelector } from '@/context/modal-context' import { getModelLanguage } from '@/i18n/metadata' import { consoleQuery } from '@/service/console' -import { commonQueryKeys, modelProviderDetailsQueryOptions } from '@/service/use-common' import { useExpandModelProviderList } from './atoms' import { CustomConfigurationStatusEnum, ModelStatusEnum, ModelTypeEnum } from './declarations' +import { modelProviderDetailsQueryOptions } from './utils' type UseDefaultModelAndModelList = ( defaultModel: DefaultModelResponse | undefined, @@ -225,7 +225,9 @@ export const useUpdateModelProviders = () => { queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(), }) - queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviderDetails }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.modelProviders.get.key(), + }) }, [queryClient]) return updateModelProviders diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx index 4296ca60102dce..581ee1ab25d450 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx @@ -42,16 +42,6 @@ let activeTextGenerationModelList: Array> = [ }, ] -vi.mock('@/service/use-common', () => ({ - useModelParameterRules: () => ({ - data: { - data: parameterRules, - }, - isLoading: isRulesLoading, - isPending: isRulesPending, - }), -})) - vi.mock('../../hooks', () => ({ useTextGenerationCurrentProviderAndModelAndModelList: () => ({ currentProvider, @@ -151,6 +141,19 @@ vi.mock('@/config', async (importOriginal) => { } }) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.modelProviders.byProvider.models.parameterRules.get': () => ({ + data: { + data: parameterRules, + }, + isLoading: isRulesLoading, + isPending: isRulesPending, + }), + }) +}) + describe('ModelParameterModal', () => { const openSettings = () => fireEvent.click(screen.getByRole('button', { name: /modelProvider\.modelSettings/i })) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx index 5da1cfc3c92f76..7e462e0b8715cc 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx @@ -17,14 +17,15 @@ import { PopoverTitle, PopoverTrigger, } from '@langgenius/dify-ui/popover' +import { useQuery } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE } from '@/config' -import { useModelParameterRules } from '@/service/use-common' import { ModelStatusEnum } from '../declarations' import { useTextGenerationCurrentProviderAndModelAndModelList } from '../hooks' import { ModelSelector, SplitModelSelector } from '../model-selector' +import { modelParameterRulesQueryOptions } from '../utils' import { ModelSettingsTrigger } from './model-settings-trigger' import ParameterItem from './parameter-item' import PresetsParameter from './presets-parameter' @@ -95,10 +96,8 @@ const ModelParameterModal: FC = ({ useTextGenerationCurrentProviderAndModelAndModelList({ provider, model: modelId }) const canFetchParameterRules = !!currentProvider && currentModel?.status === ModelStatusEnum.active - const { data: parameterRulesData, isLoading } = useModelParameterRules( - provider, - modelId, - canFetchParameterRules, + const { data: parameterRulesData, isLoading } = useQuery( + modelParameterRulesQueryOptions({ provider, model: modelId, enabled: canFetchParameterRules }), ) const isRulesLoading = canFetchParameterRules && !!provider && !!modelId && isLoading const selectableModelList = modelList ?? activeTextGenerationModelList diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx index 4cae762dce4619..ca8044f43eb6bd 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx @@ -4,13 +4,13 @@ import type { ProviderWithModelsResponse, } from '@dify/contracts/api/console/workspaces/types.gen' import type { ReactElement, ReactNode } from 'react' -import type { DefaultModel } from '../../declarations' +import type { DefaultModel, ModelProvider } from '../../declarations' import type { ModelSelectorPreviewPayload } from '../popup-item' import { createPreviewCardHandle } from '@langgenius/dify-ui/preview-card' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { modelProviderDetailsQueryOptions } from '@/app/components/header/account-setting/model-provider-page/utils' import { consoleQuery } from '@/service/console' -import { commonQueryKeys } from '@/service/use-common' import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { ConfigurationMethodEnum, @@ -178,8 +178,9 @@ const createPopupItemNode = (node: ReactElement) => node const renderPopupItem = (node: ReactElement) => { const queryClient = createConsoleQueryClient() - queryClient.setQueryData(commonQueryKeys.modelProviderDetails, { - data: [makeProvider()], + queryClient.setQueryData(modelProviderDetailsQueryOptions().queryKey, { + // Only the fields the popup reads are seeded. + data: [makeProvider() as unknown as ModelProvider], }) queryClient.setQueryData(consoleQuery.workspaces.current.modelProviders.summary.get.queryKey(), { ...mockProviderSummary(), diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx index 31919f124d7766..3e1327d9717cb4 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ import { QueryClient } from '@tanstack/react-query' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { PluginCategoryEnum } from '@/app/components/plugins/types' -import { commonQueryKeys } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import { createQueryClientWrapper } from '@/test/console/query-client' import { seedSystemFeatures } from '@/test/console/query-data' import { render } from '@/test/console/render' @@ -51,6 +51,7 @@ vi.mock('@/service/console', () => ({ workspaces: { current: { modelProviders: { + get: { key: () => [['console', 'workspaces', 'current', 'modelProviders', 'get']] }, summary: { get: { key: () => ['model-provider-summary'] } }, byProvider: { models: { @@ -280,7 +281,7 @@ describe('ProviderAddedCard', () => { expect(mockInvalidateInstalledPluginList).toHaveBeenCalledWith(PluginCategoryEnum.model) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-provider-summary'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ - queryKey: commonQueryKeys.modelProviderDetails, + queryKey: consoleQuery.workspaces.current.modelProviders.get.key(), }) expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/provider-card-actions.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/provider-card-actions.spec.tsx index 7f84564a72d3b4..1ded58e4ff4cb4 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/provider-card-actions.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/provider-card-actions.spec.tsx @@ -7,7 +7,6 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { PluginSource } from '@/app/components/plugins/types' import { consoleQuery } from '@/service/console' -import { commonQueryKeys } from '@/service/use-common' import { renderWithConsoleQuery } from '@/test/console/query-data' import ProviderCardActions from '../provider-card-actions' @@ -286,7 +285,7 @@ describe('ProviderCardActions', () => { queryKey: consoleQuery.workspaces.current.plugin.list.latestVersions.post.key(), }) expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: commonQueryKeys.modelProviderDetails, + queryKey: consoleQuery.workspaces.current.modelProviders.get.key(), }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['marketplacePlugins'] }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['marketplaceCollectionPlugins'] }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx index f2c6bfcec4c904..083e059a5ce97e 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx @@ -14,7 +14,6 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import { renderI18nObject } from '@/i18n/metadata' import { consoleQuery } from '@/service/console' -import { commonQueryKeys } from '@/service/use-common' import { useInvalidateInstalledPluginList } from '@/service/use-plugins' import { hasPermission } from '@/utils/permission' import { useModelProviderListExpanded, useSetModelProviderListExpanded } from '../atoms' @@ -128,7 +127,9 @@ const ProviderAddedCard: FC = ({ queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(), }), - queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviderDetails }), + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.modelProviders.get.key(), + }), invalidateInstalledPluginList(PluginCategoryEnum.model), ]) }, [invalidateInstalledPluginList, queryClient]) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/index.spec.tsx index b22ff59c73e4e2..92b7a5f1bf1b0a 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/index.spec.tsx @@ -2,7 +2,7 @@ import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/w import type { ModelProvider } from '../../../declarations' import type { CredentialPanelState } from '../../use-credential-panel-state' import { act, fireEvent, screen, waitFor } from '@testing-library/react' -import { commonQueryKeys } from '@/service/use-common' +import { modelProviderDetailsQueryOptions } from '@/app/components/header/account-setting/model-provider-page/utils' import { renderWithConsoleQuery } from '@/test/console/query-data' import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../../declarations' import ModelAuthDropdown from '../index' @@ -233,6 +233,8 @@ describe('ModelAuthDropdown', () => { fireEvent.click(screen.getByRole('button', { name: /addApiKey/i })) expect(await screen.findByRole('status')).toBeInTheDocument() + // The console client loads its contract segment before the first request starts. + await waitFor(() => expect(resolveFirstRequest).toBeDefined()) await act(async () => { resolveFirstRequest?.(new Response(null, { status: 500 })) }) @@ -379,7 +381,7 @@ describe('ModelAuthDropdown', () => { fireEvent.click(screen.getByRole('button', { name: /config/i })) await waitFor(() => expect(screen.getByText('Key 1')).toBeInTheDocument()) - rendered.queryClient.setQueryData(commonQueryKeys.modelProviderDetails, { + rendered.queryClient.setQueryData(modelProviderDetailsQueryOptions().queryKey, { data: [nextProvider], }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx index 5807bf6888fb30..3f4a224c770f4c 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx @@ -31,7 +31,6 @@ import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-v import useTheme from '@/hooks/use-theme' import { consoleQuery } from '@/service/console' import { uninstallPlugin } from '@/service/plugins' -import { commonQueryKeys } from '@/service/use-common' import { normalizeInstalledPluginDetail } from '@/service/use-plugins' import { getMarketplaceUrl } from '@/utils/var' @@ -75,7 +74,9 @@ const ProviderCardActions: FC = (props) => { queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.plugin.list.latestVersions.post.key(), }), - queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviderDetails }), + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.modelProviders.get.key(), + }), queryClient.invalidateQueries({ queryKey: ['marketplacePlugins'] }), queryClient.invalidateQueries({ queryKey: ['marketplaceCollectionPlugins'] }), ]) diff --git a/web/app/components/header/account-setting/model-provider-page/utils.ts b/web/app/components/header/account-setting/model-provider-page/utils.ts index 368a712ee10ffa..1b45decf3c527a 100644 --- a/web/app/components/header/account-setting/model-provider-page/utils.ts +++ b/web/app/components/header/account-setting/model-provider-page/utils.ts @@ -12,8 +12,11 @@ import type { CredentialFormSchemaTextInput, ModelItem, ModelParameterRule, + ModelProvider, TypeWithI18N, } from './declarations' +import { queryOptions, skipToken } from '@tanstack/react-query' +import { consoleClient, consoleQuery } from '@/service/console' import { ModelProviderQuotaGetPaid } from '@/types/model-provider' import openaiLogo from './assets/openai-small.svg' import tongyiLogo from './assets/tongyi.svg' @@ -220,3 +223,30 @@ export const normalizeModelParameterRule = (rule: ParameterRule): ModelParameter use_template: rule.use_template ?? undefined, options: rule.options, }) + +export const modelParameterRulesQueryOptions = ({ + provider, + model, + enabled = true, +}: { + provider?: string + model?: string + enabled?: boolean +}) => + consoleQuery.workspaces.current.modelProviders.byProvider.models.parameterRules.get.queryOptions({ + input: provider && model && enabled ? { params: { provider }, query: { model } } : skipToken, + context: { silent: true }, + select: (response) => ({ data: response.data.map(normalizeModelParameterRule) }), + }) + +// Credential schemas are untyped JSON in the console API response; ModelProvider is the +// UI's narrowed view of the provider list. +export const modelProviderDetailsQueryOptions = () => + queryOptions({ + // Keeps the provider-list prefix so contract invalidations still reach the UI view. + queryKey: [...consoleQuery.workspaces.current.modelProviders.get.key(), 'ui-details'], + queryFn: async () => + (await consoleClient.workspaces.current.modelProviders.get({})) as unknown as { + data: ModelProvider[] + }, + }) diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts index 300a1c64257a3f..eef47a63a77bef 100644 --- a/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts @@ -4,7 +4,6 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { createElement } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { consoleQuery } from '@/service/console' -import { commonQueryKeys } from '@/service/use-common' import { PluginCategoryEnum } from '../../../types' import useRefreshPluginList from '../use-refresh-plugin-list' @@ -52,7 +51,7 @@ const strategyKeys = [ ] const providerKeys = [ current.modelProviders.summary.get.key(), - commonQueryKeys.modelProviderDetails, + consoleQuery.workspaces.current.modelProviders.get.key(), ] const datasourceKey = consoleQuery.rag.pipelines.datasourcePlugins.get.queryKey() const unrelatedKey = consoleQuery.account.profile.get.key() diff --git a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx index 9b62ae969fdcb0..584c3703b7026e 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx +++ b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx @@ -3,7 +3,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useInvalidateDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { consoleQuery } from '@/service/console' -import { commonQueryKeys } from '@/service/use-common' import { useInvalidDataSourceListAuth } from '@/service/use-datasource' import { useInvalidateCheckInstalled, @@ -104,7 +103,9 @@ const useRefreshPluginList = () => { queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(), }) - queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviderDetails }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.modelProviders.get.key(), + }) refetchLLMModelList() refetchEmbeddingModelList() refetchRerankModelList() diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx index 8e239b9f95506f..c1c00c1631ce2c 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx @@ -9,6 +9,18 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { AppModeEnum } from '@/types/app' import { AppSelector } from '../index' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': () => ({ + data: undefined, + isError: false, + isFetching: false, + refetch: vi.fn(), + }), + }) +}) + const apps = [ { id: 'app-1', @@ -42,67 +54,62 @@ const apps = [ const mockAppDetailQuery = vi.hoisted(() => vi.fn()) const mockUseAppWorkflow = vi.hoisted(() => vi.fn()) -vi.mock('@/service/console', () => ({ - consoleQuery: { - apps: { - get: { - infiniteOptions: ({ - input, - getNextPageParam, - initialPageParam, - placeholderData, - }: { - input: (pageParam: number) => { query: { name?: string } } - getNextPageParam: (lastPage: { has_more: boolean; page: number }) => number | undefined - initialPageParam: number - placeholderData: unknown - }) => ({ - queryKey: ['apps', input(1).query], - queryFn: ({ pageParam = initialPageParam }: { pageParam?: number }) => { - const query = input(Number(pageParam)).query - const keyword = query.name?.toLowerCase() ?? '' - const filteredApps = keyword - ? apps.filter((app) => app.name.toLowerCase().includes(keyword)) - : apps - - return { - data: filteredApps, - has_more: false, - page: Number(pageParam), - } - }, - getNextPageParam, - initialPageParam, - placeholderData, - }), - }, - byAppId: { +vi.mock('@/service/console', async () => { + const { consoleOperationStub } = await import('@/test/console/operation-hooks') + return { + consoleQuery: { + files: { upload: { get: consoleOperationStub('files.upload.get') } }, + apps: { get: { - queryOptions: ({ input }: { input: unknown }) => { - const appId = - typeof input === 'object' && input && 'params' in input - ? (input.params as { app_id: string }).app_id - : undefined - return { - queryKey: ['apps', appId], - queryFn: () => mockAppDetailQuery(appId), - enabled: !!appId, - } + infiniteOptions: ({ + input, + getNextPageParam, + initialPageParam, + placeholderData, + }: { + input: (pageParam: number) => { query: { name?: string } } + getNextPageParam: (lastPage: { has_more: boolean; page: number }) => number | undefined + initialPageParam: number + placeholderData: unknown + }) => ({ + queryKey: ['apps', input(1).query], + queryFn: ({ pageParam = initialPageParam }: { pageParam?: number }) => { + const query = input(Number(pageParam)).query + const keyword = query.name?.toLowerCase() ?? '' + const filteredApps = keyword + ? apps.filter((app) => app.name.toLowerCase().includes(keyword)) + : apps + + return { + data: filteredApps, + has_more: false, + page: Number(pageParam), + } + }, + getNextPageParam, + initialPageParam, + placeholderData, + }), + }, + byAppId: { + get: { + queryOptions: ({ input }: { input: unknown }) => { + const appId = + typeof input === 'object' && input && 'params' in input + ? (input.params as { app_id: string }).app_id + : undefined + return { + queryKey: ['apps', appId], + queryFn: () => mockAppDetailQuery(appId), + enabled: !!appId, + } + }, }, }, }, }, - }, -})) - -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ - data: undefined, - isError: false, - isFetching: false, - refetch: vi.fn(), - }), -})) + } +}) vi.mock('@/service/use-workflow', () => ({ useAppWorkflow: (appId: string) => mockUseAppWorkflow(appId), diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/__tests__/use-app-inputs-form-schema.spec.ts b/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/__tests__/use-app-inputs-form-schema.spec.ts index f88622887585f4..be8d22daebd3ca 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/__tests__/use-app-inputs-form-schema.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/__tests__/use-app-inputs-form-schema.spec.ts @@ -13,24 +13,22 @@ const mockFileUploadConfigRefetch = vi.fn() const mockWorkflowRefetch = vi.fn() vi.mock('@tanstack/react-query', () => ({ - useQuery: () => ({ - data: mockAppDetailData, - isError: false, - isFetching: false, - refetch: mockAppRefetch, - }), -})) - -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ - data: { - file_size_limit: 15, - image_file_size_limit: 10, - }, - isError: mockFileUploadConfigIsError, - isFetching: mockFileUploadConfigIsFetching, - refetch: mockFileUploadConfigRefetch, - }), + // The file upload limits come from their own query; every other query is the app detail. + useQuery: (options: { queryKey?: unknown[] }) => + Array.isArray(options.queryKey?.[0]) && + options.queryKey[0].join('.') === 'console.files.upload.get' + ? { + data: { file_size_limit: 15, image_file_size_limit: 10 }, + isError: mockFileUploadConfigIsError, + isFetching: mockFileUploadConfigIsFetching, + refetch: mockFileUploadConfigRefetch, + } + : { + data: mockAppDetailData, + isError: false, + isFetching: false, + refetch: mockAppRefetch, + }, })) vi.mock('@/service/use-workflow', () => ({ diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema.ts b/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema.ts index 41729e79b21ab7..45333d22d8f7d2 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema.ts +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema.ts @@ -11,7 +11,6 @@ import { useMemo } from 'react' import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' import { consoleQuery } from '@/service/console' -import { useFileUploadConfig } from '@/service/use-common' import { useAppWorkflow } from '@/service/use-workflow' import { AppModeEnum, Resolution } from '@/types/app' @@ -179,7 +178,7 @@ export function useAppInputsFormSchema({ }: UseAppInputsFormSchemaParams): UseAppInputsFormSchemaResult { const isBasicApp = isBasicAppMode(appDetail.mode) - const fileUploadConfigQuery = useFileUploadConfig() + const fileUploadConfigQuery = useQuery(consoleQuery.files.upload.get.queryOptions()) const { data: fileUploadConfig } = fileUploadConfigQuery const appQuery = useQuery( consoleQuery.apps.byAppId.get.queryOptions({ diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx index 43d519c28683cf..9ec9c0655b81ac 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx @@ -12,10 +12,6 @@ import LLMParamsPanel from '../llm-params-panel' // Mock useModelParameterRules hook const mockUseModelParameterRules = vi.fn() -vi.mock('@/service/use-common', () => ({ - useModelParameterRules: (provider: string, modelId: string) => - mockUseModelParameterRules(provider, modelId), -})) // Mock config constants with inline data vi.mock('@/config', () => ({ @@ -251,6 +247,19 @@ const setupModelParameterRulesMock = ( // ==================== Tests ==================== +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.modelProviders.byProvider.models.parameterRules.get': (options: { + queryKey: [unknown, { input?: { params: { provider: string }; query: { model: string } } }] + }) => + mockUseModelParameterRules( + options.queryKey[1].input?.params?.provider, + options.queryKey[1].input?.query?.model, + ), + }) +}) + describe('LLMParamsPanel', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.tsx index 6179bfa4a057c1..e00599d634059b 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.tsx @@ -4,6 +4,7 @@ import type { } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { ParameterValue } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item' import { cn } from '@langgenius/dify-ui/cn' +import { useQuery } from '@tanstack/react-query' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -11,8 +12,8 @@ import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import ParameterItem from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item' import PresetsParameter from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter' import { getSupportedPresetConfig } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils' +import { modelParameterRulesQueryOptions } from '@/app/components/header/account-setting/model-provider-page/utils' import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE } from '@/config' -import { useModelParameterRules } from '@/service/use-common' type Props = Readonly<{ isAdvancedMode: boolean @@ -30,7 +31,9 @@ const LLMParamsPanel = ({ onCompletionParamsChange, }: Props) => { const { t } = useTranslation(['modelProvider']) - const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId) + const { data: parameterRulesData, isLoading } = useQuery( + modelParameterRulesQueryOptions({ provider, model: modelId }), + ) const isRulesLoading = !!provider && !!modelId && isLoading const parameterRules: ModelParameterRule[] = useMemo(() => { diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hooks.spec.ts index c89fcf1e692b5f..6e7be1f00e8737 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hooks.spec.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hooks.spec.ts @@ -3,6 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { PipelineInputVarType } from '@/models/pipeline' import { useConfigurations, useHiddenConfigurations, useHiddenFieldNames } from '../hooks' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': () => ({ data: {} }), + }) +}) + vi.mock('@/app/components/base/file-uploader/hooks', () => ({ useFileSizeLimit: () => ({ imgSizeLimit: 10 * 1024 * 1024, @@ -12,10 +19,6 @@ vi.mock('@/app/components/base/file-uploader/hooks', () => ({ }), })) -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ data: {} }), -})) - vi.mock('@/app/components/workflow/constants', () => ({ DEFAULT_FILE_UPLOAD_SETTING: { allowed_file_upload_methods: ['local_file', 'remote_url'], diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/index.spec.tsx index 94e9ba1650f51b..64c685a8e36280 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/index.spec.tsx @@ -21,14 +21,6 @@ const mockFileUploadConfig = { workflow_file_upload_limit: 10, } -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ - data: mockFileUploadConfig, - isLoading: false, - error: null, - }), -})) - const createFormData = (overrides?: Partial): FormData => ({ type: PipelineInputVarType.textInput, label: 'Test Label', @@ -90,6 +82,17 @@ beforeEach(() => { toastErrorSpy.mockClear() }) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': () => ({ + data: mockFileUploadConfig, + isLoading: false, + error: null, + }), + }) +}) + describe('InputFieldForm', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks.ts index 4e4c5624e58419..2051b6216134fe 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks.ts @@ -1,13 +1,14 @@ import type { DeepKeys } from '@tanstack/react-form' import type { FormData } from './types' import type { InputFieldConfiguration } from '@/app/components/base/form/form-scenarios/input-field/types' +import { useQuery } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks' import { InputFieldType } from '@/app/components/base/form/form-scenarios/input-field/types' import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants' import { PipelineInputVarType } from '@/models/pipeline' -import { useFileUploadConfig } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import { formatFileSize } from '@/utils/format' import { TEXT_MAX_LENGTH } from './schema' @@ -217,7 +218,7 @@ export const useHiddenConfigurations = (props: { options: string[] | undefined } const { options } = props - const { data: fileUploadConfigResponse } = useFileUploadConfig() + const { data: fileUploadConfigResponse } = useQuery(consoleQuery.files.upload.get.queryOptions()) const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit } = useFileSizeLimit(fileUploadConfigResponse) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/index.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/index.tsx index 2acce00d697493..df0dc6e359420c 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/index.tsx @@ -2,13 +2,14 @@ import type { FormData, InputFieldFormProps } from './types' import type { MoreInfo } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { Separator } from '@langgenius/dify-ui/separator' +import { useQuery } from '@tanstack/react-query' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks' import { useAppForm } from '@/app/components/base/form' import { ChangeType } from '@/app/components/workflow/types' import { toast } from '@/app/notifications' -import { useFileUploadConfig } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import HiddenFields from './hidden-fields' import InitialFields from './initial-fields' import { createInputFieldSchema } from './schema' @@ -22,7 +23,7 @@ const InputFieldForm = ({ isEditMode = true, }: InputFieldFormProps) => { const { t } = useTranslation(['appDebug', 'common']) - const { data: fileUploadConfigResponse } = useFileUploadConfig() + const { data: fileUploadConfigResponse } = useQuery(consoleQuery.files.upload.get.queryOptions()) const { maxFileUploadLimit } = useFileSizeLimit(fileUploadConfigResponse) const inputFieldForm = useAppForm({ defaultValues: initialData, diff --git a/web/app/components/tools/provider/__tests__/detail.spec.tsx b/web/app/components/tools/provider/__tests__/detail.spec.tsx index d95b94d48f5aec..bfac8c6c1fd8db 100644 --- a/web/app/components/tools/provider/__tests__/detail.spec.tsx +++ b/web/app/components/tools/provider/__tests__/detail.spec.tsx @@ -1,10 +1,11 @@ import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { ReactElement } from 'react' import type { Collection } from '../../types' +import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations' import { act, cleanup, fireEvent, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { modelProviderDetailsQueryOptions } from '@/app/components/header/account-setting/model-provider-page/utils' import { consoleQuery } from '@/service/console' -import { commonQueryKeys } from '@/service/use-common' import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { AuthType, CollectionType } from '../../types' import ProviderDetail from '../detail' @@ -37,11 +38,12 @@ const render = (ui: ReactElement) => { ], plugins: {}, }) - queryClient.setQueryData(commonQueryKeys.modelProviderDetails, { - data: [{ provider: 'model-collection-id' }], + queryClient.setQueryData(modelProviderDetailsQueryOptions().queryKey, { + // Only the provider id is read when opening the model modal. + data: [{ provider: 'model-collection-id' } as ModelProvider], }) void queryClient.invalidateQueries({ - queryKey: commonQueryKeys.modelProviderDetails, + queryKey: modelProviderDetailsQueryOptions().queryKey, exact: true, refetchType: 'none', }) diff --git a/web/app/components/tools/provider/detail.tsx b/web/app/components/tools/provider/detail.tsx index 9e7136d6692082..b52f3e083044f5 100644 --- a/web/app/components/tools/provider/detail.tsx +++ b/web/app/components/tools/provider/detail.tsx @@ -36,6 +36,7 @@ import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { modelProviderDetailsQueryOptions } from '@/app/components/header/account-setting/model-provider-page/utils' import Icon from '@/app/components/plugins/card/base/card-icon' import Description from '@/app/components/plugins/card/base/description' import OrgInfo from '@/app/components/plugins/card/base/org-info' @@ -61,7 +62,6 @@ import { updateBuiltInToolCredential, updateCustomCollection, } from '@/service/tools' -import { modelProviderDetailsQueryOptions } from '@/service/use-common' import { useInvalidateAllWorkflowTools } from '@/service/use-tools' import { basePath } from '@/utils/var' import { AuthHeaderPrefix, AuthType, CollectionType } from '../types' diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx index d4b823c1c523b6..7c5446ee6e3831 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx @@ -4,18 +4,24 @@ import userEvent from '@testing-library/user-event' import { useState } from 'react' import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks' import { SupportUploadFileTypes } from '@/app/components/workflow/types' -import { useFileUploadConfig } from '@/service/use-common' import { TransferMethod } from '@/types/app' import FileTypeItem from '../file-type-item' import FileUploadSetting from '../file-upload-setting' -const mockUseFileUploadConfig = vi.mocked(useFileUploadConfig) -const mockUseFileSizeLimit = vi.mocked(useFileSizeLimit) - -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: vi.fn(), +const { mockFileUploadConfigHook } = vi.hoisted(() => ({ + mockFileUploadConfigHook: vi.fn(), })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': (options: unknown) => mockFileUploadConfigHook(options), + }) +}) + +const mockUseFileUploadConfig = mockFileUploadConfigHook +const mockUseFileSizeLimit = vi.mocked(useFileSizeLimit) + vi.mock('@/app/components/base/file-uploader/hooks', () => ({ useFileSizeLimit: vi.fn(), })) @@ -40,7 +46,9 @@ const createPayload = (overrides: Partial = {}): UploadFileSe describe('File upload support components', () => { beforeEach(() => { vi.clearAllMocks() - mockUseFileUploadConfig.mockReturnValue({ data: {} } as ReturnType) + mockUseFileUploadConfig.mockReturnValue({ data: {} } as ReturnType< + typeof mockFileUploadConfigHook + >) mockUseFileSizeLimit.mockReturnValue({ imgSizeLimit: 10 * 1024 * 1024, docSizeLimit: 20 * 1024 * 1024, diff --git a/web/app/components/workflow/nodes/_base/components/file-upload-setting.tsx b/web/app/components/workflow/nodes/_base/components/file-upload-setting.tsx index aa02e7413bb8b0..6572d6cbcc9d42 100644 --- a/web/app/components/workflow/nodes/_base/components/file-upload-setting.tsx +++ b/web/app/components/workflow/nodes/_base/components/file-upload-setting.tsx @@ -5,13 +5,14 @@ import { cn } from '@langgenius/dify-ui/cn' import { FieldItem, FieldLabel, Field as FormField } from '@langgenius/dify-ui/field' import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset' import { RadioGroup, RadioItem } from '@langgenius/dify-ui/radio-group' +import { useQuery } from '@tanstack/react-query' import { produce } from 'immer' import * as React from 'react' import { useCallback, useId } from 'react' import { useTranslation } from 'react-i18next' import Field from '@/app/components/app/configuration/config-var/config-modal/field' import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks' -import { useFileUploadConfig } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import { TransferMethod } from '@/types/app' import { formatFileSize } from '@/utils/format' import { SupportUploadFileTypes } from '../../../types' @@ -50,7 +51,7 @@ const FileUploadSetting: FC = ({ allowed_file_types = [], allowed_file_extensions = [], } = payload - const { data: fileUploadConfigResponse } = useFileUploadConfig() + const { data: fileUploadConfigResponse } = useQuery(consoleQuery.files.upload.get.queryOptions()) const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit, maxFileUploadLimit } = useFileSizeLimit(fileUploadConfigResponse) diff --git a/web/app/components/workflow/nodes/_base/components/variable/use-match-schema-type.ts b/web/app/components/workflow/nodes/_base/components/variable/use-match-schema-type.ts index 7f2b9f21245197..5d66587ecb7978 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/use-match-schema-type.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/use-match-schema-type.ts @@ -1,6 +1,8 @@ import type { AnyObj } from './match-schema-type' import type { SchemaTypeDefinition } from '@/service/use-common' -import { useSchemaTypeDefinitions } from '@/service/use-common' +import { useQuery } from '@tanstack/react-query' +import { isPlainObject } from 'es-toolkit/predicate' +import { consoleQuery } from '@/service/console' import matchTheSchemaType from './match-schema-type' export const getMatchedSchemaType = ( @@ -12,8 +14,23 @@ export const getMatchedSchemaType = ( return matched ? matched.name : '' } +// Definition schemas are untyped JSON in the console API response. +const toSchemaTypeDefinitions = ( + definitions: Array<{ name: string; schema: Record }>, +): SchemaTypeDefinition[] => + definitions.map(({ name, schema }) => ({ + name, + schema: { + properties: isPlainObject(schema.properties) + ? (schema.properties as Record) + : {}, + }, + })) + const useMatchSchemaType = () => { - const { data: schemaTypeDefinitions, isLoading } = useSchemaTypeDefinitions() + const { data: schemaTypeDefinitions, isLoading } = useQuery( + consoleQuery.spec.schemaDefinitions.get.queryOptions({ select: toSchemaTypeDefinitions }), + ) return { isLoading, diff --git a/web/app/components/workflow/nodes/data-source/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/data-source/__tests__/panel.spec.tsx index e11c35edb739e2..c710b48928b61f 100644 --- a/web/app/components/workflow/nodes/data-source/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/data-source/__tests__/panel.spec.tsx @@ -14,6 +14,11 @@ const { update, get } = vi.hoisted(() => ({ vi.mock('@/service/base', async (importOriginal) => ({ ...(await importOriginal()), get, + // Console contract requests; only the schema type definitions are expected. + request: async (_url: string, _init: RequestInit, { request }: { request: Request }) => { + if (new URL(request.url).pathname.endsWith('/spec/schema-definitions')) return Response.json([]) + throw new Error(`Unexpected request: ${request.url}`) + }, })) vi.mock('../../../hooks/use-node-data-update', () => ({ useNodeDataUpdate: () => ({ handleNodeDataUpdateWithSyncDraft: update }), diff --git a/web/app/components/workflow/nodes/llm/components/__tests__/structure-output.browser.spec.tsx b/web/app/components/workflow/nodes/llm/components/__tests__/structure-output.browser.spec.tsx index 92f71c59dd274a..47f74404ae4da5 100644 --- a/web/app/components/workflow/nodes/llm/components/__tests__/structure-output.browser.spec.tsx +++ b/web/app/components/workflow/nodes/llm/components/__tests__/structure-output.browser.spec.tsx @@ -4,7 +4,6 @@ import { useState } from 'react' import { render } from 'vitest-browser-react' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { consoleQuery } from '@/service/console' -import { commonQueryKeys } from '@/service/use-common' import { Type } from '../../types' import { StructureOutput } from '../structure-output' @@ -63,9 +62,14 @@ function renderOutput() { }).queryKey, { data: [] }, ) - queryClient.setQueryData(commonQueryKeys.defaultModel(ModelTypeEnum.textGeneration), { - data: null, - }) + queryClient.setQueryData( + consoleQuery.workspaces.current.defaultModel.get.queryKey({ + input: { query: { model_type: ModelTypeEnum.textGeneration } }, + }), + { + data: null, + }, + ) return render( diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-schema-config.spec.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-schema-config.spec.tsx index d045323c663899..66daf201631de5 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-schema-config.spec.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-schema-config.spec.tsx @@ -6,7 +6,6 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { useState } from 'react' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { consoleQuery } from '@/service/console' -import { commonQueryKeys } from '@/service/use-common' import { QueryClientTestProvider } from '@/test/console/query-provider' import { createSystemFeaturesFixture } from '@/test/console/system-features' import { Type } from '../../../types' @@ -66,7 +65,12 @@ function renderOutput({ readOnly = false, onChange = vi.fn() } = {}) { }).queryKey, { data: [] }, ) - client.setQueryData(commonQueryKeys.defaultModel(ModelTypeEnum.textGeneration), { data: null }) + client.setQueryData( + consoleQuery.workspaces.current.defaultModel.get.queryKey({ + input: { query: { model_type: ModelTypeEnum.textGeneration } }, + }), + { data: null }, + ) client.setQueryData( consoleQuery.systemFeatures.get.queryOptions().queryKey, createSystemFeaturesFixture(), diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx index 29a9fd48aa5678..2482398293c67e 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx @@ -2,6 +2,7 @@ import type { SchemaRoot } from '../../../types' import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { CompletionParams, Model } from '@/types/app' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useMutation } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -10,7 +11,7 @@ import { ModelTypeEnum } from '@/app/components/header/account-setting/model-pro import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { toast } from '@/app/notifications' import useTheme from '@/hooks/use-theme' -import { useGenerateStructuredOutputRules } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import { ModelModeType, Theme } from '@/types/app' import { useMittContext } from '../visual-editor/context' import { useVisualEditorStore } from '../visual-editor/store' @@ -100,13 +101,19 @@ export function JsonSchemaGenerator({ onApply, crossAxisOffset }: JsonSchemaGene [resolvedModel, setModel], ) - const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = - useGenerateStructuredOutputRules() + const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = useMutation( + consoleQuery.ruleStructuredOutputGenerate.post.mutationOptions(), + ) const generateSchema = useCallback(async () => { const { output, error } = await generateStructuredOutputRules({ - instruction, - model_config: resolvedModel, + body: { + instruction, + model_config: { + ...resolvedModel, + mode: resolvedModel.mode === 'completion' ? 'completion' : 'chat', + }, + }, }) if (error) { toast.error(error) diff --git a/web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx index 6f4011ca420bd0..7e8e6fd48a4703 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx @@ -2,6 +2,7 @@ import type { FileUploadConfigResponse } from '@/models/common' import type { VarInInspect } from '@/types/workflow' import { fireEvent, render, screen } from '@testing-library/react' import { VarType } from '@/app/components/workflow/types' +import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config' import { VarInInspectType } from '@/types/workflow' import { BoolArraySection, @@ -49,15 +50,16 @@ vi.mock('@/next/navigation', () => ({ })) describe('value-content sections', () => { - const createFileUploadConfig = (): FileUploadConfigResponse => ({ - batch_count_limit: 10, - image_file_batch_limit: 10, - single_chunk_attachment_limit: 10, - attachment_image_file_size_limit: 2, - file_size_limit: 15, - file_upload_limit: 5, - workflow_file_upload_limit: 5, - }) + const createFileUploadConfig = (): FileUploadConfigResponse => + createFileUploadConfigFixture({ + batch_count_limit: 10, + image_file_batch_limit: 10, + single_chunk_attachment_limit: 10, + attachment_image_file_size_limit: 2, + file_size_limit: 15, + file_upload_limit: 5, + workflow_file_upload_limit: 5, + }) const createVar = (overrides: Partial): VarInInspect => ({ diff --git a/web/app/signin/__tests__/one-more-step.spec.tsx b/web/app/signin/__tests__/one-more-step.spec.tsx index 94572f750ca1c4..9c16c4135373af 100644 --- a/web/app/signin/__tests__/one-more-step.spec.tsx +++ b/web/app/signin/__tests__/one-more-step.spec.tsx @@ -4,24 +4,30 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useRouter, useSearchParams } from '@/next/navigation' -import { useOneMoreStep } from '@/service/use-common' import OneMoreStep from '../one-more-step' +const { mockOneMoreStepHook } = vi.hoisted(() => ({ + mockOneMoreStepHook: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'account.init.post': (options: unknown) => mockOneMoreStepHook(options), + }) +}) + vi.mock('@/next/navigation', () => ({ useRouter: vi.fn(), useSearchParams: vi.fn(), })) -vi.mock('@/service/use-common', () => ({ - useOneMoreStep: vi.fn(), -})) - const mockReplace = vi.fn() const mockSubmitOneMoreStep = vi.fn() const mockUseRouter = useRouter as unknown as MockedFunction const mockUseSearchParams = useSearchParams as unknown as MockedFunction -const mockUseOneMoreStep = useOneMoreStep as unknown as MockedFunction +const mockUseOneMoreStep = mockOneMoreStepHook describe('OneMoreStep', () => { beforeEach(() => { @@ -37,7 +43,7 @@ describe('OneMoreStep', () => { mockUseOneMoreStep.mockReturnValue({ mutateAsync: mockSubmitOneMoreStep, isPending: false, - } as unknown as ReturnType) + } as unknown as ReturnType) mockSubmitOneMoreStep.mockResolvedValue({ result: 'success' }) }) diff --git a/web/app/signin/invite-settings/__tests__/page.spec.tsx b/web/app/signin/invite-settings/__tests__/page.spec.tsx index 9341dd85378236..2b62a6e37f62e2 100644 --- a/web/app/signin/invite-settings/__tests__/page.spec.tsx +++ b/web/app/signin/invite-settings/__tests__/page.spec.tsx @@ -1,15 +1,19 @@ +import type { useQuery } from '@tanstack/react-query' import type { MockedFunction } from 'vite-plus/test' -import { useQuery } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useLocale } from '#i18n' import { useRouter, useSearchParams } from '@/next/navigation' import { consoleClient } from '@/service/console' -import { useInvitationCheck } from '@/service/use-common' import { getBrowserTimezone } from '@/utils/timezone' import InviteSettingsPage from '../page' +const { mockInvitationCheckHook, mockProfileQuery } = vi.hoisted(() => ({ + mockInvitationCheckHook: vi.fn(), + mockProfileQuery: vi.fn(), +})) + vi.mock('react-i18next', async () => { const actual = await vi.importActual('react-i18next') const { createReactI18nextMock } = await import('@/test/i18n-mock') @@ -27,7 +31,12 @@ vi.mock('@tanstack/react-query', async () => { await vi.importActual('@tanstack/react-query') return { ...actual, - useQuery: vi.fn(), + // The invitation check is answered separately from the account profile query. + useQuery: (options: { queryKey: unknown[] }) => + Array.isArray(options.queryKey[0]) && + options.queryKey[0].join('.') === 'console.activate.check.get' + ? mockInvitationCheckHook(options) + : mockProfileQuery(options), useQueryClient: vi.fn(() => ({ resetQueries: vi.fn(), })), @@ -71,10 +80,6 @@ vi.mock('@/service/console', async (importOriginal) => ({ }, })) -vi.mock('@/service/use-common', () => ({ - useInvitationCheck: vi.fn(), -})) - vi.mock('@/utils/timezone', () => ({ getBrowserTimezone: vi.fn(), timezones: [ @@ -90,10 +95,8 @@ const mockUseLocale = useLocale as unknown as MockedFunction const mockUseRouter = useRouter as unknown as MockedFunction const mockUseSearchParams = useSearchParams as unknown as MockedFunction const mockActivateMember = vi.mocked(consoleClient.activate.post) -const mockUseQuery = vi.mocked(useQuery) -const mockUseInvitationCheck = useInvitationCheck as unknown as MockedFunction< - typeof useInvitationCheck -> +const mockUseQuery = mockProfileQuery +const mockUseInvitationCheck = mockInvitationCheckHook const mockGetBrowserTimezone = getBrowserTimezone as unknown as MockedFunction< typeof getBrowserTimezone > @@ -122,7 +125,7 @@ describe('InviteSettingsPage', () => { }, }, refetch: mockRefetch, - } as unknown as ReturnType) + } as unknown as ReturnType) mockUseQuery.mockReturnValue({ data: { profile: { @@ -157,7 +160,7 @@ describe('InviteSettingsPage', () => { }, }, refetch: mockRefetch, - } as unknown as ReturnType) + } as unknown as ReturnType) render() @@ -232,7 +235,7 @@ describe('InviteSettingsPage', () => { }, }, refetch: mockRefetch, - } as unknown as ReturnType) + } as unknown as ReturnType) render() @@ -261,7 +264,7 @@ describe('InviteSettingsPage', () => { }, }, refetch: mockRefetch, - } as unknown as ReturnType) + } as unknown as ReturnType) render() @@ -291,7 +294,7 @@ describe('InviteSettingsPage', () => { }, }, refetch: mockRefetch, - } as unknown as ReturnType) + } as unknown as ReturnType) render() diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx index 8fd2090408e322..9fb518203426b6 100644 --- a/web/app/signin/invite-settings/page.tsx +++ b/web/app/signin/invite-settings/page.tsx @@ -30,7 +30,6 @@ import Link from '@/next/link' import { useRouter, useSearchParams } from '@/next/navigation' import { consoleClient, consoleQuery } from '@/service/console' import { isUnauthorizedError } from '@/service/http-error' -import { useInvitationCheck } from '@/service/use-common' import { replaceLoginRedirect } from '@/utils/login-redirect.client' import { getBrowserTimezone, timezones } from '@/utils/timezone' import { basePath } from '@/utils/var' @@ -105,7 +104,13 @@ export default function InviteSettingsPage() { token, }, } - const { data: checkRes, refetch: recheck } = useInvitationCheck(checkParams.params, !!token) + const { data: checkRes, refetch: recheck } = useQuery( + consoleQuery.activate.check.get.queryOptions({ + input: { query: checkParams.params }, + enabled: !!token, + retry: false, + }), + ) const isInvitationForCurrentAccount = isInvitationForAccount( checkRes?.data?.email, userResp?.profile.email, diff --git a/web/app/signin/one-more-step.tsx b/web/app/signin/one-more-step.tsx index d8ad7297c0e785..73cd12568f70a2 100644 --- a/web/app/signin/one-more-step.tsx +++ b/web/app/signin/one-more-step.tsx @@ -15,7 +15,7 @@ import { SelectLabel, SelectTrigger, } from '@langgenius/dify-ui/select' -import { useQueryClient } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useReducer } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' @@ -26,7 +26,6 @@ import Link from '@/next/link' import { useRouter, useSearchParams } from '@/next/navigation' import { consoleQuery } from '@/service/console' import { getHttpErrorStatus } from '@/service/http-error' -import { useOneMoreStep } from '@/service/use-common' import { replaceLoginRedirect } from '@/utils/login-redirect.client' import { timezones } from '@/utils/timezone' import { basePath } from '@/utils/var' @@ -77,7 +76,9 @@ const OneMoreStep = () => { interface_language: 'en-US', timezone: 'Asia/Shanghai', }) - const { mutateAsync: submitOneMoreStep, isPending } = useOneMoreStep() + const { mutateAsync: submitOneMoreStep, isPending } = useMutation( + consoleQuery.account.init.post.mutationOptions(), + ) const selectedLanguage = LANGUAGE_OPTIONS.find((item) => item.value === state.interface_language) const selectedTimezone = TIMEZONE_OPTIONS.find((item) => item.value === state.timezone) @@ -95,9 +96,11 @@ const OneMoreStep = () => { if (isPending) return try { await submitOneMoreStep({ - invitation_code: state.invitation_code.trim(), - interface_language: state.interface_language, - timezone: state.timezone, + body: { + invitation_code: state.invitation_code.trim(), + interface_language: state.interface_language, + timezone: state.timezone, + }, }) await queryClient.resetQueries({ queryKey: consoleQuery.account.profile.get.key() }) replaceLoginRedirect(resolvePostLoginRedirect(searchParams), router.replace, basePath) diff --git a/web/app/signup/check-code/__tests__/page.spec.tsx b/web/app/signup/check-code/__tests__/page.spec.tsx index a92209311842e4..c2f8444278833a 100644 --- a/web/app/signup/check-code/__tests__/page.spec.tsx +++ b/web/app/signup/check-code/__tests__/page.spec.tsx @@ -3,9 +3,21 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useLocale } from '#i18n' import { useRouter, useSearchParams } from '@/next/navigation' -import { useMailValidity, useSendMail } from '@/service/use-common' import CheckCode from '../page' +const { mockSendMailHook, mockMailValidityHook } = vi.hoisted(() => ({ + mockSendMailHook: vi.fn(), + mockMailValidityHook: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'emailRegister.sendEmail.post': (options: unknown) => mockSendMailHook(options), + 'emailRegister.validity.post': (options: unknown) => mockMailValidityHook(options), + }) +}) + const mockBack = vi.fn() const mockPush = vi.fn() const mockReplace = vi.fn() @@ -34,16 +46,11 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: vi.fn(), })) -vi.mock('@/service/use-common', () => ({ - useMailValidity: vi.fn(), - useSendMail: vi.fn(), -})) - const mockUseLocale = useLocale as unknown as MockedFunction const mockUseRouter = useRouter as unknown as MockedFunction const mockUseSearchParams = useSearchParams as unknown as MockedFunction -const mockUseMailValidity = useMailValidity as unknown as MockedFunction -const mockUseSendMail = useSendMail as unknown as MockedFunction +const mockUseMailValidity = mockMailValidityHook +const mockUseSendMail = mockSendMailHook describe('Signup Check Code Page', () => { beforeEach(() => { @@ -62,10 +69,10 @@ describe('Signup Check Code Page', () => { ) mockUseMailValidity.mockReturnValue({ mutateAsync: mockVerifyCode, - } as unknown as ReturnType) + } as unknown as ReturnType) mockUseSendMail.mockReturnValue({ mutateAsync: mockSubmitMail, - } as unknown as ReturnType) + } as unknown as ReturnType) mockVerifyCode.mockResolvedValue({ is_valid: true, token: 'verified-token' }) }) @@ -88,9 +95,11 @@ describe('Signup Check Code Page', () => { await waitFor(() => { expect(mockVerifyCode).toHaveBeenCalledWith({ - code: '123456', - email: 'user@example.com', - token: 'signup-token', + body: { + code: '123456', + email: 'user@example.com', + token: 'signup-token', + }, }) }) expect(mockVerifyCode).toHaveBeenCalledTimes(1) diff --git a/web/app/signup/check-code/page.tsx b/web/app/signup/check-code/page.tsx index 55d640e9c22bb7..4439016e04b411 100644 --- a/web/app/signup/check-code/page.tsx +++ b/web/app/signup/check-code/page.tsx @@ -1,9 +1,9 @@ 'use client' -import type { MailSendResponse, MailValidityResponse } from '@/service/use-common' import { Button } from '@langgenius/dify-ui/button' import { Field, FieldError, FieldLabel } from '@langgenius/dify-ui/field' import { Form } from '@langgenius/dify-ui/form' import { Input } from '@langgenius/dify-ui/input' +import { useMutation } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' @@ -11,7 +11,7 @@ import Countdown from '@/app/components/signin/countdown' import { toast } from '@/app/notifications' import useDocumentTitle from '@/hooks/use-document-title' import { useRouter, useSearchParams } from '@/next/navigation' -import { useMailValidity, useSendMail } from '@/service/use-common' +import { consoleQuery } from '@/service/console' type CheckCodeFormValues = { code: string @@ -25,8 +25,12 @@ export default function CheckCode() { const [token, setToken] = useState(() => decodeURIComponent(searchParams.get('token') as string)) const [loading, setLoading] = useState(false) const locale = useLocale() - const { mutateAsync: submitMail } = useSendMail() - const { mutateAsync: verifyCode } = useMailValidity() + const { mutateAsync: submitMail } = useMutation( + consoleQuery.emailRegister.sendEmail.post.mutationOptions(), + ) + const { mutateAsync: verifyCode } = useMutation( + consoleQuery.emailRegister.validity.post.mutationOptions(), + ) const pageTitle = t(($) => $['checkCode.checkYourEmail'], { ns: 'login' }) useDocumentTitle(pageTitle) @@ -34,10 +38,10 @@ export default function CheckCode() { if (loading) return try { setLoading(true) - const res = await verifyCode({ email, code, token }) - if ((res as MailValidityResponse).is_valid) { + const res = await verifyCode({ body: { email, code, token } }) + if (res.is_valid) { const params = new URLSearchParams(searchParams) - params.set('token', encodeURIComponent((res as MailValidityResponse).token)) + params.set('token', encodeURIComponent(res.token)) router.push(`/signup/set-password?${params.toString()}`) } else { toast.error(t(($) => $['checkCode.invalidCode'], { ns: 'login' })) @@ -51,10 +55,10 @@ export default function CheckCode() { const resendCode = async () => { try { - const res = await submitMail({ email, language: locale }) - if ((res as MailSendResponse).result === 'success') { + const res = await submitMail({ body: { email, language: locale } }) + if (res.result === 'success') { const params = new URLSearchParams(searchParams) - const newToken = (res as MailSendResponse)?.data + const newToken = res.data params.set('token', encodeURIComponent(newToken)) setToken(newToken) router.replace(`/signup/check-code?${params.toString()}`) diff --git a/web/app/signup/components/input-mail.spec.tsx b/web/app/signup/components/input-mail.spec.tsx index dbfebc1934c319..8e0c2055a681c2 100644 --- a/web/app/signup/components/input-mail.spec.tsx +++ b/web/app/signup/components/input-mail.spec.tsx @@ -4,10 +4,20 @@ import userEvent from '@testing-library/user-event' import * as React from 'react' import { useLocale } from '#i18n' import { useSearchParams } from '@/next/navigation' -import { useSendMail } from '@/service/use-common' import { renderWithConsoleQuery } from '@/test/console/query-data' import SignupEmailForm from './input-mail' +const { mockSendMailHook } = vi.hoisted(() => ({ + mockSendMailHook: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'emailRegister.sendEmail.post': (options: unknown) => mockSendMailHook(options), + }) +}) + const mockSubmitMail = vi.fn() const mockOnSuccess = vi.fn() @@ -40,15 +50,11 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: vi.fn(), })) -vi.mock('@/service/use-common', () => ({ - useSendMail: vi.fn(), -})) - -type UseSendMailResult = ReturnType +type UseSendMailResult = ReturnType const mockUseLocale = useLocale as unknown as MockedFunction const mockUseSearchParams = useSearchParams as unknown as MockedFunction -const mockUseSendMail = useSendMail as unknown as MockedFunction +const mockUseSendMail = mockSendMailHook const renderForm = ({ brandingEnabled = false, @@ -122,8 +128,10 @@ describe('InputMail Form', () => { await user.type(input, 'test@example.com{Enter}') expect(mockSubmitMail).toHaveBeenCalledWith({ - email: 'test@example.com', - language: 'en-US', + body: { + email: 'test@example.com', + language: 'en-US', + }, }) expect(mockSubmitMail).toHaveBeenCalledTimes(1) diff --git a/web/app/signup/components/input-mail.tsx b/web/app/signup/components/input-mail.tsx index 0227e5e7e621d5..a6a285b9ad5ec4 100644 --- a/web/app/signup/components/input-mail.tsx +++ b/web/app/signup/components/input-mail.tsx @@ -1,10 +1,9 @@ 'use client' -import type { MailSendResponse } from '@/service/use-common' import { Button } from '@langgenius/dify-ui/button' import { Field, FieldError, FieldLabel, FieldValidity } from '@langgenius/dify-ui/field' import { Form } from '@langgenius/dify-ui/form' import { Input } from '@langgenius/dify-ui/input' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' import Split from '@/app/signin/split' @@ -12,7 +11,7 @@ import { emailRegex } from '@/config' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' import { useSearchParams } from '@/next/navigation' -import { useSendMail } from '@/service/use-common' +import { consoleQuery } from '@/service/console' type Props = { onSuccess: (email: string, payload: string) => void @@ -30,13 +29,14 @@ export default function SignupEmailForm({ onSuccess }: Props) { const signinHref = queryString ? `/signin?${queryString}` : '/signin' const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { mutateAsync: submitMail, isPending } = useSendMail() + const { mutateAsync: submitMail, isPending } = useMutation( + consoleQuery.emailRegister.sendEmail.post.mutationOptions(), + ) const handleSubmit = async (email: string) => { if (isPending) return - const res = await submitMail({ email, language: locale }) - if ((res as MailSendResponse).result === 'success') - onSuccess(email, (res as MailSendResponse).data) + const res = await submitMail({ body: { email, language: locale } }) + if (res.result === 'success') onSuccess(email, res.data ?? '') } return ( diff --git a/web/app/signup/set-password/__tests__/page.spec.tsx b/web/app/signup/set-password/__tests__/page.spec.tsx index 8febabd4cc281d..94155229bf64a2 100644 --- a/web/app/signup/set-password/__tests__/page.spec.tsx +++ b/web/app/signup/set-password/__tests__/page.spec.tsx @@ -7,11 +7,21 @@ import Cookies from 'js-cookie' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useLocale } from '#i18n' import { useRouter, useSearchParams } from '@/next/navigation' -import { useMailRegister } from '@/service/use-common' import { seedSystemFeatures } from '@/test/console/query-data' import { getBrowserTimezone } from '@/utils/timezone' import ChangePasswordForm from '../page' +const { mockMailRegisterHook } = vi.hoisted(() => ({ + mockMailRegisterHook: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'emailRegister.post': (options: unknown) => mockMailRegisterHook(options), + }) +}) + const { mockRememberCreateAppExternalAttribution, mockRememberRegistrationSuccess, @@ -32,10 +42,6 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: vi.fn(), })) -vi.mock('@/service/use-common', () => ({ - useMailRegister: vi.fn(), -})) - vi.mock('@/utils/timezone', () => ({ getBrowserTimezone: vi.fn(), })) @@ -59,7 +65,7 @@ const mockReplace = vi.fn() const mockUseLocale = useLocale as unknown as MockedFunction const mockUseSearchParams = useSearchParams as unknown as MockedFunction const mockUseRouter = useRouter as unknown as MockedFunction -const mockUseMailRegister = useMailRegister as unknown as MockedFunction +const mockUseMailRegister = mockMailRegisterHook const mockGetBrowserTimezone = getBrowserTimezone as unknown as MockedFunction< typeof getBrowserTimezone > @@ -89,7 +95,7 @@ describe('Signup Set Password Page', () => { mockUseMailRegister.mockReturnValue({ mutateAsync: mockRegister, isPending: false, - } as unknown as ReturnType) + } as unknown as ReturnType) mockGetBrowserTimezone.mockReturnValue('Asia/Shanghai') mockRegister.mockResolvedValue({ result: 'fail', data: {} }) }) @@ -117,11 +123,13 @@ describe('Signup Set Password Page', () => { await waitFor(() => { expect(mockRegister).toHaveBeenCalledWith({ - token: 'register-token', - new_password: 'ValidPass123!', - password_confirm: 'ValidPass123!', - language: 'zh-Hans', - timezone: 'Asia/Shanghai', + body: { + token: 'register-token', + new_password: 'ValidPass123!', + password_confirm: 'ValidPass123!', + language: 'zh-Hans', + timezone: 'Asia/Shanghai', + }, }) }) }) diff --git a/web/app/signup/set-password/page.tsx b/web/app/signup/set-password/page.tsx index 1d933b5df39850..35c9fe66234632 100644 --- a/web/app/signup/set-password/page.tsx +++ b/web/app/signup/set-password/page.tsx @@ -1,6 +1,5 @@ 'use client' import type { FormActions } from '@langgenius/dify-ui/form' -import type { MailRegisterResponse } from '@/service/use-common' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { @@ -12,7 +11,7 @@ import { } from '@langgenius/dify-ui/field' import { Form } from '@langgenius/dify-ui/form' import { Input } from '@langgenius/dify-ui/input' -import { useQueryClient } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import Cookies from 'js-cookie' import { useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' @@ -23,7 +22,6 @@ import { validPassword } from '@/config' import useDocumentTitle from '@/hooks/use-document-title' import { useRouter, useSearchParams } from '@/next/navigation' import { consoleQuery } from '@/service/console' -import { useMailRegister } from '@/service/use-common' import { rememberCreateAppExternalAttribution } from '@/utils/create-app-tracking' import { sendGAEvent } from '@/utils/gtag' import { replaceLoginRedirect } from '@/utils/login-redirect.client' @@ -56,7 +54,9 @@ const ChangePasswordForm = () => { const formActionsRef = useRef(null) const confirmPasswordRef = useRef(null) - const { mutateAsync: register, isPending } = useMailRegister() + const { mutateAsync: register, isPending } = useMutation( + consoleQuery.emailRegister.post.mutationOptions(), + ) const pageTitle = t(($) => $.changePassword, { ns: 'login' }) useDocumentTitle(pageTitle) @@ -65,13 +65,15 @@ const ChangePasswordForm = () => { if (isPending) return try { const res = await register({ - token, - new_password: formValues.password, - password_confirm: formValues.confirmPassword, - language: locale, - timezone: getBrowserTimezone(), + body: { + token, + new_password: formValues.password, + password_confirm: formValues.confirmPassword, + language: locale, + timezone: getBrowserTimezone(), + }, }) - const { result } = res as MailRegisterResponse + const { result } = res if (result === 'success') { const utmInfo = parseUtmInfo() rememberCreateAppExternalAttribution({ utmInfo }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/content-moderation.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/content-moderation.tsx index cff0a4e2056186..b1db60f186d082 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/content-moderation.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/content-moderation.tsx @@ -5,6 +5,7 @@ import type { Features } from '@/app/components/base/features/types' import type { ModerationConfig } from '@/models/debug' import { Button } from '@langgenius/dify-ui/button' import { Switch } from '@langgenius/dify-ui/switch' +import { useQuery } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' @@ -15,7 +16,7 @@ import { useAppFeatures, useSetAppFeatures, } from '@/features/agent-v2/agent-composer/store-modules/app-features' -import { useCodeBasedExtensions } from '@/service/use-common' +import { codeBasedExtensionsQueryOptions } from '@/features/code-based-extension/query' import { ConfigureSection } from '../common/section' import { useAgentOrchestrateReadOnly } from '../read-only-context' @@ -38,7 +39,7 @@ function AgentContentModerationSettingsContent() { const setAppFeatures = useSetAppFeatures() const { setShowModerationSettingModal } = useModalContext() const moderation = useFeatures((state) => state.features.moderation) - const { data: codeBasedExtensionList } = useCodeBasedExtensions('moderation') + const { data: codeBasedExtensionList } = useQuery(codeBasedExtensionsQueryOptions('moderation')) const panelId = 'agent-configure-content-moderation-panel' const persistModeration = useCallback( diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx index 6f540c9c588c4d..8caf30f14f1a4f 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx @@ -69,51 +69,22 @@ vi.mock('@/utils/download', () => ({ downloadUrl: mocks.downloadUrl, })) -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ data: mocks.fileUploadConfig }), -})) - -vi.mock('@/service/console', () => ({ - consoleQuery: { - systemFeatures: { - get: { - queryKey: () => ['console', 'systemFeatures', 'get'], - queryOptions: (options?: Record) => ({ - queryKey: ['console', 'systemFeatures', 'get'], - queryFn: () => new Promise(() => {}), - ...options, - }), - }, - }, - agent: { - byAgentId: { - config: { - files: { - post: { - mutationOptions: () => ({ mutationFn: mocks.commitFileMutationFn }), - }, - byName: { - delete: { - mutationOptions: () => ({ mutationFn: mocks.deleteFileMutationFn }), - }, - preview: { - get: { - queryOptions: mocks.previewQueryOptions, - }, - }, - download: { - get: { - queryOptions: mocks.downloadQueryOptions, - }, - }, - }, - }, +vi.mock('@/service/console', async () => { + const { consoleOperationStub } = await import('@/test/console/operation-hooks') + return { + consoleQuery: { + systemFeatures: { + get: { + queryKey: () => ['console', 'systemFeatures', 'get'], + queryOptions: (options?: Record) => ({ + queryKey: ['console', 'systemFeatures', 'get'], + queryFn: () => new Promise(() => {}), + ...options, + }), }, }, - }, - apps: { - byAppId: { - agent: { + agent: { + byAgentId: { config: { files: { post: { @@ -138,30 +109,59 @@ vi.mock('@/service/console', () => ({ }, }, }, - }, - trialApps: { - byAppId: { - agent: { - config: { - files: { - byName: { - preview: { get: { queryOptions: mocks.previewQueryOptions } }, - download: { get: { queryOptions: mocks.downloadQueryOptions } }, + apps: { + byAppId: { + agent: { + config: { + files: { + post: { + mutationOptions: () => ({ mutationFn: mocks.commitFileMutationFn }), + }, + byName: { + delete: { + mutationOptions: () => ({ mutationFn: mocks.deleteFileMutationFn }), + }, + preview: { + get: { + queryOptions: mocks.previewQueryOptions, + }, + }, + download: { + get: { + queryOptions: mocks.downloadQueryOptions, + }, + }, + }, }, }, }, }, }, - }, - files: { - upload: { - post: { - mutationOptions: () => ({ mutationFn: mocks.uploadFileMutationFn }), + trialApps: { + byAppId: { + agent: { + config: { + files: { + byName: { + preview: { get: { queryOptions: mocks.previewQueryOptions } }, + download: { get: { queryOptions: mocks.downloadQueryOptions } }, + }, + }, + }, + }, + }, + }, + files: { + upload: { + get: consoleOperationStub('files.upload.get'), + post: { + mutationOptions: () => ({ mutationFn: mocks.uploadFileMutationFn }), + }, }, }, }, - }, -})) + } +}) function ConfigSnapshotProbe() { const draft = useAtomValue(agentComposerDraftAtom) @@ -238,6 +238,13 @@ function renderAgentFiles({ } } +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': () => ({ data: mocks.fileUploadConfig }), + }) +}) + describe('AgentFiles', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/upload-dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/upload-dialog.tsx index 7795dd7bdb199b..a0619acdd70060 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/upload-dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/upload-dialog.tsx @@ -19,7 +19,7 @@ import { } from '@langgenius/dify-ui/dialog' import { FileTreeIcon } from '@langgenius/dify-ui/file-tree' import { IconButton } from '@langgenius/dify-ui/icon-button' -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQuery } from '@tanstack/react-query' import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks' @@ -28,7 +28,6 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { toast } from '@/app/notifications' import { getFileIconType } from '@/features/agent-v2/file-icon' import { consoleQuery } from '@/service/console' -import { useFileUploadConfig } from '@/service/use-common' import { formatFileSize } from '@/utils/format' function toAgentFileNode(committedFile: AgentConfigFileItemResponse): AgentFileNode { @@ -52,7 +51,7 @@ function AgentFileUploader({ file, onChange }: { file?: File; onChange: (file?: const { t } = useTranslation(['agentV2']) const { t: tAppDebug } = useTranslation(['appDebug']) const { t: tCommon } = useTranslation(['common']) - const { data: fileUploadConfig } = useFileUploadConfig() + const { data: fileUploadConfig } = useQuery(consoleQuery.files.upload.get.queryOptions()) const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit } = useFileSizeLimit(fileUploadConfig) const fileInputRef = useRef(null) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/__tests__/field.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/__tests__/field.spec.tsx index b3c594b6a20c55..d6eaa801baf9bf 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/__tests__/field.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/__tests__/field.spec.tsx @@ -5,6 +5,16 @@ import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { AgentModelField } from '../field' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.modelProviders.byProvider.models.parameterRules.get': () => ({ + data: { data: [] }, + isLoading: false, + }), + }) +}) + const providerSummary = vi.hoisted(() => vi.fn()) const modelList = vi.hoisted(() => vi.fn<() => Promise<{ data: ProviderWithModelsResponse[] }>>()) @@ -14,10 +24,6 @@ vi.mock('nuqs', async (importOriginal) => ({ useQueryState: () => [null, vi.fn()], })) -vi.mock('@/service/use-common', () => ({ - useModelParameterRules: () => ({ data: { data: [] }, isLoading: false }), -})) - vi.mock( '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({ @@ -25,38 +31,51 @@ vi.mock( }), ) -vi.mock('@/service/console', () => ({ - consoleQuery: { - workspaces: { - current: { - models: { - modelTypes: { - byModelType: { +vi.mock('@/service/console', async () => { + const { consoleOperationStub } = await import('@/test/console/operation-hooks') + return { + consoleQuery: { + files: { upload: { get: consoleOperationStub('files.upload.get') } }, + workspaces: { + current: { + models: { + modelTypes: { + byModelType: { + get: { + queryOptions: (options: object) => ({ + queryKey: ['model-list'], + queryFn: modelList, + ...options, + }), + }, + }, + }, + }, + modelProviders: { + byProvider: { + models: { + parameterRules: { + get: consoleOperationStub( + 'workspaces.current.modelProviders.byProvider.models.parameterRules.get', + ), + }, + }, + }, + summary: { get: { queryOptions: (options: object) => ({ - queryKey: ['model-list'], - queryFn: modelList, + queryKey: ['providers'], + queryFn: providerSummary, ...options, }), }, }, }, }, - modelProviders: { - summary: { - get: { - queryOptions: (options: object) => ({ - queryKey: ['providers'], - queryFn: providerSummary, - ...options, - }), - }, - }, - }, }, }, - }, -})) + } +}) function renderField(currentModel?: AgentComposerModel) { const queryClient = new QueryClient({ diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx index b00c970d2da5fe..835dd189af76ed 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx @@ -98,10 +98,6 @@ vi.mock('@/utils/download', () => ({ downloadUrl: mocks.downloadUrl, })) -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ data: mocks.fileUploadConfig }), -})) - vi.mock('@/config', async (importOriginal) => ({ ...(await importOriginal()), API_PREFIX: 'http://localhost:5001/console/api', @@ -115,62 +111,24 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('@/service/console', async (importOriginal) => ({ - consoleQuery: { - features: (await importOriginal()).consoleQuery.features, - tags: { - get: { - queryOptions: mocks.workspaceSkillTagsQueryOptions, - }, - }, - agent: { - byAgentId: { - composer: { - get: { - key: vi.fn((_options: unknown): unknown[] => ['agent-composer']), - }, +vi.mock('@/service/console', async (importOriginal) => { + const { consoleOperationStub } = await import('@/test/console/operation-hooks') + return { + consoleQuery: { + files: { upload: { get: consoleOperationStub('files.upload.get') } }, + features: (await importOriginal()).consoleQuery.features, + tags: { + get: { + queryOptions: mocks.workspaceSkillTagsQueryOptions, }, - config: { - skills: { - upload: { - post: { - mutationOptions: () => ({ mutationFn: mocks.uploadSkillMutationFn }), - }, - }, - byName: { - delete: { - mutationOptions: () => ({ mutationFn: mocks.deleteSkillMutationFn }), - }, - download: { - get: { - queryOptions: mocks.skillDownloadQueryOptions, - }, - }, - inspect: { - get: { - queryOptions: mocks.inspectQueryOptions, - }, - }, - files: { - preview: { - get: { - queryOptions: mocks.previewQueryOptions, - }, - }, - download: { - get: { - queryOptions: mocks.downloadQueryOptions, - }, - }, - }, + }, + agent: { + byAgentId: { + composer: { + get: { + key: vi.fn((_options: unknown): unknown[] => ['agent-composer']), }, }, - }, - }, - }, - apps: { - byAppId: { - agent: { config: { skills: { upload: { @@ -209,52 +167,94 @@ vi.mock('@/service/console', async (importOriginal) => ({ }, }, }, - }, - trialApps: { - byAppId: { - agent: { - config: { - skills: { - byName: { - download: { get: { queryOptions: mocks.skillDownloadQueryOptions } }, - inspect: { get: { queryOptions: mocks.inspectQueryOptions } }, - files: { - preview: { get: { queryOptions: mocks.previewQueryOptions } }, - download: { get: { queryOptions: mocks.downloadQueryOptions } }, + apps: { + byAppId: { + agent: { + config: { + skills: { + upload: { + post: { + mutationOptions: () => ({ mutationFn: mocks.uploadSkillMutationFn }), + }, + }, + byName: { + delete: { + mutationOptions: () => ({ mutationFn: mocks.deleteSkillMutationFn }), + }, + download: { + get: { + queryOptions: mocks.skillDownloadQueryOptions, + }, + }, + inspect: { + get: { + queryOptions: mocks.inspectQueryOptions, + }, + }, + files: { + preview: { + get: { + queryOptions: mocks.previewQueryOptions, + }, + }, + download: { + get: { + queryOptions: mocks.downloadQueryOptions, + }, + }, + }, }, }, }, }, }, }, - }, - workspaces: { - current: { - agents: { - byAgentId: { - skills: { - get: { - key: mocks.agentSkillBindingsKey, - queryOptions: mocks.agentSkillBindingsQueryOptions, - }, - put: { - mutationOptions: () => ({ - mutationFn: mocks.replaceAgentSkillBindingsMutationFn, - }), + trialApps: { + byAppId: { + agent: { + config: { + skills: { + byName: { + download: { get: { queryOptions: mocks.skillDownloadQueryOptions } }, + inspect: { get: { queryOptions: mocks.inspectQueryOptions } }, + files: { + preview: { get: { queryOptions: mocks.previewQueryOptions } }, + download: { get: { queryOptions: mocks.downloadQueryOptions } }, + }, + }, }, }, }, }, - skills: { - get: { - queryOptions: mocks.workspaceSkillsQueryOptions, - infiniteOptions: mocks.workspaceSkillsInfiniteOptions, + }, + workspaces: { + current: { + agents: { + byAgentId: { + skills: { + get: { + key: mocks.agentSkillBindingsKey, + queryOptions: mocks.agentSkillBindingsQueryOptions, + }, + put: { + mutationOptions: () => ({ + mutationFn: mocks.replaceAgentSkillBindingsMutationFn, + }), + }, + }, + }, + }, + skills: { + get: { + queryOptions: mocks.workspaceSkillsQueryOptions, + infiniteOptions: mocks.workspaceSkillsInfiniteOptions, + }, }, }, }, }, - }, -})) + } +}) async function openUploadSkillDialog(user: ReturnType) { await user.click( @@ -370,6 +370,13 @@ function renderAgentSkills({ } } +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': () => ({ data: mocks.fileUploadConfig }), + }) +}) + describe('AgentSkills', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx index 4ca8ae336aef8a..20f3f0e89c1873 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx @@ -15,14 +15,13 @@ import { DialogTitle, } from '@langgenius/dify-ui/dialog' import { IconButton } from '@langgenius/dify-ui/icon-button' -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQuery } from '@tanstack/react-query' import { useRef, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks' import { toast } from '@/app/notifications' import Link from '@/next/link' import { consoleQuery } from '@/service/console' -import { useFileUploadConfig } from '@/service/use-common' import { formatFileSize } from '@/utils/format' const skillPackageAccept = '.zip,.skill' @@ -71,7 +70,7 @@ function AgentSkillPackageUploader({ }) { const { t } = useTranslation(['agentV2']) const { t: tCommon } = useTranslation(['common']) - const { data: fileUploadConfig } = useFileUploadConfig() + const { data: fileUploadConfig } = useQuery(consoleQuery.files.upload.get.queryOptions()) const { skillSizeLimit } = useFileSizeLimit(fileUploadConfig) const fileInputRef = useRef(null) const dragDepthRef = useRef(0) diff --git a/web/features/code-based-extension/query.ts b/web/features/code-based-extension/query.ts new file mode 100644 index 00000000000000..3bc7b14f50e39e --- /dev/null +++ b/web/features/code-based-extension/query.ts @@ -0,0 +1,20 @@ +import type { + CodeBasedExtension, + CodeBasedExtensionForm, + CodeBasedExtensionItem, +} from '@/models/common' +import { consoleQuery } from '@/service/console' + +// `label` and `form_schema` are plugin-defined JSON in the console API response. +export const codeBasedExtensionsQueryOptions = (module: string) => + consoleQuery.codeBasedExtension.get.queryOptions({ + input: { query: { module } }, + select: (response): CodeBasedExtension => ({ + module: response.module, + data: response.data.map((item) => ({ + name: item.name, + label: (item.label ?? {}) as CodeBasedExtensionItem['label'], + form_schema: (item.form_schema ?? []) as CodeBasedExtensionForm[], + })), + }), + }) diff --git a/web/features/skills/__tests__/detail-page-builder.spec.tsx b/web/features/skills/__tests__/detail-page-builder.spec.tsx index 1fab9ebc3b631e..24afffa3267c24 100644 --- a/web/features/skills/__tests__/detail-page-builder.spec.tsx +++ b/web/features/skills/__tests__/detail-page-builder.spec.tsx @@ -560,7 +560,10 @@ describe('SkillDetailPage builder', () => { expect(mocks.modelParameterRulesFetch).toHaveBeenCalledTimes(1) const request = mocks.modelParameterRulesFetch.mock.calls[0]![0] const url = new URL(request instanceof Request ? request.url : String(request)) - expect(url.pathname).toContain('/model-providers/langgenius/anthropic/anthropic/') + // Provider ids contain slashes and are sent percent-encoded. + expect(decodeURIComponent(url.pathname)).toContain( + '/model-providers/langgenius/anthropic/anthropic/', + ) expect(url.searchParams.get('model')).toBe('claude-sonnet-4-6') }) diff --git a/web/features/skills/__tests__/detail-page.fixture.tsx b/web/features/skills/__tests__/detail-page.fixture.tsx index 382d28063dcf8b..5580980e422511 100644 --- a/web/features/skills/__tests__/detail-page.fixture.tsx +++ b/web/features/skills/__tests__/detail-page.fixture.tsx @@ -209,6 +209,7 @@ vi.mock('@/service/console', async (importOriginal) => { workspaces: { current: { models: actual.consoleQuery.workspaces.current.models, + modelProviders: actual.consoleQuery.workspaces.current.modelProviders, agents: { byAgentId: { skills: { @@ -688,6 +689,8 @@ export function resetDetailPageFixture() { mocks.request.mockImplementation(async (_url, _init, options) => { const req = options.request const pathname = new URL(req.url).pathname + // Model parameter rules go through the console client and land on the fetch mock. + if (pathname.endsWith('/models/parameter-rules')) return mocks.modelParameterRulesFetch(req) if (req.method === 'POST' && /\/skills\/[^/]+\/restore$/.test(pathname)) return Response.json(mocks.skillDetail) if (req.method === 'DELETE' && /\/skills\/[^/]+\/versions\/[^/]+$/.test(pathname)) diff --git a/web/hooks/use-pay.tsx b/web/hooks/use-pay.tsx index 8f0132b46c7445..e254f60c71bd1b 100644 --- a/web/hooks/use-pay.tsx +++ b/web/hooks/use-pay.tsx @@ -8,10 +8,11 @@ import { AlertDialogFooter, AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' +import { skipToken, useQuery } from '@tanstack/react-query' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useRouter, useSearchParams } from '@/next/navigation' -import { useNotionBinding } from '@/service/use-common' +import { consoleQuery } from '@/service/console' type ConfirmType = { type: 'info' | 'warning' @@ -76,7 +77,14 @@ const useCheckNotion = () => { const type = searchParams.get('type') const notionCode = searchParams.get('code') const notionError = searchParams.get('error') - const { data } = useNotionBinding(notionCode, canBinding) + const { data } = useQuery( + consoleQuery.oauth.dataSource.binding.byProvider.get.queryOptions({ + input: + notionCode && canBinding + ? { params: { provider: 'notion' }, query: { code: notionCode } } + : skipToken, + }), + ) useEffect(() => { if (data) router.replace('/') diff --git a/web/models/common.ts b/web/models/common.ts index 523703da211b25..e42edd44ad4b78 100644 --- a/web/models/common.ts +++ b/web/models/common.ts @@ -1,4 +1,5 @@ import type { AppConfigJsonValue } from '@dify/contracts/api/console/apps/types.gen' +import type { UploadConfig } from '@dify/contracts/api/console/files/types.gen' import type { AccountWithRoleResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { I18nText } from '@/i18n/language' import type { Model } from '@/types/app' @@ -55,20 +56,7 @@ export const DataSourceProvider = { } as const export type DataSourceProvider = (typeof DataSourceProvider)[keyof typeof DataSourceProvider] -export type FileUploadConfigResponse = { - batch_count_limit: number - image_file_size_limit?: number | string // default is 10MB - image_file_batch_limit: number // default is 10, for dataset attachment upload only - single_chunk_attachment_limit: number // default is 10, for dataset attachment upload only - attachment_image_file_size_limit: number // default is 2MB, for dataset attachment upload only - file_size_limit: number // default is 15MB - knowledge_file_size_limit?: number // current workspace's knowledge upload limit in MB - audio_file_size_limit?: number // default is 50MB - video_file_size_limit?: number // default is 100MB - skill_file_size_limit?: number // default is 50MB - workflow_file_upload_limit?: number // default is 10 - file_upload_limit: number // default is 5 -} +export type FileUploadConfigResponse = UploadConfig export type CodeBasedExtensionForm = { type: string diff --git a/web/service/annotation.spec.ts b/web/service/annotation.spec.ts index 06013c446e9293..14853d6855836e 100644 --- a/web/service/annotation.spec.ts +++ b/web/service/annotation.spec.ts @@ -1,9 +1,11 @@ import { AnnotationEnableStatus } from '@/app/components/app/annotation/type' import { updateAnnotationStatus } from './annotation' -import { post } from './base' +import { consoleClient } from './console' -vi.mock('./base', () => ({ - post: vi.fn(), +vi.mock('./console', () => ({ + consoleClient: { + apps: { byAppId: { annotationReply: { byAction: { post: vi.fn() } } } }, + }, })) describe('annotation service', () => { @@ -22,7 +24,8 @@ describe('annotation service', () => { 0, ) - expect(post).toHaveBeenCalledWith('apps/app-1/annotation-reply/enable', { + expect(consoleClient.apps.byAppId.annotationReply.byAction.post).toHaveBeenCalledWith({ + params: { app_id: 'app-1', action: 'enable' }, body: { embedding_model_name: 'model', embedding_provider_name: 'provider', diff --git a/web/service/use-common.ts b/web/service/use-common.ts index 7c4f2d3efee16a..6a5dab812ebfde 100644 --- a/web/service/use-common.ts +++ b/web/service/use-common.ts @@ -1,118 +1,18 @@ -import type { - ModelParameterRule, - ModelProvider, - ModelTypeEnum, -} from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { - CodeBasedExtension, - CommonResponse, - FileUploadConfigResponse, - StructuredOutputRulesRequestBody, - StructuredOutputRulesResponse, -} from '@/models/common' -import { queryOptions, useMutation, useQuery } from '@tanstack/react-query' +import type { CommonResponse } from '@/models/common' +import { useMutation, useQuery } from '@tanstack/react-query' import { discardRegistrationSessionState } from '@/app/components/base/amplitude/registration-session-state' import { resetUser } from '@/app/components/base/amplitude/utils' import { clearPageLeaveGuards, confirmPageLeave } from '@/utils/page-leave-guard' import { basePath } from '@/utils/var' // oxlint-disable-next-line no-restricted-imports -import { get, post } from './base' +import { post } from './base' import { consoleQuery } from './console' const NAME_SPACE = 'common' export const commonQueryKeys = { - fileUploadConfig: [NAME_SPACE, 'file-upload-config'] as const, - filePreview: (fileID: string) => [NAME_SPACE, 'file-preview', fileID] as const, - schemaDefinitions: [NAME_SPACE, 'schema-type-definitions'] as const, - modelProviders: [NAME_SPACE, 'model-providers'] as const, - modelProviderDetails: [NAME_SPACE, 'model-provider-details'] as const, - defaultModel: (type: ModelTypeEnum) => [NAME_SPACE, 'default-model', type] as const, - accountIntegrates: [NAME_SPACE, 'account-integrates'] as const, - notionConnection: [NAME_SPACE, 'notion-connection'] as const, - codeBasedExtensions: (module?: string) => [NAME_SPACE, 'code-based-extensions', module] as const, - invitationCheck: (params?: { workspace_id?: string; email?: string; token?: string }) => - [ - NAME_SPACE, - 'invitation-check', - params?.workspace_id ?? '', - params?.email ?? '', - params?.token ?? '', - ] as const, - notionBinding: (code?: string | null) => [NAME_SPACE, 'notion-binding', code] as const, - modelParameterRules: (provider?: string, model?: string) => - [NAME_SPACE, 'model-parameter-rules', provider, model] as const, - langGeniusVersion: (currentVersion?: string | null) => - [NAME_SPACE, 'langgenius-version', currentVersion] as const, forgotPasswordValidity: (token?: string | null) => [NAME_SPACE, 'forgot-password-validity', token] as const, - dataSourceIntegrates: [NAME_SPACE, 'data-source-integrates'] as const, -} - -export const useFileUploadConfig = () => { - return useQuery({ - queryKey: commonQueryKeys.fileUploadConfig, - queryFn: () => get('/files/upload'), - }) -} - -export const useGenerateStructuredOutputRules = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'generate-structured-output-rules'], - mutationFn: (body: StructuredOutputRulesRequestBody) => { - return post('/rule-structured-output-generate', { body }) - }, - }) -} - -export type MailSendResponse = { data: string; result: string } -export const useSendMail = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'mail-send'], - mutationFn: (body: { email: string; language: string }) => { - return post('/email-register/send-email', { body }) - }, - }) -} - -export type MailValidityResponse = { is_valid: boolean; token: string } - -export const useMailValidity = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'mail-validity'], - mutationFn: (body: { email: string; code: string; token: string }) => { - return post('/email-register/validity', { body }) - }, - }) -} - -export type MailRegisterResponse = { result: string; data: Record } - -export const useMailRegister = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'mail-register'], - mutationFn: (body: { - token: string - new_password: string - password_confirm: string - language?: string - timezone?: string - }) => { - return post('/email-register', { body }) - }, - }) -} - -type FilePreviewResponse = { - content: string -} - -export const useFilePreview = (fileID: string) => { - return useQuery({ - queryKey: commonQueryKeys.filePreview(fileID), - queryFn: () => get(`/files/${fileID}/preview`), - enabled: !!fileID, - }) } export type SchemaTypeDefinition = { @@ -122,13 +22,6 @@ export type SchemaTypeDefinition = { } } -export const useSchemaTypeDefinitions = () => { - return useQuery({ - queryKey: commonQueryKeys.schemaDefinitions, - queryFn: () => get('/spec/schema-definitions'), - }) -} - export const useLogout = ({ redirectTo = '/signin' }: { redirectTo?: string } = {}) => { return useMutation( consoleQuery.logout.post.mutationOptions({ @@ -160,6 +53,8 @@ export const useLogout = ({ redirectTo = '/signin' }: { redirectTo?: string } = } type ForgotPasswordValidity = CommonResponse & { is_valid: boolean; email: string; token: string } +// Not migrated to the console contract: the endpoint requires `email` and `code`, which the +// token-only reset link flow does not have, so this request cannot succeed as written. export const useVerifyForgotPasswordToken = (token?: string | null) => { return useQuery({ queryKey: commonQueryKeys.forgotPasswordValidity(token), @@ -170,79 +65,3 @@ export const useVerifyForgotPasswordToken = (token?: string | null) => { retry: false, }) } - -type OneMoreStepPayload = { - invitation_code: string - interface_language: string - timezone: string -} -export const useOneMoreStep = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'one-more-step'], - mutationFn: (body: OneMoreStepPayload) => post('/account/init', { body }), - }) -} - -export const modelProviderDetailsQueryOptions = () => - queryOptions({ - queryKey: commonQueryKeys.modelProviderDetails, - queryFn: () => get<{ data: ModelProvider[] }>('/workspaces/current/model-providers'), - }) - -export const useModelProviderDetails = (enabled = true) => { - return useQuery({ - ...modelProviderDetailsQueryOptions(), - enabled, - }) -} - -export const useCodeBasedExtensions = (module: string) => { - return useQuery({ - queryKey: commonQueryKeys.codeBasedExtensions(module), - queryFn: () => get(`/code-based-extension?module=${module}`), - }) -} - -export const useInvitationCheck = ( - params?: { workspace_id?: string; email?: string; token?: string }, - enabled?: boolean, -) => { - return useQuery({ - queryKey: commonQueryKeys.invitationCheck(params), - queryFn: () => - get<{ - is_valid: boolean - data: { - workspace_name: string - email: string - workspace_id: string - account_status?: string - requires_setup?: boolean - } - result: string - }>('/activate/check', { params }), - enabled: enabled ?? !!params?.token, - retry: false, - }) -} - -export const useNotionBinding = (code?: string | null, enabled?: boolean) => { - return useQuery({ - queryKey: commonQueryKeys.notionBinding(code), - queryFn: () => - get<{ result: string }>('/oauth/data-source/binding/notion', { params: { code } }), - enabled: !!code && (enabled ?? true), - }) -} - -export const useModelParameterRules = (provider?: string, model?: string, enabled?: boolean) => { - return useQuery<{ data: ModelParameterRule[] }>({ - queryKey: commonQueryKeys.modelParameterRules(provider, model), - queryFn: () => - get<{ data: ModelParameterRule[] }>( - `/workspaces/current/model-providers/${provider}/models/parameter-rules`, - { params: { model }, silent: true }, - ), - enabled: !!provider && !!model && (enabled ?? true), - }) -} diff --git a/web/test/console/operation-hooks.ts b/web/test/console/operation-hooks.ts index 2b9007eb236b6f..7dba890c5a485a 100644 --- a/web/test/console/operation-hooks.ts +++ b/web/test/console/operation-hooks.ts @@ -42,3 +42,24 @@ export const mockConsoleOperationHooks = (actual: typeof ReactQuery, routes: Ope return result.handled ? result.value : actual.useMutation(options as never) }) as typeof actual.useMutation, }) + +/** + * Stands in for one console operation inside a fully mocked `@/service/console` + * module, producing the same key shape the real utilities use. + */ +export const consoleOperationStub = (path: string) => { + const queryKey = (options?: { input?: unknown }) => + [['console', ...path.split('.')], { type: 'query', input: options?.input }] as const + return { + key: () => [['console', ...path.split('.')]] as const, + queryKey, + queryOptions: (options: { input?: unknown } = {}) => ({ + ...options, + queryKey: queryKey(options), + }), + mutationOptions: (options: object = {}) => ({ + ...options, + mutationKey: [['console', ...path.split('.')], { type: 'mutation' }], + }), + } +} diff --git a/web/test/fixtures/file-upload-config.ts b/web/test/fixtures/file-upload-config.ts new file mode 100644 index 00000000000000..add846613e982c --- /dev/null +++ b/web/test/fixtures/file-upload-config.ts @@ -0,0 +1,19 @@ +import type { UploadConfig } from '@dify/contracts/api/console/files/types.gen' + +export const createFileUploadConfigFixture = ( + overrides: Partial = {}, +): UploadConfig => ({ + attachment_image_file_size_limit: 2, + audio_file_size_limit: 50, + batch_count_limit: 5, + file_size_limit: 15, + file_upload_limit: 5, + image_file_batch_limit: 10, + image_file_size_limit: 10, + knowledge_file_size_limit: 15, + single_chunk_attachment_limit: 10, + skill_file_size_limit: 50, + video_file_size_limit: 100, + workflow_file_upload_limit: 10, + ...overrides, +}) From 5c9f95187420cf46b2b885c5d58079cfb05b5bf8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:23:34 +0000 Subject: [PATCH 14/60] [autofix.ci] apply automated fixes --- api/openapi/markdown/console-openapi.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index af447963abaf45..ef3121bd337497 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -9399,7 +9399,7 @@ Generate structured output rules using LLM | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Structured output generated successfully | **application/json**: [GeneratorResponse](#generatorresponse)
| +| 200 | Structured output generated successfully | **application/json**: [StructuredOutputGenerateResponse](#structuredoutputgenerateresponse)
| | 400 | Invalid request parameters | **application/json**: [CompletionRequestError](#completionrequesterror)
[ModelCurrentlyNotSupportError](#modelcurrentlynotsupporterror)
[ProviderNotInitializeError](#providernotinitializeerror)
[ProviderQuotaExceededError](#providerquotaexceedederror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| @@ -19238,6 +19238,14 @@ TEAM: Team collaboration paid plan | ---- | ---- | ----------- | -------- | | CloudPlan | string | Enum representing user plan types in the cloud platform. SANDBOX: Free/default plan with limited features PROFESSIONAL: Professional paid plan TEAM: Team collaboration paid plan | | +#### CodeBasedExtensionItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| form_schema | [ object ] | | No | +| label | object | | No | +| name | string | | Yes | + #### CodeBasedExtensionQuery | Name | Type | Description | Required | @@ -19248,7 +19256,7 @@ TEAM: Team collaboration paid plan | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | | Extension data | Yes | +| data | [ [CodeBasedExtensionItemResponse](#codebasedextensionitemresponse) ] | Non-builtin extensions of the module | Yes | | module | string | Module name | Yes | #### CompletionConversationQuery @@ -26722,6 +26730,13 @@ Default configuration for form inputs. | type | [ValueSourceType](#valuesourcetype) | | Yes | | value | string | | No | +#### StructuredOutputGenerateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| error | string | Failure reason, empty on success | Yes | +| output | string | Generated JSON Schema, empty when generation failed | Yes | + #### SubscriptionBuilderApiEntity | Name | Type | Description | Required | From 4d2c64df241d56235cf2472e2ef74c6dedb7d1a3 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:32:01 +0800 Subject: [PATCH 15/60] fix: repair tests, unused exports and i18n keys after console contract migration Co-Authored-By: Claude Opus 5.5 --- .../controllers/console/app/test_message.py | 4 +- .../console/agent/test_agent_controllers.py | 4 +- .../base/notion-page-selector-flow.test.tsx | 30 +++++++++++---- web/__tests__/file-supported-formats.spec.tsx | 15 +++++--- web/app/components/app/annotation/type.ts | 6 --- web/app/components/base/chat/chat/type.ts | 2 +- web/i18n/locales/ar-TN/dataset.json | 1 - web/i18n/locales/ar-TN/login.json | 1 - web/i18n/locales/az-AZ/dataset.json | 1 - web/i18n/locales/az-AZ/login.json | 1 - web/i18n/locales/de-DE/dataset.json | 1 - web/i18n/locales/de-DE/login.json | 1 - web/i18n/locales/en-US/dataset.json | 1 - web/i18n/locales/en-US/login.json | 1 - web/i18n/locales/es-ES/dataset.json | 1 - web/i18n/locales/es-ES/login.json | 1 - web/i18n/locales/fa-IR/dataset.json | 1 - web/i18n/locales/fa-IR/login.json | 1 - web/i18n/locales/fr-FR/dataset.json | 1 - web/i18n/locales/fr-FR/login.json | 1 - web/i18n/locales/hi-IN/dataset.json | 1 - web/i18n/locales/hi-IN/login.json | 1 - web/i18n/locales/id-ID/dataset.json | 1 - web/i18n/locales/id-ID/login.json | 1 - web/i18n/locales/it-IT/dataset.json | 1 - web/i18n/locales/it-IT/login.json | 1 - web/i18n/locales/ja-JP/dataset.json | 1 - web/i18n/locales/ja-JP/login.json | 1 - web/i18n/locales/ko-KR/dataset.json | 1 - web/i18n/locales/ko-KR/login.json | 1 - web/i18n/locales/lo-LA/dataset.json | 1 - web/i18n/locales/lo-LA/login.json | 1 - web/i18n/locales/nl-NL/dataset.json | 1 - web/i18n/locales/nl-NL/login.json | 1 - web/i18n/locales/pl-PL/dataset.json | 1 - web/i18n/locales/pl-PL/login.json | 1 - web/i18n/locales/pt-BR/dataset.json | 1 - web/i18n/locales/pt-BR/login.json | 1 - web/i18n/locales/ro-RO/dataset.json | 1 - web/i18n/locales/ro-RO/login.json | 1 - web/i18n/locales/ru-RU/dataset.json | 1 - web/i18n/locales/ru-RU/login.json | 1 - web/i18n/locales/sl-SI/dataset.json | 1 - web/i18n/locales/sl-SI/login.json | 1 - web/i18n/locales/th-TH/dataset.json | 1 - web/i18n/locales/th-TH/login.json | 1 - web/i18n/locales/tr-TR/dataset.json | 1 - web/i18n/locales/tr-TR/login.json | 1 - web/i18n/locales/uk-UA/dataset.json | 1 - web/i18n/locales/uk-UA/login.json | 1 - web/i18n/locales/vi-VN/dataset.json | 1 - web/i18n/locales/vi-VN/login.json | 1 - web/i18n/locales/zh-Hans/dataset.json | 1 - web/i18n/locales/zh-Hans/login.json | 1 - web/i18n/locales/zh-Hant/dataset.json | 1 - web/i18n/locales/zh-Hant/login.json | 1 - web/i18n/resources.generated.d.ts | 2 - web/models/access-control.ts | 37 ------------------- web/models/common.ts | 16 -------- web/service/use-common.ts | 2 +- web/test/fixtures/hit-testing.ts | 2 +- 61 files changed, 39 insertions(+), 131 deletions(-) diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_message.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_message.py index 840619fa261c50..acc819db864f87 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/app/test_message.py +++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_message.py @@ -10,7 +10,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session -from controllers.console.app.message import ChatMessagesQuery, MessageFeedbackPayload +from controllers.console.app.message import AppMessageFeedbackPayload, ChatMessagesQuery from controllers.console.app.message import attach_message_extra_contents as _attach_message_extra_contents from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from libs.datetime_utils import naive_utc_now @@ -99,7 +99,7 @@ def test_chat_messages_query_validators(self) -> None: def test_message_feedback_validators(self) -> None: assert ( - MessageFeedbackPayload.validate_message_id("123e4567-e89b-12d3-a456-426614174000") + AppMessageFeedbackPayload.validate_message_id("123e4567-e89b-12d3-a456-426614174000") == "123e4567-e89b-12d3-a456-426614174000" ) diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index cb71e5ebea7225..4f46d43a209920 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -2229,7 +2229,7 @@ def get_message_detail(**kwargs: object) -> dict[str, object]: with app.test_request_context(json={"message_id": message_id, "rating": "like"}): assert unwrap(AgentMessageFeedbackApi.post)( AgentMessageFeedbackApi(), - message_controller.MessageFeedbackPayload(message_id=message_id, rating="like"), + message_controller.AppMessageFeedbackPayload(message_id=message_id, rating="like"), unbound_session, "tenant-1", current_user, @@ -2389,7 +2389,7 @@ def test_update_message_feedback_rejects_empty_rating_without_existing_feedback( with app.test_request_context(json={"message_id": message_id, "rating": None}): with pytest.raises(ValueError, match="rating cannot be None"): message_controller._update_message_feedback( - args=message_controller.MessageFeedbackPayload(message_id=message_id, rating=None), + args=message_controller.AppMessageFeedbackPayload(message_id=message_id, rating=None), session=sqlite_session, current_user=_account(), app_model=_app_detail_obj(id=app_id), diff --git a/web/__tests__/base/notion-page-selector-flow.test.tsx b/web/__tests__/base/notion-page-selector-flow.test.tsx index 02f0fdbdd0e43c..5fdf9ed68bf790 100644 --- a/web/__tests__/base/notion-page-selector-flow.test.tsx +++ b/web/__tests__/base/notion-page-selector-flow.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import NotionPageSelector from '@/app/components/base/notion-page-selector/base' import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' +import { consoleQuery } from '@/service/console' const mockInvalidPreImportNotionPages = vi.fn() const mockSetSettingsDestination = vi.fn() @@ -22,11 +23,25 @@ vi.mock('@tanstack/react-virtual', () => ({ }), })) -vi.mock('@/service/knowledge/use-import', () => ({ - usePreImportNotionPages: (params: { datasetId: string; credentialId: string }) => - mockUsePreImportNotionPages(params), - useInvalidPreImportNotionPages: () => mockInvalidPreImportNotionPages, -})) +// The selector reads normalized workspaces from the pre-import pages query and +// invalidates that query when the credential changes. +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useQuery: (options: { + queryKey: [unknown, { input: { query: { dataset_id: string; credential_id: string } } }] + }) => { + const { query } = options.queryKey[1].input + const result = mockUsePreImportNotionPages({ + datasetId: query.dataset_id, + credentialId: query.credential_id, + }) + return { ...result, data: result.data?.notion_info } + }, + useQueryClient: () => ({ invalidateQueries: mockInvalidPreImportNotionPages }), + } +}) vi.mock('nuqs', async (importOriginal) => { const actual = await importOriginal() @@ -180,8 +195,9 @@ describe('Base Notion Page Selector Flow', () => { await user.click(screen.getByRole('option', { name: /Workspace 2/ })) expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ - datasetId: 'dataset-1', - credentialId: 'c2', + queryKey: consoleQuery.notion.preImport.pages.get.queryKey({ + input: { query: { dataset_id: 'dataset-1', credential_id: 'c2' } }, + }), }) expect(onSelect).toHaveBeenCalledWith([]) diff --git a/web/__tests__/file-supported-formats.spec.tsx b/web/__tests__/file-supported-formats.spec.tsx index 997e5c5a28eb8d..2fe8d812d92314 100644 --- a/web/__tests__/file-supported-formats.spec.tsx +++ b/web/__tests__/file-supported-formats.spec.tsx @@ -9,6 +9,15 @@ import { LanguagesSupported } from '@/i18n/language' import { consoleQuery } from '@/service/console' import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'files.upload.get': () => ({ + data: { file_size_limit: 15, batch_count_limit: 5, file_upload_limit: 10 }, + }), + }) +}) + let mockLocale = 'en-US' const request = vi.hoisted(() => vi.fn<(url: string) => Promise>()) @@ -17,12 +26,6 @@ vi.mock('@/service/base', async (importOriginal) => ({ request, })) -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ - data: { file_size_limit: 15, batch_count_limit: 5, file_upload_limit: 10 }, - }), -})) - vi.mock('#i18n', async (importOriginal) => ({ ...(await importOriginal()), useLocale: () => mockLocale, diff --git a/web/app/components/app/annotation/type.ts b/web/app/components/app/annotation/type.ts index 832c42450b97ec..bf0f1a6603bb59 100644 --- a/web/app/components/app/annotation/type.ts +++ b/web/app/components/app/annotation/type.ts @@ -8,12 +8,6 @@ export type AnnotationItemBasic = { export type AnnotationItem = Annotation -export type AnnotationCreateResponse = AnnotationItem & { - account?: { - name?: string - } -} - export type HitHistoryItem = AnnotationHitHistory export type EmbeddingModelConfig = { diff --git a/web/app/components/base/chat/chat/type.ts b/web/app/components/base/chat/chat/type.ts index 04f32f79f4f7dc..22299ba1ae4ac6 100644 --- a/web/app/components/base/chat/chat/type.ts +++ b/web/app/components/base/chat/chat/type.ts @@ -134,7 +134,7 @@ export type IChatItem = { extra_contents?: ExtraContent[] } -export type Metadata = { +type Metadata = { retriever_resources?: CitationItem[] annotation_reply: { id: string diff --git a/web/i18n/locales/ar-TN/dataset.json b/web/i18n/locales/ar-TN/dataset.json index 152a2f9a8fef14..6d7c9767e127c1 100644 --- a/web/i18n/locales/ar-TN/dataset.json +++ b/web/i18n/locales/ar-TN/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "بحث النص الكامل", "retrieval.hybrid_search.description": "تنفيذ البحث بالنص الكامل والبحث المتجه في وقت واحد، وإعادة الترتيب لتحديد أفضل تطابق لاستعلام المستخدم. يمكن للمستخدمين اختيار تعيين الأوزان أو التكوين لنموذج إعادة الترتيب.", "retrieval.hybrid_search.title": "بحث هجين", - "retrieval.invertedIndex.title": "الفهرس المعكوس", "retrieval.keyword_search.description": "الفهرس المعكوس هو هيكل يستخدم للاسترجاع الفعال. منظم حسب المصطلحات، يشير كل مصطلح إلى المستندات أو صفحات الويب التي تحتوي عليه.", "retrieval.keyword_search.title": "فهرس معكوس", "retrieval.semantic_search.description": "إنشاء تضمينات الاستعلام والبحث عن قطعة النص الأكثر تشابهًا مع تمثيلها المتجه.", diff --git a/web/i18n/locales/ar-TN/login.json b/web/i18n/locales/ar-TN/login.json index 3b04e6a872a0f1..496eddd191d0c6 100644 --- a/web/i18n/locales/ar-TN/login.json +++ b/web/i18n/locales/ar-TN/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "الاسم مطلوب", "error.passwordEmpty": "كلمة المرور مطلوبة", "error.passwordInvalid": "يجب أن تحتوي كلمة المرور على أحرف وأرقام، ويجب أن يكون الطول أكبر من 8", - "error.registrationNotAllowed": "الحساب غير موجود. يرجى الاتصال بمسؤول النظام للتسجيل.", "error.ssoFailed": "فشل تسجيل الدخول عبر SSO. يرجى المحاولة مرة أخرى أو الاتصال بالمسؤول.", "explore": "استكشاف Dify", "forget": "نسيت كلمة المرور؟", diff --git a/web/i18n/locales/az-AZ/dataset.json b/web/i18n/locales/az-AZ/dataset.json index bd3cd2e260c2de..4a55d59adff586 100644 --- a/web/i18n/locales/az-AZ/dataset.json +++ b/web/i18n/locales/az-AZ/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Tam mətn axtarışı", "retrieval.hybrid_search.description": "Tam mətn axtarışını və vektor axtarışlarını eyni vaxtda icra edin, istifadəçinin sorğusuna ən uyğun nəticəni seçmək üçün yenidən sıralayın. İstifadəçilər çəki əmsallarını təyin edə və ya yenidən sıralama modelini konfiqurasiya edə bilərlər.", "retrieval.hybrid_search.title": "Hibrid axtarış", - "retrieval.invertedIndex.title": "Tərs indeks", "retrieval.keyword_search.description": "Tərs indeks səmərəli məlumat axtarışı üçün istifadə olunan strukturdur. Terminlərə görə təşkil olunur və hər termin onu ehtiva edən sənədlərə və ya veb səhifələrə işarə edir.", "retrieval.keyword_search.title": "Tərs indeks", "retrieval.semantic_search.description": "Sorğunun vektor təsvirini yaradın və bu vektora ən çox bənzəyən vektorla təmsil olunan mətn hissəsini axtarın.", diff --git a/web/i18n/locales/az-AZ/login.json b/web/i18n/locales/az-AZ/login.json index aaf3c0e4abc5bf..f7d6e833f60e9e 100644 --- a/web/i18n/locales/az-AZ/login.json +++ b/web/i18n/locales/az-AZ/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "Ad tələb olunur", "error.passwordEmpty": "Parol tələb olunur", "error.passwordInvalid": "Parolda hərflər və rəqəmlər olmalı, uzunluğu isə 8 simvoldan çox olmalıdır", - "error.registrationNotAllowed": "Hesab tapılmadı. Qeydiyyatdan keçmək üçün sistem inzibatçısı ilə əlaqə saxlayın.", "error.ssoFailed": "SSO ilə giriş uğursuz oldu. Yenidən cəhd edin və ya administratorla əlaqə saxlayın.", "explore": "Dify ilə tanış ol", "forget": "Parolunuzu unutmusunuz?", diff --git a/web/i18n/locales/de-DE/dataset.json b/web/i18n/locales/de-DE/dataset.json index 8eedea7275fba0..23aa1b011aef20 100644 --- a/web/i18n/locales/de-DE/dataset.json +++ b/web/i18n/locales/de-DE/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Volltextsuche", "retrieval.hybrid_search.description": "Führe Volltextsuche und Vektorsuchen gleichzeitig aus, ordne neu, um die beste Übereinstimmung für die Abfrage des Benutzers auszuwählen. Konfiguration des Rerank-Modell-APIs ist notwendig.", "retrieval.hybrid_search.title": "Hybridsuche", - "retrieval.invertedIndex.title": "Invertierter Index", "retrieval.keyword_search.description": "Der invertierte Index ist eine Struktur, die für einen effizienten Abruf verwendet wird. Jeder Begriff ist nach Begriffen geordnet und verweist auf Dokumente oder Webseiten, die ihn enthalten.", "retrieval.keyword_search.title": "Invertierter Index", "retrieval.semantic_search.description": "Erzeuge Abfrage-Einbettungen und suche nach dem Textstück, das seiner Vektorrepräsentation am ähnlichsten ist.", diff --git a/web/i18n/locales/de-DE/login.json b/web/i18n/locales/de-DE/login.json index 00541e80e76cc1..c72c8dcb52c63e 100644 --- a/web/i18n/locales/de-DE/login.json +++ b/web/i18n/locales/de-DE/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "Name wird benötigt", "error.passwordEmpty": "Passwort wird benötigt", "error.passwordInvalid": "Das Passwort muss Buchstaben und Zahlen enthalten und länger als 8 Zeichen sein", - "error.registrationNotAllowed": "Konto nicht gefunden. Bitte wenden Sie sich an den Systemadministrator, um sich zu registrieren.", "error.ssoFailed": "SSO-Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut oder wenden Sie sich an Ihren Administrator.", "explore": "Dify erkunden", "forget": "Passwort vergessen?", diff --git a/web/i18n/locales/en-US/dataset.json b/web/i18n/locales/en-US/dataset.json index ed465f9940abf7..ccde333fc89348 100644 --- a/web/i18n/locales/en-US/dataset.json +++ b/web/i18n/locales/en-US/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Full-Text Search", "retrieval.hybrid_search.description": "Execute full-text search and vector searches simultaneously, re-rank to select the best match for the user's query. Users can choose to set weights or configure to a Rerank model.", "retrieval.hybrid_search.title": "Hybrid Search", - "retrieval.invertedIndex.title": "Inverted Index", "retrieval.keyword_search.description": "Inverted Index is a structure used for efficient retrieval. Organized by terms, each term points to documents or web pages containing it.", "retrieval.keyword_search.title": "Inverted Index", "retrieval.semantic_search.description": "Generate query embeddings and search for the text chunk most similar to its vector representation.", diff --git a/web/i18n/locales/en-US/login.json b/web/i18n/locales/en-US/login.json index dcf7b48cb8d4be..685ec31751e683 100644 --- a/web/i18n/locales/en-US/login.json +++ b/web/i18n/locales/en-US/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "Name is required", "error.passwordEmpty": "Password is required", "error.passwordInvalid": "Password must contain letters and numbers, and the length must be greater than 8", - "error.registrationNotAllowed": "Account not found. Please contact the system admin to register.", "error.ssoFailed": "SSO login failed. Please try again or contact your administrator.", "explore": "Explore Dify", "forget": "Forgot your password?", diff --git a/web/i18n/locales/es-ES/dataset.json b/web/i18n/locales/es-ES/dataset.json index 92f147448c9934..52ee600d53622f 100644 --- a/web/i18n/locales/es-ES/dataset.json +++ b/web/i18n/locales/es-ES/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Búsqueda de Texto Completo", "retrieval.hybrid_search.description": "Ejecuta búsquedas de texto completo y búsquedas vectoriales simultáneamente, reordena para seleccionar la mejor coincidencia para la consulta del usuario. Es necesaria la configuración de las API del modelo de reordenamiento.", "retrieval.hybrid_search.title": "Búsqueda Híbrida", - "retrieval.invertedIndex.title": "Índice invertido", "retrieval.keyword_search.description": "El índice invertido es una estructura utilizada para una recuperación eficiente. Organizado por términos, cada término apunta a documentos o páginas web que lo contienen.", "retrieval.keyword_search.title": "Índice invertido", "retrieval.semantic_search.description": "Genera incrustaciones de consulta y busca el fragmento de texto más similar a su representación vectorial.", diff --git a/web/i18n/locales/es-ES/login.json b/web/i18n/locales/es-ES/login.json index 40457caea9df37..95a4ae3a432a0a 100644 --- a/web/i18n/locales/es-ES/login.json +++ b/web/i18n/locales/es-ES/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "Se requiere un nombre", "error.passwordEmpty": "Se requiere una contraseña", "error.passwordInvalid": "La contraseña debe contener letras y números, y tener una longitud mayor a 8", - "error.registrationNotAllowed": "Cuenta no encontrada. Póngase en contacto con el administrador del sistema para registrarse.", "error.ssoFailed": "Error al iniciar sesión con SSO. Inténtelo de nuevo o póngase en contacto con su administrador.", "explore": "Explorar Dify", "forget": "¿Olvidaste tu contraseña?", diff --git a/web/i18n/locales/fa-IR/dataset.json b/web/i18n/locales/fa-IR/dataset.json index bf8e163eb0d618..d675c31ec25655 100644 --- a/web/i18n/locales/fa-IR/dataset.json +++ b/web/i18n/locales/fa-IR/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "جستجوی متن کامل", "retrieval.hybrid_search.description": "جستجوی متن کامل و برداری را همزمان اجرا می‌کند، دوباره رتبه‌بندی می‌کند تا بهترین تطابق برای درخواست کاربر انتخاب شود. کاربران می‌توانند وزن‌ها را تنظیم کنند یا به یک مدل دوباره رتبه‌بندی تنظیم کنند.", "retrieval.hybrid_search.title": "جستجوی هیبریدی", - "retrieval.invertedIndex.title": "فهرست معکوس", "retrieval.keyword_search.description": "شاخص معکوس ساختاری است که برای بازیابی کارآمد استفاده می شود. هر اصطلاح که بر اساس اصطلاحات سازماندهی شده است، به اسناد یا صفحات وب حاوی آن اشاره می کند.", "retrieval.keyword_search.title": "شاخص معکوس", "retrieval.semantic_search.description": "تولید جاسازی‌های جستجو و جستجوی بخش متنی که بیشترین شباهت را به نمایش برداری آن دارد.", diff --git a/web/i18n/locales/fa-IR/login.json b/web/i18n/locales/fa-IR/login.json index 443dd13c3f8f29..c84df67cda606e 100644 --- a/web/i18n/locales/fa-IR/login.json +++ b/web/i18n/locales/fa-IR/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "نام لازم است", "error.passwordEmpty": "رمز عبور لازم است", "error.passwordInvalid": "رمز عبور باید شامل حروف و اعداد باشد و طول آن بیشتر از ۸ کاراکتر باشد", - "error.registrationNotAllowed": "حساب پیدا نشد. لطفا برای ثبت نام با مدیر سیستم تماس حاصل فرمایید.", "error.ssoFailed": "ورود با SSO ناموفق بود. لطفاً دوباره تلاش کنید یا با مدیر سیستم تماس بگیرید.", "explore": "کاوش Dify", "forget": "رمز عبور خود را فراموش کرده‌اید؟", diff --git a/web/i18n/locales/fr-FR/dataset.json b/web/i18n/locales/fr-FR/dataset.json index 0a4816ad6f49f7..38a6a86ef6924a 100644 --- a/web/i18n/locales/fr-FR/dataset.json +++ b/web/i18n/locales/fr-FR/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Recherche en Texte Intégral", "retrieval.hybrid_search.description": "Exécutez une recherche en texte intégral et des recherches vectorielles en même temps, réorganisez pour sélectionner la meilleure correspondance pour la requête de l'utilisateur. La configuration de l'API du modèle de réorganisation est nécessaire.", "retrieval.hybrid_search.title": "Recherche Hybride", - "retrieval.invertedIndex.title": "Index inversé", "retrieval.keyword_search.description": "L’indice inversé est une structure utilisée pour une récupération efficace. Organisé par termes, chaque terme pointe vers des documents ou des pages web qui le contiennent.", "retrieval.keyword_search.title": "Index inversé", "retrieval.semantic_search.description": "Générez des embeddings de requête et recherchez le morceau de texte le plus similaire à sa représentation vectorielle.", diff --git a/web/i18n/locales/fr-FR/login.json b/web/i18n/locales/fr-FR/login.json index f3ff1b80ccdae0..7bb34e26a97af4 100644 --- a/web/i18n/locales/fr-FR/login.json +++ b/web/i18n/locales/fr-FR/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "Le nom est requis", "error.passwordEmpty": "Un mot de passe est requis", "error.passwordInvalid": "Le mot de passe doit contenir des lettres et des chiffres, et la longueur doit être supérieure à 8.", - "error.registrationNotAllowed": "Compte introuvable. Veuillez contacter l’administrateur système pour vous inscrire.", "error.ssoFailed": "Échec de la connexion SSO. Veuillez réessayer ou contacter votre administrateur.", "explore": "Explorez Dify", "forget": "Mot de passe oublié ?", diff --git a/web/i18n/locales/hi-IN/dataset.json b/web/i18n/locales/hi-IN/dataset.json index 876f9f04eae54d..db59bfebb8c0f3 100644 --- a/web/i18n/locales/hi-IN/dataset.json +++ b/web/i18n/locales/hi-IN/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "पूर्ण-पाठ खोज", "retrieval.hybrid_search.description": "पूर्ण-पाठ खोज और वेक्टर खोजों को एक साथ निष्पादित करें, पुनः रैंकिंग करें और उपयोगकर्ता के प्रश्न के लिए सर्वोत्तम मिलान का चयन करें। रीरैंक मॉडल APIs की कॉन्फ़िगरेशन आवश्यक।", "retrieval.hybrid_search.title": "हाइब्रिड खोज", - "retrieval.invertedIndex.title": "उल्टा सूचकांक", "retrieval.keyword_search.description": "इनवर्टेड इंडेक्स एक संरचना है जो कुशल पुनर्प्राप्ति के लिए उपयोग की जाती है। यह शर्तों द्वारा व्यवस्थित होती है, प्रत्येक शर्त उन दस्तावेजों या वेब पृष्ठों की ओर इशारा करती है जिनमें यह मौजूद होती है।", "retrieval.keyword_search.title": "इनवर्टेड अनुक्रमणिका", "retrieval.semantic_search.description": "प्रश्न एम्बेडिंग्स उत्पन्न करें और उसके वेक्टर प्रतिनिधित्व के समान सबसे मिलते-जुलते टेक्स्ट चंक को खोजें।", diff --git a/web/i18n/locales/hi-IN/login.json b/web/i18n/locales/hi-IN/login.json index e5fd91e9a04ac8..ae1011051ce34b 100644 --- a/web/i18n/locales/hi-IN/login.json +++ b/web/i18n/locales/hi-IN/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "नाम आवश्यक है", "error.passwordEmpty": "पासवर्ड आवश्यक है", "error.passwordInvalid": "पासवर्ड में अक्षर और अंक होने चाहिए, और लंबाई 8 से अधिक होनी चाहिए", - "error.registrationNotAllowed": "खाता नहीं मिला. रजिस्टर करने के लिए कृपया सिस्टम एडमिन से संपर्क करें।", "error.ssoFailed": "SSO लॉगिन विफल रहा। कृपया पुनः प्रयास करें या अपने व्यवस्थापक से संपर्क करें।", "explore": "Dify का अन्वेषण करें", "forget": "क्या आप पासवर्ड भूल गए?", diff --git a/web/i18n/locales/id-ID/dataset.json b/web/i18n/locales/id-ID/dataset.json index b1f8882b988941..841cf1c24fc9db 100644 --- a/web/i18n/locales/id-ID/dataset.json +++ b/web/i18n/locales/id-ID/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Pencarian Teks Lengkap", "retrieval.hybrid_search.description": "Jalankan pencarian teks lengkap dan pencarian vektor secara bersamaan, peringkatkan ulang untuk memilih kecocokan terbaik untuk kueri pengguna. Pengguna dapat memilih untuk mengatur bobot atau mengonfigurasi ke model Rerank.", "retrieval.hybrid_search.title": "Pencarian Hibrida", - "retrieval.invertedIndex.title": "Indeks Terbalik", "retrieval.keyword_search.description": "Indeks Terbalik adalah struktur yang digunakan untuk pengambilan yang efisien. Diatur berdasarkan istilah, setiap istilah menunjuk ke dokumen atau halaman web yang berisinya.", "retrieval.keyword_search.title": "Indeks Terbalik", "retrieval.semantic_search.description": "Hasilkan penyematan kueri dan cari potongan teks yang paling mirip dengan representasi vektornya.", diff --git a/web/i18n/locales/id-ID/login.json b/web/i18n/locales/id-ID/login.json index 4cf103d09273aa..4fe32c1370bb63 100644 --- a/web/i18n/locales/id-ID/login.json +++ b/web/i18n/locales/id-ID/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "Nama diperlukan", "error.passwordEmpty": "Kata sandi diperlukan", "error.passwordInvalid": "Kata sandi harus berisi huruf dan angka, dan panjangnya harus lebih besar dari 8", - "error.registrationNotAllowed": "Akun tidak ditemukan. Silakan hubungi admin sistem untuk mendaftar.", "error.ssoFailed": "Login SSO gagal. Silakan coba lagi atau hubungi administrator Anda.", "explore": "Jelajahi Dify", "forget": "Lupa Kata Sandi Anda?", diff --git a/web/i18n/locales/it-IT/dataset.json b/web/i18n/locales/it-IT/dataset.json index 52b7086b8af8f3..f7b313af17883d 100644 --- a/web/i18n/locales/it-IT/dataset.json +++ b/web/i18n/locales/it-IT/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Ricerca Full-Text", "retrieval.hybrid_search.description": "Esegui contemporaneamente la ricerca full-text e la ricerca vettoriale, riordina per selezionare la migliore corrispondenza per la query dell'utente. È necessaria la configurazione delle API del modello Rerank.", "retrieval.hybrid_search.title": "Ricerca Ibrida", - "retrieval.invertedIndex.title": "Indice Invertito", "retrieval.keyword_search.description": "L'indice invertito è una struttura utilizzata per un recupero efficiente. Organizzato per termini, ogni termine rimanda a documenti o pagine web che lo contengono.", "retrieval.keyword_search.title": "Indice invertito", "retrieval.semantic_search.description": "Genera embedding delle query e cerca il blocco di testo più simile alla sua rappresentazione vettoriale.", diff --git a/web/i18n/locales/it-IT/login.json b/web/i18n/locales/it-IT/login.json index 285db98bf29225..7dbb7d5c3dbe05 100644 --- a/web/i18n/locales/it-IT/login.json +++ b/web/i18n/locales/it-IT/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "Il nome è obbligatorio", "error.passwordEmpty": "La password è obbligatoria", "error.passwordInvalid": "La password deve contenere lettere e numeri, e la lunghezza deve essere maggiore di 8", - "error.registrationNotAllowed": "Account non trovato. Si prega di contattare l'amministratore di sistema per registrarsi.", "error.ssoFailed": "Accesso SSO non riuscito. Riprova o contatta l'amministratore.", "explore": "Esplora Dify", "forget": "Hai dimenticato la password?", diff --git a/web/i18n/locales/ja-JP/dataset.json b/web/i18n/locales/ja-JP/dataset.json index 3829cd603439ec..4e77bef83a7616 100644 --- a/web/i18n/locales/ja-JP/dataset.json +++ b/web/i18n/locales/ja-JP/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "全文検索", "retrieval.hybrid_search.description": "全文検索とベクトル検索を同時に実行し、ユーザーのクエリに最適なマッチを選択するために Rerank 付けを行います。Rerank モデル API の設定が必要です。", "retrieval.hybrid_search.title": "ハイブリッド検索", - "retrieval.invertedIndex.title": "転置インデックス", "retrieval.keyword_search.description": "逆インデックスは効率的な検索のために使用される構造です。用語によって整理されており、各用語はそれを含む文書やウェブページを指し示します。", "retrieval.keyword_search.title": "逆インデックス", "retrieval.semantic_search.description": "クエリの埋め込みを生成し、そのベクトル表現に最も類似したテキストチャンクを検索します。", diff --git a/web/i18n/locales/ja-JP/login.json b/web/i18n/locales/ja-JP/login.json index 848bc3312c7f1a..1830414f4e98f8 100644 --- a/web/i18n/locales/ja-JP/login.json +++ b/web/i18n/locales/ja-JP/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "名前は必須です", "error.passwordEmpty": "パスワードは必須です", "error.passwordInvalid": "パスワードは文字と数字を含み、長さは 8 以上である必要があります", - "error.registrationNotAllowed": "アカウントが見つかりません。登録するためにシステム管理者に連絡してください。", "error.ssoFailed": "SSO ログインに失敗しました。もう一度お試しいただくか、管理者にお問い合わせください。", "explore": "Dify を探索する", "forget": "パスワードをお忘れですか?", diff --git a/web/i18n/locales/ko-KR/dataset.json b/web/i18n/locales/ko-KR/dataset.json index 258f229b994e40..682652b895a361 100644 --- a/web/i18n/locales/ko-KR/dataset.json +++ b/web/i18n/locales/ko-KR/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "전체 텍스트 검색", "retrieval.hybrid_search.description": "전체 텍스트 검색과 벡터 검색을 동시에 실행하고 사용자 쿼리에 가장 적합한 매치를 선택하기 위해 다시 랭크를 매깁니다. 재랭크 모델 API 설정이 필요합니다.", "retrieval.hybrid_search.title": "하이브리드 검색", - "retrieval.invertedIndex.title": "역인덱스", "retrieval.keyword_search.description": "역인덱스는 효율적인 검색을 위해 사용되는 구조입니다. 용어별로 구성된 각 용어는 해당 용어가 포함된 문서 또는 웹 페이지를 가리킵니다.", "retrieval.keyword_search.title": "반전 인덱스", "retrieval.semantic_search.description": "쿼리의 임베딩을 생성하고, 해당 벡터 표현에 가장 유사한 텍스트 청크를 검색합니다.", diff --git a/web/i18n/locales/ko-KR/login.json b/web/i18n/locales/ko-KR/login.json index e27229246be9a1..b7993f2025d331 100644 --- a/web/i18n/locales/ko-KR/login.json +++ b/web/i18n/locales/ko-KR/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "사용자 이름을 입력하세요.", "error.passwordEmpty": "비밀번호를 입력하세요.", "error.passwordInvalid": "비밀번호는 문자와 숫자를 포함하고 8 자 이상이어야 합니다.", - "error.registrationNotAllowed": "계정을 찾을 수 없습니다. 등록하려면 시스템 관리자에게 문의하십시오.", "error.ssoFailed": "SSO 로그인에 실패했습니다. 다시 시도하거나 관리자에게 문의하세요.", "explore": "Dify 를 탐색하세요", "forget": "비밀번호를 잊으셨나요?", diff --git a/web/i18n/locales/lo-LA/dataset.json b/web/i18n/locales/lo-LA/dataset.json index 4cb08c1e8712d0..486c4c2e74664f 100644 --- a/web/i18n/locales/lo-LA/dataset.json +++ b/web/i18n/locales/lo-LA/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "ຄົ້ນຫາຈາກຂໍ້ຄວາມເຕັມ", "retrieval.hybrid_search.description": "ດຳເນີນການຄົ້ນຫາຈາກຂໍ້ຄວາມເຕັມ ແລະ ເວັກເຕີພ້ອມໆກັນ, ຈາກນັ້ນຈັດລໍາດັບໃໝ່ເພື່ອເລືອກຜົນການຄົ້ນຫາທີ່ກົງກັບຄຳຖາມຂອງຜູ້ໃຊ້ຫຼາຍທີ່ສຸດ. ຜູ້ໃຊ້ສາມາດເລືອກກຳນົດນ້ຳໜັກ ຫຼື ຕັ້ງຄ່າຕົວແບບ Rerank ໄດ້.", "retrieval.hybrid_search.title": "ການຄົ້ນຫາແບບໄຮບຣິດ (ປະສົມ)", - "retrieval.invertedIndex.title": "ດັດຊະນີປີ້ນກັບ", "retrieval.keyword_search.description": "ດັດຊະນີປີ້ນກັບແມ່ນໂຄງສ້າງທີ່ໃຊ້ເພື່ອການດຶງຂໍ້ມູນທີ່ມີປະສິດທິພາບ. ຈັດລະບຽບຕາມຄຳສັບ, ໂດຍແຕ່ລະຄຳສັບຈະຊີ້ໄປຫາເອກະສານ ຫຼື ໜ້າເວັບທີ່ມີຄຳນັ້ນຢູ່.", "retrieval.keyword_search.title": "ດັດຊະນີປີ້ນກັບ", "retrieval.semantic_search.description": "ສ້າງ Embedding ຂອງຄຳຖາມ ແລະ ຄົ້ນຫາຕອນຂໍ້ມູນທີ່ມີຄວາມຄ້າຍຄືກັບເວັກເຕີຂອງມັນຫຼາຍທີ່ສຸດ.", diff --git a/web/i18n/locales/lo-LA/login.json b/web/i18n/locales/lo-LA/login.json index 69b7aab440d9c5..b2a48152f31de6 100644 --- a/web/i18n/locales/lo-LA/login.json +++ b/web/i18n/locales/lo-LA/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "ກະລຸນາປ້ອນຊື່", "error.passwordEmpty": "ກະລຸນາປ້ອນລະຫັດຜ່ານ", "error.passwordInvalid": "ລະຫັດຜ່ານຕ້ອງປະກອບມີຕົວອັກສອນ ແລະ ຕົວເລກ, ແລະ ຕ້ອງມີຄວາມຍາວຫຼາຍກວ່າ 8 ຕົວອັກສອນ", - "error.registrationNotAllowed": "ບໍ່ພົບບັນຊີ. ກະລຸນາຕິດຕໍ່ຜູ້ດູແລລະບົບເພື່ອລົງທະບຽນ.", "error.ssoFailed": "ການເຂົ້າສູ່ລະບົບ SSO ລົ້ມເຫຼວ. ກະລຸນາລອງໃໝ່ ຫຼື ຕິດຕໍ່ຜູ້ດູແລລະບົບຂອງທ່ານ.", "explore": "ສຳຫຼວດ Dify", "forget": "ລືມລະຫັດຜ່ານ?", diff --git a/web/i18n/locales/nl-NL/dataset.json b/web/i18n/locales/nl-NL/dataset.json index b3db4cdfe6d4a0..3c28796222a0cf 100644 --- a/web/i18n/locales/nl-NL/dataset.json +++ b/web/i18n/locales/nl-NL/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Full-Text Search", "retrieval.hybrid_search.description": "Execute full-text search and vector searches simultaneously, re-rank to select the best match for the user's query. Users can choose to set weights or configure to a Rerank model.", "retrieval.hybrid_search.title": "Hybrid Search", - "retrieval.invertedIndex.title": "Inverted Index", "retrieval.keyword_search.description": "Inverted Index is a structure used for efficient retrieval. Organized by terms, each term points to documents or web pages containing it.", "retrieval.keyword_search.title": "Inverted Index", "retrieval.semantic_search.description": "Generate query embeddings and search for the text chunk most similar to its vector representation.", diff --git a/web/i18n/locales/nl-NL/login.json b/web/i18n/locales/nl-NL/login.json index e552c1c97c85a9..879c30cfc0c49c 100644 --- a/web/i18n/locales/nl-NL/login.json +++ b/web/i18n/locales/nl-NL/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "Name is required", "error.passwordEmpty": "Password is required", "error.passwordInvalid": "Password must contain letters and numbers, and the length must be greater than 8", - "error.registrationNotAllowed": "Account not found. Please contact the system admin to register.", "error.ssoFailed": "SSO-aanmelding mislukt. Probeer het opnieuw of neem contact op met je beheerder.", "explore": "Explore Dify", "forget": "Forgot your password?", diff --git a/web/i18n/locales/pl-PL/dataset.json b/web/i18n/locales/pl-PL/dataset.json index fe8f510579e24e..f4a3949990f3e1 100644 --- a/web/i18n/locales/pl-PL/dataset.json +++ b/web/i18n/locales/pl-PL/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Wyszukiwanie pełnotekstowe", "retrieval.hybrid_search.description": "Wykonaj jednocześnie pełnotekstowe wyszukiwanie i wyszukiwanie wektorowe, ponownie porządkuj, aby wybrać najlepsze dopasowanie dla zapytania użytkownika. Konieczna jest konfiguracja API Rerank model.", "retrieval.hybrid_search.title": "Wyszukiwanie hybrydowe", - "retrieval.invertedIndex.title": "Indeks odwrócony", "retrieval.keyword_search.description": "Inverted Index to struktura używana do efektywnego wyszukiwania. Uporządkowany według terminów, każdy termin wskazuje dokumenty lub strony internetowe, które go zawierają.", "retrieval.keyword_search.title": "Odwrócony indeks", "retrieval.semantic_search.description": "Generowanie osadzeń zapytań i wyszukiwanie fragmentów tekstu najbardziej podobnych do ich wektorowej reprezentacji.", diff --git a/web/i18n/locales/pl-PL/login.json b/web/i18n/locales/pl-PL/login.json index 1841cf639ca048..78ccb2da0971c3 100644 --- a/web/i18n/locales/pl-PL/login.json +++ b/web/i18n/locales/pl-PL/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "Nazwa jest wymagana", "error.passwordEmpty": "Hasło jest wymagane", "error.passwordInvalid": "Hasło musi zawierać litery i cyfry, a jego długość musi być większa niż 8", - "error.registrationNotAllowed": "Nie znaleziono konta. Skontaktuj się z administratorem systemu, aby się zarejestrować.", "error.ssoFailed": "Logowanie SSO nie powiodło się. Spróbuj ponownie lub skontaktuj się z administratorem.", "explore": "Odkryj Dify", "forget": "Zapomniałeś hasła?", diff --git a/web/i18n/locales/pt-BR/dataset.json b/web/i18n/locales/pt-BR/dataset.json index aae7b7dfc7e41f..ba0a64272047ec 100644 --- a/web/i18n/locales/pt-BR/dataset.json +++ b/web/i18n/locales/pt-BR/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Pesquisa de Texto Completo", "retrieval.hybrid_search.description": "Execute pesquisas de texto completo e pesquisas vetoriais simultaneamente, reclassifique para selecionar a melhor correspondência para a consulta do usuário. A configuração da API do modelo de reclassificação é necessária.", "retrieval.hybrid_search.title": "Pesquisa Híbrida", - "retrieval.invertedIndex.title": "Índice Invertido", "retrieval.keyword_search.description": "O Índice Invertido é uma estrutura usada para recuperação eficiente. Organizado por termos, cada termo aponta para documentos ou páginas da web que o contêm.", "retrieval.keyword_search.title": "Índice invertido", "retrieval.semantic_search.description": "Gere incorporações de consulta e pesquise o trecho de texto mais semelhante à sua representação vetorial.", diff --git a/web/i18n/locales/pt-BR/login.json b/web/i18n/locales/pt-BR/login.json index dac67f3cdac36c..f8af4526f8d4db 100644 --- a/web/i18n/locales/pt-BR/login.json +++ b/web/i18n/locales/pt-BR/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "O nome é obrigatório", "error.passwordEmpty": "A senha é obrigatória", "error.passwordInvalid": "A senha deve conter letras e números e ter um comprimento maior que 8", - "error.registrationNotAllowed": "Conta não encontrada. Entre em contato com o administrador do sistema para se registrar.", "error.ssoFailed": "Falha no login via SSO. Tente novamente ou entre em contato com o administrador.", "explore": "Explorar o Dify", "forget": "Esqueceu sua senha?", diff --git a/web/i18n/locales/ro-RO/dataset.json b/web/i18n/locales/ro-RO/dataset.json index ef4cd5ddf7140f..988e24f9992dc3 100644 --- a/web/i18n/locales/ro-RO/dataset.json +++ b/web/i18n/locales/ro-RO/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Căutare Full-Text", "retrieval.hybrid_search.description": "Executați căutări full-text și căutări vectoriale în același timp, reclasificați pentru a selecta cea mai bună potrivire pentru interogarea utilizatorului. Configurarea API-ului modelului Rerank este necesară.", "retrieval.hybrid_search.title": "Căutare Hibridă", - "retrieval.invertedIndex.title": "Indice inversat", "retrieval.keyword_search.description": "Indexul inversat este o structură utilizată pentru o recuperare eficientă. Organizat pe termeni, fiecare termen indică documente sau pagini web care îl conțin.", "retrieval.keyword_search.title": "Indice inversat", "retrieval.semantic_search.description": "Generați încorporările interogărilor și căutați bucata de text cea mai similară cu reprezentarea sa vectorială.", diff --git a/web/i18n/locales/ro-RO/login.json b/web/i18n/locales/ro-RO/login.json index fb84c903aa3109..144c26b5f4adb1 100644 --- a/web/i18n/locales/ro-RO/login.json +++ b/web/i18n/locales/ro-RO/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "Numele este obligatoriu", "error.passwordEmpty": "Parola este obligatorie", "error.passwordInvalid": "Parola trebuie să conțină litere și cifre, iar lungimea trebuie să fie mai mare de 8 caractere", - "error.registrationNotAllowed": "Contul nu a fost găsit. Vă rugăm să contactați administratorul de sistem pentru a vă înregistra.", "error.ssoFailed": "Autentificarea SSO a eșuat. Încercați din nou sau contactați administratorul.", "explore": "Explorează Dify", "forget": "Ai uitat parola?", diff --git a/web/i18n/locales/ru-RU/dataset.json b/web/i18n/locales/ru-RU/dataset.json index 69c19c35918af9..f7c6c5cb06b839 100644 --- a/web/i18n/locales/ru-RU/dataset.json +++ b/web/i18n/locales/ru-RU/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Полнотекстовый поиск", "retrieval.hybrid_search.description": "Выполняйте полнотекстовый поиск и векторный поиск одновременно, переранжируйте, чтобы выбрать наилучшее соответствие запросу пользователя. Пользователи могут выбрать установку весов или настройку модели переранжирования.", "retrieval.hybrid_search.title": "Гибридный поиск", - "retrieval.invertedIndex.title": "Обратный индекс", "retrieval.keyword_search.description": "Инвертированный индекс — это структура, используемая для эффективного извлечения. Каждый термин упорядочен по терминам и указывает на документы или веб-страницы, содержащие его.", "retrieval.keyword_search.title": "Инвертированный индекс", "retrieval.semantic_search.description": "Создайте встраивания запросов и найдите фрагмент текста, наиболее похожий на его векторное представление.", diff --git a/web/i18n/locales/ru-RU/login.json b/web/i18n/locales/ru-RU/login.json index 2930c307091392..9bdcc8b3d5ea26 100644 --- a/web/i18n/locales/ru-RU/login.json +++ b/web/i18n/locales/ru-RU/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "Имя обязательно", "error.passwordEmpty": "Пароль обязателен", "error.passwordInvalid": "Пароль должен содержать буквы и цифры, а длина должна быть больше 8", - "error.registrationNotAllowed": "Аккаунт не найден. Пожалуйста, свяжитесь с системным администратором для регистрации.", "error.ssoFailed": "Не удалось войти через SSO. Попробуйте ещё раз или обратитесь к администратору.", "explore": "Изучить Dify", "forget": "Забыли пароль?", diff --git a/web/i18n/locales/sl-SI/dataset.json b/web/i18n/locales/sl-SI/dataset.json index 50ec81de46350f..54d411ddbcdf66 100644 --- a/web/i18n/locales/sl-SI/dataset.json +++ b/web/i18n/locales/sl-SI/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Iskanje celotnega besedila", "retrieval.hybrid_search.description": "Istočasno izvede iskanje celotnega besedila in vektorsko iskanje ter ponovno razvrsti zadetke, da izbere najboljše ujemanje za uporabnikovo poizvedbo. Uporabniki lahko določijo uteži ali konfigurirajo model za ponovno razvrščanje.", "retrieval.hybrid_search.title": "Hibridno iskanje", - "retrieval.invertedIndex.title": "Inverzni indeks", "retrieval.keyword_search.description": "Obrnjeni indeks je struktura, ki se uporablja za učinkovito iskanje. Vsak izraz, organiziran po izrazih, kaže na dokumente ali spletne strani, ki ga vsebujejo.", "retrieval.keyword_search.title": "Obrnjeni indeks", "retrieval.semantic_search.description": "Ustvari vdelke poizvedbe in poišči odstavke besedila, ki so najbolj podobni njegovi vektorski predstavitvi.", diff --git a/web/i18n/locales/sl-SI/login.json b/web/i18n/locales/sl-SI/login.json index 053dcc912a52be..3c58492a777dcb 100644 --- a/web/i18n/locales/sl-SI/login.json +++ b/web/i18n/locales/sl-SI/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "Ime je obvezno", "error.passwordEmpty": "Geslo je obvezno", "error.passwordInvalid": "Geslo mora vsebovati črke in številke, dolžina pa mora biti več kot 8 znakov", - "error.registrationNotAllowed": "Računa ni mogoče najti. Za registracijo se obrnite na skrbnika sistema.", "error.ssoFailed": "Prijava SSO ni uspela. Poskusite znova ali se obrnite na skrbnika.", "explore": "Razišči Dify", "forget": "Ste pozabili geslo?", diff --git a/web/i18n/locales/th-TH/dataset.json b/web/i18n/locales/th-TH/dataset.json index 5454438a99d828..bad133bfeaf870 100644 --- a/web/i18n/locales/th-TH/dataset.json +++ b/web/i18n/locales/th-TH/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "การค้นหาข้อความแบบเต็ม", "retrieval.hybrid_search.description": "ดําเนินการค้นหาข้อความแบบเต็มและการค้นหาแบบเวกเตอร์พร้อมกันจัดอันดับใหม่เพื่อเลือกการจับคู่ที่ดีที่สุดสําหรับคําค้นหาของผู้ใช้ ผู้ใช้สามารถเลือกที่จะตั้งค่าน้ําหนักหรือกําหนดค่าเป็นโมเดล Rerank", "retrieval.hybrid_search.title": "การค้นหาแบบไฮบริด", - "retrieval.invertedIndex.title": "ดัชนีผกผัน", "retrieval.keyword_search.description": "Inverted Index เป็นโครงสร้างที่ใช้สําหรับการดึงข้อมูลอย่างมีประสิทธิภาพ จัดเรียงตามคําศัพท์ แต่ละคําชี้ไปที่เอกสารหรือหน้าเว็บที่มีคําดังกล่าว", "retrieval.keyword_search.title": "ดัชนีกลับด้าน", "retrieval.semantic_search.description": "สร้างการฝังแบบสอบถามและค้นหาส่วนข้อความที่คล้ายกับการแสดงเวกเตอร์มากที่สุด", diff --git a/web/i18n/locales/th-TH/login.json b/web/i18n/locales/th-TH/login.json index 52be3be2780b2a..7804d05e2511db 100644 --- a/web/i18n/locales/th-TH/login.json +++ b/web/i18n/locales/th-TH/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "ต้องระบุชื่อ", "error.passwordEmpty": "ต้องใช้รหัสผ่าน", "error.passwordInvalid": "รหัสผ่านต้องมีตัวอักษรและตัวเลข และความยาวต้องมากกว่า 8", - "error.registrationNotAllowed": "ไม่พบบัญชี โปรดติดต่อผู้ดูแลระบบเพื่อลงทะเบียน", "error.ssoFailed": "เข้าสู่ระบบด้วย SSO ไม่สำเร็จ โปรดลองอีกครั้งหรือติดต่อผู้ดูแลระบบของคุณ", "explore": "สํารวจ Dify", "forget": "ลืมรหัสผ่านใช่ไหม", diff --git a/web/i18n/locales/tr-TR/dataset.json b/web/i18n/locales/tr-TR/dataset.json index 16818656230291..8d56d6a1b65211 100644 --- a/web/i18n/locales/tr-TR/dataset.json +++ b/web/i18n/locales/tr-TR/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Tam Metin Arama", "retrieval.hybrid_search.description": "Tam metin arama ve vektör aramalarını aynı anda çalıştırın, kullanıcı sorgusu için en iyi eşleşmeyi seçmek için yeniden sıralayın. Kullanıcılar ağırlıklar ayarlayabilir veya bir Yeniden Sıralama modeli yapılandırabilir.", "retrieval.hybrid_search.title": "Hibrit Arama", - "retrieval.invertedIndex.title": "Ters İndeks", "retrieval.keyword_search.description": "Ters İndeks, verimli erişim için kullanılan bir yapıdır. Terimlere göre düzenlenen her terim, onu içeren belgelere veya web sayfalarına işaret eder.", "retrieval.keyword_search.title": "Ters Çevrilmiş İndeks", "retrieval.semantic_search.description": "Sorgu yerleştirmelerini oluşturun ve vektör temsiline en benzeyen metin parçasını arayın.", diff --git a/web/i18n/locales/tr-TR/login.json b/web/i18n/locales/tr-TR/login.json index 46f1d0e5f6bbb9..a24d79befd5dd3 100644 --- a/web/i18n/locales/tr-TR/login.json +++ b/web/i18n/locales/tr-TR/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "İsim gereklidir", "error.passwordEmpty": "Şifre gereklidir", "error.passwordInvalid": "Şifre harf ve rakamlardan oluşmalı ve uzunluğu 8 karakterden fazla olmalıdır", - "error.registrationNotAllowed": "Hesap bulunamadı. Kayıt olmak için lütfen sistem yöneticisi ile iletişime geçin.", "error.ssoFailed": "SSO ile giriş başarısız oldu. Lütfen tekrar deneyin veya yöneticinize başvurun.", "explore": "Dify'yi Keşfet", "forget": "Şifrenizi mi unuttunuz?", diff --git a/web/i18n/locales/uk-UA/dataset.json b/web/i18n/locales/uk-UA/dataset.json index efb0d0767b576f..b2d46f6d15dbc5 100644 --- a/web/i18n/locales/uk-UA/dataset.json +++ b/web/i18n/locales/uk-UA/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Повнотекстовий пошук", "retrieval.hybrid_search.description": "Виконуйте повнотекстовий пошук і векторний пошук одночасно, повторно ранжуючи, щоб вибрати найкращу відповідність на запит користувача. Необхідна конфігурація Rerank model API.", "retrieval.hybrid_search.title": "Гібридний пошук", - "retrieval.invertedIndex.title": "Перевернутий індекс", "retrieval.keyword_search.description": "Перевернутий індекс — це структура, яка використовується для ефективного пошуку. Упорядкований за термінами, кожен термін вказує на документи або веб-сторінки, що містять його.", "retrieval.keyword_search.title": "Перевернутий індекс", "retrieval.semantic_search.description": "Генерує вбудовування запитів і шукає фрагмент тексту, найбільш схожий на його векторне представлення.", diff --git a/web/i18n/locales/uk-UA/login.json b/web/i18n/locales/uk-UA/login.json index 646b95797ea5ae..b5ca10c1fcf337 100644 --- a/web/i18n/locales/uk-UA/login.json +++ b/web/i18n/locales/uk-UA/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "Ім'я обов'язкове", "error.passwordEmpty": "Пароль є обов’язковим", "error.passwordInvalid": "Пароль повинен містити літери та цифри, а довжина повинна бути більшою за 8", - "error.registrationNotAllowed": "Аккаунт не знайдено. Будь ласка, зверніться до адміністратора системи для реєстрації.", "error.ssoFailed": "Не вдалося увійти через SSO. Спробуйте ще раз або зверніться до адміністратора.", "explore": "Досліджуйте Dify", "forget": "Забули пароль?", diff --git a/web/i18n/locales/vi-VN/dataset.json b/web/i18n/locales/vi-VN/dataset.json index fbf7506743ebfb..565d5c2c9d2043 100644 --- a/web/i18n/locales/vi-VN/dataset.json +++ b/web/i18n/locales/vi-VN/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "Tìm kiếm Toàn văn bản", "retrieval.hybrid_search.description": "Thực hiện tìm kiếm toàn văn bản và tìm kiếm vector đồng thời, sắp xếp lại để chọn kết quả phù hợp nhất với truy vấn của người dùng. Yêu cầu cấu hình API mô hình Rerank.", "retrieval.hybrid_search.title": "Tìm kiếm Kết hợp", - "retrieval.invertedIndex.title": "Chỉ mục đảo ngược", "retrieval.keyword_search.description": "Chỉ số đảo ngược là một cấu trúc được sử dụng để truy xuất hiệu quả. Được sắp xếp theo thuật ngữ, mỗi thuật ngữ trỏ đến các tài liệu hoặc trang web có chứa nó.", "retrieval.keyword_search.title": "Chỉ số đảo ngược", "retrieval.semantic_search.description": "Tạo các nhúng truy vấn và tìm kiếm đoạn văn bản tương tự nhất với biểu diễn vector của nó.", diff --git a/web/i18n/locales/vi-VN/login.json b/web/i18n/locales/vi-VN/login.json index 49f960dd92ad87..200b4cffdfdd20 100644 --- a/web/i18n/locales/vi-VN/login.json +++ b/web/i18n/locales/vi-VN/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "Vui lòng nhập tên", "error.passwordEmpty": "Vui lòng nhập mật khẩu", "error.passwordInvalid": "Mật khẩu phải chứa cả chữ và số, và có độ dài ít nhất 8 ký tự", - "error.registrationNotAllowed": "Không tìm thấy tài khoản. Vui lòng liên hệ với quản trị viên hệ thống để đăng ký.", "error.ssoFailed": "Đăng nhập SSO không thành công. Vui lòng thử lại hoặc liên hệ với quản trị viên.", "explore": "Khám phá Dify", "forget": "Quên mật khẩu?", diff --git a/web/i18n/locales/zh-Hans/dataset.json b/web/i18n/locales/zh-Hans/dataset.json index 57ce37b4197e62..0b2caaa64a1d0a 100644 --- a/web/i18n/locales/zh-Hans/dataset.json +++ b/web/i18n/locales/zh-Hans/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "全文检索", "retrieval.hybrid_search.description": "同时执行全文检索和向量检索,并应用重排序步骤,从两类查询结果中选择匹配用户问题的最佳结果,用户可以选择设置权重或配置重新排序模型。", "retrieval.hybrid_search.title": "混合检索", - "retrieval.invertedIndex.title": "倒排索引", "retrieval.keyword_search.description": "倒排索引是一种用于高效检索的结构。按术语组织,每个术语指向包含它的文档或网页", "retrieval.keyword_search.title": "倒排索引", "retrieval.semantic_search.description": "通过生成查询嵌入并查询与其向量表示最相似的文本分段", diff --git a/web/i18n/locales/zh-Hans/login.json b/web/i18n/locales/zh-Hans/login.json index 57a3e12d424e49..a896ec6c36fb80 100644 --- a/web/i18n/locales/zh-Hans/login.json +++ b/web/i18n/locales/zh-Hans/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "用户名不能为空", "error.passwordEmpty": "密码不能为空", "error.passwordInvalid": "密码必须包含字母和数字,且长度不小于 8 位", - "error.registrationNotAllowed": "账户不存在,请联系系统管理员注册账户", "error.ssoFailed": "SSO 登录失败。请重试或联系管理员。", "explore": "探索 Dify", "forget": "忘记密码?", diff --git a/web/i18n/locales/zh-Hant/dataset.json b/web/i18n/locales/zh-Hant/dataset.json index cf2d7ceefc37eb..b5e2e56508e9d5 100644 --- a/web/i18n/locales/zh-Hant/dataset.json +++ b/web/i18n/locales/zh-Hant/dataset.json @@ -143,7 +143,6 @@ "retrieval.full_text_search.title": "全文檢索", "retrieval.hybrid_search.description": "同時執行全文檢索和向量檢索,並應用重排序步驟,從兩類查詢結果中選擇匹配使用者問題的最佳結果,需配置 Rerank 模型 API", "retrieval.hybrid_search.title": "混合檢索", - "retrieval.invertedIndex.title": "反向索引", "retrieval.keyword_search.description": "倒掛索引是一種用於高效檢索的結構。依字詞組織,每個字詞都指向包含它的文件或網頁。", "retrieval.keyword_search.title": "倒掛索引", "retrieval.semantic_search.description": "透過生成查詢嵌入並查詢與其向量表示最相似的文字分段", diff --git a/web/i18n/locales/zh-Hant/login.json b/web/i18n/locales/zh-Hant/login.json index 2c5a2289838494..8611597170050d 100644 --- a/web/i18n/locales/zh-Hant/login.json +++ b/web/i18n/locales/zh-Hant/login.json @@ -30,7 +30,6 @@ "error.nameEmpty": "使用者名稱不能為空", "error.passwordEmpty": "密碼不能為空", "error.passwordInvalid": "密碼必須包含字母和數字,且長度不小於 8 位", - "error.registrationNotAllowed": "找不到帳戶。請聯繫系統管理員進行註冊。", "error.ssoFailed": "SSO 登入失敗。請重試或聯絡管理員。", "explore": "探索 Dify", "forget": "忘記密碼?", diff --git a/web/i18n/resources.generated.d.ts b/web/i18n/resources.generated.d.ts index 342c78af6bc150..dc699461944d26 100644 --- a/web/i18n/resources.generated.d.ts +++ b/web/i18n/resources.generated.d.ts @@ -2084,7 +2084,6 @@ export default interface Resources { 'retrieval.full_text_search.title': 'Full-Text Search' 'retrieval.hybrid_search.description': "Execute full-text search and vector searches simultaneously, re-rank to select the best match for the user's query. Users can choose to set weights or configure to a Rerank model." 'retrieval.hybrid_search.title': 'Hybrid Search' - 'retrieval.invertedIndex.title': 'Inverted Index' 'retrieval.keyword_search.description': 'Inverted Index is a structure used for efficient retrieval. Organized by terms, each term points to documents or web pages containing it.' 'retrieval.keyword_search.title': 'Inverted Index' 'retrieval.semantic_search.description': 'Generate query embeddings and search for the text chunk most similar to its vector representation.' @@ -2969,7 +2968,6 @@ export default interface Resources { 'error.nameEmpty': 'Name is required' 'error.passwordEmpty': 'Password is required' 'error.passwordInvalid': 'Password must contain letters and numbers, and the length must be greater than 8' - 'error.registrationNotAllowed': 'Account not found. Please contact the system admin to register.' 'error.ssoFailed': 'SSO login failed. Please try again or contact your administrator.' explore: 'Explore Dify' forget: 'Forgot your password?' diff --git a/web/models/access-control.ts b/web/models/access-control.ts index 81f1a06246eb59..64b910f9f25874 100644 --- a/web/models/access-control.ts +++ b/web/models/access-control.ts @@ -102,24 +102,6 @@ export type RoleListResponse = { pagination: Pagination } -export type CreateRoleRequest = { - name: string - description?: string - permission_keys?: PermissionKey[] -} - -export type UpdateRolesRequest = { - id: string - name: string - description?: string - permission_keys?: PermissionKey[] -} - -export type CopyWorkspaceRoleRequest = { - roleId: string - copy_member: boolean -} - export type AccessPolicyResourceType = 'app' | 'dataset' | 'agent' type AccessPolicyCategory = 'global_system_default' | 'global_custom' @@ -169,20 +151,6 @@ export type GetDatasetAccessPolicyByDatasetIdResponse = { items: AccessPolicyWithBindings[] } -export type RolesOfMemberResponse = { - account_id: string - roles: Role[] -} - -export type UpdateRolesOfMemberRequest = { - memberId: string - roleIds: string[] -} - -export type GetMembersOfRoleRequest = { - roleId: string -} & PaginationParameters - type Account = { account_id: string account_name: string @@ -201,11 +169,6 @@ type ResourceUserAccessSettingsResponse = { pagination: Pagination } -export type GetMembersOfRoleResponse = { - data: Account[] - pagination: Pagination -} - export type GetAppUserAccessSettingsResponse = ResourceUserAccessSettingsResponse export type GetDatasetUserAccessSettingsResponse = ResourceUserAccessSettingsResponse diff --git a/web/models/common.ts b/web/models/common.ts index e42edd44ad4b78..21cd7204e15f2d 100644 --- a/web/models/common.ts +++ b/web/models/common.ts @@ -2,17 +2,11 @@ import type { AppConfigJsonValue } from '@dify/contracts/api/console/apps/types. import type { UploadConfig } from '@dify/contracts/api/console/files/types.gen' import type { AccountWithRoleResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { I18nText } from '@/i18n/language' -import type { Model } from '@/types/app' export type CommonResponse = { result: 'success' | 'fail' } -export type SetupStatusResponse = { - step: 'finished' | 'not_started' - setup_at?: Date -} - export type InitValidateStatusResponse = { status: 'finished' | 'not_started' } @@ -90,13 +84,3 @@ export type ExternalDataTool = { api_based_extension_id?: string } & Record } - -export type StructuredOutputRulesRequestBody = { - instruction: string - model_config: Model -} - -export type StructuredOutputRulesResponse = { - output: string - error?: string -} diff --git a/web/service/use-common.ts b/web/service/use-common.ts index 6a5dab812ebfde..7012c9b8425494 100644 --- a/web/service/use-common.ts +++ b/web/service/use-common.ts @@ -10,7 +10,7 @@ import { consoleQuery } from './console' const NAME_SPACE = 'common' -export const commonQueryKeys = { +const commonQueryKeys = { forgotPasswordValidity: (token?: string | null) => [NAME_SPACE, 'forgot-password-validity', token] as const, } diff --git a/web/test/fixtures/hit-testing.ts b/web/test/fixtures/hit-testing.ts index 9de3ffb1c9907b..581e18aec9f2ca 100644 --- a/web/test/fixtures/hit-testing.ts +++ b/web/test/fixtures/hit-testing.ts @@ -7,7 +7,7 @@ type HitTestingSegmentOverrides = Partial> & document?: Partial } -export const createHitTestingSegmentFixture = ({ +const createHitTestingSegmentFixture = ({ document, ...overrides }: HitTestingSegmentOverrides = {}): HitTestingSegment => ({ From 29c2f5e72304b2681afb26819140a5dd73311748 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:50:19 +0800 Subject: [PATCH 16/60] refactor(web): migrate datasets service and knowledge hooks to the console contract - service/datasets.ts and service/knowledge/use-{dataset,document,segment,metadata,create-dataset} call consoleClient; knowledge UI DTOs are narrowed at the service boundary - document query params (list, batch delete, batch status) and dataset query history pagination documented on the backend; repeated-key rules for document_id - 204 endpoints (delete external API, retry error docs) no longer rely on the legacy {result: 'success'} rewrite - dataset update body, notion info and pre-processing rules use generated request types - drop unused qs dependency Co-Authored-By: Claude Opus 5.5 --- api/controllers/console/datasets/datasets.py | 7 +- .../console/datasets/datasets_document.py | 20 +- .../api/console/datasets/orpc.gen.ts | 24 +- .../api/console/datasets/types.gen.ts | 17 +- .../generated/api/console/datasets/zod.gen.ts | 21 +- pnpm-lock.yaml | 40 --- pnpm-workspace.yaml | 2 - .../__tests__/use-configuration-utils.spec.ts | 2 - .../hooks/configuration-lifecycle/load.ts | 1 - .../__tests__/index-failed.spec.tsx | 2 +- .../index-failed.tsx | 6 +- .../create/step-two/__tests__/index.spec.tsx | 24 +- .../datasets/create/step-two/index.tsx | 2 +- .../hooks/use-document-actions.ts | 7 +- .../documents/detail/__tests__/index.spec.tsx | 3 +- .../embedding/hooks/use-embedding-status.ts | 5 +- .../datasets/documents/detail/index.tsx | 3 +- .../__tests__/index.spec.tsx | 24 -- .../external-knowledge-api-card/index.tsx | 10 +- .../datasets/extra-info/api-access/card.tsx | 2 +- .../datasets/rename-modal/index.tsx | 6 +- .../__tests__/provider.spec.tsx | 1 - .../datasets-detail-store/provider.tsx | 1 - .../workflow/hooks/use-checklist.ts | 1 - .../__tests__/use-config.spec.ts | 1 - .../use-knowledge-dataset-selection.spec.ts | 1 - .../hooks/use-knowledge-dataset-selection.ts | 1 - web/models/datasets.ts | 44 +-- web/package.json | 2 - web/service/console/openapi-url.ts | 2 + web/service/datasets.ts | 264 ++++++++---------- web/service/knowledge/use-create-dataset.ts | 44 +-- web/service/knowledge/use-dataset.spec.ts | 13 +- web/service/knowledge/use-dataset.ts | 80 +++--- web/service/knowledge/use-document.ts | 90 +++--- web/service/knowledge/use-metadata.ts | 38 ++- web/service/knowledge/use-segment.ts | 116 +++++--- 37 files changed, 451 insertions(+), 476 deletions(-) diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index 20da94a535e784..a938edfb21de3f 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -496,11 +496,16 @@ def get(self, request_context: RequestContext, dataset_id: UUID): return dump_response(UsageCheckResponse, {"is_using": result}), 200 +class DatasetQueryListQuery(BaseModel): + page: int = Field(default=1, description="Page number") + limit: int = Field(default=20, description="Number of items per page") + + @console_ns.route("/datasets//queries") class DatasetQueryApi(Resource): @console_ns.doc("get_dataset_queries") @console_ns.doc(description="Get dataset query history") - @console_ns.doc(params={"dataset_id": "Dataset ID"}) + @console_ns.doc(params={"dataset_id": "Dataset ID", **query_params_from_model(DatasetQueryListQuery)}) @console_ns.response( 200, "Query history retrieved successfully", diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py index 2b3007e67ac7fa..232d2fb039c8bb 100644 --- a/api/controllers/console/datasets/datasets_document.py +++ b/api/controllers/console/datasets/datasets_document.py @@ -12,7 +12,7 @@ from controllers.common.errors import InvalidArgumentError, NotFoundError from controllers.common.fields import SimpleResultMessageResponse, SimpleResultResponse, UrlResponse from controllers.common.rbac import DatasetByDocument, DatasetId, RBACCheck, enforce_rbac_checks -from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( ProviderModelCurrentlyNotSupportError, @@ -185,6 +185,10 @@ class DocumentDatasetListParam(BaseModel): fetch_val: str = Field("false", alias="fetch") +class DocumentIdListQuery(BaseModel): + document_id: list[str] = Field(default_factory=list, description="Document IDs") + + class DocumentWithSegmentsListResponse(ResponseModel): data: list[DocumentWithSegmentsResponse] has_more: bool @@ -347,17 +351,7 @@ def get(self, request_context: RequestContext): class DatasetDocumentListApi(Resource): @console_ns.doc("get_dataset_documents") @console_ns.doc(description="Get documents in a dataset") - @console_ns.doc( - params={ - "dataset_id": "Dataset ID", - "page": "Page number (default: 1)", - "limit": "Number of items per page (default: 20)", - "keyword": "Search keyword", - "sort": "Sort order (default: -created_at)", - "fetch": "Fetch full details (default: false)", - "status": "Filter documents by display status", - } - ) + @console_ns.doc(params={"dataset_id": "Dataset ID", **query_params_from_model(DocumentDatasetListParam)}) @console_ns.response( 200, "Documents retrieved successfully", @@ -401,6 +395,7 @@ def post(self, request_context: RequestContext, dataset_id: UUID): _raise_document_indexing_error(error) return dump_response(DatasetAndDocumentResponse, result) + @console_ns.doc(params=query_params_from_model(DocumentIdListQuery)) @console_ns.response(204, "Documents deleted successfully") @console_account_admission( allowed_roles=_DATASET_EDIT_ROLES, @@ -745,6 +740,7 @@ def put( @console_ns.route("/datasets//documents/status//batch") class DocumentStatusApi(Resource): + @console_ns.doc(params=query_params_from_model(DocumentIdListQuery)) @error_responses(InvalidActionError) @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @console_account_admission( diff --git a/packages/contracts/generated/api/console/datasets/orpc.gen.ts b/packages/contracts/generated/api/console/datasets/orpc.gen.ts index 93b4d2ca9803a3..7470640812ce9e 100644 --- a/packages/contracts/generated/api/console/datasets/orpc.gen.ts +++ b/packages/contracts/generated/api/console/datasets/orpc.gen.ts @@ -32,6 +32,7 @@ import { zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsQuery, zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse, zDeleteDatasetsByDatasetIdDocumentsPath, + zDeleteDatasetsByDatasetIdDocumentsQuery, zDeleteDatasetsByDatasetIdDocumentsResponse, zDeleteDatasetsByDatasetIdMetadataByMetadataIdPath, zDeleteDatasetsByDatasetIdMetadataByMetadataIdResponse, @@ -89,6 +90,7 @@ import { zGetDatasetsByDatasetIdPermissionPartUsersPath, zGetDatasetsByDatasetIdPermissionPartUsersResponse, zGetDatasetsByDatasetIdQueriesPath, + zGetDatasetsByDatasetIdQueriesQuery, zGetDatasetsByDatasetIdQueriesResponse, zGetDatasetsByDatasetIdRelatedAppsPath, zGetDatasetsByDatasetIdRelatedAppsResponse, @@ -131,6 +133,7 @@ import { zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPath, zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse, zPatchDatasetsByDatasetIdDocumentsStatusByActionBatchPath, + zPatchDatasetsByDatasetIdDocumentsStatusByActionBatchQuery, zPatchDatasetsByDatasetIdDocumentsStatusByActionBatchResponse, zPatchDatasetsByDatasetIdMetadataByMetadataIdBody, zPatchDatasetsByDatasetIdMetadataByMetadataIdPath, @@ -737,7 +740,12 @@ export const patch2 = oc path: '/datasets/{dataset_id}/documents/status/{action}/batch', tags: ['console'], }) - .input(z.object({ params: zPatchDatasetsByDatasetIdDocumentsStatusByActionBatchPath })) + .input( + z.object({ + params: zPatchDatasetsByDatasetIdDocumentsStatusByActionBatchPath, + query: zPatchDatasetsByDatasetIdDocumentsStatusByActionBatchQuery.optional(), + }), + ) .output(zPatchDatasetsByDatasetIdDocumentsStatusByActionBatchResponse) .errors({ invalid_action: { status: 400, data: zInvalidActionError } }) @@ -1331,7 +1339,12 @@ export const delete7 = oc successStatus: 204, tags: ['console'], }) - .input(z.object({ params: zDeleteDatasetsByDatasetIdDocumentsPath })) + .input( + z.object({ + params: zDeleteDatasetsByDatasetIdDocumentsPath, + query: zDeleteDatasetsByDatasetIdDocumentsQuery.optional(), + }), + ) .output(zDeleteDatasetsByDatasetIdDocumentsResponse) /** @@ -1618,7 +1631,12 @@ export const get30 = oc path: '/datasets/{dataset_id}/queries', tags: ['console'], }) - .input(z.object({ params: zGetDatasetsByDatasetIdQueriesPath })) + .input( + z.object({ + params: zGetDatasetsByDatasetIdQueriesPath, + query: zGetDatasetsByDatasetIdQueriesQuery.optional(), + }), + ) .output(zGetDatasetsByDatasetIdQueriesResponse) export const queries = { diff --git a/packages/contracts/generated/api/console/datasets/types.gen.ts b/packages/contracts/generated/api/console/datasets/types.gen.ts index 3477f2fd7b755b..3749c3b2c089f6 100644 --- a/packages/contracts/generated/api/console/datasets/types.gen.ts +++ b/packages/contracts/generated/api/console/datasets/types.gen.ts @@ -1855,7 +1855,9 @@ export type DeleteDatasetsByDatasetIdDocumentsData = { path: { dataset_id: string } - query?: never + query?: { + document_id?: Array + } url: '/datasets/{dataset_id}/documents' } @@ -1881,8 +1883,8 @@ export type GetDatasetsByDatasetIdDocumentsData = { query?: { fetch?: string keyword?: string - limit?: string - page?: string + limit?: number + page?: number sort?: string status?: string } @@ -2012,7 +2014,9 @@ export type PatchDatasetsByDatasetIdDocumentsStatusByActionBatchData = { action: string dataset_id: string } - query?: never + query?: { + document_id?: Array + } url: '/datasets/{dataset_id}/documents/status/{action}/batch' } @@ -2991,7 +2995,10 @@ export type GetDatasetsByDatasetIdQueriesData = { path: { dataset_id: string } - query?: never + query?: { + limit?: number + page?: number + } url: '/datasets/{dataset_id}/queries' } diff --git a/packages/contracts/generated/api/console/datasets/zod.gen.ts b/packages/contracts/generated/api/console/datasets/zod.gen.ts index 01740c16159a79..26133098a3484e 100644 --- a/packages/contracts/generated/api/console/datasets/zod.gen.ts +++ b/packages/contracts/generated/api/console/datasets/zod.gen.ts @@ -1935,6 +1935,10 @@ export const zDeleteDatasetsByDatasetIdDocumentsPath = z.object({ dataset_id: z.uuid(), }) +export const zDeleteDatasetsByDatasetIdDocumentsQuery = z.object({ + document_id: z.array(z.string()).optional(), +}) + /** * Documents deleted successfully */ @@ -1945,11 +1949,11 @@ export const zGetDatasetsByDatasetIdDocumentsPath = z.object({ }) export const zGetDatasetsByDatasetIdDocumentsQuery = z.object({ - fetch: z.string().optional(), + fetch: z.string().optional().default('false'), keyword: z.string().optional(), - limit: z.string().optional(), - page: z.string().optional(), - sort: z.string().optional(), + limit: z.int().optional().default(20), + page: z.int().optional().default(1), + sort: z.string().optional().default('-created_at'), status: z.string().optional(), }) @@ -2010,6 +2014,10 @@ export const zPatchDatasetsByDatasetIdDocumentsStatusByActionBatchPath = z.objec dataset_id: z.uuid(), }) +export const zPatchDatasetsByDatasetIdDocumentsStatusByActionBatchQuery = z.object({ + document_id: z.array(z.string()).optional(), +}) + /** * Success */ @@ -2470,6 +2478,11 @@ export const zGetDatasetsByDatasetIdQueriesPath = z.object({ dataset_id: z.uuid(), }) +export const zGetDatasetsByDatasetIdQueriesQuery = z.object({ + limit: z.int().optional().default(20), + page: z.int().optional().default(1), +}) + /** * Query history retrieved successfully */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2da0ca8f170f56..04dc2cf3ca4ea0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -374,9 +374,6 @@ catalogs: '@types/picomatch': specifier: 4.0.3 version: 4.0.3 - '@types/qs': - specifier: 6.15.1 - version: 6.15.1 '@types/react': specifier: 19.3.0 version: 19.3.0 @@ -647,9 +644,6 @@ catalogs: qrcode.react: specifier: 4.2.0 version: 4.2.0 - qs: - specifier: 6.16.0 - version: 6.16.0 react: specifier: 19.3.0 version: 19.3.0 @@ -1600,9 +1594,6 @@ importers: qrcode.react: specifier: 'catalog:' version: 4.2.0(react@19.3.0) - qs: - specifier: 'catalog:' - version: 6.16.0 react: specifier: 'catalog:' version: 19.3.0 @@ -1763,9 +1754,6 @@ importers: '@types/node': specifier: 'catalog:' version: 25.9.9 - '@types/qs': - specifier: 'catalog:' - version: 6.15.1 '@types/react': specifier: 'catalog:' version: 19.3.0 @@ -3171,10 +3159,6 @@ packages: resolution: {integrity: sha512-Ouw1fMwjAy1V4MpnDASfu1DCPgkP0nNFteiiWbFoEGSqa7Vnmkb6if2c522N2WcMk+RuaaabQbC1F1D4/kTXcg==} engines: {node: '>=12.4.0'} - '@nolyfill/side-channel@1.0.44': - resolution: {integrity: sha512-y3SvzjuY1ygnzWA4Krwx/WaJAsTMP11DN+e21A8Fa8PW1oDtVB5NSRW7LWurAiS2oKRkuCgcjTYMkBuBkcPCRg==} - engines: {node: '>=12.4.0'} - '@orpc/client@1.15.5': resolution: {integrity: sha512-0usheWO9w5HRHG20+nGB5AJlOQfea/fEmOX1KKHneBTkLpFTBSTkyJSqAN6ugJbhWm8BKVs9caimFQr8vcI0Ig==} @@ -4939,9 +4923,6 @@ packages: '@types/picomatch@4.0.3': resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} - '@types/qs@6.15.1': - resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} - '@types/react-dom@19.3.0': resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} peerDependencies: @@ -6441,10 +6422,6 @@ packages: error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} @@ -8322,10 +8299,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - qs@6.16.0: - resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} - engines: {node: '>=0.6'} - quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -10973,8 +10946,6 @@ snapshots: '@nolyfill/safer-buffer@1.0.44': {} - '@nolyfill/side-channel@1.0.44': {} - '@orpc/client@1.15.5': dependencies: '@orpc/shared': 1.15.5 @@ -12342,8 +12313,6 @@ snapshots: '@types/picomatch@4.0.3': {} - '@types/qs@6.15.1': {} - '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: '@types/react': 19.3.0 @@ -13783,8 +13752,6 @@ snapshots: dependencies: stackframe: 1.3.4 - es-define-property@1.0.1: {} - es-errors@1.3.0: {} es-module-lexer@1.7.0: {} @@ -16072,11 +16039,6 @@ snapshots: dependencies: react: 19.3.0 - qs@6.16.0: - dependencies: - es-define-property: 1.0.1 - side-channel: '@nolyfill/side-channel@1.0.44' - quansync@0.2.11: {} queue-microtask@1.2.3: {} @@ -17577,7 +17539,6 @@ time: '@types/negotiator@0.6.5': '2026-08-11T07:26:20.690Z' '@types/node@25.9.9': '2026-10-01T22:39:33.611Z' '@types/picomatch@4.0.3': '2026-04-03T00:13:45.392Z' - '@types/qs@6.15.1': '2026-05-06T23:46:01.024Z' '@types/react-dom@19.3.0': '2026-09-09T18:07:51.886Z' '@types/react@19.3.0': '2026-09-09T18:08:49.750Z' '@types/sortablejs@1.15.9': '2025-10-24T04:31:45.132Z' @@ -17671,7 +17632,6 @@ time: playwright@1.64.0: '2026-10-07T20:42:55.791Z' postcss@8.5.29: '2026-10-05T09:28:55.780Z' qrcode.react@4.2.0: '2024-12-11T17:22:40.569Z' - qs@6.16.0: '2026-08-29T23:50:15.803Z' react-dom@19.3.0: '2026-09-09T17:17:39.744Z' react-easy-crop@6.2.4: '2026-10-06T06:35:05.433Z' react-i18next@17.0.16: '2026-10-06T12:19:42.259Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 43b1ee8d5ac933..a1ab8dd54049d8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -138,7 +138,6 @@ catalog: '@types/negotiator': 0.6.5 '@types/node': 25.9.9 '@types/picomatch': 4.0.3 - '@types/qs': 6.15.1 '@types/react': 19.3.0 '@types/react-dom': 19.3.0 '@types/sortablejs': 1.15.9 @@ -230,7 +229,6 @@ catalog: playwright: 1.64.0 postcss: 8.5.29 qrcode.react: 4.2.0 - qs: 6.16.0 react: 19.3.0 react-dom: 19.3.0 react-easy-crop: 6.2.4 diff --git a/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts b/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts index 5772f408eb6cd1..ace890df25a9c2 100644 --- a/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts +++ b/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts @@ -558,7 +558,6 @@ describe('useConfiguration utils', () => { ids: ['dataset-1'], page: 1, }, - url: '/datasets', }) expect(state.collectionList[0]!.icon).toBe('/console/tool.svg') expect(state.promptMode).toBe('advanced') @@ -623,7 +622,6 @@ describe('useConfiguration utils', () => { const state = await loadConfigurationState({ appId: 'app-2' }) expect(mockFetchDatasets).toHaveBeenCalledWith({ - url: '/datasets', params: { page: 1, ids: ['dataset-from-tool'], diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/load.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/load.ts index de4b0ff46d6d37..21f4f38ce60691 100644 --- a/web/app/components/app/configuration/hooks/configuration-lifecycle/load.ts +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/load.ts @@ -46,7 +46,6 @@ export async function loadConfigurationState({ if (datasetIds.length) { const { data } = await fetchDatasets({ - url: '/datasets', params: { page: 1, ids: datasetIds, diff --git a/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx b/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx index db25917776311d..108a699c3a09c5 100644 --- a/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx +++ b/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx @@ -220,7 +220,7 @@ describe('RetryButton (IndexFailed)', () => { ), ) - mockRetryErrorDocs.mockResolvedValue({ result: 'fail' }) + mockRetryErrorDocs.mockRejectedValue(new Error('retry failed')) render() diff --git a/web/app/components/datasets/common/document-status-with-action/index-failed.tsx b/web/app/components/datasets/common/document-status-with-action/index-failed.tsx index 4102874d96001b..4c69ee8a8fe600 100644 --- a/web/app/components/datasets/common/document-status-with-action/index-failed.tsx +++ b/web/app/components/datasets/common/document-status-with-action/index-failed.tsx @@ -41,11 +41,11 @@ const RetryButton: FC = ({ datasetId }) => { const onRetryErrorDocs = async () => { dispatch({ type: 'retry' }) const document_ids = errorDocs?.data.map((doc: IndexingStatusResponse) => doc.id) || [] - const res = await retryErrorDocs({ datasetId, document_ids }) - if (res.result === 'success') { + try { + await retryErrorDocs({ datasetId, document_ids }) refetchErrorDocs() dispatch({ type: 'success' }) - } else { + } catch { dispatch({ type: 'error' }) } } diff --git a/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx index c81a4e41a60f77..972eaa373ed5d1 100644 --- a/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx @@ -543,7 +543,7 @@ describe('useSegmentationState', () => { it('should update rules', () => { const { result } = renderHook(() => useSegmentationState()) - const newRules: PreProcessingRule[] = [{ id: 'test', enabled: true }] + const newRules: PreProcessingRule[] = [{ id: 'remove_stopwords', enabled: true }] act(() => { result.current.setRules(newRules) @@ -593,17 +593,17 @@ describe('useSegmentationState', () => { act(() => { result.current.setRules([ - { id: 'rule1', enabled: true }, - { id: 'rule2', enabled: false }, + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, ]) }) act(() => { - result.current.toggleRule('rule1') + result.current.toggleRule('remove_extra_spaces') }) - expect(result.current.rules.find((r) => r.id === 'rule1')?.enabled).toBe(false) - expect(result.current.rules.find((r) => r.id === 'rule2')?.enabled).toBe(false) + expect(result.current.rules.find((r) => r.id === 'remove_extra_spaces')?.enabled).toBe(false) + expect(result.current.rules.find((r) => r.id === 'remove_urls_emails')?.enabled).toBe(false) }) it('should not affect other rules', () => { @@ -611,17 +611,17 @@ describe('useSegmentationState', () => { act(() => { result.current.setRules([ - { id: 'rule1', enabled: true }, - { id: 'rule2', enabled: false }, + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, ]) }) act(() => { - result.current.toggleRule('rule2') + result.current.toggleRule('remove_urls_emails') }) - expect(result.current.rules.find((r) => r.id === 'rule1')?.enabled).toBe(true) - expect(result.current.rules.find((r) => r.id === 'rule2')?.enabled).toBe(true) + expect(result.current.rules.find((r) => r.id === 'remove_extra_spaces')?.enabled).toBe(true) + expect(result.current.rules.find((r) => r.id === 'remove_urls_emails')?.enabled).toBe(true) }) }) @@ -2414,7 +2414,7 @@ describe('StepTwo Component', () => { describe('Initialization', () => { it('should fetch default process rule when not in setting mode', () => { render() - expect(mockFetchDefaultProcessRuleMutate).toHaveBeenCalledWith('/datasets/process-rule') + expect(mockFetchDefaultProcessRuleMutate).toHaveBeenCalled() }) it('should apply config from rules when in setting mode with document detail', () => { diff --git a/web/app/components/datasets/create/step-two/index.tsx b/web/app/components/datasets/create/step-two/index.tsx index caf9111a3fc18f..4a88d4eec75bc0 100644 --- a/web/app/components/datasets/create/step-two/index.tsx +++ b/web/app/components/datasets/create/step-two/index.tsx @@ -252,7 +252,7 @@ const StepTwo: FC = ({ // Initialize rules useEffect(() => { if (!isSetting) { - fetchDefaultProcessRuleMutation.mutate('/datasets/process-rule') + fetchDefaultProcessRuleMutation.mutate() } else if (documentDetail) { const rules = documentDetail.dataset_process_rule.rules const isHierarchical = diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts b/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts index d29b03d2bfa38b..676ba234b80bca 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts +++ b/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts @@ -1,4 +1,3 @@ -import type { CommonResponse } from '@/models/common' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' @@ -77,9 +76,7 @@ export const useDocumentActions = ({ const opApi = actionMutationMap[actionName] if (!opApi) return - const [e] = await asyncRunSafe( - opApi({ datasetId, documentIds: selectedIds }), - ) + const [e] = await asyncRunSafe(opApi({ datasetId, documentIds: selectedIds })) if (!e) { if (actionName === DocumentActionType.delete) onClearSelection() @@ -94,7 +91,7 @@ export const useDocumentActions = ({ ) const handleBatchReIndex = useCallback(async () => { - const [e] = await asyncRunSafe( + const [e] = await asyncRunSafe( retryIndexDocument({ datasetId, documentIds: selectedIds }), ) if (!e) { diff --git a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx index 3a6d3d2e7c7d46..242e66e6ccfe96 100644 --- a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx @@ -535,7 +535,8 @@ describe('DocumentDetail', () => { expect(mocks.batchImport).toHaveBeenCalledWith( { - url: '/datasets/ds-1/documents/doc-1/segments/batch_import', + datasetId: 'ds-1', + documentId: 'doc-1', body: { upload_file_id: 'file-1' }, }, expect.objectContaining({ diff --git a/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts b/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts index 52c44a1865335d..92d5940ba88be5 100644 --- a/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts +++ b/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts @@ -1,4 +1,3 @@ -import type { CommonResponse } from '@/models/common' import type { IndexingStatusResponse } from '@/models/datasets' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -116,7 +115,7 @@ export const usePauseIndexing = ({ onSuccess, onError, }: UsePauseResumeOptions) => { - return useMutation({ + return useMutation({ mutationKey: [NAME_SPACE, 'pause', datasetId, documentId], mutationFn: () => pauseDocIndexing({ datasetId: datasetId!, documentId: documentId! }), onSuccess, @@ -130,7 +129,7 @@ export const useResumeIndexing = ({ onSuccess, onError, }: UsePauseResumeOptions) => { - return useMutation({ + return useMutation({ mutationKey: [NAME_SPACE, 'resume', datasetId, documentId], mutationFn: () => resumeDocIndexing({ datasetId: datasetId!, documentId: documentId! }), onSuccess, diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx index 24ccb29b3ee712..27b9d31457404f 100644 --- a/web/app/components/datasets/documents/detail/index.tsx +++ b/web/app/components/datasets/documents/detail/index.tsx @@ -117,7 +117,8 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => { const runBatch = async (csv: FileItem) => { await segmentBatchImport( { - url: `/datasets/${datasetId}/documents/${documentId}/segments/batch_import`, + datasetId, + documentId, body: { upload_file_id: csv.file.id! }, }, { diff --git a/web/app/components/datasets/external-api/external-knowledge-api-card/__tests__/index.spec.tsx b/web/app/components/datasets/external-api/external-knowledge-api-card/__tests__/index.spec.tsx index 5e989cf31ce302..95dd57b412653a 100644 --- a/web/app/components/datasets/external-api/external-knowledge-api-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/external-api/external-knowledge-api-card/__tests__/index.spec.tsx @@ -410,29 +410,5 @@ describe('ExternalKnowledgeAPICard', () => { ) expect(screen.getByText('Test External API'))!.toBeInTheDocument() }) - - it('should handle delete response with unsuccessful result', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 }) - vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'error' }) - - render() - const deleteButton = screen.getByRole('button', { name: deleteButtonName }) - - fireEvent.click(deleteButton!) - - await waitFor(() => { - expect(screen.getByRole('button', { name: /confirm/i }))!.toBeInTheDocument() - }) - - const confirmButton = screen.getByRole('button', { name: /confirm/i }) - fireEvent.click(confirmButton) - - await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith('Failed to delete external API') - }) - - consoleSpy.mockRestore() - }) }) }) diff --git a/web/app/components/datasets/external-api/external-knowledge-api-card/index.tsx b/web/app/components/datasets/external-api/external-knowledge-api-card/index.tsx index 4744a0dbd84af2..78da98648f8362 100644 --- a/web/app/components/datasets/external-api/external-knowledge-api-card/index.tsx +++ b/web/app/components/datasets/external-api/external-knowledge-api-card/index.tsx @@ -106,13 +106,9 @@ const ExternalKnowledgeAPICard: React.FC = ({ if (!canManageExternalKnowledgeApi) return try { - const response = await deleteExternalAPI({ apiTemplateId: api.id }) - if (response && response.result === 'success') { - setShowConfirm(false) - await queryClient.invalidateQueries({ queryKey: externalKnowledgeApiQueryKey }) - } else { - console.error('Failed to delete external API') - } + await deleteExternalAPI({ apiTemplateId: api.id }) + setShowConfirm(false) + await queryClient.invalidateQueries({ queryKey: externalKnowledgeApiQueryKey }) } catch (error) { console.error('Error deleting external knowledge API:', error) } diff --git a/web/app/components/datasets/extra-info/api-access/card.tsx b/web/app/components/datasets/extra-info/api-access/card.tsx index f88f7f7cb9cd0b..86d598c2ca65b2 100644 --- a/web/app/components/datasets/extra-info/api-access/card.tsx +++ b/web/app/components/datasets/extra-info/api-access/card.tsx @@ -49,7 +49,7 @@ const Card = ({ apiEnabled }: CardProps) => { const onToggle = useCallback( async (state: boolean) => { - let result: 'success' | 'fail' + let result: string if (state) result = (await enableDatasetServiceApi(datasetId ?? '')).result else result = (await disableDatasetServiceApi(datasetId ?? '')).result if (result === 'success') mutateDatasetRes?.() diff --git a/web/app/components/datasets/rename-modal/index.tsx b/web/app/components/datasets/rename-modal/index.tsx index 079f6105da1c70..f507867b376773 100644 --- a/web/app/components/datasets/rename-modal/index.tsx +++ b/web/app/components/datasets/rename-modal/index.tsx @@ -1,4 +1,5 @@ 'use client' +import type { DatasetUpdatePayload } from '@dify/contracts/api/console/datasets/types.gen' import type { IconPickerValue } from '@/app/components/base/icon-picker' import type { DataSet } from '@/models/datasets' import { Button } from '@langgenius/dify-ui/button' @@ -54,10 +55,7 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset } try { setLoading(true) - const body: Partial & { - external_knowledge_id?: string - external_knowledge_api_id?: string - } = { + const body: DatasetUpdatePayload = { name, description, icon_info: { diff --git a/web/app/components/workflow/datasets-detail-store/__tests__/provider.spec.tsx b/web/app/components/workflow/datasets-detail-store/__tests__/provider.spec.tsx index 09ae01f95b71e0..a12b108ff96a3a 100644 --- a/web/app/components/workflow/datasets-detail-store/__tests__/provider.spec.tsx +++ b/web/app/components/workflow/datasets-detail-store/__tests__/provider.spec.tsx @@ -80,7 +80,6 @@ describe('datasets-detail-store provider', () => { await waitFor(() => { expect(mockFetchDatasets).toHaveBeenCalledWith({ - url: '/datasets', params: { page: 1, ids: ['dataset-1', 'dataset-2'], diff --git a/web/app/components/workflow/datasets-detail-store/provider.tsx b/web/app/components/workflow/datasets-detail-store/provider.tsx index 19365e810ea5db..0d72f868942b21 100644 --- a/web/app/components/workflow/datasets-detail-store/provider.tsx +++ b/web/app/components/workflow/datasets-detail-store/provider.tsx @@ -23,7 +23,6 @@ const DatasetsDetailProvider: FC = ({ nodes, childr const updateDatasetsDetail = useCallback( async (datasetIds: string[]) => { const { data: datasetsDetail } = await fetchDatasets({ - url: '/datasets', params: { page: 1, ids: datasetIds }, }) if (datasetsDetail && datasetsDetail.length > 0) diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index 29f1b52c606f9f..d6519980626186 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -761,7 +761,6 @@ export const useChecklistBeforePublish = () => { updateTimeRef.current = updateTimeRef.current + 1 const currUpdateTime = updateTimeRef.current const { data: datasetsDetail } = await fetchDatasets({ - url: '/datasets', params: { page: 1, ids: [...allDatasetIds] }, }) if (currUpdateTime < updateTimeRef.current) return null diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts index 3f97777611bc09..9f6265e7619add 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts @@ -317,7 +317,6 @@ describe('knowledge-retrieval/use-config', () => { }) expect(mockFetchDatasets).toHaveBeenCalledWith({ - url: '/datasets', params: { page: 1, ids: ['dataset-1'], diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-dataset-selection.spec.ts b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-dataset-selection.spec.ts index cc723b900dc543..c591d326feaddc 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-dataset-selection.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-dataset-selection.spec.ts @@ -160,7 +160,6 @@ describe('use-knowledge-dataset-selection', () => { }) expect(mockFetchDatasets).toHaveBeenCalledWith({ - url: '/datasets', params: { page: 1, ids: ['dataset-1'], diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-dataset-selection.ts b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-dataset-selection.ts index 70466798adbc05..c106df2e940885 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-dataset-selection.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/use-knowledge-dataset-selection.ts @@ -39,7 +39,6 @@ const useKnowledgeDatasetSelection = ({ const datasetIds = currentInputs.dataset_ids if (datasetIds.length > 0) { const { data: dataSetsWithDetail } = await fetchDatasets({ - url: '/datasets', params: { page: 1, ids: datasetIds, diff --git a/web/models/datasets.ts b/web/models/datasets.ts index fc378acb447cf9..de5902e53776fe 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -4,6 +4,8 @@ import type { HitTestingChildChunk as GeneratedHitTestingChildChunk, HitTestingResponse as GeneratedHitTestingResponse, HitTestingRecord as HitTestingRecordResponse, + NotionInfo as NotionInfoPayload, + PreProcessingRule as PreProcessingRulePayload, } from '@dify/contracts/api/console/datasets/types.gen' import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen' import type { DataSourceNotionPage, DataSourceProvider } from './common' @@ -143,10 +145,6 @@ export type ExternalKnowledgeItem = { tags: Tag[] } -export type ExternalAPIDeleteResponse = { - result: 'success' | 'error' -} - export type ExternalAPIUsage = { is_using: boolean count: number @@ -203,7 +201,6 @@ export type FileItem = { } export type FetchDatasetsParams = { - url: string params: { page: number ids?: string[] @@ -296,10 +293,7 @@ type Limits = { indexing_max_segmentation_tokens_length: number } -export type PreProcessingRule = { - id: string - enabled: boolean -} +export type PreProcessingRule = PreProcessingRulePayload type Segmentation = { separator: string @@ -456,18 +450,14 @@ type DocumentReq = { summary_index_setting?: SummaryIndexSetting } -export type CreateDocumentReq = DocumentReq & { +export type CreateDocumentReq = Omit & { + indexing_technique: IndexingType data_source: DataSource retrieval_model: RetrievalConfig embedding_model: string embedding_model_provider: string } -export type IndexingEstimateParams = DocumentReq & - Partial & { - dataset_id: string - } - type DataSource = { type: DataSourceType info_list: { @@ -484,11 +474,7 @@ type DataSource = { } } -export type NotionInfo = { - workspace_id: string - pages: DataSourceNotionPage[] - credential_id: string -} +export type NotionInfo = NotionInfoPayload export type ProcessRule = { mode: ProcessMode @@ -758,24 +744,6 @@ export const DOC_FORM_TEXT: Record = { // [ChunkingMode.graph]: 'graph', // todo: Graph RAG } -export type CreateDatasetReq = { - yaml_content?: string -} - -export type CreateDatasetResponse = { - id: string - name: string - description: string - permission: DatasetPermission - indexing_technique: IndexingType - created_by: string - created_at: number - updated_by: string - updated_at: number - pipeline_id: string - dataset_id: string -} - export type IndexingStatusBatchRequest = { datasetId: string batchId: string diff --git a/web/package.json b/web/package.json index 7010fdfb09f752..1d04988c6c259a 100644 --- a/web/package.json +++ b/web/package.json @@ -113,7 +113,6 @@ "next-themes": "catalog:", "nuqs": "catalog:", "qrcode.react": "catalog:", - "qs": "catalog:", "react": "catalog:", "react-dom": "catalog:", "react-easy-crop": "catalog:", @@ -169,7 +168,6 @@ "@types/js-cookie": "catalog:", "@types/negotiator": "catalog:", "@types/node": "catalog:", - "@types/qs": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@types/sortablejs": "catalog:", diff --git a/web/service/console/openapi-url.ts b/web/service/console/openapi-url.ts index e6dea280c7cb55..42761b1ae62ecd 100644 --- a/web/service/console/openapi-url.ts +++ b/web/service/console/openapi-url.ts @@ -7,6 +7,8 @@ const repeatedQueryArrayRules = [ { path: /\/apps\/starred$/, fields: ['tag_ids', 'creator_ids'] }, { path: /\/apps\/[^/]+\/annotations$/, fields: ['annotation_id'] }, { path: /\/datasets$/, fields: ['ids', 'tag_ids'] }, + { path: /\/datasets\/[^/]+\/documents$/, fields: ['document_id'] }, + { path: /\/datasets\/[^/]+\/documents\/status\/[^/]+\/batch$/, fields: ['document_id'] }, { path: /\/datasets\/[^/]+\/documents\/[^/]+\/segment\/[^/]+$/, fields: ['segment_id'] }, { path: /\/datasets\/[^/]+\/documents\/[^/]+\/segments$/, fields: ['segment_id', 'status'] }, { path: /\/trial-apps\/[^/]+\/datasets$/, fields: ['ids'] }, diff --git a/web/service/datasets.ts b/web/service/datasets.ts index c1ae7b8e646cf3..4f7c131611c3d4 100644 --- a/web/service/datasets.ts +++ b/web/service/datasets.ts @@ -1,25 +1,36 @@ +import type { + DatasetUpdatePayload, + IndexingEstimatePayload, + KnowledgeConfig, +} from '@dify/contracts/api/console/datasets/types.gen' import type { CreateExternalAPIReq } from '@/app/components/datasets/external-api/declarations' import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations' import type { CommonResponse } from '@/models/common' import type { - CreateDocumentReq, createDocumentResponse, DataSet, DataSetListResponse, - ExternalAPIDeleteResponse, ExternalAPIItem, ExternalAPIUsage, ExternalKnowledgeItem, FetchDatasetsParams, FileIndexingEstimateResponse, - IndexingEstimateParams, IndexingStatusBatchResponse, IndexingStatusResponse, ProcessRuleResponse, } from '@/models/datasets' -import qs from 'qs' import { DataSourceProvider } from '@/models/common' -import { del, get, patch, post } from './base' +// oxlint-disable-next-line no-restricted-imports -- the zip download needs the raw Blob response +import { post } from './base' +import { consoleClient } from './console' + +/** + * The knowledge UI types in `@/models/datasets` narrow fields that backend response models + * declare as plain strings (indexing_technique, doc_form, permission, indexing statuses, ...). + * Until those response models declare literal types, contract responses are narrowed to the UI + * types at this boundary instead of re-typing every consumer. + */ +export const narrowKnowledgeResponse = (response: Promise) => response as Promise // apis for documents in a dataset @@ -51,49 +62,30 @@ export const updateDatasetSetting = ({ body, }: { datasetId: string - body: Partial< - Pick< - DataSet, - | 'name' - | 'description' - | 'permission' - | 'partial_member_list' - | 'indexing_technique' - | 'retrieval_model' - | 'embedding_model' - | 'embedding_model_provider' - | 'icon_info' - | 'doc_form' - > - > + body: DatasetUpdatePayload }): Promise => { - return patch(`/datasets/${datasetId}`, { body }) + return narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.patch({ params: { dataset_id: datasetId }, body }), + ) } -export const fetchDatasets = ({ - url, - params, -}: FetchDatasetsParams): Promise => { - const urlParams = qs.stringify(params, { indices: false }) - return get(`${url}?${urlParams}`) +export const fetchDatasets = ({ params }: FetchDatasetsParams): Promise => { + return narrowKnowledgeResponse(consoleClient.datasets.get({ query: params })) } export const createEmptyDataset = ({ name }: { name: string }): Promise => { - return post('/datasets', { body: { name } }) + return narrowKnowledgeResponse(consoleClient.datasets.post({ body: { name } })) } export const checkIsUsedInApp = (id: string): Promise<{ is_using: boolean }> => { - return get<{ is_using: boolean }>( - `/datasets/${id}/use-check`, - {}, - { - silent: true, - }, + return consoleClient.datasets.byDatasetId.useCheck.get( + { params: { dataset_id: id } }, + { context: { silent: true } }, ) } -export const deleteDataset = (datasetID: string): Promise => { - return del(`/datasets/${datasetID}`) +export const deleteDataset = (datasetID: string) => { + return consoleClient.datasets.byDatasetId.delete({ params: { dataset_id: datasetID } }) } export const fetchExternalAPI = ({ @@ -101,7 +93,11 @@ export const fetchExternalAPI = ({ }: { apiTemplateId: string }): Promise => { - return get(`/datasets/external-knowledge-api/${apiTemplateId}`) + return narrowKnowledgeResponse( + consoleClient.datasets.externalKnowledgeApi.byExternalKnowledgeApiId.get({ + params: { external_knowledge_api_id: apiTemplateId }, + }), + ) } export const updateExternalAPI = ({ @@ -111,15 +107,18 @@ export const updateExternalAPI = ({ apiTemplateId: string body: ExternalAPIItem }): Promise => { - return patch(`/datasets/external-knowledge-api/${apiTemplateId}`, { body }) + return narrowKnowledgeResponse( + consoleClient.datasets.externalKnowledgeApi.byExternalKnowledgeApiId.patch({ + params: { external_knowledge_api_id: apiTemplateId }, + body, + }), + ) } -export const deleteExternalAPI = ({ - apiTemplateId, -}: { - apiTemplateId: string -}): Promise => { - return del(`/datasets/external-knowledge-api/${apiTemplateId}`) +export const deleteExternalAPI = ({ apiTemplateId }: { apiTemplateId: string }) => { + return consoleClient.datasets.externalKnowledgeApi.byExternalKnowledgeApiId.delete({ + params: { external_knowledge_api_id: apiTemplateId }, + }) } export const checkUsageExternalAPI = ({ @@ -127,7 +126,9 @@ export const checkUsageExternalAPI = ({ }: { apiTemplateId: string }): Promise => { - return get(`/datasets/external-knowledge-api/${apiTemplateId}/use-check`) + return consoleClient.datasets.externalKnowledgeApi.byExternalKnowledgeApiId.useCheck.get({ + params: { external_knowledge_api_id: apiTemplateId }, + }) } export const createExternalAPI = ({ @@ -135,7 +136,7 @@ export const createExternalAPI = ({ }: { body: CreateExternalAPIReq }): Promise => { - return post('/datasets/external-knowledge-api', { body }) + return narrowKnowledgeResponse(consoleClient.datasets.externalKnowledgeApi.post({ body })) } export const createExternalKnowledgeBase = ({ @@ -143,19 +144,19 @@ export const createExternalKnowledgeBase = ({ }: { body: CreateKnowledgeBaseReq }): Promise => { - return post('/datasets/external', { body }) + return narrowKnowledgeResponse(consoleClient.datasets.external.post({ body })) } -export const fetchDefaultProcessRule = ({ url }: { url: string }): Promise => { - return get(url) +export const fetchDefaultProcessRule = (): Promise => { + return narrowKnowledgeResponse(consoleClient.datasets.processRule.get({})) } export const createFirstDocument = ({ body, }: { - body: CreateDocumentReq + body: KnowledgeConfig }): Promise => { - return post('/datasets/init', { body }) + return narrowKnowledgeResponse(consoleClient.datasets.init.post({ body })) } export const createDocument = ({ @@ -163,18 +164,21 @@ export const createDocument = ({ body, }: { datasetId: string - body: CreateDocumentReq + body: KnowledgeConfig }): Promise => { - return post(`/datasets/${datasetId}/documents`, { body }) + return narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.documents.post({ params: { dataset_id: datasetId }, body }), + ) } export const fetchIndexingStatus = ({ datasetId, documentId, }: CommonDocReq): Promise => { - return get( - `/datasets/${datasetId}/documents/${documentId}/indexing-status`, - {}, + return narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.documents.byDocumentId.indexingStatus.get({ + params: { dataset_id: datasetId, document_id: documentId }, + }), ) } @@ -182,9 +186,10 @@ export const fetchIndexingStatusBatch = ({ datasetId, batchId, }: BatchReq): Promise => { - return get( - `/datasets/${datasetId}/batch/${batchId}/indexing-status`, - {}, + return narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.batch.byBatch.indexingStatus.get({ + params: { dataset_id: datasetId, batch: batchId }, + }), ) } @@ -193,35 +198,37 @@ export const renameDocumentName = ({ documentId, name, }: CommonDocReq & { name: string }): Promise => { - return post(`/datasets/${datasetId}/documents/${documentId}/rename`, { - body: { name }, - }) + return narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.documents.byDocumentId.rename.post({ + params: { dataset_id: datasetId, document_id: documentId }, + body: { name }, + }), + ) } -export const pauseDocIndexing = ({ - datasetId, - documentId, -}: CommonDocReq): Promise => { - return patch(`/datasets/${datasetId}/documents/${documentId}/processing/pause`) +export const pauseDocIndexing = ({ datasetId, documentId }: CommonDocReq) => { + return consoleClient.datasets.byDatasetId.documents.byDocumentId.processing.pause.patch({ + params: { dataset_id: datasetId, document_id: documentId }, + }) } -export const resumeDocIndexing = ({ - datasetId, - documentId, -}: CommonDocReq): Promise => { - return patch(`/datasets/${datasetId}/documents/${documentId}/processing/resume`) +export const resumeDocIndexing = ({ datasetId, documentId }: CommonDocReq) => { + return consoleClient.datasets.byDatasetId.documents.byDocumentId.processing.resume.patch({ + params: { dataset_id: datasetId, document_id: documentId }, + }) } export const fetchDocumentDownloadUrl = ({ datasetId, documentId, }: CommonDocReq): Promise => { - return get( - `/datasets/${datasetId}/documents/${documentId}/download`, - {}, - ) + return consoleClient.datasets.byDatasetId.documents.byDocumentId.download.get({ + params: { dataset_id: datasetId, document_id: documentId }, + }) } +// Not migrated to the console contract: the endpoint streams a zip file, which the +// JSON-based console client cannot return as a Blob. export const downloadDocumentsZip = ({ datasetId, documentIds, @@ -235,9 +242,9 @@ export const downloadDocumentsZip = ({ // hit testing export const fetchFileIndexingEstimate = ( - body: IndexingEstimateParams, + body: IndexingEstimatePayload, ): Promise => { - return post('/datasets/indexing-estimate', { body }) + return narrowKnowledgeResponse(consoleClient.datasets.indexingEstimate.post({ body })) } export const fetchNotionPagePreview = ({ @@ -249,81 +256,45 @@ export const fetchNotionPagePreview = ({ pageType: string credentialID: string }): Promise<{ content: string }> => { - return get<{ content: string }>(`notion/pages/${pageID}/${pageType}/preview`, { - params: { - credential_id: credentialID, - }, + return consoleClient.notion.pages.byPageId.byPageType.preview.get({ + params: { page_id: pageID, page_type: pageType }, + query: { credential_id: credentialID }, }) } -export const createFirecrawlTask = (body: Record): Promise => { - return post('website/crawl', { - body: { - ...body, - provider: DataSourceProvider.fireCrawl, - }, - }) -} +type WebsiteCrawlProvider = 'firecrawl' | 'jinareader' | 'watercrawl' -export const checkFirecrawlTaskStatus = (jobId: string): Promise => { - return get( - `website/crawl/status/${jobId}`, - { - params: { - provider: DataSourceProvider.fireCrawl, - }, - }, - { - silent: true, - }, - ) +type WebsiteCrawlTaskBody = { + url: string + options: Record } -export const createJinaReaderTask = (body: Record): Promise => { - return post('website/crawl', { - body: { - ...body, - provider: DataSourceProvider.jinaReader, - }, - }) -} +const createWebsiteCrawlTask = (provider: WebsiteCrawlProvider, body: WebsiteCrawlTaskBody) => + consoleClient.website.crawl.post({ body: { ...body, provider } }) -export const checkJinaReaderTaskStatus = (jobId: string): Promise => { - return get( - `website/crawl/status/${jobId}`, - { - params: { - provider: 'jinareader', - }, - }, - { - silent: true, - }, +const checkWebsiteCrawlTaskStatus = (provider: WebsiteCrawlProvider, jobId: string) => + consoleClient.website.crawl.status.byJobId.get( + { params: { job_id: jobId }, query: { provider } }, + { context: { silent: true } }, ) -} -export const createWatercrawlTask = (body: Record): Promise => { - return post('website/crawl', { - body: { - ...body, - provider: DataSourceProvider.waterCrawl, - }, - }) -} +export const createFirecrawlTask = (body: WebsiteCrawlTaskBody) => + createWebsiteCrawlTask(DataSourceProvider.fireCrawl, body) -export const checkWatercrawlTaskStatus = (jobId: string): Promise => { - return get( - `website/crawl/status/${jobId}`, - { - params: { - provider: DataSourceProvider.waterCrawl, - }, - }, - { - silent: true, - }, - ) -} +export const checkFirecrawlTaskStatus = (jobId: string) => + checkWebsiteCrawlTaskStatus(DataSourceProvider.fireCrawl, jobId) + +export const createJinaReaderTask = (body: WebsiteCrawlTaskBody) => + createWebsiteCrawlTask(DataSourceProvider.jinaReader, body) + +export const checkJinaReaderTaskStatus = (jobId: string) => + checkWebsiteCrawlTaskStatus(DataSourceProvider.jinaReader, jobId) + +export const createWatercrawlTask = (body: WebsiteCrawlTaskBody) => + createWebsiteCrawlTask(DataSourceProvider.waterCrawl, body) + +export const checkWatercrawlTaskStatus = (jobId: string) => + checkWebsiteCrawlTaskStatus(DataSourceProvider.waterCrawl, jobId) export const retryErrorDocs = ({ datasetId, @@ -331,6 +302,9 @@ export const retryErrorDocs = ({ }: { datasetId: string document_ids: string[] -}): Promise => { - return post(`/datasets/${datasetId}/retry`, { body: { document_ids } }) +}) => { + return consoleClient.datasets.byDatasetId.retry.post({ + params: { dataset_id: datasetId }, + body: { document_ids }, + }) } diff --git a/web/service/knowledge/use-create-dataset.ts b/web/service/knowledge/use-create-dataset.ts index aa146694536c05..67b6b098b3cd57 100644 --- a/web/service/knowledge/use-create-dataset.ts +++ b/web/service/knowledge/use-create-dataset.ts @@ -1,3 +1,9 @@ +import type { IndexingEstimatePayload } from '@dify/contracts/api/console/datasets/types.gen' +import type { + PostRagPipelineDatasetData, + PostRagPipelineDatasetResponse, + PostRagPipelineEmptyDatasetResponse, +} from '@dify/contracts/api/console/rag/types.gen' import type { MutationOptions } from '@tanstack/react-query' import type { IndexingType } from '@/app/components/datasets/create/step-two' import type { DataSourceProvider, NotionPage } from '@/models/common' @@ -5,21 +11,18 @@ import type { ChunkingMode, CrawlOptions, CrawlResultItem, - CreateDatasetReq, - CreateDatasetResponse, CreateDocumentReq, createDocumentResponse, CustomFile, DataSourceType, FileIndexingEstimateResponse, - IndexingEstimateParams, NotionInfo, ProcessRule, ProcessRuleResponse, } from '@/models/datasets' import { useMutation } from '@tanstack/react-query' import { groupBy } from 'es-toolkit/compat' -import { post } from '../base' +import { consoleClient } from '../console' import { createDocument, createFirstDocument, @@ -29,7 +32,7 @@ import { const NAME_SPACE = 'knowledge/create-dataset' -export const getNotionInfo = (notionPages: NotionPage[], credentialId: string) => { +export const getNotionInfo = (notionPages: NotionPage[], credentialId: string): NotionInfo[] => { const workspacesMap = groupBy(notionPages, 'workspace_id') const workspaces = Object.keys(workspacesMap).map((workspaceId) => { return { @@ -46,12 +49,13 @@ export const getNotionInfo = (notionPages: NotionPage[], credentialId: string) = return { page_id, page_name, - page_icon, + // The backend requires an icon type; an icon without one carries nothing to render. + page_icon: page_icon?.type ? { ...page_icon, type: page_icon.type } : null, type, } }), } - }) as NotionInfo[] + }) } export const getWebsiteInfo = (opts: { @@ -90,7 +94,7 @@ const getFileIndexingEstimateParamsForFile = ({ indexingTechnique, processRule, dataset_id, -}: GetFileIndexingEstimateParamsOptionFile): IndexingEstimateParams => { +}: GetFileIndexingEstimateParamsOptionFile): IndexingEstimatePayload => { const fileIds = files.map((file) => file.id).filter((id): id is string => Boolean(id)) return { @@ -135,7 +139,7 @@ const getFileIndexingEstimateParamsForNotion = ({ processRule, dataset_id, credential_id, -}: GetFileIndexingEstimateParamsOptionNotion): IndexingEstimateParams => { +}: GetFileIndexingEstimateParamsOptionNotion): IndexingEstimatePayload => { return { info_list: { data_source_type: dataSourceType, @@ -180,7 +184,7 @@ const getFileIndexingEstimateParamsForWeb = ({ indexingTechnique, processRule, dataset_id, -}: GetFileIndexingEstimateParamsOptionWeb): IndexingEstimateParams => { +}: GetFileIndexingEstimateParamsOptionWeb): IndexingEstimatePayload => { return { info_list: { data_source_type: dataSourceType, @@ -235,35 +239,37 @@ export const useCreateDocument = ( } export const useFetchDefaultProcessRule = ( - mutationOptions: MutationOptions = {}, + mutationOptions: MutationOptions = {}, ) => { return useMutation({ - mutationFn: async (url: string) => { - return fetchDefaultProcessRule({ url }) - }, + mutationFn: fetchDefaultProcessRule, ...mutationOptions, }) } export const useCreatePipelineDataset = ( - mutationOptions: MutationOptions = {}, + mutationOptions: MutationOptions = {}, ) => { return useMutation({ mutationKey: [NAME_SPACE, 'create-pipeline-empty-dataset'], mutationFn: () => { - return post('/rag/pipeline/empty-dataset') + return consoleClient.rag.pipeline.emptyDataset.post() }, ...mutationOptions, }) } export const useCreatePipelineDatasetFromCustomized = ( - mutationOptions: MutationOptions = {}, + mutationOptions: MutationOptions< + PostRagPipelineDatasetResponse, + Error, + PostRagPipelineDatasetData['body'] + > = {}, ) => { return useMutation({ mutationKey: [NAME_SPACE, 'create-pipeline-dataset'], - mutationFn: (req: CreateDatasetReq) => { - return post('/rag/pipeline/dataset', { body: req }) + mutationFn: (req: PostRagPipelineDatasetData['body']) => { + return consoleClient.rag.pipeline.dataset.post({ body: req }) }, ...mutationOptions, }) diff --git a/web/service/knowledge/use-dataset.spec.ts b/web/service/knowledge/use-dataset.spec.ts index ca472887f0d6d4..f9520da3526855 100644 --- a/web/service/knowledge/use-dataset.spec.ts +++ b/web/service/knowledge/use-dataset.spec.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { createHttpError } from '@/service/http-error' -import { get } from '../base' +import { consoleClient } from '../console' import { useDatasetDetail, useDatasetRelatedApps } from './use-dataset' vi.mock('@tanstack/react-query', () => ({ @@ -11,9 +11,8 @@ vi.mock('@tanstack/react-query', () => ({ useQueryClient: vi.fn(), })) -vi.mock('../base', () => ({ - get: vi.fn(), - post: vi.fn(), +vi.mock('../console', () => ({ + consoleClient: { datasets: { byDatasetId: { get: vi.fn() } } }, })) vi.mock('../use-base', () => ({ @@ -21,7 +20,7 @@ vi.mock('../use-base', () => ({ })) const mockUseQuery = vi.mocked(useQuery) -const mockGet = vi.mocked(get) +const mockGetDataset = vi.mocked(consoleClient.datasets.byDatasetId.get) type QueryOptions = Parameters[0] type RetryFn = (failureCount: number, error: unknown) => boolean @@ -63,7 +62,7 @@ describe('knowledge dataset hooks', () => { it('should fetch dataset detail without silent mode', () => { // Arrange - mockGet.mockResolvedValue({ id: 'dataset-1' }) + mockGetDataset.mockResolvedValue({ id: 'dataset-1' } as never) // Act useDatasetDetail('dataset-1') @@ -71,7 +70,7 @@ describe('knowledge dataset hooks', () => { queryFn() // Assert - expect(mockGet).toHaveBeenCalledWith('/datasets/dataset-1') + expect(mockGetDataset).toHaveBeenCalledWith({ params: { dataset_id: 'dataset-1' } }) }) }) diff --git a/web/service/knowledge/use-dataset.ts b/web/service/knowledge/use-dataset.ts index 1785beffa6336a..bfef6fff14bdb4 100644 --- a/web/service/knowledge/use-dataset.ts +++ b/web/service/knowledge/use-dataset.ts @@ -1,5 +1,4 @@ import type { MutationOptions } from '@tanstack/react-query' -import type { CommonResponse } from '@/models/common' import type { DataSet, DatasetListRequest, @@ -14,8 +13,8 @@ import type { } from '@/models/datasets' import { ORPCError } from '@orpc/client' import { keepPreviousData, useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query' -import qs from 'qs' -import { get, post } from '../base' +import { consoleClient } from '../console' +import { fetchIndexingStatusBatch, narrowKnowledgeResponse } from '../datasets' import { useInvalid } from '../use-base' const NAME_SPACE = 'dataset' @@ -51,16 +50,12 @@ export const useInfiniteDatasets = ( return useInfiniteQuery({ queryKey: [...datasetListQueryKey, 'infinite', normalizedParams], - queryFn: ({ pageParam = normalizedParams.page }) => { - const queryString = qs.stringify( - { - ...normalizedParams, - page: pageParam as number | undefined, - }, - { indices: false }, - ) - return get(`/datasets?${queryString}`) - }, + queryFn: ({ pageParam = normalizedParams.page }) => + narrowKnowledgeResponse( + consoleClient.datasets.get({ + query: { ...normalizedParams, page: pageParam as number | undefined }, + }), + ), getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), initialPageParam: normalizedParams.page, staleTime: 0, @@ -73,19 +68,12 @@ export const useDatasetList = (params: DatasetListRequest) => { const { initialPage, tag_ids, limit, include_all, keyword } = params return useInfiniteQuery({ queryKey: [...datasetListQueryKey, initialPage, tag_ids, limit, include_all, keyword], - queryFn: ({ pageParam = 1 }) => { - const urlParams = qs.stringify( - { - tag_ids, - limit, - include_all, - keyword, - page: pageParam, - }, - { indices: false }, - ) - return get(`/datasets?${urlParams}`) - }, + queryFn: ({ pageParam = 1 }) => + narrowKnowledgeResponse( + consoleClient.datasets.get({ + query: { tag_ids, limit, include_all, keyword, page: pageParam }, + }), + ), getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : null), initialPageParam: initialPage, placeholderData: keepPreviousData, @@ -101,7 +89,10 @@ export const datasetDetailQueryKeyPrefix = [NAME_SPACE, 'detail'] export const useDatasetDetail = (datasetId: string) => { return useQuery({ queryKey: [...datasetDetailQueryKeyPrefix, datasetId], - queryFn: () => get(`/datasets/${datasetId}`), + queryFn: () => + narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.get({ params: { dataset_id: datasetId } }), + ), enabled: !!datasetId, retry: (failureCount, error) => { if (error instanceof ORPCError && [403, 404].includes(error.status)) return false @@ -114,7 +105,10 @@ export const useDatasetDetail = (datasetId: string) => { export const useDatasetRelatedApps = (datasetId: string, options?: { enabled?: boolean }) => { return useQuery({ queryKey: [NAME_SPACE, 'related-apps', datasetId], - queryFn: () => get(`/datasets/${datasetId}/related-apps`), + queryFn: () => + narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.relatedApps.get({ params: { dataset_id: datasetId } }), + ), enabled: options?.enabled ?? !!datasetId, }) } @@ -126,8 +120,7 @@ export const useIndexingStatusBatch = ( const { datasetId, batchId } = params return useMutation({ mutationKey: [NAME_SPACE, 'indexing-status-batch', datasetId, batchId], - mutationFn: () => - get(`/datasets/${datasetId}/batch/${batchId}/indexing-status`), + mutationFn: () => fetchIndexingStatusBatch({ datasetId, batchId }), ...mutationOptions, }) } @@ -136,7 +129,9 @@ export const useProcessRule = (documentId?: string) => { return useQuery({ queryKey: [NAME_SPACE, 'process-rule', documentId], queryFn: () => - get('/datasets/process-rule', { params: { document_id: documentId } }), + narrowKnowledgeResponse( + consoleClient.datasets.processRule.get({ query: { document_id: documentId } }), + ), enabled: !!documentId, refetchOnWindowFocus: false, }) @@ -145,7 +140,7 @@ export const useProcessRule = (documentId?: string) => { export const useDatasetApiBaseUrl = () => { return useQuery<{ api_base_url: string }>({ queryKey: [NAME_SPACE, 'api-base-info'], - queryFn: () => get<{ api_base_url: string }>('/datasets/api-base-info'), + queryFn: () => consoleClient.datasets.apiBaseInfo.get(), }) } @@ -153,7 +148,9 @@ export const useEnableDatasetServiceApi = () => { return useMutation({ mutationKey: [NAME_SPACE, 'enable-api'], mutationFn: (datasetId: string) => - post(`/datasets/${datasetId}/api-keys/enable`), + consoleClient.datasets.byDatasetId.apiKeys.byStatus.post({ + params: { dataset_id: datasetId, status: 'enable' }, + }), }) } @@ -161,7 +158,9 @@ export const useDisableDatasetServiceApi = () => { return useMutation({ mutationKey: [NAME_SPACE, 'disable-api'], mutationFn: (datasetId: string) => - post(`/datasets/${datasetId}/api-keys/disable`), + consoleClient.datasets.byDatasetId.apiKeys.byStatus.post({ + params: { dataset_id: datasetId, status: 'disable' }, + }), }) } @@ -172,7 +171,13 @@ export const useDatasetTestingRecords = ( ) => { return useQuery({ queryKey: [NAME_SPACE, 'testing-records', datasetId, params], - queryFn: () => get(`/datasets/${datasetId}/queries`, { params }), + queryFn: () => + narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.queries.get({ + params: { dataset_id: datasetId! }, + query: params, + }), + ), enabled: !!datasetId && !!params && (options?.enabled ?? true), placeholderData: keepPreviousData, }) @@ -181,7 +186,10 @@ export const useDatasetTestingRecords = ( export const useDatasetErrorDocs = (datasetId?: string) => { return useQuery({ queryKey: [NAME_SPACE, 'error-docs', datasetId], - queryFn: () => get(`/datasets/${datasetId}/error-docs`), + queryFn: () => + narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.errorDocs.get({ params: { dataset_id: datasetId! } }), + ), enabled: !!datasetId, }) } diff --git a/web/service/knowledge/use-document.ts b/web/service/knowledge/use-document.ts index 8ff809e911c673..bf7699cccb2ac0 100644 --- a/web/service/knowledge/use-document.ts +++ b/web/service/knowledge/use-document.ts @@ -1,11 +1,5 @@ import type { UseQueryOptions } from '@tanstack/react-query' -import type { - DocumentDownloadResponse, - DocumentDownloadZipRequest, - MetadataType, - SortType, -} from '../datasets' -import type { CommonResponse } from '@/models/common' +import type { DocumentDownloadZipRequest, MetadataType, SortType } from '../datasets' import type { DocumentDetailResponse, DocumentListResponse, @@ -14,12 +8,14 @@ import type { import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query' import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter' import { DocumentActionType } from '@/models/datasets' -import { del, get, patch, post } from '../base' +import { consoleClient } from '../console' import { downloadDocumentsZip, fetchDocumentDownloadUrl, + narrowKnowledgeResponse, pauseDocIndexing, resumeDocIndexing, + retryErrorDocs, } from '../datasets' import { useInvalid } from '../use-base' @@ -42,7 +38,7 @@ export const useDocumentList = (payload: { const { query, datasetId, refetchInterval } = payload const { keyword, page, limit, sort, status } = query const normalizedStatus = normalizeStatusForQuery(status) - const params: Record = { + const params: { keyword: string; page: number; limit: number; sort?: string; status?: string } = { keyword, page, limit, @@ -52,9 +48,12 @@ export const useDocumentList = (payload: { return useQuery({ queryKey: [...useDocumentListKey, datasetId, params], queryFn: () => - get(`/datasets/${datasetId}/documents`, { - params, - }), + narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.documents.get({ + params: { dataset_id: datasetId }, + query: params, + }), + ), placeholderData: keepPreviousData, refetchInterval, }) @@ -68,7 +67,8 @@ const useAutoDisabledDocumentKey = [NAME_SPACE, 'autoDisabledDocument'] export const useAutoDisabledDocuments = (datasetId: string) => { return useQuery({ queryKey: [...useAutoDisabledDocumentKey, datasetId], - queryFn: () => get<{ document_ids: string[] }>(`/datasets/${datasetId}/auto-disable-logs`), + queryFn: () => + consoleClient.datasets.byDatasetId.autoDisableLogs.get({ params: { dataset_id: datasetId } }), }) } @@ -76,17 +76,16 @@ export const useInvalidDisabledDocument = () => { return useInvalid(useAutoDisabledDocumentKey) } -const toBatchDocumentsIdParams = (documentIds: string[] | string) => { - const ids = Array.isArray(documentIds) ? documentIds : [documentIds] - return ids.map((id) => `document_id=${id}`).join('&') -} +const toBatchDocumentIds = (documentIds: string[] | string) => + Array.isArray(documentIds) ? documentIds : [documentIds] const useDocumentBatchAction = (action: DocumentActionType) => { return useMutation({ mutationFn: ({ datasetId, documentIds, documentId }: UpdateDocumentBatchParams) => { - return patch( - `/datasets/${datasetId}/documents/status/${action}/batch?${toBatchDocumentsIdParams(documentId || documentIds!)}`, - ) + return consoleClient.datasets.byDatasetId.documents.status.byAction.batch.patch({ + params: { dataset_id: datasetId, action }, + query: { document_id: toBatchDocumentIds(documentId || documentIds!) }, + }) }, }) } @@ -110,9 +109,10 @@ export const useDocumentUnArchive = () => { export const useDocumentDelete = () => { return useMutation({ mutationFn: ({ datasetId, documentIds, documentId }: UpdateDocumentBatchParams) => { - return del( - `/datasets/${datasetId}/documents?${toBatchDocumentsIdParams(documentId || documentIds!)}`, - ) + return consoleClient.datasets.byDatasetId.documents.delete({ + params: { dataset_id: datasetId }, + query: { document_id: toBatchDocumentIds(documentId || documentIds!) }, + }) }, }) } @@ -120,7 +120,10 @@ export const useDocumentDelete = () => { export const useSyncDocument = () => { return useMutation({ mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { - return get(`/datasets/${datasetId}/documents/${documentId}/notion/sync`) + if (!documentId) throw new Error('documentId is required') + return consoleClient.datasets.byDatasetId.documents.byDocumentId.notion.sync.get({ + params: { dataset_id: datasetId, document_id: documentId }, + }) }, }) } @@ -128,9 +131,10 @@ export const useSyncDocument = () => { export const useDocumentSummary = () => { return useMutation({ mutationFn: ({ datasetId, documentIds, documentId }: UpdateDocumentBatchParams) => { - return post(`/datasets/${datasetId}/documents/generate-summary`, { + return consoleClient.datasets.byDatasetId.documents.generateSummary.post({ + params: { dataset_id: datasetId }, body: { - document_list: documentId ? [documentId] : documentIds!, + document_list: toBatchDocumentIds(documentId || documentIds!), }, }) }, @@ -140,7 +144,10 @@ export const useDocumentSummary = () => { export const useSyncWebsite = () => { return useMutation({ mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { - return get(`/datasets/${datasetId}/documents/${documentId}/website-sync`) + if (!documentId) throw new Error('documentId is required') + return consoleClient.datasets.byDatasetId.documents.byDocumentId.websiteSync.get({ + params: { dataset_id: datasetId, document_id: documentId }, + }) }, }) } @@ -158,7 +165,12 @@ export const useDocumentDetail = (payload: { return useQuery({ queryKey: [...useDocumentDetailKey, 'withoutMetaData', datasetId, documentId, params], queryFn: () => - get(`/datasets/${datasetId}/documents/${documentId}`, { params }), + narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.documents.byDocumentId.get({ + params: { dataset_id: datasetId, document_id: documentId }, + query: params, + }), + ), refetchInterval, }) } @@ -172,7 +184,12 @@ export const useDocumentMetadata = (payload: { return useQuery({ queryKey: [...useDocumentDetailKey, 'onlyMetaData', datasetId, documentId, params], queryFn: () => - get(`/datasets/${datasetId}/documents/${documentId}`, { params }), + narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.documents.byDocumentId.get({ + params: { dataset_id: datasetId, document_id: documentId }, + query: params, + }), + ), }) } @@ -184,7 +201,7 @@ export const useDocumentPause = () => { return useMutation({ mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { if (!datasetId || !documentId) throw new Error('datasetId and documentId are required') - return pauseDocIndexing({ datasetId, documentId }) as Promise + return pauseDocIndexing({ datasetId, documentId }) }, }) } @@ -193,7 +210,7 @@ export const useDocumentResume = () => { return useMutation({ mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { if (!datasetId || !documentId) throw new Error('datasetId and documentId are required') - return resumeDocIndexing({ datasetId, documentId }) as Promise + return resumeDocIndexing({ datasetId, documentId }) }, }) } @@ -202,10 +219,7 @@ export const useDocumentDownload = () => { return useMutation({ mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { if (!datasetId || !documentId) throw new Error('datasetId and documentId are required') - return fetchDocumentDownloadUrl({ - datasetId, - documentId, - }) as Promise + return fetchDocumentDownloadUrl({ datasetId, documentId }) }, }) } @@ -223,11 +237,7 @@ export const useDocumentDownloadZip = () => { export const useDocumentBatchRetryIndex = () => { return useMutation({ mutationFn: ({ datasetId, documentIds }: { datasetId: string; documentIds: string[] }) => { - return post(`/datasets/${datasetId}/retry`, { - body: { - document_ids: documentIds, - }, - }) + return retryErrorDocs({ datasetId, document_ids: documentIds }) }, }) } diff --git a/web/service/knowledge/use-metadata.ts b/web/service/knowledge/use-metadata.ts index adf9631fe7e0de..572024615f3d3a 100644 --- a/web/service/knowledge/use-metadata.ts +++ b/web/service/knowledge/use-metadata.ts @@ -5,7 +5,8 @@ import type { } from '@/app/components/datasets/metadata/types' import type { DocumentDetailResponse } from '@/models/datasets' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { del, get, patch, post } from '../base' +import { consoleClient } from '../console' +import { narrowKnowledgeResponse } from '../datasets' import { useInvalid } from '../use-base' import { useDocumentListKey, useInvalidDocumentList } from './use-document' @@ -16,10 +17,9 @@ export const useDatasetMetaData = (datasetId: string) => { { queryKey: [NAME_SPACE, 'dataset', datasetId], queryFn: () => { - return get<{ - doc_metadata: MetadataItemWithValueLength[] - built_in_field_enabled: boolean - }>(`/datasets/${datasetId}/metadata`) + return narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.metadata.get({ params: { dataset_id: datasetId } }), + ) }, }, ) @@ -33,7 +33,8 @@ export const useCreateMetaData = (datasetId: string) => { const invalidDatasetMetaData = useInvalidDatasetMetaData(datasetId) return useMutation({ mutationFn: async (payload: BuiltInMetadataItem) => { - await post(`/datasets/${datasetId}/metadata`, { + await consoleClient.datasets.byDatasetId.metadata.post({ + params: { dataset_id: datasetId }, body: payload, }) await invalidDatasetMetaData() @@ -70,7 +71,8 @@ export const useRenameMeta = (datasetId: string) => { const invalidateAllMetaData = useInvalidAllMetaData(datasetId) return useMutation({ mutationFn: async (payload: MetadataItemWithValueLength) => { - await patch(`/datasets/${datasetId}/metadata/${payload.id}`, { + await consoleClient.datasets.byDatasetId.metadata.byMetadataId.patch({ + params: { dataset_id: datasetId, metadata_id: payload.id }, body: { name: payload.name, }, @@ -85,7 +87,9 @@ export const useDeleteMetaData = (datasetId: string) => { return useMutation({ mutationFn: async (metaDataId: string) => { // datasetMetaData = datasetMetaData.filter(item => item.id !== metaDataId) - await del(`/datasets/${datasetId}/metadata/${metaDataId}`) + await consoleClient.datasets.byDatasetId.metadata.byMetadataId.delete({ + params: { dataset_id: datasetId, metadata_id: metaDataId }, + }) await invalidateAllMetaData() }, }) @@ -95,7 +99,7 @@ export const useBuiltInMetaDataFields = () => { return useQuery<{ fields: BuiltInMetadataItem[] }>({ queryKey: [NAME_SPACE, 'built-in'], queryFn: () => { - return get('/datasets/metadata/built-in') + return narrowKnowledgeResponse(consoleClient.datasets.metadata.builtIn.get()) }, }) } @@ -110,9 +114,12 @@ export const useDocumentMetaData = ({ return useQuery({ queryKey: [NAME_SPACE, 'document', datasetId, documentId], queryFn: () => { - return get(`/datasets/${datasetId}/documents/${documentId}`, { - params: { metadata: 'only' }, - }) + return narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.documents.byDocumentId.get({ + params: { dataset_id: datasetId, document_id: documentId }, + query: { metadata: 'only' }, + }), + ) }, }) } @@ -125,7 +132,8 @@ export const useBatchUpdateDocMetadata = () => { metadata_list: MetadataBatchEditToServer }) => { const documentIds = payload.metadata_list.map((item) => item.document_id) - await post(`/datasets/${payload.dataset_id}/documents/metadata`, { + await consoleClient.datasets.byDatasetId.documents.metadata.post({ + params: { dataset_id: payload.dataset_id }, body: { operation_data: payload.metadata_list, }, @@ -158,7 +166,9 @@ export const useUpdateBuiltInStatus = (datasetId: string) => { const invalidDatasetMetaData = useInvalidDatasetMetaData(datasetId) return useMutation({ mutationFn: async (enabled: boolean) => { - await post(`/datasets/${datasetId}/metadata/built-in/${enabled ? 'enable' : 'disable'}`) + await consoleClient.datasets.byDatasetId.metadata.builtIn.byAction.post({ + params: { dataset_id: datasetId, action: enabled ? 'enable' : 'disable' }, + }) invalidDatasetMetaData() }, }) diff --git a/web/service/knowledge/use-segment.ts b/web/service/knowledge/use-segment.ts index 745992b082532d..e6a85697b591a8 100644 --- a/web/service/knowledge/use-segment.ts +++ b/web/service/knowledge/use-segment.ts @@ -1,4 +1,3 @@ -import type { CommonResponse } from '@/models/common' import type { BatchImportResponse, ChildChunkDetail, @@ -9,7 +8,8 @@ import type { SegmentUpdater, } from '@/models/datasets' import { useMutation, useQuery } from '@tanstack/react-query' -import { del, get, patch, post } from '../base' +import { consoleClient } from '../console' +import { narrowKnowledgeResponse } from '../datasets' const NAME_SPACE = 'segment' @@ -36,9 +36,12 @@ export const useSegmentList = ( return useQuery({ queryKey: [...useSegmentListKey, datasetId, documentId, params], queryFn: () => { - return get(`/datasets/${datasetId}/documents/${documentId}/segments`, { - params, - }) + return narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.get({ + params: { dataset_id: datasetId, document_id: documentId }, + query: { ...params, enabled: String(params.enabled) }, + }), + ) }, enabled: !disable, }) @@ -54,9 +57,11 @@ export const useUpdateSegment = () => { body: SegmentUpdater }) => { const { datasetId, documentId, segmentId, body } = payload - return patch<{ data: SegmentDetailModel; doc_form: ChunkingMode }>( - `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}`, - { body }, + return narrowKnowledgeResponse<{ data: SegmentDetailModel; doc_form: ChunkingMode }>( + consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.patch({ + params: { dataset_id: datasetId, document_id: documentId, segment_id: segmentId }, + body, + }), ) }, }) @@ -67,9 +72,11 @@ export const useAddSegment = () => { mutationKey: [NAME_SPACE, 'add'], mutationFn: (payload: { datasetId: string; documentId: string; body: SegmentUpdater }) => { const { datasetId, documentId, body } = payload - return post<{ data: SegmentDetailModel; doc_form: ChunkingMode }>( - `/datasets/${datasetId}/documents/${documentId}/segment`, - { body }, + return narrowKnowledgeResponse<{ data: SegmentDetailModel; doc_form: ChunkingMode }>( + consoleClient.datasets.byDatasetId.documents.byDocumentId.segment.post({ + params: { dataset_id: datasetId, document_id: documentId }, + body, + }), ) }, }) @@ -80,10 +87,10 @@ export const useEnableSegment = () => { mutationKey: [NAME_SPACE, 'enable'], mutationFn: (payload: { datasetId: string; documentId: string; segmentIds: string[] }) => { const { datasetId, documentId, segmentIds } = payload - const query = segmentIds.map((id) => `segment_id=${id}`).join('&') - return patch( - `/datasets/${datasetId}/documents/${documentId}/segment/enable?${query}`, - ) + return consoleClient.datasets.byDatasetId.documents.byDocumentId.segment.byAction.patch({ + params: { dataset_id: datasetId, document_id: documentId, action: 'enable' }, + query: { segment_id: segmentIds }, + }) }, }) } @@ -93,10 +100,10 @@ export const useDisableSegment = () => { mutationKey: [NAME_SPACE, 'disable'], mutationFn: (payload: { datasetId: string; documentId: string; segmentIds: string[] }) => { const { datasetId, documentId, segmentIds } = payload - const query = segmentIds.map((id) => `segment_id=${id}`).join('&') - return patch( - `/datasets/${datasetId}/documents/${documentId}/segment/disable?${query}`, - ) + return consoleClient.datasets.byDatasetId.documents.byDocumentId.segment.byAction.patch({ + params: { dataset_id: datasetId, document_id: documentId, action: 'disable' }, + query: { segment_id: segmentIds }, + }) }, }) } @@ -106,8 +113,10 @@ export const useDeleteSegment = () => { mutationKey: [NAME_SPACE, 'delete'], mutationFn: (payload: { datasetId: string; documentId: string; segmentIds: string[] }) => { const { datasetId, documentId, segmentIds } = payload - const query = segmentIds.map((id) => `segment_id=${id}`).join('&') - return del(`/datasets/${datasetId}/documents/${documentId}/segments?${query}`) + return consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.delete({ + params: { dataset_id: datasetId, document_id: documentId }, + query: { segment_id: segmentIds }, + }) }, }) } @@ -132,9 +141,13 @@ export const useChildSegmentList = ( return useQuery({ queryKey: [...useChildSegmentListKey, datasetId, documentId, segmentId, params], queryFn: () => { - return get( - `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks`, - { params }, + return narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.get( + { + params: { dataset_id: datasetId, document_id: documentId, segment_id: segmentId }, + query: params, + }, + ), ) }, enabled: !disable, @@ -151,8 +164,15 @@ export const useDeleteChildSegment = () => { childChunkId: string }) => { const { datasetId, documentId, segmentId, childChunkId } = payload - return del( - `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks/${childChunkId}`, + return consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.byChildChunkId.delete( + { + params: { + dataset_id: datasetId, + document_id: documentId, + segment_id: segmentId, + child_chunk_id: childChunkId, + }, + }, ) }, }) @@ -168,9 +188,13 @@ export const useAddChildSegment = () => { body: { content: string } }) => { const { datasetId, documentId, segmentId, body } = payload - return post<{ data: ChildChunkDetail }>( - `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks`, - { body }, + return narrowKnowledgeResponse<{ data: ChildChunkDetail }>( + consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.post( + { + params: { dataset_id: datasetId, document_id: documentId, segment_id: segmentId }, + body, + }, + ), ) }, }) @@ -187,9 +211,18 @@ export const useUpdateChildSegment = () => { body: { content: string } }) => { const { datasetId, documentId, segmentId, childChunkId, body } = payload - return patch<{ data: ChildChunkDetail }>( - `/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks/${childChunkId}`, - { body }, + return narrowKnowledgeResponse<{ data: ChildChunkDetail }>( + consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.byChildChunkId.patch( + { + params: { + dataset_id: datasetId, + document_id: documentId, + segment_id: segmentId, + child_chunk_id: childChunkId, + }, + body, + }, + ), ) }, }) @@ -198,9 +231,18 @@ export const useUpdateChildSegment = () => { export const useSegmentBatchImport = () => { return useMutation({ mutationKey: [NAME_SPACE, 'batchImport'], - mutationFn: (payload: { url: string; body: { upload_file_id: string } }) => { - const { url, body } = payload - return post(url, { body }) + mutationFn: (payload: { + datasetId: string + documentId: string + body: { upload_file_id: string } + }) => { + const { datasetId, documentId, body } = payload + return narrowKnowledgeResponse( + consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.batchImport.post({ + params: { dataset_id: datasetId, document_id: documentId }, + body, + }), + ) }, }) } @@ -210,7 +252,9 @@ export const useCheckSegmentBatchImportProgress = () => { mutationKey: [NAME_SPACE, 'batchImport', 'checkProgress'], mutationFn: (payload: { jobID: string }) => { const { jobID } = payload - return get(`/datasets/batch_import_status/${jobID}`) + return narrowKnowledgeResponse( + consoleClient.datasets.batchImportStatus.byJobId.get({ params: { job_id: jobID } }), + ) }, }) } From d7892875af8a2708286c7674f908c656c7605d41 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:53:59 +0000 Subject: [PATCH 17/60] [autofix.ci] apply automated fixes --- api/openapi/markdown/console-openapi.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index ef3121bd337497..ea865f7ac00c9d 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -6038,6 +6038,7 @@ Get dataset auto disable logs | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string (uuid) | +| document_id | query | Document IDs | No | [ string ] | #### Responses @@ -6054,12 +6055,12 @@ Get documents in a dataset | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string (uuid) | -| fetch | query | Fetch full details (default: false) | No | string | -| keyword | query | Search keyword | No | string | -| limit | query | Number of items per page (default: 20) | No | string | -| page | query | Page number (default: 1) | No | string | -| sort | query | Sort order (default: -created_at) | No | string | -| status | query | Filter documents by display status | No | string | +| fetch | query | | No | string,
**Default:** false | +| keyword | query | Search keyword. | No | string | +| limit | query | Page size. | No | integer,
**Default:** 20 | +| page | query | Page number. | No | integer,
**Default:** 1 | +| sort | query | Sort by field. | No | string,
**Default:** -created_at | +| status | query | Document status. | No | string | #### Responses @@ -6169,6 +6170,7 @@ then asynchronously generates summary indexes for the provided documents. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| document_id | query | Document IDs | No | [ string ] | | action | path | | Yes | string | | dataset_id | path | | Yes | string (uuid) | @@ -6907,6 +6909,8 @@ Get dataset query history | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string (uuid) | +| limit | query | Number of items per page | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | #### Responses From 64d03e72c73eead32180352836065e2d14e3e591 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:56:14 +0800 Subject: [PATCH 18/60] refactor(web): migrate plugin console calls in use-plugins to the console contract - install/upgrade/upload, permission and auto-upgrade settings, debugging key, tasks and dynamic options call consoleClient; plugin UI DTOs are narrowed at the boundary - extract the boundary narrowing helper to service/console/narrow-response - dynamic options default provider_type to 'tool' (the backend requires it) - marketplace (external API) calls stay on getMarketplace/postMarketplace Co-Authored-By: Claude Opus 5.5 --- web/app/components/plugins/types.ts | 6 - web/service/__tests__/use-plugins.spec.tsx | 30 ++-- web/service/console/narrow-response.ts | 8 ++ web/service/datasets.ts | 37 ++--- web/service/knowledge/use-dataset.ts | 17 +-- web/service/knowledge/use-document.ts | 8 +- web/service/knowledge/use-metadata.ts | 8 +- web/service/knowledge/use-segment.ts | 18 +-- web/service/use-plugins.ts | 160 +++++++++++---------- 9 files changed, 154 insertions(+), 138 deletions(-) create mode 100644 web/service/console/narrow-response.ts diff --git a/web/app/components/plugins/types.ts b/web/app/components/plugins/types.ts index da651caa08906e..35528abaeac056 100644 --- a/web/app/components/plugins/types.ts +++ b/web/app/components/plugins/types.ts @@ -379,12 +379,6 @@ export type uploadGitHubResponse = { manifest: PluginDeclaration } -export type DebugInfo = { - key: string - host: string - port: number -} - export const TaskStatus = { pending: 'pending', running: 'running', diff --git a/web/service/__tests__/use-plugins.spec.tsx b/web/service/__tests__/use-plugins.spec.tsx index 043ec0b8e86fb7..aae9e4ea433d7f 100644 --- a/web/service/__tests__/use-plugins.spec.tsx +++ b/web/service/__tests__/use-plugins.spec.tsx @@ -49,12 +49,20 @@ vi.mock('@/service/base', () => ({ request: mockRequest, })) -mockRequest.mockImplementation(async (url: string) => { - const data = await mockGet(url) - return new Response(JSON.stringify(data), { - headers: { 'Content-Type': 'application/json' }, - }) -}) +mockRequest.mockImplementation( + async (url: string, _init: RequestInit, options: { request?: Request }) => { + const method = options.request?.method ?? 'GET' + const data = + method === 'GET' + ? await mockGet(url) + : await mockPost(new URL(url).pathname.replace('/console/api', ''), { + body: await options.request?.json(), + }) + return new Response(JSON.stringify(data ?? {}), { + headers: { 'Content-Type': 'application/json' }, + }) + }, +) vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({ default: () => ({ @@ -1346,7 +1354,7 @@ describe('usePluginAutoUpgradeSettings', () => { vi.clearAllMocks() }) - it('does not expose frontend default settings before backend data resolves', () => { + it('does not expose frontend default settings before backend data resolves', async () => { const queryClient = createQueryClient() mockGet.mockReturnValue(new Promise(() => {})) @@ -1355,10 +1363,10 @@ describe('usePluginAutoUpgradeSettings', () => { }) expect(result.current.data).toBeUndefined() - expect(mockGet).toHaveBeenCalledWith('/workspaces/current/plugin/auto-upgrade/fetch', { - params: { - category: PluginCategoryEnum.model, - }, + await waitFor(() => { + expect(mockGet).toHaveBeenCalledWith( + `http://localhost:5001/console/api/workspaces/current/plugin/auto-upgrade/fetch?category=${PluginCategoryEnum.model}`, + ) }) }) diff --git a/web/service/console/narrow-response.ts b/web/service/console/narrow-response.ts new file mode 100644 index 00000000000000..d76098014a5eb9 --- /dev/null +++ b/web/service/console/narrow-response.ts @@ -0,0 +1,8 @@ +/** + * Some UI types narrow fields that backend response models declare as plain strings or + * loose records (enum-like statuses, `doc_form`, plugin `source`, locale label maps, ...). + * Until those response models declare literal types, contract responses are narrowed to the + * UI types at the service boundary with this helper instead of re-typing every consumer. + * Prefer aliasing the generated type whenever the UI type can follow it. + */ +export const narrowResponse = (response: Promise) => response as Promise diff --git a/web/service/datasets.ts b/web/service/datasets.ts index 4f7c131611c3d4..5f53c27d9b6b6a 100644 --- a/web/service/datasets.ts +++ b/web/service/datasets.ts @@ -23,14 +23,7 @@ import { DataSourceProvider } from '@/models/common' // oxlint-disable-next-line no-restricted-imports -- the zip download needs the raw Blob response import { post } from './base' import { consoleClient } from './console' - -/** - * The knowledge UI types in `@/models/datasets` narrow fields that backend response models - * declare as plain strings (indexing_technique, doc_form, permission, indexing statuses, ...). - * Until those response models declare literal types, contract responses are narrowed to the UI - * types at this boundary instead of re-typing every consumer. - */ -export const narrowKnowledgeResponse = (response: Promise) => response as Promise +import { narrowResponse } from './console/narrow-response' // apis for documents in a dataset @@ -64,17 +57,17 @@ export const updateDatasetSetting = ({ datasetId: string body: DatasetUpdatePayload }): Promise => { - return narrowKnowledgeResponse( + return narrowResponse( consoleClient.datasets.byDatasetId.patch({ params: { dataset_id: datasetId }, body }), ) } export const fetchDatasets = ({ params }: FetchDatasetsParams): Promise => { - return narrowKnowledgeResponse(consoleClient.datasets.get({ query: params })) + return narrowResponse(consoleClient.datasets.get({ query: params })) } export const createEmptyDataset = ({ name }: { name: string }): Promise => { - return narrowKnowledgeResponse(consoleClient.datasets.post({ body: { name } })) + return narrowResponse(consoleClient.datasets.post({ body: { name } })) } export const checkIsUsedInApp = (id: string): Promise<{ is_using: boolean }> => { @@ -93,7 +86,7 @@ export const fetchExternalAPI = ({ }: { apiTemplateId: string }): Promise => { - return narrowKnowledgeResponse( + return narrowResponse( consoleClient.datasets.externalKnowledgeApi.byExternalKnowledgeApiId.get({ params: { external_knowledge_api_id: apiTemplateId }, }), @@ -107,7 +100,7 @@ export const updateExternalAPI = ({ apiTemplateId: string body: ExternalAPIItem }): Promise => { - return narrowKnowledgeResponse( + return narrowResponse( consoleClient.datasets.externalKnowledgeApi.byExternalKnowledgeApiId.patch({ params: { external_knowledge_api_id: apiTemplateId }, body, @@ -136,7 +129,7 @@ export const createExternalAPI = ({ }: { body: CreateExternalAPIReq }): Promise => { - return narrowKnowledgeResponse(consoleClient.datasets.externalKnowledgeApi.post({ body })) + return narrowResponse(consoleClient.datasets.externalKnowledgeApi.post({ body })) } export const createExternalKnowledgeBase = ({ @@ -144,11 +137,11 @@ export const createExternalKnowledgeBase = ({ }: { body: CreateKnowledgeBaseReq }): Promise => { - return narrowKnowledgeResponse(consoleClient.datasets.external.post({ body })) + return narrowResponse(consoleClient.datasets.external.post({ body })) } export const fetchDefaultProcessRule = (): Promise => { - return narrowKnowledgeResponse(consoleClient.datasets.processRule.get({})) + return narrowResponse(consoleClient.datasets.processRule.get({})) } export const createFirstDocument = ({ @@ -156,7 +149,7 @@ export const createFirstDocument = ({ }: { body: KnowledgeConfig }): Promise => { - return narrowKnowledgeResponse(consoleClient.datasets.init.post({ body })) + return narrowResponse(consoleClient.datasets.init.post({ body })) } export const createDocument = ({ @@ -166,7 +159,7 @@ export const createDocument = ({ datasetId: string body: KnowledgeConfig }): Promise => { - return narrowKnowledgeResponse( + return narrowResponse( consoleClient.datasets.byDatasetId.documents.post({ params: { dataset_id: datasetId }, body }), ) } @@ -175,7 +168,7 @@ export const fetchIndexingStatus = ({ datasetId, documentId, }: CommonDocReq): Promise => { - return narrowKnowledgeResponse( + return narrowResponse( consoleClient.datasets.byDatasetId.documents.byDocumentId.indexingStatus.get({ params: { dataset_id: datasetId, document_id: documentId }, }), @@ -186,7 +179,7 @@ export const fetchIndexingStatusBatch = ({ datasetId, batchId, }: BatchReq): Promise => { - return narrowKnowledgeResponse( + return narrowResponse( consoleClient.datasets.byDatasetId.batch.byBatch.indexingStatus.get({ params: { dataset_id: datasetId, batch: batchId }, }), @@ -198,7 +191,7 @@ export const renameDocumentName = ({ documentId, name, }: CommonDocReq & { name: string }): Promise => { - return narrowKnowledgeResponse( + return narrowResponse( consoleClient.datasets.byDatasetId.documents.byDocumentId.rename.post({ params: { dataset_id: datasetId, document_id: documentId }, body: { name }, @@ -244,7 +237,7 @@ export const downloadDocumentsZip = ({ export const fetchFileIndexingEstimate = ( body: IndexingEstimatePayload, ): Promise => { - return narrowKnowledgeResponse(consoleClient.datasets.indexingEstimate.post({ body })) + return narrowResponse(consoleClient.datasets.indexingEstimate.post({ body })) } export const fetchNotionPagePreview = ({ diff --git a/web/service/knowledge/use-dataset.ts b/web/service/knowledge/use-dataset.ts index bfef6fff14bdb4..50924ae9061edf 100644 --- a/web/service/knowledge/use-dataset.ts +++ b/web/service/knowledge/use-dataset.ts @@ -14,7 +14,8 @@ import type { import { ORPCError } from '@orpc/client' import { keepPreviousData, useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query' import { consoleClient } from '../console' -import { fetchIndexingStatusBatch, narrowKnowledgeResponse } from '../datasets' +import { narrowResponse } from '../console/narrow-response' +import { fetchIndexingStatusBatch } from '../datasets' import { useInvalid } from '../use-base' const NAME_SPACE = 'dataset' @@ -51,7 +52,7 @@ export const useInfiniteDatasets = ( return useInfiniteQuery({ queryKey: [...datasetListQueryKey, 'infinite', normalizedParams], queryFn: ({ pageParam = normalizedParams.page }) => - narrowKnowledgeResponse( + narrowResponse( consoleClient.datasets.get({ query: { ...normalizedParams, page: pageParam as number | undefined }, }), @@ -69,7 +70,7 @@ export const useDatasetList = (params: DatasetListRequest) => { return useInfiniteQuery({ queryKey: [...datasetListQueryKey, initialPage, tag_ids, limit, include_all, keyword], queryFn: ({ pageParam = 1 }) => - narrowKnowledgeResponse( + narrowResponse( consoleClient.datasets.get({ query: { tag_ids, limit, include_all, keyword, page: pageParam }, }), @@ -90,7 +91,7 @@ export const useDatasetDetail = (datasetId: string) => { return useQuery({ queryKey: [...datasetDetailQueryKeyPrefix, datasetId], queryFn: () => - narrowKnowledgeResponse( + narrowResponse( consoleClient.datasets.byDatasetId.get({ params: { dataset_id: datasetId } }), ), enabled: !!datasetId, @@ -106,7 +107,7 @@ export const useDatasetRelatedApps = (datasetId: string, options?: { enabled?: b return useQuery({ queryKey: [NAME_SPACE, 'related-apps', datasetId], queryFn: () => - narrowKnowledgeResponse( + narrowResponse( consoleClient.datasets.byDatasetId.relatedApps.get({ params: { dataset_id: datasetId } }), ), enabled: options?.enabled ?? !!datasetId, @@ -129,7 +130,7 @@ export const useProcessRule = (documentId?: string) => { return useQuery({ queryKey: [NAME_SPACE, 'process-rule', documentId], queryFn: () => - narrowKnowledgeResponse( + narrowResponse( consoleClient.datasets.processRule.get({ query: { document_id: documentId } }), ), enabled: !!documentId, @@ -172,7 +173,7 @@ export const useDatasetTestingRecords = ( return useQuery({ queryKey: [NAME_SPACE, 'testing-records', datasetId, params], queryFn: () => - narrowKnowledgeResponse( + narrowResponse( consoleClient.datasets.byDatasetId.queries.get({ params: { dataset_id: datasetId! }, query: params, @@ -187,7 +188,7 @@ export const useDatasetErrorDocs = (datasetId?: string) => { return useQuery({ queryKey: [NAME_SPACE, 'error-docs', datasetId], queryFn: () => - narrowKnowledgeResponse( + narrowResponse( consoleClient.datasets.byDatasetId.errorDocs.get({ params: { dataset_id: datasetId! } }), ), enabled: !!datasetId, diff --git a/web/service/knowledge/use-document.ts b/web/service/knowledge/use-document.ts index bf7699cccb2ac0..70771999f1e2a6 100644 --- a/web/service/knowledge/use-document.ts +++ b/web/service/knowledge/use-document.ts @@ -9,10 +9,10 @@ import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query' import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter' import { DocumentActionType } from '@/models/datasets' import { consoleClient } from '../console' +import { narrowResponse } from '../console/narrow-response' import { downloadDocumentsZip, fetchDocumentDownloadUrl, - narrowKnowledgeResponse, pauseDocIndexing, resumeDocIndexing, retryErrorDocs, @@ -48,7 +48,7 @@ export const useDocumentList = (payload: { return useQuery({ queryKey: [...useDocumentListKey, datasetId, params], queryFn: () => - narrowKnowledgeResponse( + narrowResponse( consoleClient.datasets.byDatasetId.documents.get({ params: { dataset_id: datasetId }, query: params, @@ -165,7 +165,7 @@ export const useDocumentDetail = (payload: { return useQuery({ queryKey: [...useDocumentDetailKey, 'withoutMetaData', datasetId, documentId, params], queryFn: () => - narrowKnowledgeResponse( + narrowResponse( consoleClient.datasets.byDatasetId.documents.byDocumentId.get({ params: { dataset_id: datasetId, document_id: documentId }, query: params, @@ -184,7 +184,7 @@ export const useDocumentMetadata = (payload: { return useQuery({ queryKey: [...useDocumentDetailKey, 'onlyMetaData', datasetId, documentId, params], queryFn: () => - narrowKnowledgeResponse( + narrowResponse( consoleClient.datasets.byDatasetId.documents.byDocumentId.get({ params: { dataset_id: datasetId, document_id: documentId }, query: params, diff --git a/web/service/knowledge/use-metadata.ts b/web/service/knowledge/use-metadata.ts index 572024615f3d3a..0f60aa0c2dfa52 100644 --- a/web/service/knowledge/use-metadata.ts +++ b/web/service/knowledge/use-metadata.ts @@ -6,7 +6,7 @@ import type { import type { DocumentDetailResponse } from '@/models/datasets' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { consoleClient } from '../console' -import { narrowKnowledgeResponse } from '../datasets' +import { narrowResponse } from '../console/narrow-response' import { useInvalid } from '../use-base' import { useDocumentListKey, useInvalidDocumentList } from './use-document' @@ -17,7 +17,7 @@ export const useDatasetMetaData = (datasetId: string) => { { queryKey: [NAME_SPACE, 'dataset', datasetId], queryFn: () => { - return narrowKnowledgeResponse( + return narrowResponse( consoleClient.datasets.byDatasetId.metadata.get({ params: { dataset_id: datasetId } }), ) }, @@ -99,7 +99,7 @@ export const useBuiltInMetaDataFields = () => { return useQuery<{ fields: BuiltInMetadataItem[] }>({ queryKey: [NAME_SPACE, 'built-in'], queryFn: () => { - return narrowKnowledgeResponse(consoleClient.datasets.metadata.builtIn.get()) + return narrowResponse(consoleClient.datasets.metadata.builtIn.get()) }, }) } @@ -114,7 +114,7 @@ export const useDocumentMetaData = ({ return useQuery({ queryKey: [NAME_SPACE, 'document', datasetId, documentId], queryFn: () => { - return narrowKnowledgeResponse( + return narrowResponse( consoleClient.datasets.byDatasetId.documents.byDocumentId.get({ params: { dataset_id: datasetId, document_id: documentId }, query: { metadata: 'only' }, diff --git a/web/service/knowledge/use-segment.ts b/web/service/knowledge/use-segment.ts index e6a85697b591a8..ad371f24d30dd7 100644 --- a/web/service/knowledge/use-segment.ts +++ b/web/service/knowledge/use-segment.ts @@ -9,7 +9,7 @@ import type { } from '@/models/datasets' import { useMutation, useQuery } from '@tanstack/react-query' import { consoleClient } from '../console' -import { narrowKnowledgeResponse } from '../datasets' +import { narrowResponse } from '../console/narrow-response' const NAME_SPACE = 'segment' @@ -36,7 +36,7 @@ export const useSegmentList = ( return useQuery({ queryKey: [...useSegmentListKey, datasetId, documentId, params], queryFn: () => { - return narrowKnowledgeResponse( + return narrowResponse( consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.get({ params: { dataset_id: datasetId, document_id: documentId }, query: { ...params, enabled: String(params.enabled) }, @@ -57,7 +57,7 @@ export const useUpdateSegment = () => { body: SegmentUpdater }) => { const { datasetId, documentId, segmentId, body } = payload - return narrowKnowledgeResponse<{ data: SegmentDetailModel; doc_form: ChunkingMode }>( + return narrowResponse<{ data: SegmentDetailModel; doc_form: ChunkingMode }>( consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.patch({ params: { dataset_id: datasetId, document_id: documentId, segment_id: segmentId }, body, @@ -72,7 +72,7 @@ export const useAddSegment = () => { mutationKey: [NAME_SPACE, 'add'], mutationFn: (payload: { datasetId: string; documentId: string; body: SegmentUpdater }) => { const { datasetId, documentId, body } = payload - return narrowKnowledgeResponse<{ data: SegmentDetailModel; doc_form: ChunkingMode }>( + return narrowResponse<{ data: SegmentDetailModel; doc_form: ChunkingMode }>( consoleClient.datasets.byDatasetId.documents.byDocumentId.segment.post({ params: { dataset_id: datasetId, document_id: documentId }, body, @@ -141,7 +141,7 @@ export const useChildSegmentList = ( return useQuery({ queryKey: [...useChildSegmentListKey, datasetId, documentId, segmentId, params], queryFn: () => { - return narrowKnowledgeResponse( + return narrowResponse( consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.get( { params: { dataset_id: datasetId, document_id: documentId, segment_id: segmentId }, @@ -188,7 +188,7 @@ export const useAddChildSegment = () => { body: { content: string } }) => { const { datasetId, documentId, segmentId, body } = payload - return narrowKnowledgeResponse<{ data: ChildChunkDetail }>( + return narrowResponse<{ data: ChildChunkDetail }>( consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.post( { params: { dataset_id: datasetId, document_id: documentId, segment_id: segmentId }, @@ -211,7 +211,7 @@ export const useUpdateChildSegment = () => { body: { content: string } }) => { const { datasetId, documentId, segmentId, childChunkId, body } = payload - return narrowKnowledgeResponse<{ data: ChildChunkDetail }>( + return narrowResponse<{ data: ChildChunkDetail }>( consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.byChildChunkId.patch( { params: { @@ -237,7 +237,7 @@ export const useSegmentBatchImport = () => { body: { upload_file_id: string } }) => { const { datasetId, documentId, body } = payload - return narrowKnowledgeResponse( + return narrowResponse( consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.batchImport.post({ params: { dataset_id: datasetId, document_id: documentId }, body, @@ -252,7 +252,7 @@ export const useCheckSegmentBatchImportProgress = () => { mutationKey: [NAME_SPACE, 'batchImport', 'checkProgress'], mutationFn: (payload: { jobID: string }) => { const { jobID } = payload - return narrowKnowledgeResponse( + return narrowResponse( consoleClient.datasets.batchImportStatus.byJobId.get({ params: { job_id: jobID } }), ) }, diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 2847fbbcea919e..bca83fda64b6c8 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -1,5 +1,6 @@ import type { GetWorkspacesCurrentPluginByCategoryListData, + ParserMarketplaceUpgrade, PluginCategoryInstalledPluginResponse, PluginCategoryListResponse, PluginEntity, @@ -15,7 +16,6 @@ import type { InfiniteData, MutateOptions, QueryClient, QueryOptions } from '@ta import type { FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { AutoUpdateConfig } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types' import type { - DebugInfo as DebugInfoTypes, Dependency, GitHubItemAndMarketPlaceDependency, InstallPackageResponse, @@ -48,11 +48,12 @@ import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/ import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' import { PluginCategoryEnum, PluginSource, TaskStatus } from '@/app/components/plugins/types' import { workspacePermissionKeysAtom } from '@/context/permission-state' -import { consoleQuery } from '@/service/console' +import { consoleClient, consoleQuery } from '@/service/console' +import { narrowResponse } from '@/service/console/narrow-response' import { uninstallPlugin } from '@/service/plugins' import { hasPermission } from '@/utils/permission' // oxlint-disable-next-line no-restricted-imports -import { get, getMarketplace, post, postMarketplace } from './base' +import { getMarketplace, postMarketplace } from './base' import { useInvalidateAllBuiltInTools } from './use-tools' const NAME_SPACE = 'plugins' @@ -809,9 +810,11 @@ export const useInstallPackageFromMarketPlace = ( return useMutation({ ...options, mutationFn: (uniqueIdentifier: string) => { - return post('/workspaces/current/plugin/install/marketplace', { - body: { plugin_unique_identifiers: [uniqueIdentifier] }, - }) + return narrowResponse( + consoleClient.workspaces.current.plugin.install.marketplace.post({ + body: { plugin_unique_identifiers: [uniqueIdentifier] }, + }), + ) }, onSuccess: (data, variables, context, mutation) => { upsertStartedPluginTask(queryClient, data) @@ -821,15 +824,17 @@ export const useInstallPackageFromMarketPlace = ( } export const useUpdatePackageFromMarketPlace = ( - options?: MutateOptions, + options?: MutateOptions, ) => { const queryClient = useQueryClient() return useMutation({ ...options, - mutationFn: (body: object) => { - return post('/workspaces/current/plugin/upgrade/marketplace', { - body, - }) + mutationFn: (body: ParserMarketplaceUpgrade) => { + return narrowResponse( + consoleClient.workspaces.current.plugin.upgrade.marketplace.post({ + body, + }), + ) }, onSuccess: (data, variables, context, mutation) => { upsertStartedPluginTask(queryClient, data) @@ -842,9 +847,11 @@ export const usePluginDeclarationFromMarketPlace = (pluginUniqueIdentifier: stri return useQuery({ queryKey: [NAME_SPACE, 'pluginDeclaration', pluginUniqueIdentifier], queryFn: () => - get<{ manifest: PluginDeclaration }>('/workspaces/current/plugin/marketplace/pkg', { - params: { plugin_unique_identifier: pluginUniqueIdentifier }, - }), + narrowResponse<{ manifest: PluginDeclaration }>( + consoleClient.workspaces.current.plugin.marketplace.pkg.get({ + query: { plugin_unique_identifier: pluginUniqueIdentifier }, + }), + ), enabled: !!pluginUniqueIdentifier, }) } @@ -864,9 +871,11 @@ export const useInstallPackageFromLocal = () => { const queryClient = useQueryClient() return useMutation({ mutationFn: (uniqueIdentifier: string) => { - return post('/workspaces/current/plugin/install/pkg', { - body: { plugin_unique_identifiers: [uniqueIdentifier] }, - }) + return narrowResponse( + consoleClient.workspaces.current.plugin.install.pkg.post({ + body: { plugin_unique_identifiers: [uniqueIdentifier] }, + }), + ) }, onSuccess: (data) => { upsertStartedPluginTask(queryClient, data) @@ -888,14 +897,16 @@ export const useInstallPackageFromGitHub = () => { selectedPackage: string uniqueIdentifier: string }) => { - return post('/workspaces/current/plugin/install/github', { - body: { - repo: repoUrl, - version: selectedVersion, - package: selectedPackage, - plugin_unique_identifier: uniqueIdentifier, - }, - }) + return narrowResponse( + consoleClient.workspaces.current.plugin.install.github.post({ + body: { + repo: repoUrl, + version: selectedVersion, + package: selectedPackage, + plugin_unique_identifier: uniqueIdentifier, + }, + }), + ) }, onSuccess: (data) => { upsertStartedPluginTask(queryClient, data) @@ -907,9 +918,11 @@ export const useUploadGitHub = (payload: { repo: string; version: string; packag return useQuery({ queryKey: [NAME_SPACE, 'uploadGitHub', payload], queryFn: () => - post('/workspaces/current/plugin/upload/github', { - body: payload, - }), + narrowResponse( + consoleClient.workspaces.current.plugin.upload.github.post({ + body: payload, + }), + ), retry: 0, }) } @@ -945,15 +958,14 @@ export const useInstallOrUpdate = ({ // From local bundle don't have data.value.github_plugin_unique_identifier uniqueIdentifier = data.value.github_plugin_unique_identifier! if (!uniqueIdentifier) { - const { unique_identifier } = await post( - '/workspaces/current/plugin/upload/github', - { + const { unique_identifier } = await narrowResponse( + consoleClient.workspaces.current.plugin.upload.github.post({ body: { repo: data.value.repo!, version: data.value.release! || data.value.version!, package: data.value.packages! || data.value.package!, }, - }, + }), ) uniqueIdentifier = data.value.github_plugin_unique_identifier! || unique_identifier // has the same version, but not installed @@ -966,16 +978,15 @@ export const useInstallOrUpdate = ({ } } if (!isInstalled) { - const response = await post( - '/workspaces/current/plugin/install/github', - { + const response = await narrowResponse( + consoleClient.workspaces.current.plugin.install.github.post({ body: { repo: data.value.repo!, version: data.value.release! || data.value.version!, package: data.value.packages! || data.value.package!, plugin_unique_identifier: uniqueIdentifier, }, - }, + }), ) upsertStartedPluginTask(queryClient, response) const { task_id, all_installed } = response @@ -997,13 +1008,12 @@ export const useInstallOrUpdate = ({ } } if (!isInstalled) { - const response = await post( - '/workspaces/current/plugin/install/marketplace', - { + const response = await narrowResponse( + consoleClient.workspaces.current.plugin.install.marketplace.post({ body: { plugin_unique_identifiers: [uniqueIdentifier], }, - }, + }), ) upsertStartedPluginTask(queryClient, response) const { task_id, all_installed } = response @@ -1022,13 +1032,12 @@ export const useInstallOrUpdate = ({ } } if (!isInstalled) { - const response = await post( - '/workspaces/current/plugin/install/pkg', - { + const response = await narrowResponse( + consoleClient.workspaces.current.plugin.install.pkg.post({ body: { plugin_unique_identifiers: [uniqueIdentifier], }, - }, + }), ) upsertStartedPluginTask(queryClient, response) const { task_id, all_installed } = response @@ -1039,13 +1048,12 @@ export const useInstallOrUpdate = ({ if (isInstalled) { if (item.type === 'package') { await uninstallPlugin(installedPayload.installedId, { preserveCredentials: true }) - const response = await post( - '/workspaces/current/plugin/install/pkg', - { + const response = await narrowResponse( + consoleClient.workspaces.current.plugin.install.pkg.post({ body: { plugin_unique_identifiers: [uniqueIdentifier], }, - }, + }), ) upsertStartedPluginTask(queryClient, response) const { task_id, all_installed } = response @@ -1087,7 +1095,7 @@ export const useInstallOrUpdate = ({ export const useDebugKey = () => { return useQuery({ queryKey: [NAME_SPACE, 'debugKey'], - queryFn: () => get('/workspaces/current/plugin/debugging-key'), + queryFn: () => consoleClient.workspaces.current.plugin.debuggingKey.get(), }) } @@ -1151,7 +1159,7 @@ const areAutoUpgradeSettingsEqual = ( export const usePluginPermissionSettings = () => { return useQuery({ queryKey: usePluginPermissionSettingsKey, - queryFn: () => get('/workspaces/current/plugin/permission/fetch'), + queryFn: () => consoleClient.workspaces.current.plugin.permission.fetch.get(), }) } @@ -1159,9 +1167,7 @@ export const usePluginAutoUpgradeSettings = (category: PluginCategoryEnum) => { return useQuery({ queryKey: pluginAutoUpgradeSettingsQueryKey(category), queryFn: () => - get('/workspaces/current/plugin/auto-upgrade/fetch', { - params: { category }, - }), + consoleClient.workspaces.current.plugin.autoUpgrade.fetch.get({ query: { category } }), staleTime: 60 * 1000, }) } @@ -1184,7 +1190,7 @@ export const useMutationPluginPermissionSettings = ({ return useMutation({ mutationFn: (payload: Permissions) => { - return post('/workspaces/current/plugin/permission/change', { body: payload }) + return consoleClient.workspaces.current.plugin.permission.change.post({ body: payload }) }, onMutate: async (payload) => { await queryClient.cancelQueries({ queryKey: usePluginPermissionSettingsKey }) @@ -1219,7 +1225,7 @@ export const useMutationPluginAutoUpgradeSettings = ({ return useMutation({ mutationFn: (payload: AutoUpdateConfig) => { - return post('/workspaces/current/plugin/auto-upgrade/change', { + return consoleClient.workspaces.current.plugin.autoUpgrade.change.post({ body: { category, auto_upgrade: payload, @@ -1270,14 +1276,16 @@ export const useMutationReferenceSettings = ({ if (!arePermissionsEqual(payload.permission, currentReferenceSetting?.permission)) mutations.push( - post('/workspaces/current/plugin/permission/change', { body: payload.permission }), + consoleClient.workspaces.current.plugin.permission.change.post({ + body: payload.permission, + }), ) if ( !areAutoUpgradeSettingsEqual(payload.auto_upgrade, currentReferenceSetting?.auto_upgrade) ) { mutations.push( - post('/workspaces/current/plugin/auto-upgrade/change', { + consoleClient.workspaces.current.plugin.autoUpgrade.change.post({ body: { category, auto_upgrade: payload.auto_upgrade, @@ -1358,7 +1366,7 @@ export const useRemoveAutoUpgrade = () => { return useMutation({ mutationFn: (payload: { plugin_id: string; category: PluginCategoryEnum }) => { - return post('/workspaces/current/plugin/auto-upgrade/exclude', { body: payload }) + return consoleClient.workspaces.current.plugin.autoUpgrade.exclude.post({ body: payload }) }, onSuccess: (_data, payload) => { queryClient.invalidateQueries({ @@ -1414,7 +1422,9 @@ export const usePluginTaskList = (category?: PluginCategoryEnum | string) => { enabled: canManagement, queryKey: usePluginTaskListKey, queryFn: () => - get<{ tasks: PluginTask[] }>('/workspaces/current/plugin/tasks?page=1&page_size=100'), + narrowResponse<{ tasks: PluginTask[] }>( + consoleClient.workspaces.current.plugin.tasks.get({ query: { page: 1, page_size: 100 } }), + ), structuralSharing: (previousData, nextData) => preserveLocalUnfinishedPluginTasks( previousData as PluginTaskListResponse | undefined, @@ -1478,10 +1488,9 @@ export const usePluginTaskList = (category?: PluginCategoryEnum | string) => { export const useMutationClearTaskPlugin = () => { return useMutation({ mutationFn: ({ taskId, pluginId }: { taskId: string; pluginId: string }) => { - const encodedPluginId = encodeURIComponent(pluginId) - return post<{ success: boolean }>( - `/workspaces/current/plugin/tasks/${taskId}/delete/${encodedPluginId}`, - ) + return consoleClient.workspaces.current.plugin.tasks.byTaskId.delete.byIdentifier.post({ + params: { task_id: taskId, identifier: pluginId }, + }) }, }) } @@ -1503,20 +1512,23 @@ export const useFetchDynamicOptions = ( provider: string, action: string, parameter: string, - provider_type?: 'tool' | 'trigger', + // The backend requires a provider type; callers without one render tool options. + provider_type: 'tool' | 'trigger' = 'tool', extra?: Record, ) => { return useMutation({ mutationFn: () => - get<{ options: FormOption[] }>('/workspaces/current/plugin/parameters/dynamic-options', { - params: { - plugin_id, - provider, - action, - parameter, - provider_type, - ...extra, - }, - }), + narrowResponse<{ options: FormOption[] }>( + consoleClient.workspaces.current.plugin.parameters.dynamicOptions.get({ + query: { + plugin_id, + provider, + action, + parameter, + provider_type, + ...extra, + }, + }), + ), }) } From 42a3ab4790f0321b76fe0c6eb38436d52b22cd3c Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:56:51 +0800 Subject: [PATCH 19/60] chore(web): prune oxlint suppressions for migrated service files Co-Authored-By: Claude Opus 5.5 --- oxlint-suppressions.json | 32 +------------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 2a6461a252aee5..7fc1aab6b71ebd 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4060,14 +4060,6 @@ "count": 1 } }, - "web/service/datasets.ts": { - "no-restricted-imports": { - "count": 1 - }, - "typescript/no-explicit-any": { - "count": 3 - } - }, "web/service/debug.ts": { "no-restricted-imports": { "count": 2 @@ -4089,46 +4081,24 @@ "count": 2 } }, - "web/service/knowledge/use-create-dataset.ts": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/service/knowledge/use-dataset.spec.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "web/service/knowledge/use-dataset.ts": { "@tanstack/query/prefer-query-options": { "count": 8 - }, - "no-restricted-imports": { - "count": 1 } }, "web/service/knowledge/use-document.ts": { "@tanstack/query/prefer-query-options": { "count": 4 - }, - "no-restricted-imports": { - "count": 1 } }, "web/service/knowledge/use-metadata.ts": { "@tanstack/query/prefer-query-options": { "count": 8 - }, - "no-restricted-imports": { - "count": 1 } }, "web/service/knowledge/use-segment.ts": { "@tanstack/query/prefer-query-options": { "count": 2 - }, - "no-restricted-imports": { - "count": 1 } }, "web/service/log.ts": { @@ -4329,4 +4299,4 @@ "count": 2 } } -} \ No newline at end of file +} From 0f7e4fd5a81641dda9479793cccf1f40c779e2fd Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:05:54 +0800 Subject: [PATCH 20/60] refactor(web): migrate tool services and hooks to the console contract - service/tools.ts and service/use-tools.ts call consoleClient; tool UI DTOs are narrowed at the boundary - remove fetchModelToolList and the isModel prop: the backend has no model tool provider type and /tool-provider/model/tools does not exist - keep the legacy built-in credential flow handwritten: it no longer matches the backend (schema moved, credentials became a list, update/delete need credential_id) - MCP provider payloads require server_identifier and send icon_background without null; MCP server payloads require parameters; workflow tool requests carry label - drop the unused _tools_util helper Co-Authored-By: Claude Opus 5.5 --- oxlint-suppressions.json | 10 +- .../__tests__/setting-built-in-tool.spec.tsx | 10 +- .../config/agent/agent-tools/index.tsx | 1 - .../agent-tools/setting-built-in-tool.tsx | 12 +- .../plugin-detail-panel/action-list.tsx | 1 - web/app/components/tools/mcp/modal.tsx | 2 +- .../tools/provider/__tests__/detail.spec.tsx | 27 ---- .../provider/__tests__/tool-item.spec.tsx | 4 +- web/app/components/tools/provider/detail.tsx | 5 - .../components/tools/provider/tool-item.tsx | 4 +- web/app/components/tools/types.ts | 1 + .../__tests__/tool-picker.spec.tsx | 2 +- .../tools/provider-tool/dialog.tsx | 1 - web/service/__tests__/use-tools.spec.tsx | 14 +-- web/service/_tools_util.spec.ts | 52 -------- web/service/_tools_util.ts | 5 - web/service/tools.ts | 113 +++++++++-------- web/service/use-tools.ts | 115 +++++++++--------- 18 files changed, 137 insertions(+), 242 deletions(-) delete mode 100644 web/service/_tools_util.spec.ts delete mode 100644 web/service/_tools_util.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 7fc1aab6b71ebd..954cd4fef50935 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4129,11 +4129,6 @@ "count": 1 } }, - "web/service/tools.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "web/service/use-common.ts": { "@tanstack/query/prefer-query-options": { "count": 1 @@ -4200,9 +4195,6 @@ "web/service/use-tools.ts": { "@tanstack/query/prefer-query-options": { "count": 15 - }, - "no-restricted-imports": { - "count": 1 } }, "web/service/use-triggers.ts": { @@ -4299,4 +4291,4 @@ "count": 2 } } -} +} \ No newline at end of file diff --git a/web/app/components/app/configuration/config/agent/agent-tools/__tests__/setting-built-in-tool.spec.tsx b/web/app/components/app/configuration/config/agent/agent-tools/__tests__/setting-built-in-tool.spec.tsx index 86b86cfb3989eb..f94aebe52152fd 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/__tests__/setting-built-in-tool.spec.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/__tests__/setting-built-in-tool.spec.tsx @@ -5,12 +5,10 @@ import * as React from 'react' import { CollectionType } from '@/app/components/tools/types' import SettingBuiltInTool from '../setting-built-in-tool' -const fetchModelToolList = vi.fn() const fetchBuiltInToolList = vi.fn() const fetchCustomToolList = vi.fn() const fetchWorkflowToolList = vi.fn() vi.mock('@/service/tools', () => ({ - fetchModelToolList: (collectionName: string) => fetchModelToolList(collectionName), fetchBuiltInToolList: (collectionName: string) => fetchBuiltInToolList(collectionName), fetchCustomToolList: (collectionName: string) => fetchCustomToolList(collectionName), fetchWorkflowToolList: (appId: string) => fetchWorkflowToolList(appId), @@ -133,7 +131,6 @@ const renderComponent = (props?: Partial { }) it('should fetch tool list when collection has no tools', async () => { - fetchModelToolList.mockResolvedValueOnce([createTool()]) + fetchBuiltInToolList.mockResolvedValueOnce([createTool()]) renderComponent({ collection: { ...baseCollection, @@ -166,8 +163,8 @@ describe('SettingBuiltInTool', () => { }) await waitFor(() => { - expect(fetchModelToolList).toHaveBeenCalledTimes(1) - expect(fetchModelToolList).toHaveBeenCalledWith('vendor/provider-1') + expect(fetchBuiltInToolList).toHaveBeenCalledTimes(1) + expect(fetchBuiltInToolList).toHaveBeenCalledWith('vendor/provider-1') }) expect(await screen.findByText('Search Tool')).toBeInTheDocument() }) @@ -287,7 +284,6 @@ describe('SettingBuiltInTool', () => { id: 'workflow-1', } as any, isBuiltIn: false, - isModel: false, }) await waitFor(() => { diff --git a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx index 95703f7be31dc1..7ad92410dd0271 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx @@ -402,7 +402,6 @@ const AgentTools: FC = () => { toolName={currentTool?.tool_name as string} setting={currentTool?.tool_parameters} collection={currentTool?.collection as ToolWithProvider} - isModel={currentTool?.collection?.type === CollectionType.model} onSave={handleToolSettingChange} onHide={() => setIsShowSettingTool(false)} credentialId={currentTool?.credential_id} diff --git a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx index 8334ca4dbe0d1c..0b0dd5d56eaeaa 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx @@ -30,18 +30,12 @@ import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import { CollectionType } from '@/app/components/tools/types' import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' import { getPluginLanguage } from '@/i18n/metadata' -import { - fetchBuiltInToolList, - fetchCustomToolList, - fetchModelToolList, - fetchWorkflowToolList, -} from '@/service/tools' +import { fetchBuiltInToolList, fetchCustomToolList, fetchWorkflowToolList } from '@/service/tools' type Props = Readonly<{ showBackButton?: boolean collection: Collection | ToolWithProvider isBuiltIn?: boolean - isModel?: boolean toolName: string setting?: Record readonly?: boolean @@ -56,7 +50,6 @@ const SettingBuiltInTool: FC = ({ showBackButton = false, collection, isBuiltIn = true, - isModel = true, toolName, setting = {}, readonly, @@ -90,8 +83,7 @@ const SettingBuiltInTool: FC = ({ try { const list = await new Promise((resolve) => { ;(async function () { - if (isModel) resolve(await fetchModelToolList(collection.name)) - else if (isBuiltIn) resolve(await fetchBuiltInToolList(collection.name)) + if (isBuiltIn) resolve(await fetchBuiltInToolList(collection.name)) else if (collection.type === CollectionType.workflow) resolve(await fetchWorkflowToolList(collection.id)) else resolve(await fetchCustomToolList(collection.name)) diff --git a/web/app/components/plugins/plugin-detail-panel/action-list.tsx b/web/app/components/plugins/plugin-detail-panel/action-list.tsx index 10dd51dc2a5477..892520f0e20d1e 100644 --- a/web/app/components/plugins/plugin-detail-panel/action-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/action-list.tsx @@ -40,7 +40,6 @@ const ActionList = ({ detail }: Props) => { collection={provider} tool={tool} isBuiltIn={true} - isModel={false} /> ))}
diff --git a/web/app/components/tools/mcp/modal.tsx b/web/app/components/tools/mcp/modal.tsx index d07104413b60f9..0cf2156549eeab 100644 --- a/web/app/components/tools/mcp/modal.tsx +++ b/web/app/components/tools/mcp/modal.tsx @@ -39,7 +39,7 @@ type MCPModalConfirmPayload = { server_url: string icon_type: AppIconType icon: string - icon_background?: string | null + icon_background?: string server_identifier: string headers?: Record is_dynamic_registration?: boolean diff --git a/web/app/components/tools/provider/__tests__/detail.spec.tsx b/web/app/components/tools/provider/__tests__/detail.spec.tsx index bfac8c6c1fd8db..8853010d663743 100644 --- a/web/app/components/tools/provider/__tests__/detail.spec.tsx +++ b/web/app/components/tools/provider/__tests__/detail.spec.tsx @@ -81,7 +81,6 @@ vi.mock('@/context/modal-context', () => ({ const mockFetchBuiltInToolList = vi.fn().mockResolvedValue([]) const mockFetchCustomToolList = vi.fn().mockResolvedValue([]) -const mockFetchModelToolList = vi.fn().mockResolvedValue([]) const mockFetchCustomCollection = vi.fn().mockResolvedValue({ credentials: { auth_type: 'none' }, }) @@ -100,7 +99,6 @@ const mockSaveWorkflowToolProvider = vi.fn().mockResolvedValue({}) vi.mock('@/service/tools', () => ({ fetchBuiltInToolList: (...args: unknown[]) => mockFetchBuiltInToolList(...args), fetchCustomToolList: (...args: unknown[]) => mockFetchCustomToolList(...args), - fetchModelToolList: (...args: unknown[]) => mockFetchModelToolList(...args), fetchCustomCollection: (...args: unknown[]) => mockFetchCustomCollection(...args), fetchWorkflowToolDetail: (...args: unknown[]) => mockFetchWorkflowToolDetail(...args), updateBuiltInToolCredential: (...args: unknown[]) => mockUpdateBuiltInToolCredential(...args), @@ -278,7 +276,6 @@ describe('ProviderDetail', () => { }, ]) mockFetchCustomToolList.mockResolvedValue([]) - mockFetchModelToolList.mockResolvedValue([]) mockConsoleState.workspacePermissionKeys = [ 'tool.manage', 'credential.use', @@ -584,17 +581,6 @@ describe('ProviderDetail', () => { describe('Model Collection', () => { it('opens model modal when clicking auth button for model type', async () => { - mockFetchModelToolList.mockResolvedValue([ - { - name: 'model-tool-1', - label: { en_US: 'MT1' }, - description: { en_US: '' }, - parameters: [], - labels: [], - author: '', - output_schema: {}, - }, - ]) render( { }) }) - it('calls fetchModelToolList for model type', async () => { - render( - , - ) - await waitFor(() => { - expect(mockFetchModelToolList).toHaveBeenCalledWith('test-collection') - }) - }) - it('calls fetchCustomToolList for custom type', async () => { render( { it('opens and closes tool details from the keyboard', async () => { const user = userEvent.setup() - render() + render() const toolButton = screen.getByRole('button', { name: 'Tool label' }) toolButton.focus() @@ -62,7 +62,7 @@ describe('ToolItem', () => { it('does not open tool details when disabled', async () => { const user = userEvent.setup() - render() + render() const toolButton = screen.getByRole('button', { name: 'Tool label' }) expect(toolButton).toBeDisabled() diff --git a/web/app/components/tools/provider/detail.tsx b/web/app/components/tools/provider/detail.tsx index b52f3e083044f5..13c20b51b3f461 100644 --- a/web/app/components/tools/provider/detail.tsx +++ b/web/app/components/tools/provider/detail.tsx @@ -54,7 +54,6 @@ import { fetchBuiltInToolList, fetchCustomCollection, fetchCustomToolList, - fetchModelToolList, fetchWorkflowToolDetail, removeBuiltInToolCredential, removeCustomCollection, @@ -234,9 +233,6 @@ const ProviderDetail = ({ collection, onHide, onRefreshData }: Props) => { if (collection.type === CollectionType.builtIn) { const list = await fetchBuiltInToolList(collection.name) setToolList(list) - } else if (collection.type === CollectionType.model) { - const list = await fetchModelToolList(collection.name) - setToolList(list) } else if (collection.type === CollectionType.workflow) { setToolList([]) } else { @@ -470,7 +466,6 @@ const ProviderDetail = ({ collection, onHide, onRefreshData }: Props) => { collection={collection} tool={tool} isBuiltIn={isBuiltIn} - isModel={isModel} /> ))} {collection.type === CollectionType.workflow && diff --git a/web/app/components/tools/provider/tool-item.tsx b/web/app/components/tools/provider/tool-item.tsx index eb1bd73307bc2a..f89a7b4329203a 100644 --- a/web/app/components/tools/provider/tool-item.tsx +++ b/web/app/components/tools/provider/tool-item.tsx @@ -12,10 +12,9 @@ type Props = Readonly<{ collection: Collection tool: Tool isBuiltIn: boolean - isModel: boolean }> -const ToolItem = ({ disabled, collection, tool, isBuiltIn, isModel }: Props) => { +const ToolItem = ({ disabled, collection, tool, isBuiltIn }: Props) => { const locale = useLocale() const language = getPluginLanguage(locale) const [showDetail, setShowDetail] = useState(false) @@ -53,7 +52,6 @@ const ToolItem = ({ disabled, collection, tool, isBuiltIn, isModel }: Props) => setShowDetail(false) }} isBuiltIn={isBuiltIn} - isModel={isModel} /> )} diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index 22884c218033c0..560dc80a5f0153 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -247,6 +247,7 @@ export type WorkflowToolProviderOutputSchema = { export type WorkflowToolProviderRequest = { name: string + label: string icon: Emoji description: string parameters: WorkflowToolProviderParameter[] diff --git a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx index 7425c259a4bc0a..abf3e22d17f548 100644 --- a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx @@ -387,7 +387,7 @@ describe('ToolPicker', () => { plugins: [], isLoading: false, } as ReturnType) - mockCreateCustomCollection.mockResolvedValue(undefined) + mockCreateCustomCollection.mockResolvedValue({ result: 'success' }) mockInstallPackageFromMarketPlace.mockResolvedValue({ all_installed: true, task_id: 'task-1', diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/dialog.tsx index 5849b7ca62077a..17b5d8d66a3052 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/dialog.tsx @@ -94,7 +94,6 @@ export function ProviderToolSettingsDialog({ toolName={currentTarget.action.toolName} setting={toolSettings[currentTarget.action.id]} collection={toolCollection} - isModel={false} credentialId={currentTarget.tool.credentialId} onSave={handleSave} onHide={onClose} diff --git a/web/service/__tests__/use-tools.spec.tsx b/web/service/__tests__/use-tools.spec.tsx index 35213ac72f09fe..e5a463b68bcd8f 100644 --- a/web/service/__tests__/use-tools.spec.tsx +++ b/web/service/__tests__/use-tools.spec.tsx @@ -10,15 +10,9 @@ const { mockGet, mockPost } = vi.hoisted(() => ({ mockPost: vi.fn(), })) -vi.mock('@/service/base', () => ({ - del: vi.fn(), - get: mockGet, - post: mockPost, - put: vi.fn(), -})) - vi.mock('@/service/console', () => ({ consoleClient: { + workspaces: { current: { toolProviders: { get: mockGet } } }, apps: { byAppId: { server: { @@ -85,9 +79,7 @@ describe('useAllToolProviders', () => { ) await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2)) - expect(mockGet).toHaveBeenCalledWith('/workspaces/current/tool-providers') - expect(mockGet).toHaveBeenCalledWith('/workspaces/current/tool-providers', { - params: { type: 'mcp' }, - }) + expect(mockGet).toHaveBeenCalledWith({ query: {} }) + expect(mockGet).toHaveBeenCalledWith({ query: { type: 'mcp' } }) }) }) diff --git a/web/service/_tools_util.spec.ts b/web/service/_tools_util.spec.ts deleted file mode 100644 index e3bec3efcf6880..00000000000000 --- a/web/service/_tools_util.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { buildProviderQuery } from './_tools_util' - -describe('makeProviderQuery', () => { - it('collectionName without special chars', () => { - expect(buildProviderQuery('ABC')).toBe('provider=ABC') - }) - it('should escape &', () => { - expect(buildProviderQuery('ABC&DEF')).toBe('provider=ABC%26DEF') - }) - it('should escape /', () => { - expect(buildProviderQuery('ABC/DEF')).toBe('provider=ABC%2FDEF') - }) - it('should escape ?', () => { - expect(buildProviderQuery('ABC?DEF')).toBe('provider=ABC%3FDEF') - }) -}) - -describe('Tools Utilities', () => { - describe('buildProviderQuery', () => { - it('should build query string with provider parameter', () => { - const result = buildProviderQuery('openai') - expect(result).toBe('provider=openai') - }) - - it('should handle provider names with special characters', () => { - const result = buildProviderQuery('provider-name') - expect(result).toBe('provider=provider-name') - }) - - it('should handle empty string', () => { - const result = buildProviderQuery('') - expect(result).toBe('provider=') - }) - - it('should URL encode special characters', () => { - const result = buildProviderQuery('provider name') - expect(result).toBe('provider=provider+name') - }) - - it('should handle Unicode characters', () => { - const result = buildProviderQuery('提供者') - expect(result).toContain('provider=') - expect(decodeURIComponent(result)).toBe('provider=提供者') - }) - - it('should handle provider names with slashes', () => { - const result = buildProviderQuery('langgenius/openai/gpt-4') - expect(result).toContain('provider=') - expect(decodeURIComponent(result)).toBe('provider=langgenius/openai/gpt-4') - }) - }) -}) diff --git a/web/service/_tools_util.ts b/web/service/_tools_util.ts deleted file mode 100644 index 89dede8163f4c6..00000000000000 --- a/web/service/_tools_util.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const buildProviderQuery = (collectionName: string): string => { - const query = new URLSearchParams() - query.set('provider', collectionName) - return query.toString() -} diff --git a/web/service/tools.ts b/web/service/tools.ts index 08b6f54e5c1a27..69eea1e0202675 100644 --- a/web/service/tools.ts +++ b/web/service/tools.ts @@ -5,34 +5,57 @@ import type { CustomParamSchema, Tool, ToolCredential, + WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse, } from '@/app/components/tools/types' -import { buildProviderQuery } from './_tools_util' +// oxlint-disable-next-line no-restricted-imports -- legacy built-in credential flow, see below import { get, post } from './base' +import { consoleClient } from './console' +import { narrowResponse } from './console/narrow-response' + +const toolProvider = consoleClient.workspaces.current.toolProvider + +type ApiToolSchemaType = 'openai_actions' | 'openai_plugin' | 'openapi' | 'swagger' + +type WorkflowToolParameterForm = 'form' | 'llm' | 'schema' + +// The workflow tool editor keeps parameter forms as select values; the backend validates them. +const toWorkflowToolParameters = (parameters: WorkflowToolProviderParameter[]) => + parameters.map((parameter) => ({ + ...parameter, + form: parameter.form as WorkflowToolParameterForm, + })) + +// The custom tool editor keeps `schema_type` as a free string until a schema is parsed; +// the backend validates the value. +const toSchemaType = (schemaType: string) => schemaType as ApiToolSchemaType export const fetchCollectionList = () => { - return get('/workspaces/current/tool-providers') + return narrowResponse(consoleClient.workspaces.current.toolProviders.get({})) } export const fetchBuiltInToolList = (collectionName: string) => { - return get(`/workspaces/current/tool-provider/builtin/${collectionName}/tools`) + return narrowResponse( + toolProvider.builtin.byProvider.tools.get({ params: { provider: collectionName } }), + ) } export const fetchCustomToolList = (collectionName: string) => { - const query = buildProviderQuery(collectionName) - return get(`/workspaces/current/tool-provider/api/tools?${query}`) -} - -export const fetchModelToolList = (collectionName: string) => { - const query = buildProviderQuery(collectionName) - return get(`/workspaces/current/tool-provider/model/tools?${query}`) + return narrowResponse(toolProvider.api.tools.get({ query: { provider: collectionName } })) } export const fetchWorkflowToolList = (appID: string) => { - return get(`/workspaces/current/tool-provider/workflow/tools?workflow_tool_id=${appID}`) + return narrowResponse( + toolProvider.workflow.tools.get({ query: { workflow_tool_id: appID } }), + ) } +// Not migrated to the console contract: this legacy built-in credential flow predates +// multi-credential tool auth. The backend now serves the schema at +// `credential/schema/{credential_type}`, returns a credential list from `credentials`, and +// requires `credential_id` for update/delete. Only providers without a plugin reach it, and +// none of the remaining hard-coded built-in providers require credentials. export const fetchBuiltInToolCredentialSchema = (collectionName: string) => { return get( `/workspaces/current/tool-provider/builtin/${collectionName}/credentials_schema`, @@ -44,6 +67,7 @@ export const fetchBuiltInToolCredential = (collectionName: string) => { `/workspaces/current/tool-provider/builtin/${collectionName}/credentials`, ) } + export const updateBuiltInToolCredential = ( collectionName: string, credential: Record, @@ -62,51 +86,40 @@ export const removeBuiltInToolCredential = (collectionName: string) => { } export const parseParamsSchema = (schema: string) => { - return post<{ parameters_schema: CustomParamSchema[]; schema_type: string }>( - '/workspaces/current/tool-provider/api/schema', - { - body: { - schema, - }, - }, + return narrowResponse<{ parameters_schema: CustomParamSchema[]; schema_type: string }>( + toolProvider.api.schema.post({ body: { schema } }), ) } export const fetchCustomCollection = (collectionName: string) => { - const query = buildProviderQuery(collectionName) - return get(`/workspaces/current/tool-provider/api/get?${query}`) + return narrowResponse( + toolProvider.api.get.get({ query: { provider: collectionName } }), + ) } export const createCustomCollection = (collection: CustomCollectionBackend) => { - return post('/workspaces/current/tool-provider/api/add', { - body: { - ...collection, - }, + return toolProvider.api.add.post({ + body: { ...collection, schema_type: toSchemaType(collection.schema_type) }, }) } export const updateCustomCollection = (collection: CustomCollectionBackend) => { - return post('/workspaces/current/tool-provider/api/update', { + return toolProvider.api.update.post({ body: { ...collection, + // A collection that was not renamed keeps its current name as the original one. + original_provider: collection.original_provider ?? collection.provider, + schema_type: toSchemaType(collection.schema_type), }, }) } export const removeCustomCollection = (collectionName: string) => { - return post('/workspaces/current/tool-provider/api/delete', { - body: { - provider: collectionName, - }, - }) + return toolProvider.api.delete.post({ body: { provider: collectionName } }) } export const importSchemaFromURL = (url: string) => { - return get<{ schema: string }>('/workspaces/current/tool-provider/api/remote', { - params: { - url, - }, - }) + return toolProvider.api.remote.get({ query: { url } }) } export const testAPIAvailable = (payload: { @@ -117,18 +130,16 @@ export const testAPIAvailable = (payload: { schema: string parameters: Record }) => { - return post('/workspaces/current/tool-provider/api/test/pre', { - body: { - ...payload, - }, + return toolProvider.api.test.pre.post({ + body: { ...payload, schema_type: toSchemaType(payload.schema_type) }, }) } export const createWorkflowToolProvider = ( payload: WorkflowToolProviderRequest & { workflow_app_id: string }, ) => { - return post('/workspaces/current/tool-provider/workflow/create', { - body: { ...payload }, + return toolProvider.workflow.create.post({ + body: { ...payload, parameters: toWorkflowToolParameters(payload.parameters) }, }) } @@ -139,21 +150,23 @@ export const saveWorkflowToolProvider = ( workflow_tool_id: string }>, ) => { - return post('/workspaces/current/tool-provider/workflow/update', { - body: { ...payload }, + const { workflow_tool_id } = payload + if (!workflow_tool_id) throw new Error('workflow_tool_id is required to update a workflow tool') + return toolProvider.workflow.update.post({ + body: { + ...payload, + workflow_tool_id, + parameters: toWorkflowToolParameters(payload.parameters), + }, }) } export const fetchWorkflowToolDetail = (toolID: string) => { - return get( - `/workspaces/current/tool-provider/workflow/get?workflow_tool_id=${toolID}`, + return narrowResponse( + toolProvider.workflow.get.get({ query: { workflow_tool_id: toolID } }), ) } export const deleteWorkflowTool = (toolID: string) => { - return post('/workspaces/current/tool-provider/workflow/delete', { - body: { - workflow_tool_id: toolID, - }, - }) + return toolProvider.workflow.delete.post({ body: { workflow_tool_id: toolID } }) } diff --git a/web/service/use-tools.ts b/web/service/use-tools.ts index 002cbc9b2644c1..15c024f7d26b32 100644 --- a/web/service/use-tools.ts +++ b/web/service/use-tools.ts @@ -1,3 +1,4 @@ +import type { AppMcpServerStatus } from '@dify/contracts/api/console/apps/types.gen' import type { QueryKey, UseQueryOptions } from '@tanstack/react-query' import type { Collection, @@ -11,7 +12,7 @@ import type { AppIconType } from '@/types/app' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { CollectionType } from '@/app/components/tools/types' import { consoleClient } from '@/service/console' -import { del, get, post, put } from './base' +import { narrowResponse } from './console/narrow-response' import { useInvalid } from './use-base' const NAME_SPACE = 'tools' @@ -23,9 +24,9 @@ export const useAllToolProviders = (enabled = true, type?: ToolProviderListType) return useQuery({ queryKey: type ? [...useAllToolProvidersKey, type] : useAllToolProvidersKey, queryFn: () => - type - ? get('/workspaces/current/tool-providers', { params: { type } }) - : get('/workspaces/current/tool-providers'), + narrowResponse( + consoleClient.workspaces.current.toolProviders.get({ query: type ? { type } : {} }), + ), enabled, }) } @@ -38,7 +39,8 @@ const useAllBuiltInToolsKey = [NAME_SPACE, 'builtIn'] export const useAllBuiltInTools = (enabled = true) => { return useQuery({ queryKey: useAllBuiltInToolsKey, - queryFn: () => get('/workspaces/current/tools/builtin'), + queryFn: () => + narrowResponse(consoleClient.workspaces.current.tools.builtin.get()), enabled, }) } @@ -51,7 +53,8 @@ const useAllCustomToolsKey = [NAME_SPACE, 'customTools'] export const useAllCustomTools = (enabled = true) => { return useQuery({ queryKey: useAllCustomToolsKey, - queryFn: () => get('/workspaces/current/tools/api'), + queryFn: () => + narrowResponse(consoleClient.workspaces.current.tools.api.get()), enabled, }) } @@ -64,7 +67,8 @@ const useAllWorkflowToolsKey = [NAME_SPACE, 'workflowTools'] export const useAllWorkflowTools = (enabled = true) => { return useQuery({ queryKey: useAllWorkflowToolsKey, - queryFn: () => get('/workspaces/current/tools/workflow'), + queryFn: () => + narrowResponse(consoleClient.workspaces.current.tools.workflow.get()), enabled, }) } @@ -77,7 +81,8 @@ const useAllMCPToolsKey = [NAME_SPACE, 'MCPTools'] export const useAllMCPTools = (enabled = true) => { return useQuery({ queryKey: useAllMCPToolsKey, - queryFn: () => get('/workspaces/current/tools/mcp'), + queryFn: () => + narrowResponse(consoleClient.workspaces.current.tools.mcp.get()), enabled, }) } @@ -103,19 +108,18 @@ export const useCreateMCP = () => { mutationFn: (payload: { name: string server_url: string + server_identifier: string icon_type: AppIconType icon: string - icon_background?: string | null + icon_background?: string timeout?: number sse_read_timeout?: number headers?: Record identity_mode?: 'off' | 'idp_token' }) => { - return post('workspaces/current/tool-provider/mcp', { - body: { - ...payload, - }, - }) + return narrowResponse( + consoleClient.workspaces.current.toolProvider.mcp.post({ body: { ...payload } }), + ) }, }) } @@ -126,20 +130,17 @@ export const useUpdateMCP = ({ onSuccess }: { onSuccess?: () => void }) => { mutationFn: (payload: { name: string server_url: string + server_identifier: string icon_type: AppIconType icon: string - icon_background?: string | null + icon_background?: string provider_id: string timeout?: number sse_read_timeout?: number headers?: Record identity_mode?: 'off' | 'idp_token' }) => { - return put('workspaces/current/tool-provider/mcp', { - body: { - ...payload, - }, - }) + return consoleClient.workspaces.current.toolProvider.mcp.put({ body: { ...payload } }) }, onSuccess, }) @@ -149,11 +150,7 @@ export const useDeleteMCP = ({ onSuccess }: { onSuccess?: () => void }) => { return useMutation({ mutationKey: [NAME_SPACE, 'delete-mcp'], mutationFn: (id: string) => { - return del('/workspaces/current/tool-provider/mcp', { - body: { - provider_id: id, - }, - }) + return consoleClient.workspaces.current.toolProvider.mcp.delete({ body: { provider_id: id } }) }, onSuccess, }) @@ -163,12 +160,7 @@ export const useAuthorizeMCP = () => { return useMutation({ mutationKey: [NAME_SPACE, 'authorize-mcp'], mutationFn: (payload: { provider_id: string }) => { - return post<{ result?: string; authorization_url?: string }>( - '/workspaces/current/tool-provider/mcp/auth', - { - body: payload, - }, - ) + return consoleClient.workspaces.current.toolProvider.mcp.auth.post({ body: payload }) }, }) } @@ -178,7 +170,11 @@ export const useMCPTools = (providerID: string) => { enabled: !!providerID, queryKey: [NAME_SPACE, 'get-MCP-provider-tool', providerID], queryFn: () => - get<{ tools: Tool[] }>(`/workspaces/current/tool-provider/mcp/tools/${providerID}`), + narrowResponse<{ tools: Tool[] }>( + consoleClient.workspaces.current.toolProvider.mcp.tools.byProviderId.get({ + params: { provider_id: providerID }, + }), + ), }) } export const useInvalidateMCPTools = () => { @@ -193,14 +189,21 @@ export const useInvalidateMCPTools = () => { export const useUpdateMCPTools = () => { return useMutation({ mutationFn: (providerID: string) => - get<{ tools: Tool[] }>(`/workspaces/current/tool-provider/mcp/update/${providerID}`), + narrowResponse<{ tools: Tool[] }>( + consoleClient.workspaces.current.toolProvider.mcp.update.byProviderId.get({ + params: { provider_id: providerID }, + }), + ), }) } export const useMCPServerDetail = (appID: string, enabled = true) => { return useQuery({ queryKey: [NAME_SPACE, 'MCPServerDetail', appID], - queryFn: () => get(`/apps/${appID}/server`), + queryFn: () => + narrowResponse( + consoleClient.apps.byAppId.server.get({ params: { app_id: appID } }), + ), enabled, }) } @@ -220,14 +223,10 @@ export const useCreateMCPServer = () => { mutationFn: (payload: { appID: string description?: string - parameters?: Record + parameters: Record }) => { const { appID, ...rest } = payload - return post(`apps/${appID}/server`, { - body: { - ...rest, - }, - }) + return consoleClient.apps.byAppId.server.post({ params: { app_id: appID }, body: rest }) }, }) } @@ -239,15 +238,11 @@ export const useUpdateMCPServer = () => { appID: string id: string description?: string - status?: string - parameters?: Record + status?: AppMcpServerStatus + parameters: Record }) => { const { appID, ...rest } = payload - return put(`apps/${appID}/server`, { - body: { - ...rest, - }, - }) + return consoleClient.apps.byAppId.server.put({ params: { app_id: appID }, body: rest }) }, }) } @@ -269,7 +264,12 @@ export const useBuiltinTools = (providerName: string) => { return useQuery({ enabled: !!providerName, queryKey: [NAME_SPACE, 'builtin-provider-tools', providerName], - queryFn: () => get(`/workspaces/current/tool-provider/builtin/${providerName}/tools`), + queryFn: () => + narrowResponse( + consoleClient.workspaces.current.toolProvider.builtin.byProvider.tools.get({ + params: { provider: providerName }, + }), + ), }) } @@ -279,11 +279,9 @@ export const useRAGRecommendedPlugins = (type: 'tool' | 'datasource' | 'all' = ' return useQuery({ queryKey: [...useRAGRecommendedPluginListKey, type], queryFn: () => - get('/rag/pipelines/recommended-plugins', { - params: { - type, - }, - }), + narrowResponse( + consoleClient.rag.pipelines.recommendedPlugins.get({ query: { type } }), + ), }) } @@ -315,7 +313,10 @@ export const useAppTriggers = ( ) => { return useQuery<{ data: AppTrigger[] }>({ queryKey: [NAME_SPACE, 'app-triggers', appId], - queryFn: () => get<{ data: AppTrigger[] }>(`/apps/${appId}/triggers`), + queryFn: () => + narrowResponse<{ data: AppTrigger[] }>( + consoleClient.apps.byAppId.triggers.get({ params: { app_id: appId! } }), + ), enabled: !!appId, ...options, // Merge additional options while maintaining backward compatibility }) @@ -340,8 +341,10 @@ export const useWorkflowToolDetailByAppID = (appId: string, enabled = true) => { return useQuery({ queryKey: workflowToolDetailByAppIDKey(appId), queryFn: () => - get( - `/workspaces/current/tool-provider/workflow/get?workflow_app_id=${appId}`, + narrowResponse( + consoleClient.workspaces.current.toolProvider.workflow.get.get({ + query: { workflow_app_id: appId }, + }), ), enabled: enabled && !!appId, }) From 191d4d8f415eefbcaf9ce81435cdd5d9cf56f1b2 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:08:41 +0800 Subject: [PATCH 21/60] refactor(web): migrate pipeline and datasource hooks to the console contract Co-Authored-By: Claude Opus 5.5 --- oxlint-suppressions.json | 11 -- web/service/__tests__/use-pipeline.spec.tsx | 36 +++-- web/service/use-datasource.ts | 26 ++- web/service/use-pipeline.ts | 169 ++++++++++++-------- 4 files changed, 142 insertions(+), 100 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 954cd4fef50935..65150d98bc5d5d 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4016,11 +4016,6 @@ "count": 1 } }, - "web/service/__tests__/use-pipeline.spec.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/service/access-control/__tests__/use-app-access-control.spec.tsx": { "no-restricted-imports": { "count": 1 @@ -4140,9 +4135,6 @@ "web/service/use-datasource.ts": { "@tanstack/query/prefer-query-options": { "count": 3 - }, - "no-restricted-imports": { - "count": 1 } }, "web/service/use-flow.ts": { @@ -4153,9 +4145,6 @@ "web/service/use-pipeline.ts": { "@tanstack/query/prefer-query-options": { "count": 8 - }, - "no-restricted-imports": { - "count": 1 } }, "web/service/use-plugins-auth.ts": { diff --git a/web/service/__tests__/use-pipeline.spec.tsx b/web/service/__tests__/use-pipeline.spec.tsx index 8dae4041b1d079..999028b5d73d12 100644 --- a/web/service/__tests__/use-pipeline.spec.tsx +++ b/web/service/__tests__/use-pipeline.spec.tsx @@ -3,16 +3,25 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { DSLImportMode, DSLImportStatus } from '@/models/app' -import { post } from '../base' +import { consoleClient } from '../console' import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '../use-pipeline' -vi.mock('../base', () => ({ - post: vi.fn(), - get: vi.fn(), - patch: vi.fn(), - del: vi.fn(), +vi.mock('../console', () => ({ + consoleClient: { + rag: { + pipelines: { + imports: { + post: vi.fn(), + byImportId: { confirm: { post: vi.fn() } }, + }, + }, + }, + }, })) +const importPost = vi.mocked(consoleClient.rag.pipelines.imports.post) +const confirmPost = vi.mocked(consoleClient.rag.pipelines.imports.byImportId.confirm.post) + const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { @@ -29,14 +38,16 @@ const createWrapper = () => { describe('use-pipeline imports', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(post).mockResolvedValue({ + const importResult = { id: 'import-id', status: DSLImportStatus.COMPLETED, pipeline_id: 'pipeline-id', dataset_id: 'dataset-id', current_dsl_version: '0.1.0', imported_dsl_version: '0.1.0', - }) + } + importPost.mockResolvedValue(importResult as never) + confirmPost.mockResolvedValue(importResult as never) }) it('should import pipeline DSL silently so callers can own error toasts', async () => { @@ -50,7 +61,7 @@ describe('use-pipeline imports', () => { await result.current.mutateAsync(request) }) - expect(post).toHaveBeenCalledWith('/rag/pipelines/imports', { body: request }, { silent: true }) + expect(importPost).toHaveBeenCalledWith({ body: request }, { context: { silent: true } }) }) it('should confirm pipeline DSL import silently so callers can own error toasts', async () => { @@ -60,10 +71,9 @@ describe('use-pipeline imports', () => { await result.current.mutateAsync('import-id') }) - expect(post).toHaveBeenCalledWith( - '/rag/pipelines/imports/import-id/confirm', - {}, - { silent: true }, + expect(confirmPost).toHaveBeenCalledWith( + { params: { import_id: 'import-id' } }, + { context: { silent: true } }, ) }) }) diff --git a/web/service/use-datasource.ts b/web/service/use-datasource.ts index 2e15fe2e30d785..5ecb61c3ae1085 100644 --- a/web/service/use-datasource.ts +++ b/web/service/use-datasource.ts @@ -3,7 +3,8 @@ import type { DataSourceCredential, } from '@/app/components/header/account-setting/data-source-page-new/types' import { useMutation, useQuery } from '@tanstack/react-query' -import { get } from './base' +import { consoleClient } from './console' +import { narrowResponse } from './console/narrow-response' import { useInvalid } from './use-base' const NAME_SPACE = 'data-source-auth' @@ -11,7 +12,8 @@ const NAME_SPACE = 'data-source-auth' export const useGetDataSourceListAuth = () => { return useQuery({ queryKey: [NAME_SPACE, 'list'], - queryFn: () => get<{ result: DataSourceAuth[] }>('/auth/plugin/datasource/list'), + queryFn: () => + narrowResponse<{ result: DataSourceAuth[] }>(consoleClient.auth.plugin.datasource.list.get()), retry: 0, }) } @@ -24,7 +26,10 @@ export const useInvalidDataSourceListAuth = () => { export const useGetDefaultDataSourceListAuth = () => { return useQuery({ queryKey: [NAME_SPACE, 'default-list'], - queryFn: () => get<{ result: DataSourceAuth[] }>('/auth/plugin/datasource/default-list'), + queryFn: () => + narrowResponse<{ result: DataSourceAuth[] }>( + consoleClient.auth.plugin.datasource.defaultList.get(), + ), retry: 0, }) } @@ -37,11 +42,16 @@ export const useGetDataSourceOAuthUrl = (provider: string) => { return useMutation({ mutationKey: [NAME_SPACE, 'oauth-url', provider], mutationFn: (credentialId?: string) => { - return get<{ + return narrowResponse<{ authorization_url: string state: string context_id: string - }>(`/oauth/plugin/${provider}/datasource/get-authorization-url?credential_id=${credentialId}`) + }>( + consoleClient.oauth.plugin.byProviderId.datasource.getAuthorizationUrl.get({ + params: { provider_id: provider }, + query: { credential_id: credentialId }, + }), + ) }, }) } @@ -56,7 +66,11 @@ export const useGetDataSourceAuth = ({ return useQuery({ queryKey: [NAME_SPACE, 'specific-data-source', pluginId, provider], queryFn: () => - get<{ result: DataSourceCredential[] }>(`/auth/plugin/datasource/${pluginId}/${provider}`), + narrowResponse<{ result: DataSourceCredential[] }>( + consoleClient.auth.plugin.datasource.byProviderId.get({ + params: { provider_id: `${pluginId}/${provider}` }, + }), + ), retry: 0, }) } diff --git a/web/service/use-pipeline.ts b/web/service/use-pipeline.ts index 37490c920231fe..dd6ddf43e84f60 100644 --- a/web/service/use-pipeline.ts +++ b/web/service/use-pipeline.ts @@ -31,7 +31,8 @@ import type { } from '@/models/pipeline' import { useMutation, useQuery } from '@tanstack/react-query' import { DatasourceType } from '@/models/pipeline' -import { del, get, patch, post } from './base' +import { consoleClient } from './console' +import { narrowResponse } from './console/narrow-response' import { useInvalid } from './use-base' const NAME_SPACE = 'pipeline' @@ -41,7 +42,9 @@ export const usePipelineTemplateList = (params: PipelineTemplateListParams, enab return useQuery({ queryKey: [...PipelineTemplateListQueryKeyPrefix, params], queryFn: () => { - return get('/rag/pipeline/templates', { params }) + return narrowResponse( + consoleClient.rag.pipeline.templates.get({ query: params }), + ) }, enabled, }) @@ -56,11 +59,14 @@ export const usePipelineTemplateById = (params: PipelineTemplateByIdRequest, ena return useQuery({ queryKey: [NAME_SPACE, 'template', type, template_id], queryFn: () => { - return get(`/rag/pipeline/templates/${template_id}`, { - params: { - type, - }, - }) + return narrowResponse( + consoleClient.rag.pipeline.templates.byTemplateId.get({ + params: { template_id }, + query: { + type, + }, + }), + ) }, enabled, staleTime: 0, @@ -78,11 +84,11 @@ export const useUpdateTemplateInfo = ( mutationKey: [NAME_SPACE, 'template-update'], mutationFn: (request: UpdateTemplateInfoRequest) => { const { template_id, ...rest } = request - return patch( - `/rag/pipeline/customized/templates/${template_id}`, - { + return narrowResponse( + consoleClient.rag.pipeline.customized.templates.byTemplateId.patch({ + params: { template_id }, body: rest, - }, + }), ) }, ...mutationOptions, @@ -95,7 +101,11 @@ export const useDeleteTemplate = ( return useMutation({ mutationKey: [NAME_SPACE, 'template-delete'], mutationFn: (templateId: string) => { - return del(`/rag/pipeline/customized/templates/${templateId}`) + return narrowResponse( + consoleClient.rag.pipeline.customized.templates.byTemplateId.delete({ + params: { template_id: templateId }, + }), + ) }, ...mutationOptions, }) @@ -107,7 +117,11 @@ export const useExportTemplateDSL = ( return useMutation({ mutationKey: [NAME_SPACE, 'template-dsl-export'], mutationFn: (templateId: string) => { - return post(`/rag/pipeline/customized/templates/${templateId}`) + return narrowResponse( + consoleClient.rag.pipeline.customized.templates.byTemplateId.post({ + params: { template_id: templateId }, + }), + ) }, ...mutationOptions, }) @@ -119,10 +133,8 @@ export const useImportPipelineDSL = ( return useMutation({ mutationKey: [NAME_SPACE, 'dsl-import'], mutationFn: (request: ImportPipelineDSLRequest) => { - return post( - '/rag/pipelines/imports', - { body: request }, - { silent: true }, + return narrowResponse( + consoleClient.rag.pipelines.imports.post({ body: request }, { context: { silent: true } }), ) }, ...mutationOptions, @@ -135,10 +147,11 @@ export const useImportPipelineDSLConfirm = ( return useMutation({ mutationKey: [NAME_SPACE, 'dsl-import-confirm'], mutationFn: (importId: string) => { - return post( - `/rag/pipelines/imports/${importId}/confirm`, - {}, - { silent: true }, + return narrowResponse( + consoleClient.rag.pipelines.imports.byImportId.confirm.post( + { params: { import_id: importId } }, + { context: { silent: true } }, + ), ) }, ...mutationOptions, @@ -151,8 +164,10 @@ export const useCheckPipelineDependencies = ( return useMutation({ mutationKey: [NAME_SPACE, 'check-dependencies'], mutationFn: (pipelineId: string) => { - return get( - `/rag/pipelines/imports/${pipelineId}/check-dependencies`, + return narrowResponse( + consoleClient.rag.pipelines.imports.byPipelineId.checkDependencies.get({ + params: { pipeline_id: pipelineId }, + }), ) }, ...mutationOptions, @@ -167,13 +182,13 @@ export const useDraftPipelineProcessingParams = ( return useQuery({ queryKey: [NAME_SPACE, 'draft-pipeline-processing-params', pipeline_id, node_id], queryFn: () => { - return get( - `/rag/pipelines/${pipeline_id}/workflows/draft/processing/parameters`, - { - params: { + return narrowResponse( + consoleClient.rag.pipelines.byPipelineId.workflows.draft.processing.parameters.get({ + params: { pipeline_id }, + query: { node_id, }, - }, + }), ) }, staleTime: 0, @@ -186,13 +201,13 @@ export const usePublishedPipelineProcessingParams = (params: PipelineProcessingP return useQuery({ queryKey: [NAME_SPACE, 'published-pipeline-processing-params', pipeline_id, node_id], queryFn: () => { - return get( - `/rag/pipelines/${pipeline_id}/workflows/published/processing/parameters`, - { - params: { + return narrowResponse( + consoleClient.rag.pipelines.byPipelineId.workflows.published.processing.parameters.get({ + params: { pipeline_id }, + query: { node_id, }, - }, + }), ) }, staleTime: 0, @@ -205,8 +220,10 @@ export const usePublishedPipelineInfo = (pipelineId: string) => { return useQuery({ queryKey: [...publishedPipelineInfoQueryKeyPrefix, pipelineId], queryFn: () => { - return get( - `/rag/pipelines/${pipelineId}/workflows/publish`, + return narrowResponse( + consoleClient.rag.pipelines.byPipelineId.workflows.publish.get({ + params: { pipeline_id: pipelineId }, + }), ) }, enabled: !!pipelineId, @@ -224,15 +241,15 @@ export const useRunPublishedPipeline = ( mutationKey: [NAME_SPACE, 'run-published-pipeline'], mutationFn: (request: PublishedPipelineRunRequest) => { const { pipeline_id: pipelineId, is_preview, ...rest } = request - return post( - `/rag/pipelines/${pipelineId}/workflows/published/run`, - { + return narrowResponse( + consoleClient.rag.pipelines.byPipelineId.workflows.published.run.post({ + params: { pipeline_id: pipelineId }, body: { ...rest, is_preview, response_mode: 'blocking', }, - }, + }), ) }, ...mutationOptions, @@ -247,13 +264,13 @@ export const useDraftPipelinePreProcessingParams = ( return useQuery({ queryKey: [NAME_SPACE, 'draft-pipeline-pre-processing-params', pipeline_id, node_id], queryFn: () => { - return get( - `/rag/pipelines/${pipeline_id}/workflows/draft/pre-processing/parameters`, - { - params: { + return narrowResponse( + consoleClient.rag.pipelines.byPipelineId.workflows.draft.preProcessing.parameters.get({ + params: { pipeline_id }, + query: { node_id, }, - }, + }), ) }, staleTime: 0, @@ -269,13 +286,13 @@ export const usePublishedPipelinePreProcessingParams = ( return useQuery({ queryKey: [NAME_SPACE, 'published-pipeline-pre-processing-params', pipeline_id, node_id], queryFn: () => { - return get( - `/rag/pipelines/${pipeline_id}/workflows/published/pre-processing/parameters`, - { - params: { + return narrowResponse( + consoleClient.rag.pipelines.byPipelineId.workflows.published.preProcessing.parameters.get({ + params: { pipeline_id }, + query: { node_id, }, - }, + }), ) }, staleTime: 0, @@ -287,8 +304,11 @@ export const useExportPipelineDSL = () => { return useMutation({ mutationKey: [NAME_SPACE, 'export-pipeline-dsl'], mutationFn: ({ pipelineId, include = false }: { pipelineId: string; include?: boolean }) => { - return get( - `/rag/pipelines/${pipelineId}/exports?include_secret=${include}`, + return narrowResponse( + consoleClient.rag.pipelines.byPipelineId.exports.get({ + params: { pipeline_id: pipelineId }, + query: { include_secret: String(include) }, + }), ) }, }) @@ -308,7 +328,8 @@ export const usePublishAsCustomizedPipeline = () => { icon_info: IconInfo description?: string }) => { - return post(`/rag/pipelines/${pipelineId}/customized/publish`, { + return consoleClient.rag.pipelines.byPipelineId.customized.publish.post({ + params: { pipeline_id: pipelineId }, body: { name, icon_info, @@ -324,8 +345,10 @@ export const usePipelineExecutionLog = (params: PipelineExecutionLogRequest) => return useQuery({ queryKey: [NAME_SPACE, 'pipeline-execution-log', dataset_id, document_id], queryFn: () => { - return get( - `/datasets/${dataset_id}/documents/${document_id}/pipeline-execution-log`, + return narrowResponse( + consoleClient.datasets.byDatasetId.documents.byDocumentId.pipelineExecutionLog.get({ + params: { dataset_id, document_id }, + }), ) }, staleTime: 0, @@ -337,19 +360,21 @@ export const usePreviewOnlineDocument = () => { mutationKey: [NAME_SPACE, 'preview-online-document'], mutationFn: (params: OnlineDocumentPreviewRequest) => { const { pipelineId, datasourceNodeId, workspaceID, pageID, pageType, credentialId } = params - return post( - `/rag/pipelines/${pipelineId}/workflows/published/datasource/nodes/${datasourceNodeId}/preview`, - { - body: { - datasource_type: DatasourceType.onlineDocument, - credential_id: credentialId, - inputs: { - workspace_id: workspaceID, - page_id: pageID, - type: pageType, + return narrowResponse( + consoleClient.rag.pipelines.byPipelineId.workflows.published.datasource.nodes.byNodeId.preview.post( + { + params: { pipeline_id: pipelineId, node_id: datasourceNodeId }, + body: { + datasource_type: DatasourceType.onlineDocument, + credential_id: credentialId, + inputs: { + workspace_id: workspaceID, + page_id: pageID, + type: pageType, + }, }, }, - }, + ), ) }, }) @@ -359,7 +384,11 @@ export const useConvertDatasetToPipeline = () => { return useMutation({ mutationKey: [NAME_SPACE, 'convert-dataset-to-pipeline'], mutationFn: (datasetId: string) => { - return post(`/rag/pipelines/transform/datasets/${datasetId}`) + return narrowResponse( + consoleClient.rag.pipelines.transform.datasets.byDatasetId.post({ + params: { dataset_id: datasetId }, + }), + ) }, }) } @@ -375,11 +404,11 @@ export const useDatasourceSingleRun = ( mutationKey: [NAME_SPACE, 'datasource-node-single-run'], mutationFn: (params: DatasourceNodeSingleRunRequest) => { const { pipeline_id: pipelineId, ...rest } = params - return post( - `/rag/pipelines/${pipelineId}/workflows/draft/datasource/variables-inspect`, - { + return narrowResponse( + consoleClient.rag.pipelines.byPipelineId.workflows.draft.datasource.variablesInspect.post({ + params: { pipeline_id: pipelineId }, body: rest, - }, + }), ) }, ...mutationOptions, From c9409f54651755c55dff666d2ec180c95119597a Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:15:51 +0800 Subject: [PATCH 22/60] refactor(web): migrate workflow draft variables, debug and plugin service calls - draft node runs, inspect variables, last run, system/conversation variables, variable reset/edit/delete call the console contract per flow type via callByFlowType - block default configs, environment variables, human input form submission and email delivery tests call consoleClient - debug: suggested questions and conversation messages pass an AbortSignal; rule and instruction generation, prompt templates and message detail use the contract - plugins: upgrade, GitHub upload and task status use the contract - RAG pipelines have no conversation variables, so that query resolves empty for them - hooks that take prebuilt flow URLs (run history, config, version history, publish, restore, delete) stay on the legacy client Co-Authored-By: Claude Opus 5.5 --- oxlint-suppressions.json | 9 +- .../app/text-generate/item/index.tsx | 9 +- .../__tests__/chat-wrapper.spec.tsx | 2 +- .../nodes/_base/hooks/use-one-step-run.ts | 6 +- web/service/debug.ts | 106 ++++++----- web/service/plugins.ts | 52 +++--- web/service/use-workflow.ts | 176 +++++++++++++++--- web/service/utils.ts | 17 ++ web/service/workflow.spec.ts | 9 +- web/service/workflow.ts | 90 +++++++-- 10 files changed, 350 insertions(+), 126 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 65150d98bc5d5d..1790cc4198a2f0 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -2764,7 +2764,7 @@ "count": 1 }, "typescript/no-explicit-any": { - "count": 21 + "count": 20 } }, "web/app/components/workflow/nodes/_base/hooks/use-output-var-list.ts": { @@ -4060,7 +4060,7 @@ "count": 2 }, "typescript/no-explicit-any": { - "count": 6 + "count": 1 } }, "web/service/fetch.spec.ts": { @@ -4200,11 +4200,8 @@ "@tanstack/query/prefer-query-options": { "count": 9 }, - "no-restricted-imports": { - "count": 1 - }, "typescript/no-explicit-any": { - "count": 3 + "count": 2 } }, "web/service/webapp-auth.ts": { diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index 10255b9956535f..6664d0371ddd9d 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/web/app/components/app/text-generate/item/index.tsx @@ -170,7 +170,14 @@ const GenerationItem: FC = ({ appId: params.appId as string, messageId: messageId!, }) - onOpenLog(buildPromptLogItem(data)) + const promptLog = buildPromptLogItem(data) + onOpenLog({ + id: promptLog.id, + // The prompt log modal only renders `log`; message file entries are passed through as-is. + log: promptLog.log as IChatItem['log'], + content: data.answer, + isAnswer: true, + }) } const [currentTab, setCurrentTab] = useState('DETAIL') diff --git a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx index c65284461d692d..552a6c3f351267 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx @@ -931,7 +931,7 @@ describe('ChatWrapper', () => { it('should handle human input form submission for installed app', async () => { const { submitHumanInputForm: submitWorkflowForm } = await import('@/service/workflow') - vi.mocked(submitWorkflowForm).mockResolvedValue({} as unknown as void) + vi.mocked(submitWorkflowForm).mockResolvedValue({}) let resolveWorkflowEventsReady: (isReady: boolean) => void = () => {} const prepareHumanInputSubmission = vi.fn( () => diff --git a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts index 77443dc0542092..c28c0c22e61f57 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts @@ -766,7 +766,9 @@ const useOneStepRun = ({ } } else { const isStartNode = data.type === BlockEnum.Start - const postData: Record = {} + const postData: Parameters[3] & { conversation_id?: string } = { + inputs: submitData, + } if (isStartNode) { const { '#sys.query#': query, '#sys.files#': files, ...inputs } = submitData if (isChatMode) postData.conversation_id = '' @@ -774,8 +776,6 @@ const useOneStepRun = ({ postData.inputs = inputs postData.query = query postData.files = files || [] - } else { - postData.inputs = submitData } res = (await singleNodeRun(flowType, flowId!, id, postData)) as any } diff --git a/web/service/debug.ts b/web/service/debug.ts index 566521271f9f91..31461302e29735 100644 --- a/web/service/debug.ts +++ b/web/service/debug.ts @@ -1,7 +1,11 @@ +import type { PostInstructionGenerateData } from '@dify/contracts/api/console/instruction-generate/types.gen' +import type { PostRuleGenerateData } from '@dify/contracts/api/console/rule-generate/types.gen' import type { IOnCompleted, IOnData, IOnError, IOnMessageReplace } from './base' import type { ChatPromptConfig, CompletionPromptConfig } from '@/models/debug' -import type { AppModeEnum, ModelModeType } from '@/types/app' -import { get, post, ssePost } from './base' +import type { AppModeEnum, Model, ModelModeType } from '@/types/app' +import { ssePost } from './base' +import { consoleClient } from './console' +import { narrowResponse } from './console/narrow-response' type BasicAppFirstRes = { prompt: string @@ -19,7 +23,9 @@ export type GenRes = { } export const stopChatMessageResponding = async (appId: string, taskId: string) => { - return post(`apps/${appId}/chat-messages/${taskId}/stop`) + return consoleClient.apps.byAppId.chatMessages.byTaskId.stop.post({ + params: { app_id: appId, task_id: taskId }, + }) } export const sendCompletionMessage = async ( @@ -45,48 +51,58 @@ export const sendCompletionMessage = async ( ) } +const createAbortSignal = (getAbortController?: (abortController: AbortController) => void) => { + const abortController = new AbortController() + getAbortController?.(abortController) + return abortController.signal +} + export const fetchSuggestedQuestions = ( appId: string, messageId: string, - getAbortController?: any, + getAbortController?: (abortController: AbortController) => void, ) => { - return get( - `apps/${appId}/chat-messages/${messageId}/suggested-questions`, - {}, - { - getAbortController, - }, + return consoleClient.apps.byAppId.chatMessages.byMessageId.suggestedQuestions.get( + { params: { app_id: appId, message_id: messageId } }, + { signal: createAbortSignal(getAbortController) }, ) } export const fetchConversationMessages = ( appId: string, conversation_id: string, - getAbortController?: any, + getAbortController?: (abortController: AbortController) => void, ) => { - return get( - `apps/${appId}/chat-messages`, - { - params: { - conversation_id, - }, - }, - { - getAbortController, - }, + return consoleClient.apps.byAppId.chatMessages.get( + { params: { app_id: appId }, query: { conversation_id } }, + { signal: createAbortSignal(getAbortController) }, ) } -export const generateBasicAppFirstTimeRule = (body: Record) => { - return post('/rule-generate', { - body, - }) +// The model selector keeps an unset mode as '' and generators may omit the flow id; the +// backend validates both, so the UI request shapes are passed through as the contract bodies. +type RuleGenerateRequest = Omit & { + model_config: Model } -export const generateRule = (body: Record) => { - return post('/instruction-generate', { - body, - }) +type InstructionGenerateRequest = Omit< + PostInstructionGenerateData['body'], + 'model_config' | 'flow_id' +> & { + model_config: Model + flow_id?: string +} + +export const generateBasicAppFirstTimeRule = (body: RuleGenerateRequest) => { + return narrowResponse( + consoleClient.ruleGenerate.post({ body: body as PostRuleGenerateData['body'] }), + ) +} + +export const generateRule = (body: InstructionGenerateRequest) => { + return narrowResponse( + consoleClient.instructionGenerate.post({ body: body as PostInstructionGenerateData['body'] }), + ) } export const fetchPromptTemplate = ({ @@ -100,20 +116,20 @@ export const fetchPromptTemplate = ({ modelName: string hasSetDataSet: boolean }) => { - return get< - Promise<{ - chat_prompt_config: ChatPromptConfig - completion_prompt_config: CompletionPromptConfig - stop: [] - }> - >('/app/prompt-templates', { - params: { - app_mode: appMode, - model_mode: mode, - model_name: modelName, - has_context: hasSetDataSet, - }, - }) + return narrowResponse<{ + chat_prompt_config: ChatPromptConfig + completion_prompt_config: CompletionPromptConfig + stop: [] + }>( + consoleClient.app.promptTemplates.get({ + query: { + app_mode: appMode, + model_mode: mode, + model_name: modelName, + has_context: String(hasSetDataSet), + }, + }), + ) } export const fetchTextGenerationMessage = ({ @@ -123,5 +139,7 @@ export const fetchTextGenerationMessage = ({ appId: string messageId: string }) => { - return get>(`/apps/${appId}/messages/${messageId}`) + return consoleClient.apps.byAppId.messages.byMessageId.get({ + params: { app_id: appId, message_id: messageId }, + }) } diff --git a/web/service/plugins.ts b/web/service/plugins.ts index fdfcdb47d2c725..bb2f0d1184ff19 100644 --- a/web/service/plugins.ts +++ b/web/service/plugins.ts @@ -1,3 +1,4 @@ +import type { ParserMarketplaceUpgrade } from '@dify/contracts/api/console/workspaces/types.gen' import type { PluginInfoFromMarketPlace } from '@dify/contracts/marketplace' import type { Dependency, @@ -8,7 +9,8 @@ import type { uploadGitHubResponse, } from '@/app/components/plugins/types' import { consoleClient } from '@/service/console' -import { get, getMarketplace, post, upload } from './base' +import { getMarketplace, upload } from './base' +import { narrowResponse } from './console/narrow-response' export const uploadFile = async (file: File, isBundle: boolean) => { const formData = new FormData() @@ -23,10 +25,10 @@ export const uploadFile = async (file: File, isBundle: boolean) => { ) } -export const updateFromMarketPlace = async (body: Record) => { - return post('/workspaces/current/plugin/upgrade/marketplace', { - body, - }) +export const updateFromMarketPlace = async (body: ParserMarketplaceUpgrade) => { + return narrowResponse( + consoleClient.workspaces.current.plugin.upgrade.marketplace.post({ body }), + ) } export const updateFromGitHub = async ( @@ -36,15 +38,17 @@ export const updateFromGitHub = async ( originalPlugin: string, newPlugin: string, ) => { - return post('/workspaces/current/plugin/upgrade/github', { - body: { - repo: repoUrl, - version: selectedVersion, - package: selectedPackage, - original_plugin_unique_identifier: originalPlugin, - new_plugin_unique_identifier: newPlugin, - }, - }) + return narrowResponse( + consoleClient.workspaces.current.plugin.upgrade.github.post({ + body: { + repo: repoUrl, + version: selectedVersion, + package: selectedPackage, + original_plugin_unique_identifier: originalPlugin, + new_plugin_unique_identifier: newPlugin, + }, + }), + ) } export const uploadGitHub = async ( @@ -52,13 +56,15 @@ export const uploadGitHub = async ( selectedVersion: string, selectedPackage: string, ) => { - return post('/workspaces/current/plugin/upload/github', { - body: { - repo: repoUrl, - version: selectedVersion, - package: selectedPackage, - }, - }) + return narrowResponse( + consoleClient.workspaces.current.plugin.upload.github.post({ + body: { + repo: repoUrl, + version: selectedVersion, + package: selectedPackage, + }, + }), + ) } export const fetchManifestFromMarketPlace = async (uniqueIdentifier: string) => { @@ -84,7 +90,9 @@ export const fetchPluginInfoFromMarketPlace = async ({ org, name }: Record { - return get(`/workspaces/current/plugin/tasks/${taskId}`) + return narrowResponse( + consoleClient.workspaces.current.plugin.tasks.byTaskId.get({ params: { task_id: taskId } }), + ) } export const uninstallPlugin = async ( diff --git a/web/service/use-workflow.ts b/web/service/use-workflow.ts index cb8211823ee1b0..e3ef5538603a77 100644 --- a/web/service/use-workflow.ts +++ b/web/service/use-workflow.ts @@ -23,11 +23,13 @@ import type { WorkflowRunHistoryResponse, } from '@/types/workflow' import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { consoleQuery } from '@/service/console' +import { consoleClient, consoleQuery } from '@/service/console' +import { narrowResponse } from '@/service/console/narrow-response' import { AppModeEnum } from '@/types/app' -import { del, get, patch, post, put } from './base' +// oxlint-disable-next-line no-restricted-imports -- run history, config, version and publish hooks take prebuilt flow URLs +import { del, get, patch, post } from './base' import { useInvalid } from './use-base' -import { getFlowPrefix } from './utils' +import { callByFlowType } from './utils' import { appWorkflowQueryOptions, appWorkflowVersionsInfiniteQueryKey } from './workflow-queries' const NAME_SPACE = 'workflow' @@ -338,12 +340,24 @@ export const useLastRun = ( enabled, queryKey: [...useLastRunKey, flowType, flowId, nodeId], queryFn: async () => { - return get( - `${getFlowPrefix(flowType)}/${flowId}/workflows/draft/nodes/${nodeId}/last-run`, - {}, - { - silent: true, - }, + return narrowResponse( + callByFlowType(flowType, { + app: () => + consoleClient.apps.byAppId.workflows.draft.nodes.byNodeId.lastRun.get( + { params: { app_id: flowId, node_id: nodeId } }, + { context: { silent: true } }, + ), + ragPipeline: () => + consoleClient.rag.pipelines.byPipelineId.workflows.draft.nodes.byNodeId.lastRun.get( + { params: { pipeline_id: flowId, node_id: nodeId } }, + { context: { silent: true } }, + ), + snippet: () => + consoleClient.snippets.bySnippetId.workflows.draft.nodes.byNodeId.lastRun.get( + { params: { snippet_id: flowId, node_id: nodeId } }, + { context: { silent: true } }, + ), + }), ) }, retry: 0, @@ -364,9 +378,21 @@ export const useConversationVarValues = (flowType?: FlowType, flowId?: string) = enabled: !!flowId, queryKey: [NAME_SPACE, flowType, 'conversation var values', flowId], queryFn: async () => { - const { items } = (await get( - `${getFlowPrefix(flowType)}/${flowId}/workflows/draft/conversation-variables`, - )) as { items: VarInInspect[] } + if (!flowId) return [] + // RAG pipelines have no conversation variables. + const { items } = await narrowResponse<{ items: VarInInspect[] }>( + callByFlowType(flowType, { + app: () => + consoleClient.apps.byAppId.workflows.draft.conversationVariables.get({ + params: { app_id: flowId }, + }), + ragPipeline: () => Promise.resolve({ items: [] }), + snippet: () => + consoleClient.snippets.bySnippetId.workflows.draft.conversationVariables.get({ + params: { snippet_id: flowId }, + }), + }), + ) return items }, }) @@ -380,7 +406,20 @@ export const useResetConversationVar = (flowType: FlowType, flowId: string) => { return useMutation({ mutationKey: [NAME_SPACE, flowType, 'reset conversation var', flowId], mutationFn: async (varId: string) => { - return put(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/variables/${varId}/reset`) + return callByFlowType(flowType, { + app: () => + consoleClient.apps.byAppId.workflows.draft.variables.byVariableId.reset.put({ + params: { app_id: flowId, variable_id: varId }, + }), + ragPipeline: () => + consoleClient.rag.pipelines.byPipelineId.workflows.draft.variables.byVariableId.reset.put( + { params: { pipeline_id: flowId, variable_id: varId } }, + ), + snippet: () => + consoleClient.snippets.bySnippetId.workflows.draft.variables.byVariableId.reset.put({ + params: { snippet_id: flowId, variable_id: varId }, + }), + }) }, }) } @@ -388,8 +427,23 @@ export const useResetConversationVar = (flowType: FlowType, flowId: string) => { export const useResetToLastRunValue = (flowType: FlowType, flowId: string) => { return useMutation({ mutationKey: [NAME_SPACE, flowType, 'reset to last run value', flowId], - mutationFn: async (varId: string): Promise<{ value: any }> => { - return put(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/variables/${varId}/reset`) + mutationFn: async (varId: string) => { + return narrowResponse<{ value: unknown }>( + callByFlowType(flowType, { + app: () => + consoleClient.apps.byAppId.workflows.draft.variables.byVariableId.reset.put({ + params: { app_id: flowId, variable_id: varId }, + }), + ragPipeline: () => + consoleClient.rag.pipelines.byPipelineId.workflows.draft.variables.byVariableId.reset.put( + { params: { pipeline_id: flowId, variable_id: varId } }, + ), + snippet: () => + consoleClient.snippets.bySnippetId.workflows.draft.variables.byVariableId.reset.put({ + params: { snippet_id: flowId, variable_id: varId }, + }), + }), + ) }, }) } @@ -399,9 +453,23 @@ export const useSysVarValues = (flowType?: FlowType, flowId?: string) => { enabled: !!flowId, queryKey: [NAME_SPACE, flowType, 'sys var values', flowId], queryFn: async () => { - const { items } = (await get( - `${getFlowPrefix(flowType)}/${flowId}/workflows/draft/system-variables`, - )) as { items: VarInInspect[] } + if (!flowId) return [] + const { items } = await narrowResponse<{ items: VarInInspect[] }>( + callByFlowType(flowType, { + app: () => + consoleClient.apps.byAppId.workflows.draft.systemVariables.get({ + params: { app_id: flowId }, + }), + ragPipeline: () => + consoleClient.rag.pipelines.byPipelineId.workflows.draft.systemVariables.get({ + params: { pipeline_id: flowId }, + }), + snippet: () => + consoleClient.snippets.bySnippetId.workflows.draft.systemVariables.get({ + params: { snippet_id: flowId }, + }), + }), + ) return items }, }) @@ -415,7 +483,20 @@ export const useDeleteAllInspectorVars = (flowType: FlowType, flowId: string) => return useMutation({ mutationKey: [NAME_SPACE, flowType, 'delete all inspector vars', flowId], mutationFn: async () => { - return del(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/variables`) + return callByFlowType(flowType, { + app: () => + consoleClient.apps.byAppId.workflows.draft.variables.delete({ + params: { app_id: flowId }, + }), + ragPipeline: () => + consoleClient.rag.pipelines.byPipelineId.workflows.draft.variables.delete({ + params: { pipeline_id: flowId }, + }), + snippet: () => + consoleClient.snippets.bySnippetId.workflows.draft.variables.delete({ + params: { snippet_id: flowId }, + }), + }) }, }) } @@ -424,7 +505,20 @@ export const useDeleteNodeInspectorVars = (flowType: FlowType, flowId: string) = return useMutation({ mutationKey: [NAME_SPACE, flowType, 'delete node inspector vars', flowId], mutationFn: async (nodeId: string) => { - return del(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/nodes/${nodeId}/variables`) + return callByFlowType(flowType, { + app: () => + consoleClient.apps.byAppId.workflows.draft.nodes.byNodeId.variables.delete({ + params: { app_id: flowId, node_id: nodeId }, + }), + ragPipeline: () => + consoleClient.rag.pipelines.byPipelineId.workflows.draft.nodes.byNodeId.variables.delete({ + params: { pipeline_id: flowId, node_id: nodeId }, + }), + snippet: () => + consoleClient.snippets.bySnippetId.workflows.draft.nodes.byNodeId.variables.delete({ + params: { snippet_id: flowId, node_id: nodeId }, + }), + }) }, }) } @@ -433,7 +527,20 @@ export const useDeleteInspectVar = (flowType: FlowType, flowId: string) => { return useMutation({ mutationKey: [NAME_SPACE, flowType, 'delete inspector var', flowId], mutationFn: async (varId: string) => { - return del(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/variables/${varId}`) + return callByFlowType(flowType, { + app: () => + consoleClient.apps.byAppId.workflows.draft.variables.byVariableId.delete({ + params: { app_id: flowId, variable_id: varId }, + }), + ragPipeline: () => + consoleClient.rag.pipelines.byPipelineId.workflows.draft.variables.byVariableId.delete({ + params: { pipeline_id: flowId, variable_id: varId }, + }), + snippet: () => + consoleClient.snippets.bySnippetId.workflows.draft.variables.byVariableId.delete({ + params: { snippet_id: flowId, variable_id: varId }, + }), + }) }, }) } @@ -443,8 +550,22 @@ export const useEditInspectorVar = (flowType: FlowType, flowId: string) => { return useMutation({ mutationKey: [NAME_SPACE, flowType, 'edit inspector var', flowId], mutationFn: async ({ varId, ...rest }: { varId: string; name?: string; value?: any }) => { - return patch(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/variables/${varId}`, { - body: rest, + return callByFlowType(flowType, { + app: () => + consoleClient.apps.byAppId.workflows.draft.variables.byVariableId.patch({ + params: { app_id: flowId, variable_id: varId }, + body: rest, + }), + ragPipeline: () => + consoleClient.rag.pipelines.byPipelineId.workflows.draft.variables.byVariableId.patch({ + params: { pipeline_id: flowId, variable_id: varId }, + body: rest, + }), + snippet: () => + consoleClient.snippets.bySnippetId.workflows.draft.variables.byVariableId.patch({ + params: { snippet_id: flowId, variable_id: varId }, + body: rest, + }), }) }, }) @@ -460,13 +581,10 @@ export const useTestEmailSender = () => { inputs: Record }) => { const { appID, nodeID, deliveryID, inputs } = data - return post( - `/apps/${appID}/workflows/draft/human-input/nodes/${nodeID}/delivery-test`, + return consoleClient.apps.byAppId.workflows.draft.humanInput.nodes.byNodeId.deliveryTest.post( { - body: { - delivery_method_id: deliveryID, - inputs, - }, + params: { app_id: appID, node_id: nodeID }, + body: { delivery_method_id: deliveryID, inputs }, }, ) }, diff --git a/web/service/utils.ts b/web/service/utils.ts index 6677147c4588a7..a95ee895b91642 100644 --- a/web/service/utils.ts +++ b/web/service/utils.ts @@ -9,3 +9,20 @@ const flowPrefixMap = { export const getFlowPrefix = (type?: FlowType) => { return flowPrefixMap[type!] || flowPrefixMap[FlowType.appFlow] } + +type FlowTypeCalls = { + app: () => TApp + ragPipeline: () => TPipeline + snippet: () => TSnippet +} + +// Draft workflow endpoints exist per flow type with their own path params, so each branch keeps +// its own fully typed console client call. +export const callByFlowType = ( + type: FlowType | undefined, + calls: FlowTypeCalls, +): TApp | TPipeline | TSnippet => { + if (type === FlowType.ragPipeline) return calls.ragPipeline() + if (type === FlowType.snippet) return calls.snippet() + return calls.app() +} diff --git a/web/service/workflow.spec.ts b/web/service/workflow.spec.ts index 4388e1431710e2..a7a846e52b82d4 100644 --- a/web/service/workflow.spec.ts +++ b/web/service/workflow.spec.ts @@ -6,11 +6,13 @@ const mockPost = vi.hoisted(() => vi.fn()) vi.mock('./base', () => ({ get: vi.fn(), - post: mockPost, + post: vi.fn(), })) vi.mock('@/service/console', () => ({ - consoleClient: {}, + consoleClient: { + apps: { byAppId: { workflows: { draft: { environmentVariables: { post: mockPost } } } } }, + }, })) describe('updateEnvironmentVariables', () => { @@ -40,7 +42,8 @@ describe('updateEnvironmentVariables', () => { deletedEnvironmentVariableIds: ['env-2'], }) - expect(mockPost).toHaveBeenCalledWith('apps/app-1/workflows/draft/environment-variables', { + expect(mockPost).toHaveBeenCalledWith({ + params: { app_id: 'app-1' }, body: { environment_variables: environmentVariables, patch: true, diff --git a/web/service/workflow.ts b/web/service/workflow.ts index 4d39477709cb64..a134ddf628678d 100644 --- a/web/service/workflow.ts +++ b/web/service/workflow.ts @@ -15,7 +15,8 @@ import type { } from '@/types/workflow' import { consoleClient } from '@/service/console' import { get, post } from './base' -import { getFlowPrefix } from './utils' +import { narrowResponse } from './console/narrow-response' +import { callByFlowType, getFlowPrefix } from './utils' export type WorkflowDraftFeaturesPayload = WorkflowFeaturesConfigPayload @@ -59,10 +60,28 @@ export const singleNodeRun = ( flowType: FlowType, flowId: string, nodeId: string, - params: object, + params: { + inputs: Record + query?: string + files?: Record[] + }, ) => { - return post(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/nodes/${nodeId}/run`, { - body: params, + return callByFlowType(flowType, { + app: () => + consoleClient.apps.byAppId.workflows.draft.nodes.byNodeId.run.post({ + params: { app_id: flowId, node_id: nodeId }, + body: params, + }), + ragPipeline: () => + consoleClient.rag.pipelines.byPipelineId.workflows.draft.nodes.byNodeId.run.post({ + params: { pipeline_id: flowId, node_id: nodeId }, + body: params, + }), + snippet: () => + consoleClient.snippets.bySnippetId.workflows.draft.nodes.byNodeId.run.post({ + params: { snippet_id: flowId, node_id: nodeId }, + body: params, + }), }) } @@ -89,15 +108,19 @@ export const stopWorkflowRun = (url: string) => { } export const fetchNodeDefault = (appId: string, blockType: BlockEnum, query = {}) => { - return get(`apps/${appId}/workflows/default-workflow-block-configs/${blockType}`, { - params: { q: JSON.stringify(query) }, + return consoleClient.apps.byAppId.workflows.defaultWorkflowBlockConfigs.byBlockType.get({ + params: { app_id: appId, block_type: blockType }, + query: { q: JSON.stringify(query) }, }) } export const fetchPipelineNodeDefault = (pipelineId: string, blockType: BlockEnum, query = {}) => { - return get(`rag/pipelines/${pipelineId}/workflows/default-workflow-block-configs/${blockType}`, { - params: { q: JSON.stringify(query) }, - }) + return consoleClient.rag.pipelines.byPipelineId.workflows.defaultWorkflowBlockConfigs.byBlockType.get( + { + params: { pipeline_id: pipelineId, block_type: blockType }, + query: { q: JSON.stringify(query) }, + }, + ) } export const fetchCurrentValueOfConversationVariable = ({ @@ -115,9 +138,25 @@ const fetchAllInspectVarsOnePage = async ( flowId: string, page: number, ): Promise<{ total: number; items: VarInInspect[] }> => { - return get(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/variables`, { - params: { page, limit: 100 }, - }) + return narrowResponse<{ total: number; items: VarInInspect[] }>( + callByFlowType(flowType, { + app: () => + consoleClient.apps.byAppId.workflows.draft.variables.get({ + params: { app_id: flowId }, + query: { page, limit: 100 }, + }), + ragPipeline: () => + consoleClient.rag.pipelines.byPipelineId.workflows.draft.variables.get({ + params: { pipeline_id: flowId }, + query: { page, limit: 100 }, + }), + snippet: () => + consoleClient.snippets.bySnippetId.workflows.draft.variables.get({ + params: { snippet_id: flowId }, + query: { page, limit: 100 }, + }), + }), + ) } export const fetchAllInspectVars = async ( flowType: FlowType, @@ -144,9 +183,22 @@ export const fetchNodeInspectVars = async ( flowId: string, nodeId: string, ): Promise => { - const { items } = (await get( - `${getFlowPrefix(flowType)}/${flowId}/workflows/draft/nodes/${nodeId}/variables`, - )) as { items: VarInInspect[] } + const { items } = await narrowResponse<{ items: VarInInspect[] }>( + callByFlowType(flowType, { + app: () => + consoleClient.apps.byAppId.workflows.draft.nodes.byNodeId.variables.get({ + params: { app_id: flowId, node_id: nodeId }, + }), + ragPipeline: () => + consoleClient.rag.pipelines.byPipelineId.workflows.draft.nodes.byNodeId.variables.get({ + params: { pipeline_id: flowId, node_id: nodeId }, + }), + snippet: () => + consoleClient.snippets.bySnippetId.workflows.draft.nodes.byNodeId.variables.get({ + params: { snippet_id: flowId, node_id: nodeId }, + }), + }), + ) return items } @@ -157,7 +209,8 @@ export const updateEnvironmentVariables = ({ }: { appId: string } & EnvironmentVariablePatch) => { - return post(`apps/${appId}/workflows/draft/environment-variables`, { + return consoleClient.apps.byAppId.workflows.draft.environmentVariables.post({ + params: { app_id: appId }, body: { environment_variables: environmentVariables, patch: true, @@ -199,7 +252,10 @@ export const submitHumanInputForm = ( action: string }, ) => { - return post(`/form/human_input/${token}`, { body: data }) + return consoleClient.form.humanInput.byFormToken.post({ + params: { form_token: token }, + body: data, + }) } export const fetchHumanInputNodeStepRunForm = ( From 03fa068bf98252d4fb6ac56c80221b800745e229 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:10:44 +0800 Subject: [PATCH 23/60] fix: CI failures after migrating knowledge, plugin and tool calls - type the empty form_schema list in the code-based extension test (pyrefly) - e2e document list queries pass numeric page/limit, now typed by the contract - drop the unused legacy put helper and InitValidateStatusResponse type - route the auto-upgrade settings dialog spec through the console transport Co-Authored-By: Claude Opus 5.5 --- .../controllers/console/test_extension.py | 3 ++- e2e/features/agent-v2/support/fixtures/datasets.ts | 2 +- e2e/features/agent-v2/support/seed.ts | 2 +- .../__tests__/index.spec.tsx | 1 - .../__tests__/update-setting-dialog.spec.tsx | 14 ++++++++++++-- .../forgot-password/ForgotPasswordForm.spec.tsx | 5 ++--- web/app/install/installForm.spec.tsx | 3 +-- web/models/common.ts | 4 ---- web/service/base.spec.ts | 14 +------------- web/service/base.ts | 10 ---------- 10 files changed, 20 insertions(+), 38 deletions(-) diff --git a/api/tests/unit_tests/controllers/console/test_extension.py b/api/tests/unit_tests/controllers/console/test_extension.py index 5abfed64232c79..3890be82a23f1e 100644 --- a/api/tests/unit_tests/controllers/console/test_extension.py +++ b/api/tests/unit_tests/controllers/console/test_extension.py @@ -120,7 +120,8 @@ def extensions_service() -> Iterator[MagicMock]: def test_code_based_extension_get_returns_service_data(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - service_result = [{"name": "custom_tool", "label": {"en-US": "Custom tool"}, "form_schema": []}] + form_schema: list[dict[str, object]] = [] + service_result = [{"name": "custom_tool", "label": {"en-US": "Custom tool"}, "form_schema": form_schema}] service_mock = MagicMock(return_value=service_result) monkeypatch.setattr( "controllers.console.extension.CodeBasedExtensionService.get_code_based_extension", diff --git a/e2e/features/agent-v2/support/fixtures/datasets.ts b/e2e/features/agent-v2/support/fixtures/datasets.ts index 1c7ec293eada49..64145f5e08610b 100644 --- a/e2e/features/agent-v2/support/fixtures/datasets.ts +++ b/e2e/features/agent-v2/support/fixtures/datasets.ts @@ -29,7 +29,7 @@ const getDatasetDocuments = async (client: ConsoleClient, datasetId: string) => while (hasMore) { const response = await client.datasets.byDatasetId.documents.get({ params: { dataset_id: datasetId }, - query: { limit: '100', page: String(page) }, + query: { limit: 100, page }, }) documents.push(...response.data) diff --git a/e2e/features/agent-v2/support/seed.ts b/e2e/features/agent-v2/support/seed.ts index 22d24f1babe740..25a9be453ba7e3 100644 --- a/e2e/features/agent-v2/support/seed.ts +++ b/e2e/features/agent-v2/support/seed.ts @@ -423,7 +423,7 @@ const datasetHasKnowledgeSegment = async ( ) => { const documents = await client.datasets.byDatasetId.documents.get({ params: { dataset_id: datasetId }, - query: { limit: '100', page: '1' }, + query: { limit: 100, page: 1 }, }) for (const document of documents.data) { const body = await client.datasets.byDatasetId.documents.byDocumentId.segments.get({ diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx index 43041781fa32f2..31a540a111600b 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx @@ -99,7 +99,6 @@ vi.mock('@/service/base', () => ({ get: vi.fn(() => Promise.resolve({ data: {} })), del: vi.fn(() => Promise.resolve({ data: {} })), patch: vi.fn(() => Promise.resolve({ data: {} })), - put: vi.fn(() => Promise.resolve({ data: {} })), })) vi.mock('@/service/fetch', () => ({ diff --git a/web/app/components/header/account-setting/__tests__/update-setting-dialog.spec.tsx b/web/app/components/header/account-setting/__tests__/update-setting-dialog.spec.tsx index 64582efcaee0cc..40400ed8e2fc4d 100644 --- a/web/app/components/header/account-setting/__tests__/update-setting-dialog.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/update-setting-dialog.spec.tsx @@ -17,10 +17,20 @@ const { getSettings, saveSettings } = vi.hoisted(() => ({ saveSettings: vi.fn(), })) +// The console client sends through `request`; adapt it back to path + params/body calls. vi.mock('@/service/base', async (importOriginal) => ({ ...(await importOriginal()), - get: getSettings, - post: saveSettings, + request: async (url: string, _init: RequestInit, options: { request?: Request }) => { + const { pathname, searchParams } = new URL(url) + const path = pathname.replace('/console/api', '') + const data = + options.request?.method === 'POST' + ? await saveSettings(path, { body: await options.request.json() }) + : await getSettings(path, { params: Object.fromEntries(searchParams) }) + return new Response(JSON.stringify(data ?? {}), { + headers: { 'Content-Type': 'application/json' }, + }) + }, })) const savedConfig: AutoUpdateConfig = { diff --git a/web/app/forgot-password/ForgotPasswordForm.spec.tsx b/web/app/forgot-password/ForgotPasswordForm.spec.tsx index 6ccb45282804ff..4968ca03298182 100644 --- a/web/app/forgot-password/ForgotPasswordForm.spec.tsx +++ b/web/app/forgot-password/ForgotPasswordForm.spec.tsx @@ -1,4 +1,3 @@ -import type { InitValidateStatusResponse } from '@/models/common' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import useDocumentTitle from '@/hooks/use-document-title' @@ -34,7 +33,7 @@ const prepareLoadedState = () => { mockFetchSetupStatus.mockResolvedValue({ step: 'not_started' }) mockFetchInitValidateStatus.mockResolvedValue({ status: 'finished', - } as InitValidateStatusResponse) + }) } describe('ForgotPasswordForm', () => { @@ -180,7 +179,7 @@ describe('ForgotPasswordForm', () => { }) mockFetchInitValidateStatus.mockResolvedValue({ status: 'not_started', - } as InitValidateStatusResponse) + }) render() diff --git a/web/app/install/installForm.spec.tsx b/web/app/install/installForm.spec.tsx index bad68f2d765e2d..c7a50ad41f5578 100644 --- a/web/app/install/installForm.spec.tsx +++ b/web/app/install/installForm.spec.tsx @@ -1,5 +1,4 @@ import type { ReactElement } from 'react' -import type { InitValidateStatusResponse } from '@/models/common' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { consoleClient } from '@/service/console' @@ -46,7 +45,7 @@ const prepareLoadedState = () => { mockFetchSetupStatus.mockResolvedValue({ step: 'not_started' }) mockFetchInitValidateStatus.mockResolvedValue({ status: 'finished', - } as InitValidateStatusResponse) + }) } describe('InstallForm', () => { diff --git a/web/models/common.ts b/web/models/common.ts index 21cd7204e15f2d..f79aa12db55dca 100644 --- a/web/models/common.ts +++ b/web/models/common.ts @@ -7,10 +7,6 @@ export type CommonResponse = { result: 'success' | 'fail' } -export type InitValidateStatusResponse = { - status: 'finished' | 'not_started' -} - export type Member = AccountWithRoleResponse export type DataSourceNotionPage = { diff --git a/web/service/base.spec.ts b/web/service/base.spec.ts index 586e4fcb9f4c75..d2a80e994c10d7 100644 --- a/web/service/base.spec.ts +++ b/web/service/base.spec.ts @@ -3,18 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { toast } from '@/app/notifications' import { PUBLIC_API_PREFIX } from '@/config' // oxlint-disable-next-line no-restricted-imports -import { - del, - get, - handleStream, - patch, - post, - postPublic, - put, - sseGet, - ssePost, - upload, -} from './base' +import { del, get, handleStream, patch, post, postPublic, sseGet, ssePost, upload } from './base' const refreshAccessTokenOrReLoginMock = vi.hoisted(() => vi.fn()) @@ -754,7 +743,6 @@ describe('HTTP methods', () => { it('should export methods correctly', () => { expect(typeof get).toBe('function') expect(typeof post).toBe('function') - expect(typeof put).toBe('function') expect(typeof patch).toBe('function') expect(typeof del).toBe('function') }) diff --git a/web/service/base.ts b/web/service/base.ts index 5133179383d6ad..23a0cffd5bd9fd 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -1196,16 +1196,6 @@ export const postPublic = (url: string, options = {}, otherOptions?: IOtherOp return post(url, options, { ...otherOptions, isPublicAPI: true }) } -/** - * @deprecated For console JSON APIs, prefer generated contract clients (`consoleClient`/`consoleQuery`) - * only after the backend OpenAPI schema produces accurate method, path, input, and output types. - * Keep this helper for endpoints whose generated contract is missing or too loose, and for non-console - * flows such as public APIs, marketplace APIs, streaming, upload, or download. - */ -export const put = (url: string, options = {}, otherOptions?: IOtherOptions) => { - return request(url, Object.assign({}, options, { method: 'PUT' }), otherOptions) -} - /** * @deprecated For console JSON APIs, prefer generated contract clients (`consoleClient`/`consoleQuery`) * only after the backend OpenAPI schema produces accurate method, path, input, and output types. From 68002efd6a8dee47aceca67a1755f874706f0057 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:11:54 +0800 Subject: [PATCH 24/60] test(api): build the same-named schema model with explicit fields Co-Authored-By: Claude Opus 5.5 --- .../unit_tests/controllers/common/test_schema.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/api/tests/unit_tests/controllers/common/test_schema.py b/api/tests/unit_tests/controllers/common/test_schema.py index 1e2302cf29c91d..3af43405d7e326 100644 --- a/api/tests/unit_tests/controllers/common/test_schema.py +++ b/api/tests/unit_tests/controllers/common/test_schema.py @@ -127,14 +127,10 @@ def test_register_schema_model_promotes_nested_pydantic_definitions(): assert called_schemas["ChildModel"] == parent_schema["$defs"]["ChildModel"] -def _make_same_named_model(fields: dict[str, type]) -> type[BaseModel]: +def _make_same_named_model() -> type[BaseModel]: """Build a model whose qualified name differs from UserModel but whose bare name matches.""" - return create_model( - "UserModel", - __module__="other_module", - **{name: (annotation, ...) for name, annotation in fields.items()}, - ) + return create_model("UserModel", __module__="other_module", email=(str, ...)) def test_register_schema_model_allows_reregistering_the_same_model(): @@ -155,7 +151,7 @@ def test_register_schema_model_rejects_different_model_with_same_name(): register_schema_model(namespace, UserModel) with pytest.raises(SchemaNameCollisionError, match="'UserModel'.*produced by both"): - register_schema_model(namespace, _make_same_named_model({"email": str})) + register_schema_model(namespace, _make_same_named_model()) def test_register_schema_model_rejects_nested_definition_with_same_name(): @@ -208,7 +204,7 @@ def test_register_schema_model_keeps_namespaces_independent(): from controllers.common.schema import register_schema_model register_schema_model(Namespace("first"), UserModel) - register_schema_model(Namespace("second"), _make_same_named_model({"email": str})) + register_schema_model(Namespace("second"), _make_same_named_model()) def test_register_schema_model_applies_schema_name_overrides(monkeypatch: pytest.MonkeyPatch): From da0d1bbe87e383b136376f11943e73c6af3993c0 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:22:55 +0800 Subject: [PATCH 25/60] refactor(web): use generated datasource auth options directly Remove the forwarding hooks in service/use-datasource. Consumers call consoleQuery.auth.plugin.datasource list, defaultList and byProviderId queryOptions and the OAuth authorization URL mutationOptions directly, adapt responses to the data source UI types in select, and invalidate generated keys inline. The shared retry policy moves to query-policies. Co-Authored-By: Claude Opus 5.5 --- oxlint-suppressions.json | 5 -- .../__tests__/layout-main.spec.tsx | 17 ++-- .../datasets/create/__tests__/index.spec.tsx | 17 ++-- web/app/components/datasets/create/index.tsx | 11 ++- .../online-documents/__tests__/index.spec.tsx | 18 +++- .../data-source/online-documents/index.tsx | 14 +-- .../online-drive/__tests__/index.spec.tsx | 18 +++- .../data-source/online-drive/index.tsx | 14 +-- .../website-crawl/__tests__/index.spec.tsx | 18 +++- .../data-source/website-crawl/index.tsx | 14 +-- .../account-setting/__tests__/index.spec.tsx | 5 +- .../__tests__/card.spec.tsx | 40 ++++----- .../__tests__/index.spec.tsx | 45 +++++----- .../data-source-page-new/card.tsx | 12 ++- .../use-data-source-auth-update.spec.ts | 24 +++--- .../hooks/use-data-source-auth-update.ts | 32 +++---- .../data-source-page-new/index.tsx | 15 ++-- .../data-source-page-new/utils.ts | 13 +++ .../__tests__/use-refresh-plugin-list.spec.ts | 10 +-- .../hooks/use-refresh-plugin-list.tsx | 7 +- web/service/console/query-policies.ts | 9 ++ web/service/use-datasource.ts | 86 ------------------- 22 files changed, 211 insertions(+), 233 deletions(-) create mode 100644 web/app/components/header/account-setting/data-source-page-new/utils.ts delete mode 100644 web/service/use-datasource.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 1790cc4198a2f0..e958edee52fd0b 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4132,11 +4132,6 @@ "count": 1 } }, - "web/service/use-datasource.ts": { - "@tanstack/query/prefer-query-options": { - "count": 3 - } - }, "web/service/use-flow.ts": { "eslint-react/no-unnecessary-use-prefix": { "count": 1 diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx index 14f417ca6ab8d2..6896f16bc1749e 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx @@ -35,13 +35,16 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useDefaultModel: () => ({ data: undefined }), })) -vi.mock('@/service/use-datasource', () => ({ - useGetDefaultDataSourceListAuth: () => ({ - data: { result: [] }, - isLoading: false, - isError: false, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'auth.plugin.datasource.defaultList.get': () => ({ + data: { result: [] }, + isLoading: false, + isError: false, + }), + }) +}) vi.mock('@/app/components/datasets/create/step-one', () => ({ default: () =>
Create knowledge content
, diff --git a/web/app/components/datasets/create/__tests__/index.spec.tsx b/web/app/components/datasets/create/__tests__/index.spec.tsx index 6a80a5eb1f66a3..7d5d6fe3e684b1 100644 --- a/web/app/components/datasets/create/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/__tests__/index.spec.tsx @@ -114,13 +114,16 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () let mockDataSourceList: { result: DataSourceAuth[] } | undefined let mockIsLoadingDataSourceList = false let mockFetchingError = false -vi.mock('@/service/use-datasource', () => ({ - useGetDefaultDataSourceListAuth: () => ({ - data: mockDataSourceList, - isLoading: mockIsLoadingDataSourceList, - isError: mockFetchingError, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'auth.plugin.datasource.defaultList.get': () => ({ + data: mockDataSourceList, + isLoading: mockIsLoadingDataSourceList, + isError: mockFetchingError, + }), + }) +}) // Track props passed to child components let stepOneProps = {} as StepOneProps diff --git a/web/app/components/datasets/create/index.tsx b/web/app/components/datasets/create/index.tsx index f3605fa11da78d..4a6dc78e28d361 100644 --- a/web/app/components/datasets/create/index.tsx +++ b/web/app/components/datasets/create/index.tsx @@ -8,7 +8,7 @@ import type { FileItem, } from '@/models/datasets' import type { RETRIEVE_METHOD } from '@/types/app' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { produce } from 'immer' import { useAtomValue } from 'jotai' import { useQueryState } from 'nuqs' @@ -16,6 +16,7 @@ import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' +import { selectDataSourceAuthList } from '@/app/components/header/account-setting/data-source-page-new/utils' import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { settingsQueryParamName, @@ -30,7 +31,7 @@ import { userProfileQueryOptions } from '@/features/account-profile/client' import { DataSourceProvider } from '@/models/common' import { DataSourceType } from '@/models/datasets' import { useRouter } from '@/next/navigation' -import { useGetDefaultDataSourceListAuth } from '@/service/use-datasource' +import { consoleQuery } from '@/service/console' import { getDatasetACLCapabilities } from '@/utils/permission' import AppUnavailable from '../../base/app-unavailable' import { ModelTypeEnum } from '../../header/account-setting/model-provider-page/declarations' @@ -94,7 +95,11 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => { data: dataSourceList, isLoading: isLoadingAuthedDataSourceList, isError: fetchingAuthedDataSourceListError, - } = useGetDefaultDataSourceListAuth() + } = useQuery( + consoleQuery.auth.plugin.datasource.defaultList.get.queryOptions({ + select: selectDataSourceAuthList, + }), + ) const updateNotionPages = useCallback((value: NotionPage[]) => { setNotionPages(value) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx index f0636b4b83889c..18f7d47f853a0a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx @@ -54,9 +54,21 @@ const { mockUseGetDataSourceAuth } = vi.hoisted(() => ({ mockUseGetDataSourceAuth: vi.fn(), })) -vi.mock('@/service/use-datasource', () => ({ - useGetDataSourceAuth: mockUseGetDataSourceAuth, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + // The credential query is keyed by `${pluginId}/${provider}`; report it as the two ids. + 'auth.plugin.datasource.byProviderId.get': (options: { queryKey: unknown[] }) => { + const { input } = options.queryKey[1] as { input: { params: { provider_id: string } } } + const providerId = input.params.provider_id + const separator = providerId.lastIndexOf('/') + return mockUseGetDataSourceAuth({ + pluginId: providerId.slice(0, separator), + provider: providerId.slice(separator + 1), + }) + }, + }) +}) // Note: zustand/react/shallow useShallow is imported directly (simple utility function) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx index 937bfcfd918dbf..200f65f26b0222 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx @@ -1,11 +1,13 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' import type { DataSourceNotionPageMap, DataSourceNotionWorkspace } from '@/models/common' import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } from '@/types/pipeline' +import { useQuery } from '@tanstack/react-query' import { useQueryState } from 'nuqs' import { useCallback, useEffect, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import SearchInput from '@/app/components/base/notion-page-selector/search-input' +import { selectDataSourceCredentials } from '@/app/components/header/account-setting/data-source-page-new/utils' import { settingsQueryParamName, settingsQueryParser, @@ -15,7 +17,7 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useDocLink } from '@/context/i18n' import { DatasourceType } from '@/models/pipeline' import { ssePost } from '@/service/base' -import { useGetDataSourceAuth } from '@/service/use-datasource' +import { consoleQuery } from '@/service/console' import Header from '../base/header' import { useDataSourceStore, useDataSourceStoreWithSelector } from '../store' import PageSelector from './page-selector' @@ -49,10 +51,12 @@ const OnlineDocuments = ({ })), ) - const { data: dataSourceAuth } = useGetDataSourceAuth({ - pluginId: nodeData.plugin_id, - provider: nodeData.provider_name, - }) + const { data: dataSourceAuth } = useQuery( + consoleQuery.auth.plugin.datasource.byProviderId.get.queryOptions({ + input: { params: { provider_id: `${nodeData.plugin_id}/${nodeData.provider_name}` } }, + select: selectDataSourceCredentials, + }), + ) const dataSourceStore = useDataSourceStore() diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx index 38f8efbad42222..7d7aeac3602658 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx @@ -57,9 +57,21 @@ vi.mock('@/service/base', () => ({ ssePost: (...args: unknown[]) => mocks.ssePost(...args), })) -vi.mock('@/service/use-datasource', () => ({ - useGetDataSourceAuth: (...args: unknown[]) => mocks.useGetDataSourceAuth(...args), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + // The credential query is keyed by `${pluginId}/${provider}`; report it as the two ids. + 'auth.plugin.datasource.byProviderId.get': (options: { queryKey: unknown[] }) => { + const { input } = options.queryKey[1] as { input: { params: { provider_id: string } } } + const providerId = input.params.provider_id + const separator = providerId.lastIndexOf('/') + return mocks.useGetDataSourceAuth({ + pluginId: providerId.slice(0, separator), + provider: providerId.slice(separator + 1), + }) + }, + }) +}) vi.mock('@/app/notifications', async (importOriginal) => { const actual = await importOriginal() diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx index 9652ba65e41c3a..ecd0fa5915b10c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx @@ -1,10 +1,12 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' import type { OnlineDriveFile } from '@/models/pipeline' import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } from '@/types/pipeline' +import { useQuery } from '@tanstack/react-query' import { produce } from 'immer' import { useQueryState } from 'nuqs' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' +import { selectDataSourceCredentials } from '@/app/components/header/account-setting/data-source-page-new/utils' import { settingsQueryParamName, settingsQueryParser, @@ -14,7 +16,7 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useDocLink } from '@/context/i18n' import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline' import { ssePost } from '@/service/base' -import { useGetDataSourceAuth } from '@/service/use-datasource' +import { consoleQuery } from '@/service/console' import Header from '../base/header' import { useDataSourceStore, useDataSourceStoreWithSelector } from '../store' import FileList from './file-list' @@ -64,10 +66,12 @@ const OnlineDrive = ({ const [isLoading, setIsLoading] = useState(false) const isLoadingRef = useRef(false) - const { data: dataSourceAuth } = useGetDataSourceAuth({ - pluginId: nodeData.plugin_id, - provider: nodeData.provider_name, - }) + const { data: dataSourceAuth } = useQuery( + consoleQuery.auth.plugin.datasource.byProviderId.get.queryOptions({ + input: { params: { provider_id: `${nodeData.plugin_id}/${nodeData.provider_name}` } }, + select: selectDataSourceCredentials, + }), + ) const datasourceNodeRunURL = !isInPipeline ? `/rag/pipelines/${pipelineId}/workflows/published/datasource/nodes/${nodeId}/run` diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx index b0c26ac25c9631..f15eabec29bf08 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx @@ -39,9 +39,21 @@ const { mockUseGetDataSourceAuth } = vi.hoisted(() => ({ mockUseGetDataSourceAuth: vi.fn(), })) -vi.mock('@/service/use-datasource', () => ({ - useGetDataSourceAuth: mockUseGetDataSourceAuth, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + // The credential query is keyed by `${pluginId}/${provider}`; report it as the two ids. + 'auth.plugin.datasource.byProviderId.get': (options: { queryKey: unknown[] }) => { + const { input } = options.queryKey[1] as { input: { params: { provider_id: string } } } + const providerId = input.params.provider_id + const separator = providerId.lastIndexOf('/') + return mockUseGetDataSourceAuth({ + pluginId: providerId.slice(0, separator), + provider: providerId.slice(separator + 1), + }) + }, + }) +}) // Mock usePipeline hooks - API service hooks require mocking const { mockUseDraftPipelinePreProcessingParams, mockUsePublishedPipelinePreProcessingParams } = diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx index 7cf4a033154593..86d6a5444c0af5 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx @@ -6,11 +6,13 @@ import type { DataSourceNodeErrorResponse, DataSourceNodeProcessingResponse, } from '@/types/pipeline' +import { useQuery } from '@tanstack/react-query' import { useQueryState } from 'nuqs' import * as React from 'react' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' +import { selectDataSourceCredentials } from '@/app/components/header/account-setting/data-source-page-new/utils' import { settingsQueryParamName, settingsQueryParser, @@ -20,7 +22,7 @@ import { useDocLink } from '@/context/i18n' import { CrawlStep } from '@/models/datasets' import { DatasourceType } from '@/models/pipeline' import { ssePost } from '@/service/base' -import { useGetDataSourceAuth } from '@/service/use-datasource' +import { consoleQuery } from '@/service/console' import { useDraftPipelinePreProcessingParams, usePublishedPipelinePreProcessingParams, @@ -67,10 +69,12 @@ const WebsiteCrawl = ({ })), ) - const { data: dataSourceAuth } = useGetDataSourceAuth({ - pluginId: nodeData.plugin_id, - provider: nodeData.provider_name, - }) + const { data: dataSourceAuth } = useQuery( + consoleQuery.auth.plugin.datasource.byProviderId.get.queryOptions({ + input: { params: { provider_id: `${nodeData.plugin_id}/${nodeData.provider_name}` } }, + select: selectDataSourceCredentials, + }), + ) const dataSourceStore = useDataSourceStore() diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx index 0b1194780afe42..f4f8e222f58995 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -13,6 +13,7 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { 'workspaces.current.members.get': () => ({ data: { accounts: [] }, refetch: vi.fn() }), + 'auth.plugin.datasource.list.get': () => ({ data: { result: [] } }), }) }) @@ -78,10 +79,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({ ), })) -vi.mock('@/service/use-datasource', () => ({ - useGetDataSourceListAuth: vi.fn(() => ({ data: { result: [] } })), -})) - vi.mock('@/service/use-common', async (importOriginal) => { const actual = await importOriginal() return { diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx index 908027a08f2fa7..951153eaf7a10c 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx @@ -7,12 +7,6 @@ import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' import { CollectionType } from '@/app/components/tools/types' import { useRenderI18nObject } from '@/hooks/use-i18n' import { openOAuthPopup } from '@/hooks/use-oauth' -import { - useGetDataSourceOAuthUrl, - useInvalidDataSourceAuth, - useInvalidDataSourceListAuth, - useInvalidDefaultDataSourceListAuth, -} from '@/service/use-datasource' import { render } from '@/test/console/render' import Card from '../card' import { useDataSourceAuthUpdate } from '../hooks' @@ -99,23 +93,25 @@ vi.mock('@/hooks/use-oauth', () => ({ openOAuthPopup: vi.fn(), })) -vi.mock('@/service/use-datasource', () => ({ - useGetDataSourceOAuthUrl: vi.fn(), - useInvalidDataSourceAuth: vi.fn(() => vi.fn()), - useInvalidDataSourceListAuth: vi.fn(() => vi.fn()), - useInvalidDefaultDataSourceListAuth: vi.fn(() => vi.fn()), -})) +const { mockGetPluginOAuthUrl } = vi.hoisted(() => ({ mockGetPluginOAuthUrl: vi.fn() })) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'oauth.plugin.byProviderId.datasource.getAuthorizationUrl.get': () => ({ + mutateAsync: mockGetPluginOAuthUrl, + }), + }) +}) vi.mock('../hooks', () => ({ useDataSourceAuthUpdate: vi.fn(), })) type UsePluginAuthActionReturn = ReturnType -type UseGetDataSourceOAuthUrlReturn = ReturnType type UseRenderI18nObjectReturn = ReturnType describe('Card Component', () => { - const mockGetPluginOAuthUrl = vi.fn() const mockRenderI18nObjectResult = vi.fn((obj: Record) => obj.en_US) const mockInvalidateDataSourceListAuth = vi.fn() const mockInvalidDefaultDataSourceListAuth = vi.fn() @@ -181,19 +177,11 @@ describe('Card Component', () => { mockPluginAuthActionReturn = createMockPluginAuthActionReturn() vi.mocked(useDataSourceAuthUpdate).mockReturnValue({ handleAuthUpdate: mockHandleAuthUpdate }) - vi.mocked(useInvalidDataSourceListAuth).mockReturnValue(mockInvalidateDataSourceListAuth) - vi.mocked(useInvalidDefaultDataSourceListAuth).mockReturnValue( - mockInvalidDefaultDataSourceListAuth, - ) - vi.mocked(useInvalidDataSourceAuth).mockReturnValue(mockInvalidateDataSourceAuth) vi.mocked(usePluginAuthAction).mockReturnValue(mockPluginAuthActionReturn) vi.mocked(useRenderI18nObject).mockReturnValue( mockRenderI18nObjectResult as unknown as UseRenderI18nObjectReturn, ) - vi.mocked(useGetDataSourceOAuthUrl).mockReturnValue({ - mutateAsync: mockGetPluginOAuthUrl, - } as unknown as UseGetDataSourceOAuthUrlReturn) }) const expectAuthUpdated = () => { @@ -341,7 +329,9 @@ describe('Card Component', () => { // Assert await waitFor(() => { - expect(mockGetPluginOAuthUrl).toHaveBeenCalledWith('c1') + expect(mockGetPluginOAuthUrl).toHaveBeenCalledWith( + expect.objectContaining({ query: { credential_id: 'c1' } }), + ) expect(openOAuthPopup).toHaveBeenCalledWith('https://oauth.url', mockHandleAuthUpdate) }) }) @@ -366,7 +356,9 @@ describe('Card Component', () => { // Assert await waitFor(() => { - expect(mockGetPluginOAuthUrl).toHaveBeenCalledWith('c1') + expect(mockGetPluginOAuthUrl).toHaveBeenCalledWith( + expect.objectContaining({ query: { credential_id: 'c1' } }), + ) }) expect(openOAuthPopup).not.toHaveBeenCalled() }) diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx index 865b2c8c9af71a..916b422ce0e3d6 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx @@ -9,11 +9,6 @@ import { usePluginAuthAction } from '@/app/components/plugins/plugin-auth' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { useRenderI18nObject } from '@/hooks/use-i18n' import { consoleQuery } from '@/service/console' -import { - useGetDataSourceListAuth, - useGetDataSourceOAuthUrl, - useInvalidDataSourceListAuth, -} from '@/service/use-datasource' import { useCheckInstalled, useInstalledPluginList, @@ -37,11 +32,17 @@ vi.mock('@/hooks/use-i18n', () => ({ useRenderI18nObject: vi.fn(), })) -vi.mock('@/service/use-datasource', () => ({ - useGetDataSourceListAuth: vi.fn(), - useGetDataSourceOAuthUrl: vi.fn(), - useInvalidDataSourceListAuth: vi.fn(), -})) +const { mockDataSourceListAuth } = vi.hoisted(() => ({ mockDataSourceListAuth: vi.fn() })) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'auth.plugin.datasource.list.get': () => mockDataSourceListAuth(), + 'oauth.plugin.byProviderId.datasource.getAuthorizationUrl.get': () => ({ + mutateAsync: vi.fn(), + }), + }) +}) vi.mock('@/service/use-plugins', () => ({ useCheckInstalled: vi.fn(), @@ -180,10 +181,6 @@ describe('DataSourcePage Component', () => { typeof useTheme >) vi.mocked(useRenderI18nObject).mockReturnValue((obj) => obj?.en_US || '') - vi.mocked(useGetDataSourceOAuthUrl).mockReturnValue({ - mutateAsync: vi.fn(), - } as unknown as ReturnType) - vi.mocked(useInvalidDataSourceListAuth).mockReturnValue(vi.fn()) vi.mocked(useInstalledPluginList).mockReturnValue({ data: { plugins: [], total: 0 }, } as unknown as ReturnType) @@ -217,7 +214,7 @@ describe('DataSourcePage Component', () => { describe('Initial View Rendering', () => { it('should render an empty view when no data is available and marketplace is disabled', () => { // Arrange - vi.mocked(useGetDataSourceListAuth).mockReturnValue({ + mockDataSourceListAuth.mockReturnValue({ data: { result: [] }, isLoading: false, } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) @@ -250,7 +247,7 @@ describe('DataSourcePage Component', () => { it('should show data source placeholders while the list is loading', () => { // Arrange - vi.mocked(useGetDataSourceListAuth).mockReturnValue({ + mockDataSourceListAuth.mockReturnValue({ data: undefined, isLoading: true, } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) @@ -272,7 +269,7 @@ describe('DataSourcePage Component', () => { describe('Data Source List Rendering', () => { it('should render Card components for each data source returned from the API', () => { // Arrange - vi.mocked(useGetDataSourceListAuth).mockReturnValue({ + mockDataSourceListAuth.mockReturnValue({ data: { result: mockProviders }, } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) @@ -289,7 +286,7 @@ describe('DataSourcePage Component', () => { it('should map installed plugin operations onto the data source card header', () => { // Arrange - vi.mocked(useGetDataSourceListAuth).mockReturnValue({ + mockDataSourceListAuth.mockReturnValue({ data: { result: mockProviders }, } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) vi.mocked(useInstalledPluginList).mockReturnValue({ @@ -315,7 +312,7 @@ describe('DataSourcePage Component', () => { it('should filter installed data sources and pass search text to marketplace', () => { // Arrange - vi.mocked(useGetDataSourceListAuth).mockReturnValue({ + mockDataSourceListAuth.mockReturnValue({ data: { result: mockProviders }, } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) @@ -334,7 +331,7 @@ describe('DataSourcePage Component', () => { }) it('announces the filtered installed data source count while searching', async () => { - vi.mocked(useGetDataSourceListAuth).mockReturnValue({ + mockDataSourceListAuth.mockReturnValue({ data: { result: mockProviders }, isLoading: false, } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) @@ -359,7 +356,7 @@ describe('DataSourcePage Component', () => { describe('Marketplace Integration', () => { it('should render the InstallFromMarketplace component when enable_marketplace feature is enabled', () => { // Arrange - vi.mocked(useGetDataSourceListAuth).mockReturnValue({ + mockDataSourceListAuth.mockReturnValue({ data: { result: mockProviders }, } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) @@ -375,7 +372,7 @@ describe('DataSourcePage Component', () => { it('should pass an empty array to InstallFromMarketplace if data result is missing but marketplace is enabled', () => { // Arrange - vi.mocked(useGetDataSourceListAuth).mockReturnValue({ + mockDataSourceListAuth.mockReturnValue({ data: undefined, } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) @@ -390,7 +387,7 @@ describe('DataSourcePage Component', () => { it('should handle the case where data exists but result is an empty array', () => { // Arrange - vi.mocked(useGetDataSourceListAuth).mockReturnValue({ + mockDataSourceListAuth.mockReturnValue({ data: { result: [] }, } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) @@ -406,7 +403,7 @@ describe('DataSourcePage Component', () => { it('should handle the case where enable_marketplace is false (edge case for coverage)', () => { // Arrange - vi.mocked(useGetDataSourceListAuth).mockReturnValue({ + mockDataSourceListAuth.mockReturnValue({ data: { result: [] }, } as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>) diff --git a/web/app/components/header/account-setting/data-source-page-new/card.tsx b/web/app/components/header/account-setting/data-source-page-new/card.tsx index 6352515e4aa937..953b690a7df9c3 100644 --- a/web/app/components/header/account-setting/data-source-page-new/card.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/card.tsx @@ -8,6 +8,7 @@ import { AlertDialogFooter, AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' +import { useMutation } from '@tanstack/react-query' import { memo, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' @@ -17,7 +18,7 @@ import { CollectionType } from '@/app/components/tools/types' import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import { useRenderI18nObject } from '@/hooks/use-i18n' import { openOAuthPopup } from '@/hooks/use-oauth' -import { useGetDataSourceOAuthUrl } from '@/service/use-datasource' +import { consoleQuery } from '@/service/console' import Configure from './configure' import { useDataSourceAuthUpdate } from './hooks' import Item from './item' @@ -64,9 +65,14 @@ const Card = ({ item, disabled, pluginDetail, onPluginUpdate }: CardProps) => { pendingOperationCredentialId, } = usePluginAuthAction(pluginPayload, handleAuthUpdate) const changeCredentialIdRef = useRef(undefined) - const { mutateAsync: getPluginOAuthUrl } = useGetDataSourceOAuthUrl(pluginPayload.provider) + const { mutateAsync: getPluginOAuthUrl } = useMutation( + consoleQuery.oauth.plugin.byProviderId.datasource.getAuthorizationUrl.get.mutationOptions(), + ) const handleOAuth = useCallback(async () => { - const { authorization_url } = await getPluginOAuthUrl(changeCredentialIdRef.current) + const { authorization_url } = await getPluginOAuthUrl({ + params: { provider_id: pluginPayload.provider }, + query: { credential_id: changeCredentialIdRef.current }, + }) if (authorization_url) { openOAuthPopup(authorization_url, handleAuthUpdate) diff --git a/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-data-source-auth-update.spec.ts b/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-data-source-auth-update.spec.ts index ada8014aebbaf9..d3a298528d5a9d 100644 --- a/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-data-source-auth-update.spec.ts +++ b/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-data-source-auth-update.spec.ts @@ -6,17 +6,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { consoleQuery } from '@/service/console' import { useDataSourceAuthUpdate } from '../use-data-source-auth-update' -const mocks = vi.hoisted(() => ({ auth: vi.fn(), list: vi.fn(), defaults: vi.fn() })) -vi.mock('@/service/use-datasource', () => ({ - useInvalidDataSourceAuth: () => mocks.auth, - useInvalidDataSourceListAuth: () => mocks.list, - useInvalidDefaultDataSourceListAuth: () => mocks.defaults, -})) - describe('datasource authentication reconciliation', () => { let client: QueryClient const catalogKey = consoleQuery.rag.pipelines.datasourcePlugins.get.queryKey() const unrelatedKey = consoleQuery.account.profile.get.key() + const datasource = consoleQuery.auth.plugin.datasource + const authKeys = [ + datasource.list.get.queryKey(), + datasource.defaultList.get.queryKey(), + datasource.byProviderId.get.queryKey({ + input: { params: { provider_id: 'langgenius/notion/notion' } }, + }), + ] + const otherProviderKey = datasource.byProviderId.get.queryKey({ + input: { params: { provider_id: 'langgenius/firecrawl/firecrawl' } }, + }) const wrapper = ({ children }: PropsWithChildren) => createElement(QueryClientProvider, { client }, children) beforeEach(() => { @@ -24,6 +28,7 @@ describe('datasource authentication reconciliation', () => { client = new QueryClient() client.setQueryData(catalogKey, []) client.setQueryData(unrelatedKey, { cached: true }) + for (const key of [...authKeys, otherProviderKey]) client.setQueryData(key, { result: [] }) }) afterEach(() => client.clear()) @@ -41,8 +46,7 @@ describe('datasource authentication reconciliation', () => { expect(client.getQueryState(catalogKey)?.isInvalidated).toBe(true) expect(client.getQueryState(unrelatedKey)?.isInvalidated).toBe(false) - expect(mocks.auth).toHaveBeenCalledOnce() - expect(mocks.list).toHaveBeenCalledOnce() - expect(mocks.defaults).toHaveBeenCalledOnce() + for (const key of authKeys) expect(client.getQueryState(key)?.isInvalidated).toBe(true) + expect(client.getQueryState(otherProviderKey)?.isInvalidated).toBe(false) }) }) diff --git a/web/app/components/header/account-setting/data-source-page-new/hooks/use-data-source-auth-update.ts b/web/app/components/header/account-setting/data-source-page-new/hooks/use-data-source-auth-update.ts index d78a317711b47d..42ff99bcf8fab1 100644 --- a/web/app/components/header/account-setting/data-source-page-new/hooks/use-data-source-auth-update.ts +++ b/web/app/components/header/account-setting/data-source-page-new/hooks/use-data-source-auth-update.ts @@ -1,11 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' import { consoleQuery } from '@/service/console' -import { - useInvalidDataSourceAuth, - useInvalidDataSourceListAuth, - useInvalidDefaultDataSourceListAuth, -} from '@/service/use-datasource' export const useDataSourceAuthUpdate = ({ pluginId, @@ -15,25 +10,22 @@ export const useDataSourceAuthUpdate = ({ provider: string }) => { const queryClient = useQueryClient() - const invalidateDataSourceListAuth = useInvalidDataSourceListAuth() - const invalidDefaultDataSourceListAuth = useInvalidDefaultDataSourceListAuth() - const invalidateDataSourceAuth = useInvalidDataSourceAuth({ - pluginId, - provider, - }) const handleAuthUpdate = useCallback(() => { - invalidateDataSourceListAuth() - invalidDefaultDataSourceListAuth() + queryClient.invalidateQueries({ + queryKey: consoleQuery.auth.plugin.datasource.list.get.queryKey(), + }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.auth.plugin.datasource.defaultList.get.queryKey(), + }) queryClient.invalidateQueries({ queryKey: consoleQuery.rag.pipelines.datasourcePlugins.get.key(), }) - invalidateDataSourceAuth() - }, [ - invalidateDataSourceListAuth, - queryClient, - invalidateDataSourceAuth, - invalidDefaultDataSourceListAuth, - ]) + queryClient.invalidateQueries({ + queryKey: consoleQuery.auth.plugin.datasource.byProviderId.get.queryKey({ + input: { params: { provider_id: `${pluginId}/${provider}` } }, + }), + }) + }, [pluginId, provider, queryClient]) return { handleAuthUpdate, diff --git a/web/app/components/header/account-setting/data-source-page-new/index.tsx b/web/app/components/header/account-setting/data-source-page-new/index.tsx index b9e6db7d53bc59..9b96e9f4eed0aa 100644 --- a/web/app/components/header/account-setting/data-source-page-new/index.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/index.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { memo, useCallback, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { SearchInput } from '@/app/components/base/search-input' @@ -11,11 +11,11 @@ import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/ta import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useRenderI18nObject } from '@/hooks/use-i18n' import { consoleQuery } from '@/service/console' -import { useGetDataSourceListAuth, useInvalidDataSourceListAuth } from '@/service/use-datasource' import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins' import { UpdateSettingDialog } from '../update-setting-dialog' import Card from './card' import InstallFromMarketplace from './install-from-marketplace' +import { selectDataSourceAuthList } from './utils' type DataSourcePageProps = { layout?: (parts: { body: ReactNode; toolbar: ReactNode }) => ReactNode @@ -63,13 +63,14 @@ const DataSourcePage = ({ layout, onOpenMarketplace, stickyToolbar }: DataSource ...systemFeaturesQueryOptions(), select: (s) => s.enable_marketplace, }) - const { data, isLoading: isDataSourceListLoading } = useGetDataSourceListAuth() + const { data, isLoading: isDataSourceListLoading } = useQuery( + consoleQuery.auth.plugin.datasource.list.get.queryOptions({ select: selectDataSourceAuthList }), + ) const { data: installedPluginList } = useInstalledPluginList({ category: PluginCategoryEnum.datasource, }) const pluginListWithLatestVersion = usePluginsWithLatestVersion(installedPluginList?.plugins) const invalidateInstalledPluginList = useInvalidateInstalledPluginList() - const invalidateDataSourceListAuth = useInvalidDataSourceListAuth() const dataSources = useMemo(() => data?.result ?? [], [data?.result]) const dataSourcePluginDetails = useMemo(() => { return pluginListWithLatestVersion.filter( @@ -96,11 +97,13 @@ const DataSourcePage = ({ layout, onOpenMarketplace, stickyToolbar }: DataSource }, [dataSources, renderI18nObject, searchText]) const handlePluginUpdate = useCallback(() => { invalidateInstalledPluginList() - invalidateDataSourceListAuth() + queryClient.invalidateQueries({ + queryKey: consoleQuery.auth.plugin.datasource.list.get.queryKey(), + }) queryClient.invalidateQueries({ queryKey: consoleQuery.rag.pipelines.datasourcePlugins.get.key(), }) - }, [queryClient, invalidateDataSourceListAuth, invalidateInstalledPluginList]) + }, [queryClient, invalidateInstalledPluginList]) const toolbar = (
+ data as unknown as { result: DataSourceAuth[] } + +export const selectDataSourceCredentials = (data: DatasourceCredentialListResponse) => + data as unknown as { result: DataSourceCredential[] } diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts index eef47a63a77bef..ec5a86d312079a 100644 --- a/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts @@ -14,7 +14,6 @@ const mocks = vi.hoisted(() => ({ defaultModel: vi.fn(), toolProviders: vi.fn(), builtInTools: vi.fn(), - dataSourceAuth: vi.fn(), triggers: vi.fn(), recommended: vi.fn(), })) @@ -32,9 +31,6 @@ vi.mock('@/service/use-tools', () => ({ useInvalidateAllBuiltInTools: () => mocks.builtInTools, useInvalidateRAGRecommendedPlugins: () => mocks.recommended, })) -vi.mock('@/service/use-datasource', () => ({ - useInvalidDataSourceListAuth: () => mocks.dataSourceAuth, -})) vi.mock('@/service/use-triggers', () => ({ useInvalidateAllTriggerPlugins: () => mocks.triggers, })) @@ -54,6 +50,7 @@ const providerKeys = [ consoleQuery.workspaces.current.modelProviders.get.key(), ] const datasourceKey = consoleQuery.rag.pipelines.datasourcePlugins.get.queryKey() +const dataSourceAuthKey = consoleQuery.auth.plugin.datasource.list.get.queryKey() const unrelatedKey = consoleQuery.account.profile.get.key() const modelTypes = ['llm', 'text-embedding', 'rerank', 'speech2text', 'tts'] @@ -71,6 +68,7 @@ describe('plugin installation refresh', () => { for (const key of [...strategyKeys, ...providerKeys, unrelatedKey]) client.setQueryData(key, { cached: true }) client.setQueryData(datasourceKey, []) + client.setQueryData(dataSourceAuthKey, { result: [] }) for (const model_type of modelTypes) { client.setQueryData( current.models.modelTypes.byModelType.get.queryKey({ input: { params: { model_type } } }), @@ -114,7 +112,7 @@ describe('plugin installation refresh', () => { } if (category === PluginCategoryEnum.datasource) { expect(client.getQueryState(datasourceKey)?.isInvalidated).toBe(true) - expect(mocks.dataSourceAuth).toHaveBeenCalledOnce() + expect(client.getQueryState(dataSourceAuthKey)?.isInvalidated).toBe(true) } if (category === PluginCategoryEnum.trigger) expect(mocks.triggers).toHaveBeenCalledOnce() }, @@ -141,7 +139,7 @@ describe('plugin installation refresh', () => { expect(mocks.toolProviders).toHaveBeenCalledOnce() expect(mocks.builtInTools).toHaveBeenCalledOnce() expect(client.getQueryState(datasourceKey)?.isInvalidated).toBe(true) - expect(mocks.dataSourceAuth).toHaveBeenCalledOnce() + expect(client.getQueryState(dataSourceAuthKey)?.isInvalidated).toBe(true) expect(mocks.triggers).toHaveBeenCalledOnce() expect(mocks.recommended).toHaveBeenCalledWith('tool') } diff --git a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx index 584c3703b7026e..61a6d1428f7ff9 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx +++ b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx @@ -3,7 +3,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useInvalidateDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { consoleQuery } from '@/service/console' -import { useInvalidDataSourceListAuth } from '@/service/use-datasource' import { useInvalidateCheckInstalled, useInvalidateInstalledPluginList, @@ -62,8 +61,6 @@ const useRefreshPluginList = () => { const invalidateAllToolProviders = useInvalidateAllToolProviders() const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools() - const invalidateDataSourceListAuth = useInvalidDataSourceListAuth() - const invalidateAllTriggerPlugins = useInvalidateAllTriggerPlugins() const invalidateRAGRecommendedPlugins = useInvalidateRAGRecommendedPlugins() @@ -95,7 +92,9 @@ const useRefreshPluginList = () => { queryClient.invalidateQueries({ queryKey: consoleQuery.rag.pipelines.datasourcePlugins.get.key(), }) - invalidateDataSourceListAuth() + queryClient.invalidateQueries({ + queryKey: consoleQuery.auth.plugin.datasource.list.get.queryKey(), + }) } // model select diff --git a/web/service/console/query-policies.ts b/web/service/console/query-policies.ts index 040ee00a42704e..2b661c121a7e0a 100644 --- a/web/service/console/query-policies.ts +++ b/web/service/console/query-policies.ts @@ -75,6 +75,15 @@ export function createConsoleQuery(consoleClient: ConsoleClient) { }, }, }, + auth: { + plugin: { + datasource: { + list: { get: { queryOptions: { retry: false } } }, + defaultList: { get: { queryOptions: { retry: false } } }, + byProviderId: { get: { queryOptions: { retry: false } } }, + }, + }, + }, rag: { pipelines: { datasourcePlugins: { diff --git a/web/service/use-datasource.ts b/web/service/use-datasource.ts deleted file mode 100644 index 5ecb61c3ae1085..00000000000000 --- a/web/service/use-datasource.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { - DataSourceAuth, - DataSourceCredential, -} from '@/app/components/header/account-setting/data-source-page-new/types' -import { useMutation, useQuery } from '@tanstack/react-query' -import { consoleClient } from './console' -import { narrowResponse } from './console/narrow-response' -import { useInvalid } from './use-base' - -const NAME_SPACE = 'data-source-auth' - -export const useGetDataSourceListAuth = () => { - return useQuery({ - queryKey: [NAME_SPACE, 'list'], - queryFn: () => - narrowResponse<{ result: DataSourceAuth[] }>(consoleClient.auth.plugin.datasource.list.get()), - retry: 0, - }) -} - -export const useInvalidDataSourceListAuth = () => { - return useInvalid([NAME_SPACE, 'list']) -} - -// !This hook is used for fetching the default data source list, which will be legacy and deprecated in the near future. -export const useGetDefaultDataSourceListAuth = () => { - return useQuery({ - queryKey: [NAME_SPACE, 'default-list'], - queryFn: () => - narrowResponse<{ result: DataSourceAuth[] }>( - consoleClient.auth.plugin.datasource.defaultList.get(), - ), - retry: 0, - }) -} - -export const useInvalidDefaultDataSourceListAuth = () => { - return useInvalid([NAME_SPACE, 'default-list']) -} - -export const useGetDataSourceOAuthUrl = (provider: string) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'oauth-url', provider], - mutationFn: (credentialId?: string) => { - return narrowResponse<{ - authorization_url: string - state: string - context_id: string - }>( - consoleClient.oauth.plugin.byProviderId.datasource.getAuthorizationUrl.get({ - params: { provider_id: provider }, - query: { credential_id: credentialId }, - }), - ) - }, - }) -} - -export const useGetDataSourceAuth = ({ - pluginId, - provider, -}: { - pluginId: string - provider: string -}) => { - return useQuery({ - queryKey: [NAME_SPACE, 'specific-data-source', pluginId, provider], - queryFn: () => - narrowResponse<{ result: DataSourceCredential[] }>( - consoleClient.auth.plugin.datasource.byProviderId.get({ - params: { provider_id: `${pluginId}/${provider}` }, - }), - ), - retry: 0, - }) -} - -export const useInvalidDataSourceAuth = ({ - pluginId, - provider, -}: { - pluginId: string - provider: string -}) => { - return useInvalid([NAME_SPACE, 'specific-data-source', pluginId, provider]) -} From edddfafa739ce56864f53830da82d30b408ef03d Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:26:20 +0800 Subject: [PATCH 26/60] test(web): allow the Builder model settings flow more time on loaded CI shards The model settings dialog now loads parameter rules through the console client, which loads its contract segment lazily; under the full sharded run the interaction-heavy test exceeded the 5s default. Co-Authored-By: Claude Opus 5.5 --- web/features/skills/__tests__/detail-page-builder.spec.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/features/skills/__tests__/detail-page-builder.spec.tsx b/web/features/skills/__tests__/detail-page-builder.spec.tsx index 24afffa3267c24..6f9768bbc77b47 100644 --- a/web/features/skills/__tests__/detail-page-builder.spec.tsx +++ b/web/features/skills/__tests__/detail-page-builder.spec.tsx @@ -655,7 +655,8 @@ describe('SkillDetailPage builder', () => { }, }), ) - }) + // Opens the settings dialog, loads parameter rules and types through several fields. + }, 15000) it('uploads image attachments in Skill Builder', async () => { const user = userEvent.setup({ applyAccept: false }) From d553a13b364785d1b7dec0df765b3b0941b6a5b5 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:35:06 +0800 Subject: [PATCH 27/60] test(web): wait longer for the Builder model settings button on loaded CI shards Co-Authored-By: Claude Opus 5.5 --- oxlint-suppressions.json | 5 ----- .../skills/__tests__/detail-page-builder.spec.tsx | 9 ++++++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index e958edee52fd0b..861e3b1b48b333 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4176,11 +4176,6 @@ "count": 1 } }, - "web/service/use-tools.ts": { - "@tanstack/query/prefer-query-options": { - "count": 15 - } - }, "web/service/use-triggers.ts": { "@tanstack/query/prefer-query-options": { "count": 6 diff --git a/web/features/skills/__tests__/detail-page-builder.spec.tsx b/web/features/skills/__tests__/detail-page-builder.spec.tsx index 6f9768bbc77b47..6436b04823b7b5 100644 --- a/web/features/skills/__tests__/detail-page-builder.spec.tsx +++ b/web/features/skills/__tests__/detail-page-builder.spec.tsx @@ -629,10 +629,13 @@ describe('SkillDetailPage builder', () => { ) renderSkillDetailPage() + // The settings button waits for the model list and its lazily loaded contract segment. await user.click( - await screen.findByRole('button', { - name: 'modelProvider.modelProvider.modelSettings', - }), + await screen.findByRole( + 'button', + { name: 'modelProvider.modelProvider.modelSettings' }, + { timeout: 5000 }, + ), ) const dialog = await screen.findByRole('dialog', { name: 'modelProvider.modelProvider.modelSettings', From 7553fb3dafe9a1ba94ad054db331c9f97f179cc3 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:35:45 +0800 Subject: [PATCH 28/60] chore(web): restore use-tools lint suppressions d553a13 accidentally pruned them while use-tools.ts still needs them on this branch. Co-Authored-By: Claude Opus 5.5 --- oxlint-suppressions.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 861e3b1b48b333..e958edee52fd0b 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4176,6 +4176,11 @@ "count": 1 } }, + "web/service/use-tools.ts": { + "@tanstack/query/prefer-query-options": { + "count": 15 + } + }, "web/service/use-triggers.ts": { "@tanstack/query/prefer-query-options": { "count": 6 From 6a889f484e4ed5f78c2871d8c13b71046e8f2fea Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:43:33 +0800 Subject: [PATCH 29/60] fix(web): convert agent log thought files only once The log list converted message files to FileEntity before attaching them to agent thoughts, and AgentContent converted them again for FileList. The second conversion dropped supportFileType, so tool image outputs rendered as blank file cards without a thumbnail or preview. Attach the raw message files, as the chat history hooks do, so the renderer converts them once. Co-Authored-By: Claude Opus 5.5 --- .../app/log/__tests__/list-utils.spec.ts | 50 +++++++++++++++++++ web/app/components/app/log/list-utils.ts | 17 ++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/web/app/components/app/log/__tests__/list-utils.spec.ts b/web/app/components/app/log/__tests__/list-utils.spec.ts index e27d04c2e47bf0..d28c818a881f3d 100644 --- a/web/app/components/app/log/__tests__/list-utils.spec.ts +++ b/web/app/components/app/log/__tests__/list-utils.spec.ts @@ -1,5 +1,6 @@ import type { MessageDetailResponse } from '@dify/contracts/api/console/apps/types.gen' import type { IChatItem } from '@/app/components/base/chat/chat/type' +import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import { applyAnnotationAdded, applyAnnotationEdited, @@ -291,6 +292,55 @@ describe('log list utils', () => { ).toEqual(['https://example.com/file-1']) }) + it('keeps agent thought files raw so the agent renderer converts them once', () => { + const [, answer] = getFormattedChatList( + [ + createMessage({ + id: 'message-1', + agent_thoughts: [ + { + id: 'thought-1', + message_id: 'message-1', + position: 1, + files: ['file-1'], + tool: 'image_generator', + tool_labels: {}, + }, + ], + message_files: [ + { + belongs_to: 'assistant', + id: 'file-1', + filename: 'image.png', + transfer_method: 'tool_file', + type: 'image', + url: 'https://example.com/image.png', + }, + ], + }), + ], + 'conversation-1', + 'UTC', + 'YYYY-MM-DD', + ) + + const thoughtFiles = answer!.agent_thoughts![0]!.message_files! + expect(thoughtFiles).toEqual([ + expect.objectContaining({ + id: 'file-1', + type: 'image', + url: 'https://example.com/image.png', + }), + ]) + // Mirrors AgentContent, which converts thought files for FileList. + const [rendered] = getProcessedFilesFromResponse( + thoughtFiles.map((file) => ({ ...file, related_id: file.id }) as never), + ) + expect(rendered).toEqual( + expect.objectContaining({ supportFileType: 'image', url: 'https://example.com/image.png' }), + ) + }) + it('should handle default inputs', () => { expect( getConversationRowValues({ diff --git a/web/app/components/app/log/list-utils.ts b/web/app/components/app/log/list-utils.ts index 131bb39f172a8e..02d5a5a929a552 100644 --- a/web/app/components/app/log/list-utils.ts +++ b/web/app/components/app/log/list-utils.ts @@ -7,6 +7,7 @@ import type { ThoughtItem, } from '@/app/components/base/chat/chat/type' import type { Annotation } from '@/models/log' +import type { VisionFile } from '@/types/app' import type { FileResponse } from '@/types/workflow' import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' @@ -53,6 +54,19 @@ type LogMessage = MessageDetailResponse const isTransferMethod = (value: string): value is TransferMethod => (Object.values(TransferMethod) as string[]).includes(value) +// Agent thoughts keep raw message files; the agent content renderer converts them once. +const toThoughtFiles = (item: LogMessage): VisionFile[] => + item.message_files.map((file) => ({ + id: file.id, + type: file.type, + transfer_method: isTransferMethod(file.transfer_method) + ? file.transfer_method + : TransferMethod.local_file, + url: file.url ?? '', + upload_file_id: file.upload_file_id ?? '', + belongs_to: file.belongs_to ?? undefined, + })) + const toFileResponse = (file: LogMessage['message_files'][number]): FileResponse => ({ related_id: file.id ?? file.upload_file_id, extension: '', @@ -146,7 +160,6 @@ export const getFormattedChatList = ( const newChatList: IChatItem[] = [] messages.forEach((item) => { - const files = getProcessedFilesFromResponse(item.message_files.map(toFileResponse)) const questionFiles = getProcessedFilesFromResponse( item.message_files.filter((file) => file.belongs_to === 'user').map(toFileResponse), ) @@ -165,7 +178,7 @@ export const getFormattedChatList = ( newChatList.push({ id: item.id, content: item.answer, - agent_thoughts: addFileInfos(sortAgentSorts(toThoughtItems(item)), files), + agent_thoughts: addFileInfos(sortAgentSorts(toThoughtItems(item)), toThoughtFiles(item)), feedback: toFeedback(item.feedbacks.find((feedback) => feedback.from_source === 'user')), adminFeedback: toFeedback( item.feedbacks.find((feedback) => feedback.from_source === 'admin'), From 791829fdb0df560498d9f129d4de16e0f2d26abc Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:49:00 +0800 Subject: [PATCH 30/60] refactor(web): use generated tool options directly Remove the forwarding hooks in service/use-tools. Consumers call the generated tool list, MCP provider, MCP server, app trigger, workflow tool detail and RAG recommendation queryOptions and mutationOptions directly, adapt responses to the tool UI types with select adapters in tools/utils/query-select, and invalidate generated keys inline. The provider-type to tool list key mapping lives in tools/utils/tool-list-query-key. Specs answer generated operations through mockConsoleOperationHooks and assert invalidation through cache state. Co-Authored-By: Claude Opus 5.5 --- oxlint-suppressions.json | 5 - .../access-point/__tests__/mcp-card.spec.tsx | 62 +-- .../__tests__/trigger-card.spec.tsx | 36 +- .../built-in-access-points/mcp-card.tsx | 34 +- .../built-in-access-points/trigger-card.tsx | 23 +- .../environment-deployment-flow.spec.tsx | 15 +- .../app-publisher/__tests__/index.spec.tsx | 17 +- .../agent-tools/__tests__/index.spec.tsx | 15 +- .../config/agent/agent-tools/index.tsx | 29 +- .../app/deploy/__tests__/index.spec.tsx | 39 +- .../built-in-environment-card/index.tsx | 7 +- .../__tests__/use-provider-icon.spec.tsx | 15 +- .../use-provider-icon.ts | 35 +- .../try-app/preview/basic-app-preview.tsx | 15 +- .../__tests__/tool-provider-list.spec.tsx | 21 +- .../integrations/tool-provider-list.tsx | 31 +- .../__tests__/use-refresh-plugin-list.spec.ts | 34 +- .../hooks/use-refresh-plugin-list.tsx | 24 +- .../__tests__/authorized-in-node.spec.tsx | 4 - .../__tests__/plugin-auth-in-agent.spec.tsx | 4 - .../hooks/__tests__/use-credential.spec.ts | 21 +- .../plugin-auth/hooks/use-credential.ts | 9 +- .../plugin-detail-panel/action-list.tsx | 22 +- .../detail-header/__tests__/index.spec.tsx | 25 +- .../detail-header/index.tsx | 11 +- .../__tests__/focus-restoration.spec.tsx | 21 +- .../__tests__/index.spec.tsx | 13 +- .../multiple-tool-selector/index.tsx | 8 +- .../hooks/__tests__/use-tool-selector.spec.ts | 165 ++++---- .../tool-selector/hooks/use-tool-selector.ts | 38 +- .../marketplace/__tests__/index.spec.tsx | 5 - .../tools/mcp/__tests__/create-card.spec.tsx | 19 +- .../tools/mcp/__tests__/index.spec.tsx | 47 ++- .../mcp/__tests__/mcp-server-modal.spec.tsx | 24 +- web/app/components/tools/mcp/create-card.tsx | 17 +- .../mcp/detail/__tests__/content.spec.tsx | 60 +-- .../components/tools/mcp/detail/content.tsx | 55 ++- web/app/components/tools/mcp/index.tsx | 26 +- .../components/tools/mcp/mcp-server-modal.tsx | 56 +-- web/app/components/tools/mcp/modal.tsx | 3 +- .../tools/provider/__tests__/detail.spec.tsx | 4 - web/app/components/tools/provider/detail.tsx | 6 +- web/app/components/tools/types.ts | 12 + .../components/tools/utils/query-select.ts | 47 +++ .../tools/utils/tool-list-query-key.ts | 20 + .../__tests__/use-configure-button.spec.ts | 287 ++++++++------ .../hooks/use-configure-button.ts | 49 ++- .../workflow-app/__tests__/index.spec.tsx | 9 +- .../__tests__/provider-contract.spec.tsx | 11 +- .../__tests__/features-trigger.spec.tsx | 17 +- .../workflow-header/features-trigger.tsx | 9 +- web/app/components/workflow-app/index.tsx | 17 +- .../__tests__/service-mock-factory.ts | 17 +- .../__tests__/workflow-edge-events.spec.tsx | 15 +- .../block-selector/__tests__/index.spec.tsx | 16 +- .../block-selector/__tests__/tabs.spec.tsx | 28 +- .../__tests__/tool-picker.spec.tsx | 112 +++--- .../rag-tool-recommendations/index.tsx | 11 +- .../workflow/block-selector/tool-panel.tsx | 39 +- .../workflow/block-selector/tool-picker.tsx | 56 +-- .../__tests__/use-available-blocks.spec.ts | 7 +- .../hooks/__tests__/use-checklist.spec.ts | 18 +- .../use-fetch-workflow-inspect-vars.spec.ts | 7 +- .../use-inspect-vars-crud-common.spec.ts | 7 +- .../use-node-plugin-installation.spec.ts | 40 +- .../__tests__/use-nodes-meta-data.spec.tsx | 13 +- .../hooks/__tests__/use-tool-icon.spec.ts | 37 +- .../__tests__/use-workflow-search.spec.tsx | 31 +- .../__tests__/use-workflow-variables.spec.ts | 7 +- .../workflow/hooks/use-checklist.ts | 43 ++- .../hooks/use-fetch-workflow-inspect-vars.ts | 29 +- .../hooks/use-inspect-vars-crud-common.ts | 29 +- .../hooks/use-node-plugin-installation.ts | 46 ++- .../workflow/hooks/use-nodes-meta-data.ts | 20 +- .../workflow/hooks/use-tool-icon.ts | 49 ++- .../workflow/hooks/use-workflow-search.tsx | 29 +- .../workflow/hooks/use-workflow-variables.ts | 29 +- web/app/components/workflow/index.tsx | 33 +- .../__tests__/details.spec.tsx | 20 +- .../__tests__/index.spec.tsx | 13 +- .../use-node-actions-menu-model.ts | 10 +- .../__tests__/node-focus.browser.spec.tsx | 19 +- .../components/__tests__/node-handle.spec.tsx | 16 +- .../workflow-panel/__tests__/index.spec.tsx | 13 +- .../_base/components/workflow-panel/index.tsx | 11 +- .../hooks/__tests__/use-one-step-run.spec.ts | 15 +- .../nodes/_base/hooks/use-one-step-run.ts | 29 +- .../agent/__tests__/integration.spec.tsx | 18 +- .../components/__tests__/tool-icon.spec.tsx | 15 +- .../nodes/agent/components/tool-icon.tsx | 29 +- .../if-else/__tests__/integration.spec.tsx | 15 +- .../condition-list/condition-item.tsx | 29 +- .../iteration/__tests__/use-config.spec.ts | 15 +- .../workflow/nodes/iteration/use-config.ts | 29 +- .../nodes/loop/__tests__/use-config.spec.tsx | 15 +- .../workflow/nodes/loop/use-config.ts | 29 +- .../__tests__/import-from-tool.spec.tsx | 16 +- .../extract-parameter/import-from-tool.tsx | 20 +- .../tool/hooks/__tests__/use-config.spec.tsx | 30 +- .../use-current-tool-collection.spec.tsx | 15 +- .../workflow/nodes/tool/hooks/use-config.ts | 10 +- .../tool/hooks/use-current-tool-collection.ts | 29 +- .../operator/__tests__/add-block.spec.tsx | 16 +- .../workflow-canvas.browser.spec.tsx | 7 +- .../use-agent-configure-sync.spec.tsx | 90 +++-- .../__tests__/agent-prompt-editor.spec.tsx | 15 +- .../orchestrate/prompt-editor/hooks.ts | 38 +- .../orchestrate/prompt-editor/slash.tsx | 29 +- .../tools/__tests__/index.spec.tsx | 29 +- .../components/orchestrate/tools/index.tsx | 46 ++- .../configure/tool-provider-catalog.ts | 29 +- web/service/__tests__/use-tools.spec.tsx | 85 ----- web/service/use-plugins.ts | 6 +- web/service/use-tools.ts | 360 ------------------ 114 files changed, 1934 insertions(+), 1642 deletions(-) create mode 100644 web/app/components/tools/utils/query-select.ts create mode 100644 web/app/components/tools/utils/tool-list-query-key.ts delete mode 100644 web/service/__tests__/use-tools.spec.tsx delete mode 100644 web/service/use-tools.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index e958edee52fd0b..861e3b1b48b333 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4176,11 +4176,6 @@ "count": 1 } }, - "web/service/use-tools.ts": { - "@tanstack/query/prefer-query-options": { - "count": 15 - } - }, "web/service/use-triggers.ts": { "@tanstack/query/prefer-query-options": { "count": 6 diff --git a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx index 4fd5ec63630054..7d3b06a713fc70 100644 --- a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx @@ -12,7 +12,6 @@ import { AppModeEnum } from '@/types/app' import { MCPAccessPointCard } from '../built-in-access-points/mcp-card' const mocks = vi.hoisted(() => ({ - invalidateServerDetail: vi.fn(), serverDetail: { data: undefined as undefined | { id: string; server_code: string; status: string }, isPending: false, @@ -29,31 +28,38 @@ vi.mock('@/app/notifications', () => ({ }, })) -vi.mock('@/service/console', () => ({ - consoleQuery: { - apps: { - byAppId: { - server: { - put: { - mutationOptions: (options = {}) => ({ - mutationFn: mocks.updateServer, - ...options, - }), +vi.mock('@/service/console', async () => { + const { consoleOperationStub } = await import('@/test/console/operation-hooks') + return { + consoleQuery: { + apps: { + byAppId: { + server: { + get: consoleOperationStub('apps.byAppId.server.get'), + refresh: { post: consoleOperationStub('apps.byAppId.server.refresh.post') }, + put: { + mutationOptions: (options = {}) => ({ + mutationFn: mocks.updateServer, + ...options, + }), + }, }, }, }, }, - }, -})) + } +}) -vi.mock('@/service/use-tools', () => ({ - useInvalidateMCPServerDetail: () => mocks.invalidateServerDetail, - useMCPServerDetail: () => mocks.serverDetail, - useRefreshMCPServerCode: () => ({ - isPending: false, - mutateAsync: mocks.refreshServerCode, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'apps.byAppId.server.get': () => mocks.serverDetail, + 'apps.byAppId.server.refresh.post': () => ({ + isPending: false, + mutateAsync: mocks.refreshServerCode, + }), + }) +}) vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({ default: (props: Record) => { @@ -112,8 +118,9 @@ function createDeferredPromise() { function renderCard(cardAppInfo: AppDetailWithSite = appInfo, workflow?: PublishedWorkflow) { const queryClient = createTestQueryClient() + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') - return render( + const view = render( , ) + return { ...view, invalidateQueries } } describe('MCPAccessPointCard', () => { @@ -228,7 +236,7 @@ describe('MCPAccessPointCard', () => { mocks.updateServer .mockReturnValueOnce(firstToggle.promise) .mockReturnValueOnce(secondToggle.promise) - renderCard() + const { invalidateQueries } = renderCard() const accessSwitch = screen.getByRole('switch') await user.click(accessSwitch) @@ -250,7 +258,13 @@ describe('MCPAccessPointCard', () => { secondToggle.resolve() await waitFor(() => { - expect(mocks.invalidateServerDetail).toHaveBeenCalledTimes(2) + expect(invalidateQueries).toHaveBeenCalledTimes(2) + expect(invalidateQueries).toHaveBeenLastCalledWith({ + queryKey: [ + ['console', 'apps', 'byAppId', 'server', 'get'], + { type: 'query', input: { params: { app_id: 'app-1' } } }, + ], + }) }) expect(accessSwitch).toHaveAttribute('aria-checked', 'true') }) diff --git a/web/app/components/app/access-point/__tests__/trigger-card.spec.tsx b/web/app/components/app/access-point/__tests__/trigger-card.spec.tsx index a0d9de65e828b0..7819855913e9fd 100644 --- a/web/app/components/app/access-point/__tests__/trigger-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/trigger-card.spec.tsx @@ -1,4 +1,4 @@ -import type { AppTrigger } from '@/service/use-tools' +import type { AppTrigger } from '@/app/components/tools/types' import { QueryClientProvider } from '@tanstack/react-query' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -10,7 +10,6 @@ import { AppModeEnum } from '@/types/app' import { TriggerAccessPointCard } from '../built-in-access-points/trigger-card' const mocks = vi.hoisted(() => ({ - invalidateTriggers: vi.fn(), setTriggerStatuses: vi.fn(), triggerQuery: { data: undefined as { data: AppTrigger[] } | undefined, @@ -39,10 +38,15 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `https://docs.example.test/en${path}`, })) -vi.mock('@/service/console', () => ({ +vi.mock('@/service/console', async () => ({ consoleQuery: { apps: { byAppId: { + triggers: { + get: (await import('@/test/console/operation-hooks')).consoleOperationStub( + 'apps.byAppId.triggers.get', + ), + }, triggerEnable: { post: { mutationOptions: (options = {}) => ({ @@ -56,10 +60,12 @@ vi.mock('@/service/console', () => ({ }, })) -vi.mock('@/service/use-tools', () => ({ - useAppTriggers: () => mocks.triggerQuery, - useInvalidateAppTriggers: () => mocks.invalidateTriggers, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'apps.byAppId.triggers.get': () => mocks.triggerQuery, + }) +}) vi.mock('@/service/use-triggers', () => ({ useAllTriggerPlugins: () => ({ data: [] }), @@ -88,14 +94,21 @@ function createTrigger(id: string, status: AppTrigger['status']): AppTrigger { } } +const triggersQueryKey = [ + ['console', 'apps', 'byAppId', 'triggers', 'get'], + { type: 'query', input: { params: { app_id: 'app-1' } } }, +] + function renderCard(availability: 'available' | 'loading' | 'unavailable') { const queryClient = createTestQueryClient() + queryClient.setQueryData(triggersQueryKey, { data: [] }) render( , ) + return queryClient } function createDeferredPromise() { @@ -187,7 +200,7 @@ describe('TriggerAccessPointCard', () => { mocks.triggerQuery.data = { data: [createTrigger('disabled', 'disabled')], } - renderCard('available') + const queryClient = renderCard('available') await user.click(screen.getByRole('switch', { name: 'Trigger disabled' })) @@ -204,7 +217,7 @@ describe('TriggerAccessPointCard', () => { }, expect.any(Object), ) - expect(mocks.invalidateTriggers).toHaveBeenCalledWith('app-1') + expect(queryClient.getQueryState(triggersQueryKey)?.isInvalidated).toBe(true) }) expect(toast.success).not.toHaveBeenCalled() }) @@ -235,7 +248,8 @@ describe('TriggerAccessPointCard', () => { mocks.updateTriggerStatus .mockReturnValueOnce(firstToggle.promise) .mockReturnValueOnce(secondToggle.promise) - renderCard('available') + const queryClient = renderCard('available') + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') const triggerSwitch = screen.getByRole('switch', { name: 'Trigger enabled' }) await user.click(triggerSwitch) @@ -257,7 +271,7 @@ describe('TriggerAccessPointCard', () => { secondToggle.resolve() await waitFor(() => { - expect(mocks.invalidateTriggers).toHaveBeenCalledTimes(2) + expect(invalidateQueries).toHaveBeenCalledTimes(2) }) expect(triggerSwitch).toHaveAttribute('aria-checked', 'true') }) diff --git a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx index 5e2c4f5ae08346..3545909fd84b13 100644 --- a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx +++ b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx @@ -12,20 +12,16 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { AccessPointCard } from '@/app/components/base/access-point/card' import { AccessPointUrl } from '@/app/components/base/access-point/url' import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal' +import { selectMCPServerDetail } from '@/app/components/tools/utils/query-select' import { BlockEnum } from '@/app/components/workflow/types' import { toast } from '@/app/notifications' import { consoleQuery } from '@/service/console' -import { - useInvalidateMCPServerDetail, - useMCPServerDetail, - useRefreshMCPServerCode, -} from '@/service/use-tools' import { AppModeEnum } from '@/types/app' import { useAccessPointStatusLabel } from '../shared/use-access-point-status-label' import { getPublishedWorkflowNodes, isAdvancedApp } from '../shared/utils' @@ -55,23 +51,29 @@ export function MCPAccessPointCard({ const [showRegenerate, setShowRegenerate] = useState(false) const basicConfig = appInfo.model_config const basicAppInputForm = basicConfig?.user_input_form - const { data: detail, isPending: serverDetailLoading } = useMCPServerDetail( - appInfo.id, - Boolean(appInfo.id), - ) - const invalidateServerDetail = useInvalidateMCPServerDetail() + const queryClient = useQueryClient() + const serverDetailQueryOptions = consoleQuery.apps.byAppId.server.get.queryOptions({ + input: { params: { app_id: appInfo.id } }, + enabled: Boolean(appInfo.id), + select: selectMCPServerDetail, + }) + const { data: detail, isPending: serverDetailLoading } = useQuery(serverDetailQueryOptions) + const invalidateServerDetail = () => + queryClient.invalidateQueries({ queryKey: serverDetailQueryOptions.queryKey }) const updateServerMutation = useMutation( consoleQuery.apps.byAppId.server.put.mutationOptions({ scope: { id: `app-mcp-toggle:${appInfo.id}`, }, - onSuccess: () => invalidateServerDetail(appInfo.id), + onSuccess: () => invalidateServerDetail(), onError: () => { toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) }, }), ) - const { mutateAsync: refreshServerCode, isPending: regenerating } = useRefreshMCPServerCode() + const { mutateAsync: refreshServerCode, isPending: regenerating } = useMutation( + consoleQuery.apps.byAppId.server.refresh.post.mutationOptions(), + ) const serverPublished = Boolean(detail?.id) const serverActivated = detail?.status === 'active' @@ -137,8 +139,8 @@ export function MCPAccessPointCard({ const handleRegenerate = async () => { if (!canManageAccessPoint || !detail?.id) return - await refreshServerCode(appInfo.id) - invalidateServerDetail(appInfo.id) + await refreshServerCode({ params: { app_id: appInfo.id } }) + invalidateServerDetail() setShowRegenerate(false) } @@ -207,7 +209,7 @@ export function MCPAccessPointCard({ latestParams={latestParams} onHide={() => { setShowServerModal(false) - invalidateServerDetail(appInfo.id) + invalidateServerDetail() }} appInfo={appInfo} /> diff --git a/web/app/components/app/access-point/built-in-access-points/trigger-card.tsx b/web/app/components/app/access-point/built-in-access-points/trigger-card.tsx index abf8c9bf080a2e..cf3f086de7a75f 100644 --- a/web/app/components/app/access-point/built-in-access-points/trigger-card.tsx +++ b/web/app/components/app/access-point/built-in-access-points/trigger-card.tsx @@ -1,14 +1,15 @@ 'use client' import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' +import type { AppTrigger } from '@/app/components/tools/types' import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' -import type { AppTrigger } from '@/service/use-tools' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Switch } from '@langgenius/dify-ui/switch' -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { AccessPointCard, AccessPointEmptyContent } from '@/app/components/base/access-point/card' +import { selectAppTriggers } from '@/app/components/tools/utils/query-select' import BlockIcon from '@/app/components/workflow/block-icon' import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status' import { BlockEnum } from '@/app/components/workflow/types' @@ -16,7 +17,6 @@ import { toast } from '@/app/notifications' import { useDocLink } from '@/context/i18n' import Link from '@/next/link' import { consoleQuery } from '@/service/console' -import { useAppTriggers, useInvalidateAppTriggers } from '@/service/use-tools' import { useAllTriggerPlugins } from '@/service/use-triggers' import { canFindTool } from '@/utils' import { useAccessPointStatusLabel } from '../shared/use-access-point-status-label' @@ -64,13 +64,18 @@ function TriggerAccessPointItem({ triggerPlugins: TriggerWithProvider[] }) { const { t } = useTranslation(['agentV2', 'appOverview', 'common']) - const invalidateTriggers = useInvalidateAppTriggers() + const queryClient = useQueryClient() const updateTriggerMutation = useMutation( consoleQuery.apps.byAppId.triggerEnable.post.mutationOptions({ scope: { id: `app-trigger-toggle:${appId}:${trigger.id}`, }, - onSuccess: () => invalidateTriggers(appId), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: consoleQuery.apps.byAppId.triggers.get.queryKey({ + input: { params: { app_id: appId } }, + }), + }), onError: () => { toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) }, @@ -145,7 +150,13 @@ export function TriggerAccessPointCard({ }: TriggerAccessPointCardProps) { const { t } = useTranslation(['appOverview', 'deployments', 'navigation']) const docLink = useDocLink() - const { data: response, isLoading } = useAppTriggers(appInfo.id) + const { data: response, isLoading } = useQuery( + consoleQuery.apps.byAppId.triggers.get.queryOptions({ + input: { params: { app_id: appInfo.id } }, + enabled: !!appInfo.id, + select: selectAppTriggers, + }), + ) const { data: triggerPlugins = [] } = useAllTriggerPlugins() const setTriggerStatuses = useTriggerStatusStore((state) => state.setTriggerStatuses) const triggers = useMemo(() => response?.data ?? [], [response?.data]) diff --git a/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx b/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx index 4529251fdd6676..c983f3fa2a811f 100644 --- a/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx @@ -67,12 +67,15 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ }), })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: [] }), - useAllCustomTools: () => ({ data: [] }), - useAllMCPTools: () => ({ data: [] }), - useAllWorkflowTools: () => ({ data: [] }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: [] }), + 'workspaces.current.tools.api.get': () => ({ data: [] }), + 'workspaces.current.tools.mcp.get': () => ({ data: [] }), + 'workspaces.current.tools.workflow.get': () => ({ data: [] }), + }) +}) function publishedWorkflowVersion({ id, diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index a74e51ebbf6595..8cc6f1af40eeb7 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -132,14 +132,15 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () }, })) -vi.mock('@/service/use-tools', () => ({ - useWorkflowToolDetailByAppID: () => ({ - data: undefined, - isLoading: false, - }), - useInvalidateAllWorkflowTools: () => vi.fn(), - useInvalidateWorkflowToolDetailByAppID: () => vi.fn(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.toolProvider.workflow.get.get': () => ({ + data: undefined, + isLoading: false, + }), + }) +}) vi.mock('@/context/workspace-state', async () => { const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') diff --git a/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx b/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx index af5bcc2af18f8e..c9a7cb328589e3 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx @@ -31,12 +31,15 @@ let builtInTools: ToolWithProvider[] = [] let customTools: ToolWithProvider[] = [] let workflowTools: ToolWithProvider[] = [] let mcpTools: ToolWithProvider[] = [] -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: builtInTools }), - useAllCustomTools: () => ({ data: customTools }), - useAllWorkflowTools: () => ({ data: workflowTools }), - useAllMCPTools: () => ({ data: mcpTools }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: builtInTools }), + 'workspaces.current.tools.api.get': () => ({ data: customTools }), + 'workspaces.current.tools.workflow.get': () => ({ data: workflowTools }), + 'workspaces.current.tools.mcp.get': () => ({ data: mcpTools }), + }) +}) type ToolPickerProps = React.ComponentProps let singleToolSelection: ToolDefaultValue | null = null diff --git a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx index 7ad92410dd0271..2dc637af297b76 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx @@ -12,6 +12,7 @@ import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Switch } from '@langgenius/dify-ui/switch' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiDeleteBinLine, RiEqualizer2Line } from '@remixicon/react' +import { useQuery } from '@tanstack/react-query' import { produce } from 'immer' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' @@ -22,6 +23,7 @@ import { OperationButton } from '@/app/components/app/configuration/base/operati import AppIcon from '@/app/components/base/app-icon' import { parseToolProviderType } from '@/app/components/tools/provider-type' import { CollectionType } from '@/app/components/tools/types' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { addDefaultValue, toolParametersToFormSchemas, @@ -29,12 +31,7 @@ import { import ToolPicker from '@/app/components/workflow/block-selector/tool-picker' import { MAX_TOOLS_NUM } from '@/config' import ConfigContext from '@/context/debug-configuration' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { writeTextToClipboard } from '@/utils/clipboard' import { matchesProviderReference } from '@/utils/provider-reference' import { useFormattingChangedDispatcher } from '../../../debug/hooks' @@ -48,10 +45,22 @@ const AgentTools: FC = () => { const { t } = useTranslation(['appDebug', 'common', 'tools']) const [isShowChooseTool, setIsShowChooseTool] = useState(false) const { readonly, modelConfig, setModelConfig } = useContext(ConfigContext) - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const collectionList = useMemo(() => { const allTools = [ ...(buildInTools || []), diff --git a/web/app/components/app/deploy/__tests__/index.spec.tsx b/web/app/components/app/deploy/__tests__/index.spec.tsx index 37b925cfc41852..647471d7934a04 100644 --- a/web/app/components/app/deploy/__tests__/index.spec.tsx +++ b/web/app/components/app/deploy/__tests__/index.spec.tsx @@ -823,24 +823,27 @@ vi.mock('@/hooks/use-timestamp', () => ({ }), })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: [] }), - useAllCustomTools: () => ({ data: [] }), - useAllMCPTools: () => ({ - data: [ - { - icon: '/notion-mcp.svg', - id: 'notion', - name: 'Notion', - plugin_id: 'langgenius/notion', - }, - ], - }), - useAllWorkflowTools: () => ({ data: [] }), - useMCPServerDetail: () => ({ - data: mockBuiltInEnvironment.mcpServerDetail, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: [] }), + 'workspaces.current.tools.api.get': () => ({ data: [] }), + 'workspaces.current.tools.mcp.get': () => ({ + data: [ + { + icon: '/notion-mcp.svg', + id: 'notion', + name: 'Notion', + plugin_id: 'langgenius/notion', + }, + ], + }), + 'workspaces.current.tools.workflow.get': () => ({ data: [] }), + 'apps.byAppId.server.get': () => ({ + data: mockBuiltInEnvironment.mcpServerDetail, + }), + }) +}) vi.mock('@/next/navigation', async (importOriginal) => ({ ...(await importOriginal()), diff --git a/web/app/components/app/deploy/built-in-environment-card/index.tsx b/web/app/components/app/deploy/built-in-environment-card/index.tsx index c576f94b04315b..ca83bbecce13b2 100644 --- a/web/app/components/app/deploy/built-in-environment-card/index.tsx +++ b/web/app/components/app/deploy/built-in-environment-card/index.tsx @@ -10,7 +10,6 @@ import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types' import useTimestamp from '@/hooks/use-timestamp' import { useParams } from '@/next/navigation' import { consoleQuery } from '@/service/console' -import { useMCPServerDetail } from '@/service/use-tools' import { appWorkflowQueryOptions } from '@/service/workflow-queries' import { AccessPointIcon } from '../shared/access-point-icon' import { RuntimeStateIndicator } from '../shared/runtime-state' @@ -30,7 +29,11 @@ export const BuiltInEnvironmentCard = memo( consoleQuery.apps.byAppId.get.queryOptions({ input: { params: { app_id: appId } } }), ) const { data: publishedWorkflow } = useQuery(appWorkflowQueryOptions(appId)) - const { data: mcpServerDetail } = useMCPServerDetail(appId) + const { data: mcpServerDetail } = useQuery( + consoleQuery.apps.byAppId.server.get.queryOptions({ + input: { params: { app_id: appId } }, + }), + ) const publishedNodes = Array.isArray(publishedWorkflow?.graph.nodes) ? (publishedWorkflow.graph.nodes as Node[]) : [] diff --git a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/use-provider-icon.spec.tsx b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/use-provider-icon.spec.tsx index d698f9f945d97d..b3975a4347910c 100644 --- a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/use-provider-icon.spec.tsx +++ b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/use-provider-icon.spec.tsx @@ -10,12 +10,15 @@ import { useGetProviderIcon } from '../use-provider-icon' const request = vi.hoisted(() => vi.fn<(url: string) => Promise>()) vi.mock('@/service/base', () => ({ request })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({}), - useAllCustomTools: () => ({}), - useAllWorkflowTools: () => ({}), - useAllMCPTools: () => ({}), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({}), + 'workspaces.current.tools.api.get': () => ({}), + 'workspaces.current.tools.workflow.get': () => ({}), + 'workspaces.current.tools.mcp.get': () => ({}), + }) +}) vi.mock('@/service/use-triggers', () => ({ useAllTriggerPlugins: () => ({}) })) vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }) })) vi.mock('@/utils/var', async (importOriginal) => ({ diff --git a/web/app/components/app/deploy/shared/deployment-configuration/use-provider-icon.ts b/web/app/components/app/deploy/shared/deployment-configuration/use-provider-icon.ts index efc796759d24a0..3ec0579b15aae4 100644 --- a/web/app/components/app/deploy/shared/deployment-configuration/use-provider-icon.ts +++ b/web/app/components/app/deploy/shared/deployment-configuration/use-provider-icon.ts @@ -2,16 +2,11 @@ import type { UnsupportedNode } from '@dify/contracts/enterprise-app-deploy/type import type { Node } from '@/app/components/workflow/types' import { useQuery } from '@tanstack/react-query' import { useCallback } from 'react' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { findNodeIcon } from '@/app/components/workflow/hooks/use-tool-icon' import { BlockEnum } from '@/app/components/workflow/types' import useTheme from '@/hooks/use-theme' import { consoleQuery } from '@/service/console' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' import { useAllTriggerPlugins } from '@/service/use-triggers' const PROVIDER_ICON_BLOCK_TYPES = new Set([ @@ -35,10 +30,30 @@ export const useGetProviderIcon = (nodes: UnsupportedNode[]) => { const hasToolNode = nodes.some((node) => node.type === BlockEnum.Tool) const hasDataSourceNode = nodes.some((node) => node.type === BlockEnum.DataSource) const hasTriggerPluginNode = nodes.some((node) => node.type === BlockEnum.TriggerPlugin) - const { data: buildInTools } = useAllBuiltInTools(hasToolNode) - const { data: customTools } = useAllCustomTools(hasToolNode) - const { data: workflowTools } = useAllWorkflowTools(hasToolNode) - const { data: mcpTools } = useAllMCPTools(hasToolNode) + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + enabled: hasToolNode, + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ + enabled: hasToolNode, + select: selectToolsWithProvider, + }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + enabled: hasToolNode, + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ + enabled: hasToolNode, + select: selectToolsWithProvider, + }), + ) const { data: dataSourceList } = useQuery( consoleQuery.rag.pipelines.datasourcePlugins.get.queryOptions({ enabled: hasDataSourceNode, diff --git a/web/app/components/explore/try-app/preview/basic-app-preview.tsx b/web/app/components/explore/try-app/preview/basic-app-preview.tsx index 86ceddfef54391..f1ad3320e10752 100644 --- a/web/app/components/explore/try-app/preview/basic-app-preview.tsx +++ b/web/app/components/explore/try-app/preview/basic-app-preview.tsx @@ -3,8 +3,10 @@ import type { TrialAppDetailResponse } from '@dify/contracts/api/console/trial-a import type { FC } from 'react' import type { Features as FeaturesData, FileUpload } from '@/app/components/base/features/types' import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { Collection } from '@/app/components/tools/types' import type { ModelConfig } from '@/models/debug' import type { PromptVariable } from '@/types/app' +import { useQuery } from '@tanstack/react-query' import { noop } from 'es-toolkit/function' import { clone } from 'es-toolkit/object' import * as React from 'react' @@ -16,6 +18,7 @@ import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { CollectionType } from '@/app/components/tools/types' +import { selectToolCollections } from '@/app/components/tools/utils/query-select' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { ANNOTATION_DEFAULT, @@ -26,7 +29,7 @@ import { import ConfigContext from '@/context/debug-configuration' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { PromptMode } from '@/models/debug' -import { useAllToolProviders } from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { useGetTryAppDataSets } from '@/service/use-try-app' import { AgentStrategy, ModelModeType, Resolution, TransferMethod, TtsAutoPlay } from '@/types/app' import { correctModelProvider, correctToolProvider } from '@/utils' @@ -295,7 +298,7 @@ const normalizeExternalDataToolFormItem = (item: Record) => { const normalizeAgentTool = ( tool: Record, deletedTools: TrialAppDetailResponse['deleted_tools'] | undefined, - collectionList: ReturnType['data'] | undefined, + collectionList: Collection[] | undefined, ): AgentToolItem => { const providerId = getString(tool.provider_id) const providerName = getString(tool.provider_name) @@ -330,8 +333,12 @@ const BasicAppPreview: FC = ({ appId, appDetail }) => { const media = useBreakpoints() const isMobile = media === MediaType.mobile - const { data: collectionListFromServer, isLoading: isLoadingToolProviders } = - useAllToolProviders() + const { data: collectionListFromServer, isLoading: isLoadingToolProviders } = useQuery( + consoleQuery.workspaces.current.toolProviders.get.queryOptions({ + input: { query: {} }, + select: selectToolCollections, + }), + ) const collectionList = collectionListFromServer?.map((item) => { return { ...item, diff --git a/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx b/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx index 2f1830cd2d29c7..a2aa4bfec22825 100644 --- a/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx +++ b/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx @@ -96,11 +96,22 @@ const mockRefetch = vi.fn() const mockUseAllToolProviders = vi.hoisted(() => vi.fn()) const mockUseAllCustomTools = vi.hoisted(() => vi.fn()) const mockUseAllWorkflowTools = vi.hoisted(() => vi.fn()) -vi.mock('@/service/use-tools', () => ({ - useAllToolProviders: (enabled?: boolean, type?: string) => mockUseAllToolProviders(enabled, type), - useAllCustomTools: (enabled?: boolean) => mockUseAllCustomTools(enabled), - useAllWorkflowTools: (enabled?: boolean) => mockUseAllWorkflowTools(enabled), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.toolProviders.get': ({ + input, + enabled, + }: { + input: { query: { type?: string } } + enabled?: boolean + }) => mockUseAllToolProviders(enabled, input.query.type), + 'workspaces.current.tools.api.get': ({ enabled }: { enabled?: boolean }) => + mockUseAllCustomTools(enabled), + 'workspaces.current.tools.workflow.get': ({ enabled }: { enabled?: boolean }) => + mockUseAllWorkflowTools(enabled), + }) +}) const mockConsoleState = vi.hoisted(() => ({ workspacePermissionKeys: ['tool.manage', 'mcp.manage'] as string[], diff --git a/web/app/components/integrations/tool-provider-list.tsx b/web/app/components/integrations/tool-provider-list.tsx index e7ef086a3f4cda..10906bda1d0e42 100644 --- a/web/app/components/integrations/tool-provider-list.tsx +++ b/web/app/components/integrations/tool-provider-list.tsx @@ -11,7 +11,7 @@ import { ScrollAreaThumb, ScrollAreaViewport, } from '@langgenius/dify-ui/scroll-area' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { isSearchResultEmpty } from '@/app/components/base/search-input/search-state' @@ -32,9 +32,13 @@ import { BuiltinMarketplacePanel } from '@/app/components/tools/marketplace/buil import MCPList from '@/app/components/tools/mcp' import ProviderDetail from '@/app/components/tools/provider/detail' import { ToolProviderGrid } from '@/app/components/tools/tool-provider-grid' +import { + selectToolCollections, + selectToolsWithProvider, +} from '@/app/components/tools/utils/query-select' import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import { consoleQuery } from '@/service/console' import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins' -import { useAllCustomTools, useAllToolProviders, useAllWorkflowTools } from '@/service/use-tools' import { useToolProviderCategory } from './hooks/use-tool-provider-category' import ToolProviderCreateAction from './tool-provider-create-action' import { ToolProviderToolbar } from './tool-provider-toolbar' @@ -85,12 +89,25 @@ const ProviderList = ({ category, contentInset = 'default', layout }: ProviderLi const handleCreatedMCPProviderHandled = useCallback(() => { setCreatedMCPProviderId(undefined) }, []) - const allToolProvidersQuery = useAllToolProviders( - activeTab === 'builtin' || activeTab === 'mcp', - activeTab === 'mcp' ? 'mcp' : undefined, + const allToolProvidersQuery = useQuery( + consoleQuery.workspaces.current.toolProviders.get.queryOptions({ + input: { query: activeTab === 'mcp' ? { type: 'mcp' } : {} }, + enabled: activeTab === 'builtin' || activeTab === 'mcp', + select: selectToolCollections, + }), + ) + const customToolsQuery = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ + enabled: activeTab === 'api', + select: selectToolsWithProvider, + }), + ) + const workflowToolsQuery = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + enabled: activeTab === 'workflow', + select: selectToolsWithProvider, + }), ) - const customToolsQuery = useAllCustomTools(activeTab === 'api') - const workflowToolsQuery = useAllWorkflowTools(activeTab === 'workflow') const { refetch: refetchMcpTools } = allToolProvidersQuery const activeToolsQuery = activeTab === 'api' diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts index ec5a86d312079a..0fd9b191a5555c 100644 --- a/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts @@ -12,10 +12,7 @@ const mocks = vi.hoisted(() => ({ installed: vi.fn(), checkInstalled: vi.fn(), defaultModel: vi.fn(), - toolProviders: vi.fn(), - builtInTools: vi.fn(), triggers: vi.fn(), - recommended: vi.fn(), })) vi.mock('@/service/base', () => ({ request: mocks.request })) @@ -26,11 +23,6 @@ vi.mock('@/service/use-plugins', () => ({ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useInvalidateDefaultModel: () => mocks.defaultModel, })) -vi.mock('@/service/use-tools', () => ({ - useInvalidateAllToolProviders: () => mocks.toolProviders, - useInvalidateAllBuiltInTools: () => mocks.builtInTools, - useInvalidateRAGRecommendedPlugins: () => mocks.recommended, -})) vi.mock('@/service/use-triggers', () => ({ useInvalidateAllTriggerPlugins: () => mocks.triggers, })) @@ -51,6 +43,17 @@ const providerKeys = [ ] const datasourceKey = consoleQuery.rag.pipelines.datasourcePlugins.get.queryKey() const dataSourceAuthKey = consoleQuery.auth.plugin.datasource.list.get.queryKey() +const toolKeys = [ + current.toolProviders.get.queryKey({ input: { query: {} } }), + current.toolProviders.get.queryKey({ input: { query: { type: 'mcp' } } }), + current.tools.builtin.get.queryKey(), + consoleQuery.rag.pipelines.recommendedPlugins.get.queryKey({ + input: { query: { type: 'tool' } }, + }), +] +const otherRecommendedKey = consoleQuery.rag.pipelines.recommendedPlugins.get.queryKey({ + input: { query: { type: 'datasource' } }, +}) const unrelatedKey = consoleQuery.account.profile.get.key() const modelTypes = ['llm', 'text-embedding', 'rerank', 'speech2text', 'tts'] @@ -69,6 +72,7 @@ describe('plugin installation refresh', () => { client.setQueryData(key, { cached: true }) client.setQueryData(datasourceKey, []) client.setQueryData(dataSourceAuthKey, { result: [] }) + for (const key of [...toolKeys, otherRecommendedKey]) client.setQueryData(key, []) for (const model_type of modelTypes) { client.setQueryData( current.models.modelTypes.byModelType.get.queryKey({ input: { params: { model_type } } }), @@ -105,11 +109,9 @@ describe('plugin installation refresh', () => { expect(client.getQueryState(datasourceKey)?.isInvalidated).toBe( category === PluginCategoryEnum.datasource, ) - if (category === PluginCategoryEnum.tool) { - expect(mocks.toolProviders).toHaveBeenCalledOnce() - expect(mocks.builtInTools).toHaveBeenCalledOnce() - expect(mocks.recommended).toHaveBeenCalledWith('tool') - } + for (const key of toolKeys) + expect(client.getQueryState(key)?.isInvalidated).toBe(category === PluginCategoryEnum.tool) + expect(client.getQueryState(otherRecommendedKey)?.isInvalidated).toBe(false) if (category === PluginCategoryEnum.datasource) { expect(client.getQueryState(datasourceKey)?.isInvalidated).toBe(true) expect(client.getQueryState(dataSourceAuthKey)?.isInvalidated).toBe(true) @@ -136,12 +138,10 @@ describe('plugin installation refresh', () => { expect(client.getQueryState(datasourceKey)?.isInvalidated).toBe(all) if (all) { expect(mocks.installed).toHaveBeenCalledWith() - expect(mocks.toolProviders).toHaveBeenCalledOnce() - expect(mocks.builtInTools).toHaveBeenCalledOnce() + for (const key of toolKeys) expect(client.getQueryState(key)?.isInvalidated).toBe(true) expect(client.getQueryState(datasourceKey)?.isInvalidated).toBe(true) expect(client.getQueryState(dataSourceAuthKey)?.isInvalidated).toBe(true) expect(mocks.triggers).toHaveBeenCalledOnce() - expect(mocks.recommended).toHaveBeenCalledWith('tool') } }) @@ -156,7 +156,7 @@ describe('plugin installation refresh', () => { expect(mocks.checkInstalled).toHaveBeenCalledOnce() for (const key of [...strategyKeys, ...providerKeys]) expect(client.getQueryState(key)?.isInvalidated).toBe(false) - expect(mocks.toolProviders).not.toHaveBeenCalled() + for (const key of toolKeys) expect(client.getQueryState(key)?.isInvalidated).toBe(false) expect(client.getQueryState(datasourceKey)?.isInvalidated).toBe(false) expect(mocks.triggers).not.toHaveBeenCalled() expect(mocks.request).not.toHaveBeenCalled() diff --git a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx index 61a6d1428f7ff9..e8ae6fecbe32da 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx +++ b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx @@ -7,11 +7,6 @@ import { useInvalidateCheckInstalled, useInvalidateInstalledPluginList, } from '@/service/use-plugins' -import { - useInvalidateAllBuiltInTools, - useInvalidateAllToolProviders, - useInvalidateRAGRecommendedPlugins, -} from '@/service/use-tools' import { useInvalidateAllTriggerPlugins } from '@/service/use-triggers' import { PluginCategoryEnum } from '../../types' @@ -58,12 +53,7 @@ const useRefreshPluginList = () => { const invalidateDefaultModel = useInvalidateDefaultModel() const queryClient = useQueryClient() - const invalidateAllToolProviders = useInvalidateAllToolProviders() - const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools() - const invalidateAllTriggerPlugins = useInvalidateAllTriggerPlugins() - - const invalidateRAGRecommendedPlugins = useInvalidateRAGRecommendedPlugins() return { refreshPluginList: ( manifest?: PluginManifestInMarket | Plugin | PluginDeclaration | PluginCategoryPayload | null, @@ -76,9 +66,17 @@ const useRefreshPluginList = () => { // tool page, tool select if ((manifest && PluginCategoryEnum.tool.includes(manifest.category)) || refreshAllType) { - invalidateAllToolProviders() - invalidateAllBuiltInTools() - invalidateRAGRecommendedPlugins('tool') + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.toolProviders.get.key(), + }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.tools.builtin.get.queryKey(), + }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.rag.pipelines.recommendedPlugins.get.queryKey({ + input: { query: { type: 'tool' } }, + }), + }) // TODO: update suggested tools. It's a function in hook useMarketplacePlugins,handleUpdatePlugins } diff --git a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx index b0c8297af63b03..8f1356f844cfca 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx @@ -40,10 +40,6 @@ vi.mock('@/service/use-plugins-auth', () => ({ useGetPluginCredentialSchema: () => ({ data: undefined, isLoading: false }), })) -vi.mock('@/service/use-tools', () => ({ - useInvalidToolsByType: () => vi.fn(), -})) - const mockIsCurrentWorkspaceManager = vi.fn() vi.mock('@/hooks/use-oauth', () => ({ diff --git a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx index c2c9e3cbf6f0a3..93dc802bc8153b 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx @@ -33,10 +33,6 @@ vi.mock('@/service/use-plugins-auth', () => ({ useGetPluginCredentialSchema: () => ({ data: undefined, isLoading: false }), })) -vi.mock('@/service/use-tools', () => ({ - useInvalidToolsByType: () => vi.fn(), -})) - const mockIsCurrentWorkspaceManager = vi.fn() const mockUserProfile = { id: 'test-user', diff --git a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-credential.spec.ts b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-credential.spec.ts index f7f65d5a753eca..ee4942f53898e7 100644 --- a/web/app/components/plugins/plugin-auth/hooks/__tests__/use-credential.spec.ts +++ b/web/app/components/plugins/plugin-auth/hooks/__tests__/use-credential.spec.ts @@ -1,6 +1,9 @@ import type { PluginPayload } from '../../types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { renderHook } from '@testing-library/react' +import { createElement } from 'react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { consoleQuery } from '@/service/console' import { AuthCategory, CredentialTypeEnum } from '../../types' import { useAddPluginCredentialHook, @@ -30,7 +33,6 @@ const mockUseGetPluginOAuthClientSchema = vi.fn().mockReturnValue({ data: null, const mockUseInvalidPluginOAuthClientSchema = vi.fn().mockReturnValue(vi.fn()) const mockUseSetPluginOAuthCustomClient = vi.fn().mockReturnValue({ mutateAsync: vi.fn() }) const mockUseDeletePluginOAuthCustomClient = vi.fn().mockReturnValue({ mutateAsync: vi.fn() }) -const mockInvalidToolsByType = vi.fn() vi.mock('@/service/use-plugins-auth', () => ({ useGetPluginCredentialInfo: (...args: unknown[]) => mockUseGetPluginCredentialInfo(...args), @@ -50,10 +52,6 @@ vi.mock('@/service/use-plugins-auth', () => ({ mockUseDeletePluginOAuthCustomClient(...args), })) -vi.mock('@/service/use-tools', () => ({ - useInvalidToolsByType: () => mockInvalidToolsByType, -})) - const toolPayload = { category: AuthCategory.tool, provider: 'test-provider', @@ -90,13 +88,22 @@ describe('use-credential hooks', () => { describe('useInvalidPluginCredentialInfoHook', () => { it('should return a function that invalidates both credential info and tools', () => { - const { result } = renderHook(() => useInvalidPluginCredentialInfoHook(toolPayload)) + const queryClient = new QueryClient() + const builtInToolsKey = consoleQuery.workspaces.current.tools.builtin.get.queryKey() + const customToolsKey = consoleQuery.workspaces.current.tools.api.get.queryKey() + queryClient.setQueryData(builtInToolsKey, []) + queryClient.setQueryData(customToolsKey, []) + const { result } = renderHook(() => useInvalidPluginCredentialInfoHook(toolPayload), { + wrapper: ({ children }) => + createElement(QueryClientProvider, { client: queryClient }, children), + }) result.current() const invalidFn = mockUseInvalidPluginCredentialInfo.mock.results[0]!.value expect(invalidFn).toHaveBeenCalled() - expect(mockInvalidToolsByType).toHaveBeenCalled() + expect(queryClient.getQueryState(builtInToolsKey)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(customToolsKey)?.isInvalidated).toBe(false) }) }) diff --git a/web/app/components/plugins/plugin-auth/hooks/use-credential.ts b/web/app/components/plugins/plugin-auth/hooks/use-credential.ts index 80e7151ab21033..a9150a50cb45a2 100644 --- a/web/app/components/plugins/plugin-auth/hooks/use-credential.ts +++ b/web/app/components/plugins/plugin-auth/hooks/use-credential.ts @@ -1,4 +1,6 @@ import type { CredentialTypeEnum, PluginPayload } from '../types' +import { useQueryClient } from '@tanstack/react-query' +import { getToolListQueryKey } from '@/app/components/tools/utils/tool-list-query-key' import { useAddPluginCredential, useDeletePluginCredential, @@ -13,7 +15,6 @@ import { useSetPluginOAuthCustomClient, useUpdatePluginCredential, } from '@/service/use-plugins-auth' -import { useInvalidToolsByType } from '@/service/use-tools' import { useGetApi } from './use-get-api' export const useGetPluginCredentialInfoHook = ( @@ -41,12 +42,12 @@ export const useDeletePluginCredentialHook = (pluginPayload: PluginPayload) => { export const useInvalidPluginCredentialInfoHook = (pluginPayload: PluginPayload) => { const apiMap = useGetApi(pluginPayload) const invalidPluginCredentialInfo = useInvalidPluginCredentialInfo(apiMap.getCredentialInfo) - const providerType = pluginPayload.providerType - const invalidToolsByType = useInvalidToolsByType(providerType) + const queryClient = useQueryClient() + const toolListQueryKey = getToolListQueryKey(pluginPayload.providerType) return () => { invalidPluginCredentialInfo() - invalidToolsByType() + if (toolListQueryKey) queryClient.invalidateQueries({ queryKey: toolListQueryKey }) } } diff --git a/web/app/components/plugins/plugin-detail-panel/action-list.tsx b/web/app/components/plugins/plugin-detail-panel/action-list.tsx index 892520f0e20d1e..761e90a6e8c747 100644 --- a/web/app/components/plugins/plugin-detail-panel/action-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/action-list.tsx @@ -1,9 +1,14 @@ import type { PluginDetail } from '@/app/components/plugins/types' +import { useQuery } from '@tanstack/react-query' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import ToolItem from '@/app/components/tools/provider/tool-item' -import { useAllToolProviders, useBuiltinTools } from '@/service/use-tools' +import { + selectBuiltinProviderTools, + selectToolCollections, +} from '@/app/components/tools/utils/query-select' +import { consoleQuery } from '@/service/console' type Props = Readonly<{ detail: PluginDetail @@ -13,11 +18,22 @@ const ActionList = ({ detail }: Props) => { const { t } = useTranslation(['plugin']) const providerBriefInfo = detail.declaration?.tool?.identity const providerKey = providerBriefInfo ? `${detail.plugin_id}/${providerBriefInfo.name}` : '' - const { data: collectionList = [] } = useAllToolProviders() + const { data: collectionList = [] } = useQuery( + consoleQuery.workspaces.current.toolProviders.get.queryOptions({ + input: { query: {} }, + select: selectToolCollections, + }), + ) const provider = useMemo(() => { return collectionList.find((collection) => collection.name === providerKey) }, [collectionList, providerKey]) - const { data } = useBuiltinTools(providerKey) + const { data } = useQuery( + consoleQuery.workspaces.current.toolProvider.builtin.byProvider.tools.get.queryOptions({ + input: { params: { provider: providerKey } }, + enabled: !!providerKey, + select: selectBuiltinProviderTools, + }), + ) if (!providerKey || !data || !provider) return null diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx index 0ef3bb63d0c5e3..3069b579325d9c 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx @@ -24,17 +24,20 @@ vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }), })) -vi.mock('@/service/use-tools', () => ({ - useAllToolProviders: () => ({ - data: [ - { - name: 'tool-plugin/provider-a', - type: 'builtin', - allow_delete: true, - }, - ], - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.toolProviders.get': () => ({ + data: [ + { + name: 'tool-plugin/provider-a', + type: 'builtin', + allow_delete: true, + }, + ], + }), + }) +}) vi.mock('@/service/use-plugins', () => ({ useVersionListOfPlugin: () => ({ data: { data: { versions: [] } } }), diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx index f318f7864f28c3..a95283c041a2ce 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx @@ -15,11 +15,12 @@ import { OperationDropdown } from '@/app/components/plugins/plugin-detail-panel/ import { BUILTIN_TOOLS_ARRAY } from '@/app/components/plugins/readme-panel/constants' import { useReadmePanelStore } from '@/app/components/plugins/readme-panel/store' import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker' +import { selectToolCollections } from '@/app/components/tools/utils/query-select' import { API_PREFIX } from '@/config' import { useGetLanguage } from '@/context/i18n' import { userProfileQueryOptions } from '@/features/account-profile/client' import useTheme from '@/hooks/use-theme' -import { useAllToolProviders } from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { getMarketplaceUrl } from '@/utils/var' import Verified from '../../base/badges/verified' import DeprecationNotice from '../../base/deprecation-notice' @@ -134,7 +135,13 @@ const DetailHeader = ({ const isTool = category === PluginCategoryEnum.tool const providerBriefInfo = tool?.identity const providerKey = `${plugin_id}/${providerBriefInfo?.name}` - const { data: collectionList = [] } = useAllToolProviders(isTool) + const { data: collectionList = [] } = useQuery( + consoleQuery.workspaces.current.toolProviders.get.queryOptions({ + input: { query: {} }, + enabled: isTool, + select: selectToolCollections, + }), + ) const provider = useMemo(() => { return collectionList.find((collection) => collection.name === providerKey) }, [collectionList, providerKey]) diff --git a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/focus-restoration.spec.tsx b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/focus-restoration.spec.tsx index 97e0bc22ca0af5..b633fccbf85ffb 100644 --- a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/focus-restoration.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/focus-restoration.spec.tsx @@ -1,23 +1,26 @@ import type { ToolValue } from '@/app/components/workflow/block-selector/types' import type { ToolWithProvider } from '@/app/components/workflow/types' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' import { CollectionType } from '@/app/components/tools/types' import { MCPToolAvailabilityProvider } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability' +import { renderWithConsoleQuery } from '@/test/console/query-data' import MultipleToolSelector from '../index' const mockTools = vi.hoisted(() => ({ builtIn: [] as ToolWithProvider[], })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: mockTools.builtIn, isFetched: true }), - useAllCustomTools: () => ({ data: [], isFetched: true }), - useAllMCPTools: () => ({ data: [], isFetched: true }), - useAllWorkflowTools: () => ({ data: [], isFetched: true }), - useInvalidateAllBuiltInTools: () => vi.fn(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: mockTools.builtIn, isFetched: true }), + 'workspaces.current.tools.api.get': () => ({ data: [], isFetched: true }), + 'workspaces.current.tools.mcp.get': () => ({ data: [], isFetched: true }), + 'workspaces.current.tools.workflow.get': () => ({ data: [], isFetched: true }), + }) +}) vi.mock('@/service/use-plugins', () => ({ useCheckInstalled: () => ({ data: undefined }), @@ -77,7 +80,7 @@ function renderSelector(initialValue: ToolValue[]) { ) } - return render() + return renderWithConsoleQuery() } describe('MultipleToolSelector focus restoration', () => { diff --git a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx index f6efaa7dce11da..b1461060777209 100644 --- a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx @@ -14,11 +14,14 @@ import MultipleToolSelector from '../index' // Mock useAllMCPTools hook const mockMCPToolsData = vi.fn<() => ToolWithProvider[] | undefined>(() => undefined) -vi.mock('@/service/use-tools', () => ({ - useAllMCPTools: () => ({ - data: mockMCPToolsData(), - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.mcp.get': () => ({ + data: mockMCPToolsData(), + }), + }) +}) // Track edit tool index for unique test IDs let editToolIndex = 0 diff --git a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx index f9678e939589c8..c1d9a13c04ce65 100644 --- a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx @@ -7,11 +7,13 @@ import { IconButton } from '@langgenius/dify-ui/icon-button' import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/infotip' import { Separator } from '@langgenius/dify-ui/separator' import { useRefWithInit } from '@langgenius/dify-ui/use-ref-with-init' +import { useQuery } from '@tanstack/react-query' import * as React from 'react' import { useTranslation } from 'react-i18next' import ToolSelector from '@/app/components/plugins/plugin-detail-panel/tool-selector' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability' -import { useAllMCPTools } from '@/service/use-tools' +import { consoleQuery } from '@/service/console' type Props = Readonly<{ disabled?: boolean @@ -46,7 +48,9 @@ const MultipleToolSelector = ({ const { t } = useTranslation(['appDebug', 'plugin']) const { allowed: isMCPToolAllowed } = useMCPToolAvailability() - const { data: mcpTools } = useAllMCPTools() + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const addToolButtonRef = React.useRef(null) const toolItemTriggerByKeyRef = useRefWithInit(() => new Map()) const pendingFocusTargetRef = React.useRef<{ toolKey?: string } | null>(null) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector.spec.ts index 4ea540d54b5592..ca1c24c6b297a7 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector.spec.ts @@ -2,8 +2,13 @@ import type { ToolValue } from '@/app/components/workflow/block-selector/types' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { CollectionType } from '@/app/components/tools/types' +import { createQueryClientWrapper } from '@/test/console/query-client' +import { createTestQueryClient } from '@/test/query-client' import { useToolSelector } from '../use-tool-selector' +const queryClient = createTestQueryClient() +const queryClientWrapper = createQueryClientWrapper(queryClient) + const mockToolParams = [ { name: 'param1', form: 'llm', type: 'string', required: true, label: { en_US: 'Param 1' } }, { name: 'param2', form: 'form', type: 'number', required: false, label: { en_US: 'Param 2' } }, @@ -27,13 +32,18 @@ const mockTools = [ ] let areToolQueriesFetched = true -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: mockTools, isFetched: areToolQueriesFetched }), - useAllCustomTools: () => ({ data: [], isFetched: areToolQueriesFetched }), - useAllWorkflowTools: () => ({ data: [], isFetched: areToolQueriesFetched }), - useAllMCPTools: () => ({ data: [], isFetched: areToolQueriesFetched }), - useInvalidateAllBuiltInTools: () => vi.fn().mockResolvedValue(undefined), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ + data: mockTools, + isFetched: areToolQueriesFetched, + }), + 'workspaces.current.tools.api.get': () => ({ data: [], isFetched: areToolQueriesFetched }), + 'workspaces.current.tools.workflow.get': () => ({ data: [], isFetched: areToolQueriesFetched }), + 'workspaces.current.tools.mcp.get': () => ({ data: [], isFetched: areToolQueriesFetched }), + }) +}) vi.mock('@/service/use-plugins', () => ({ useInvalidateInstalledPluginList: () => vi.fn().mockResolvedValue(undefined), @@ -88,7 +98,9 @@ describe('useToolSelector', () => { }) it('should initialize with default panel states', () => { - const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect })) + const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect }), { + wrapper: queryClientWrapper, + }) expect(result.current.isShow).toBe(false) expect(result.current.isShowChooseTool).toBe(false) @@ -96,8 +108,9 @@ describe('useToolSelector', () => { }) it('should find current provider from tool value', () => { - const { result } = renderHook(() => - useToolSelector({ value: toolValue, onSelect: mockOnSelect }), + const { result } = renderHook( + () => useToolSelector({ value: toolValue, onSelect: mockOnSelect }), + { wrapper: queryClientWrapper }, ) expect(result.current.currentProvider).toBeDefined() @@ -105,8 +118,9 @@ describe('useToolSelector', () => { }) it('should find current tool from provider', () => { - const { result } = renderHook(() => - useToolSelector({ value: toolValue, onSelect: mockOnSelect }), + const { result } = renderHook( + () => useToolSelector({ value: toolValue, onSelect: mockOnSelect }), + { wrapper: queryClientWrapper }, ) expect(result.current.currentTool).toBeDefined() @@ -114,8 +128,9 @@ describe('useToolSelector', () => { }) it('should compute tool settings and params correctly', () => { - const { result } = renderHook(() => - useToolSelector({ value: toolValue, onSelect: mockOnSelect }), + const { result } = renderHook( + () => useToolSelector({ value: toolValue, onSelect: mockOnSelect }), + { wrapper: queryClientWrapper }, ) // param2 has form='form' (not 'llm'), so it goes to settings @@ -128,8 +143,9 @@ describe('useToolSelector', () => { }) it('should show tab slider when both settings and params exist', () => { - const { result } = renderHook(() => - useToolSelector({ value: toolValue, onSelect: mockOnSelect }), + const { result } = renderHook( + () => useToolSelector({ value: toolValue, onSelect: mockOnSelect }), + { wrapper: queryClientWrapper }, ) expect(result.current.showTabSlider).toBe(true) @@ -138,7 +154,9 @@ describe('useToolSelector', () => { }) it('should toggle panel visibility', () => { - const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect })) + const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect }), { + wrapper: queryClientWrapper, + }) act(() => { result.current.setIsShow(true) @@ -152,7 +170,9 @@ describe('useToolSelector', () => { }) it('should switch tab type', () => { - const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect })) + const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect }), { + wrapper: queryClientWrapper, + }) act(() => { result.current.setCurrType('params') @@ -161,8 +181,9 @@ describe('useToolSelector', () => { }) it('should handle description change', () => { - const { result } = renderHook(() => - useToolSelector({ value: toolValue, onSelect: mockOnSelect }), + const { result } = renderHook( + () => useToolSelector({ value: toolValue, onSelect: mockOnSelect }), + { wrapper: queryClientWrapper }, ) act(() => { @@ -177,8 +198,9 @@ describe('useToolSelector', () => { }) it('should handle enabled change', () => { - const { result } = renderHook(() => - useToolSelector({ value: toolValue, onSelect: mockOnSelect }), + const { result } = renderHook( + () => useToolSelector({ value: toolValue, onSelect: mockOnSelect }), + { wrapper: queryClientWrapper }, ) act(() => { @@ -193,8 +215,9 @@ describe('useToolSelector', () => { }) it('should handle authorization item click', () => { - const { result } = renderHook(() => - useToolSelector({ value: toolValue, onSelect: mockOnSelect }), + const { result } = renderHook( + () => useToolSelector({ value: toolValue, onSelect: mockOnSelect }), + { wrapper: queryClientWrapper }, ) act(() => { @@ -209,7 +232,9 @@ describe('useToolSelector', () => { }) it('should not call onSelect if value is undefined', () => { - const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect })) + const { result } = renderHook(() => useToolSelector({ onSelect: mockOnSelect }), { + wrapper: queryClientWrapper, + }) act(() => { result.current.handleEnabledChange(true) @@ -218,11 +243,13 @@ describe('useToolSelector', () => { }) it('should return empty arrays when no provider matches', () => { - const { result } = renderHook(() => - useToolSelector({ - value: { ...toolValue, provider_name: 'nonexistent' }, - onSelect: mockOnSelect, - }), + const { result } = renderHook( + () => + useToolSelector({ + value: { ...toolValue, provider_name: 'nonexistent' }, + onSelect: mockOnSelect, + }), + { wrapper: queryClientWrapper }, ) expect(result.current.currentProvider).toBeUndefined() @@ -232,7 +259,9 @@ describe('useToolSelector', () => { }) it('should skip plugin checks after resolving the current provider and tool', () => { - renderHook(() => useToolSelector({ value: toolValue, onSelect: mockOnSelect })) + renderHook(() => useToolSelector({ value: toolValue, onSelect: mockOnSelect }), { + wrapper: queryClientWrapper, + }) expect(mockUsePluginInstalledCheck).toHaveBeenCalledWith({ providerPluginId: 'org/test-plugin', @@ -241,11 +270,13 @@ describe('useToolSelector', () => { }) it('should keep plugin checks enabled when the current tool cannot be resolved', () => { - renderHook(() => - useToolSelector({ - value: { ...toolValue, tool_name: 'missing-tool' }, - onSelect: mockOnSelect, - }), + renderHook( + () => + useToolSelector({ + value: { ...toolValue, tool_name: 'missing-tool' }, + onSelect: mockOnSelect, + }), + { wrapper: queryClientWrapper }, ) expect(mockUsePluginInstalledCheck).toHaveBeenCalledWith({ @@ -255,15 +286,17 @@ describe('useToolSelector', () => { }) it('should keep marketplace fallback enabled after tool queries settle without resolving the provider', () => { - renderHook(() => - useToolSelector({ - value: { - ...toolValue, - provider_name: 'org/market-plugin/search', - plugin_id: 'org/market-plugin', - }, - onSelect: mockOnSelect, - }), + renderHook( + () => + useToolSelector({ + value: { + ...toolValue, + provider_name: 'org/market-plugin/search', + plugin_id: 'org/market-plugin', + }, + onSelect: mockOnSelect, + }), + { wrapper: queryClientWrapper }, ) expect(mockUsePluginInstalledCheck).toHaveBeenCalledWith({ @@ -275,15 +308,17 @@ describe('useToolSelector', () => { it('should keep marketplace fallback enabled when legacy provider lists are still pending but plugin id is known', () => { areToolQueriesFetched = false - renderHook(() => - useToolSelector({ - value: { - ...toolValue, - provider_name: 'org/market-plugin/search', - plugin_id: 'org/market-plugin', - }, - onSelect: mockOnSelect, - }), + renderHook( + () => + useToolSelector({ + value: { + ...toolValue, + provider_name: 'org/market-plugin/search', + plugin_id: 'org/market-plugin', + }, + onSelect: mockOnSelect, + }), + { wrapper: queryClientWrapper }, ) expect(mockUsePluginInstalledCheck).toHaveBeenCalledWith({ @@ -293,16 +328,18 @@ describe('useToolSelector', () => { }) it('should skip marketplace checks for unresolved non-plugin workflow tools', () => { - renderHook(() => - useToolSelector({ - value: { - ...toolValue, - provider_name: 'author/tool-b', - plugin_id: undefined, - type: CollectionType.workflow, - }, - onSelect: mockOnSelect, - }), + renderHook( + () => + useToolSelector({ + value: { + ...toolValue, + provider_name: 'author/tool-b', + plugin_id: undefined, + type: CollectionType.workflow, + }, + onSelect: mockOnSelect, + }), + { wrapper: queryClientWrapper }, ) expect(mockUsePluginInstalledCheck).toHaveBeenCalledWith({ diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-tool-selector.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-tool-selector.ts index 973c670a76fc28..dd8241f033667e 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-tool-selector.ts +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/use-tool-selector.ts @@ -3,23 +3,19 @@ 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 { useQuery, useQueryClient } from '@tanstack/react-query' import { useCallback, useMemo, useState } from 'react' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { CollectionType } from '@/app/components/tools/types' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { generateFormValue, getPlainValue, getStructureValue, toolParametersToFormSchemas, } from '@/app/components/tools/utils/to-form-schema' +import { consoleQuery } from '@/service/console' import { useInvalidateInstalledPluginList } from '@/service/use-plugins' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, - useInvalidateAllBuiltInTools, -} from '@/service/use-tools' import { getIconFromMarketPlace } from '@/utils/get-icon' import { matchesProviderReference } from '@/utils/provider-reference' import { usePluginInstalledCheck } from './use-plugin-installed-check' @@ -37,15 +33,27 @@ export function useToolSelector({ value, onSelect, onSelectMultiple }: UseToolSe const [isShowChooseTool, setIsShowChooseTool] = useState(false) const [currType, setCurrType] = useState('settings') - const buildInToolsQuery = useAllBuiltInTools() - const customToolsQuery = useAllCustomTools() - const workflowToolsQuery = useAllWorkflowTools() - const mcpToolsQuery = useAllMCPTools() + const buildInToolsQuery = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const customToolsQuery = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const workflowToolsQuery = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const mcpToolsQuery = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const { data: buildInTools } = buildInToolsQuery const { data: customTools } = customToolsQuery const { data: workflowTools } = workflowToolsQuery const { data: mcpTools } = mcpToolsQuery - const invalidateAllBuiltinTools = useInvalidateAllBuiltInTools() + const queryClient = useQueryClient() const invalidateInstalledPluginList = useInvalidateInstalledPluginList() const currentProvider = useMemo(() => { @@ -245,7 +253,9 @@ export function useToolSelector({ value, onSelect, onSelectMultiple }: UseToolSe const handleInstall = useCallback(async () => { try { - await invalidateAllBuiltinTools() + await queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.tools.builtin.get.queryKey(), + }) } catch (error) { console.error('Failed to invalidate built-in tools cache', error) } @@ -254,7 +264,7 @@ export function useToolSelector({ value, onSelect, onSelectMultiple }: UseToolSe } catch (error) { console.error('Failed to invalidate installed plugin list cache', error) } - }, [invalidateAllBuiltinTools, invalidateInstalledPluginList]) + }, [invalidateInstalledPluginList, queryClient]) const settingsValue = useMemo((): ResourceVarInputs => { return getPlainValue( diff --git a/web/app/components/tools/marketplace/__tests__/index.spec.tsx b/web/app/components/tools/marketplace/__tests__/index.spec.tsx index e49901b3012889..71649cfa384048 100644 --- a/web/app/components/tools/marketplace/__tests__/index.spec.tsx +++ b/web/app/components/tools/marketplace/__tests__/index.spec.tsx @@ -47,11 +47,6 @@ vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ useMarketplacePlugins: (...args: unknown[]) => mockUseMarketplacePlugins(...args), })) -const mockUseAllToolProviders = vi.fn() -vi.mock('@/service/use-tools', () => ({ - useAllToolProviders: (...args: unknown[]) => mockUseAllToolProviders(...args), -})) - vi.mock('@/utils/var', () => ({ getMarketplaceUrl: vi.fn(() => 'https://marketplace.test/market'), })) diff --git a/web/app/components/tools/mcp/__tests__/create-card.spec.tsx b/web/app/components/tools/mcp/__tests__/create-card.spec.tsx index b8fc457486c2f9..32926ef761e6a6 100644 --- a/web/app/components/tools/mcp/__tests__/create-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/create-card.spec.tsx @@ -12,11 +12,14 @@ import NewMCPCard, { NewMCPButton } from '../create-card' const mockCreateMCP = vi.fn().mockResolvedValue({ id: 'new-mcp-id', name: 'New MCP' }) // Mock the service -vi.mock('@/service/use-tools', () => ({ - useCreateMCP: () => ({ - mutateAsync: mockCreateMCP, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.toolProvider.mcp.post': () => ({ + mutateAsync: mockCreateMCP, + }), + }) +}) // Mock the MCP Modal type MockMCPModalProps = { @@ -196,8 +199,10 @@ describe('NewMCPCard', () => { await waitFor(() => { expect(mockCreateMCP).toHaveBeenCalledWith({ - name: 'Test MCP', - server_url: 'https://test.com', + body: { + name: 'Test MCP', + server_url: 'https://test.com', + }, }) expect(handleCreate).toHaveBeenCalled() }) diff --git a/web/app/components/tools/mcp/__tests__/index.spec.tsx b/web/app/components/tools/mcp/__tests__/index.spec.tsx index 4dd304794d2bdf..d74d6a24a2b9b8 100644 --- a/web/app/components/tools/mcp/__tests__/index.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/index.spec.tsx @@ -23,23 +23,26 @@ const mockConsoleState = vi.hoisted(() => ({ workspacePermissionKeys: ['mcp.manage'] as string[], })) -vi.mock('@/service/use-tools', () => ({ - useAllMCPTools: (enabled?: boolean) => { - mockUseAllMCPTools(enabled) - return { - data: mockProviders, - isLoading: mockIsLoadingToolProviders, - refetch: mockRefetch, - } - }, - useUpdateMCP: () => ({ - mutateAsync: mockUpdateMCP, - }), - useDeleteMCP: () => ({ - mutateAsync: mockDeleteMCP, - isPending: false, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.mcp.get': ({ enabled }: { enabled?: boolean }) => { + mockUseAllMCPTools(enabled) + return { + data: mockProviders, + isLoading: mockIsLoadingToolProviders, + refetch: mockRefetch, + } + }, + 'workspaces.current.toolProvider.mcp.put': () => ({ + mutateAsync: mockUpdateMCP, + }), + 'workspaces.current.toolProvider.mcp.delete': () => ({ + mutateAsync: mockDeleteMCP, + isPending: false, + }), + }) +}) vi.mock('@/context/permission-state', async () => { const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') @@ -475,9 +478,11 @@ describe('MCPList', () => { }) expect(mockUpdateMCP).toHaveBeenCalledWith({ - name: 'Updated MCP', - server_url: 'https://updated.com', - provider_id: '1', + body: { + name: 'Updated MCP', + server_url: 'https://updated.com', + provider_id: '1', + }, }) expect(mockRefetch).toHaveBeenCalled() expect(screen.getByTestId('detail-panel')).toBeInTheDocument() @@ -528,7 +533,7 @@ describe('MCPList', () => { await Promise.resolve() }) - expect(mockDeleteMCP).toHaveBeenCalledWith('1') + expect(mockDeleteMCP).toHaveBeenCalledWith({ body: { provider_id: '1' } }) expect(mockRefetch).toHaveBeenCalled() expect(screen.queryByTestId('detail-panel')).not.toBeInTheDocument() }) diff --git a/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx index 6f6ce92961863f..154899a1158fee 100644 --- a/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx @@ -7,17 +7,19 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import MCPServerModal from '../mcp-server-modal' // Mock the services -vi.mock('@/service/use-tools', () => ({ - useCreateMCPServer: () => ({ - mutateAsync: vi.fn().mockResolvedValue({ result: 'success' }), - isPending: false, - }), - useUpdateMCPServer: () => ({ - mutateAsync: vi.fn().mockResolvedValue({ result: 'success' }), - isPending: false, - }), - useInvalidateMCPServerDetail: () => vi.fn(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'apps.byAppId.server.post': () => ({ + mutateAsync: vi.fn().mockResolvedValue({ result: 'success' }), + isPending: false, + }), + 'apps.byAppId.server.put': () => ({ + mutateAsync: vi.fn().mockResolvedValue({ result: 'success' }), + isPending: false, + }), + }) +}) describe('MCPServerModal', () => { const createWrapper = () => { diff --git a/web/app/components/tools/mcp/create-card.tsx b/web/app/components/tools/mcp/create-card.tsx index 03b9477f0afcb5..066fa39e19918a 100644 --- a/web/app/components/tools/mcp/create-card.tsx +++ b/web/app/components/tools/mcp/create-card.tsx @@ -1,29 +1,36 @@ 'use client' +import type { ComponentProps } from 'react' import type { ToolWithProvider } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' +import { useMutation } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' import { useCanManageMCP } from '@/app/components/tools/hooks/use-tool-permissions' import { useDocLink } from '@/context/i18n' -import { useCreateMCP } from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import CreateEntryCard from '../provider/create-entry-card' +import { selectToolProvider } from '../utils/query-select' import MCPModal from './modal' +type MCPModalConfirmPayload = Parameters['onConfirm']>[0] + type Props = Readonly<{ handleCreate: (provider: ToolWithProvider) => Promise | void }> function useMCPCreateAction({ handleCreate }: Props) { const canManageMCP = useCanManageMCP() - const { mutateAsync: createMCP } = useCreateMCP() + const { mutateAsync: createMCP } = useMutation( + consoleQuery.workspaces.current.toolProvider.mcp.post.mutationOptions(), + ) const [showModal, setShowModal] = useState(false) - const create = async (info: Parameters[0]) => { + const create = async (info: MCPModalConfirmPayload) => { if (!canManageMCP) return - const provider = await createMCP(info) - await handleCreate(provider) + const provider = await createMCP({ body: info }) + await handleCreate(selectToolProvider(provider)) } return { diff --git a/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx index 4359bd4c2ccdf9..1ccbeed6af093b 100644 --- a/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx @@ -4,14 +4,19 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { consoleQuery } from '@/service/console' import { render } from '@/test/console/render' import MCPDetailContent from '../content' // Mutable mock functions const mockUpdateTools = vi.fn().mockResolvedValue({}) const mockAuthorizeMcp = vi.fn().mockResolvedValue({ result: 'success' }) -const mockInvalidateMCPTools = vi.fn() -const mockInvalidateAllMCPTools = vi.fn() +const mockInvalidateQueries = vi.fn() +const mcpToolsKey = consoleQuery.workspaces.current.tools.mcp.get.queryKey() +const mcpProviderToolsKey = + consoleQuery.workspaces.current.toolProvider.mcp.tools.byProviderId.get.queryKey({ + input: { params: { provider_id: 'mcp-1' } }, + }) const mockOpenOAuthPopup = vi.fn() // Mutable mock state @@ -27,22 +32,23 @@ let mockIsUpdating = false let mockIsAuthorizing = false // Mock the services -vi.mock('@/service/use-tools', () => ({ - useMCPTools: () => ({ - data: mockToolsData, - isFetching: mockIsFetching, - }), - useInvalidateMCPTools: () => mockInvalidateMCPTools, - useInvalidateAllMCPTools: () => mockInvalidateAllMCPTools, - useUpdateMCPTools: () => ({ - mutateAsync: mockUpdateTools, - isPending: mockIsUpdating, - }), - useAuthorizeMCP: () => ({ - mutateAsync: mockAuthorizeMcp, - isPending: mockIsAuthorizing, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.toolProvider.mcp.tools.byProviderId.get': () => ({ + data: mockToolsData, + isFetching: mockIsFetching, + }), + 'workspaces.current.toolProvider.mcp.update.byProviderId.get': () => ({ + mutateAsync: mockUpdateTools, + isPending: mockIsUpdating, + }), + 'workspaces.current.toolProvider.mcp.auth.post': () => ({ + mutateAsync: mockAuthorizeMcp, + isPending: mockIsAuthorizing, + }), + }) +}) // Mock OAuth hook type OAuthArgs = readonly unknown[] @@ -118,6 +124,7 @@ describe('MCPDetailContent', () => { }, }, }) + vi.spyOn(queryClient, 'invalidateQueries').mockImplementation(mockInvalidateQueries) return ({ children }: { children: ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children) } @@ -151,8 +158,7 @@ describe('MCPDetailContent', () => { // Reset mocks mockUpdateTools.mockClear() mockAuthorizeMcp.mockClear() - mockInvalidateMCPTools.mockClear() - mockInvalidateAllMCPTools.mockClear() + mockInvalidateQueries.mockReset().mockResolvedValue(undefined) mockOpenOAuthPopup.mockClear() // Reset mock return values @@ -349,7 +355,7 @@ describe('MCPDetailContent', () => { await waitFor(() => { expect(onFirstCreate).toHaveBeenCalled() - expect(mockAuthorizeMcp).toHaveBeenCalledWith({ provider_id: 'mcp-1' }) + expect(mockAuthorizeMcp).toHaveBeenCalledWith({ body: { provider_id: 'mcp-1' } }) }) }) @@ -435,9 +441,9 @@ describe('MCPDetailContent', () => { fireEvent.click(getConfirmButton()) await waitFor(() => { - expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1') - expect(mockInvalidateMCPTools).toHaveBeenCalledWith('mcp-1') - expect(mockInvalidateAllMCPTools).toHaveBeenCalled() + expect(mockUpdateTools).toHaveBeenCalledWith({ params: { provider_id: 'mcp-1' } }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: mcpProviderToolsKey }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: mcpToolsKey }) expect(onUpdate).toHaveBeenCalled() }) }) @@ -453,8 +459,8 @@ describe('MCPDetailContent', () => { fireEvent.click(getToolsBtn) await waitFor(() => { - expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1') - expect(mockInvalidateAllMCPTools).toHaveBeenCalled() + expect(mockUpdateTools).toHaveBeenCalledWith({ params: { provider_id: 'mcp-1' } }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: mcpToolsKey }) }) }) }) @@ -533,7 +539,7 @@ describe('MCPDetailContent', () => { oauthCallback() await waitFor(() => { - expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1') + expect(mockUpdateTools).toHaveBeenCalledWith({ params: { provider_id: 'mcp-1' } }) }) }) diff --git a/web/app/components/tools/mcp/detail/content.tsx b/web/app/components/tools/mcp/detail/content.tsx index 8717e560ea2d95..e1ad0265c32672 100644 --- a/web/app/components/tools/mcp/detail/content.tsx +++ b/web/app/components/tools/mcp/detail/content.tsx @@ -15,6 +15,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { IconButton } from '@langgenius/dify-ui/icon-button' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useBoolean } from 'ahooks' import copy from 'copy-to-clipboard' import * as React from 'react' @@ -23,13 +24,8 @@ import { useTranslation } from 'react-i18next' import Icon from '@/app/components/plugins/card/base/card-icon' import { useCanManageMCP } from '@/app/components/tools/hooks/use-tool-permissions' import { openOAuthPopup } from '@/hooks/use-oauth' -import { - useAuthorizeMCP, - useInvalidateAllMCPTools, - useInvalidateMCPTools, - useMCPTools, - useUpdateMCPTools, -} from '@/service/use-tools' +import { consoleQuery } from '@/service/console' +import { selectToolProviderTools } from '../../utils/query-select' import ListLoading from './list-loading' import OperationDropdown from './operation-dropdown' import ToolItem from './tool-item' @@ -56,13 +52,20 @@ const MCPDetailContent: FC = ({ const { t } = useTranslation(['common', 'tools']) const canManageMCP = useCanManageMCP() - const { data, isFetching: isGettingTools } = useMCPTools( - detail.is_team_authorization ? detail.id : '', + const queryClient = useQueryClient() + const { data, isFetching: isGettingTools } = useQuery( + consoleQuery.workspaces.current.toolProvider.mcp.tools.byProviderId.get.queryOptions({ + input: { params: { provider_id: detail.id } }, + enabled: !!detail.is_team_authorization && !!detail.id, + select: selectToolProviderTools, + }), + ) + const { mutateAsync: updateTools, isPending: isUpdating } = useMutation( + consoleQuery.workspaces.current.toolProvider.mcp.update.byProviderId.get.mutationOptions(), + ) + const { mutateAsync: authorizeMcp, isPending: isAuthorizing } = useMutation( + consoleQuery.workspaces.current.toolProvider.mcp.auth.post.mutationOptions(), ) - const invalidateMCPTools = useInvalidateMCPTools() - const invalidateAllMCPTools = useInvalidateAllMCPTools() - const { mutateAsync: updateTools, isPending: isUpdating } = useUpdateMCPTools() - const { mutateAsync: authorizeMcp, isPending: isAuthorizing } = useAuthorizeMCP() const toolList = data?.tools || [] const [isShowUpdateConfirm, { setTrue: showUpdateConfirm, setFalse: hideUpdateConfirm }] = @@ -71,19 +74,17 @@ const MCPDetailContent: FC = ({ const handleUpdateTools = useCallback(async () => { hideUpdateConfirm() if (!canManageMCP || !detail) return - await updateTools(detail.id) - invalidateMCPTools(detail.id) - invalidateAllMCPTools() + await updateTools({ params: { provider_id: detail.id } }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.toolProvider.mcp.tools.byProviderId.get.queryKey({ + input: { params: { provider_id: detail.id } }, + }), + }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.tools.mcp.get.queryKey(), + }) onUpdate() - }, [ - canManageMCP, - detail, - hideUpdateConfirm, - invalidateAllMCPTools, - invalidateMCPTools, - onUpdate, - updateTools, - ]) + }, [canManageMCP, detail, hideUpdateConfirm, onUpdate, queryClient, updateTools]) const hasTriggeredAuthorizeRef = useRef(false) @@ -98,9 +99,7 @@ const MCPDetailContent: FC = ({ onFirstCreate() if (!detail) return try { - const res = await authorizeMcp({ - provider_id: detail.id, - }) + const res = await authorizeMcp({ body: { provider_id: detail.id } }) if (res.result === 'success') handleUpdateTools() else if (res.authorization_url) openOAuthPopup(res.authorization_url, handleOAuthCallback) } catch { diff --git a/web/app/components/tools/mcp/index.tsx b/web/app/components/tools/mcp/index.tsx index 6d6fc853af0bbc..f55292fabec2a4 100644 --- a/web/app/components/tools/mcp/index.tsx +++ b/web/app/components/tools/mcp/index.tsx @@ -13,13 +13,15 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' +import { useMutation, useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' import { useCanManageMCP } from '@/app/components/tools/hooks/use-tool-permissions' import ToolCardSkeletonGrid from '@/app/components/tools/provider/tool-card-skeleton' -import { useAllMCPTools, useDeleteMCP, useUpdateMCP } from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { toolsContentInsetClassNames, toolsUnifiedContentFrameClassName } from '../content-inset' +import { selectToolsWithProvider } from '../utils/query-select' import NewMCPCard from './create-card' import MCPDetailPanel from './detail/provider-detail' import MCPModal from './modal' @@ -54,7 +56,12 @@ const MCPList = ({ }: Props) => { const { t } = useTranslation(['common', 'tools']) const canManageMCP = useCanManageMCP() - const fallbackMCPToolsQuery = useAllMCPTools(providers === undefined) + const fallbackMCPToolsQuery = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ + enabled: providers === undefined, + select: selectToolsWithProvider, + }), + ) const list = providers ?? fallbackMCPToolsQuery.data ?? EMPTY_MCP_TOOLS const isLoading = isLoadingProviders ?? fallbackMCPToolsQuery.isLoading const refetch = onRefresh ?? fallbackMCPToolsQuery.refetch @@ -76,8 +83,12 @@ const MCPList = ({ const editingProvider = list.find((provider) => provider.id === editingProviderID) const deletingProvider = list.find((provider) => provider.id === deletingProviderID) const detailProvider = editingProvider || deletingProvider ? undefined : currentProvider - const { mutateAsync: updateMCP } = useUpdateMCP({}) - const { mutateAsync: deleteMCP, isPending: isDeleting } = useDeleteMCP({}) + const { mutateAsync: updateMCP } = useMutation( + consoleQuery.workspaces.current.toolProvider.mcp.put.mutationOptions(), + ) + const { mutateAsync: deleteMCP, isPending: isDeleting } = useMutation( + consoleQuery.workspaces.current.toolProvider.mcp.delete.mutationOptions(), + ) const handleCreate = async (provider: ToolWithProvider) => { if (!canManageMCP) return @@ -121,8 +132,7 @@ const MCPList = ({ if (!canManageMCP || !editingProvider) return const res = (await updateMCP({ - ...form, - provider_id: editingProvider.id, + body: { ...form, provider_id: editingProvider.id }, })) as MutationResult if (res.result !== 'success') return @@ -141,7 +151,9 @@ const MCPList = ({ const handleDeleteConfirm = async () => { if (!canManageMCP || !deletingProvider) return - const res = (await deleteMCP(deletingProvider.id)) as MutationResult + const res = (await deleteMCP({ + body: { provider_id: deletingProvider.id }, + })) as MutationResult if (res.result !== 'success') return await refetch() diff --git a/web/app/components/tools/mcp/mcp-server-modal.tsx b/web/app/components/tools/mcp/mcp-server-modal.tsx index 6673108f797380..f4313504945026 100644 --- a/web/app/components/tools/mcp/mcp-server-modal.tsx +++ b/web/app/components/tools/mcp/mcp-server-modal.tsx @@ -5,14 +5,11 @@ import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { Separator } from '@langgenius/dify-ui/separator' import { Textarea } from '@langgenius/dify-ui/textarea' import { RiCloseLine } from '@remixicon/react' +import { useMutation, useQueryClient } from '@tanstack/react-query' import * as React from 'react' import { useTranslation } from 'react-i18next' import MCPServerParamItem from '@/app/components/tools/mcp/mcp-server-param-item' -import { - useCreateMCPServer, - useInvalidateMCPServerDetail, - useUpdateMCPServer, -} from '@/service/use-tools' +import { consoleQuery } from '@/service/console' type ModalProps = { appID: string @@ -33,9 +30,19 @@ type MCPServerParam = { const MCPServerModal = ({ appID, latestParams = [], data, show, onHide, appInfo }: ModalProps) => { const { t } = useTranslation(['common', 'tools']) - const { mutateAsync: createMCPServer, isPending: creating } = useCreateMCPServer() - const { mutateAsync: updateMCPServer, isPending: updating } = useUpdateMCPServer() - const invalidateMCPServerDetail = useInvalidateMCPServerDetail() + const queryClient = useQueryClient() + const { mutateAsync: createMCPServer, isPending: creating } = useMutation( + consoleQuery.apps.byAppId.server.post.mutationOptions(), + ) + const { mutateAsync: updateMCPServer, isPending: updating } = useMutation( + consoleQuery.apps.byAppId.server.put.mutationOptions(), + ) + const invalidateMCPServerDetail = () => + queryClient.invalidateQueries({ + queryKey: consoleQuery.apps.byAppId.server.get.queryKey({ + input: { params: { app_id: appID } }, + }), + }) const defaultDescription = data?.description || appInfo?.description || '' const [description, setDescription] = React.useState(defaultDescription) @@ -61,35 +68,28 @@ const MCPServerModal = ({ appID, latestParams = [], data, show, onHide, appInfo const submit = async () => { if (!data) { - const payload: { - appID: string + const body: { description?: string parameters: Record } = { - appID, parameters: getParamValue(), } - if (description.trim()) payload.description = description + if (description.trim()) body.description = description - await createMCPServer(payload) - invalidateMCPServerDetail(appID) + await createMCPServer({ params: { app_id: appID }, body }) + invalidateMCPServerDetail() onHide() } else { - const payload: { - appID: string - id: string - description: string - parameters: Record - } = { - appID, - id: data.id, - parameters: getParamValue(), - description, - } - - await updateMCPServer(payload) - invalidateMCPServerDetail(appID) + await updateMCPServer({ + params: { app_id: appID }, + body: { + id: data.id, + parameters: getParamValue(), + description, + }, + }) + invalidateMCPServerDetail() onHide() } } diff --git a/web/app/components/tools/mcp/modal.tsx b/web/app/components/tools/mcp/modal.tsx index 0cf2156549eeab..ab9c0390493686 100644 --- a/web/app/components/tools/mcp/modal.tsx +++ b/web/app/components/tools/mcp/modal.tsx @@ -44,9 +44,8 @@ type MCPModalConfirmPayload = { headers?: Record is_dynamic_registration?: boolean authentication?: { - client_id?: string + client_id: string client_secret?: string - grant_type?: string } configuration: { timeout: number diff --git a/web/app/components/tools/provider/__tests__/detail.spec.tsx b/web/app/components/tools/provider/__tests__/detail.spec.tsx index 8853010d663743..cf811304a8eac8 100644 --- a/web/app/components/tools/provider/__tests__/detail.spec.tsx +++ b/web/app/components/tools/provider/__tests__/detail.spec.tsx @@ -109,10 +109,6 @@ vi.mock('@/service/tools', () => ({ saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args), })) -vi.mock('@/service/use-tools', () => ({ - useInvalidateAllWorkflowTools: () => vi.fn(), -})) - vi.mock('@/utils/var', () => ({ basePath: '', })) diff --git a/web/app/components/tools/provider/detail.tsx b/web/app/components/tools/provider/detail.tsx index 13c20b51b3f461..64f2ec50181e33 100644 --- a/web/app/components/tools/provider/detail.tsx +++ b/web/app/components/tools/provider/detail.tsx @@ -61,7 +61,6 @@ import { updateBuiltInToolCredential, updateCustomCollection, } from '@/service/tools' -import { useInvalidateAllWorkflowTools } from '@/service/use-tools' import { basePath } from '@/utils/var' import { AuthHeaderPrefix, AuthType, CollectionType } from '../types' import ToolItem from './tool-item' @@ -85,7 +84,6 @@ const ProviderDetail = ({ collection, onHide, onRefreshData }: Props) => { const canOpenCredentialSettings = isAuthed ? canUseCredential : canCreateCredential const canSaveCredentialSettings = isAuthed ? canManageCredential : canCreateCredential const canManageTools = useCanManageTools() - const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools() const [isDetailLoading, setIsDetailLoading] = useState(false) // built in provider @@ -205,7 +203,9 @@ const ProviderDetail = ({ collection, onHide, onRefreshData }: Props) => { if (!canManageTools) return await saveWorkflowToolProvider(data) - invalidateAllWorkflowTools() + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.tools.workflow.get.queryKey(), + }) onRefreshData() getWorkflowToolProvider() setWorkflowToolDrawerOpen(false) diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index 560dc80a5f0153..d8d9252cdcbce4 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -291,3 +291,15 @@ export const MCPAuthMethod = { } as const export type MCPAuthMethod = (typeof MCPAuthMethod)[keyof typeof MCPAuthMethod] + +export type AppTrigger = { + id: string + trigger_type: 'trigger-webhook' | 'trigger-schedule' | 'trigger-plugin' + title: string + node_id: string + provider_name: string + icon: string + status: 'enabled' | 'disabled' | 'unauthorized' + created_at: string + updated_at: string +} diff --git a/web/app/components/tools/utils/query-select.ts b/web/app/components/tools/utils/query-select.ts new file mode 100644 index 00000000000000..2d5fbc85eee35f --- /dev/null +++ b/web/app/components/tools/utils/query-select.ts @@ -0,0 +1,47 @@ +import type { + AppMcpServerResponse, + WorkflowTriggerListResponse, +} from '@dify/contracts/api/console/apps/types.gen' +import type { RagPipelineOpaqueResponse } from '@dify/contracts/api/console/rag/types.gen' +import type { + ToolApiListResponse, + ToolProviderApiEntityResponse, + ToolProviderListResponse, + WorkflowToolDetailResponse, +} from '@dify/contracts/api/console/workspaces/types.gen' +import type { + AppTrigger, + Collection, + MCPServerDetail, + Tool, + WorkflowToolProviderResponse, +} from '../types' +import type { RAGRecommendedPlugins, ToolWithProvider } from '@/app/components/workflow/types' + +// The tool UI types narrow provider types, statuses, icons and parameter schemas that the +// backend declares as plain strings and loose records. +export const selectToolCollections = (data: ToolProviderListResponse) => + data as unknown as Collection[] + +export const selectToolsWithProvider = (data: ToolProviderListResponse) => + data as unknown as ToolWithProvider[] + +export const selectToolProvider = (data: ToolProviderApiEntityResponse) => + data as unknown as ToolWithProvider + +export const selectToolProviderTools = (data: ToolProviderApiEntityResponse) => + data as unknown as { tools: Tool[] } + +export const selectBuiltinProviderTools = (data: ToolApiListResponse) => data as unknown as Tool[] + +export const selectMCPServerDetail = (data: AppMcpServerResponse) => + data as unknown as MCPServerDetail + +export const selectWorkflowToolDetail = (data: WorkflowToolDetailResponse) => + data as unknown as WorkflowToolProviderResponse + +export const selectAppTriggers = (data: WorkflowTriggerListResponse) => + data as unknown as { data: AppTrigger[] } + +export const selectRAGRecommendedPlugins = (data: RagPipelineOpaqueResponse) => + data as unknown as RAGRecommendedPlugins diff --git a/web/app/components/tools/utils/tool-list-query-key.ts b/web/app/components/tools/utils/tool-list-query-key.ts new file mode 100644 index 00000000000000..fa6293a87be4a7 --- /dev/null +++ b/web/app/components/tools/utils/tool-list-query-key.ts @@ -0,0 +1,20 @@ +import type { CollectionProviderType } from '../types' +import { consoleQuery } from '@/service/console' +import { CollectionType } from '../types' + +// Each provider type is listed by its own console operation; other provider types have no list. +export const getToolListQueryKey = (type?: CollectionProviderType) => { + const tools = consoleQuery.workspaces.current.tools + switch (type) { + case CollectionType.builtIn: + return tools.builtin.get.queryKey() + case CollectionType.custom: + return tools.api.get.queryKey() + case CollectionType.workflow: + return tools.workflow.get.queryKey() + case CollectionType.mcp: + return tools.mcp.get.queryKey() + default: + return undefined + } +} diff --git a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts index 5949c47dd1d78e..6f7c21cd497f47 100644 --- a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts +++ b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts @@ -1,11 +1,15 @@ +import type { ReactNode } from 'react' import type { WorkflowToolOutputVariable, WorkflowToolProviderRequest, WorkflowToolProviderResponse, } from '@/app/components/tools/types' import type { InputVar } from '@/app/components/workflow/types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook } from '@testing-library/react' +import { createElement } from 'react' import { InputVarType } from '@/app/components/workflow/types' +import { consoleQuery } from '@/service/console' import { isParametersOutdated, useConfigureButton } from '../use-configure-button' const mockCreateWorkflowToolProvider = vi.fn() @@ -15,14 +19,29 @@ vi.mock('@/service/tools', () => ({ saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args), })) -const mockInvalidateAllWorkflowTools = vi.fn() -const mockInvalidateWorkflowToolDetailByAppID = vi.fn() const mockUseWorkflowToolDetailByAppID = vi.fn() -vi.mock('@/service/use-tools', () => ({ - useInvalidateAllWorkflowTools: () => mockInvalidateAllWorkflowTools, - useInvalidateWorkflowToolDetailByAppID: () => mockInvalidateWorkflowToolDetailByAppID, - useWorkflowToolDetailByAppID: (...args: unknown[]) => mockUseWorkflowToolDetailByAppID(...args), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.toolProvider.workflow.get.get': ({ + input, + enabled, + }: { + input: { query: { workflow_app_id: string } } + enabled?: boolean + }) => mockUseWorkflowToolDetailByAppID(input.query.workflow_app_id, enabled), + }) +}) + +let queryClient: QueryClient +const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children) +const workflowToolsKey = consoleQuery.workspaces.current.tools.workflow.get.queryKey() +const detailKey = consoleQuery.workspaces.current.toolProvider.workflow.get.get.queryKey({ + input: { query: { workflow_app_id: 'app-123' } }, +}) +const isInvalidated = (queryKey: readonly unknown[]) => + queryClient.getQueryState(queryKey)?.isInvalidated const mockToastNotify = vi.fn() vi.mock('@/app/notifications', () => ({ @@ -203,6 +222,9 @@ describe('isParametersOutdated', () => { describe('useConfigureButton', () => { beforeEach(() => { vi.clearAllMocks() + queryClient = new QueryClient() + for (const key of [workflowToolsKey, detailKey]) + queryClient.setQueryData(key as readonly unknown[], {}) mockUseWorkflowToolDetailByAppID.mockImplementation((_appId: string, enabled: boolean) => ({ data: enabled ? createMockDetail() : undefined, isLoading: false, @@ -215,31 +237,39 @@ describe('useConfigureButton', () => { describe('Initialization', () => { it('should return workflow tool state without owning drawer visibility', () => { - const { result } = renderHook(() => useConfigureButton(createDefaultOptions())) + const { result } = renderHook(() => useConfigureButton(createDefaultOptions()), { + wrapper, + }) expect(result.current.payload).toMatchObject({ workflow_app_id: 'app-123' }) }) it('should forward isLoading from query hook', () => { mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: true }) - const { result } = renderHook(() => - useConfigureButton(createDefaultOptions({ published: true })), + const { result } = renderHook( + () => useConfigureButton(createDefaultOptions({ published: true })), + { wrapper }, ) expect(result.current.isLoading).toBe(true) }) it('should call query hook with enabled=true when published', () => { - renderHook(() => useConfigureButton(createDefaultOptions({ published: true }))) + renderHook(() => useConfigureButton(createDefaultOptions({ published: true })), { + wrapper, + }) expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', true) }) it('should call query hook with enabled=false when not published', () => { - renderHook(() => useConfigureButton(createDefaultOptions({ published: false }))) + renderHook(() => useConfigureButton(createDefaultOptions({ published: false })), { + wrapper, + }) expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', false) }) it('should call query hook with enabled=false when controller is disabled', () => { - renderHook(() => - useConfigureButton(createDefaultOptions({ enabled: false, published: true })), + renderHook( + () => useConfigureButton(createDefaultOptions({ enabled: false, published: true })), + { wrapper }, ) expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', false) }) @@ -248,33 +278,39 @@ describe('useConfigureButton', () => { // Computed values describe('Computed - outdated', () => { it('should be false when not published (no detail)', () => { - const { result } = renderHook(() => useConfigureButton(createDefaultOptions())) + const { result } = renderHook(() => useConfigureButton(createDefaultOptions()), { + wrapper, + }) expect(result.current.outdated).toBe(false) }) it('should be true when parameters differ', () => { - const { result } = renderHook(() => - useConfigureButton( - createDefaultOptions({ - published: true, - inputs: [ - createMockInputVar({ variable: 'test_var' }), - createMockInputVar({ variable: 'extra_var' }), - ], - }), - ), + const { result } = renderHook( + () => + useConfigureButton( + createDefaultOptions({ + published: true, + inputs: [ + createMockInputVar({ variable: 'test_var' }), + createMockInputVar({ variable: 'extra_var' }), + ], + }), + ), + { wrapper }, ) expect(result.current.outdated).toBe(true) }) it('should be false when parameters match', () => { - const { result } = renderHook(() => - useConfigureButton( - createDefaultOptions({ - published: true, - inputs: [createMockInputVar({ variable: 'test_var', required: true })], - }), - ), + const { result } = renderHook( + () => + useConfigureButton( + createDefaultOptions({ + published: true, + inputs: [createMockInputVar({ variable: 'test_var', required: true })], + }), + ), + { wrapper }, ) expect(result.current.outdated).toBe(false) }) @@ -282,7 +318,9 @@ describe('useConfigureButton', () => { describe('Computed - payload', () => { it('should use prop values when not published', () => { - const { result } = renderHook(() => useConfigureButton(createDefaultOptions())) + const { result } = renderHook(() => useConfigureButton(createDefaultOptions()), { + wrapper, + }) expect(result.current.payload).toMatchObject({ icon: createMockEmoji(), @@ -304,8 +342,9 @@ describe('useConfigureButton', () => { }) it('should use detail values when published with detail', () => { - const { result } = renderHook(() => - useConfigureButton(createDefaultOptions({ published: true })), + const { result } = renderHook( + () => useConfigureButton(createDefaultOptions({ published: true })), + { wrapper }, ) expect(result.current.payload).toMatchObject({ @@ -326,8 +365,9 @@ describe('useConfigureButton', () => { it('should return empty parameters when published without detail', () => { mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: false }) - const { result } = renderHook(() => - useConfigureButton(createDefaultOptions({ published: true })), + const { result } = renderHook( + () => useConfigureButton(createDefaultOptions({ published: true })), + { wrapper }, ) expect(result.current.payload.parameters).toHaveLength(0) @@ -335,8 +375,9 @@ describe('useConfigureButton', () => { }) it('should build output parameters from detail output_schema', () => { - const { result } = renderHook(() => - useConfigureButton(createDefaultOptions({ published: true })), + const { result } = renderHook( + () => useConfigureButton(createDefaultOptions({ published: true })), + { wrapper }, ) expect(result.current.payload.outputParameters).toHaveLength(1) @@ -352,8 +393,9 @@ describe('useConfigureButton', () => { detail.tool.output_schema = undefined mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: detail, isLoading: false }) - const { result } = renderHook(() => - useConfigureButton(createDefaultOptions({ published: true })), + const { result } = renderHook( + () => useConfigureButton(createDefaultOptions({ published: true })), + { wrapper }, ) expect(result.current.payload.outputParameters[0]).toMatchObject({ @@ -363,13 +405,15 @@ describe('useConfigureButton', () => { }) it('should convert paragraph type to string in existing parameters', () => { - const { result } = renderHook(() => - useConfigureButton( - createDefaultOptions({ - published: true, - inputs: [createMockInputVar({ variable: 'test_var', type: InputVarType.paragraph })], - }), - ), + const { result } = renderHook( + () => + useConfigureButton( + createDefaultOptions({ + published: true, + inputs: [createMockInputVar({ variable: 'test_var', type: InputVarType.paragraph })], + }), + ), + { wrapper }, ) expect(result.current.payload.parameters[0]!.type).toBe('string') @@ -381,8 +425,9 @@ describe('useConfigureButton', () => { it('should publish before creating the provider', async () => { mockCreateWorkflowToolProvider.mockResolvedValue({}) const handlePublish = vi.fn().mockResolvedValue(undefined) - const { result } = renderHook(() => - useConfigureButton(createDefaultOptions({ handlePublish })), + const { result } = renderHook( + () => useConfigureButton(createDefaultOptions({ handlePublish })), + { wrapper }, ) await act(async () => { @@ -402,8 +447,9 @@ describe('useConfigureButton', () => { it('should not create the provider when publishing fails', async () => { const handlePublish = vi.fn().mockRejectedValue(new Error('Publish failed')) - const { result } = renderHook(() => - useConfigureButton(createDefaultOptions({ handlePublish })), + const { result } = renderHook( + () => useConfigureButton(createDefaultOptions({ handlePublish })), + { wrapper }, ) await act(async () => { @@ -422,8 +468,9 @@ describe('useConfigureButton', () => { mockCreateWorkflowToolProvider.mockResolvedValue({}) const onRefreshData = vi.fn() const onConfigured = vi.fn() - const { result } = renderHook(() => - useConfigureButton(createDefaultOptions({ onRefreshData, onConfigured })), + const { result } = renderHook( + () => useConfigureButton(createDefaultOptions({ onRefreshData, onConfigured })), + { wrapper }, ) await act(async () => { @@ -435,16 +482,18 @@ describe('useConfigureButton', () => { }) expect(mockCreateWorkflowToolProvider).toHaveBeenCalled() - expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled() + expect(isInvalidated(workflowToolsKey)).toBe(true) expect(onRefreshData).toHaveBeenCalled() - expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123') + expect(isInvalidated(detailKey)).toBe(true) expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) }) expect(onConfigured).toHaveBeenCalled() }) it('should show error toast on failure', async () => { mockCreateWorkflowToolProvider.mockRejectedValue(new Error('Create failed')) - const { result } = renderHook(() => useConfigureButton(createDefaultOptions())) + const { result } = renderHook(() => useConfigureButton(createDefaultOptions()), { + wrapper, + }) await act(async () => { await result.current.handleCreate( @@ -464,15 +513,17 @@ describe('useConfigureButton', () => { const handlePublish = vi.fn().mockResolvedValue(undefined) const onRefreshData = vi.fn() const onConfigured = vi.fn() - const { result } = renderHook(() => - useConfigureButton( - createDefaultOptions({ - published: true, - handlePublish, - onRefreshData, - onConfigured, - }), - ), + const { result } = renderHook( + () => + useConfigureButton( + createDefaultOptions({ + published: true, + handlePublish, + onRefreshData, + onConfigured, + }), + ), + { wrapper }, ) await act(async () => { @@ -485,21 +536,23 @@ describe('useConfigureButton', () => { expect(handlePublish).toHaveBeenCalled() expect(mockSaveWorkflowToolProvider).toHaveBeenCalled() expect(onRefreshData).toHaveBeenCalled() - expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled() - expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123') + expect(isInvalidated(workflowToolsKey)).toBe(true) + expect(isInvalidated(detailKey)).toBe(true) expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) }) expect(onConfigured).toHaveBeenCalled() }) it('should show error toast when publish fails', async () => { const handlePublish = vi.fn().mockRejectedValue(new Error('Publish failed')) - const { result } = renderHook(() => - useConfigureButton( - createDefaultOptions({ - published: true, - handlePublish, - }), - ), + const { result } = renderHook( + () => + useConfigureButton( + createDefaultOptions({ + published: true, + handlePublish, + }), + ), + { wrapper }, ) await act(async () => { @@ -514,8 +567,9 @@ describe('useConfigureButton', () => { it('should show error toast when save fails', async () => { mockSaveWorkflowToolProvider.mockRejectedValue(new Error('Save failed')) - const { result } = renderHook(() => - useConfigureButton(createDefaultOptions({ published: true })), + const { result } = renderHook( + () => useConfigureButton(createDefaultOptions({ published: true })), + { wrapper }, ) await act(async () => { @@ -535,38 +589,40 @@ describe('useConfigureButton', () => { const options = createDefaultOptions({ published: true, detailNeedUpdate: false }) const { rerender } = renderHook( (props: ReturnType) => useConfigureButton(props), - { initialProps: options }, + { initialProps: options, wrapper }, ) rerender({ ...options, detailNeedUpdate: true }) - expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123') + expect(isInvalidated(detailKey)).toBe(true) }) it('should not invalidate when detailNeedUpdate stays false', () => { const options = createDefaultOptions({ published: true, detailNeedUpdate: false }) const { rerender } = renderHook( (props: ReturnType) => useConfigureButton(props), - { initialProps: options }, + { initialProps: options, wrapper }, ) rerender({ ...options }) - expect(mockInvalidateWorkflowToolDetailByAppID).not.toHaveBeenCalled() + expect(isInvalidated(detailKey)).toBe(false) }) it('should not invalidate detail while disabled', () => { - renderHook(() => - useConfigureButton( - createDefaultOptions({ - enabled: false, - published: true, - detailNeedUpdate: true, - }), - ), + renderHook( + () => + useConfigureButton( + createDefaultOptions({ + enabled: false, + published: true, + detailNeedUpdate: true, + }), + ), + { wrapper }, ) - expect(mockInvalidateWorkflowToolDetailByAppID).not.toHaveBeenCalled() + expect(isInvalidated(detailKey)).toBe(false) }) }) @@ -574,8 +630,9 @@ describe('useConfigureButton', () => { describe('Edge Cases', () => { it('should handle undefined detail from query gracefully', () => { mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: false }) - const { result } = renderHook(() => - useConfigureButton(createDefaultOptions({ published: true })), + const { result } = renderHook( + () => useConfigureButton(createDefaultOptions({ published: true })), + { wrapper }, ) expect(result.current.outdated).toBe(false) @@ -587,26 +644,30 @@ describe('useConfigureButton', () => { detail.tool.parameters = [] mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: detail, isLoading: false }) - const { result } = renderHook(() => - useConfigureButton( - createDefaultOptions({ - published: true, - inputs: [], - }), - ), + const { result } = renderHook( + () => + useConfigureButton( + createDefaultOptions({ + published: true, + inputs: [], + }), + ), + { wrapper }, ) expect(result.current.outdated).toBe(false) }) it('should handle undefined inputs and outputs', () => { - const { result } = renderHook(() => - useConfigureButton( - createDefaultOptions({ - inputs: undefined, - outputs: undefined, - }), - ), + const { result } = renderHook( + () => + useConfigureButton( + createDefaultOptions({ + inputs: undefined, + outputs: undefined, + }), + ), + { wrapper }, ) expect(result.current.payload.parameters).toHaveLength(0) @@ -615,12 +676,14 @@ describe('useConfigureButton', () => { it('should handle missing onRefreshData callback in create', async () => { mockCreateWorkflowToolProvider.mockResolvedValue({}) - const { result } = renderHook(() => - useConfigureButton( - createDefaultOptions({ - onRefreshData: undefined, - }), - ), + const { result } = renderHook( + () => + useConfigureButton( + createDefaultOptions({ + onRefreshData: undefined, + }), + ), + { wrapper }, ) // Should not throw diff --git a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts index 44e9f6f96e9c12..3cbbda0154bccc 100644 --- a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts +++ b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts @@ -8,15 +8,13 @@ import type { } from '@/app/components/tools/types' import type { InputVar } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' -import { useEffect, useMemo, useRef } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { selectWorkflowToolDetail } from '@/app/components/tools/utils/query-select' import { toast } from '@/app/notifications' +import { consoleQuery } from '@/service/console' import { createWorkflowToolProvider, saveWorkflowToolProvider } from '@/service/tools' -import { - useInvalidateAllWorkflowTools, - useInvalidateWorkflowToolDetailByAppID, - useWorkflowToolDetailByAppID, -} from '@/service/use-tools' // region Pure helpers @@ -129,24 +127,33 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { const { t } = useTranslation(['common']) // Data fetching via React Query - const { data: detail, isLoading } = useWorkflowToolDetailByAppID( - workflowAppId, - enabled && published, + const queryClient = useQueryClient() + const detailQueryKey = consoleQuery.workspaces.current.toolProvider.workflow.get.get.queryKey({ + input: { query: { workflow_app_id: workflowAppId } }, + }) + const { data: detail, isLoading } = useQuery( + consoleQuery.workspaces.current.toolProvider.workflow.get.get.queryOptions({ + input: { query: { workflow_app_id: workflowAppId } }, + enabled: enabled && published && !!workflowAppId, + select: selectWorkflowToolDetail, + }), ) - // Invalidation functions (store in ref for stable effect dependency) - const invalidateDetail = useInvalidateWorkflowToolDetailByAppID() - const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools() - - const invalidateDetailRef = useRef(invalidateDetail) - useEffect(() => { - invalidateDetailRef.current = invalidateDetail - }, [invalidateDetail]) + const invalidateDetail = () => queryClient.invalidateQueries({ queryKey: detailQueryKey }) + const invalidateAllWorkflowTools = () => + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.tools.workflow.get.queryKey(), + }) // Refetch when detailNeedUpdate becomes true useEffect(() => { - if (enabled && detailNeedUpdate) invalidateDetailRef.current(workflowAppId) - }, [detailNeedUpdate, enabled, workflowAppId]) + if (enabled && detailNeedUpdate) + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.toolProvider.workflow.get.get.queryKey({ + input: { query: { workflow_app_id: workflowAppId } }, + }), + }) + }, [detailNeedUpdate, enabled, queryClient, workflowAppId]) // Computed values const outdated = useMemo(() => isParametersOutdated(detail, inputs), [detail, inputs]) @@ -188,7 +195,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { await createWorkflowToolProvider(data) invalidateAllWorkflowTools() onRefreshData?.() - invalidateDetail(workflowAppId) + invalidateDetail() toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) onConfigured?.() } catch (e) { @@ -208,7 +215,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { await saveWorkflowToolProvider(data) onRefreshData?.() invalidateAllWorkflowTools() - invalidateDetail(workflowAppId) + invalidateDetail() toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) onConfigured?.() } catch (e) { diff --git a/web/app/components/workflow-app/__tests__/index.spec.tsx b/web/app/components/workflow-app/__tests__/index.spec.tsx index 7aa1cf62a96ffa..cd29fb7ebf3b80 100644 --- a/web/app/components/workflow-app/__tests__/index.spec.tsx +++ b/web/app/components/workflow-app/__tests__/index.spec.tsx @@ -126,9 +126,12 @@ vi.mock('@/service/log', () => ({ fetchRunDetail: (...args: unknown[]) => mockFetchRunDetail(...args), })) -vi.mock('@/service/use-tools', () => ({ - useAppTriggers: () => appTriggersState, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'apps.byAppId.triggers.get': () => appTriggersState, + }) +}) vi.mock('../hooks/use-workflow-init', () => ({ useWorkflowInit: () => workflowInitState, diff --git a/web/app/components/workflow-app/__tests__/provider-contract.spec.tsx b/web/app/components/workflow-app/__tests__/provider-contract.spec.tsx index 41b4e233c08e88..b6184690e67566 100644 --- a/web/app/components/workflow-app/__tests__/provider-contract.spec.tsx +++ b/web/app/components/workflow-app/__tests__/provider-contract.spec.tsx @@ -46,12 +46,11 @@ vi.mock('@/next/navigation', async (importOriginal) => ({ useSearchParams: () => ({ get: () => null }), })) -vi.mock('@/service/use-tools', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useAppTriggers: () => ({}), - } +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'apps.byAppId.triggers.get': () => ({}), + }) }) vi.mock('../hooks/use-get-run-and-trace-url', () => ({ diff --git a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx index 0879fed0d1ef07..2ab27e247473b4 100644 --- a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx @@ -50,7 +50,6 @@ const mockHandleSyncWorkflowDraft = vi.fn() const mockPublishWorkflow = vi.fn() const mockUpdatePublishedWorkflow = vi.fn() const mockResetWorkflowVersionHistory = vi.fn() -const mockInvalidateAppTriggers = vi.fn() const mockInvalidateQueries = vi.fn() const mockSetPublishedAt = vi.fn() const mockSetLastPublishedHasUserInput = vi.fn() @@ -222,10 +221,6 @@ vi.mock('@/service/use-workflow', () => ({ useResetWorkflowVersionHistory: () => mockResetWorkflowVersionHistory, })) -vi.mock('@/service/use-tools', () => ({ - useInvalidateAppTriggers: () => mockInvalidateAppTriggers, -})) - vi.mock('@/next/navigation', async (importOriginal) => ({ ...(await importOriginal()), useParams: () => ({ appId: 'app-id' }), @@ -618,7 +613,11 @@ describe('FeaturesTrigger', () => { releaseNotes: '', }) expect(mockUpdatePublishedWorkflow).toHaveBeenCalledWith('app-id') - expect(mockInvalidateAppTriggers).toHaveBeenCalledWith('app-id') + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.apps.byAppId.triggers.get.queryKey({ + input: { params: { app_id: 'app-id' } }, + }), + }) expect(mockSetPublishedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z') expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true) expect(mockResetWorkflowVersionHistory).toHaveBeenCalled() @@ -785,7 +784,11 @@ describe('FeaturesTrigger', () => { message: 'common.api.actionSuccess', }) expect(mockUpdatePublishedWorkflow).not.toHaveBeenCalled() - expect(mockInvalidateAppTriggers).not.toHaveBeenCalled() + expect(mockInvalidateQueries).not.toHaveBeenCalledWith({ + queryKey: consoleQuery.apps.byAppId.triggers.get.queryKey({ + input: { params: { app_id: 'app-id' } }, + }), + }) expect(mockSetPublishedAt).not.toHaveBeenCalled() expect(mockSetLastPublishedHasUserInput).not.toHaveBeenCalled() expect(mockResetWorkflowVersionHistory).not.toHaveBeenCalled() diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx index 5c0c0b9c605bda..903a625c6b5af8 100644 --- a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx +++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx @@ -31,7 +31,6 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useTheme from '@/hooks/use-theme' import { useParams } from '@/next/navigation' import { consoleQuery } from '@/service/console' -import { useInvalidateAppTriggers } from '@/service/use-tools' import { useInvalidateAppWorkflow, usePublishWorkflow, @@ -149,7 +148,6 @@ const FeaturesTrigger = () => { }, [nodes]) const resetWorkflowVersionHistory = useResetWorkflowVersionHistory() - const invalidateAppTriggers = useInvalidateAppTriggers() const handleShowFeatures = useCallback(() => { const { showFeaturesPanel, isRestoring, setShowFeaturesPanel } = workflowStore.getState() @@ -195,7 +193,11 @@ const FeaturesTrigger = () => { input: { params: { app_id: appID } }, }), }) - invalidateAppTriggers(appID) + void queryClient.invalidateQueries({ + queryKey: consoleQuery.apps.byAppId.triggers.get.queryKey({ + input: { params: { app_id: appID } }, + }), + }) if (rosterAgentIds.length > 0) { void queryClient.invalidateQueries({ queryKey: consoleQuery.agent.get.key(), @@ -230,7 +232,6 @@ const FeaturesTrigger = () => { appID, t, updatePublishedWorkflow, - invalidateAppTriggers, rosterAgentIds, queryClient, workflowStore, diff --git a/web/app/components/workflow-app/index.tsx b/web/app/components/workflow-app/index.tsx index 7a47e2c20e5d02..7b265571f33c29 100644 --- a/web/app/components/workflow-app/index.tsx +++ b/web/app/components/workflow-app/index.tsx @@ -2,11 +2,12 @@ import type { Features as FeaturesData } from '@/app/components/base/features/types' import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useEffect, useMemo } from 'react' import { FeaturesProvider } from '@/app/components/base/features' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' +import { selectAppTriggers } from '@/app/components/tools/utils/query-select' import WorkflowWithDefaultContext from '@/app/components/workflow' import { WorkflowContextProvider } from '@/app/components/workflow/context' import { useWorkflowStore } from '@/app/components/workflow/store' @@ -18,7 +19,6 @@ import { userProfileQueryOptions } from '@/features/account-profile/client' import { useParams, useSearchParams } from '@/next/navigation' import { consoleQuery } from '@/service/console' import { fetchRunDetail } from '@/service/log' -import { useAppTriggers } from '@/service/use-tools' import { AppModeEnum } from '@/types/app' import { getAppACLCapabilities } from '@/utils/permission' import WorkflowAppMain from './components/workflow-main' @@ -54,10 +54,15 @@ const WorkflowAppWithAdditionalContext = () => { [appDetail.maintainer, appDetail.permission_keys, currentUserId, workspacePermissionKeys], ) const isWorkflowMode = appDetail.mode === AppModeEnum.WORKFLOW - const { data: triggersResponse } = useAppTriggers(isWorkflowMode ? appId : undefined, { - staleTime: 5 * 60 * 1000, // 5 minutes cache - refetchOnWindowFocus: false, - }) + const { data: triggersResponse } = useQuery( + consoleQuery.apps.byAppId.triggers.get.queryOptions({ + input: { params: { app_id: appId } }, + enabled: isWorkflowMode && !!appId, + select: selectAppTriggers, + staleTime: 5 * 60 * 1000, // 5 minutes cache + refetchOnWindowFocus: false, + }), + ) // Sync trigger statuses to store when data loads useEffect(() => { diff --git a/web/app/components/workflow/__tests__/service-mock-factory.ts b/web/app/components/workflow/__tests__/service-mock-factory.ts index 2705a6c5daae36..820c3275bb7472 100644 --- a/web/app/components/workflow/__tests__/service-mock-factory.ts +++ b/web/app/components/workflow/__tests__/service-mock-factory.ts @@ -3,8 +3,11 @@ * * Usage: * ```ts - * vi.mock('@/service/use-tools', async () => - * (await import('../../__tests__/service-mock-factory')).createToolServiceMock(), + * vi.mock('@tanstack/react-query', async (importOriginal) => + * (await import('@/test/console/operation-hooks')).mockConsoleOperationHooks( + * await importOriginal(), + * (await import('../../__tests__/service-mock-factory')).createToolOperationRoutes(), + * ), * ) * ``` */ @@ -24,12 +27,12 @@ type TriggerMockData = { triggerPlugins?: unknown[] } -export function createToolServiceMock(data?: ToolMockData) { +export function createToolOperationRoutes(data?: ToolMockData) { return { - useAllBuiltInTools: vi.fn(() => ({ data: data?.buildInTools ?? [] })), - useAllCustomTools: vi.fn(() => ({ data: data?.customTools ?? [] })), - useAllWorkflowTools: vi.fn(() => ({ data: data?.workflowTools ?? [] })), - useAllMCPTools: vi.fn(() => ({ data: data?.mcpTools ?? [] })), + 'workspaces.current.tools.builtin.get': () => ({ data: data?.buildInTools ?? [] }), + 'workspaces.current.tools.api.get': () => ({ data: data?.customTools ?? [] }), + 'workspaces.current.tools.workflow.get': () => ({ data: data?.workflowTools ?? [] }), + 'workspaces.current.tools.mcp.get': () => ({ data: data?.mcpTools ?? [] }), } } diff --git a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx index 706e63784618fe..ce5689b2d592af 100644 --- a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx @@ -182,12 +182,15 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: [] }), - useAllCustomTools: () => ({ data: [] }), - useAllMCPTools: () => ({ data: [] }), - useAllWorkflowTools: () => ({ data: [] }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: [] }), + 'workspaces.current.tools.api.get': () => ({ data: [] }), + 'workspaces.current.tools.mcp.get': () => ({ data: [] }), + 'workspaces.current.tools.workflow.get': () => ({ data: [] }), + }) +}) vi.mock('@/service/workflow', () => ({ fetchAllInspectVars: vi.fn().mockResolvedValue([]), diff --git a/web/app/components/workflow/block-selector/__tests__/index.spec.tsx b/web/app/components/workflow/block-selector/__tests__/index.spec.tsx index 65673beaebf370..5dfa32b13795b1 100644 --- a/web/app/components/workflow/block-selector/__tests__/index.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/index.spec.tsx @@ -38,13 +38,15 @@ vi.mock('@/service/use-triggers', () => ({ useInvalidateAllTriggerPlugins: () => vi.fn(), })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: [] }), - useAllCustomTools: () => ({ data: [] }), - useAllWorkflowTools: () => ({ data: [] }), - useAllMCPTools: () => ({ data: [] }), - useInvalidateAllBuiltInTools: () => vi.fn(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: [] }), + 'workspaces.current.tools.api.get': () => ({ data: [] }), + 'workspaces.current.tools.workflow.get': () => ({ data: [] }), + 'workspaces.current.tools.mcp.get': () => ({ data: [] }), + }) +}) const createBlock = (type: BlockEnum, title: string): NodeDefault => ({ metaData: { diff --git a/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx index 2ac5ef970d3ce1..76c1c074890c61 100644 --- a/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx @@ -1,6 +1,7 @@ import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' +import { consoleQuery } from '@/service/console' import { renderWithConsoleQuery } from '@/test/console/query-data' import { BlockSelectorPanels } from '../tabs' import { TabType } from '../types' @@ -8,8 +9,7 @@ import { TabType } from '../types' const render = (ui: React.ReactElement) => renderWithConsoleQuery(ui, { systemFeatures: { enable_marketplace: true } }) -const { mockInvalidateBuiltInTools, mockToolsState } = vi.hoisted(() => ({ - mockInvalidateBuiltInTools: vi.fn(), +const { mockToolsState } = vi.hoisted(() => ({ mockToolsState: { buildInTools: [{ icon: '/tool.svg', name: 'tool' }] as | Array<{ icon: string | Record; name: string }> @@ -27,13 +27,15 @@ vi.mock('@/service/use-plugins', () => ({ }), })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: mockToolsState.buildInTools }), - useAllCustomTools: () => ({ data: mockToolsState.customTools }), - useAllWorkflowTools: () => ({ data: mockToolsState.workflowTools }), - useAllMCPTools: () => ({ data: mockToolsState.mcpTools }), - useInvalidateAllBuiltInTools: () => mockInvalidateBuiltInTools, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: mockToolsState.buildInTools }), + 'workspaces.current.tools.api.get': () => ({ data: mockToolsState.customTools }), + 'workspaces.current.tools.workflow.get': () => ({ data: mockToolsState.workflowTools }), + 'workspaces.current.tools.mcp.get': () => ({ data: mockToolsState.mcpTools }), + }) +}) vi.mock('@/utils/var', () => ({ basePath: '/console', @@ -219,12 +221,16 @@ describe('Tabs', () => { it('should force start content to render and invalidate built-in tools after featured installs', async () => { const user = userEvent.setup() - render() + const { queryClient } = render( + , + ) + const builtInToolsKey = consoleQuery.workspaces.current.tools.builtin.get.queryKey() + queryClient.setQueryData(builtInToolsKey, []) await user.click(screen.getByRole('button', { name: 'Install featured tool' })) expect(screen.getByText('tools-content'))!.toBeInTheDocument() - expect(mockInvalidateBuiltInTools).toHaveBeenCalledTimes(1) + expect(queryClient.getQueryState(builtInToolsKey)?.isInvalidated).toBe(true) }) it('should compose start content directly without tab semantics in standalone mode', () => { diff --git a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx index abf3e22d17f548..c500e12e24c72e 100644 --- a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx @@ -13,27 +13,20 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types' import { CollectionType } from '@/app/components/tools/types' import { useGetLanguage } from '@/context/i18n' import useTheme from '@/hooks/use-theme' +import { consoleQuery } from '@/service/console' import { createCustomCollection } from '@/service/tools' import { useFeaturedToolsRecommendations } from '@/service/use-plugins' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, - useInvalidateAllBuiltInTools, - useInvalidateAllCustomTools, - useInvalidateAllMCPTools, - useInvalidateAllWorkflowTools, -} from '@/service/use-tools' import { renderWithConsoleQuery } from '@/test/console/query-data' import { Theme } from '@/types/app' import ToolPicker, { ToolPickerContent } from '../tool-picker' const mockNotify = vi.fn() -const mockInvalidateBuiltInTools = vi.fn() -const mockInvalidateCustomTools = vi.fn() -const mockInvalidateWorkflowTools = vi.fn() -const mockInvalidateMcpTools = vi.fn() +const toolListKeys = { + builtin: consoleQuery.workspaces.current.tools.builtin.get.queryKey(), + custom: consoleQuery.workspaces.current.tools.api.get.queryKey(), + workflow: consoleQuery.workspaces.current.tools.workflow.get.queryKey(), + mcp: consoleQuery.workspaces.current.tools.mcp.get.queryKey(), +} const mockCreateCustomCollection = vi.mocked(createCustomCollection) const mockInstallPackageFromMarketPlace = vi.fn() const mockCheckInstalled = vi.fn() @@ -44,14 +37,13 @@ const mockUseGetLanguage = vi.mocked(useGetLanguage) const mockUseTheme = vi.mocked(useTheme) const mockUseTags = vi.mocked(useTags) const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins) -const mockUseAllBuiltInTools = vi.mocked(useAllBuiltInTools) -const mockUseAllCustomTools = vi.mocked(useAllCustomTools) -const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools) -const mockUseAllMCPTools = vi.mocked(useAllMCPTools) -const mockUseInvalidateAllBuiltInTools = vi.mocked(useInvalidateAllBuiltInTools) -const mockUseInvalidateAllCustomTools = vi.mocked(useInvalidateAllCustomTools) -const mockUseInvalidateAllWorkflowTools = vi.mocked(useInvalidateAllWorkflowTools) -const mockUseInvalidateAllMCPTools = vi.mocked(useInvalidateAllMCPTools) +const { mockBuiltInToolsQuery, mockCustomToolsQuery, mockWorkflowToolsQuery, mockMCPToolsQuery } = + vi.hoisted(() => ({ + mockBuiltInToolsQuery: vi.fn(), + mockCustomToolsQuery: vi.fn(), + mockWorkflowToolsQuery: vi.fn(), + mockMCPToolsQuery: vi.fn(), + })) const mockUseFeaturedToolsRecommendations = vi.mocked(useFeaturedToolsRecommendations) vi.mock('@/context/i18n', () => ({ @@ -121,16 +113,17 @@ vi.mock('@/service/use-plugins', () => ({ }), })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: vi.fn(), - useAllCustomTools: vi.fn(), - useAllWorkflowTools: vi.fn(), - useAllMCPTools: vi.fn(), - useInvalidateAllBuiltInTools: vi.fn(), - useInvalidateAllCustomTools: vi.fn(), - useInvalidateAllWorkflowTools: vi.fn(), - useInvalidateAllMCPTools: vi.fn(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => mockBuiltInToolsQuery(), + 'workspaces.current.tools.api.get': ({ enabled }: { enabled?: boolean }) => + mockCustomToolsQuery(enabled), + 'workspaces.current.tools.workflow.get': ({ enabled }: { enabled?: boolean }) => + mockWorkflowToolsQuery(enabled), + 'workspaces.current.tools.mcp.get': () => mockMCPToolsQuery(), + }) +}) vi.mock('@/app/notifications', () => ({ toast: { @@ -363,26 +356,14 @@ describe('ToolPicker', () => { mockUseMarketplacePlugins.mockReturnValue({ data: undefined, } as ReturnType) - mockUseAllBuiltInTools.mockReturnValue({ data: builtInTools } as ReturnType< - typeof useAllBuiltInTools - >) - mockUseAllCustomTools.mockImplementation( - (enabled = true) => - ({ - data: enabled ? customTools : [], - }) as ReturnType, - ) - mockUseAllWorkflowTools.mockImplementation( - (enabled = true) => - ({ - data: enabled ? workflowTools : [], - }) as ReturnType, - ) - mockUseAllMCPTools.mockReturnValue({ data: mcpTools } as ReturnType) - mockUseInvalidateAllBuiltInTools.mockReturnValue(mockInvalidateBuiltInTools) - mockUseInvalidateAllCustomTools.mockReturnValue(mockInvalidateCustomTools) - mockUseInvalidateAllWorkflowTools.mockReturnValue(mockInvalidateWorkflowTools) - mockUseInvalidateAllMCPTools.mockReturnValue(mockInvalidateMcpTools) + mockBuiltInToolsQuery.mockReturnValue({ data: builtInTools }) + mockCustomToolsQuery.mockImplementation((enabled = true) => ({ + data: enabled ? customTools : [], + })) + mockWorkflowToolsQuery.mockImplementation((enabled = true) => ({ + data: enabled ? workflowTools : [], + })) + mockMCPToolsQuery.mockReturnValue({ data: mcpTools }) mockUseFeaturedToolsRecommendations.mockReturnValue({ plugins: [], isLoading: false, @@ -587,9 +568,9 @@ describe('ToolPicker', () => { plugin_id: 'available-plugin', label: { en_US: 'Available Plugin' }, }) - mockUseAllBuiltInTools.mockReturnValue({ + mockBuiltInToolsQuery.mockReturnValue({ data: [createToolProvider({ ...builtInTools[0], plugin_id: installedPlugin.plugin_id })], - } as ReturnType) + }) mockUseMarketplacePlugins.mockImplementation( (params) => ({ @@ -670,12 +651,12 @@ describe('ToolPicker', () => { expect(await screen.findByText('New Plugin')).toBeInTheDocument() expect(screen.getByText('Available Plugin')).toBeInTheDocument() - mockUseAllBuiltInTools.mockReturnValue({ + mockBuiltInToolsQuery.mockReturnValue({ data: [ ...builtInTools, createToolProvider({ id: 'new-provider', plugin_id: newlyInstalledPlugin.plugin_id }), ], - } as ReturnType) + }) rerender() expect(screen.queryByText('New Plugin')).not.toBeInTheDocument() @@ -686,10 +667,11 @@ describe('ToolPicker', () => { it('should create a custom collection from the add button and refresh custom tools', async () => { const user = userEvent.setup() - renderToolPicker({ + const { queryClient } = renderToolPicker({ isShow: true, supportAddCustomTool: true, }) + for (const key of Object.values(toolListKeys)) queryClient.setQueryData(key, []) await user.click(screen.getByRole('button', { name: 'tools.addToolModal.custom.tip' })) expect(screen.getByTestId('edit-custom-tool-modal')).toBeInTheDocument() @@ -699,7 +681,8 @@ describe('ToolPicker', () => { await waitFor(() => { expect(mockCreateCustomCollection).toHaveBeenCalledWith({ name: 'collection-a' }) }) - expect(mockInvalidateCustomTools).toHaveBeenCalledTimes(1) + expect(queryClient.getQueryState(toolListKeys.custom)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(toolListKeys.builtin)?.isInvalidated).toBe(false) expect(screen.queryByTestId('edit-custom-tool-modal')).not.toBeInTheDocument() }) @@ -711,8 +694,8 @@ describe('ToolPicker', () => { supportAddCustomTool: true, }) - expect(mockUseAllCustomTools).toHaveBeenCalledWith(true) - expect(mockUseAllWorkflowTools).toHaveBeenCalledWith(true) + expect(mockCustomToolsQuery).toHaveBeenCalledWith(true) + expect(mockWorkflowToolsQuery).toHaveBeenCalledWith(true) expect(screen.getByText('Custom Provider')).toBeInTheDocument() expect(screen.getByText('Built-in Provider')).toBeInTheDocument() expect(screen.getByText('MCP Provider')).toBeInTheDocument() @@ -731,10 +714,11 @@ describe('ToolPicker', () => { isLoading: false, } as ReturnType) - renderToolPicker({ + const { queryClient } = renderToolPicker({ isShow: true, selectedTools: [], }) + for (const key of Object.values(toolListKeys)) queryClient.setQueryData(key, []) const featuredPluginItem = await screen.findByText('Plugin One') await user.hover(featuredPluginItem) @@ -744,10 +728,8 @@ describe('ToolPicker', () => { await waitFor( () => { - expect(mockInvalidateBuiltInTools).toHaveBeenCalledTimes(1) - expect(mockInvalidateCustomTools).toHaveBeenCalledTimes(1) - expect(mockInvalidateWorkflowTools).toHaveBeenCalledTimes(1) - expect(mockInvalidateMcpTools).toHaveBeenCalledTimes(1) + for (const key of Object.values(toolListKeys)) + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true) }, { timeout: 3000 }, ) diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx index a775e06c8f3b3f..f1f309435d4e1a 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx @@ -3,13 +3,15 @@ import type { ViewType } from '@/app/components/workflow/block-selector/types' import type { OnSelectBlock } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' +import { selectRAGRecommendedPlugins } from '@/app/components/tools/utils/query-select' import { useRAGRecommendationsCollapsed } from '@/app/components/workflow/block-selector/storage' import Link from '@/next/link' -import { useRAGRecommendedPlugins } from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { getMarketplaceUrl } from '@/utils/var' import List from './list' @@ -31,7 +33,12 @@ export function RAGToolRecommendations({ data: ragRecommendedPlugins, isLoading: isLoadingRAGRecommendedPlugins, isFetching: isFetchingRAGRecommendedPlugins, - } = useRAGRecommendedPlugins('tool') + } = useQuery( + consoleQuery.rag.pipelines.recommendedPlugins.get.queryOptions({ + input: { query: { type: 'tool' } }, + select: selectRAGRecommendedPlugins, + }), + ) const recommendedPlugins = useMemo(() => { if (ragRecommendedPlugins) return ragRecommendedPlugins.installed_recommended_plugins diff --git a/web/app/components/workflow/block-selector/tool-panel.tsx b/web/app/components/workflow/block-selector/tool-panel.tsx index a08ae8cd2c624f..eccce8510d9855 100644 --- a/web/app/components/workflow/block-selector/tool-panel.tsx +++ b/web/app/components/workflow/block-selector/tool-panel.tsx @@ -1,17 +1,12 @@ import type { RagPipelineDatasourceProviderResponse } from '@dify/contracts/api/console/rag/types.gen' import type { OnSelectBlock, ToolWithProvider } from '../types' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import dynamic from 'next/dynamic' import { useEffect, useMemo } from 'react' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import { consoleQuery } from '@/service/console' import { useFeaturedToolsRecommendations } from '@/service/use-plugins' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, - useInvalidateAllBuiltInTools, -} from '@/service/use-tools' import { basePath } from '@/utils/var' import { useWorkflowStore } from '../store' @@ -85,11 +80,23 @@ export function ToolPanel({ onSelect: OnSelectBlock dataSources: RagPipelineDatasourceProviderResponse[] }) { - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() - const invalidateBuiltInTools = useInvalidateAllBuiltInTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) + const queryClient = useQueryClient() const { data: enableMarketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), select: (systemFeatures) => systemFeatures.enable_marketplace, @@ -150,7 +157,11 @@ export function ToolPanel({ featuredPlugins={featuredPlugins} featuredLoading={isFeaturedLoading} showFeatured={enableMarketplace && !inRAGPipeline} - onFeaturedInstallSuccess={invalidateBuiltInTools} + onFeaturedInstallSuccess={() => + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.tools.builtin.get.queryKey(), + }) + } /> ) } diff --git a/web/app/components/workflow/block-selector/tool-picker.tsx b/web/app/components/workflow/block-selector/tool-picker.tsx index ad888b267fc72d..b7c14e230bdafe 100644 --- a/web/app/components/workflow/block-selector/tool-picker.tsx +++ b/web/app/components/workflow/block-selector/tool-picker.tsx @@ -7,7 +7,7 @@ import type { BlockEnum, OnSelectBlock } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' import { IconButton } from '@langgenius/dify-ui/icon-button' import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useBoolean } from 'ahooks' import dynamic from 'next/dynamic' import { useMemo, useState } from 'react' @@ -15,19 +15,11 @@ import { useTranslation } from 'react-i18next' import { PluginSearchInput } from '@/app/components/plugins/plugin-search-input' import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal' import { useCanManageTools } from '@/app/components/tools/hooks/use-tool-permissions' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import { consoleQuery } from '@/service/console' import { createCustomCollection } from '@/service/tools' import { useFeaturedToolsRecommendations } from '@/service/use-plugins' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, - useInvalidateAllBuiltInTools, - useInvalidateAllCustomTools, - useInvalidateAllMCPTools, - useInvalidateAllWorkflowTools, -} from '@/service/use-tools' const ToolBrowser = dynamic(() => import('./tool-browser'), { loading: () =>
, @@ -73,20 +65,33 @@ export function ToolPickerContent({ ...systemFeaturesQueryOptions(), select: (s) => s.enable_marketplace, }) - const { data: buildInTools } = useAllBuiltInTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) const installedPluginIds = useMemo( () => new Set(buildInTools?.map((provider) => provider.plugin_id || provider.id)), [buildInTools], ) const shouldFetchCustomTools = scope !== 'plugins' && scope !== 'workflow' - const { data: customTools } = useAllCustomTools(shouldFetchCustomTools) - const invalidateCustomTools = useInvalidateAllCustomTools() + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ + enabled: shouldFetchCustomTools, + select: selectToolsWithProvider, + }), + ) + const queryClient = useQueryClient() const shouldFetchWorkflowTools = scope !== 'plugins' && scope !== 'custom' - const { data: workflowTools } = useAllWorkflowTools(shouldFetchWorkflowTools) - const { data: mcpTools } = useAllMCPTools() - const invalidateBuiltInTools = useInvalidateAllBuiltInTools() - const invalidateWorkflowTools = useInvalidateAllWorkflowTools() - const invalidateMcpTools = useInvalidateAllMCPTools() + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + enabled: shouldFetchWorkflowTools, + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const { plugins: featuredPlugins = [], isLoading: isFeaturedLoading } = useFeaturedToolsRecommendations(enable_marketplace) @@ -138,7 +143,9 @@ export function ToolPickerContent({ await createCustomCollection(data) hideEditCustomCollectionModal() - invalidateCustomTools() + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.tools.api.get.queryKey(), + }) } if (isShowEditCollectionToolModal && canManageTools) { @@ -202,10 +209,11 @@ export function ToolPickerContent({ featuredLoading={isFeaturedLoading} showFeatured={scope === 'all' && enable_marketplace} onFeaturedInstallSuccess={async () => { - invalidateBuiltInTools() - invalidateCustomTools() - invalidateWorkflowTools() - invalidateMcpTools() + const tools = consoleQuery.workspaces.current.tools + queryClient.invalidateQueries({ queryKey: tools.builtin.get.queryKey() }) + queryClient.invalidateQueries({ queryKey: tools.api.get.queryKey() }) + queryClient.invalidateQueries({ queryKey: tools.workflow.get.queryKey() }) + queryClient.invalidateQueries({ queryKey: tools.mcp.get.queryKey() }) }} />
diff --git a/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts b/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts index df354ccf3f0e69..09ea5c77ff6379 100644 --- a/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts @@ -5,8 +5,11 @@ import { BlockEnum } from '../../types' import { useAvailableBlocks } from '../use-available-blocks' // Transitive imports of use-nodes-meta-data.ts — only useNodeMetaData uses these -vi.mock('@/service/use-tools', async () => - (await import('../../__tests__/service-mock-factory')).createToolServiceMock(), +vi.mock('@tanstack/react-query', async (importOriginal) => + (await import('@/test/console/operation-hooks')).mockConsoleOperationHooks( + await importOriginal(), + (await import('../../__tests__/service-mock-factory')).createToolOperationRoutes(), + ), ) vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en' })) diff --git a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts index 054a4d6d729caf..7caad67d661bcd 100644 --- a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts @@ -60,13 +60,6 @@ vi.mock('reactflow', async () => { } }) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: toolServiceState.buildInTools }), - useAllCustomTools: () => ({ data: toolServiceState.customTools }), - useAllMCPTools: () => ({ data: toolServiceState.mcpTools }), - useAllWorkflowTools: () => ({ data: toolServiceState.workflowTools }), -})) - vi.mock('@/service/use-plugins', () => ({ useFetchPluginsInMarketPlaceByInfo: (infos: Array<{ organization: string; plugin: string }>) => ({ data: @@ -853,7 +846,8 @@ describe('useWorkflowRunValidation', () => { vi.mock('@tanstack/react-query', async (importOriginal) => { const actual = await importOriginal() - return { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + const reactQuery = { ...actual, useQuery: (options: { queryKey: OperationKey< @@ -871,5 +865,11 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { if (!options.queryKey[0].includes('modelTypes')) return actual.useQuery(options) return { data: [] } }, - } + } as typeof actual + return mockConsoleOperationHooks(reactQuery, { + 'workspaces.current.tools.builtin.get': () => ({ data: toolServiceState.buildInTools }), + 'workspaces.current.tools.api.get': () => ({ data: toolServiceState.customTools }), + 'workspaces.current.tools.mcp.get': () => ({ data: toolServiceState.mcpTools }), + 'workspaces.current.tools.workflow.get': () => ({ data: toolServiceState.workflowTools }), + }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts b/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts index 553dc9f224f0ec..92ef5704940cf0 100644 --- a/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts @@ -27,8 +27,11 @@ vi.mock('reactflow', async () => (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), ) -vi.mock('@/service/use-tools', async () => - (await import('../../__tests__/service-mock-factory')).createToolServiceMock(), +vi.mock('@tanstack/react-query', async (importOriginal) => + (await import('@/test/console/operation-hooks')).mockConsoleOperationHooks( + await importOriginal(), + (await import('../../__tests__/service-mock-factory')).createToolOperationRoutes(), + ), ) vi.mock('@/service/use-workflow', () => ({ diff --git a/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud-common.spec.ts b/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud-common.spec.ts index c91c3b8f686bdf..1a421db84dd696 100644 --- a/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud-common.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud-common.spec.ts @@ -42,8 +42,11 @@ vi.mock('@/service/use-flow', () => ({ }), })) -vi.mock('@/service/use-tools', async () => - (await import('../../__tests__/service-mock-factory')).createToolServiceMock(), +vi.mock('@tanstack/react-query', async (importOriginal) => + (await import('@/test/console/operation-hooks')).mockConsoleOperationHooks( + await importOriginal(), + (await import('../../__tests__/service-mock-factory')).createToolOperationRoutes(), + ), ) vi.mock('@/service/workflow', () => ({ diff --git a/web/app/components/workflow/hooks/__tests__/use-node-plugin-installation.spec.ts b/web/app/components/workflow/hooks/__tests__/use-node-plugin-installation.spec.ts index 552159f2d64d89..134e2a4cf0bf3c 100644 --- a/web/app/components/workflow/hooks/__tests__/use-node-plugin-installation.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-node-plugin-installation.spec.ts @@ -1,11 +1,11 @@ import type { CommonNodeType } from '../../types' -import type { CollectionProviderType } from '@/app/components/tools/types' import { QueryClient } from '@tanstack/react-query' import { act } from '@testing-library/react' import { createDatasourceProvider } from '@/app/components/rag-pipeline/__tests__/datasource-fixtures' import { CollectionType } from '@/app/components/tools/types' import { consoleQuery } from '@/service/console' import { seedAccountProfileQuery } from '@/test/console/account-profile' +import { createTestQueryClient } from '@/test/query-client' import { renderWorkflowHook } from '../../__tests__/workflow-test-env' import { BlockEnum } from '../../types' import { useNodePluginInstallation } from '../use-node-plugin-installation' @@ -14,7 +14,6 @@ const mockBuiltInTools = vi.fn() const mockCustomTools = vi.fn() const mockWorkflowTools = vi.fn() const mockMcpTools = vi.fn() -const mockInvalidToolsByType = vi.fn() const mockTriggerPlugins = vi.fn() const mockInvalidateTriggers = vi.fn() let mockWorkspacePermissionKeys = ['plugin.install'] @@ -26,14 +25,19 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: (enabled: boolean) => mockBuiltInTools(enabled), - useAllCustomTools: (enabled: boolean) => mockCustomTools(enabled), - useAllWorkflowTools: (enabled: boolean) => mockWorkflowTools(enabled), - useAllMCPTools: (enabled: boolean) => mockMcpTools(enabled), - useInvalidToolsByType: (providerType?: CollectionProviderType) => - mockInvalidToolsByType(providerType), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': ({ enabled }: { enabled?: boolean }) => + mockBuiltInTools(enabled), + 'workspaces.current.tools.api.get': ({ enabled }: { enabled?: boolean }) => + mockCustomTools(enabled), + 'workspaces.current.tools.workflow.get': ({ enabled }: { enabled?: boolean }) => + mockWorkflowTools(enabled), + 'workspaces.current.tools.mcp.get': ({ enabled }: { enabled?: boolean }) => + mockMcpTools(enabled), + }) +}) vi.mock('@/service/use-triggers', () => ({ useAllTriggerPlugins: (enabled: boolean) => mockTriggerPlugins(enabled), @@ -103,7 +107,6 @@ describe('useNodePluginInstallation', () => { mockCustomTools.mockReturnValue({ data: undefined, isLoading: false }) mockWorkflowTools.mockReturnValue({ data: undefined, isLoading: false }) mockMcpTools.mockReturnValue({ data: undefined, isLoading: false }) - mockInvalidToolsByType.mockReturnValue(undefined) mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: false }) mockInvalidateTriggers.mockReset() }) @@ -128,11 +131,17 @@ describe('useNodePluginInstallation', () => { }) it('should report loading and invalidate built-in tools while the collection is resolving', () => { - const invalidateTools = vi.fn() mockBuiltInTools.mockReturnValue({ data: undefined, isLoading: true }) - mockInvalidToolsByType.mockReturnValue(invalidateTools) + const queryClient = createTestQueryClient() + seedAccountProfileQuery(queryClient) + const builtInToolsKey = consoleQuery.workspaces.current.tools.builtin.get.queryKey() + const customToolsKey = consoleQuery.workspaces.current.tools.api.get.queryKey() + queryClient.setQueryData(builtInToolsKey, []) + queryClient.setQueryData(customToolsKey, []) - const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeToolNode())) + const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeToolNode()), { + queryClient, + }) expect(mockBuiltInTools).toHaveBeenCalledWith(true) expect(result.current.isChecking).toBe(true) @@ -145,7 +154,8 @@ describe('useNodePluginInstallation', () => { result.current.onInstallSuccess() }) - expect(invalidateTools).toHaveBeenCalled() + expect(queryClient.getQueryState(builtInToolsKey)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(customToolsKey)?.isInvalidated).toBe(false) }) it.each([ diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-meta-data.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-nodes-meta-data.spec.tsx index 8850b018f94d2d..d50303803ddd3d 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-meta-data.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-meta-data.spec.tsx @@ -19,11 +19,14 @@ const workflowToolsState = vi.hoisted( () => [] as Array<{ id: string; author: string; description: Record }>, ) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: buildInToolsState }), - useAllCustomTools: () => ({ data: customToolsState }), - useAllWorkflowTools: () => ({ data: workflowToolsState }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: buildInToolsState }), + 'workspaces.current.tools.api.get': () => ({ data: customToolsState }), + 'workspaces.current.tools.workflow.get': () => ({ data: workflowToolsState }), + }) +}) const createNode = (overrides: Partial = {}): Node => ({ diff --git a/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts b/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts index 02c6250c625f99..8f930fc1219b22 100644 --- a/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts @@ -9,23 +9,26 @@ vi.mock('reactflow', async () => (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), ) -vi.mock('@/service/use-tools', async () => - (await import('../../__tests__/service-mock-factory')).createToolServiceMock({ - buildInTools: [ - { - id: 'builtin-1', - name: 'builtin', - icon: '/builtin.svg', - icon_dark: '/builtin-dark.svg', - plugin_id: 'p1', - }, - ], - customTools: [{ id: 'custom-1', name: 'custom', icon: '/custom.svg', plugin_id: 'p2' }], - workflowTools: [ - { id: 'workflow-1', name: 'workflow-tool', icon: '/workflow.svg', plugin_id: 'p3' }, - ], - mcpTools: [{ id: 'mcp-1', name: 'mcp-tool', icon: '/mcp.svg', plugin_id: 'p4' }], - }), +vi.mock('@tanstack/react-query', async (importOriginal) => + (await import('@/test/console/operation-hooks')).mockConsoleOperationHooks( + await importOriginal(), + (await import('../../__tests__/service-mock-factory')).createToolOperationRoutes({ + buildInTools: [ + { + id: 'builtin-1', + name: 'builtin', + icon: '/builtin.svg', + icon_dark: '/builtin-dark.svg', + plugin_id: 'p1', + }, + ], + customTools: [{ id: 'custom-1', name: 'custom', icon: '/custom.svg', plugin_id: 'p2' }], + workflowTools: [ + { id: 'workflow-1', name: 'workflow-tool', icon: '/workflow.svg', plugin_id: 'p3' }, + ], + mcpTools: [{ id: 'mcp-1', name: 'mcp-tool', icon: '/mcp.svg', plugin_id: 'p4' }], + }), + ), ) vi.mock('@/service/use-triggers', async () => diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-search.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-search.spec.tsx index e0a9a9d5aab696..8d06f72ed57706 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-search.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-search.spec.tsx @@ -18,20 +18,23 @@ vi.mock('../use-nodes-interactions', () => ({ }), })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ - data: [ - { - id: 'provider-1', - icon: 'tool-icon', - tools: [], - }, - ] satisfies Partial[], - }), - useAllCustomTools: () => ({ data: [] }), - useAllWorkflowTools: () => ({ data: [] }), - useAllMCPTools: () => ({ data: [] }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ + data: [ + { + id: 'provider-1', + icon: 'tool-icon', + tools: [], + }, + ] satisfies Partial[], + }), + 'workspaces.current.tools.api.get': () => ({ data: [] }), + 'workspaces.current.tools.workflow.get': () => ({ data: [] }), + 'workspaces.current.tools.mcp.get': () => ({ data: [] }), + }) +}) const createNode = (overrides: Partial = {}): Node => ({ id: 'node-1', diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts index 6bdb76d83f55af..a729060b2ab910 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts @@ -6,8 +6,11 @@ vi.mock('reactflow', async () => (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), ) -vi.mock('@/service/use-tools', async () => - (await import('../../__tests__/service-mock-factory')).createToolServiceMock(), +vi.mock('@tanstack/react-query', async (importOriginal) => + (await import('@/test/console/operation-hooks')).mockConsoleOperationHooks( + await importOriginal(), + (await import('../../__tests__/service-mock-factory')).createToolOperationRoutes(), + ), ) const { mockToNodeAvailableVars, mockGetVarType } = vi.hoisted(() => ({ diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index d6519980626186..4bd90eaa81a049 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -25,6 +25,7 @@ import { useTranslation } from 'react-i18next' import { useEdges, useStoreApi } from 'reactflow' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { normalizeModelProviderModelsResponse } from '@/app/components/header/account-setting/model-provider-page/utils' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' import { toast } from '@/app/notifications' import { MAX_TREE_DEPTH } from '@/config' @@ -38,12 +39,6 @@ import { import { useParams } from '@/next/navigation' import { consoleQuery } from '@/service/console' import { fetchDatasets } from '@/service/datasets' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' import { useAllTriggerPlugins } from '@/service/use-triggers' import { AppModeEnum } from '@/types/app' import { FlowType } from '@/types/common' @@ -166,10 +161,22 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? const { t } = useTranslation(['agentV2', 'common', 'workflow', 'modelProvider']) const language = useGetLanguage() const { nodesMap: nodesExtraData } = useNodesMetaData() - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const inlineAgentToolProviderCatalog = useMemo( () => createAgentToolProviderCatalog({ buildInTools, customTools, mcpTools, workflowTools }), [buildInTools, customTools, mcpTools, workflowTools], @@ -653,9 +660,19 @@ export const useChecklistBeforePublish = () => { select: (response) => response.data, }), ) - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) const flowType = useHooksStore((s) => s.configsMap?.flowType) const { appId } = useParams<{ appId?: string }>() const { data: appMode } = useQuery( diff --git a/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts b/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts index 653bd9d55954be..b96b6d4abc31ae 100644 --- a/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts +++ b/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts @@ -2,15 +2,12 @@ import type { Node, WorkflowPluginCatalogs } from '@/app/components/workflow/typ import type { SchemaTypeDefinition } from '@/service/use-common' import type { FlowType } from '@/types/common' import type { NodeWithVar, VarInInspect } from '@/types/workflow' +import { useQuery } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' import { useStoreApi } from 'reactflow' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { useInvalidateConversationVarValues, useInvalidateSysVarValues, @@ -32,10 +29,22 @@ export const useSetWorkflowVarsWithValue = ({ flowType, flowId }: Params) => { const invalidateSysVarValues = useInvalidateSysVarValues(flowType, flowId) const { handleCancelAllNodeSuccessStatus } = useNodesInteractionsWithoutSync() const { schemaTypeDefinitions } = useMatchSchemaType() - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const dataSourceList = useStore((s) => s.dataSourceList) const allPluginInfoList = useMemo(() => { diff --git a/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts b/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts index d8913d5a5fc3df..f1d42c6e4900a8 100644 --- a/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts +++ b/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts @@ -2,9 +2,11 @@ import type { Node, ValueSelector } from '@/app/components/workflow/types' import type { SchemaTypeDefinition } from '@/service/use-common' import type { FlowType } from '@/types/common' import type { VarInInspect } from '@/types/workflow' +import { useQuery } from '@tanstack/react-query' import { produce } from 'immer' import { useCallback } from 'react' import { useStoreApi } from 'reactflow' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { isConversationVar, isENV, @@ -12,13 +14,8 @@ import { toNodeOutputVars, } from '@/app/components/workflow/nodes/_base/components/variable/utils' import { useWorkflowStore } from '@/app/components/workflow/store' +import { consoleQuery } from '@/service/console' import useFLow from '@/service/use-flow' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' import { fetchNodeInspectVars } from '@/service/workflow' import { VarInInspectType } from '@/types/workflow' import { useEdgesInteractionsWithoutSync } from './use-edges-interactions-without-sync' @@ -53,10 +50,22 @@ export const useInspectVarsCrudCommon = ({ flowId, flowType }: Params) => { const { mutateAsync: doEditInspectorVar } = useEditInspectorVar(flowId) const { handleCancelNodeSuccessStatus } = useNodesInteractionsWithoutSync() const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync() - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const getNodeInspectVars = useCallback( (nodeId: string) => { diff --git a/web/app/components/workflow/hooks/use-node-plugin-installation.ts b/web/app/components/workflow/hooks/use-node-plugin-installation.ts index 5c228a791c9a5a..c754f27f56c9aa 100644 --- a/web/app/components/workflow/hooks/use-node-plugin-installation.ts +++ b/web/app/components/workflow/hooks/use-node-plugin-installation.ts @@ -2,18 +2,13 @@ import type { DataSourceNodeType } from '../nodes/data-source/types' import type { ToolNodeType } from '../nodes/tool/types' import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types' import type { CommonNodeType } from '../types' -import { useQueryClient } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission' import { CollectionType } from '@/app/components/tools/types' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' +import { getToolListQueryKey } from '@/app/components/tools/utils/tool-list-query-key' import { consoleQuery } from '@/service/console' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, - useInvalidToolsByType, -} from '@/service/use-tools' import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers' import { useStore } from '../store' import { BlockEnum } from '../types' @@ -51,11 +46,32 @@ const useToolInstallation = ( const isWorkflow = enabled && data.provider_type === CollectionType.workflow const isMcp = enabled && data.provider_type === CollectionType.mcp - const builtInQuery = useAllBuiltInTools(isBuiltIn) - const customQuery = useAllCustomTools(isCustom) - const workflowQuery = useAllWorkflowTools(isWorkflow) - const mcpQuery = useAllMCPTools(isMcp) - const invalidateTools = useInvalidToolsByType(enabled ? data.provider_type : undefined) + const builtInQuery = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + enabled: isBuiltIn, + select: selectToolsWithProvider, + }), + ) + const customQuery = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ + enabled: isCustom, + select: selectToolsWithProvider, + }), + ) + const workflowQuery = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + enabled: isWorkflow, + select: selectToolsWithProvider, + }), + ) + const mcpQuery = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ + enabled: isMcp, + select: selectToolsWithProvider, + }), + ) + const queryClient = useQueryClient() + const toolListQueryKey = enabled ? getToolListQueryKey(data.provider_type) : undefined const collectionInfo = useMemo(() => { if (!enabled) return undefined @@ -112,8 +128,8 @@ const useToolInstallation = ( const canInstall = Boolean(data.plugin_unique_identifier) && canInstallPlugin const onInstallSuccess = useCallback(() => { - if (invalidateTools) invalidateTools() - }, [invalidateTools]) + if (toolListQueryKey) queryClient.invalidateQueries({ queryKey: toolListQueryKey }) + }, [queryClient, toolListQueryKey]) const shouldDim = (!!collectionInfo && !isResolved) || (isResolved && !matchedCollection) diff --git a/web/app/components/workflow/hooks/use-nodes-meta-data.ts b/web/app/components/workflow/hooks/use-nodes-meta-data.ts index b7d195a878ffb9..054377f3e63d13 100644 --- a/web/app/components/workflow/hooks/use-nodes-meta-data.ts +++ b/web/app/components/workflow/hooks/use-nodes-meta-data.ts @@ -1,14 +1,16 @@ import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-store' import type { Node } from '@/app/components/workflow/types' +import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { CollectionType } from '@/app/components/tools/types' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { useHooksStore } from '@/app/components/workflow/hooks-store' import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { getNodeCatalogType } from '@/app/components/workflow/utils/node' import { useGetLanguage } from '@/context/i18n' import { renderI18nObject } from '@/i18n/metadata' -import { useAllBuiltInTools, useAllCustomTools, useAllWorkflowTools } from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { matchesProviderReference } from '@/utils/provider-reference' import { matchDataSource } from '../utils/plugin-install-check' @@ -25,9 +27,19 @@ export const useNodesMetaData = () => { export const useNodeMetaData = (node: Node) => { const language = useGetLanguage() - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) const dataSourceList = useStore((s) => s.dataSourceList) const availableNodesMetaData = useNodesMetaData() const { data } = node diff --git a/web/app/components/workflow/hooks/use-tool-icon.ts b/web/app/components/workflow/hooks/use-tool-icon.ts index 1b71c7fd0f69f1..f9a13bf1af4a7d 100644 --- a/web/app/components/workflow/hooks/use-tool-icon.ts +++ b/web/app/components/workflow/hooks/use-tool-icon.ts @@ -4,16 +4,13 @@ import type { DataSourceNodeType } from '../nodes/data-source/types' import type { ToolNodeType } from '../nodes/tool/types' import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types' import type { Node, ToolWithProvider } from '../types' +import { useQuery } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' import { resolveDatasourceIcon } from '@/app/components/rag-pipeline/utils/datasource-icon' import { CollectionType } from '@/app/components/tools/types' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import useTheme from '@/hooks/use-theme' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { useAllTriggerPlugins } from '@/service/use-triggers' import { canFindTool } from '@/utils' import { matchesProviderReference } from '@/utils/provider-reference' @@ -174,10 +171,22 @@ export const findNodeIcon = ({ } export const useToolIcon = (data?: Node['data']) => { - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const dataSourceList = useStore((s) => s.dataSourceList) const { data: triggerPlugins } = useAllTriggerPlugins() const { theme } = useTheme() @@ -212,10 +221,22 @@ export const useToolIcon = (data?: Node['data']) => { } export const useGetToolIcon = () => { - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const { data: triggerPlugins } = useAllTriggerPlugins() const workflowStore = useWorkflowStore() const { theme } = useTheme() diff --git a/web/app/components/workflow/hooks/use-workflow-search.tsx b/web/app/components/workflow/hooks/use-workflow-search.tsx index fa7e7cb9b5a105..c605b9562d9d80 100644 --- a/web/app/components/workflow/hooks/use-workflow-search.tsx +++ b/web/app/components/workflow/hooks/use-workflow-search.tsx @@ -3,17 +3,14 @@ import type { LLMNodeType } from '../nodes/llm/types' import type { CommonNodeType } from '../types' import type { Emoji } from '@/app/components/tools/types' +import { useQuery } from '@tanstack/react-query' import { useCallback, useEffect, useMemo } from 'react' import { useNodes } from 'reactflow' import { CollectionType } from '@/app/components/tools/types' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import BlockIcon from '@/app/components/workflow/block-icon' import { registerWorkflowNodeSearch } from '@/app/components/workflow/goto-anything-search' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { matchesProviderReference } from '@/utils/provider-reference' import { BlockEnum } from '../types' import { setupNodeSelectionListener } from '../utils/node-navigation' @@ -27,10 +24,22 @@ export const useWorkflowSearch = () => { const { handleNodeSelect } = useNodesInteractions() // Filter and process nodes for search - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) // Extract tool icon logic - clean separation of concerns const getToolIcon = useCallback( diff --git a/web/app/components/workflow/hooks/use-workflow-variables.ts b/web/app/components/workflow/hooks/use-workflow-variables.ts index 2d722372518ed8..297aaf32ae1102 100644 --- a/web/app/components/workflow/hooks/use-workflow-variables.ts +++ b/web/app/components/workflow/hooks/use-workflow-variables.ts @@ -1,18 +1,15 @@ import type { Type } from '../nodes/llm/types' import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' +import { useQuery } from '@tanstack/react-query' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { getVarType, toNodeAvailableVars, } from '@/app/components/workflow/nodes/_base/components/variable/utils' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import useMatchSchemaType from '../nodes/_base/components/variable/use-match-schema-type' import { useWorkflowStore } from '../store' import { useIsChatMode } from './use-workflow' @@ -22,10 +19,22 @@ export const useWorkflowVariables = () => { const workflowStore = useWorkflowStore() const { schemaTypeDefinitions } = useMatchSchemaType() - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const getNodeAvailableVars = useCallback( ({ diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index e2f3a6bd4761c1..5d836b9cadb61b 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -19,6 +19,7 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' +import { useQuery } from '@tanstack/react-query' import { useEventListener } from 'ahooks' import { isEqual } from 'es-toolkit/predicate' import { setAutoFreeze } from 'immer' @@ -46,15 +47,11 @@ import { useReactFlow, useStoreApi, } from 'reactflow' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { toast } from '@/app/notifications' import { IS_DEV } from '@/config' import { useEventEmitterContextContext } from '@/context/event-emitter' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { fetchAllInspectVars } from '@/service/workflow' import CandidateNode from './candidate-node' import UserCursors from './collaboration/components/user-cursors' @@ -567,10 +564,26 @@ export const Workflow: FC = memo( const { schemaTypeDefinitions } = useMatchSchemaType() const { fetchInspectVars } = useSetWorkflowVarsWithValue() - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) const dataSourceList = useStore((s) => s.dataSourceList) // buildInTools, customTools, workflowTools, mcpTools, dataSourceList const configsMap = useHooksStore((s) => s.configsMap) diff --git a/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx b/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx index 623341788083aa..a62903423bacac 100644 --- a/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx +++ b/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx @@ -5,7 +5,6 @@ import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__ import { useHooksStore } from '@/app/components/workflow/hooks-store' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' -import { useAllWorkflowTools } from '@/service/use-tools' import { FlowType } from '@/types/common' import { BlockClassification } from '../../block-selector/types' import { useAvailableBlocks } from '../../hooks/use-available-blocks' @@ -59,13 +58,17 @@ vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ default: vi.fn(), })) -vi.mock('@/service/use-tools', () => ({ - useAllWorkflowTools: vi.fn(), - useAllBuiltInTools: () => ({ data: [] }), - useAllCustomTools: () => ({ data: [] }), - useAllMCPTools: () => ({ data: [] }), - useInvalidateAllBuiltInTools: () => vi.fn(), -})) +const { mockUseAllWorkflowTools } = vi.hoisted(() => ({ mockUseAllWorkflowTools: vi.fn() })) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.workflow.get': () => mockUseAllWorkflowTools(), + 'workspaces.current.tools.builtin.get': () => ({ data: [] }), + 'workspaces.current.tools.api.get': () => ({ data: [] }), + 'workspaces.current.tools.mcp.get': () => ({ data: [] }), + }) +}) vi.mock('@/service/use-plugins', () => ({ useFeaturedToolsRecommendations: () => ({ plugins: [], isLoading: false }), @@ -86,7 +89,6 @@ const mockUseNodesInteractions = vi.mocked(useNodesInteractions) const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) const mockUseHooksStore = vi.mocked(useHooksStore) const mockUseNodes = vi.mocked(useNodes) -const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools) async function renderDropdownContent({ showHelpLink = true, diff --git a/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx index d0a655d6fadebb..92909f0934efba 100644 --- a/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx +++ b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx @@ -5,7 +5,6 @@ import { fireEvent, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' import { BlockEnum } from '@/app/components/workflow/types' -import { useAllWorkflowTools } from '@/service/use-tools' import { useNodesInteractions } from '../../hooks/use-nodes-interactions' import { useNodeMetaData } from '../../hooks/use-nodes-meta-data' import { useNodesReadOnly } from '../../hooks/use-workflow' @@ -38,9 +37,14 @@ vi.mock('../../hooks/use-workflow', async (importOriginal) => { } }) -vi.mock('@/service/use-tools', () => ({ - useAllWorkflowTools: vi.fn(), -})) +const { mockUseAllWorkflowTools } = vi.hoisted(() => ({ mockUseAllWorkflowTools: vi.fn() })) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.workflow.get': () => mockUseAllWorkflowTools(), + }) +}) vi.mock('../change-block-popup', () => ({ ChangeBlockPopup: () =>
, @@ -49,7 +53,6 @@ vi.mock('../change-block-popup', () => ({ const mockUseNodeMetaData = vi.mocked(useNodeMetaData) const mockUseNodesInteractions = vi.mocked(useNodesInteractions) const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) -const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools) const createQueryResult = (data: T): UseQueryResult => ({ diff --git a/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts index f3652a4b6c376c..4110a996fb519e 100644 --- a/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts +++ b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts @@ -1,12 +1,14 @@ import type { Node } from '@/app/components/workflow/types' +import { useQuery } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' import { useEdges } from 'reactflow' import { CollectionType } from '@/app/components/tools/types' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { useHooksStore } from '@/app/components/workflow/hooks-store' import { useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' import { canRunBySingle } from '@/app/components/workflow/utils' -import { useAllWorkflowTools } from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { matchesProviderReference } from '@/utils/provider-reference' import { useNodesInteractions } from '../hooks/use-nodes-interactions' import { useNodeMetaData } from '../hooks/use-nodes-meta-data' @@ -32,7 +34,11 @@ export function useNodeActionsMenuModel({ const { nodesReadOnly } = useNodesReadOnly() const canRunWorkflow = useHooksStore((s) => s.accessControl.canRun) const nodeMetaData = useNodeMetaData({ id, data } as Node) - const { data: workflowTools } = useAllWorkflowTools() + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) const isChildNode = !!(data.isInIteration || data.isInLoop) const canRun = canRunWorkflow && !nodesReadOnly && canRunBySingle(data.type, isChildNode) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/node-focus.browser.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/node-focus.browser.spec.tsx index cacd1ca5b69361..297fec4f32cd80 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/node-focus.browser.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/node-focus.browser.spec.tsx @@ -59,15 +59,16 @@ vi.mock('@/service/use-triggers', () => ({ useInvalidateAllTriggerPlugins: () => vi.fn(), })) -vi.mock('@/service/use-tools', () => ({ - useRAGRecommendedPlugins: () => ({ data: [] }), - useInvalidateAllToolProviders: () => vi.fn(), - useAllBuiltInTools: () => ({ data: [] }), - useAllCustomTools: () => ({ data: [] }), - useAllWorkflowTools: () => ({ data: [] }), - useAllMCPTools: () => ({ data: [] }), - useInvalidateAllBuiltInTools: () => vi.fn(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.recommendedPlugins.get': () => ({ data: [] }), + 'workspaces.current.tools.builtin.get': () => ({ data: [] }), + 'workspaces.current.tools.api.get': () => ({ data: [] }), + 'workspaces.current.tools.workflow.get': () => ({ data: [] }), + 'workspaces.current.tools.mcp.get': () => ({ data: [] }), + }) +}) vi.mock('@/app/components/plugins/marketplace/query', () => ({ useMarketplacePlugins: () => ({ data: undefined }), diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx index 57c7ff0c34b221..9c8adec850b350 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx @@ -144,13 +144,15 @@ vi.mock('@/service/use-triggers', () => ({ useInvalidateAllTriggerPlugins: () => vi.fn(), })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: [] }), - useAllCustomTools: () => ({ data: [] }), - useAllWorkflowTools: () => ({ data: [] }), - useAllMCPTools: () => ({ data: [] }), - useInvalidateAllBuiltInTools: () => vi.fn(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: [] }), + 'workspaces.current.tools.api.get': () => ({ data: [] }), + 'workspaces.current.tools.workflow.get': () => ({ data: [] }), + 'workspaces.current.tools.mcp.get': () => ({ data: [] }), + }) +}) vi.mock('@/app/components/plugins/marketplace/query', () => ({ useMarketplacePlugins: () => ({ data: undefined }), diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx index 5cfda71a375329..7365ebda700b95 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx @@ -207,11 +207,14 @@ vi.mock('@/app/components/workflow/run/hooks', () => ({ useLogs: () => mockLogsState, })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ - data: mockBuiltInTools, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ + data: mockBuiltInTools, + }), + }) +}) vi.mock('@/service/use-triggers', () => ({ useAllTriggerPlugins: () => ({ diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 24b5391f58f49e..0c1bd954b358ac 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -6,7 +6,7 @@ import { IconButton } from '@langgenius/dify-ui/icon-button' import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiCloseLine, RiPlayLargeLine } from '@remixicon/react' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { debounce } from 'es-toolkit/compat' import { useQueryState } from 'nuqs' import * as React from 'react' @@ -28,6 +28,7 @@ import { } from '@/app/components/plugins/plugin-auth' import { usePluginStore } from '@/app/components/plugins/plugin-detail-panel/store' import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import BlockIcon from '@/app/components/workflow/block-icon' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration' @@ -48,7 +49,7 @@ import { } from '@/app/components/workflow/utils' import { userProfileQueryOptions } from '@/features/account-profile/client' import { useParams } from '@/next/navigation' -import { useAllBuiltInTools } from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { useAllTriggerPlugins } from '@/service/use-triggers' import { FlowType } from '@/types/common' import { useAvailableBlocks } from '../../../../hooks/use-available-blocks' @@ -361,7 +362,11 @@ const BasePanel: FC = ({ id, data, children }) => { }, [data.type, logParams]) const storeBuildInTools = useStore((s) => s.buildInTools) - const { data: buildInTools } = useAllBuiltInTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) const currToolCollection = useMemo( () => getCurrentToolCollection(buildInTools, storeBuildInTools, data.provider_id), [buildInTools, storeBuildInTools, data.provider_id], diff --git a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts index 4b30f53ab708f3..47fbad8a972ae5 100644 --- a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts +++ b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts @@ -95,12 +95,15 @@ vi.mock('reactflow', () => ({ }), })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: [] }), - useAllCustomTools: () => ({ data: [] }), - useAllWorkflowTools: () => ({ data: [] }), - useAllMCPTools: () => ({ data: [] }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: [] }), + 'workspaces.current.tools.api.get': () => ({ data: [] }), + 'workspaces.current.tools.workflow.get': () => ({ data: [] }), + 'workspaces.current.tools.mcp.get': () => ({ data: [] }), + }) +}) vi.mock('@/service/use-workflow', () => ({ useInvalidLastRun: () => vi.fn(), diff --git a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts index c28c0c22e61f57..9a616c0eadc7a6 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts @@ -8,6 +8,7 @@ import type { } from '@/app/components/workflow/types' import type { FlowType } from '@/types/common' import type { NodeRunResult, NodeTracing } from '@/types/workflow' +import { useQuery } from '@tanstack/react-query' import { unionBy } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function' import { produce } from 'immer' @@ -16,6 +17,7 @@ import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' import { trackEvent } from '@/app/components/base/amplitude' import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { getNodeInfoById, isConversationVar, @@ -50,12 +52,7 @@ import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/ import { toast } from '@/app/notifications' import { useEventEmitterContextContext } from '@/context/event-emitter' import { post, ssePost } from '@/service/base' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { useInvalidLastRun } from '@/service/use-workflow' import { fetchNodeInspectVars, @@ -171,10 +168,22 @@ const useOneStepRun = ({ const workflowStore = useWorkflowStore() const { schemaTypeDefinitions } = useMatchSchemaType() - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const getVar = (valueSelector: ValueSelector): Var | undefined => { const isSystem = valueSelector[0] === 'sys' diff --git a/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx index 3af81888d1b228..c6a71bba7bebba 100644 --- a/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx @@ -55,13 +55,6 @@ vi.mock('@langgenius/dify-ui/status-dot', () => ({ StatusDot: ({ status }: any) =>
{`indicator:${status}`}
, })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: mockBuiltInTools }), - useAllCustomTools: () => ({ data: mockCustomTools }), - useAllWorkflowTools: () => ({ data: mockWorkflowTools }), - useAllMCPTools: () => ({ data: mockMcpTools }), -})) - vi.mock('@/utils/get-icon', () => ({ getIconFromMarketPlace: () => mockMarketplaceIcon, })) @@ -372,7 +365,8 @@ describe('agent path', () => { vi.mock('@tanstack/react-query', async (importOriginal) => { const actual = await importOriginal() - return { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + const reactQuery = { ...actual, useQuery: (options: { queryKey: OperationKey< @@ -391,5 +385,11 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { if (modelType === ModelTypeEnum.textEmbedding) return { data: mockTextEmbeddingModels } return { data: mockTtsModels } }, - } + } as typeof actual + return mockConsoleOperationHooks(reactQuery, { + 'workspaces.current.tools.builtin.get': () => ({ data: mockBuiltInTools }), + 'workspaces.current.tools.api.get': () => ({ data: mockCustomTools }), + 'workspaces.current.tools.workflow.get': () => ({ data: mockWorkflowTools }), + 'workspaces.current.tools.mcp.get': () => ({ data: mockMcpTools }), + }) }) diff --git a/web/app/components/workflow/nodes/agent/components/__tests__/tool-icon.spec.tsx b/web/app/components/workflow/nodes/agent/components/__tests__/tool-icon.spec.tsx index 810a1771523cfe..d645249e4ed8c0 100644 --- a/web/app/components/workflow/nodes/agent/components/__tests__/tool-icon.spec.tsx +++ b/web/app/components/workflow/nodes/agent/components/__tests__/tool-icon.spec.tsx @@ -14,12 +14,15 @@ let mockWorkflowTools: ToolProvider[] | undefined let mockMcpTools: ToolProvider[] | undefined let mockMarketplaceIcon: string | { content: string; background: string } | undefined -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: mockBuiltInTools }), - useAllCustomTools: () => ({ data: mockCustomTools }), - useAllWorkflowTools: () => ({ data: mockWorkflowTools }), - useAllMCPTools: () => ({ data: mockMcpTools }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: mockBuiltInTools }), + 'workspaces.current.tools.api.get': () => ({ data: mockCustomTools }), + 'workspaces.current.tools.workflow.get': () => ({ data: mockWorkflowTools }), + 'workspaces.current.tools.mcp.get': () => ({ data: mockMcpTools }), + }) +}) vi.mock('@langgenius/dify-ui/status-dot', () => ({ StatusDot: ({ status }: { status: string }) =>
{`indicator:${status}`}
, diff --git a/web/app/components/workflow/nodes/agent/components/tool-icon.tsx b/web/app/components/workflow/nodes/agent/components/tool-icon.tsx index 7aae1bdaa066e6..aa5397b782b29e 100644 --- a/web/app/components/workflow/nodes/agent/components/tool-icon.tsx +++ b/web/app/components/workflow/nodes/agent/components/tool-icon.tsx @@ -2,15 +2,12 @@ import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useQuery } from '@tanstack/react-query' import { memo, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' +import { consoleQuery } from '@/service/console' import { getIconFromMarketPlace } from '@/utils/get-icon' import { matchesProviderReference } from '@/utils/provider-reference' @@ -23,10 +20,22 @@ export type ToolIconProps = { export const ToolIcon = memo(({ providerName }: ToolIconProps) => { const containerRef = useRef(null) - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const isDataReady = !!buildInTools && !!customTools && !!workflowTools && !!mcpTools const currentProvider = useMemo(() => { const mergedTools = [ diff --git a/web/app/components/workflow/nodes/if-else/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/if-else/__tests__/integration.spec.tsx index 912cd0334a7961..c48160ef89d505 100644 --- a/web/app/components/workflow/nodes/if-else/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/if-else/__tests__/integration.spec.tsx @@ -101,12 +101,15 @@ vi.mock('../../variable-assigner/hooks', () => ({ ], })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: [] }), - useAllCustomTools: () => ({ data: [] }), - useAllWorkflowTools: () => ({ data: [] }), - useAllMCPTools: () => ({ data: [] }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: [] }), + 'workspaces.current.tools.api.get': () => ({ data: [] }), + 'workspaces.current.tools.workflow.get': () => ({ data: [] }), + 'workspaces.current.tools.mcp.get': () => ({ data: [] }), + }) +}) vi.mock('../use-config', () => ({ default: vi.fn(), diff --git a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx index 666db4f05f3f82..b341795bc06ae0 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx @@ -22,19 +22,16 @@ import { SelectTrigger, } from '@langgenius/dify-ui/select' import { RiDeleteBinLine } from '@remixicon/react' +import { useQuery } from '@tanstack/react-query' import { produce } from 'immer' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { getVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils' import BoolValue from '@/app/components/workflow/panel/chat-variable-panel/components/bool-value' import { useWorkflowStore } from '@/app/components/workflow/store' import { VarType } from '@/app/components/workflow/types' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { useIsChatMode } from '../../../../hooks/use-workflow' import useMatchSchemaType from '../../../_base/components/variable/use-match-schema-type' import { FILE_TYPE_OPTIONS, SUB_VARIABLES, TRANSFER_METHOD } from '../../../constants' @@ -105,10 +102,22 @@ const ConditionItem = ({ const [isHovered, setIsHovered] = useState(false) const [open, setOpen] = useState(false) - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const workflowStore = useWorkflowStore() diff --git a/web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts index 564ed804d75d7b..f10ca6e2dcc975 100644 --- a/web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts @@ -43,12 +43,15 @@ vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ default: (...args: unknown[]) => mockUseNodeCrud(...args), })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => mockUseAllBuiltInTools(), - useAllCustomTools: () => mockUseAllCustomTools(), - useAllWorkflowTools: () => mockUseAllWorkflowTools(), - useAllMCPTools: () => mockUseAllMCPTools(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => mockUseAllBuiltInTools(), + 'workspaces.current.tools.api.get': () => mockUseAllCustomTools(), + 'workspaces.current.tools.workflow.get': () => mockUseAllWorkflowTools(), + 'workspaces.current.tools.mcp.get': () => mockUseAllMCPTools(), + }) +}) vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({ toNodeOutputVars: (...args: unknown[]) => mockToNodeOutputVars(...args), diff --git a/web/app/components/workflow/nodes/iteration/use-config.ts b/web/app/components/workflow/nodes/iteration/use-config.ts index 4aa1d471c26240..dfda7124a90867 100644 --- a/web/app/components/workflow/nodes/iteration/use-config.ts +++ b/web/app/components/workflow/nodes/iteration/use-config.ts @@ -1,15 +1,12 @@ import type { ErrorHandleMode, ValueSelector, Var } from '../../types' import type { IterationNodeType } from './types' import type { VarKindType } from '@/app/components/workflow/nodes/_base/types' +import { useQuery } from '@tanstack/react-query' import { isEqual } from 'es-toolkit/predicate' import { produce } from 'immer' import { useCallback } from 'react' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' +import { consoleQuery } from '@/service/console' import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks/use-workflow' import { useStore } from '../../store' @@ -56,10 +53,22 @@ const useConfig = (id: string, payload: IterationNodeType) => { // output const { getIterationNodeChildren } = useWorkflow() const iterationChildrenNodes = getIterationNodeChildren(id) - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const dataSourceList = useStore((s) => s.dataSourceList) const allPluginInfoList = { buildInTools: buildInTools || [], diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx index 84261702a96364..a73d69813ce00a 100644 --- a/web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx @@ -27,12 +27,15 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: [] }), - useAllCustomTools: () => ({ data: [] }), - useAllWorkflowTools: () => ({ data: [] }), - useAllMCPTools: () => ({ data: [] }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: [] }), + 'workspaces.current.tools.api.get': () => ({ data: [] }), + 'workspaces.current.tools.workflow.get': () => ({ data: [] }), + 'workspaces.current.tools.mcp.get': () => ({ data: [] }), + }) +}) vi.mock('../../../hooks/use-workflow', async (importOriginal) => { const actual = await importOriginal() diff --git a/web/app/components/workflow/nodes/loop/use-config.ts b/web/app/components/workflow/nodes/loop/use-config.ts index 118e3095053dd8..68eeeb013cd46d 100644 --- a/web/app/components/workflow/nodes/loop/use-config.ts +++ b/web/app/components/workflow/nodes/loop/use-config.ts @@ -9,14 +9,11 @@ import type { HandleUpdateSubVariableCondition, LoopNodeType, } from './types' +import { useQuery } from '@tanstack/react-query' import { useCallback, useEffect, useRef } from 'react' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { useStore } from '@/app/components/workflow/store' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks/use-workflow' import { toNodeOutputVars } from '../_base/components/variable/utils' import useNodeCrud from '../_base/hooks/use-node-crud' @@ -61,10 +58,22 @@ const useConfig = (id: string, payload: LoopNodeType) => { // output const { getLoopNodeChildren } = useWorkflow() const loopChildrenNodes = [{ id, data: payload } as any, ...getLoopNodeChildren(id)] - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const dataSourceList = useStore((s) => s.dataSourceList) const allPluginInfoList = { buildInTools: buildInTools || [], diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx index a65eb3e3ba216e..dda0e6953eb20a 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx @@ -32,13 +32,15 @@ const mockToolCollections = vi.hoisted(() => ({ workflow: [] as ToolWithProvider[], })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: mockToolCollections.builtIn }), - useAllCustomTools: () => ({ data: mockToolCollections.custom }), - useAllWorkflowTools: () => ({ data: mockToolCollections.workflow }), - useAllMCPTools: () => ({ data: [] }), - useInvalidateAllBuiltInTools: () => vi.fn(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: mockToolCollections.builtIn }), + 'workspaces.current.tools.api.get': () => ({ data: mockToolCollections.custom }), + 'workspaces.current.tools.workflow.get': () => ({ data: mockToolCollections.workflow }), + 'workspaces.current.tools.mcp.get': () => ({ data: [] }), + }) +}) vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useLanguage: () => 'en_US', diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx index 61e4f2e1c54f97..d827dc07b379fe 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx @@ -8,11 +8,13 @@ import type { } from '@/app/components/workflow/block-selector/types' import type { BlockEnum } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' +import { useQuery } from '@tanstack/react-query' import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { CollectionType } from '@/app/components/tools/types' -import { useAllBuiltInTools, useAllCustomTools, useAllWorkflowTools } from '@/service/use-tools' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' +import { consoleQuery } from '@/service/console' import { matchesProviderReference } from '@/utils/provider-reference' import BlockSelector from '../../../../block-selector' import { TabType } from '../../../../block-selector/types' @@ -38,9 +40,19 @@ const ImportFromTool: FC = ({ onImport }) => { const { t } = useTranslation(['workflowModels']) const language = useLanguage() - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) const handleSelectTool = useCallback( (_type: BlockEnum, toolInfo?: BlockDefaultValue) => { diff --git a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-config.spec.tsx index 4c5eb618e810bb..9b4268ac895a82 100644 --- a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-config.spec.tsx @@ -3,8 +3,13 @@ import { renderHook } from '@testing-library/react' import { CollectionType } from '@/app/components/tools/types' import { VarKindType } from '@/app/components/workflow/nodes/_base/types' import { BlockEnum } from '@/app/components/workflow/types' +import { createQueryClientWrapper } from '@/test/console/query-client' +import { createTestQueryClient } from '@/test/query-client' import useConfig from '../use-config' +const queryClient = createTestQueryClient() +const queryClientWrapper = createQueryClientWrapper(queryClient) + const mockSetInputs = vi.hoisted(() => vi.fn()) const mockSetControlPromptEditorRerenderKey = vi.hoisted(() => vi.fn()) const mockUseCurrentToolCollection = vi.hoisted(() => vi.fn()) @@ -41,10 +46,6 @@ vi.mock('@/service/tools', () => ({ updateBuiltInToolCredential: vi.fn(), })) -vi.mock('@/service/use-tools', () => ({ - useInvalidToolsByType: () => vi.fn(), -})) - vi.mock('@/app/components/workflow/store', () => ({ useWorkflowStore: () => ({ getState: () => ({ @@ -162,6 +163,7 @@ describe('useConfig', () => { const { rerender } = renderHook(({ payload }) => useConfig('tool-node-1', payload), { initialProps: { payload: emptyPayload }, + wrapper: queryClientWrapper, }) expect(mockSetInputs).toHaveBeenCalledTimes(1) @@ -178,14 +180,16 @@ describe('useConfig', () => { }) it('should not update inputs when tool values are already populated on first render', () => { - renderHook(() => - useConfig( - 'tool-node-1', - createNodeData({ - tool_parameters: { query: createToolVarInput('existing query') }, - tool_configurations: { api_key: createToolVarInput('existing secret') }, - }), - ), + renderHook( + () => + useConfig( + 'tool-node-1', + createNodeData({ + tool_parameters: { query: createToolVarInput('existing query') }, + tool_configurations: { api_key: createToolVarInput('existing secret') }, + }), + ), + { wrapper: queryClientWrapper }, ) expect(mockSetInputs).not.toHaveBeenCalled() @@ -202,7 +206,7 @@ describe('useConfig', () => { }) mockGetConfiguredValue.mockReturnValue({}) - renderHook(() => useConfig('tool-node-1', createNodeData())) + renderHook(() => useConfig('tool-node-1', createNodeData()), { wrapper: queryClientWrapper }) expect(mockSetInputs).not.toHaveBeenCalled() }) diff --git a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-current-tool-collection.spec.tsx b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-current-tool-collection.spec.tsx index 3e0b36ecb9c008..c0ed4709e0a10d 100644 --- a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-current-tool-collection.spec.tsx +++ b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-current-tool-collection.spec.tsx @@ -9,12 +9,15 @@ const mockUseAllWorkflowTools = vi.hoisted(() => vi.fn()) const mockUseAllMCPTools = vi.hoisted(() => vi.fn()) const mockCanFindTool = vi.hoisted(() => vi.fn()) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => mockUseAllBuiltInTools(), - useAllCustomTools: () => mockUseAllCustomTools(), - useAllWorkflowTools: () => mockUseAllWorkflowTools(), - useAllMCPTools: () => mockUseAllMCPTools(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => mockUseAllBuiltInTools(), + 'workspaces.current.tools.api.get': () => mockUseAllCustomTools(), + 'workspaces.current.tools.workflow.get': () => mockUseAllWorkflowTools(), + 'workspaces.current.tools.mcp.get': () => mockUseAllMCPTools(), + }) +}) vi.mock('@/utils', () => ({ canFindTool: (...args: unknown[]) => mockCanFindTool(...args), diff --git a/web/app/components/workflow/nodes/tool/hooks/use-config.ts b/web/app/components/workflow/nodes/tool/hooks/use-config.ts index a782820cb07adc..df389818d350d1 100644 --- a/web/app/components/workflow/nodes/tool/hooks/use-config.ts +++ b/web/app/components/workflow/nodes/tool/hooks/use-config.ts @@ -1,6 +1,7 @@ import type { ToolNodeType } from '../types' import type { ResourceVarInputs } from '@/app/components/workflow/nodes/_base/types' import type { InputVar } from '@/app/components/workflow/types' +import { useQueryClient } from '@tanstack/react-query' import { capitalize } from 'es-toolkit/string' import { produce } from 'immer' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -10,10 +11,10 @@ import { getConfiguredValue, toolParametersToFormSchemas, } from '@/app/components/tools/utils/to-form-schema' +import { getToolListQueryKey } from '@/app/components/tools/utils/tool-list-query-key' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useWorkflowStore } from '@/app/components/workflow/store' import { updateBuiltInToolCredential } from '@/service/tools' -import { useInvalidToolsByType } from '@/service/use-tools' import { useNodesReadOnly } from '../../../hooks/use-workflow' import { isToolAuthorizationRequired } from '../auth' import { normalizeJsonSchemaType } from '../output-schema-utils' @@ -42,15 +43,16 @@ const useConfig = (id: string, payload: ToolNodeType) => { const isShowAuthBtn = isToolAuthorizationRequired(provider_type, currCollection) const [showSetAuth, setShowSetAuth] = useState(false) - const invalidToolsByType = useInvalidToolsByType(provider_type) + const queryClient = useQueryClient() const handleSaveAuth = useCallback( async (value: any) => { await updateBuiltInToolCredential(currCollection?.name as string, value) - invalidToolsByType() + const toolListQueryKey = getToolListQueryKey(provider_type) + if (toolListQueryKey) queryClient.invalidateQueries({ queryKey: toolListQueryKey }) setShowSetAuth(false) }, - [currCollection?.name, invalidToolsByType], + [currCollection?.name, provider_type, queryClient], ) const currTool = useMemo(() => { diff --git a/web/app/components/workflow/nodes/tool/hooks/use-current-tool-collection.ts b/web/app/components/workflow/nodes/tool/hooks/use-current-tool-collection.ts index 87199877414667..b792a67bb7b6e4 100644 --- a/web/app/components/workflow/nodes/tool/hooks/use-current-tool-collection.ts +++ b/web/app/components/workflow/nodes/tool/hooks/use-current-tool-collection.ts @@ -1,23 +1,32 @@ import type { ToolNodeType } from '../types' import type { ToolWithProvider } from '@/app/components/workflow/types' +import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { CollectionType } from '@/app/components/tools/types' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' +import { consoleQuery } from '@/service/console' import { matchesProviderReference } from '@/utils/provider-reference' const useCurrentToolCollection = ( providerType: ToolNodeType['provider_type'], providerId: string, ) => { - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const currentTools = useMemo(() => { switch (providerType) { diff --git a/web/app/components/workflow/operator/__tests__/add-block.spec.tsx b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx index 6a08533e79e220..61d00afba3b873 100644 --- a/web/app/components/workflow/operator/__tests__/add-block.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx @@ -105,13 +105,15 @@ vi.mock('@/service/use-triggers', () => ({ useInvalidateAllTriggerPlugins: () => vi.fn(), })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: [] }), - useAllCustomTools: () => ({ data: [] }), - useAllWorkflowTools: () => ({ data: [] }), - useAllMCPTools: () => ({ data: [] }), - useInvalidateAllBuiltInTools: () => vi.fn(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: [] }), + 'workspaces.current.tools.api.get': () => ({ data: [] }), + 'workspaces.current.tools.workflow.get': () => ({ data: [] }), + 'workspaces.current.tools.mcp.get': () => ({ data: [] }), + }) +}) vi.mock('@/app/components/plugins/marketplace/query', () => ({ useMarketplacePlugins: () => ({ data: undefined }), diff --git a/web/app/components/workflow/shortcuts/__tests__/workflow-canvas.browser.spec.tsx b/web/app/components/workflow/shortcuts/__tests__/workflow-canvas.browser.spec.tsx index fc04d89d9af099..f92bfdbfe894ff 100644 --- a/web/app/components/workflow/shortcuts/__tests__/workflow-canvas.browser.spec.tsx +++ b/web/app/components/workflow/shortcuts/__tests__/workflow-canvas.browser.spec.tsx @@ -94,7 +94,12 @@ vi.mock('../../hooks/use-nodes-meta-data', () => ({ author: 'Dify', }), })) -vi.mock('@/service/use-tools', () => ({ useAllWorkflowTools: () => ({ data: [] }) })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.workflow.get': () => ({ data: [] }), + }) +}) vi.mock('../../node-actions-menu/change-block-popup', () => ({ ChangeBlockPopup: () => null })) vi.mock('@/context/permission-state', async () => { diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx index 2c153d9b91299b..3cead400987acf 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx @@ -183,59 +183,75 @@ vi.mock('@/service/use-plugins', () => ({ }), })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: toolProviderState.builtInTools }), - useAllCustomTools: () => ({ data: toolProviderState.customTools }), - useAllMCPTools: () => ({ data: toolProviderState.mcpTools }), - useAllWorkflowTools: () => ({ data: toolProviderState.workflowTools }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: toolProviderState.builtInTools }), + 'workspaces.current.tools.api.get': () => ({ data: toolProviderState.customTools }), + 'workspaces.current.tools.mcp.get': () => ({ data: toolProviderState.mcpTools }), + 'workspaces.current.tools.workflow.get': () => ({ data: toolProviderState.workflowTools }), + }) +}) -vi.mock('@/service/console', () => ({ - consoleQuery: { - agent: { - get: { - key: () => ['agents'], +vi.mock('@/service/console', async () => { + const { consoleOperationStub } = await import('@/test/console/operation-hooks') + return { + consoleQuery: { + workspaces: { + current: { + tools: { + builtin: { get: consoleOperationStub('workspaces.current.tools.builtin.get') }, + api: { get: consoleOperationStub('workspaces.current.tools.api.get') }, + workflow: { get: consoleOperationStub('workspaces.current.tools.workflow.get') }, + mcp: { get: consoleOperationStub('workspaces.current.tools.mcp.get') }, + }, + }, }, - byAgentId: { + agent: { get: { - queryKey: ({ input }: { input: { params: { agent_id: string } } }) => [ - 'agent-detail', - input.params.agent_id, - ], + key: () => ['agents'], }, - apiAccess: { + byAgentId: { get: { queryKey: ({ input }: { input: { params: { agent_id: string } } }) => [ - 'agent-api-access', + 'agent-detail', input.params.agent_id, ], }, - }, - composer: { - get: { - queryKey: ({ input }: { input: { params: { agent_id: string } } }) => [ - 'agent-composer', - input.params.agent_id, - ], + apiAccess: { + get: { + queryKey: ({ input }: { input: { params: { agent_id: string } } }) => [ + 'agent-api-access', + input.params.agent_id, + ], + }, }, - put: { - mutationOptions: composerPutMutationOptions, + composer: { + get: { + queryKey: ({ input }: { input: { params: { agent_id: string } } }) => [ + 'agent-composer', + input.params.agent_id, + ], + }, + put: { + mutationOptions: composerPutMutationOptions, + }, }, - }, - publish: { - post: { - mutationOptions: publishAgentMutationOptions, + publish: { + post: { + mutationOptions: publishAgentMutationOptions, + }, }, - }, - versions: { - get: { - key: () => ['agent-versions'], + versions: { + get: { + key: () => ['agent-versions'], + }, }, }, }, }, - }, -})) + } +}) function renderUseAgentConfigureSync({ agentName = 'Agent', diff --git a/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx index b5d1246bdd9db9..64df485a1f5dd2 100644 --- a/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx @@ -230,12 +230,15 @@ vi.mock('@/context/workspace-state', async () => { })) }) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: mockBuiltInTools }), - useAllCustomTools: () => ({ data: [] }), - useAllWorkflowTools: () => ({ data: [] }), - useAllMCPTools: () => ({ data: mockMCPTools.current }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: mockBuiltInTools }), + 'workspaces.current.tools.api.get': () => ({ data: [] }), + 'workspaces.current.tools.workflow.get': () => ({ data: [] }), + 'workspaces.current.tools.mcp.get': () => ({ data: mockMCPTools.current }), + }) +}) vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }), diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/hooks.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/hooks.ts index 330964fe154574..b8fdb5eb45efc6 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/hooks.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/hooks.ts @@ -2,18 +2,15 @@ import type { ToolWithProvider } from '@/app/components/workflow/types' import type { AgentTool } from '@/features/agent-v2/agent-composer/form-state' +import { useQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useMemo } from 'react' import { CollectionType } from '@/app/components/tools/types' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { API_PREFIX } from '@/config' import { currentWorkspaceIdAtom } from '@/context/workspace-state' import useTheme from '@/hooks/use-theme' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { Theme } from '@/types/app' import { basePath } from '@/utils/var' import { @@ -50,16 +47,31 @@ export function useAgentPromptToolIconResolver( ) { const { theme } = useTheme() const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) - const { data: builtInTools } = useAllBuiltInTools( - !providerTypes || providerTypes.has(CollectionType.builtIn) || providerTypes.has('plugin'), + const { data: builtInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + enabled: + !providerTypes || providerTypes.has(CollectionType.builtIn) || providerTypes.has('plugin'), + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ + enabled: !providerTypes || providerTypes.has(CollectionType.custom), + select: selectToolsWithProvider, + }), ) - const { data: customTools } = useAllCustomTools( - !providerTypes || providerTypes.has(CollectionType.custom), + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + enabled: !providerTypes || providerTypes.has(CollectionType.workflow), + select: selectToolsWithProvider, + }), ) - const { data: workflowTools } = useAllWorkflowTools( - !providerTypes || providerTypes.has(CollectionType.workflow), + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ + enabled: !providerTypes || providerTypes.has(CollectionType.mcp), + select: selectToolsWithProvider, + }), ) - const { data: mcpTools } = useAllMCPTools(!providerTypes || providerTypes.has(CollectionType.mcp)) const providerById = useMemo( () => diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx index 54647fa3d2af91..1539ca1b098bd7 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx @@ -15,23 +15,20 @@ import type { import type { AgentProviderToolDefaultValue } from '@/features/agent-v2/agent-composer/store-modules/tools' import { cn } from '@langgenius/dify-ui/cn' import { FileTreeIcon } from '@langgenius/dify-ui/file-tree' +import { useQuery } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { getMarketplaceCategoryUrl } from '@/app/components/plugins/marketplace/utils' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { parseToolProviderType } from '@/app/components/tools/provider-type' import { CollectionType } from '@/app/components/tools/types' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import BlockIcon from '@/app/components/workflow/block-icon' import { ToolType } from '@/app/components/workflow/block-selector/types' import { BlockEnum } from '@/app/components/workflow/types' import { useGetLanguage } from '@/context/i18n' import { ENABLE_AGENT_CLI_TOOLS } from '@/features/agent-v2/agent-detail/configure/feature-flags' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' +import { consoleQuery } from '@/service/console' import { getProviderReference, matchesProviderReference } from '@/utils/provider-reference' import { useAgentPromptToolIconResolver } from './hooks' @@ -362,10 +359,22 @@ function AgentPromptToolRows({ const { getProviderIcon, getProviderIcons } = useAgentPromptToolIconResolver() const [activeTab, setActiveTab] = useState('all') const [expandedProviderIds, setExpandedProviderIds] = useState>(() => new Set()) - const { data: builtInTools = [] } = useAllBuiltInTools() - const { data: customTools = [] } = useAllCustomTools() - const { data: workflowTools = [] } = useAllWorkflowTools() - const { data: mcpTools = [] } = useAllMCPTools() + const { data: builtInTools = [] } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools = [] } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools = [] } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools = [] } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) const configuredCliTools = ENABLE_AGENT_CLI_TOOLS ? configuredTools.filter((tool) => tool.kind === 'cli') : [] diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx index 276e198dcd4871..b6837be6ec2d55 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx @@ -15,6 +15,7 @@ import { agentComposerSavedDraftAtom, isAgentComposerDirtyAtom, } from '@/features/agent-v2/agent-composer/store' +import { consoleQuery } from '@/service/console' import { seedAccountProfileQuery } from '@/test/console/account-profile' import { AgentOrchestrateReadOnlyContext, @@ -45,7 +46,6 @@ const pluginInstallState = vi.hoisted(() => ({ latest_package_identifier: string } | undefined, - invalidateBuiltInTools: vi.fn(), invalidateInstalledPluginList: vi.fn(), })) @@ -158,14 +158,15 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal ), })) -vi.mock('@/service/use-tools', () => ({ - useAllBuiltInTools: () => ({ data: toolProviderState.builtInTools }), - useAllCustomTools: () => ({ data: toolProviderState.customTools }), - useAllWorkflowTools: () => ({ data: [] }), - useAllMCPTools: () => ({ data: [] }), - useInvalidateAllBuiltInTools: () => pluginInstallState.invalidateBuiltInTools, - useInvalidToolsByType: () => vi.fn(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.tools.builtin.get': () => ({ data: toolProviderState.builtInTools }), + 'workspaces.current.tools.api.get': () => ({ data: toolProviderState.customTools }), + 'workspaces.current.tools.workflow.get': () => ({ data: [] }), + 'workspaces.current.tools.mcp.get': () => ({ data: [] }), + }) +}) const agentToolsDraft = { ...defaultAgentSoulConfigFormState, @@ -452,13 +453,14 @@ function renderAgentTools(initialDraft: AgentSoulConfigFormState = agentToolsDra }) seedAccountProfileQuery(queryClient, { id: 'user-1' }) - return render( + const view = render( , ) + return { ...view, queryClient } } function renderAgentToolsWithStore(initialDraft: AgentSoulConfigFormState = agentToolsDraft) { @@ -531,7 +533,6 @@ describe('AgentTools', () => { pluginAuthState.notAllowCustomCredential = false pluginInstallState.manifest = undefined pluginInstallState.fallbackManifest = undefined - pluginInstallState.invalidateBuiltInTools.mockResolvedValue(undefined) pluginInstallState.invalidateInstalledPluginList.mockResolvedValue(undefined) }) @@ -727,7 +728,9 @@ describe('AgentTools', () => { }, latest_package_identifier: 'langgenius/google:1.0.0@checksum', } - renderAgentTools(reflectedUninstalledPluginDraft) + const { queryClient } = renderAgentTools(reflectedUninstalledPluginDraft) + const builtInToolsKey = consoleQuery.workspaces.current.tools.builtin.get.queryKey() + queryClient.setQueryData(builtInToolsKey, []) expect(screen.getByRole('button', { name: 'Google Tools' })).toBeInTheDocument() expect( @@ -750,7 +753,7 @@ describe('AgentTools', () => { await user.click(installButton) await waitFor(() => { - expect(pluginInstallState.invalidateBuiltInTools).toHaveBeenCalledTimes(1) + expect(queryClient.getQueryState(builtInToolsKey)?.isInvalidated).toBe(true) expect(pluginInstallState.invalidateInstalledPluginList).toHaveBeenCalledTimes(1) }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx index 9e0efc56250b6d..df35738f0bb347 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx @@ -12,6 +12,7 @@ import type { import type { AgentProviderToolDefaultValue } from '@/features/agent-v2/agent-composer/store-modules/tools' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { noop } from 'es-toolkit/function' import { useAtomValue, useSetAtom } from 'jotai' import { memo, useCallback, useId, useMemo, useRef, useState } from 'react' @@ -19,6 +20,7 @@ import { useTranslation } from 'react-i18next' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { parseToolProviderType } from '@/app/components/tools/provider-type' import { CollectionType } from '@/app/components/tools/types' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { ToolPickerContent } from '@/app/components/workflow/block-selector/tool-picker' import { useGetLanguage } from '@/context/i18n' import { @@ -27,14 +29,8 @@ import { setProviderToolCredentialAtom, } from '@/features/agent-v2/agent-composer/store-modules/tools' import { ENABLE_AGENT_CLI_TOOLS } from '@/features/agent-v2/agent-detail/configure/feature-flags' +import { consoleQuery } from '@/service/console' import { useInvalidateInstalledPluginList } from '@/service/use-plugins' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, - useInvalidateAllBuiltInTools, -} from '@/service/use-tools' import { createAgentToolProviderCatalog, getAgentProviderPluginId, @@ -398,7 +394,7 @@ export function AgentTools() { const { t } = useTranslation(['agentV2']) const readOnly = useAgentOrchestrateReadOnly() const setProviderToolCredential = useSetAtom(setProviderToolCredentialAtom) - const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools() + const queryClient = useQueryClient() const invalidateInstalledPluginList = useInvalidateInstalledPluginList() const { providerById, resolvedProviderTypes } = useAgentToolProviderCatalog() const tools = useAtomValue(agentComposerToolsAtom) @@ -432,10 +428,12 @@ export function AgentTools() { ) const handlePluginInstalled = useCallback(() => { void Promise.allSettled([ - invalidateAllBuiltInTools(), + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.tools.builtin.get.queryKey(), + }), invalidateInstalledPluginList(PluginCategoryEnum.tool), ]) - }, [invalidateAllBuiltInTools, invalidateInstalledPluginList]) + }, [invalidateInstalledPluginList, queryClient]) const visibleTools = useMemo( () => (ENABLE_AGENT_CLI_TOOLS ? tools : tools.filter((tool) => tool.kind !== 'cli')), [tools], @@ -603,12 +601,30 @@ export function AgentTemplateTools() { const providerTypes = new Set( visibleTools.filter((tool) => tool.kind === 'provider').map((tool) => tool.providerType), ) - const { data: buildInTools } = useAllBuiltInTools( - providerTypes.has(CollectionType.builtIn) || providerTypes.has('plugin'), + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + enabled: providerTypes.has(CollectionType.builtIn) || providerTypes.has('plugin'), + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ + enabled: providerTypes.has(CollectionType.custom), + select: selectToolsWithProvider, + }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + enabled: providerTypes.has(CollectionType.workflow), + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ + enabled: providerTypes.has(CollectionType.mcp), + select: selectToolsWithProvider, + }), ) - const { data: customTools } = useAllCustomTools(providerTypes.has(CollectionType.custom)) - const { data: workflowTools } = useAllWorkflowTools(providerTypes.has(CollectionType.workflow)) - const { data: mcpTools } = useAllMCPTools(providerTypes.has(CollectionType.mcp)) const catalog = useMemo( () => createAgentToolProviderCatalog({ buildInTools, customTools, workflowTools, mcpTools }), [buildInTools, customTools, workflowTools, mcpTools], diff --git a/web/features/agent-v2/agent-detail/configure/tool-provider-catalog.ts b/web/features/agent-v2/agent-detail/configure/tool-provider-catalog.ts index 51a4b7e3a26728..68261d7ae798b8 100644 --- a/web/features/agent-v2/agent-detail/configure/tool-provider-catalog.ts +++ b/web/features/agent-v2/agent-detail/configure/tool-provider-catalog.ts @@ -3,16 +3,13 @@ import type { MarketplacePlugin } from '@dify/contracts/marketplace' import type { ToolWithProvider } from '@/app/components/workflow/types' import type { AgentProviderTool, AgentTool } from '@/features/agent-v2/agent-composer/form-state' +import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { CollectionType } from '@/app/components/tools/types' +import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select' import { useGetLanguage } from '@/context/i18n' +import { consoleQuery } from '@/service/console' import { useFetchPluginsInMarketPlaceByInfo } from '@/service/use-plugins' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, -} from '@/service/use-tools' import { getIconFromMarketPlace } from '@/utils/get-icon' import { getProviderReference } from '@/utils/provider-reference' @@ -73,10 +70,22 @@ export function createAgentToolProviderCatalog({ } export function useAgentToolProviderCatalog(): AgentToolProviderCatalog { - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() + const { data: buildInTools } = useQuery( + consoleQuery.workspaces.current.tools.builtin.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: customTools } = useQuery( + consoleQuery.workspaces.current.tools.api.get.queryOptions({ select: selectToolsWithProvider }), + ) + const { data: workflowTools } = useQuery( + consoleQuery.workspaces.current.tools.workflow.get.queryOptions({ + select: selectToolsWithProvider, + }), + ) + const { data: mcpTools } = useQuery( + consoleQuery.workspaces.current.tools.mcp.get.queryOptions({ select: selectToolsWithProvider }), + ) return useMemo( () => createAgentToolProviderCatalog({ buildInTools, customTools, mcpTools, workflowTools }), diff --git a/web/service/__tests__/use-tools.spec.tsx b/web/service/__tests__/use-tools.spec.tsx deleted file mode 100644 index e5a463b68bcd8f..00000000000000 --- a/web/service/__tests__/use-tools.spec.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { ReactNode } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { renderHook } from '@/test/console/render' -import { useAllToolProviders, useRefreshMCPServerCode } from '../use-tools' - -const { mockGet, mockPost } = vi.hoisted(() => ({ - mockGet: vi.fn(), - mockPost: vi.fn(), -})) - -vi.mock('@/service/console', () => ({ - consoleClient: { - workspaces: { current: { toolProviders: { get: mockGet } } }, - apps: { - byAppId: { - server: { - refresh: { - post: mockPost, - }, - }, - }, - }, - }, -})) - -const createWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { - mutations: { - retry: false, - }, - }, - }) - - return function Wrapper({ children }: { children: ReactNode }) { - return {children} - } -} - -describe('useRefreshMCPServerCode', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('refreshes the MCP server with POST and the app ID', async () => { - mockPost.mockResolvedValue({ id: 'server-1', server_code: 'new-code' }) - const { result } = renderHook(() => useRefreshMCPServerCode(), { - wrapper: createWrapper(), - }) - - await act(async () => { - await result.current.mutateAsync('app-1') - }) - - expect(mockPost).toHaveBeenCalledWith({ - params: { - app_id: 'app-1', - }, - }) - expect(mockGet).not.toHaveBeenCalled() - }) -}) - -describe('useAllToolProviders', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGet.mockResolvedValue([]) - }) - - it('keeps filtered MCP providers in a separate query', async () => { - renderHook( - () => { - useAllToolProviders() - useAllToolProviders(true, 'mcp') - }, - { wrapper: createWrapper() }, - ) - - await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2)) - expect(mockGet).toHaveBeenCalledWith({ query: {} }) - expect(mockGet).toHaveBeenCalledWith({ query: { type: 'mcp' } }) - }) -}) diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index bca83fda64b6c8..4eda2c81aaa3bd 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -54,7 +54,6 @@ import { uninstallPlugin } from '@/service/plugins' import { hasPermission } from '@/utils/permission' // oxlint-disable-next-line no-restricted-imports import { getMarketplace, postMarketplace } from './base' -import { useInvalidateAllBuiltInTools } from './use-tools' const NAME_SPACE = 'plugins' const usePluginTaskListKey = [NAME_SPACE, 'pluginTaskList'] @@ -760,7 +759,10 @@ export const useRemoveFilteredInstalledPluginPageOnUnmount = ( export const useInvalidateInstalledPluginList = () => { const queryClient = useQueryClient() - const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools() + const invalidateAllBuiltInTools = () => + queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.tools.builtin.get.queryKey(), + }) return (category?: PluginCategoryEnum) => { if (category) { const invalidateCategoryPluginList = queryClient.invalidateQueries({ diff --git a/web/service/use-tools.ts b/web/service/use-tools.ts deleted file mode 100644 index 15c024f7d26b32..00000000000000 --- a/web/service/use-tools.ts +++ /dev/null @@ -1,360 +0,0 @@ -import type { AppMcpServerStatus } from '@dify/contracts/api/console/apps/types.gen' -import type { QueryKey, UseQueryOptions } from '@tanstack/react-query' -import type { - Collection, - CollectionProviderType, - MCPServerDetail, - Tool, - WorkflowToolProviderResponse, -} from '@/app/components/tools/types' -import type { RAGRecommendedPlugins, ToolWithProvider } from '@/app/components/workflow/types' -import type { AppIconType } from '@/types/app' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { CollectionType } from '@/app/components/tools/types' -import { consoleClient } from '@/service/console' -import { narrowResponse } from './console/narrow-response' -import { useInvalid } from './use-base' - -const NAME_SPACE = 'tools' - -const useAllToolProvidersKey = [NAME_SPACE, 'allToolProviders'] -type ToolProviderListType = 'builtin' | 'model' | 'api' | 'workflow' | 'mcp' - -export const useAllToolProviders = (enabled = true, type?: ToolProviderListType) => { - return useQuery({ - queryKey: type ? [...useAllToolProvidersKey, type] : useAllToolProvidersKey, - queryFn: () => - narrowResponse( - consoleClient.workspaces.current.toolProviders.get({ query: type ? { type } : {} }), - ), - enabled, - }) -} - -export const useInvalidateAllToolProviders = () => { - return useInvalid(useAllToolProvidersKey) -} - -const useAllBuiltInToolsKey = [NAME_SPACE, 'builtIn'] -export const useAllBuiltInTools = (enabled = true) => { - return useQuery({ - queryKey: useAllBuiltInToolsKey, - queryFn: () => - narrowResponse(consoleClient.workspaces.current.tools.builtin.get()), - enabled, - }) -} - -export const useInvalidateAllBuiltInTools = () => { - return useInvalid(useAllBuiltInToolsKey) -} - -const useAllCustomToolsKey = [NAME_SPACE, 'customTools'] -export const useAllCustomTools = (enabled = true) => { - return useQuery({ - queryKey: useAllCustomToolsKey, - queryFn: () => - narrowResponse(consoleClient.workspaces.current.tools.api.get()), - enabled, - }) -} - -export const useInvalidateAllCustomTools = () => { - return useInvalid(useAllCustomToolsKey) -} - -const useAllWorkflowToolsKey = [NAME_SPACE, 'workflowTools'] -export const useAllWorkflowTools = (enabled = true) => { - return useQuery({ - queryKey: useAllWorkflowToolsKey, - queryFn: () => - narrowResponse(consoleClient.workspaces.current.tools.workflow.get()), - enabled, - }) -} - -export const useInvalidateAllWorkflowTools = () => { - return useInvalid(useAllWorkflowToolsKey) -} - -const useAllMCPToolsKey = [NAME_SPACE, 'MCPTools'] -export const useAllMCPTools = (enabled = true) => { - return useQuery({ - queryKey: useAllMCPToolsKey, - queryFn: () => - narrowResponse(consoleClient.workspaces.current.tools.mcp.get()), - enabled, - }) -} - -export const useInvalidateAllMCPTools = () => { - return useInvalid(useAllMCPToolsKey) -} - -const useInvalidToolsKeyMap: Partial> = { - [CollectionType.builtIn]: useAllBuiltInToolsKey, - [CollectionType.custom]: useAllCustomToolsKey, - [CollectionType.workflow]: useAllWorkflowToolsKey, - [CollectionType.mcp]: useAllMCPToolsKey, -} -export const useInvalidToolsByType = (type?: CollectionProviderType) => { - const queryKey = type ? useInvalidToolsKeyMap[type] : undefined - return useInvalid(queryKey) -} - -export const useCreateMCP = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'create-mcp'], - mutationFn: (payload: { - name: string - server_url: string - server_identifier: string - icon_type: AppIconType - icon: string - icon_background?: string - timeout?: number - sse_read_timeout?: number - headers?: Record - identity_mode?: 'off' | 'idp_token' - }) => { - return narrowResponse( - consoleClient.workspaces.current.toolProvider.mcp.post({ body: { ...payload } }), - ) - }, - }) -} - -export const useUpdateMCP = ({ onSuccess }: { onSuccess?: () => void }) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'update-mcp'], - mutationFn: (payload: { - name: string - server_url: string - server_identifier: string - icon_type: AppIconType - icon: string - icon_background?: string - provider_id: string - timeout?: number - sse_read_timeout?: number - headers?: Record - identity_mode?: 'off' | 'idp_token' - }) => { - return consoleClient.workspaces.current.toolProvider.mcp.put({ body: { ...payload } }) - }, - onSuccess, - }) -} - -export const useDeleteMCP = ({ onSuccess }: { onSuccess?: () => void }) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'delete-mcp'], - mutationFn: (id: string) => { - return consoleClient.workspaces.current.toolProvider.mcp.delete({ body: { provider_id: id } }) - }, - onSuccess, - }) -} - -export const useAuthorizeMCP = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'authorize-mcp'], - mutationFn: (payload: { provider_id: string }) => { - return consoleClient.workspaces.current.toolProvider.mcp.auth.post({ body: payload }) - }, - }) -} - -export const useMCPTools = (providerID: string) => { - return useQuery({ - enabled: !!providerID, - queryKey: [NAME_SPACE, 'get-MCP-provider-tool', providerID], - queryFn: () => - narrowResponse<{ tools: Tool[] }>( - consoleClient.workspaces.current.toolProvider.mcp.tools.byProviderId.get({ - params: { provider_id: providerID }, - }), - ), - }) -} -export const useInvalidateMCPTools = () => { - const queryClient = useQueryClient() - return (providerID: string) => { - queryClient.invalidateQueries({ - queryKey: [NAME_SPACE, 'get-MCP-provider-tool', providerID], - }) - } -} - -export const useUpdateMCPTools = () => { - return useMutation({ - mutationFn: (providerID: string) => - narrowResponse<{ tools: Tool[] }>( - consoleClient.workspaces.current.toolProvider.mcp.update.byProviderId.get({ - params: { provider_id: providerID }, - }), - ), - }) -} - -export const useMCPServerDetail = (appID: string, enabled = true) => { - return useQuery({ - queryKey: [NAME_SPACE, 'MCPServerDetail', appID], - queryFn: () => - narrowResponse( - consoleClient.apps.byAppId.server.get({ params: { app_id: appID } }), - ), - enabled, - }) -} - -export const useInvalidateMCPServerDetail = () => { - const queryClient = useQueryClient() - return (appID: string) => { - return queryClient.invalidateQueries({ - queryKey: [NAME_SPACE, 'MCPServerDetail', appID], - }) - } -} - -export const useCreateMCPServer = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'create-mcp-server'], - mutationFn: (payload: { - appID: string - description?: string - parameters: Record - }) => { - const { appID, ...rest } = payload - return consoleClient.apps.byAppId.server.post({ params: { app_id: appID }, body: rest }) - }, - }) -} - -export const useUpdateMCPServer = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'update-mcp-server'], - mutationFn: (payload: { - appID: string - id: string - description?: string - status?: AppMcpServerStatus - parameters: Record - }) => { - const { appID, ...rest } = payload - return consoleClient.apps.byAppId.server.put({ params: { app_id: appID }, body: rest }) - }, - }) -} - -export const useRefreshMCPServerCode = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'refresh-mcp-server-code'], - mutationFn: (appID: string) => { - return consoleClient.apps.byAppId.server.refresh.post({ - params: { - app_id: appID, - }, - }) - }, - }) -} - -export const useBuiltinTools = (providerName: string) => { - return useQuery({ - enabled: !!providerName, - queryKey: [NAME_SPACE, 'builtin-provider-tools', providerName], - queryFn: () => - narrowResponse( - consoleClient.workspaces.current.toolProvider.builtin.byProvider.tools.get({ - params: { provider: providerName }, - }), - ), - }) -} - -const useRAGRecommendedPluginListKey = [NAME_SPACE, 'rag-recommended-plugins'] - -export const useRAGRecommendedPlugins = (type: 'tool' | 'datasource' | 'all' = 'all') => { - return useQuery({ - queryKey: [...useRAGRecommendedPluginListKey, type], - queryFn: () => - narrowResponse( - consoleClient.rag.pipelines.recommendedPlugins.get({ query: { type } }), - ), - }) -} - -export const useInvalidateRAGRecommendedPlugins = () => { - const queryClient = useQueryClient() - return (type: 'tool' | 'datasource' | 'all' = 'all') => { - queryClient.invalidateQueries({ - queryKey: [...useRAGRecommendedPluginListKey, type], - }) - } -} - -// App Triggers API hooks -export type AppTrigger = { - id: string - trigger_type: 'trigger-webhook' | 'trigger-schedule' | 'trigger-plugin' - title: string - node_id: string - provider_name: string - icon: string - status: 'enabled' | 'disabled' | 'unauthorized' - created_at: string - updated_at: string -} - -export const useAppTriggers = ( - appId: string | undefined, - options?: Omit, 'queryKey' | 'queryFn'>, -) => { - return useQuery<{ data: AppTrigger[] }>({ - queryKey: [NAME_SPACE, 'app-triggers', appId], - queryFn: () => - narrowResponse<{ data: AppTrigger[] }>( - consoleClient.apps.byAppId.triggers.get({ params: { app_id: appId! } }), - ), - enabled: !!appId, - ...options, // Merge additional options while maintaining backward compatibility - }) -} - -export const useInvalidateAppTriggers = () => { - const queryClient = useQueryClient() - return (appId: string) => { - return queryClient.invalidateQueries({ - queryKey: [NAME_SPACE, 'app-triggers', appId], - }) - } -} - -const workflowToolDetailByAppIDKey = (appId: string) => [ - NAME_SPACE, - 'workflowToolDetailByAppID', - appId, -] - -export const useWorkflowToolDetailByAppID = (appId: string, enabled = true) => { - return useQuery({ - queryKey: workflowToolDetailByAppIDKey(appId), - queryFn: () => - narrowResponse( - consoleClient.workspaces.current.toolProvider.workflow.get.get({ - query: { workflow_app_id: appId }, - }), - ), - enabled: enabled && !!appId, - }) -} - -export const useInvalidateWorkflowToolDetailByAppID = () => { - const queryClient = useQueryClient() - return (appId: string) => { - queryClient.invalidateQueries({ - queryKey: workflowToolDetailByAppIDKey(appId), - }) - } -} From adb394591a4f1d9621b7c096a46d917b93e3737b Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:54:45 +0800 Subject: [PATCH 31/60] fix(web): keep full schema definitions for workflow schema type matching The schema definitions select kept only `properties`, dropping `type` and `items`, so file, general_structure and parent_child_structure outputs no longer matched and file outputs fell back to plain objects. Use the generated definition type and pass the response through unchanged. Co-Authored-By: Claude Opus 5.5 --- .../use-fetch-workflow-inspect-vars.spec.ts | 1 + .../use-inspect-vars-crud-common.spec.ts | 1 + .../__tests__/use-match-schema-type.spec.tsx | 77 +++++++++++++++++++ .../variable/use-match-schema-type.ts | 16 +--- web/service/use-common.ts | 8 +- 5 files changed, 82 insertions(+), 21 deletions(-) create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/use-match-schema-type.spec.tsx diff --git a/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts b/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts index 92ef5704940cf0..69efc70d83f4d2 100644 --- a/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts @@ -17,6 +17,7 @@ const mockToNodeOutputVars = vi.hoisted(() => vi.fn()) const schemaTypeDefinitions: SchemaTypeDefinition[] = [ { name: 'simple', + label: 'Simple', schema: { properties: {}, }, diff --git a/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud-common.spec.ts b/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud-common.spec.ts index 1a421db84dd696..b2d1ac879c30f0 100644 --- a/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud-common.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud-common.spec.ts @@ -19,6 +19,7 @@ const mockToNodeOutputVars = vi.hoisted(() => vi.fn()) const schemaTypeDefinitions: SchemaTypeDefinition[] = [ { name: 'simple', + label: 'Simple', schema: { properties: {}, }, diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/use-match-schema-type.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/use-match-schema-type.spec.tsx new file mode 100644 index 00000000000000..f7f14ad1c2f029 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/use-match-schema-type.spec.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import useMatchSchemaType, { getMatchedSchemaType } from '../use-match-schema-type' + +// Built-in definitions as served by /spec/schema-definitions (api/core/schemas/builtin). +const schemaDefinitions = [ + { + name: 'file', + label: 'File', + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + size: { type: 'number' }, + extension: { type: 'string' }, + type: { type: 'string' }, + mime_type: { type: 'string' }, + transfer_method: { type: 'string' }, + url: { type: 'string' }, + related_id: { type: 'string' }, + }, + required: ['name'], + }, + }, + { + name: 'general_structure', + label: 'General Structure', + schema: { + type: 'array', + items: { type: 'string' }, + }, + }, +] + +vi.mock('@/service/base', async (importOriginal) => ({ + ...(await importOriginal()), + request: async () => Response.json(schemaDefinitions), +})) + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +) + +describe('useMatchSchemaType', () => { + it('keeps the full definition schemas so typed structures still match', async () => { + const { result } = renderHook(() => useMatchSchemaType(), { wrapper }) + + await waitFor(() => expect(result.current.schemaTypeDefinitions).toBeDefined()) + const definitions = result.current.schemaTypeDefinitions + + expect( + getMatchedSchemaType( + { + type: 'object', + properties: { + name: { type: 'string' }, + size: { type: 'number' }, + extension: { type: 'string' }, + type: { type: 'string' }, + mime_type: { type: 'string' }, + transfer_method: { type: 'string' }, + url: { type: 'string' }, + related_id: { type: 'string' }, + }, + }, + definitions, + ), + ).toBe('file') + expect(getMatchedSchemaType({ type: 'array', items: { type: 'string' } }, definitions)).toBe( + 'general_structure', + ) + expect(getMatchedSchemaType({ type: 'object', properties: {} }, definitions)).toBe('') + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/variable/use-match-schema-type.ts b/web/app/components/workflow/nodes/_base/components/variable/use-match-schema-type.ts index 5d66587ecb7978..355ce736cbde0a 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/use-match-schema-type.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/use-match-schema-type.ts @@ -1,7 +1,6 @@ import type { AnyObj } from './match-schema-type' import type { SchemaTypeDefinition } from '@/service/use-common' import { useQuery } from '@tanstack/react-query' -import { isPlainObject } from 'es-toolkit/predicate' import { consoleQuery } from '@/service/console' import matchTheSchemaType from './match-schema-type' @@ -14,22 +13,9 @@ export const getMatchedSchemaType = ( return matched ? matched.name : '' } -// Definition schemas are untyped JSON in the console API response. -const toSchemaTypeDefinitions = ( - definitions: Array<{ name: string; schema: Record }>, -): SchemaTypeDefinition[] => - definitions.map(({ name, schema }) => ({ - name, - schema: { - properties: isPlainObject(schema.properties) - ? (schema.properties as Record) - : {}, - }, - })) - const useMatchSchemaType = () => { const { data: schemaTypeDefinitions, isLoading } = useQuery( - consoleQuery.spec.schemaDefinitions.get.queryOptions({ select: toSchemaTypeDefinitions }), + consoleQuery.spec.schemaDefinitions.get.queryOptions(), ) return { diff --git a/web/service/use-common.ts b/web/service/use-common.ts index 7012c9b8425494..b11e6f19d2ebcc 100644 --- a/web/service/use-common.ts +++ b/web/service/use-common.ts @@ -1,3 +1,4 @@ +import type { SchemaDefinitionItemResponse } from '@dify/contracts/api/console/spec/types.gen' import type { CommonResponse } from '@/models/common' import { useMutation, useQuery } from '@tanstack/react-query' import { discardRegistrationSessionState } from '@/app/components/base/amplitude/registration-session-state' @@ -15,12 +16,7 @@ const commonQueryKeys = { [NAME_SPACE, 'forgot-password-validity', token] as const, } -export type SchemaTypeDefinition = { - name: string - schema: { - properties: Record - } -} +export type SchemaTypeDefinition = SchemaDefinitionItemResponse export const useLogout = ({ redirectTo = '/signin' }: { redirectTo?: string } = {}) => { return useMutation( From 13224997f26ad69c154058780ee81f2aee68280a Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:04:52 +0800 Subject: [PATCH 32/60] test(web): follow the generated tool list keys in plugin and agent specs The use-tools migration invalidates and reads the generated tool list keys directly, so the plugin invalidation spec asserts cache state on the built-in tools key and the agent configure page spec stubs the four tool list queries. Co-Authored-By: Claude Opus 5.5 --- .../configure/__tests__/page.spec.tsx | 23 +++++++++++++++++++ web/service/__tests__/use-plugins.spec.tsx | 14 ++++------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx index 29b5a741262043..592f47a5860edd 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx @@ -205,8 +205,31 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: trackEventMock, })) +// The agent tool provider catalog reads these lists; the page tests start with none. +const { emptyToolList } = vi.hoisted(() => ({ + emptyToolList: (path: string) => ({ + get: { + queryOptions: (options?: object) => ({ + ...options, + queryKey: [path], + queryFn: async () => [], + }), + }, + }), +})) + vi.mock('@/service/console', () => ({ consoleQuery: { + workspaces: { + current: { + tools: { + builtin: emptyToolList('tools-builtin'), + api: emptyToolList('tools-api'), + workflow: emptyToolList('tools-workflow'), + mcp: emptyToolList('tools-mcp'), + }, + }, + }, systemFeatures: { get: { queryKey: () => ['system-features'], diff --git a/web/service/__tests__/use-plugins.spec.tsx b/web/service/__tests__/use-plugins.spec.tsx index aae9e4ea433d7f..33d2e9973a9bd2 100644 --- a/web/service/__tests__/use-plugins.spec.tsx +++ b/web/service/__tests__/use-plugins.spec.tsx @@ -78,13 +78,7 @@ vi.mock('@/context/permission-state', async () => { })) }) -const { mockInvalidateAllBuiltInTools } = vi.hoisted(() => ({ - mockInvalidateAllBuiltInTools: vi.fn(), -})) - -vi.mock('../use-tools', () => ({ - useInvalidateAllBuiltInTools: () => mockInvalidateAllBuiltInTools, -})) +const builtInToolsKey = consoleQuery.workspaces.current.tools.builtin.get.queryKey() const createQueryClient = () => new QueryClient({ @@ -1065,6 +1059,7 @@ describe('useInvalidateInstalledPluginList', () => { queryClient.setQueryData(modelInstalledIdsKey, { plugin_ids: ['langgenius/openai'] }) queryClient.setQueryData(toolInstalledIdsKey, { plugin_ids: ['langgenius/google'] }) + queryClient.setQueryData(builtInToolsKey, []) const { result: listResult, unmount } = renderHook( () => ({ @@ -1107,11 +1102,12 @@ describe('useInvalidateInstalledPluginList', () => { expect(toolCategoryQueries[0]?.state.isInvalidated).toBe(false) expect(queryClient.getQueryState(modelInstalledIdsKey)?.isInvalidated).toBe(true) expect(queryClient.getQueryState(toolInstalledIdsKey)?.isInvalidated).toBe(false) - expect(mockInvalidateAllBuiltInTools).not.toHaveBeenCalled() + expect(queryClient.getQueryState(builtInToolsKey)?.isInvalidated).toBe(false) }) it('also invalidates built-in tools for the tool category', async () => { const queryClient = createQueryClient() + queryClient.setQueryData(builtInToolsKey, []) mockGet.mockResolvedValue({ plugins: [], has_more: false }) const { result } = renderHook( @@ -1132,7 +1128,7 @@ describe('useInvalidateInstalledPluginList', () => { await result.current.invalidate(PluginCategoryEnum.tool) }) - expect(mockInvalidateAllBuiltInTools).toHaveBeenCalledOnce() + expect(queryClient.getQueryState(builtInToolsKey)?.isInvalidated).toBe(true) }) }) From a695c8885b670ee7f505a29cd394c5c6821b11e5 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:16:40 +0800 Subject: [PATCH 33/60] refactor(web): narrow tool and datasource responses without casts Replace the as-unknown select adapters. The datasource credential and auth UI types alias the generated contract types, with provider configs mapped to form schemas where the forms need them. Tool providers, tools, MCP server details and workflow tool details are mapped field by field, filling missing translations from en_US and defaulting optional flags; app triggers alias the generated trigger type, whose status also carries rate_limited. Tool parameter defaults keep the contract's non-string values. Only the recommended plugins response stays asserted, because its endpoint declares no response schema. Co-Authored-By: Claude Opus 5.5 --- oxlint-suppressions.json | 9 +- .../built-in-access-points/trigger-card.tsx | 2 - .../base/notion-page-selector/base.tsx | 9 +- web/app/components/datasets/create/index.tsx | 7 +- .../create/website/__tests__/index.spec.tsx | 2 + .../base/credential-selector/item.tsx | 2 +- .../base/credential-selector/trigger.tsx | 2 +- .../data-source/online-documents/index.tsx | 2 - .../data-source/online-drive/index.tsx | 2 - .../data-source/website-crawl/index.tsx | 2 - .../__tests__/card.spec.tsx | 28 ++- .../__tests__/configure.spec.tsx | 51 ++++-- .../__tests__/index.spec.tsx | 4 + .../install-from-marketplace.spec.tsx | 2 + .../__tests__/utils.spec.ts | 33 ++++ .../data-source-page-new/card.tsx | 9 +- .../data-source-page-new/configure.tsx | 19 +- .../data-source-page-new/index.tsx | 3 +- .../data-source-page-new/types.ts | 36 +--- .../data-source-page-new/utils.ts | 36 ++-- .../model-provider-page/declarations.ts | 2 +- .../model-provider-page/model-modal/Form.tsx | 2 +- .../tools/mcp/__tests__/create-card.spec.tsx | 11 +- web/app/components/tools/types.ts | 18 +- .../utils/__tests__/query-select.spec.ts | 139 +++++++++++++++ .../components/tools/utils/query-select.ts | 168 +++++++++++++++--- .../components/tools/utils/to-form-schema.ts | 2 +- web/app/components/workflow-app/index.tsx | 2 - web/types/pipeline.tsx | 11 +- 29 files changed, 467 insertions(+), 148 deletions(-) create mode 100644 web/app/components/header/account-setting/data-source-page-new/__tests__/utils.spec.ts create mode 100644 web/app/components/tools/utils/__tests__/query-select.spec.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 861e3b1b48b333..c66a7e64e09fd0 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1804,11 +1804,6 @@ "count": 1 } }, - "web/app/components/header/account-setting/data-source-page-new/types.ts": { - "typescript/no-explicit-any": { - "count": 2 - } - }, "web/app/components/header/account-setting/model-provider-page/declarations.ts": { "typescript/no-explicit-any": { "count": 4 @@ -4219,7 +4214,7 @@ }, "web/types/pipeline.tsx": { "typescript/no-explicit-any": { - "count": 3 + "count": 2 } }, "web/types/workflow.ts": { @@ -4267,4 +4262,4 @@ "count": 2 } } -} \ No newline at end of file +} diff --git a/web/app/components/app/access-point/built-in-access-points/trigger-card.tsx b/web/app/components/app/access-point/built-in-access-points/trigger-card.tsx index cf3f086de7a75f..6398864f3a1ac7 100644 --- a/web/app/components/app/access-point/built-in-access-points/trigger-card.tsx +++ b/web/app/components/app/access-point/built-in-access-points/trigger-card.tsx @@ -9,7 +9,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { AccessPointCard, AccessPointEmptyContent } from '@/app/components/base/access-point/card' -import { selectAppTriggers } from '@/app/components/tools/utils/query-select' import BlockIcon from '@/app/components/workflow/block-icon' import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status' import { BlockEnum } from '@/app/components/workflow/types' @@ -154,7 +153,6 @@ export function TriggerAccessPointCard({ consoleQuery.apps.byAppId.triggers.get.queryOptions({ input: { params: { app_id: appInfo.id } }, enabled: !!appInfo.id, - select: selectAppTriggers, }), ) const { data: triggerPlugins = [] } = useAllTriggerPlugins() diff --git a/web/app/components/base/notion-page-selector/base.tsx b/web/app/components/base/notion-page-selector/base.tsx index 74b09ce656083c..89f89806586ec1 100644 --- a/web/app/components/base/notion-page-selector/base.tsx +++ b/web/app/components/base/notion-page-selector/base.tsx @@ -39,6 +39,11 @@ const toNotionWorkspaces = ({ notion_info }: NotionIntegrateInfoListResponse) => })), })) +const getStringField = (record: Record, key: string) => { + const value = record[key] + return typeof value === 'string' ? value : undefined +} + type NotionPageSelectorProps = { value?: string[] onSelect: (selectedPages: NotionPage[]) => void @@ -80,8 +85,8 @@ const NotionPageSelector = ({ return { credentialId: item.id, credentialName: item.name, - workspaceIcon: item.credential.workspace_icon, - workspaceName: item.credential.workspace_name, + workspaceIcon: getStringField(item.credential, 'workspace_icon'), + workspaceName: getStringField(item.credential, 'workspace_name'), } }) }, [credentialList]) diff --git a/web/app/components/datasets/create/index.tsx b/web/app/components/datasets/create/index.tsx index 4a6dc78e28d361..afbb4b1da5ce03 100644 --- a/web/app/components/datasets/create/index.tsx +++ b/web/app/components/datasets/create/index.tsx @@ -16,7 +16,6 @@ import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' -import { selectDataSourceAuthList } from '@/app/components/header/account-setting/data-source-page-new/utils' import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { settingsQueryParamName, @@ -95,11 +94,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => { data: dataSourceList, isLoading: isLoadingAuthedDataSourceList, isError: fetchingAuthedDataSourceListError, - } = useQuery( - consoleQuery.auth.plugin.datasource.defaultList.get.queryOptions({ - select: selectDataSourceAuthList, - }), - ) + } = useQuery(consoleQuery.auth.plugin.datasource.defaultList.get.queryOptions()) const updateNotionPages = useCallback((value: NotionPage[]) => { setNotionPages(value) diff --git a/web/app/components/datasets/create/website/__tests__/index.spec.tsx b/web/app/components/datasets/create/website/__tests__/index.spec.tsx index fd0a861941a8d9..d6d0696f6c1468 100644 --- a/web/app/components/datasets/create/website/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/website/__tests__/index.spec.tsx @@ -92,6 +92,8 @@ const createMockDataSourceAuth = (provider: string, credentialsCount = 1): DataS name: provider, label: { en_US: provider, zh_Hans: provider }, description: { en_US: `${provider} description`, zh_Hans: `${provider} description` }, + credential_schema: [], + oauth_schema: null, credentials_list: Array.from({ length: credentialsCount }, (_, i) => ({ credential: {}, type: CredentialTypeEnum.API_KEY, diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx index 4b5eae85517f6f..13e8424f326a2a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx @@ -22,7 +22,7 @@ const Item = ({ credential, isSelected, onCredentialChange }: ItemProps) => { className="flex cursor-pointer items-center gap-x-2 rounded-lg p-2 hover:bg-state-base-hover" onClick={handleCredentialChange} > - + {name} {isSelected && }
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/trigger.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/trigger.tsx index c65d742cd319d8..2bb9c18132b7bd 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/trigger.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/trigger.tsx @@ -19,7 +19,7 @@ const Trigger = ({ currentCredential, isOpen }: TriggerProps) => { isOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover', )} > - +
{name} diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx index 200f65f26b0222..8baa764a7be371 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx @@ -7,7 +7,6 @@ import { useCallback, useEffect, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import SearchInput from '@/app/components/base/notion-page-selector/search-input' -import { selectDataSourceCredentials } from '@/app/components/header/account-setting/data-source-page-new/utils' import { settingsQueryParamName, settingsQueryParser, @@ -54,7 +53,6 @@ const OnlineDocuments = ({ const { data: dataSourceAuth } = useQuery( consoleQuery.auth.plugin.datasource.byProviderId.get.queryOptions({ input: { params: { provider_id: `${nodeData.plugin_id}/${nodeData.provider_name}` } }, - select: selectDataSourceCredentials, }), ) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx index ecd0fa5915b10c..c4e25b857a0ea7 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx @@ -6,7 +6,6 @@ import { produce } from 'immer' import { useQueryState } from 'nuqs' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' -import { selectDataSourceCredentials } from '@/app/components/header/account-setting/data-source-page-new/utils' import { settingsQueryParamName, settingsQueryParser, @@ -69,7 +68,6 @@ const OnlineDrive = ({ const { data: dataSourceAuth } = useQuery( consoleQuery.auth.plugin.datasource.byProviderId.get.queryOptions({ input: { params: { provider_id: `${nodeData.plugin_id}/${nodeData.provider_name}` } }, - select: selectDataSourceCredentials, }), ) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx index 86d6a5444c0af5..aa5e6c998f0929 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx @@ -12,7 +12,6 @@ import * as React from 'react' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' -import { selectDataSourceCredentials } from '@/app/components/header/account-setting/data-source-page-new/utils' import { settingsQueryParamName, settingsQueryParser, @@ -72,7 +71,6 @@ const WebsiteCrawl = ({ const { data: dataSourceAuth } = useQuery( consoleQuery.auth.plugin.datasource.byProviderId.get.queryOptions({ input: { params: { provider_id: `${nodeData.plugin_id}/${nodeData.provider_name}` } }, - select: selectDataSourceCredentials, }), ) diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx index 951153eaf7a10c..dc2cea4e09a47e 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx @@ -157,6 +157,8 @@ describe('Card Component', () => { en_US: 'Test Description', zh_Hans: '', }, + credential_schema: [], + oauth_schema: null, credentials_list: [ { id: 'c1', @@ -508,7 +510,12 @@ describe('Card Component', () => { const configurableItem: DataSourceAuth = { ...mockItem, credential_schema: [ - { name: 'api_key', type: FormTypeEnum.textInput, label: 'API Key', required: true }, + { + name: 'api_key', + type: FormTypeEnum.textInput, + label: { en_US: 'API Key' }, + required: true, + }, ], } @@ -529,12 +536,27 @@ describe('Card Component', () => { const configurableItem: DataSourceAuth = { ...mockItem, credential_schema: [ - { name: 'api_key', type: FormTypeEnum.textInput, label: 'API Key', required: true }, + { + name: 'api_key', + type: FormTypeEnum.textInput, + label: { en_US: 'API Key' }, + required: true, + }, ], oauth_schema: { client_schema: [ - { name: 'oauth_key', type: FormTypeEnum.textInput, label: 'OAuth Key', required: true }, + { + name: 'oauth_key', + type: FormTypeEnum.textInput, + label: { en_US: 'OAuth Key' }, + required: true, + }, ], + credentials_schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + oauth_custom_client_params: null, + redirect_uri: '', }, } diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx index 9e12f953514c19..e9ceba8393b6d9 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx @@ -1,5 +1,8 @@ +import type { + DatasourceOAuthSchemaResponse, + ProviderConfig, +} from '@dify/contracts/api/console/auth/types.gen' import type { DataSourceAuth } from '../types' -import type { FormSchema } from '@/app/components/base/form/types' import type { AddApiKeyButtonProps, AddOAuthButtonProps, @@ -72,9 +75,23 @@ describe('Configure Component', () => { label: { en_US: 'Test Label', zh_Hans: 'zh_hans' }, description: { en_US: 'Test Description', zh_Hans: 'zh_hans' }, credentials_list: [], + credential_schema: [], + oauth_schema: null, } - const mockFormSchema: FormSchema = { + const createOAuthSchema = ( + overrides: Partial = {}, + ): DatasourceOAuthSchemaResponse => ({ + client_schema: [], + credentials_schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + oauth_custom_client_params: null, + redirect_uri: '', + ...overrides, + }) + + const mockFormSchema: ProviderConfig = { name: 'api_key', label: { en_US: 'API Key', zh_Hans: 'zh_hans' }, type: FormTypeEnum.textInput, @@ -133,9 +150,9 @@ describe('Configure Component', () => { // Arrange const itemWithOAuth: DataSourceAuth = { ...mockItemBase, - oauth_schema: { + oauth_schema: createOAuthSchema({ client_schema: [mockFormSchema], - }, + }), } // Act @@ -152,9 +169,9 @@ describe('Configure Component', () => { const itemWithBoth: DataSourceAuth = { ...mockItemBase, credential_schema: [mockFormSchema], - oauth_schema: { + oauth_schema: createOAuthSchema({ client_schema: [mockFormSchema], - }, + }), } // Act @@ -172,9 +189,9 @@ describe('Configure Component', () => { it('should keep the permission dialog mounted after the OAuth menu closes', () => { const itemWithOAuth: DataSourceAuth = { ...mockItemBase, - oauth_schema: { + oauth_schema: createOAuthSchema({ client_schema: [mockFormSchema], - }, + }), } render( { const itemWithBoth: DataSourceAuth = { ...mockItemBase, credential_schema: [mockFormSchema], - oauth_schema: { + oauth_schema: createOAuthSchema({ client_schema: [mockFormSchema], - }, + }), } render() @@ -243,9 +260,9 @@ describe('Configure Component', () => { const itemWithBoth: DataSourceAuth = { ...mockItemBase, credential_schema: [mockFormSchema], - oauth_schema: { + oauth_schema: createOAuthSchema({ client_schema: [mockFormSchema], - }, + }), } // Act: Open the configuration menu @@ -270,7 +287,7 @@ describe('Configure Component', () => { const itemEmpty: DataSourceAuth = { ...mockItemBase, credential_schema: [], - oauth_schema: { client_schema: [] }, + oauth_schema: createOAuthSchema({ client_schema: [] }), } // Act rerender() @@ -282,9 +299,9 @@ describe('Configure Component', () => { // Arrange (Partial OAuth schema) const itemPartialOAuth: DataSourceAuth = { ...mockItemBase, - oauth_schema: { + oauth_schema: createOAuthSchema({ is_oauth_custom_client_enabled: true, - }, + }), } // Act rerender() @@ -297,7 +314,7 @@ describe('Configure Component', () => { let count = 0 const itemWithGlitchedSchema = { ...mockItemBase, - oauth_schema: { + oauth_schema: createOAuthSchema({ get client_schema() { count++ if (count % 2 !== 0) return [mockFormSchema] @@ -307,7 +324,7 @@ describe('Configure Component', () => { is_system_oauth_params_exists: false, oauth_custom_client_params: {}, redirect_uri: '', - }, + }), } as unknown as DataSourceAuth render() diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx index 916b422ce0e3d6..7bf37e59c9e881 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx @@ -107,6 +107,8 @@ describe('DataSourcePage Component', () => { name: 'Dify Source', label: { en_US: 'Dify Source', zh_Hans: 'zh_hans_dify_source' }, description: { en_US: 'Dify Description', zh_Hans: 'zh_hans_dify_description' }, + credential_schema: [], + oauth_schema: null, credentials_list: [], }, { @@ -118,6 +120,8 @@ describe('DataSourcePage Component', () => { name: 'Partner Source', label: { en_US: 'Partner Source', zh_Hans: 'zh_hans_partner_source' }, description: { en_US: 'Partner Description', zh_Hans: 'zh_hans_partner_description' }, + credential_schema: [], + oauth_schema: null, credentials_list: [], }, ] diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/install-from-marketplace.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/install-from-marketplace.spec.tsx index 527b904c13e2e6..e802e8a3e289c3 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/install-from-marketplace.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/install-from-marketplace.spec.tsx @@ -84,6 +84,8 @@ describe('InstallFromMarketplace Component', () => { name: 'name', label: { en_US: 'Label', zh_Hans: '标签' }, description: { en_US: 'Desc', zh_Hans: '描述' }, + credential_schema: [], + oauth_schema: null, credentials_list: [], }, ] diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/utils.spec.ts b/web/app/components/header/account-setting/data-source-page-new/__tests__/utils.spec.ts new file mode 100644 index 00000000000000..1e5dd96889f463 --- /dev/null +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/utils.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vite-plus/test' +import { providerConfigToFormSchema } from '../utils' + +describe('providerConfigToFormSchema', () => { + it('should map provider configs to form schemas with en_US fallbacks', () => { + expect( + providerConfigToFormSchema({ + name: 'api_key', + type: 'secret-input', + label: { en_US: 'API Key', zh_Hans: null, ja_JP: 'APIキー' }, + help: null, + url: null, + options: [{ label: { en_US: 'One' }, value: '1' }], + }), + ).toEqual({ + name: 'api_key', + type: 'secret-input', + label: { en_US: 'API Key', zh_Hans: 'API Key', ja_JP: 'APIキー' }, + required: false, + multiple: undefined, + default: undefined, + scope: undefined, + url: undefined, + help: undefined, + placeholder: undefined, + options: [{ label: { en_US: 'One', zh_Hans: 'One' }, value: '1' }], + }) + }) + + it('should fall back to the config name when no label is declared', () => { + expect(providerConfigToFormSchema({ name: 'region', type: 'text-input' }).label).toBe('region') + }) +}) diff --git a/web/app/components/header/account-setting/data-source-page-new/card.tsx b/web/app/components/header/account-setting/data-source-page-new/card.tsx index 953b690a7df9c3..a285438c32e650 100644 --- a/web/app/components/header/account-setting/data-source-page-new/card.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/card.tsx @@ -9,7 +9,7 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { useMutation } from '@tanstack/react-query' -import { memo, useCallback, useRef } from 'react' +import { memo, useCallback, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import { ApiKeyModal, usePluginAuthAction } from '@/app/components/plugins/plugin-auth' @@ -23,6 +23,7 @@ import Configure from './configure' import { useDataSourceAuthUpdate } from './hooks' import Item from './item' import DataSourcePluginActions from './plugin-actions' +import { providerConfigToFormSchema } from './utils' const getPluginVersion = (uniqueIdentifier?: string) => { return uniqueIdentifier?.match(/:([^:@]+)@/)?.[1] @@ -40,6 +41,10 @@ const Card = ({ item, disabled, pluginDetail, onPluginUpdate }: CardProps) => { const renderI18nObject = useRenderI18nObject() const { icon, label, credentials_list, credential_schema } = item const providerLabel = renderI18nObject(label) + const credentialFormSchemas = useMemo( + () => credential_schema.map(providerConfigToFormSchema), + [credential_schema], + ) const fallbackPluginVersion = getPluginVersion(item.plugin_unique_identifier) const pluginPayload = { category: AuthCategory.datasource, @@ -204,7 +209,7 @@ const Card = ({ item, disabled, pluginDetail, onPluginUpdate }: CardProps) => { pendingOperationCredentialId.current = null }} onUpdate={handleAuthUpdate} - formSchemas={credential_schema} + formSchemas={credentialFormSchemas} editValues={editValues} onRemove={handleRemove} disabled={disabled || doingAction || !canManageCredential} diff --git a/web/app/components/header/account-setting/data-source-page-new/configure.tsx b/web/app/components/header/account-setting/data-source-page-new/configure.tsx index 40923c8f62c6ba..9c9334dc319a19 100644 --- a/web/app/components/header/account-setting/data-source-page-new/configure.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/configure.tsx @@ -11,6 +11,7 @@ import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { AddApiKeyButton, AddOAuthButton } from '@/app/components/plugins/plugin-auth' import { useRenderI18nObject } from '@/hooks/use-i18n' +import { providerConfigToFormSchema } from './utils' type ConfigureProps = { item: DataSourceAuth @@ -23,9 +24,13 @@ const Configure = ({ item, pluginPayload, onUpdate, disabled }: ConfigureProps) const renderI18nObject = useRenderI18nObject() const providerLabel = renderI18nObject(item.label) const [open, setOpen] = useState(false) - const canApiKey = item.credential_schema?.length - const oAuthData = item.oauth_schema || {} - const canOAuth = oAuthData.client_schema?.length + const canApiKey = item.credential_schema.length + const credentialFormSchemas = useMemo( + () => item.credential_schema.map(providerConfigToFormSchema), + [item.credential_schema], + ) + const oAuthData = item.oauth_schema + const canOAuth = oAuthData?.client_schema.length const oAuthButtonProps: AddOAuthButtonProps = useMemo(() => { return { buttonText: t(($) => $['auth.addOAuth'], { ns: 'plugin' }), @@ -78,7 +83,7 @@ const Configure = ({ item, pluginPayload, onUpdate, disabled }: ConfigureProps) {!!canApiKey && ( @@ -89,17 +94,17 @@ const Configure = ({ item, pluginPayload, onUpdate, disabled }: ConfigureProps) ) } - if (!canOAuth) return renderConfigurePopover() + if (!oAuthData || !canOAuth) return renderConfigurePopover() return ( ReactNode @@ -64,7 +63,7 @@ const DataSourcePage = ({ layout, onOpenMarketplace, stickyToolbar }: DataSource select: (s) => s.enable_marketplace, }) const { data, isLoading: isDataSourceListLoading } = useQuery( - consoleQuery.auth.plugin.datasource.list.get.queryOptions({ select: selectDataSourceAuthList }), + consoleQuery.auth.plugin.datasource.list.get.queryOptions(), ) const { data: installedPluginList } = useInstalledPluginList({ category: PluginCategoryEnum.datasource, diff --git a/web/app/components/header/account-setting/data-source-page-new/types.ts b/web/app/components/header/account-setting/data-source-page-new/types.ts index d24a15f01afe2e..ea2a402b011dfd 100644 --- a/web/app/components/header/account-setting/data-source-page-new/types.ts +++ b/web/app/components/header/account-setting/data-source-page-new/types.ts @@ -1,31 +1,7 @@ -import type { FormSchema, TypeWithI18N } from '@/app/components/base/form/types' -import type { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' +import type { + DatasourceCredentialResponse, + DatasourceProviderAuthResponse, +} from '@dify/contracts/api/console/auth/types.gen' -export type DataSourceCredential = { - credential: Record - type: CredentialTypeEnum - name: string - id: string - is_default: boolean - avatar_url: string -} -export type DataSourceAuth = { - author: string - provider: string - plugin_id: string - plugin_unique_identifier: string - icon: string - name: string - label: TypeWithI18N - description: TypeWithI18N - credential_schema?: FormSchema[] - oauth_schema?: { - client_schema?: FormSchema[] - credentials_schema?: FormSchema[] - is_oauth_custom_client_enabled?: boolean - is_system_oauth_params_exists?: boolean - oauth_custom_client_params?: Record - redirect_uri?: string - } - credentials_list: DataSourceCredential[] -} +export type DataSourceCredential = DatasourceCredentialResponse +export type DataSourceAuth = DatasourceProviderAuthResponse diff --git a/web/app/components/header/account-setting/data-source-page-new/utils.ts b/web/app/components/header/account-setting/data-source-page-new/utils.ts index 6975a1ac4e11b0..ba85f1f1ed3705 100644 --- a/web/app/components/header/account-setting/data-source-page-new/utils.ts +++ b/web/app/components/header/account-setting/data-source-page-new/utils.ts @@ -1,13 +1,27 @@ -import type { - DatasourceCredentialListResponse, - DatasourceProviderAuthListResponse, -} from '@dify/contracts/api/console/auth/types.gen' -import type { DataSourceAuth, DataSourceCredential } from './types' +import type { I18nObject, ProviderConfig } from '@dify/contracts/api/console/auth/types.gen' +import type { FormOption, FormSchema, TypeWithI18N } from '@/app/components/base/form/types' -// The data source UI types credential and OAuth schemas as auth form schemas and credential -// types as `CredentialTypeEnum`; the backend declares provider configs and plain strings. -export const selectDataSourceAuthList = (data: DatasourceProviderAuthListResponse) => - data as unknown as { result: DataSourceAuth[] } +// Untranslated locales fall back to en_US, matching renderI18nObject. +const toTypeWithI18N = ({ en_US, ja_JP, pt_BR, zh_Hans }: I18nObject): TypeWithI18N => ({ + en_US, + zh_Hans: zh_Hans ?? en_US, + ...(ja_JP != null && { ja_JP }), + ...(pt_BR != null && { pt_BR }), +}) -export const selectDataSourceCredentials = (data: DatasourceCredentialListResponse) => - data as unknown as { result: DataSourceCredential[] } +export const providerConfigToFormSchema = (config: ProviderConfig): FormSchema => ({ + type: config.type, + name: config.name, + label: config.label ? toTypeWithI18N(config.label) : config.name, + required: config.required ?? false, + multiple: config.multiple, + default: config.default ?? undefined, + scope: config.scope ?? undefined, + url: config.url ?? undefined, + help: config.help ? toTypeWithI18N(config.help) : undefined, + placeholder: config.placeholder ? toTypeWithI18N(config.placeholder) : undefined, + options: config.options?.map((option): FormOption => ({ + label: toTypeWithI18N(option.label), + value: option.value, + })), +}) diff --git a/web/app/components/header/account-setting/model-provider-page/declarations.ts b/web/app/components/header/account-setting/model-provider-page/declarations.ts index 3591b54701bfbd..a52bc87bb0afb8 100644 --- a/web/app/components/header/account-setting/model-provider-page/declarations.ts +++ b/web/app/components/header/account-setting/model-provider-page/declarations.ts @@ -121,7 +121,7 @@ export type CredentialFormSchemaBase = { label: TypeWithI18N type: FormTypeEnum required: boolean - default?: string + default?: string | number | boolean | unknown[] | Record | null tooltip?: TypeWithI18N show_on: FormShowOnObject[] url?: string diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx index c9519ee5eae269..b56d037eb50bfa 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx @@ -133,7 +133,7 @@ function Form< if (isEditMode && (key === '__model_type' || key === '__model_name')) return setChangeKey(key) - const shouldClearVariable: Record = {} + const shouldClearVariable: FormValue = {} if (showOnVariableMap[key]?.length) { showOnVariableMap[key].forEach((clearVariable) => { const schema = formSchemas.find((it) => it.variable === clearVariable) diff --git a/web/app/components/tools/mcp/__tests__/create-card.spec.tsx b/web/app/components/tools/mcp/__tests__/create-card.spec.tsx index 32926ef761e6a6..fce5b5b99d77d0 100644 --- a/web/app/components/tools/mcp/__tests__/create-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/create-card.spec.tsx @@ -1,3 +1,4 @@ +import type { ToolProviderApiEntityResponse } from '@dify/contracts/api/console/workspaces/types.gen' import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { @@ -9,7 +10,15 @@ import { render } from '@/test/console/render' import NewMCPCard, { NewMCPButton } from '../create-card' // Track the mock functions -const mockCreateMCP = vi.fn().mockResolvedValue({ id: 'new-mcp-id', name: 'New MCP' }) +const mockCreateMCP = vi.fn().mockResolvedValue({ + id: 'new-mcp-id', + name: 'New MCP', + author: 'tester', + label: { en_US: 'New MCP' }, + description: { en_US: '' }, + icon: '', + type: 'mcp', +} satisfies ToolProviderApiEntityResponse) // Mock the service vi.mock('@tanstack/react-query', async (importOriginal) => { diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index d8d9252cdcbce4..df76caa78c2ed4 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -1,4 +1,6 @@ +import type { WorkflowTriggerResponse } from '@dify/contracts/api/console/apps/types.gen' import type { + ToolParameter as ContractToolParameter, DatasourceProviderType, ToolProviderType, } from '@dify/contracts/api/console/workspaces/types.gen' @@ -109,7 +111,7 @@ export type ToolParameter = { llm_description: string required: boolean multiple: boolean - default: string + default?: ContractToolParameter['default'] options?: { label: LocalizedText value: string @@ -191,7 +193,7 @@ type ParamItem = { type: string form: string required: boolean - default: string + default?: ContractToolParameter['default'] min?: number max?: number options?: { @@ -292,14 +294,4 @@ export const MCPAuthMethod = { export type MCPAuthMethod = (typeof MCPAuthMethod)[keyof typeof MCPAuthMethod] -export type AppTrigger = { - id: string - trigger_type: 'trigger-webhook' | 'trigger-schedule' | 'trigger-plugin' - title: string - node_id: string - provider_name: string - icon: string - status: 'enabled' | 'disabled' | 'unauthorized' - created_at: string - updated_at: string -} +export type AppTrigger = WorkflowTriggerResponse diff --git a/web/app/components/tools/utils/__tests__/query-select.spec.ts b/web/app/components/tools/utils/__tests__/query-select.spec.ts new file mode 100644 index 00000000000000..f495a959a2ec9c --- /dev/null +++ b/web/app/components/tools/utils/__tests__/query-select.spec.ts @@ -0,0 +1,139 @@ +import type { ToolProviderApiEntityResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import { describe, expect, it } from 'vite-plus/test' +import { + selectMCPServerDetail, + selectToolsWithProvider, + selectWorkflowToolDetail, +} from '../query-select' + +const createProvider = ( + overrides: Partial = {}, +): ToolProviderApiEntityResponse => ({ + id: 'provider-1', + name: 'provider', + author: 'dify', + label: { en_US: 'Provider', zh_Hans: null }, + description: { en_US: 'Description', zh_Hans: '描述' }, + icon: '/icon.svg', + type: 'builtin', + ...overrides, +}) + +describe('tool query selectors', () => { + it('should fill untranslated labels and defaulted provider flags', () => { + const [provider] = selectToolsWithProvider([createProvider()]) + + expect(provider).toMatchObject({ + label: { en_US: 'Provider', zh_Hans: 'Provider' }, + description: { en_US: 'Description', zh_Hans: '描述' }, + is_team_authorization: false, + allow_delete: false, + labels: [], + team_credentials: {}, + tools: [], + }) + expect(provider!.label).not.toHaveProperty('ja_JP') + }) + + it('should keep emoji icons and drop unknown identity modes', () => { + const [provider] = selectToolsWithProvider([ + createProvider({ + icon: { background: '#fff', content: '🔧' }, + identity_mode: 'legacy', + plugin_id: null, + }), + ]) + + expect(provider!.icon).toEqual({ background: '#fff', content: '🔧' }) + expect(provider!.identity_mode).toBeUndefined() + expect(provider!.plugin_id).toBeUndefined() + }) + + it('should map tool parameters and keep non-string defaults', () => { + const [provider] = selectToolsWithProvider([ + createProvider({ + tools: [ + { + name: 'search', + author: 'dify', + label: { en_US: 'Search' }, + description: { en_US: 'Search the web' }, + parameters: [ + { + name: 'limit', + label: { en_US: 'Limit' }, + form: 'form', + type: 'number', + default: 10, + human_description: null, + options: [{ label: { en_US: 'Ten' }, value: '10' }], + }, + ], + }, + ], + }), + ]) + + const parameter = provider!.tools[0]!.parameters[0]! + expect(parameter).toMatchObject({ + default: 10, + required: false, + multiple: false, + llm_description: '', + human_description: { en_US: '', zh_Hans: '' }, + options: [{ label: { en_US: 'Ten', zh_Hans: 'Ten' }, value: '10' }], + }) + }) + + it('should keep only string MCP server parameters', () => { + expect( + selectMCPServerDetail({ + id: 'server-1', + name: 'server', + server_code: 'code', + description: '', + status: 'active', + parameters: { query: 'Question', count: 3 }, + }).parameters, + ).toEqual({ query: 'Question' }) + expect( + selectMCPServerDetail({ + id: 'server-1', + name: 'server', + server_code: 'code', + description: '', + status: 'active', + parameters: [], + }).parameters, + ).toBeUndefined() + }) + + it('should narrow the workflow tool output schema', () => { + const detail = selectWorkflowToolDetail({ + name: 'tool', + label: 'Tool', + description: '', + icon: { background: '#fff', content: '🔧' }, + parameters: [], + synced: true, + workflow_app_id: 'app-1', + workflow_tool_id: 'tool-1', + tool: { + name: 'tool', + author: 'dify', + label: { en_US: 'Tool' }, + description: { en_US: '' }, + output_schema: { + type: 'object', + properties: { text: { type: 'string', description: 'Text' }, broken: 'x' }, + }, + }, + }) + + expect(detail.privacy_policy).toBe('') + expect(detail.tool.output_schema).toEqual({ + type: 'object', + properties: { text: { type: 'string', description: 'Text' } }, + }) + }) +}) diff --git a/web/app/components/tools/utils/query-select.ts b/web/app/components/tools/utils/query-select.ts index 2d5fbc85eee35f..a6f3bc31e7cd4d 100644 --- a/web/app/components/tools/utils/query-select.ts +++ b/web/app/components/tools/utils/query-select.ts @@ -1,47 +1,169 @@ -import type { - AppMcpServerResponse, - WorkflowTriggerListResponse, -} from '@dify/contracts/api/console/apps/types.gen' +import type { AppMcpServerResponse } from '@dify/contracts/api/console/apps/types.gen' import type { RagPipelineOpaqueResponse } from '@dify/contracts/api/console/rag/types.gen' import type { + ToolParameter as ContractToolParameter, + I18nObject, + ToolApiEntity, ToolApiListResponse, ToolProviderApiEntityResponse, ToolProviderListResponse, WorkflowToolDetailResponse, } from '@dify/contracts/api/console/workspaces/types.gen' import type { - AppTrigger, Collection, + Emoji, MCPServerDetail, Tool, + ToolParameter, + WorkflowToolProviderOutputSchema, WorkflowToolProviderResponse, } from '../types' import type { RAGRecommendedPlugins, ToolWithProvider } from '@/app/components/workflow/types' -// The tool UI types narrow provider types, statuses, icons and parameter schemas that the -// backend declares as plain strings and loose records. -export const selectToolCollections = (data: ToolProviderListResponse) => - data as unknown as Collection[] +type LocalizedText = Collection['label'] +type ProviderIcon = ToolProviderApiEntityResponse['icon'] -export const selectToolsWithProvider = (data: ToolProviderListResponse) => - data as unknown as ToolWithProvider[] +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +// Untranslated locales fall back to en_US, matching renderI18nObject. +const toLocalizedText = ({ en_US, ja_JP, pt_BR, zh_Hans }: I18nObject): LocalizedText => ({ + en_US, + zh_Hans: zh_Hans ?? en_US, + ...(ja_JP != null && { ja_JP }), + ...(pt_BR != null && { pt_BR }), +}) + +const EMPTY_TEXT: LocalizedText = { en_US: '', zh_Hans: '' } + +// Providers return an icon URL, or an emoji record for API and workflow tools. +const toIcon = (icon: ProviderIcon): string | Emoji => + typeof icon === 'string' + ? icon + : { background: icon.background ?? '', content: icon.content ?? '' } + +const toIdentityMode = (mode: string | undefined) => + mode === 'off' || mode === 'idp_token' ? mode : undefined -export const selectToolProvider = (data: ToolProviderApiEntityResponse) => - data as unknown as ToolWithProvider +const toToolParameter = (parameter: ContractToolParameter): ToolParameter => ({ + ...parameter, + label: toLocalizedText(parameter.label), + human_description: parameter.human_description + ? toLocalizedText(parameter.human_description) + : EMPTY_TEXT, + llm_description: parameter.llm_description ?? '', + required: parameter.required ?? false, + multiple: parameter.multiple ?? false, + min: parameter.min ?? undefined, + max: parameter.max ?? undefined, + options: parameter.options?.map((option) => ({ + ...option, + label: toLocalizedText(option.label), + })), +}) + +const toTool = (tool: ToolApiEntity): Tool => ({ + ...tool, + label: toLocalizedText(tool.label), + description: toLocalizedText(tool.description), + labels: tool.labels ?? [], + output_schema: tool.output_schema ?? {}, + parameters: (tool.parameters ?? []).map(toToolParameter), +}) + +const toCollection = (provider: ToolProviderApiEntityResponse): Collection => ({ + ...provider, + label: toLocalizedText(provider.label), + description: toLocalizedText(provider.description), + icon: toIcon(provider.icon), + icon_dark: provider.icon_dark === undefined ? undefined : toIcon(provider.icon_dark), + team_credentials: provider.team_credentials ?? {}, + is_team_authorization: provider.is_team_authorization ?? false, + allow_delete: provider.allow_delete ?? false, + labels: provider.labels ?? [], + tools: provider.tools?.map(toTool), + plugin_id: provider.plugin_id ?? undefined, + server_url: provider.server_url ?? undefined, + server_identifier: provider.server_identifier ?? undefined, + masked_headers: provider.masked_headers ?? undefined, + authentication: provider.authentication + ? { + client_id: provider.authentication.client_id, + client_secret: provider.authentication.client_secret ?? undefined, + } + : undefined, + configuration: provider.configuration ?? undefined, + identity_mode: toIdentityMode(provider.identity_mode), + workflow_app_id: provider.workflow_app_id ?? undefined, +}) + +export const selectToolProvider = (provider: ToolProviderApiEntityResponse): ToolWithProvider => ({ + ...toCollection(provider), + tools: (provider.tools ?? []).map(toTool), + plugin_unique_identifier: provider.plugin_unique_identifier ?? undefined, +}) + +export const selectToolCollections = (data: ToolProviderListResponse) => data.map(toCollection) + +export const selectToolsWithProvider = (data: ToolProviderListResponse) => + data.map(selectToolProvider) -export const selectToolProviderTools = (data: ToolProviderApiEntityResponse) => - data as unknown as { tools: Tool[] } +export const selectToolProviderTools = (provider: ToolProviderApiEntityResponse) => ({ + tools: (provider.tools ?? []).map(toTool), +}) -export const selectBuiltinProviderTools = (data: ToolApiListResponse) => data as unknown as Tool[] +export const selectBuiltinProviderTools = (data: ToolApiListResponse) => data.map(toTool) -export const selectMCPServerDetail = (data: AppMcpServerResponse) => - data as unknown as MCPServerDetail +export const selectMCPServerDetail = (detail: AppMcpServerResponse): MCPServerDetail => ({ + ...detail, + parameters: isRecord(detail.parameters) + ? Object.fromEntries( + Object.entries(detail.parameters).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ) + : undefined, +}) -export const selectWorkflowToolDetail = (data: WorkflowToolDetailResponse) => - data as unknown as WorkflowToolProviderResponse +const toOutputSchema = ( + schema: Record = {}, +): WorkflowToolProviderOutputSchema => ({ + type: typeof schema.type === 'string' ? schema.type : 'object', + properties: isRecord(schema.properties) + ? Object.fromEntries( + Object.entries(schema.properties).flatMap(([name, property]) => + isRecord(property) && typeof property.type === 'string' + ? [ + [ + name, + { + type: property.type, + description: + typeof property.description === 'string' ? property.description : '', + }, + ], + ] + : [], + ), + ) + : {}, +}) -export const selectAppTriggers = (data: WorkflowTriggerListResponse) => - data as unknown as { data: AppTrigger[] } +export const selectWorkflowToolDetail = ( + detail: WorkflowToolDetailResponse, +): WorkflowToolProviderResponse => ({ + ...detail, + privacy_policy: detail.privacy_policy ?? '', + tool: { + ...detail.tool, + label: toLocalizedText(detail.tool.label), + description: toLocalizedText(detail.tool.description), + labels: detail.tool.labels ?? [], + parameters: (detail.tool.parameters ?? []).map(toToolParameter), + output_schema: toOutputSchema(detail.tool.output_schema), + }, +}) +// The recommended plugins endpoint declares no response schema, so there is nothing to narrow. export const selectRAGRecommendedPlugins = (data: RagPipelineOpaqueResponse) => - data as unknown as RAGRecommendedPlugins + data as RAGRecommendedPlugins diff --git a/web/app/components/tools/utils/to-form-schema.ts b/web/app/components/tools/utils/to-form-schema.ts index a00a5f2a1fe8e2..fdcf75f8148667 100644 --- a/web/app/components/tools/utils/to-form-schema.ts +++ b/web/app/components/tools/utils/to-form-schema.ts @@ -46,7 +46,7 @@ export type ToolFormSchema = { _type: string form: string required: boolean - default?: string + default?: ToolParameter['default'] tooltip?: TypeWithI18N show_on: { variable: string; value: string }[] options?: { diff --git a/web/app/components/workflow-app/index.tsx b/web/app/components/workflow-app/index.tsx index 7b265571f33c29..bd908bc822d050 100644 --- a/web/app/components/workflow-app/index.tsx +++ b/web/app/components/workflow-app/index.tsx @@ -7,7 +7,6 @@ import { useAtomValue } from 'jotai' import { useEffect, useMemo } from 'react' import { FeaturesProvider } from '@/app/components/base/features' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' -import { selectAppTriggers } from '@/app/components/tools/utils/query-select' import WorkflowWithDefaultContext from '@/app/components/workflow' import { WorkflowContextProvider } from '@/app/components/workflow/context' import { useWorkflowStore } from '@/app/components/workflow/store' @@ -58,7 +57,6 @@ const WorkflowAppWithAdditionalContext = () => { consoleQuery.apps.byAppId.triggers.get.queryOptions({ input: { params: { app_id: appId } }, enabled: isWorkflowMode && !!appId, - select: selectAppTriggers, staleTime: 5 * 60 * 1000, // 5 minutes cache refetchOnWindowFocus: false, }), diff --git a/web/types/pipeline.tsx b/web/types/pipeline.tsx index 9377868db7d592..afa6a63b3af953 100644 --- a/web/types/pipeline.tsx +++ b/web/types/pipeline.tsx @@ -1,4 +1,4 @@ -import type { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth' +import type { DatasourceCredentialResponse } from '@dify/contracts/api/console/auth/types.gen' export type DataSourceNodeProcessingResponse = { event: 'datasource_processing' @@ -31,11 +31,4 @@ export type DataSourceNodeErrorResponse = { error: string } -export type DataSourceCredential = { - avatar_url?: string - credential: Record - id: string - is_default: boolean - name: string - type: CredentialTypeEnum -} +export type DataSourceCredential = DatasourceCredentialResponse From 57de70700b6f453f88b83f84ae63a8f139fe7d9a Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:25:33 +0800 Subject: [PATCH 34/60] refactor(web): map model provider details from the contract Model provider details now come from the generated provider list options, with a select that maps each provider and its credential form schemas to the UI provider instead of casting the response. The UI provider type follows the contract where the backend omits fields: help, icons and credential schemas are optional, the current quota type may be absent and quota configurations use the generated shape. Credential schemas may declare `switch`, which keeps its existing no-renderer behavior. Specs seed contract-shaped providers and drop the as-unknown casts on mocked hook results and client responses. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/index.spec.tsx | 28 ++--- .../__tests__/member-selector.spec.tsx | 4 +- .../__tests__/utils.spec.ts | 56 +++++++++ .../model-provider-page/declarations.ts | 28 +++-- .../model-modal/__tests__/dialog.spec.tsx | 2 +- .../model-modal/__tests__/index.spec.tsx | 1 - .../model-provider-page/model-modal/index.tsx | 2 +- .../__tests__/popup-item.spec.tsx | 11 +- .../__tests__/index.spec.tsx | 15 +-- .../model-provider-page/utils.ts | 106 ++++++++++++++++-- .../tools/provider/__tests__/detail.spec.tsx | 5 +- .../signin/__tests__/one-more-step.spec.tsx | 2 +- .../invite-settings/__tests__/page.spec.tsx | 10 +- .../signup/check-code/__tests__/page.spec.tsx | 4 +- .../set-password/__tests__/page.spec.tsx | 2 +- web/test/console/model-provider.ts | 25 +++++ 16 files changed, 228 insertions(+), 73 deletions(-) create mode 100644 web/test/console/model-provider.ts diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx index ed170a319060f3..d12c0627405117 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx @@ -127,16 +127,12 @@ describe('TransferOwnershipModal', () => { ).mockResolvedValue({ data: 'step-token', result: 'success', - } as unknown as Awaited< - ReturnType - >) + }) vi.mocked(consoleClient.workspaces.current.members.ownerTransferCheck.post).mockResolvedValue({ + email: 'owner@example.com', is_valid: isValid, token, - result: 'success', - } as unknown as Awaited< - ReturnType - >) + }) } const goToTransferStep = async (user: ReturnType) => { @@ -160,9 +156,7 @@ describe('TransferOwnershipModal', () => { consoleClient.workspaces.current.members.byMemberId.ownerTransfer.post, ).mockResolvedValue({ result: 'success', - } as unknown as Awaited< - ReturnType - >) + }) renderModal() await goToTransferStep(user) @@ -190,9 +184,7 @@ describe('TransferOwnershipModal', () => { ).mockResolvedValue({ data: 'token', result: 'success', - } as unknown as Awaited< - ReturnType - >) + }) renderModal() // Trigger the email send (which starts the timer) @@ -308,23 +300,21 @@ describe('TransferOwnershipModal', () => { }) }) - it('should handle sendOwnerEmail returning null data', async () => { + it('should handle sendOwnerEmail returning an empty step token', async () => { const user = userEvent.setup() vi.mocked( consoleClient.workspaces.current.members.sendOwnerTransferConfirmEmail.post, ).mockResolvedValue({ - data: null, + data: '', result: 'success', - } as unknown as Awaited< - ReturnType - >) + }) renderModal() await user.click( screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }), ) - // Should advance to verify step even with null data + // Should advance to verify step even without a step token await waitFor(() => { expect(screen.getByText(/members\.transferModal\.verifyEmail/i)).toBeInTheDocument() }) diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/member-selector.spec.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/member-selector.spec.tsx index 222ce385d8592e..4c11b4f49e5770 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/member-selector.spec.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/member-selector.spec.tsx @@ -114,9 +114,7 @@ describe('MemberSelector', () => { }) it('should handle missing data gracefully', () => { - vi.mocked(mockMembersHook).mockReturnValue({ data: undefined } as unknown as ReturnType< - typeof mockMembersHook - >) + vi.mocked(mockMembersHook).mockReturnValue({ data: undefined }) render() expect(getTrigger()).toBeInTheDocument() }) diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/utils.spec.ts b/web/app/components/header/account-setting/model-provider-page/__tests__/utils.spec.ts index 1bcff84d74c844..780b1ea4363d75 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/utils.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/utils.spec.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from 'vite-plus/test' +import { createProviderResponseFixture } from '@/test/console/model-provider' import { FormTypeEnum, ModelTypeEnum } from '../declarations' import { genModelNameFormSchema, genModelTypeFormSchema, + modelProviderDetailsQueryOptions, modelTypeFormat, sizeFormat, } from '../utils' @@ -60,4 +62,58 @@ describe('utils', () => { expect(schema.placeholder!.en_US).toBe('Enter custom') }) }) + + describe('modelProviderDetailsQueryOptions', () => { + it('should map credential schemas and nullable provider fields to the UI provider', () => { + const select = modelProviderDetailsQueryOptions().select! + const { data } = select({ + data: [ + createProviderResponseFixture({ + help: null, + background: null, + provider_credential_schema: { + credential_form_schemas: [ + { + variable: 'api_key', + type: 'secret-input', + label: { en_US: 'API Key', zh_Hans: null }, + max_length: 0, + required: false, + show_on: [], + options: [{ label: { en_US: 'One' }, value: '1', show_on: [] }], + }, + ], + }, + system_configuration: { + enabled: true, + current_quota_type: null, + quota_configurations: [], + }, + }), + ], + }) + + expect(data[0]).toMatchObject({ + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + help: undefined, + background: undefined, + supported_model_types: [ModelTypeEnum.textGeneration], + system_configuration: { enabled: true, quota_configurations: [] }, + provider_credential_schema: { + credential_form_schemas: [ + { + name: 'api_key', + variable: 'api_key', + type: FormTypeEnum.secretInput, + label: { en_US: 'API Key', zh_Hans: 'API Key' }, + required: false, + show_on: [], + options: [{ label: { en_US: 'One', zh_Hans: 'One' }, value: '1', show_on: [] }], + }, + ], + }, + }) + expect(data[0]!.system_configuration.current_quota_type).toBeUndefined() + }) + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/declarations.ts b/web/app/components/header/account-setting/model-provider-page/declarations.ts index a52bc87bb0afb8..554f2bc84c6266 100644 --- a/web/app/components/header/account-setting/model-provider-page/declarations.ts +++ b/web/app/components/header/account-setting/model-provider-page/declarations.ts @@ -1,4 +1,8 @@ -import type { GetWorkspacesCurrentDefaultModelResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { + FormType as ContractFormType, + QuotaConfiguration as ContractQuotaConfiguration, + GetWorkspacesCurrentDefaultModelResponse, +} from '@dify/contracts/api/console/workspaces/types.gen' import type { Field } from '@/app/components/workflow/nodes/llm/types' export type FormValue = Record @@ -119,7 +123,8 @@ export type CredentialFormSchemaBase = { name: string variable: string label: TypeWithI18N - type: FormTypeEnum + // Model provider credential forms may also declare `switch`, which has no field renderer. + type: FormTypeEnum | ContractFormType required: boolean default?: string | number | boolean | unknown[] | Record | null tooltip?: TypeWithI18N @@ -194,14 +199,7 @@ export const QuotaUnitEnum = { export type QuotaUnitEnum = (typeof QuotaUnitEnum)[keyof typeof QuotaUnitEnum] -type QuotaConfiguration = { - quota_type: CurrentSystemQuotaTypeEnum - quota_unit: QuotaUnitEnum - quota_limit: number - quota_used: number - last_used: number - is_valid: boolean -} +type QuotaConfiguration = ContractQuotaConfiguration export type Credential = { credential_id: string @@ -226,19 +224,19 @@ export type ModelProvider = { provider: string label: TypeWithI18N description?: TypeWithI18N - help: { + help?: { title: TypeWithI18N url: TypeWithI18N } - icon_small: TypeWithI18N + icon_small?: TypeWithI18N icon_small_dark?: TypeWithI18N background?: string supported_model_types: ModelTypeEnum[] configurate_methods: ConfigurationMethodEnum[] - provider_credential_schema: { + provider_credential_schema?: { credential_form_schemas: CredentialFormSchema[] } - model_credential_schema: { + model_credential_schema?: { model: { label: TypeWithI18N placeholder: TypeWithI18N @@ -259,7 +257,7 @@ export type ModelProvider = { } system_configuration: { enabled: boolean - current_quota_type: CurrentSystemQuotaTypeEnum + current_quota_type?: CurrentSystemQuotaTypeEnum quota_configurations: QuotaConfiguration[] } allow_custom_token?: boolean diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx index b937f94b2d5f59..3da2ca569f81ad 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx @@ -150,7 +150,7 @@ describe('ModelModal dialog branches', () => { provider={createProvider({ help: { title: { en_US: 'English Help' }, - url: '' as unknown as ModelProvider['help']['url'], + url: '' as unknown as NonNullable['url'], } as ModelProvider['help'], })} configurateMethod={ConfigurationMethodEnum.predefinedModel} diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx index 9fafb857421bd5..2781de32fa9d05 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx @@ -185,7 +185,6 @@ const createProvider = (overrides?: Partial): ModelProvider => ({ quota_unit: QuotaUnitEnum.times, quota_limit: 0, quota_used: 0, - last_used: 0, is_valid: true, }, ], diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx index 229d7189d43321..d997c8d3a843cc 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx @@ -398,7 +398,7 @@ const ModelModal: FC = ({ target="_blank" rel="noopener noreferrer" className="mt-2 inline-block align-middle system-xs-regular text-text-accent" - onClick={(e) => !provider.help.url && e.preventDefault()} + onClick={(e) => !provider.help?.url && e.preventDefault()} > {provider.help.title?.[language] || provider.help.url[language] || diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx index ca8044f43eb6bd..b055ff7a63f173 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx @@ -4,13 +4,14 @@ import type { ProviderWithModelsResponse, } from '@dify/contracts/api/console/workspaces/types.gen' import type { ReactElement, ReactNode } from 'react' -import type { DefaultModel, ModelProvider } from '../../declarations' +import type { DefaultModel } from '../../declarations' import type { ModelSelectorPreviewPayload } from '../popup-item' import { createPreviewCardHandle } from '@langgenius/dify-ui/preview-card' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { modelProviderDetailsQueryOptions } from '@/app/components/header/account-setting/model-provider-page/utils' import { consoleQuery } from '@/service/console' +import { createProviderResponseFixture } from '@/test/console/model-provider' import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { ConfigurationMethodEnum, @@ -179,8 +180,12 @@ const createPopupItemNode = (node: ReactElement) => node const renderPopupItem = (node: ReactElement) => { const queryClient = createConsoleQueryClient() queryClient.setQueryData(modelProviderDetailsQueryOptions().queryKey, { - // Only the fields the popup reads are seeded. - data: [makeProvider() as unknown as ModelProvider], + data: [ + createProviderResponseFixture({ + provider: 'openai', + custom_configuration: { status: 'active', current_credential_name: 'my-api-key' }, + }), + ], }) queryClient.setQueryData(consoleQuery.workspaces.current.modelProviders.summary.get.queryKey(), { ...mockProviderSummary(), diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/index.spec.tsx index 92b7a5f1bf1b0a..2aef4ba9a0b34f 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/index.spec.tsx @@ -3,6 +3,7 @@ import type { ModelProvider } from '../../../declarations' import type { CredentialPanelState } from '../../use-credential-panel-state' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { modelProviderDetailsQueryOptions } from '@/app/components/header/account-setting/model-provider-page/utils' +import { createProviderResponseFixture } from '@/test/console/model-provider' import { renderWithConsoleQuery } from '@/test/console/query-data' import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../../declarations' import ModelAuthDropdown from '../index' @@ -212,7 +213,7 @@ describe('ModelAuthDropdown', () => { }, system_configuration: { enabled: true }, } satisfies ModelProviderSummaryResponse - const fullProvider = createProvider() + const fullProvider = createProviderResponseFixture({ provider: 'test' }) render( { }) it('should load provider detail on first click and reuse it on reopen', async () => { - const fullProvider = createProvider({ + const fullProvider = createProviderResponseFixture({ + provider: 'test', custom_configuration: { - status: CustomConfigurationStatusEnum.active, available_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }], current_credential_id: 'c1', current_credential_name: 'Key 1', @@ -346,17 +347,17 @@ describe('ModelAuthDropdown', () => { current_credential_usable: true, }, } as unknown as ModelProviderSummaryResponse - const firstProvider = createProvider({ + const firstProvider = createProviderResponseFixture({ + provider: 'test', custom_configuration: { - status: CustomConfigurationStatusEnum.active, available_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }], current_credential_id: 'c1', current_credential_name: 'Key 1', }, }) - const nextProvider = createProvider({ + const nextProvider = createProviderResponseFixture({ + provider: 'test', custom_configuration: { - status: CustomConfigurationStatusEnum.active, available_credentials: [{ credential_id: 'c2', credential_name: 'Key 2' }], current_credential_id: 'c2', current_credential_name: 'Key 2', diff --git a/web/app/components/header/account-setting/model-provider-page/utils.ts b/web/app/components/header/account-setting/model-provider-page/utils.ts index 1b45decf3c527a..79d8578688d866 100644 --- a/web/app/components/header/account-setting/model-provider-page/utils.ts +++ b/web/app/components/header/account-setting/model-provider-page/utils.ts @@ -1,13 +1,16 @@ import type { + CredentialFormSchema as ContractCredentialFormSchema, FetchFrom, ModelFeature, ModelStatus, ModelType, ModelWithProviderEntityResponse, ParameterRule, + ProviderResponse, } from '@dify/contracts/api/console/workspaces/types.gen' import type { StaticImageData } from 'next/image' import type { + CredentialFormSchema, CredentialFormSchemaSelect, CredentialFormSchemaTextInput, ModelItem, @@ -15,8 +18,8 @@ import type { ModelProvider, TypeWithI18N, } from './declarations' -import { queryOptions, skipToken } from '@tanstack/react-query' -import { consoleClient, consoleQuery } from '@/service/console' +import { skipToken } from '@tanstack/react-query' +import { consoleQuery } from '@/service/console' import { ModelProviderQuotaGetPaid } from '@/types/model-provider' import openaiLogo from './assets/openai-small.svg' import tongyiLogo from './assets/tongyi.svg' @@ -239,14 +242,95 @@ export const modelParameterRulesQueryOptions = ({ select: (response) => ({ data: response.data.map(normalizeModelParameterRule) }), }) -// Credential schemas are untyped JSON in the console API response; ModelProvider is the -// UI's narrowed view of the provider list. +const normalizeCredentialFormSchema = ( + schema: ContractCredentialFormSchema, +): CredentialFormSchema => ({ + name: schema.variable, + variable: schema.variable, + type: schema.type, + label: normalizeModelLabel(schema.label), + required: schema.required ?? false, + default: schema.default, + show_on: schema.show_on ?? [], + max_length: schema.max_length, + placeholder: schema.placeholder ? normalizeModelLabel(schema.placeholder) : undefined, + options: (schema.options ?? []).map((option) => ({ + label: normalizeModelLabel(option.label), + value: option.value, + show_on: option.show_on ?? [], + })), +}) + +const normalizeModelProvider = (provider: ProviderResponse): ModelProvider => { + const { custom_configuration: customConfiguration } = provider + const modelCredentialSchema = provider.model_credential_schema + return { + provider: provider.provider, + label: normalizeModelLabel(provider.label), + description: provider.description ? normalizeModelLabel(provider.description) : undefined, + help: provider.help + ? { + title: normalizeModelLabel(provider.help.title), + url: normalizeModelLabel(provider.help.url), + } + : undefined, + icon_small: provider.icon_small ? normalizeModelLabel(provider.icon_small) : undefined, + icon_small_dark: provider.icon_small_dark + ? normalizeModelLabel(provider.icon_small_dark) + : undefined, + background: provider.background ?? undefined, + supported_model_types: provider.supported_model_types.map((type) => modelTypeMap[type]), + configurate_methods: provider.configurate_methods.map((method) => fetchFromMap[method]), + provider_credential_schema: provider.provider_credential_schema + ? { + credential_form_schemas: provider.provider_credential_schema.credential_form_schemas.map( + normalizeCredentialFormSchema, + ), + } + : undefined, + model_credential_schema: modelCredentialSchema + ? { + model: { + label: normalizeModelLabel(modelCredentialSchema.model.label), + placeholder: normalizeModelLabel( + modelCredentialSchema.model.placeholder ?? modelCredentialSchema.model.label, + ), + }, + credential_form_schemas: modelCredentialSchema.credential_form_schemas.map( + normalizeCredentialFormSchema, + ), + } + : undefined, + preferred_provider_type: provider.preferred_provider_type, + custom_configuration: { + status: customConfiguration.status, + current_credential_id: customConfiguration.current_credential_id ?? undefined, + current_credential_name: customConfiguration.current_credential_name ?? undefined, + available_credentials: customConfiguration.available_credentials ?? undefined, + custom_models: customConfiguration.custom_models?.map((model) => ({ + model: model.model, + model_type: modelTypeMap[model.model_type], + credentials: model.credentials ?? undefined, + available_model_credentials: model.available_model_credentials, + current_credential_id: model.current_credential_id ?? undefined, + current_credential_name: model.current_credential_name ?? undefined, + })), + can_added_models: customConfiguration.can_added_models?.map((model) => ({ + model: model.model, + model_type: modelTypeMap[model.model_type], + })), + }, + system_configuration: { + enabled: provider.system_configuration.enabled, + current_quota_type: provider.system_configuration.current_quota_type ?? undefined, + quota_configurations: provider.system_configuration.quota_configurations ?? [], + }, + } +} + +// ModelProvider is the UI's view of the provider list, with credential schemas as form schemas. export const modelProviderDetailsQueryOptions = () => - queryOptions({ - // Keeps the provider-list prefix so contract invalidations still reach the UI view. - queryKey: [...consoleQuery.workspaces.current.modelProviders.get.key(), 'ui-details'], - queryFn: async () => - (await consoleClient.workspaces.current.modelProviders.get({})) as unknown as { - data: ModelProvider[] - }, + consoleQuery.workspaces.current.modelProviders.get.queryOptions({ + input: {}, + select: (response) => ({ data: response.data.map(normalizeModelProvider) }), }) diff --git a/web/app/components/tools/provider/__tests__/detail.spec.tsx b/web/app/components/tools/provider/__tests__/detail.spec.tsx index cf811304a8eac8..7a279a0a786c3f 100644 --- a/web/app/components/tools/provider/__tests__/detail.spec.tsx +++ b/web/app/components/tools/provider/__tests__/detail.spec.tsx @@ -1,11 +1,11 @@ import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { ReactElement } from 'react' import type { Collection } from '../../types' -import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations' import { act, cleanup, fireEvent, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { modelProviderDetailsQueryOptions } from '@/app/components/header/account-setting/model-provider-page/utils' import { consoleQuery } from '@/service/console' +import { createProviderResponseFixture } from '@/test/console/model-provider' import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { AuthType, CollectionType } from '../../types' import ProviderDetail from '../detail' @@ -39,8 +39,7 @@ const render = (ui: ReactElement) => { plugins: {}, }) queryClient.setQueryData(modelProviderDetailsQueryOptions().queryKey, { - // Only the provider id is read when opening the model modal. - data: [{ provider: 'model-collection-id' } as ModelProvider], + data: [createProviderResponseFixture({ provider: 'model-collection-id' })], }) void queryClient.invalidateQueries({ queryKey: modelProviderDetailsQueryOptions().queryKey, diff --git a/web/app/signin/__tests__/one-more-step.spec.tsx b/web/app/signin/__tests__/one-more-step.spec.tsx index 9c16c4135373af..40fc652434f40e 100644 --- a/web/app/signin/__tests__/one-more-step.spec.tsx +++ b/web/app/signin/__tests__/one-more-step.spec.tsx @@ -43,7 +43,7 @@ describe('OneMoreStep', () => { mockUseOneMoreStep.mockReturnValue({ mutateAsync: mockSubmitOneMoreStep, isPending: false, - } as unknown as ReturnType) + }) mockSubmitOneMoreStep.mockResolvedValue({ result: 'success' }) }) diff --git a/web/app/signin/invite-settings/__tests__/page.spec.tsx b/web/app/signin/invite-settings/__tests__/page.spec.tsx index 2b62a6e37f62e2..6ad5a105428244 100644 --- a/web/app/signin/invite-settings/__tests__/page.spec.tsx +++ b/web/app/signin/invite-settings/__tests__/page.spec.tsx @@ -125,7 +125,7 @@ describe('InviteSettingsPage', () => { }, }, refetch: mockRefetch, - } as unknown as ReturnType) + }) mockUseQuery.mockReturnValue({ data: { profile: { @@ -160,7 +160,7 @@ describe('InviteSettingsPage', () => { }, }, refetch: mockRefetch, - } as unknown as ReturnType) + }) render() @@ -235,7 +235,7 @@ describe('InviteSettingsPage', () => { }, }, refetch: mockRefetch, - } as unknown as ReturnType) + }) render() @@ -264,7 +264,7 @@ describe('InviteSettingsPage', () => { }, }, refetch: mockRefetch, - } as unknown as ReturnType) + }) render() @@ -294,7 +294,7 @@ describe('InviteSettingsPage', () => { }, }, refetch: mockRefetch, - } as unknown as ReturnType) + }) render() diff --git a/web/app/signup/check-code/__tests__/page.spec.tsx b/web/app/signup/check-code/__tests__/page.spec.tsx index c2f8444278833a..54219bb309a6f2 100644 --- a/web/app/signup/check-code/__tests__/page.spec.tsx +++ b/web/app/signup/check-code/__tests__/page.spec.tsx @@ -69,10 +69,10 @@ describe('Signup Check Code Page', () => { ) mockUseMailValidity.mockReturnValue({ mutateAsync: mockVerifyCode, - } as unknown as ReturnType) + }) mockUseSendMail.mockReturnValue({ mutateAsync: mockSubmitMail, - } as unknown as ReturnType) + }) mockVerifyCode.mockResolvedValue({ is_valid: true, token: 'verified-token' }) }) diff --git a/web/app/signup/set-password/__tests__/page.spec.tsx b/web/app/signup/set-password/__tests__/page.spec.tsx index 94155229bf64a2..800cc2e1c6f8a9 100644 --- a/web/app/signup/set-password/__tests__/page.spec.tsx +++ b/web/app/signup/set-password/__tests__/page.spec.tsx @@ -95,7 +95,7 @@ describe('Signup Set Password Page', () => { mockUseMailRegister.mockReturnValue({ mutateAsync: mockRegister, isPending: false, - } as unknown as ReturnType) + }) mockGetBrowserTimezone.mockReturnValue('Asia/Shanghai') mockRegister.mockResolvedValue({ result: 'fail', data: {} }) }) diff --git a/web/test/console/model-provider.ts b/web/test/console/model-provider.ts new file mode 100644 index 00000000000000..c0d251a448f5ad --- /dev/null +++ b/web/test/console/model-provider.ts @@ -0,0 +1,25 @@ +import type { ConsoleClient } from '@/service/console' + +type ProviderResponse = Awaited< + ReturnType +>['data'][number] + +type ProviderResponseOverrides = Partial> & { + custom_configuration?: Partial +} + +/** A console model provider list item, as `modelProviders.get` returns it. */ +export const createProviderResponseFixture = ({ + custom_configuration, + ...overrides +}: ProviderResponseOverrides = {}): ProviderResponse => ({ + provider: 'openai', + tenant_id: 'tenant-1', + label: { en_US: 'OpenAI' }, + supported_model_types: ['llm'], + configurate_methods: ['predefined-model'], + preferred_provider_type: 'custom', + custom_configuration: { status: 'active', ...custom_configuration }, + system_configuration: { enabled: false, quota_configurations: [] }, + ...overrides, +}) From c2b7592380f5a5fa3fcb9c9209a75e25b3d5498b Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:25:41 +0800 Subject: [PATCH 35/60] chore: keep oxlint suppressions without a final newline .editorconfig expects the oxlint-generated baseline without a trailing newline. Co-Authored-By: Claude Opus 5.5 --- oxlint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index c66a7e64e09fd0..93f92cac05325b 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4262,4 +4262,4 @@ "count": 2 } } -} +} \ No newline at end of file From c282d4149786d2b3a90db0412a937ae2c91f9022 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:34:01 +0800 Subject: [PATCH 36/60] test(web): stub model provider details options in the provider card spec Co-Authored-By: Claude Opus 5.5 --- .../provider-added-card/__tests__/index.spec.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx index 3e1327d9717cb4..7502cc69ba1488 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx @@ -51,7 +51,14 @@ vi.mock('@/service/console', () => ({ workspaces: { current: { modelProviders: { - get: { key: () => [['console', 'workspaces', 'current', 'modelProviders', 'get']] }, + get: { + key: () => [['console', 'workspaces', 'current', 'modelProviders', 'get']], + queryOptions: (options: Record = {}) => ({ + ...options, + queryKey: [['console', 'workspaces', 'current', 'modelProviders', 'get']], + queryFn: async () => ({ data: [] }), + }), + }, summary: { get: { key: () => ['model-provider-summary'] } }, byProvider: { models: { From f4978ead6841b79b43cb4ff3a4e8034716d0407c Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:54:12 +0800 Subject: [PATCH 37/60] refactor(web): use generated pipeline options directly Remove the forwarding hooks in service/use-pipeline. Consumers call the generated template, DSL import, processing parameter, published workflow, run, execution log, preview, conversion and single-run options directly, and invalidate generated keys inline. The shared staleTime and silent import policies move to query-policies. The console API now declares the pipeline responses that were opaque: step parameters list typed pipeline variables, the published run returns either the processing batch or the preview workflow result, and dataset conversion and recommended plugins have response models. Select adapters in rag-pipeline/utils/query-select narrow the remaining free-form fields (workflow graphs, template icons, chunk structures, enum-like strings) with type guards instead of casts, and the handwritten pipeline response DTOs are removed in favour of the generated types. Co-Authored-By: Claude Opus 5.5 --- api/controllers/common/schema.py | 1 + .../rag_pipeline/rag_pipeline_workflow.py | 54 ++- .../test_rag_pipeline_workflow.py | 22 +- .../test_rag_pipeline_workflow_apis.py | 22 +- .../unit_tests/controllers/test_swagger.py | 2 +- oxlint-suppressions.json | 7 +- .../generated/api/console/rag/types.gen.ts | 217 ++++++++- .../generated/api/console/rag/zod.gen.ts | 284 +++++++++++- .../__tests__/dropdown-callbacks.spec.tsx | 9 +- .../dataset-info/__tests__/index.spec.tsx | 17 +- .../app-sidebar/dataset-info/dropdown.tsx | 12 +- .../__tests__/index.spec.tsx | 39 +- .../hooks/__tests__/use-dsl-import.spec.tsx | 33 +- .../hooks/use-dsl-import.ts | 19 +- .../__tests__/built-in-pipeline-list.spec.tsx | 12 +- .../list/__tests__/customized-list.spec.tsx | 12 +- .../list/built-in-pipeline-list.tsx | 14 +- .../list/customized-list.tsx | 11 +- .../__tests__/edit-pipeline-info.spec.tsx | 87 ++-- .../__tests__/focus.browser.spec.tsx | 19 +- .../template-card/__tests__/index.spec.tsx | 66 +-- .../details/__tests__/index.spec.tsx | 16 +- .../list/template-card/details/index.tsx | 15 +- .../list/template-card/edit-pipeline-info.tsx | 40 +- .../list/template-card/index.tsx | 71 +-- .../__tests__/index.spec.tsx | 11 +- .../website-crawl/__tests__/index.spec.tsx | 32 +- .../data-source/website-crawl/index.tsx | 26 +- .../__tests__/use-datasource-actions.spec.ts | 26 +- .../hooks/use-datasource-actions.ts | 58 ++- .../documents/create-from-pipeline/index.tsx | 12 +- .../online-document-preview.spec.tsx | 26 +- .../preview/online-document-preview.tsx | 27 +- .../process-documents/__tests__/hooks.spec.ts | 18 +- .../process-documents/hooks.ts | 16 +- .../__tests__/index.spec.tsx | 2 +- .../processing/embedding-process/index.tsx | 7 +- .../steps/__tests__/preview-panel.spec.tsx | 12 +- .../steps/__tests__/step-one-content.spec.tsx | 27 -- .../__tests__/index.spec.tsx | 59 +-- .../settings/pipeline-settings/index.tsx | 62 ++- .../process-documents/__tests__/hooks.spec.ts | 17 +- .../__tests__/index.spec.tsx | 15 +- .../process-documents/hooks.ts | 16 +- .../__tests__/use-dataset-card-state.spec.ts | 9 +- .../hooks/use-dataset-card-state.ts | 9 +- .../components/__tests__/conversion.spec.tsx | 17 +- .../__tests__/update-dsl-modal.spec.tsx | 13 +- .../rag-pipeline/components/conversion.tsx | 35 +- .../panel/input-field/editor/utils.ts | 4 +- .../preview/__tests__/data-source.spec.tsx | 18 +- .../preview/__tests__/index.spec.tsx | 35 +- .../__tests__/process-documents.spec.tsx | 18 +- .../panel/input-field/preview/data-source.tsx | 18 +- .../input-field/preview/process-documents.tsx | 16 +- .../__tests__/hooks.spec.ts | 18 +- .../__tests__/index.spec.tsx | 15 +- .../preparation/document-processing/hooks.ts | 14 +- .../publisher/__tests__/index.spec.tsx | 49 ++- .../publisher/__tests__/popup.spec.tsx | 21 +- .../rag-pipeline-header/publisher/index.tsx | 26 +- .../rag-pipeline-header/publisher/popup.tsx | 17 +- .../__tests__/use-update-dsl-modal.spec.ts | 21 +- .../hooks/use-update-dsl-modal.ts | 28 +- .../utils/__tests__/query-select.spec.ts | 185 ++++++++ .../rag-pipeline/utils/query-select.ts | 231 ++++++++++ .../use-before-run-form.branches.spec.tsx | 63 +-- .../__tests__/use-before-run-form.spec.tsx | 41 +- .../data-source/hooks/use-before-run-form.ts | 22 +- .../__tests__/hooks.spec.tsx | 26 +- .../workflow/plugin-dependency/hooks.ts | 13 +- web/models/pipeline.ts | 214 +-------- web/service/__tests__/use-pipeline.spec.tsx | 79 ---- web/service/console/query-policies.ts | 31 ++ web/service/use-pipeline.ts | 416 ------------------ 75 files changed, 1932 insertions(+), 1360 deletions(-) create mode 100644 web/app/components/rag-pipeline/utils/__tests__/query-select.spec.ts create mode 100644 web/app/components/rag-pipeline/utils/query-select.ts delete mode 100644 web/service/__tests__/use-pipeline.spec.tsx delete mode 100644 web/service/use-pipeline.ts diff --git a/api/controllers/common/schema.py b/api/controllers/common/schema.py index 1ad9f043427303..726b35d663d968 100644 --- a/api/controllers/common/schema.py +++ b/api/controllers/common/schema.py @@ -71,6 +71,7 @@ class SchemaNameCollisionError(ValueError): "core.plugin.entities.bundle.PluginBundleDependency.Github": "PluginBundleDependencyGithub", "core.plugin.entities.bundle.PluginBundleDependency.Marketplace": "PluginBundleDependencyMarketplace", "core.plugin.entities.bundle.PluginBundleDependency.Package": "PluginBundleDependencyPackage", + "core.app.entities.task_entities.WorkflowAppBlockingResponse.Data": "WorkflowAppBlockingResponseData", } _REF_ID_SUFFIX = re.compile(r":\d+(?=$|[\[\],])") diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py index 96813c213130b8..29ae07485d138a 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -26,6 +26,7 @@ RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE, DefaultBlockConfigResponse, DefaultBlockConfigsResponse, + PipelineVariableResponse, WorkflowPaginationResponse, WorkflowResponse, WorkflowResponseSource, @@ -33,6 +34,7 @@ from controllers.console.app.wraps import with_session from controllers.console.datasets.error import PipelineNotFoundError from controllers.console.datasets.wraps import get_rag_pipeline, load_rag_pipeline +from controllers.console.workspace.tool_providers import ToolProviderApiEntityResponse from controllers.console.wraps import ( RBACPermission, account_initialization_required, @@ -47,6 +49,8 @@ from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.apps.pipeline.pipeline_generator import PipelineGenerator from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.entities.task_entities import WorkflowAppBlockingResponse +from core.entities.knowledge_entities import PipelineGenerateResponse from core.plugin.entities.plugin_daemon import PluginDatasourceProviderEntity from extensions.ext_application_services import application_services from extensions.ext_database import db @@ -164,7 +168,23 @@ class RagPipelineDatasourceListResponse(RootModel[list[RagPipelineDatasourceProv class RagPipelineStepParametersResponse(ResponseModel): - variables: Any + variables: list[PipelineVariableResponse] + + +class RagPipelinePublishedRunResponse(RootModel[PipelineGenerateResponse | WorkflowAppBlockingResponse]): + """Blocking run result: a preview run returns the workflow result, a processing run the queued batch.""" + + +class RagPipelineTransformResponse(ResponseModel): + pipeline_id: str + dataset_id: str + status: str + + +class RagPipelineRecommendedPluginsResponse(ResponseModel): + installed_recommended_plugins: list[ToolProviderApiEntityResponse] + # Marketplace plugin manifests, proxied as the marketplace returns them. + uninstalled_recommended_plugins: list[dict[str, Any]] register_schema_models( @@ -190,7 +210,10 @@ class RagPipelineStepParametersResponse(ResponseModel): RagPipelineDatasourceListResponse, RagPipelineDatasourceProviderResponse, RagPipelineOpaqueResponse, + RagPipelinePublishedRunResponse, + RagPipelineRecommendedPluginsResponse, RagPipelineStepParametersResponse, + RagPipelineTransformResponse, RagPipelineWorkflowPublishResponse, RagPipelineWorkflowSyncResponse, SimpleResultResponse, @@ -413,7 +436,7 @@ def post( class PublishedRagPipelineRunApi(Resource): @error_responses(InvokeRateLimitHttpError, PipelineNotFoundError) @console_ns.expect(console_ns.models[PublishedWorkflowRunPayload.__name__]) - @console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__]) + @console_ns.response(200, "Success", console_ns.models[RagPipelinePublishedRunResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -864,9 +887,7 @@ def get(self, req_data: NodeIdQuery, pipeline: Pipeline): node_id = req_data.node_id rag_pipeline_service = RagPipelineService(db.session()) variables = rag_pipeline_service.get_second_step_parameters(pipeline=pipeline, node_id=node_id, is_draft=False) - return { - "variables": variables, - } + return dump_response(RagPipelineStepParametersResponse, {"variables": variables}, exclude_unset=True) @console_ns.route("/rag/pipelines//workflows/published/pre-processing/parameters") @@ -887,9 +908,7 @@ def get(self, req_data: NodeIdQuery, pipeline: Pipeline): node_id = req_data.node_id rag_pipeline_service = RagPipelineService(db.session()) variables = rag_pipeline_service.get_first_step_parameters(pipeline=pipeline, node_id=node_id, is_draft=False) - return { - "variables": variables, - } + return dump_response(RagPipelineStepParametersResponse, {"variables": variables}, exclude_unset=True) @console_ns.route("/rag/pipelines//workflows/draft/pre-processing/parameters") @@ -910,9 +929,7 @@ def get(self, req_data: NodeIdQuery, pipeline: Pipeline): node_id = req_data.node_id rag_pipeline_service = RagPipelineService(db.session()) variables = rag_pipeline_service.get_first_step_parameters(pipeline=pipeline, node_id=node_id, is_draft=True) - return { - "variables": variables, - } + return dump_response(RagPipelineStepParametersResponse, {"variables": variables}, exclude_unset=True) @console_ns.route("/rag/pipelines//workflows/draft/processing/parameters") @@ -934,9 +951,7 @@ def get(self, req_data: NodeIdQuery, pipeline: Pipeline): rag_pipeline_service = RagPipelineService(db.session()) variables = rag_pipeline_service.get_second_step_parameters(pipeline=pipeline, node_id=node_id, is_draft=True) - return { - "variables": variables, - } + return dump_response(RagPipelineStepParametersResponse, {"variables": variables}, exclude_unset=True) @console_ns.route("/rag/pipelines//workflow-runs") @@ -1078,7 +1093,7 @@ def get(self, pipeline: Pipeline, node_id: str): @console_ns.route("/rag/pipelines/transform/datasets/") class RagPipelineTransformApi(Resource): - @console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__]) + @console_ns.response(200, "Success", console_ns.models[RagPipelineTransformResponse.__name__]) @console_ns.response(404, "Dataset or pipeline not found") @setup_required @login_required @@ -1101,7 +1116,10 @@ def post(self, session: Session, current_tenant_id: str, current_user: Account, raise Forbidden(str(exc)) from exc try: - return RagPipelineTransformService().transform_dataset(dataset, current_user.id, session) + return dump_response( + RagPipelineTransformResponse, + RagPipelineTransformService().transform_dataset(dataset, current_user.id, session), + ) except RagPipelineResourceNotFoundError as exc: raise NotFound(str(exc)) from exc @@ -1142,7 +1160,7 @@ def post(self, req_data: DatasourceVariablesPayload, current_user: Account, pipe @console_ns.route("/rag/pipelines/recommended-plugins") class RagPipelineRecommendedPluginApi(Resource): @console_ns.doc(params=query_params_from_model(RagPipelineRecommendedPluginQuery)) - @console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__]) + @console_ns.response(200, "Success", console_ns.models[RagPipelineRecommendedPluginsResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -1155,4 +1173,4 @@ def get(self, req_data: RagPipelineRecommendedPluginQuery, current_tenant_id: st recommended_plugins = rag_pipeline_service.get_recommended_plugins( req_data.type, current_user, current_tenant_id ) - return recommended_plugins + return dump_response(RagPipelineRecommendedPluginsResponse, recommended_plugins, exclude_unset=True) diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py index 6f5b00a0153673..5d1705d3696b73 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py @@ -236,7 +236,27 @@ def test_draft_rag_pipeline_second_step_parameters_serializes_variables(database def test_rag_pipeline_recommended_plugins_serializes_known_envelope(database_app: Flask) -> None: recommended_plugins = { - "installed_recommended_plugins": [{"name": "Dify Extractor", "meta": {"version": "1.0.0"}}], + "installed_recommended_plugins": [ + { + "id": "langgenius/dify_extractor/dify_extractor", + "author": "langgenius", + "name": "langgenius/dify_extractor/dify_extractor", + "description": { + "en_US": "Extract text", + "zh_Hans": "Extract text", + "pt_BR": "Extract text", + "ja_JP": "Extract text", + }, + "icon": "/icon.svg", + "label": { + "en_US": "Dify Extractor", + "zh_Hans": "Dify Extractor", + "pt_BR": "Dify Extractor", + "ja_JP": "Dify Extractor", + }, + "type": "builtin", + } + ], "uninstalled_recommended_plugins": [{"plugin_id": "langgenius/notion_datasource"}], } api = module.RagPipelineRecommendedPluginApi() diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow_apis.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow_apis.py index 2fa671462c04aa..02c162e39afaad 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow_apis.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow_apis.py @@ -550,7 +550,27 @@ def test_recommended_plugins(self, app: Flask) -> None: service = MagicMock() recommended_plugins = { - "installed_recommended_plugins": [{"id": "p1"}], + "installed_recommended_plugins": [ + { + "id": "p1", + "author": "langgenius", + "name": "p1", + "description": { + "en_US": "Plugin one", + "zh_Hans": "Plugin one", + "pt_BR": "Plugin one", + "ja_JP": "Plugin one", + }, + "icon": "/icon.svg", + "label": { + "en_US": "Plugin one", + "zh_Hans": "Plugin one", + "pt_BR": "Plugin one", + "ja_JP": "Plugin one", + }, + "type": "builtin", + } + ], "uninstalled_recommended_plugins": [{"id": "p2"}], } service.get_recommended_plugins.return_value = recommended_plugins diff --git a/api/tests/unit_tests/controllers/test_swagger.py b/api/tests/unit_tests/controllers/test_swagger.py index f1bf2efdfd4b3f..6cc6496dbcddb2 100644 --- a/api/tests/unit_tests/controllers/test_swagger.py +++ b/api/tests/unit_tests/controllers/test_swagger.py @@ -890,7 +890,7 @@ def test_console_datasource_catalog_exports_domain_response_schema(exported_cons ] recommended = payload["paths"]["/rag/pipelines/recommended-plugins"]["get"] assert recommended["responses"]["200"]["content"]["application/json"]["schema"] == { - "$ref": "#/components/schemas/RagPipelineOpaqueResponse" + "$ref": "#/components/schemas/RagPipelineRecommendedPluginsResponse" } diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 93f92cac05325b..b5b378dc499066 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -3978,7 +3978,7 @@ }, "web/models/pipeline.ts": { "typescript/no-explicit-any": { - "count": 6 + "count": 2 } }, "web/models/share.ts": { @@ -4132,11 +4132,6 @@ "count": 1 } }, - "web/service/use-pipeline.ts": { - "@tanstack/query/prefer-query-options": { - "count": 8 - } - }, "web/service/use-plugins-auth.ts": { "@tanstack/query/prefer-query-options": { "count": 3 diff --git a/packages/contracts/generated/api/console/rag/types.gen.ts b/packages/contracts/generated/api/console/rag/types.gen.ts index 5cb1b46195aaac..5f1c737dcb5c7e 100644 --- a/packages/contracts/generated/api/console/rag/types.gen.ts +++ b/packages/contracts/generated/api/console/rag/types.gen.ts @@ -127,7 +127,18 @@ export type PipelineNotFoundError = ErrorResponse & { status: 404 } -export type RagPipelineOpaqueResponse = unknown +export type RagPipelineRecommendedPluginsResponse = { + installed_recommended_plugins: Array + uninstalled_recommended_plugins: Array<{ + [key: string]: unknown + }> +} + +export type RagPipelineTransformResponse = { + dataset_id: string + pipeline_id: string + status: string +} export type WorkflowRunPaginationResponse = { data: Array @@ -243,6 +254,8 @@ export type DatasourceNodeRunPayload = { } } +export type RagPipelineOpaqueResponse = unknown + export type DatasourceVariablesPayload = { datasource_info: { [key: string]: unknown @@ -299,7 +312,7 @@ export type WorkflowDraftVariableListResponse = { } export type RagPipelineStepParametersResponse = { - variables: unknown + variables: Array } export type RagPipelineDraftWorkflowRunPayload = { @@ -369,6 +382,8 @@ export type PublishedWorkflowRunPayload = { start_node_id: string } +export type RagPipelinePublishedRunResponse = PipelineGenerateResponse | WorkflowAppBlockingResponse + export type WorkflowUpdatePayload = { marked_comment?: string | null marked_name?: string | null @@ -453,6 +468,48 @@ export type PluginDependency = { value: PluginDependencyGithub | PluginDependencyMarketplace | PluginDependencyPackage } +export type ToolProviderApiEntityResponse = { + allow_delete?: boolean + authentication?: McpAuthentication | null + author: string + configuration?: McpConfiguration | null + description: I18nObject + icon: + | string + | { + [key: string]: string + } + icon_dark?: + | string + | { + [key: string]: string + } + id: string + identity_mode?: string + is_dynamic_registration?: boolean + is_team_authorization?: boolean + label: I18nObject + labels?: Array + masked_headers?: { + [key: string]: string + } | null + name: string + original_headers?: { + [key: string]: string + } | null + plugin_id?: string | null + plugin_unique_identifier?: string | null + server_identifier?: string | null + server_url?: string | null + team_credentials?: { + [key: string]: unknown + } + tools?: Array + type: ToolProviderType + updated_at?: number + workflow_app_id?: string | null +} + export type WorkflowRunForListResponse = { created_at?: number | null created_by_account?: SimpleAccountResponse | null @@ -556,6 +613,18 @@ export type RecursiveJsonValue = | Array | null +export type PipelineGenerateResponse = { + batch: string + dataset: PipelineDataset + documents: Array +} + +export type WorkflowAppBlockingResponse = { + data: WorkflowAppBlockingResponseData + task_id: string + workflow_run_id: string +} + export type DatasetRerankingModelResponse = { reranking_model_name?: string | null reranking_provider_name?: string | null @@ -594,6 +663,79 @@ export type PluginDependencyPackage = { version?: string | null } +export type McpAuthentication = { + client_id: string + client_secret?: string | null +} + +export type McpConfiguration = { + sse_read_timeout?: number + timeout?: number +} + +export type I18nObject = { + en_US: string + ja_JP?: string | null + pt_BR?: string | null + zh_Hans?: string | null +} + +export type ToolApiEntity = { + author: string + description: I18nObject + label: I18nObject + labels?: Array + name: string + output_schema?: { + [key: string]: unknown + } + parameters?: Array | null +} + +export type ToolProviderType = + | 'api' + | 'app' + | 'builtin' + | 'dataset-retrieval' + | 'mcp' + | 'plugin' + | 'workflow' + +export type PipelineDataset = { + chunk_structure: string + description?: string + id: string + name: string +} + +export type PipelineDocument = { + data_source_info?: { + [key: string]: unknown + } | null + data_source_type: string + enabled: boolean + error?: string | null + id: string + indexing_status: string + name: string + position: number +} + +export type WorkflowAppBlockingResponseData = { + created_at: number + elapsed_time: number + error?: string | null + finished_at: number | null + id: string + outputs?: { + [key: string]: unknown + } | null + status: WorkflowExecutionStatus + total_steps: number + total_tokens: number + workflow_id: string +} + export type DatasetKeywordSettingResponse = { keyword_weight?: number | null } @@ -647,13 +789,47 @@ export type DatasourceProviderType = | 'online_drive' | 'website_crawl' -export type I18nObject = { - en_US: string - ja_JP?: string | null - pt_BR?: string | null - zh_Hans?: string | null +export type ToolParameter = { + auto_generate?: PluginParameterAutoGenerate | null + default?: + | number + | number + | string + | boolean + | Array + | { + [key: string]: unknown + } + | null + form: ToolParameterForm + human_description?: I18nObject | null + input_schema?: { + [key: string]: unknown + } | null + label: I18nObject + llm_description?: string | null + max?: number | number | null + min?: number | number | null + multiple?: boolean + name: string + options?: Array + placeholder?: I18nObject | null + precision?: number | null + required?: boolean + scope?: string | null + template?: PluginParameterTemplate | null + type: ToolParameterType } +export type WorkflowExecutionStatus = + | 'failed' + | 'partial-succeeded' + | 'paused' + | 'running' + | 'scheduled' + | 'stopped' + | 'succeeded' + export type Option = { label: I18nObject value: string @@ -738,6 +914,8 @@ export type PluginParameterAutoGenerate = { type: PluginParameterAutoGenerateType } +export type ToolParameterForm = 'form' | 'llm' | 'schema' + export type PluginParameterOption = { icon?: string | null label: I18nObject @@ -748,6 +926,25 @@ export type PluginParameterTemplate = { enabled?: boolean } +export type ToolParameterType = + | 'any' + | 'app-selector' + | 'array' + | 'boolean' + | 'checkbox' + | 'date' + | 'date-range' + | 'dynamic-select' + | 'file' + | 'files' + | 'model-selector' + | 'number' + | 'object' + | 'secret-input' + | 'select' + | 'string' + | 'system-files' + export type DatasourceParameterType = | 'boolean' | 'file' @@ -1032,7 +1229,7 @@ export type GetRagPipelinesRecommendedPluginsError = GetRagPipelinesRecommendedPluginsErrors[keyof GetRagPipelinesRecommendedPluginsErrors] export type GetRagPipelinesRecommendedPluginsResponses = { - 200: RagPipelineOpaqueResponse + 200: RagPipelineRecommendedPluginsResponse } export type GetRagPipelinesRecommendedPluginsResponse = @@ -1056,7 +1253,7 @@ export type PostRagPipelinesTransformDatasetsByDatasetIdError = PostRagPipelinesTransformDatasetsByDatasetIdErrors[keyof PostRagPipelinesTransformDatasetsByDatasetIdErrors] export type PostRagPipelinesTransformDatasetsByDatasetIdResponses = { - 200: RagPipelineOpaqueResponse + 200: RagPipelineTransformResponse } export type PostRagPipelinesTransformDatasetsByDatasetIdResponse = @@ -1992,7 +2189,7 @@ export type PostRagPipelinesByPipelineIdWorkflowsPublishedRunError = PostRagPipelinesByPipelineIdWorkflowsPublishedRunErrors[keyof PostRagPipelinesByPipelineIdWorkflowsPublishedRunErrors] export type PostRagPipelinesByPipelineIdWorkflowsPublishedRunResponses = { - 200: RagPipelineOpaqueResponse + 200: RagPipelinePublishedRunResponse } export type PostRagPipelinesByPipelineIdWorkflowsPublishedRunResponse = diff --git a/packages/contracts/generated/api/console/rag/zod.gen.ts b/packages/contracts/generated/api/console/rag/zod.gen.ts index 6b6e56ea9018f6..f759c04d0cb28b 100644 --- a/packages/contracts/generated/api/console/rag/zod.gen.ts +++ b/packages/contracts/generated/api/console/rag/zod.gen.ts @@ -95,9 +95,13 @@ export const zPipelineNotFoundError = zErrorResponse.and( ) /** - * RagPipelineOpaqueResponse + * RagPipelineTransformResponse */ -export const zRagPipelineOpaqueResponse = z.unknown() +export const zRagPipelineTransformResponse = z.object({ + dataset_id: z.string(), + pipeline_id: z.string(), + status: z.string(), +}) /** * SimpleResultResponse @@ -170,6 +174,11 @@ export const zDatasourceNodeRunPayload = z.object({ inputs: z.record(z.string(), z.unknown()), }) +/** + * RagPipelineOpaqueResponse + */ +export const zRagPipelineOpaqueResponse = z.unknown() + /** * DatasourceVariablesPayload */ @@ -194,13 +203,6 @@ export const zNodeRunRequiredPayload = z.object({ inputs: z.record(z.string(), z.unknown()), }) -/** - * RagPipelineStepParametersResponse - */ -export const zRagPipelineStepParametersResponse = z.object({ - variables: z.unknown(), -}) - /** * RagPipelineDraftWorkflowRunPayload */ @@ -542,6 +544,13 @@ export const zWorkflowPaginationResponse = z.object({ page: z.int(), }) +/** + * RagPipelineStepParametersResponse + */ +export const zRagPipelineStepParametersResponse = z.object({ + variables: z.array(zPipelineVariableResponse), +}) + /** * EnvironmentVariableItemResponse */ @@ -691,6 +700,82 @@ export const zRagPipelineImportCheckDependenciesResponse = z.object({ leaked_dependencies: z.array(zPluginDependency).optional(), }) +/** + * MCPAuthentication + */ +export const zMcpAuthentication = z.object({ + client_id: z.string(), + client_secret: z.string().nullish(), +}) + +/** + * MCPConfiguration + */ +export const zMcpConfiguration = z.object({ + sse_read_timeout: z.number().optional().default(300), + timeout: z.number().optional().default(30), +}) + +/** + * I18nObject + * + * Model class for i18n object. + */ +export const zI18nObject = z.object({ + en_US: z.string(), + ja_JP: z.string().nullish(), + pt_BR: z.string().nullish(), + zh_Hans: z.string().nullish(), +}) + +/** + * ToolProviderType + * + * Enum class for tool provider + */ +export const zToolProviderType = z.enum([ + 'api', + 'app', + 'builtin', + 'dataset-retrieval', + 'mcp', + 'plugin', + 'workflow', +]) + +/** + * PipelineDataset + */ +export const zPipelineDataset = z.object({ + chunk_structure: z.string(), + description: z.string().optional().default(''), + id: z.string(), + name: z.string(), +}) + +/** + * PipelineDocument + */ +export const zPipelineDocument = z.object({ + data_source_info: z.record(z.string(), z.unknown()).nullish(), + data_source_type: z.string(), + enabled: z.boolean(), + error: z.string().nullish(), + id: z.string(), + indexing_status: z.string(), + name: z.string(), + position: z.int(), +}) + +/** + * PipelineGenerateResponse + */ +export const zPipelineGenerateResponse = z.object({ + batch: z.string(), + dataset: zPipelineDataset, + documents: z.array(zPipelineDocument), +}) + /** * DatasetKeywordSettingResponse */ @@ -786,17 +871,57 @@ export const zDatasourceProviderType = z.enum([ ]) /** - * I18nObject + * WorkflowExecutionStatus + */ +export const zWorkflowExecutionStatus = z.enum([ + 'failed', + 'partial-succeeded', + 'paused', + 'running', + 'scheduled', + 'stopped', + 'succeeded', +]) + +/** + * Data * - * Model class for i18n object. + * Data entity */ -export const zI18nObject = z.object({ - en_US: z.string(), - ja_JP: z.string().nullish(), - pt_BR: z.string().nullish(), - zh_Hans: z.string().nullish(), +export const zWorkflowAppBlockingResponseData = z.object({ + created_at: z.int(), + elapsed_time: z.number(), + error: z.string().nullish(), + finished_at: z.int().nullable(), + id: z.string(), + outputs: z.record(z.string(), z.unknown()).nullish(), + status: zWorkflowExecutionStatus, + total_steps: z.int(), + total_tokens: z.int(), + workflow_id: z.string(), }) +/** + * WorkflowAppBlockingResponse + * + * WorkflowAppBlockingResponse entity + */ +export const zWorkflowAppBlockingResponse = z.object({ + data: zWorkflowAppBlockingResponseData, + task_id: z.string(), + workflow_run_id: z.string(), +}) + +/** + * RagPipelinePublishedRunResponse + * + * Blocking run result: a preview run returns the workflow result, a processing run the queued batch. + */ +export const zRagPipelinePublishedRunResponse = z.union([ + zPipelineGenerateResponse, + zWorkflowAppBlockingResponse, +]) + /** * Option */ @@ -916,6 +1041,11 @@ export const zDatasourceProviderIdentity = z.object({ tags: z.array(zToolLabelEnum).nullish().default([]), }) +/** + * ToolParameterForm + */ +export const zToolParameterForm = z.enum(['form', 'llm', 'schema']) + /** * PluginParameterOption */ @@ -932,6 +1062,31 @@ export const zPluginParameterTemplate = z.object({ enabled: z.boolean().optional().default(false), }) +/** + * ToolParameterType + * + * removes TOOLS_SELECTOR from PluginParameterType + */ +export const zToolParameterType = z.enum([ + 'any', + 'app-selector', + 'array', + 'boolean', + 'checkbox', + 'date', + 'date-range', + 'dynamic-select', + 'file', + 'files', + 'model-selector', + 'number', + 'object', + 'secret-input', + 'select', + 'string', + 'system-files', +]) + /** * DatasourceParameterType * @@ -960,6 +1115,96 @@ export const zPluginParameterAutoGenerate = z.object({ type: zPluginParameterAutoGenerateType, }) +/** + * ToolParameter + * + * Tool-specific parameter declaration and invocation-value normalization. + */ +export const zToolParameter = z.object({ + auto_generate: zPluginParameterAutoGenerate.nullish(), + default: z + .union([ + z.number(), + z.int(), + z.string(), + z.boolean(), + z.array(z.unknown()), + z.record(z.string(), z.unknown()), + ]) + .nullish(), + form: zToolParameterForm, + human_description: zI18nObject.nullish(), + input_schema: z.record(z.string(), z.unknown()).nullish(), + label: zI18nObject, + llm_description: z.string().nullish(), + max: z.union([z.number(), z.int()]).nullish(), + min: z.union([z.number(), z.int()]).nullish(), + multiple: z.boolean().optional().default(false), + name: z.string(), + options: z.array(zPluginParameterOption).optional(), + placeholder: zI18nObject.nullish(), + precision: z.int().nullish(), + required: z.boolean().optional().default(false), + scope: z.string().nullish(), + template: zPluginParameterTemplate.nullish(), + type: zToolParameterType, +}) + +/** + * ToolApiEntity + */ +export const zToolApiEntity = z.object({ + author: z.string(), + description: zI18nObject, + label: zI18nObject, + labels: z.array(z.string()).optional(), + name: z.string(), + output_schema: z.record(z.string(), z.unknown()).optional(), + parameters: z.array(zToolParameter).nullish(), +}) + +/** + * ToolProviderApiEntityResponse + */ +export const zToolProviderApiEntityResponse = z.object({ + allow_delete: z.boolean().optional().default(true), + authentication: zMcpAuthentication.nullish(), + author: z.string(), + configuration: zMcpConfiguration.nullish(), + description: zI18nObject, + icon: z.union([z.string(), z.record(z.string(), z.string())]), + icon_dark: z + .union([z.string(), z.record(z.string(), z.string())]) + .optional() + .default(''), + id: z.string(), + identity_mode: z.string().optional().default('off'), + is_dynamic_registration: z.boolean().optional().default(true), + is_team_authorization: z.boolean().optional().default(false), + label: zI18nObject, + labels: z.array(z.string()).optional(), + masked_headers: z.record(z.string(), z.string()).nullish(), + name: z.string(), + original_headers: z.record(z.string(), z.string()).nullish(), + plugin_id: z.string().nullish().default(''), + plugin_unique_identifier: z.string().nullish().default(''), + server_identifier: z.string().nullish().default(''), + server_url: z.string().nullish().default(''), + team_credentials: z.record(z.string(), z.unknown()).optional(), + tools: z.array(zToolApiEntity).optional(), + type: zToolProviderType, + updated_at: z.int().optional(), + workflow_app_id: z.string().nullish(), +}) + +/** + * RagPipelineRecommendedPluginsResponse + */ +export const zRagPipelineRecommendedPluginsResponse = z.object({ + installed_recommended_plugins: z.array(zToolProviderApiEntityResponse), + uninstalled_recommended_plugins: z.array(z.record(z.string(), z.unknown())), +}) + /** * DatasourceParameter * @@ -1131,7 +1376,7 @@ export const zGetRagPipelinesRecommendedPluginsQuery = z.object({ /** * Success */ -export const zGetRagPipelinesRecommendedPluginsResponse = zRagPipelineOpaqueResponse +export const zGetRagPipelinesRecommendedPluginsResponse = zRagPipelineRecommendedPluginsResponse export const zPostRagPipelinesTransformDatasetsByDatasetIdPath = z.object({ dataset_id: z.uuid(), @@ -1140,7 +1385,7 @@ export const zPostRagPipelinesTransformDatasetsByDatasetIdPath = z.object({ /** * Success */ -export const zPostRagPipelinesTransformDatasetsByDatasetIdResponse = zRagPipelineOpaqueResponse +export const zPostRagPipelinesTransformDatasetsByDatasetIdResponse = zRagPipelineTransformResponse export const zPostRagPipelinesByPipelineIdCustomizedPublishBody = zCustomizedPipelineTemplatePayload @@ -1589,7 +1834,8 @@ export const zPostRagPipelinesByPipelineIdWorkflowsPublishedRunPath = z.object({ /** * Success */ -export const zPostRagPipelinesByPipelineIdWorkflowsPublishedRunResponse = zRagPipelineOpaqueResponse +export const zPostRagPipelinesByPipelineIdWorkflowsPublishedRunResponse = + zRagPipelinePublishedRunResponse export const zDeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdPath = z.object({ pipeline_id: z.uuid(), diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx index 9bfaca9519ab34..18e5b8ce26a427 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx @@ -121,9 +121,12 @@ vi.mock('@/service/use-base', () => ({ useInvalid: () => mockInvalidDatasetDetail, })) -vi.mock('@/service/use-pipeline', () => ({ - useExportPipelineDSL: () => ({ mutateAsync: mockExportPipeline }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.exports.get': () => ({ mutateAsync: mockExportPipeline }), + }) +}) vi.mock('@/service/datasets', () => ({ checkIsUsedInApp: (...args: unknown[]) => mockCheckIsUsedInApp(...args), diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx index e02eece79f7f27..de3e9fa948ea66 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx @@ -130,11 +130,14 @@ vi.mock('@/service/use-base', () => ({ useInvalid: () => mockInvalidDatasetDetail, })) -vi.mock('@/service/use-pipeline', () => ({ - useExportPipelineDSL: () => ({ - mutateAsync: mockExportPipeline, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.exports.get': () => ({ + mutateAsync: mockExportPipeline, + }), + }) +}) vi.mock('@/service/datasets', () => ({ checkIsUsedInApp: (...args: unknown[]) => mockCheckIsUsedInApp(...args), @@ -326,8 +329,8 @@ describe('Dropdown', () => { // Assert await waitFor(() => { expect(mockExportPipeline).toHaveBeenCalledWith({ - pipelineId: 'pipeline-1', - include: false, + params: { pipeline_id: 'pipeline-1' }, + query: { include_secret: 'false' }, }) }) expect(createObjectURLSpy).toHaveBeenCalledTimes(1) diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index 2511a490422c7d..a496212f9c2678 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -17,7 +17,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { IconButton } from '@langgenius/dify-ui/icon-button' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import * as React from 'react' import { useCallback, useState } from 'react' @@ -28,10 +28,10 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useRouter } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { checkIsUsedInApp, deleteDataset } from '@/service/datasets' import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/knowledge/use-dataset' import { useInvalid } from '@/service/use-base' -import { useExportPipelineDSL } from '@/service/use-pipeline' import { downloadBlob } from '@/utils/download' import { getErrorMessage } from '@/utils/error-message' import { getDatasetACLCapabilities } from '@/utils/permission' @@ -93,7 +93,9 @@ const DropDown = ({ expand, triggerClassName }: DropDownProps) => { }) }, []) - const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL() + const { mutateAsync: exportPipelineConfig } = useMutation( + consoleQuery.rag.pipelines.byPipelineId.exports.get.mutationOptions(), + ) const handleExportPipeline = useCallback( async (include = false) => { @@ -101,8 +103,8 @@ const DropDown = ({ expand, triggerClassName }: DropDownProps) => { if (!pipeline_id) return try { const { data } = await exportPipelineConfig({ - pipelineId: pipeline_id, - include, + params: { pipeline_id }, + query: { include_secret: String(include) }, }) const file = new Blob([data], { type: 'application/yaml' }) downloadBlob({ data: file, fileName: `${name}.pipeline` }) diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx index 83574c22c920b8..03f13662611be9 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx @@ -15,14 +15,17 @@ vi.mock('@/next/navigation', () => ({ const mockImportDSL = vi.fn() const mockImportDSLConfirm = vi.fn() -vi.mock('@/service/use-pipeline', () => ({ - useImportPipelineDSL: () => ({ - mutateAsync: mockImportDSL, - }), - useImportPipelineDSLConfirm: () => ({ - mutateAsync: mockImportDSLConfirm, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.imports.post': () => ({ + mutateAsync: mockImportDSL, + }), + 'rag.pipelines.imports.byImportId.confirm.post': () => ({ + mutateAsync: mockImportDSLConfirm, + }), + }) +}) // Mock plugin dependencies hook const mockHandleCheckPluginDependencies = vi.fn() @@ -311,8 +314,10 @@ describe('CreateFromDSLModal', () => { await waitFor(() => { expect(mockImportDSL).toHaveBeenCalledWith({ - mode: 'yaml-url', - yaml_url: 'https://example.com/test.pipeline', + body: { + mode: 'yaml-url', + yaml_url: 'https://example.com/test.pipeline', + }, }) }) }) @@ -538,8 +543,10 @@ describe('CreateFromDSLModal', () => { await waitFor(() => { expect(mockImportDSL).toHaveBeenCalledWith({ - mode: 'yaml-url', - yaml_url: 'https://example.com/test.pipeline', + body: { + mode: 'yaml-url', + yaml_url: 'https://example.com/test.pipeline', + }, }) }) }) @@ -865,8 +872,10 @@ describe('CreateFromDSLModal', () => { // Verify API was called with file content await waitFor(() => { expect(mockImportDSL).toHaveBeenCalledWith({ - mode: 'yaml-content', - yaml_content: fileContent, + body: { + mode: 'yaml-content', + yaml_content: fileContent, + }, }) }) }) @@ -967,7 +976,7 @@ describe('CreateFromDSLModal', () => { // Verify confirm was called await waitFor(() => { - expect(mockImportDSLConfirm).toHaveBeenCalledWith('import-123') + expect(mockImportDSLConfirm).toHaveBeenCalledWith({ params: { import_id: 'import-123' } }) }) // Verify success handling diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx index 7c56bb112fc343..828ad7ec931331 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx @@ -15,14 +15,17 @@ vi.mock('@/next/navigation', () => ({ const mockImportDSL = vi.fn() const mockImportDSLConfirm = vi.fn() -vi.mock('@/service/use-pipeline', () => ({ - useImportPipelineDSL: () => ({ - mutateAsync: mockImportDSL, - }), - useImportPipelineDSLConfirm: () => ({ - mutateAsync: mockImportDSLConfirm, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.imports.post': () => ({ + mutateAsync: mockImportDSL, + }), + 'rag.pipelines.imports.byImportId.confirm.post': () => ({ + mutateAsync: mockImportDSLConfirm, + }), + }) +}) // Mock plugin dependencies hook const mockHandleCheckPluginDependencies = vi.fn() @@ -277,8 +280,10 @@ describe('useDSLImport', () => { await waitFor(() => { expect(mockImportDSL).toHaveBeenCalledWith({ - mode: 'yaml-url', - yaml_url: 'https://example.com/test.pipeline', + body: { + mode: 'yaml-url', + yaml_url: 'https://example.com/test.pipeline', + }, }) }) @@ -625,8 +630,10 @@ describe('useDSLImport', () => { await waitFor(() => { expect(mockImportDSL).toHaveBeenCalledWith({ - mode: 'yaml-content', - yaml_content: fileContent, + body: { + mode: 'yaml-content', + yaml_content: fileContent, + }, }) }) @@ -706,7 +713,7 @@ describe('useDSLImport', () => { }) await waitFor(() => { - expect(mockImportDSLConfirm).toHaveBeenCalledWith('import-123') + expect(mockImportDSLConfirm).toHaveBeenCalledWith({ params: { import_id: 'import-123' } }) expect(onSuccess).toHaveBeenCalled() expect(result.current.showConfirmModal).toBe(false) }) diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts index 7afbf30863042b..619b5951cb22e3 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts @@ -1,4 +1,5 @@ 'use client' +import { useMutation } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -6,7 +7,7 @@ import { usePluginDependencies } from '@/app/components/workflow/plugin-dependen import { toast } from '@/app/notifications' import { DSLImportMode, DSLImportStatus } from '@/models/app' import { useRouter } from '@/next/navigation' -import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '@/service/use-pipeline' +import { consoleQuery } from '@/service/console' import { getErrorMessage } from '@/utils/error-message' export const CreateFromDSLModalTab = { @@ -44,8 +45,12 @@ export const useDSLImport = ({ const [isConfirming, setIsConfirming] = useState(false) const { handleCheckPluginDependencies } = usePluginDependencies() const isCreatingRef = useRef(false) - const { mutateAsync: importDSL } = useImportPipelineDSL() - const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm() + const { mutateAsync: importDSL } = useMutation( + consoleQuery.rag.pipelines.imports.post.mutationOptions(), + ) + const { mutateAsync: importDSLConfirm } = useMutation( + consoleQuery.rag.pipelines.imports.byImportId.confirm.post.mutationOptions(), + ) const notifyError = useCallback( (message?: string) => { toast.error(message || t(($) => $['creation.errorTip'], { ns: 'datasetPipeline' })) @@ -77,14 +82,12 @@ export const useDSLImport = ({ let response if (currentTab === CreateFromDSLModalTab.FROM_FILE) { response = await importDSL({ - mode: DSLImportMode.YAML_CONTENT, - yaml_content: fileContent || '', + body: { mode: DSLImportMode.YAML_CONTENT, yaml_content: fileContent || '' }, }) } if (currentTab === CreateFromDSLModalTab.FROM_URL) { response = await importDSL({ - mode: DSLImportMode.YAML_URL, - yaml_url: dslUrlValue || '', + body: { mode: DSLImportMode.YAML_URL, yaml_url: dslUrlValue || '' }, }) } if (!response) { @@ -150,7 +153,7 @@ export const useDSLImport = ({ if (!importId) return setIsConfirming(true) try { - const response = await importDSLConfirm(importId) + const response = await importDSLConfirm({ params: { import_id: importId } }) if (!response) { notifyError() return diff --git a/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx index 442088e11df1fa..5e47e45fee23ee 100644 --- a/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx @@ -38,9 +38,15 @@ vi.mock('#i18n', async (importOriginal) => ({ })) const mockUsePipelineTemplateList = vi.fn() -vi.mock('@/service/use-pipeline', () => ({ - usePipelineTemplateList: (...args: unknown[]) => mockUsePipelineTemplateList(...args), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipeline.templates.get': (o: { + input: { query: { type: string; language?: string } } + enabled?: boolean + }) => mockUsePipelineTemplateList(o.input.query, o.enabled), + }) +}) // BuiltInPipelineList Component Tests diff --git a/web/app/components/datasets/create-from-pipeline/list/__tests__/customized-list.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/__tests__/customized-list.spec.tsx index 620b690a94c622..43f987d00749e9 100644 --- a/web/app/components/datasets/create-from-pipeline/list/__tests__/customized-list.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/__tests__/customized-list.spec.tsx @@ -13,9 +13,15 @@ vi.mock('../template-card', () => ({ // Mock usePipelineTemplateList hook const mockUsePipelineTemplateList = vi.fn() -vi.mock('@/service/use-pipeline', () => ({ - usePipelineTemplateList: (...args: unknown[]) => mockUsePipelineTemplateList(...args), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipeline.templates.get': (o: { + input: { query: { type: string; language?: string } } + enabled?: boolean + }) => mockUsePipelineTemplateList(o.input.query), + }) +}) // CustomizedList Component Tests diff --git a/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx b/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx index 1badbede64aa7b..f1cc9614ccb764 100644 --- a/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx @@ -1,9 +1,10 @@ -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { useLocale } from '#i18n' +import { selectPipelineTemplateList } from '@/app/components/rag-pipeline/utils/query-select' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { LanguagesSupported } from '@/i18n/language' -import { usePipelineTemplateList } from '@/service/use-pipeline' +import { consoleQuery } from '@/service/console' import CreateCard from './create-card' import TemplateCard from './template-card' @@ -17,9 +18,12 @@ const BuiltInPipelineList = () => { ...systemFeaturesQueryOptions(), select: (s) => s.enable_marketplace, }) - const { data: pipelineList, isLoading } = usePipelineTemplateList( - { type: 'built-in', language }, - enableMarketplace, + const { data: pipelineList, isLoading } = useQuery( + consoleQuery.rag.pipeline.templates.get.queryOptions({ + input: { query: { type: 'built-in', language } }, + enabled: enableMarketplace, + select: selectPipelineTemplateList, + }), ) const list = pipelineList?.pipeline_templates || [] diff --git a/web/app/components/datasets/create-from-pipeline/list/customized-list.tsx b/web/app/components/datasets/create-from-pipeline/list/customized-list.tsx index cbbe5e6de2e8a8..b5771754fada45 100644 --- a/web/app/components/datasets/create-from-pipeline/list/customized-list.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/customized-list.tsx @@ -1,10 +1,17 @@ +import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import { usePipelineTemplateList } from '@/service/use-pipeline' +import { selectPipelineTemplateList } from '@/app/components/rag-pipeline/utils/query-select' +import { consoleQuery } from '@/service/console' import TemplateCard from './template-card' const CustomizedList = () => { const { t } = useTranslation(['datasetPipeline']) - const { data: pipelineList, isLoading } = usePipelineTemplateList({ type: 'customized' }) + const { data: pipelineList, isLoading } = useQuery( + consoleQuery.rag.pipeline.templates.get.queryOptions({ + input: { query: { type: 'customized' } }, + select: selectPipelineTemplateList, + }), + ) const list = pipelineList?.pipeline_templates || [] if (isLoading || list.length === 0) return null diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx index af7a31ac8b5115..019af27353a7a2 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from 'react' import type { PipelineTemplate } from '@/models/pipeline' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { QueryClient } from '@tanstack/react-query' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' @@ -16,14 +17,15 @@ const render = (ui: ReactElement) => ) const mockUpdatePipeline = vi.fn() -const mockInvalidCustomizedTemplateList = vi.fn() -vi.mock('@/service/use-pipeline', () => ({ - useUpdateTemplateInfo: () => ({ - mutateAsync: mockUpdatePipeline, - }), - useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipeline.customized.templates.byTemplateId.patch': () => ({ + mutateAsync: mockUpdatePipeline, + }), + }) +}) const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn(), @@ -82,6 +84,7 @@ describe('EditPipelineInfo', () => { }) beforeEach(() => { + vi.spyOn(QueryClient.prototype, 'invalidateQueries') vi.clearAllMocks() mockToastError.mockReset() }) @@ -192,7 +195,9 @@ describe('EditPipelineInfo', () => { fireEvent.click(saveButton) await waitFor(() => { - expect(mockInvalidCustomizedTemplateList).toHaveBeenCalled() + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: expect.arrayContaining([['console', 'rag', 'pipeline', 'templates', 'get']]), + }) }) }) @@ -304,9 +309,11 @@ describe('EditPipelineInfo', () => { await waitFor(() => { expect(mockUpdatePipeline).toHaveBeenCalledWith( expect.objectContaining({ - icon_info: expect.objectContaining({ - icon_type: 'image', - icon: 'file-id-123', + body: expect.objectContaining({ + icon_info: expect.objectContaining({ + icon_type: 'image', + icon: 'file-id-123', + }), }), }), expect.any(Object), @@ -328,9 +335,11 @@ describe('EditPipelineInfo', () => { await waitFor(() => { expect(mockUpdatePipeline).toHaveBeenCalledWith( expect.objectContaining({ - icon_info: expect.objectContaining({ - icon_type: 'emoji', - icon: '📊', + body: expect.objectContaining({ + icon_info: expect.objectContaining({ + icon_type: 'emoji', + icon: '📊', + }), }), }), expect.any(Object), @@ -384,10 +393,12 @@ describe('EditPipelineInfo', () => { await waitFor(() => { expect(mockUpdatePipeline).toHaveBeenCalledWith( expect.objectContaining({ - icon_info: expect.objectContaining({ - icon_type: 'emoji', - icon: expect.any(String), - icon_background: '#F3FEE7', + body: expect.objectContaining({ + icon_info: expect.objectContaining({ + icon_type: 'emoji', + icon: expect.any(String), + icon_background: '#F3FEE7', + }), }), }), expect.any(Object), @@ -478,10 +489,12 @@ describe('EditPipelineInfo', () => { await waitFor(() => { expect(mockUpdatePipeline).toHaveBeenCalledWith( expect.objectContaining({ - icon_info: expect.objectContaining({ - icon_type: 'emoji', - icon: '📊', - icon_background: '#F3FEE7', + body: expect.objectContaining({ + icon_info: expect.objectContaining({ + icon_type: 'emoji', + icon: '📊', + icon_background: '#F3FEE7', + }), }), }), expect.any(Object), @@ -511,10 +524,12 @@ describe('EditPipelineInfo', () => { await waitFor(() => { expect(mockUpdatePipeline).toHaveBeenCalledWith( expect.objectContaining({ - icon_info: expect.objectContaining({ - icon_type: 'emoji', - icon: expect.any(String), - icon_background: '#F3FEE7', + body: expect.objectContaining({ + icon_info: expect.objectContaining({ + icon_type: 'emoji', + icon: expect.any(String), + icon_background: '#F3FEE7', + }), }), }), expect.any(Object), @@ -539,11 +554,13 @@ describe('EditPipelineInfo', () => { await waitFor(() => { expect(mockUpdatePipeline).toHaveBeenCalledWith( expect.objectContaining({ - template_id: 'pipeline-1', - name: 'Test Pipeline', - description: 'Test pipeline description', - icon_info: expect.objectContaining({ - icon_type: 'emoji', + params: { template_id: 'pipeline-1' }, + body: expect.objectContaining({ + name: 'Test Pipeline', + description: 'Test pipeline description', + icon_info: expect.objectContaining({ + icon_type: 'emoji', + }), }), }), expect.any(Object), @@ -569,9 +586,11 @@ describe('EditPipelineInfo', () => { await waitFor(() => { expect(mockUpdatePipeline).toHaveBeenCalledWith( expect.objectContaining({ - template_id: 'pipeline-2', - icon_info: expect.objectContaining({ - icon_type: 'image', + params: { template_id: 'pipeline-2' }, + body: expect.objectContaining({ + icon_info: expect.objectContaining({ + icon_type: 'image', + }), }), }), expect.any(Object), diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/focus.browser.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/focus.browser.spec.tsx index e82a8b59b6fbee..bb14c8b25bae6d 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/focus.browser.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/focus.browser.spec.tsx @@ -26,12 +26,19 @@ vi.mock('@/service/knowledge/use-create-dataset', () => ({ useCreatePipelineDatasetFromCustomized: () => ({ mutateAsync: vi.fn() }), })) vi.mock('@/service/knowledge/use-dataset', () => ({ useInvalidDatasetList: () => vi.fn() })) -vi.mock('@/service/use-pipeline', () => ({ - usePipelineTemplateById: () => useQuery(templateQueryOptions), - useDeleteTemplate: () => ({ mutateAsync: vi.fn() }), - useExportTemplateDSL: () => ({ mutateAsync: vi.fn(), isPending: false }), - useInvalidCustomizedTemplateList: () => vi.fn(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipeline.templates.byTemplateId.get': function useTemplateDetailQuery() { + return useQuery(templateQueryOptions) + }, + 'rag.pipeline.customized.templates.byTemplateId.delete': () => ({ mutateAsync: vi.fn() }), + 'rag.pipeline.customized.templates.byTemplateId.post': () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), + }) +}) vi.mock('@/app/components/workflow/workflow-preview', () => ({ default: () =>
Workflow preview
, diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx index 7a15e480d013e4..0e66b27bb4adf1 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx @@ -1,8 +1,10 @@ import type { PipelineTemplate } from '@/models/pipeline' import { DialogTrigger } from '@langgenius/dify-ui/dialog' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient } from '@tanstack/react-query' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { ChunkingMode } from '@/models/datasets' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import TemplateCard from '../index' const mockPush = vi.fn() @@ -104,7 +106,6 @@ const mockInvalidDatasetList = vi.fn() const mockGetPipelineTemplateInfo = vi.fn() const mockDeletePipeline = vi.fn() const mockExportPipelineDSL = vi.fn() -const mockInvalidCustomizedTemplateList = vi.fn() const mockHandleCheckPluginDependencies = vi.fn() // Configurable isPending for export @@ -120,28 +121,30 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ useInvalidDatasetList: () => mockInvalidDatasetList, })) -vi.mock('@/service/use-pipeline', () => ({ - usePipelineTemplateById: () => ({ - data: { - name: 'Test Pipeline', - description: 'Test pipeline description', - icon_info: { icon_type: 'emoji', icon: '📊', icon_background: '#FFF4ED', icon_url: '' }, - chunk_structure: 'text', - graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }, - }, - refetch: mockGetPipelineTemplateInfo, - }), - useDeleteTemplate: () => ({ - mutateAsync: mockDeletePipeline, - }), - useExportTemplateDSL: () => ({ - mutateAsync: mockExportPipelineDSL, - get isPending() { - return mockIsExporting - }, - }), - useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipeline.templates.byTemplateId.get': () => ({ + data: { + name: 'Test Pipeline', + description: 'Test pipeline description', + icon_info: { icon_type: 'emoji', icon: '📊', icon_background: '#FFF4ED', icon_url: '' }, + chunk_structure: 'text', + graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }, + }, + refetch: mockGetPipelineTemplateInfo, + }), + 'rag.pipeline.customized.templates.byTemplateId.delete': () => ({ + mutateAsync: mockDeletePipeline, + }), + 'rag.pipeline.customized.templates.byTemplateId.post': () => ({ + mutateAsync: mockExportPipelineDSL, + get isPending() { + return mockIsExporting + }, + }), + }) +}) // Mock plugin dependencies hook vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ @@ -180,6 +183,7 @@ describe('TemplateCard', () => { screen.getByRole('button', { name: 'common.operation.cancel' }) beforeEach(() => { + vi.spyOn(QueryClient.prototype, 'invalidateQueries') vi.clearAllMocks() mockToastSuccess.mockReset() mockToastError.mockReset() @@ -453,7 +457,10 @@ describe('TemplateCard', () => { fireEvent.click(exportButton) await waitFor(() => { - expect(mockExportPipelineDSL).toHaveBeenCalledWith('pipeline-1', expect.any(Object)) + expect(mockExportPipelineDSL).toHaveBeenCalledWith( + { params: { template_id: 'pipeline-1' } }, + expect.any(Object), + ) }) }) @@ -553,7 +560,10 @@ describe('TemplateCard', () => { fireEvent.click(getDeleteConfirmButton()) await waitFor(() => { - expect(mockDeletePipeline).toHaveBeenCalledWith('pipeline-1', expect.any(Object)) + expect(mockDeletePipeline).toHaveBeenCalledWith( + { params: { template_id: 'pipeline-1' } }, + expect.any(Object), + ) }) }) @@ -574,7 +584,9 @@ describe('TemplateCard', () => { fireEvent.click(getDeleteConfirmButton()) await waitFor(() => { - expect(mockInvalidCustomizedTemplateList).toHaveBeenCalled() + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: expect.arrayContaining([['console', 'rag', 'pipeline', 'templates', 'get']]), + }) }) }) diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/index.spec.tsx index 11f75abdd2e8f9..cbe95cd6fd9104 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/details/__tests__/index.spec.tsx @@ -15,9 +15,19 @@ vi.mock('@/app/components/workflow/workflow-preview', () => ({ })) const mockUsePipelineTemplateById = vi.fn() -vi.mock('@/service/use-pipeline', () => ({ - usePipelineTemplateById: (...args: unknown[]) => mockUsePipelineTemplateById(...args), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipeline.templates.byTemplateId.get': (o: { + input: { params: { template_id: string }; query: { type: string } } + enabled?: boolean + }) => + mockUsePipelineTemplateById( + { template_id: o.input.params.template_id, type: o.input.query.type }, + o.enabled ?? true, + ), + }) +}) const createPipelineTemplateInfo = (overrides = {}) => ({ name: 'Test Pipeline', diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx index 3bfdafb86297dc..41bd48cbee9107 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx @@ -3,12 +3,14 @@ import { DialogClose, DialogTitle } from '@langgenius/dify-ui/dialog' import { IconButton } from '@langgenius/dify-ui/icon-button' import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/infotip' import { RiAddLine } from '@remixicon/react' +import { useQuery } from '@tanstack/react-query' import * as React from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' +import { selectPipelineTemplate } from '@/app/components/rag-pipeline/utils/query-select' import WorkflowPreview from '@/app/components/workflow/workflow-preview' -import { usePipelineTemplateById } from '@/service/use-pipeline' +import { consoleQuery } from '@/service/console' import ChunkStructureCard from './chunk-structure-card' import { useChunkStructureConfig } from './hooks' @@ -23,12 +25,11 @@ const Details = ({ id, type, name, onApplyTemplate }: DetailsProps) => { const structureLabelId = React.useId() const { t } = useTranslation(['datasetPipeline', 'common']) - const { data: pipelineTemplateInfo } = usePipelineTemplateById( - { - template_id: id, - type, - }, - true, + const { data: pipelineTemplateInfo } = useQuery( + consoleQuery.rag.pipeline.templates.byTemplateId.get.queryOptions({ + input: { params: { template_id: id }, query: { type } }, + select: selectPipelineTemplate, + }), ) const chunkStructureConfig = useChunkStructureConfig() diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/edit-pipeline-info.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/edit-pipeline-info.tsx index 36d237c0318e67..fe07d7f09a0e20 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/edit-pipeline-info.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/edit-pipeline-info.tsx @@ -6,6 +6,7 @@ import { Field, FieldLabel } from '@langgenius/dify-ui/field' import { IconButton } from '@langgenius/dify-ui/icon-button' import { Input } from '@langgenius/dify-ui/input' import { Textarea } from '@langgenius/dify-ui/textarea' +import { useMutation, useQueryClient } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -16,7 +17,7 @@ import { IconPickerTrigger, } from '@/app/components/base/icon-picker' import { toast } from '@/app/notifications' -import { useInvalidCustomizedTemplateList, useUpdateTemplateInfo } from '@/service/use-pipeline' +import { consoleQuery } from '@/service/console' type EditPipelineInfoProps = { onClose: () => void @@ -51,16 +52,17 @@ const EditPipelineInfo = ({ onClose, pipeline }: EditPipelineInfoProps) => { setDescription(value) }, []) - const { mutateAsync: updatePipeline } = useUpdateTemplateInfo() - const invalidCustomizedTemplateList = useInvalidCustomizedTemplateList() + const queryClient = useQueryClient() + const { mutateAsync: updatePipeline } = useMutation( + consoleQuery.rag.pipeline.customized.templates.byTemplateId.patch.mutationOptions(), + ) const handleSave = useCallback(async () => { if (!name) { toast.error(t(($) => $.editPipelineInfoNameRequired, { ns: 'datasetPipeline' })) return } - const request = { - template_id: pipeline.id, + const body = { name, icon_info: { icon_type: appIcon.type, @@ -70,22 +72,20 @@ const EditPipelineInfo = ({ onClose, pipeline }: EditPipelineInfoProps) => { }, description, } - await updatePipeline(request, { - onSuccess: () => { - invalidCustomizedTemplateList() - onClose() + await updatePipeline( + { params: { template_id: pipeline.id }, body }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.rag.pipeline.templates.get.queryKey({ + input: { query: { type: 'customized' } }, + }), + }) + onClose() + }, }, - }) - }, [ - name, - appIcon, - description, - pipeline.id, - updatePipeline, - invalidCustomizedTemplateList, - onClose, - t, - ]) + ) + }, [name, appIcon, description, pipeline.id, updatePipeline, queryClient, onClose, t]) return (
{ if (isExporting) return - await exportPipelineDSL(pipeline.id, { - onSuccess: (res) => { - const blob = new Blob([res.data], { type: 'application/yaml' }) - downloadBlob({ data: blob, fileName: `${pipeline.name}.pipeline` }) - toast.success(t(($) => $['exportDSL.successTip'], { ns: 'datasetPipeline' })) - }, - onError: () => { - toast.error(t(($) => $['exportDSL.errorTip'], { ns: 'datasetPipeline' })) + await exportPipelineDSL( + { params: { template_id: pipeline.id } }, + { + onSuccess: (res) => { + const blob = new Blob([res.data], { type: 'application/yaml' }) + downloadBlob({ data: blob, fileName: `${pipeline.name}.pipeline` }) + toast.success(t(($) => $['exportDSL.successTip'], { ns: 'datasetPipeline' })) + }, + onError: () => { + toast.error(t(($) => $['exportDSL.errorTip'], { ns: 'datasetPipeline' })) + }, }, - }) + ) }, [t, isExporting, pipeline.id, pipeline.name, exportPipelineDSL]) const handleDelete = useCallback(() => { @@ -123,17 +126,25 @@ const TemplateCard = ({ pipeline, showMoreOperations = true, type }: TemplateCar setShowConfirmDelete(false) }, []) - const { mutateAsync: deletePipeline } = useDeleteTemplate() - const invalidCustomizedTemplateList = useInvalidCustomizedTemplateList() + const { mutateAsync: deletePipeline } = useMutation( + consoleQuery.rag.pipeline.customized.templates.byTemplateId.delete.mutationOptions(), + ) const onConfirmDelete = useCallback(async () => { - await deletePipeline(pipeline.id, { - onSuccess: () => { - invalidCustomizedTemplateList() - setShowConfirmDelete(false) + await deletePipeline( + { params: { template_id: pipeline.id } }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.rag.pipeline.templates.get.queryKey({ + input: { query: { type: 'customized' } }, + }), + }) + setShowConfirmDelete(false) + }, }, - }) - }, [pipeline.id, deletePipeline, invalidCustomizedTemplateList]) + ) + }, [pipeline.id, deletePipeline, queryClient]) return (
diff --git a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx index 29b8c57c277676..c7c2022b1f60dd 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx @@ -6,6 +6,10 @@ import CreateFromPipeline from '../index' vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.workflows.publish.get': () => ({ + data: { graph: { nodes: [] } }, + isFetching: false, + }), 'files.upload.get': () => ({ data: { file_size_limit: 15, batch_count_limit: 5 }, }), @@ -98,13 +102,6 @@ vi.mock('@/next/navigation', () => ({ }), })) -vi.mock('@/service/use-pipeline', () => ({ - usePublishedPipelineInfo: () => ({ - data: { graph: { nodes: [] } }, - isFetching: false, - }), -})) - vi.mock('../data-source/store', () => ({ useDataSourceStore: () => ({ getState: () => ({}) }), })) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx index f15eabec29bf08..845ddeb54bedb5 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx @@ -39,9 +39,31 @@ const { mockUseGetDataSourceAuth } = vi.hoisted(() => ({ mockUseGetDataSourceAuth: vi.fn(), })) +const { mockUseDraftPipelinePreProcessingParams, mockUsePublishedPipelinePreProcessingParams } = + vi.hoisted(() => ({ + mockUseDraftPipelinePreProcessingParams: vi.fn(), + mockUsePublishedPipelinePreProcessingParams: vi.fn(), + })) + vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.workflows.draft.preProcessing.parameters.get': (o: { + input: { params: { pipeline_id: string }; query: { node_id: string } } + enabled?: boolean + }) => + mockUseDraftPipelinePreProcessingParams( + { pipeline_id: o.input.params.pipeline_id, node_id: o.input.query.node_id }, + o.enabled, + ), + 'rag.pipelines.byPipelineId.workflows.published.preProcessing.parameters.get': (o: { + input: { params: { pipeline_id: string }; query: { node_id: string } } + enabled?: boolean + }) => + mockUsePublishedPipelinePreProcessingParams( + { pipeline_id: o.input.params.pipeline_id, node_id: o.input.query.node_id }, + o.enabled, + ), // The credential query is keyed by `${pluginId}/${provider}`; report it as the two ids. 'auth.plugin.datasource.byProviderId.get': (options: { queryKey: unknown[] }) => { const { input } = options.queryKey[1] as { input: { params: { provider_id: string } } } @@ -56,16 +78,6 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { }) // Mock usePipeline hooks - API service hooks require mocking -const { mockUseDraftPipelinePreProcessingParams, mockUsePublishedPipelinePreProcessingParams } = - vi.hoisted(() => ({ - mockUseDraftPipelinePreProcessingParams: vi.fn(), - mockUsePublishedPipelinePreProcessingParams: vi.fn(), - })) - -vi.mock('@/service/use-pipeline', () => ({ - useDraftPipelinePreProcessingParams: mockUseDraftPipelinePreProcessingParams, - usePublishedPipelinePreProcessingParams: mockUsePublishedPipelinePreProcessingParams, -})) // Note: zustand/react/shallow useShallow is imported directly (simple utility function) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx index aa5e6c998f0929..725693d347d852 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx @@ -9,23 +9,20 @@ import type { import { useQuery } from '@tanstack/react-query' import { useQueryState } from 'nuqs' import * as React from 'react' -import { useCallback, useRef, useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { settingsQueryParamName, settingsQueryParser, } from '@/app/components/header/account-setting/query-params' +import { selectPipelineStepParameters } from '@/app/components/rag-pipeline/utils/query-select' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useDocLink } from '@/context/i18n' import { CrawlStep } from '@/models/datasets' import { DatasourceType } from '@/models/pipeline' import { ssePost } from '@/service/base' import { consoleQuery } from '@/service/console' -import { - useDraftPipelinePreProcessingParams, - usePublishedPipelinePreProcessingParams, -} from '@/service/use-pipeline' import Header from '../base/header' import { useDataSourceStore, useDataSourceStoreWithSelector } from '../store' import CrawledResult from './base/crawled-result' @@ -76,15 +73,16 @@ const WebsiteCrawl = ({ const dataSourceStore = useDataSourceStore() - const usePreProcessingParams = useRef( - !isInPipeline ? usePublishedPipelinePreProcessingParams : useDraftPipelinePreProcessingParams, - ) - const { data: paramsConfig, isFetching: isFetchingParams } = usePreProcessingParams.current( - { - pipeline_id: pipelineId!, - node_id: nodeId, - }, - !!pipelineId && !!nodeId, + const preProcessingParamsOptions = { + input: { params: { pipeline_id: pipelineId! }, query: { node_id: nodeId } }, + enabled: !!pipelineId && !!nodeId, + select: selectPipelineStepParameters, + } + const workflows = consoleQuery.rag.pipelines.byPipelineId.workflows + const { data: paramsConfig, isFetching: isFetchingParams } = useQuery( + isInPipeline + ? workflows.draft.preProcessing.parameters.get.queryOptions(preProcessingParamsOptions) + : workflows.published.preProcessing.parameters.get.queryOptions(preProcessingParamsOptions), ) const isInit = step === CrawlStep.init diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts index 3ad4b2e8ef2248..8793cc8b09be15 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts @@ -9,13 +9,16 @@ import { createDataSourceStore } from '../../data-source/store' import { useDatasourceActions } from '../use-datasource-actions' const mockRunPublishedPipeline = vi.fn() -vi.mock('@/service/use-pipeline', () => ({ - useRunPublishedPipeline: () => ({ - mutateAsync: mockRunPublishedPipeline, - isIdle: true, - isPending: false, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.workflows.published.run.post': () => ({ + mutateAsync: mockRunPublishedPipeline, + isIdle: true, + isPending: false, + }), + }) +}) vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), })) @@ -206,11 +209,10 @@ describe('useDatasourceActions', () => { }) expect(mockRunPublishedPipeline).toHaveBeenCalledWith( - expect.objectContaining({ - pipeline_id: 'pipeline-1', - is_preview: true, - start_node_id: 'node-1', - }), + { + params: { pipeline_id: 'pipeline-1' }, + body: expect.objectContaining({ is_preview: true, start_node_id: 'node-1' }), + }, expect.anything(), ) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-actions.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-actions.ts index e6e65cb72e0513..f4f0924f08c5dd 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-actions.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-actions.ts @@ -8,15 +8,16 @@ import type { CustomFile as File, FileIndexingEstimateResponse, } from '@/models/datasets' -import type { - OnlineDriveFile, - PublishedPipelineRunPreviewResponse, - PublishedPipelineRunResponse, -} from '@/models/pipeline' +import type { OnlineDriveFile, PublishedPipelineRunResponse } from '@/models/pipeline' +import { useMutation } from '@tanstack/react-query' import { useCallback, useRef } from 'react' import { trackEvent } from '@/app/components/base/amplitude' +import { + isPipelineProcessingRun, + selectPreviewRunEstimate, +} from '@/app/components/rag-pipeline/utils/query-select' import { DatasourceType } from '@/models/pipeline' -import { useRunPublishedPipeline } from '@/service/use-pipeline' +import { consoleQuery } from '@/service/console' import { buildLocalFileDatasourceInfo, buildOnlineDocumentDatasourceInfo, @@ -65,7 +66,13 @@ export const useDatasourceActions = ({ const isPreview = useRef(false) const formRef = useRef<{ submit: () => void } | null>(null) - const { mutateAsync: runPublishedPipeline, isIdle, isPending } = useRunPublishedPipeline() + const { + mutateAsync: runPublishedPipeline, + isIdle, + isPending, + } = useMutation( + consoleQuery.rag.pipelines.byPipelineId.workflows.published.run.post.mutationOptions(), + ) // Build datasource info for preview (single item) const buildPreviewDatasourceInfo = useCallback(() => { @@ -162,16 +169,19 @@ export const useDatasourceActions = ({ const datasourceInfoList = buildPreviewDatasourceInfo() await runPublishedPipeline( { - pipeline_id: pipelineId, - inputs: data, - start_node_id: datasource.nodeId, - datasource_type: datasourceType as DatasourceType, - datasource_info_list: datasourceInfoList, - is_preview: true, + params: { pipeline_id: pipelineId }, + body: { + inputs: data, + start_node_id: datasource.nodeId, + datasource_type: datasourceType as DatasourceType, + datasource_info_list: datasourceInfoList, + is_preview: true, + response_mode: 'blocking', + }, }, { onSuccess: (res) => { - setEstimateData((res as PublishedPipelineRunPreviewResponse).data.outputs) + if (!isPipelineProcessingRun(res)) setEstimateData(selectPreviewRunEstimate(res)) }, }, ) @@ -196,17 +206,21 @@ export const useDatasourceActions = ({ const datasourceInfoList = buildProcessDatasourceInfo() await runPublishedPipeline( { - pipeline_id: pipelineId, - inputs: data, - start_node_id: datasource.nodeId, - datasource_type: datasourceType as DatasourceType, - datasource_info_list: datasourceInfoList, - is_preview: false, + params: { pipeline_id: pipelineId }, + body: { + inputs: data, + start_node_id: datasource.nodeId, + datasource_type: datasourceType as DatasourceType, + datasource_info_list: datasourceInfoList, + is_preview: false, + response_mode: 'blocking', + }, }, { onSuccess: (res) => { - setBatchId((res as PublishedPipelineRunResponse).batch || '') - setDocuments((res as PublishedPipelineRunResponse).documents || []) + if (!isPipelineProcessingRun(res)) return + setBatchId(res.batch) + setDocuments(res.documents) handleNextStep() trackEvent('dataset_document_added', { data_source_type: datasourceType, diff --git a/web/app/components/datasets/documents/create-from-pipeline/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/index.tsx index 6b91bdae0cc647..88ef6330131544 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/index.tsx @@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' +import { selectPublishedPipelineNodes } from '@/app/components/rag-pipeline/utils/query-select' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { workspacePermissionKeysAtom, @@ -21,7 +22,6 @@ import { deploymentEditionAtom } from '@/features/system-features/state' import { DatasourceType } from '@/models/pipeline' import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/console' -import { usePublishedPipelineInfo } from '@/service/use-pipeline' import { getDatasetACLCapabilities } from '@/utils/permission' import { useDataSourceStore } from './data-source/store' import DataSourceProvider from './data-source/store/provider' @@ -74,8 +74,12 @@ const CreateFormPipeline = () => { const [documents, setDocuments] = useState([]) // Data fetching - const { data: pipelineInfo, isFetching: isFetchingPipelineInfo } = usePublishedPipelineInfo( - pipelineId || '', + const { data: pipelineNodes, isFetching: isFetchingPipelineInfo } = useQuery( + consoleQuery.rag.pipelines.byPipelineId.workflows.publish.get.queryOptions({ + input: { params: { pipeline_id: pipelineId || '' } }, + enabled: !!pipelineId, + select: selectPublishedPipelineNodes, + }), ) const { data: fileUploadConfigResponse } = useQuery(consoleQuery.files.upload.get.queryOptions()) @@ -264,7 +268,7 @@ const CreateFormPipeline = () => { []} + pipelineNodes={(pipelineNodes || []) as Node[]} supportBatchUpload={supportBatchUpload} showBatchUploadUpgrade={deploymentEdition === 'CLOUD' && plan === 'sandbox'} isShowVectorSpaceFull={isShowVectorSpaceFull} diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx index 821f3744b545dd..c7fa957196f37a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx @@ -33,9 +33,13 @@ vi.mock('@/context/dataset-detail', () => ({ // Mock usePreviewOnlineDocument hook - needs mock to control mutation behavior const mockMutateAsync = vi.fn() const mockUsePreviewOnlineDocument = vi.fn() -vi.mock('@/service/use-pipeline', () => ({ - usePreviewOnlineDocument: () => mockUsePreviewOnlineDocument(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.workflows.published.datasource.nodes.byNodeId.preview.post': () => + mockUsePreviewOnlineDocument(), + }) +}) // Mock data source store - needs mock to control store state const mockCurrentCredentialId = 'credential-123' @@ -124,12 +128,12 @@ describe('OnlineDocumentPreview', () => { await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( { - workspaceID: 'ws-123', - pageID: 'pg-456', - pageType: 'page', - pipelineId: 'pipeline-123', - datasourceNodeId: 'node-789', - credentialId: mockCurrentCredentialId, + params: { pipeline_id: 'pipeline-123', node_id: 'node-789' }, + body: { + datasource_type: 'online_document', + credential_id: mockCurrentCredentialId, + inputs: { workspace_id: 'ws-123', page_id: 'pg-456', type: 'page' }, + }, }, expect.objectContaining({ onSuccess: expect.any(Function), @@ -166,7 +170,7 @@ describe('OnlineDocumentPreview', () => { await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ - pipelineId: '', + params: expect.objectContaining({ pipeline_id: '' }), }), expect.anything(), ) @@ -338,7 +342,7 @@ describe('OnlineDocumentPreview', () => { await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ - credentialId: 'custom-credential', + body: expect.objectContaining({ credential_id: 'custom-credential' }), }), expect.anything(), ) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx index 333783c018120e..af5348f0590bb8 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx @@ -1,13 +1,16 @@ 'use client' import type { NotionPage } from '@/models/common' import { RiCloseLine } from '@remixicon/react' +import { useMutation } from '@tanstack/react-query' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Markdown } from '@/app/components/base/markdown' +import { selectOnlineDocumentPreview } from '@/app/components/rag-pipeline/utils/query-select' import { toast } from '@/app/notifications' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import { usePreviewOnlineDocument } from '@/service/use-pipeline' +import { DatasourceType } from '@/models/pipeline' +import { consoleQuery } from '@/service/console' import { formatNumberAbbreviated } from '@/utils/format' import { useDataSourceStore } from '../data-source/store' import Loading from './loading' @@ -26,23 +29,29 @@ const OnlineDocumentPreview = ({ const { t } = useTranslation(['datasetPipeline']) const [content, setContent] = useState('') const pipelineId = useDatasetDetailContextWithSelector((state) => state.dataset?.pipeline_id) - const { mutateAsync: getOnlineDocumentContent, isPending } = usePreviewOnlineDocument() + const { mutateAsync: getOnlineDocumentContent, isPending } = useMutation( + consoleQuery.rag.pipelines.byPipelineId.workflows.published.datasource.nodes.byNodeId.preview.post.mutationOptions(), + ) const dataSourceStore = useDataSourceStore() useEffect(() => { const { currentCredentialId } = dataSourceStore.getState() getOnlineDocumentContent( { - workspaceID: currentPage.workspace_id, - pageID: currentPage.page_id, - pageType: currentPage.type, - pipelineId: pipelineId || '', - datasourceNodeId, - credentialId: currentCredentialId, + params: { pipeline_id: pipelineId || '', node_id: datasourceNodeId }, + body: { + datasource_type: DatasourceType.onlineDocument, + credential_id: currentCredentialId, + inputs: { + workspace_id: currentPage.workspace_id, + page_id: currentPage.page_id, + type: currentPage.type, + }, + }, }, { onSuccess(data) { - setContent(data.content) + setContent(selectOnlineDocumentPreview(data).content) }, onError(error) { toast.error(error.message) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts index a52658d7bb4049..15f3f812e223d6 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts @@ -1,4 +1,3 @@ -import type { PipelineProcessingParamsRequest } from '@/models/pipeline' import { renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useInputVariables } from '../hooks' @@ -10,10 +9,19 @@ vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: (selector: (value: unknown) => unknown) => mockUseDatasetDetailContextWithSelector(selector), })) -vi.mock('@/service/use-pipeline', () => ({ - usePublishedPipelineProcessingParams: (params: PipelineProcessingParamsRequest) => - mockUsePublishedPipelineProcessingParams(params), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.workflows.published.processing.parameters.get': (o: { + input: { params: { pipeline_id: string }; query: { node_id: string } } + enabled?: boolean + }) => + mockUsePublishedPipelineProcessingParams({ + pipeline_id: o.input.params.pipeline_id, + node_id: o.input.query.node_id, + }), + }) +}) describe('useInputVariables', () => { beforeEach(() => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/hooks.ts b/web/app/components/datasets/documents/create-from-pipeline/process-documents/hooks.ts index dd5655d062b7e8..08d60249be5082 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/hooks.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/hooks.ts @@ -1,13 +1,17 @@ +import { useQuery } from '@tanstack/react-query' +import { selectPipelineStepParameters } from '@/app/components/rag-pipeline/utils/query-select' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import { usePublishedPipelineProcessingParams } from '@/service/use-pipeline' +import { consoleQuery } from '@/service/console' export const useInputVariables = (datasourceNodeId: string) => { const pipelineId = useDatasetDetailContextWithSelector((state) => state.dataset?.pipeline_id) - const { data: paramsConfig, isFetching: isFetchingParams } = usePublishedPipelineProcessingParams( - { - pipeline_id: pipelineId!, - node_id: datasourceNodeId, - }, + const { data: paramsConfig, isFetching: isFetchingParams } = useQuery( + consoleQuery.rag.pipelines.byPipelineId.workflows.published.processing.parameters.get.queryOptions( + { + input: { params: { pipeline_id: pipelineId! }, query: { node_id: datasourceNodeId } }, + select: selectPipelineStepParameters, + }, + ), ) return { diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx index 8d703d513ad6fa..a0940f19631904 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx @@ -91,7 +91,7 @@ const createMockDocument = ( name: 'test-document.txt', data_source_type: DatasourceType.localFile, data_source_info: {}, - enable: true, + enabled: true, error: '', indexing_status: 'waiting' as DocumentIndexingStatus, position: 0, diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx index 6dcf5747a8748c..abb8c43719c6a0 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx @@ -1,4 +1,5 @@ import type { IndexingType } from '@/app/components/datasets/create/step-two' +import type { DataSourceNotionPage } from '@/models/common' import type { IndexingStatusResponse } from '@/models/datasets' import type { InitialDocumentDetail } from '@/models/pipeline' import type { RETRIEVE_METHOD } from '@/types/app' @@ -33,6 +34,9 @@ import { useIndexingStatusBatch, useProcessRule } from '@/service/knowledge/use- import { useInvalidDocumentList } from '@/service/knowledge/use-document' import RuleDetail from './rule-detail' +const isNotionPageIcon = (value: unknown): value is DataSourceNotionPage['page_icon'] => + typeof value === 'object' && value !== null && 'type' in value + type EmbeddingProcessProps = { datasetId: string batchId: string @@ -145,7 +149,8 @@ const EmbeddingProcess = ({ const getIcon = (id: string) => { const doc = documents.find((document) => document.id === id) - return doc?.data_source_info.notion_page_icon + const icon = doc?.data_source_info?.notion_page_icon + return typeof icon === 'string' || isNotionPageIcon(icon) ? icon : undefined } const isSourceEmbedding = (detail: IndexingStatusResponse) => ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting'].includes( diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx index 014f1ee1507c0e..d4b66f5ddb1a6c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx @@ -16,6 +16,11 @@ import { StepOnePreview, StepTwoPreview } from '../preview-panel' vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.workflows.published.datasource.nodes.byNodeId.preview.post': + () => ({ + mutateAsync: vi.fn().mockResolvedValue({ content: 'Mock document content' }), + isPending: false, + }), 'files.byFileId.preview.get': vi.fn(() => ({ data: { content: 'Mock file content for testing' }, isFetching: false, @@ -39,13 +44,6 @@ vi.mock('@/context/dataset-detail', () => ({ // Mock API hooks (底层依赖) -vi.mock('@/service/use-pipeline', () => ({ - usePreviewOnlineDocument: vi.fn(() => ({ - mutateAsync: vi.fn().mockResolvedValue({ content: 'Mock document content' }), - isPending: false, - })), -})) - // Mock data source store vi.mock('../../data-source/store', () => ({ useDataSourceStore: vi.fn(() => ({ diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx index 3ef21cb78d51f8..3f9a7c05d14267 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx @@ -180,33 +180,6 @@ vi.mock('@/next/navigation', () => ({ })) // Mock pipeline service hooks -vi.mock('@/service/use-pipeline', () => ({ - useNotionWorkspaces: vi.fn(() => ({ - data: [], - isLoading: false, - })), - useNotionPages: vi.fn(() => ({ - data: { pages: [] }, - isLoading: false, - })), - useCrawlResult: vi.fn(() => ({ - data: { data: [] }, - isLoading: false, - })), - useSupportedOauth: vi.fn(() => ({ - data: [], - isLoading: false, - })), - useOnlineDriveCredentialList: vi.fn(() => ({ - data: [], - isLoading: false, - })), - useOnlineDriveFileList: vi.fn(() => ({ - data: { data: [] }, - isLoading: false, - })), -})) - function render(...args: Parameters) { args[0] = {args[0]} return renderWithoutPricing(...args) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx index 0e94ab4d31ec2e..320993865d4a02 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx @@ -26,16 +26,20 @@ vi.mock('@/context/dataset-detail', () => ({ const mockUsePipelineExecutionLog = vi.fn() const mockMutateAsync = vi.fn() const mockUseRunPublishedPipeline = vi.fn() -vi.mock('@/service/use-pipeline', () => ({ - usePipelineExecutionLog: (params: { dataset_id: string; document_id: string }) => - mockUsePipelineExecutionLog(params), - useRunPublishedPipeline: () => mockUseRunPublishedPipeline(), - // For ProcessDocuments component - usePublishedPipelineProcessingParams: () => ({ - data: { variables: [] }, - isFetching: false, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.byDocumentId.pipelineExecutionLog.get': (o: { + input: { params: { dataset_id: string; document_id: string } } + }) => mockUsePipelineExecutionLog(o.input.params), + 'rag.pipelines.byPipelineId.workflows.published.run.post': () => mockUseRunPublishedPipeline(), + // For ProcessDocuments component + 'rag.pipelines.byPipelineId.workflows.published.processing.parameters.get': () => ({ + data: { variables: [] }, + isFetching: false, + }), + }) +}) // Mock document invalidation hooks const mockInvalidDocumentList = vi.fn() @@ -377,9 +381,11 @@ describe('PipelineSettings', () => { await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ - is_preview: false, - pipeline_id: mockPipelineId, - original_document_id: 'document-456', + params: { pipeline_id: mockPipelineId }, + body: expect.objectContaining({ + is_preview: false, + original_document_id: 'document-456', + }), }), expect.any(Object), ) @@ -446,8 +452,8 @@ describe('PipelineSettings', () => { await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ - is_preview: true, - pipeline_id: mockPipelineId, + params: { pipeline_id: mockPipelineId }, + body: expect.objectContaining({ is_preview: true }), }), expect.any(Object), ) @@ -455,7 +461,7 @@ describe('PipelineSettings', () => { }) it('should update estimateData on successful preview', async () => { - const mockOutputs = { chunks: [], total_tokens: 50 } + const mockOutputs = { total_nodes: 1, total_segments: 0, preview: [], qa_preview: [] } mockMutateAsync.mockImplementation((_req, opts) => { opts?.onSuccess?.({ data: { outputs: mockOutputs } }) return Promise.resolve({ data: { outputs: mockOutputs } }) @@ -496,12 +502,15 @@ describe('PipelineSettings', () => { await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( { - pipeline_id: mockPipelineId, - inputs: {}, - start_node_id: 'node-xyz', - datasource_type: DatasourceType.localFile, - datasource_info_list: [{ related_id: 'file-1', name: 'test.pdf', extension: 'pdf' }], - is_preview: true, + params: { pipeline_id: mockPipelineId }, + body: { + inputs: {}, + start_node_id: 'node-xyz', + datasource_type: DatasourceType.localFile, + datasource_info_list: [{ related_id: 'file-1', name: 'test.pdf', extension: 'pdf' }], + is_preview: true, + response_mode: 'blocking', + }, }, expect.any(Object), ) @@ -583,7 +592,7 @@ describe('PipelineSettings', () => { }) it('should update estimateData after successful preview', async () => { - const mockEstimateData = { chunks: [], total_tokens: 50 } + const mockEstimateData = { total_nodes: 1, total_segments: 0, preview: [], qa_preview: [] } mockMutateAsync.mockImplementation((_req, opts) => { opts?.onSuccess?.({ data: { outputs: mockEstimateData } }) return Promise.resolve({ data: { outputs: mockEstimateData } }) @@ -609,7 +618,7 @@ describe('PipelineSettings', () => { await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( - expect.objectContaining({ is_preview: false }), + expect.objectContaining({ body: expect.objectContaining({ is_preview: false }) }), expect.any(Object), ) }) @@ -624,7 +633,7 @@ describe('PipelineSettings', () => { await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( - expect.objectContaining({ is_preview: true }), + expect.objectContaining({ body: expect.objectContaining({ is_preview: true }) }), expect.any(Object), ) }) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx index 35fc30795e9689..90f6952a0f6991 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx @@ -1,16 +1,22 @@ import type { NotionPage } from '@/models/common' import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse } from '@/models/datasets' -import type { OnlineDriveFile, PublishedPipelineRunPreviewResponse } from '@/models/pipeline' +import type { OnlineDriveFile } from '@/models/pipeline' +import { useMutation, useQuery } from '@tanstack/react-query' import { noop } from 'es-toolkit/function' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AppUnavailable from '@/app/components/base/app-unavailable' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' +import { + isPipelineProcessingRun, + selectPipelineExecutionLog, + selectPreviewRunEstimate, +} from '@/app/components/rag-pipeline/utils/query-select' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { DatasourceType } from '@/models/pipeline' import { useRouter } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document' -import { usePipelineExecutionLog, useRunPublishedPipeline } from '@/service/use-pipeline' import ChunkPreview from '../../../create-from-pipeline/preview/chunk-preview' import LeftHeader from './left-header' import ProcessDocuments from './process-documents' @@ -35,10 +41,12 @@ const PipelineSettings = ({ datasetId, documentId }: PipelineSettingsProps) => { data: lastRunData, isFetching: isFetchingLastRunData, isError, - } = usePipelineExecutionLog({ - dataset_id: datasetId, - document_id: documentId, - }) + } = useQuery( + consoleQuery.datasets.byDatasetId.documents.byDocumentId.pipelineExecutionLog.get.queryOptions({ + input: { params: { dataset_id: datasetId, document_id: documentId } }, + select: selectPipelineExecutionLog, + }), + ) const files = useMemo(() => { const files: CustomFile[] = [] @@ -93,7 +101,13 @@ const PipelineSettings = ({ datasetId, documentId }: PipelineSettingsProps) => { return onlineDriveFiles }, [lastRunData]) - const { mutateAsync: runPublishedPipeline, isIdle, isPending } = useRunPublishedPipeline() + const { + mutateAsync: runPublishedPipeline, + isIdle, + isPending, + } = useMutation( + consoleQuery.rag.pipelines.byPipelineId.workflows.published.run.post.mutationOptions(), + ) const handlePreviewChunks = useCallback( async (data: Record) => { @@ -103,16 +117,19 @@ const PipelineSettings = ({ datasetId, documentId }: PipelineSettingsProps) => { datasourceInfoList.push(documentInfo) await runPublishedPipeline( { - pipeline_id: pipelineId!, - inputs: data, - start_node_id: lastRunData.datasource_node_id, - datasource_type: lastRunData.datasource_type, - datasource_info_list: datasourceInfoList, - is_preview: true, + params: { pipeline_id: pipelineId! }, + body: { + inputs: data, + start_node_id: lastRunData.datasource_node_id, + datasource_type: lastRunData.datasource_type, + datasource_info_list: datasourceInfoList, + is_preview: true, + response_mode: 'blocking', + }, }, { onSuccess: (res) => { - setEstimateData((res as PublishedPipelineRunPreviewResponse).data.outputs) + if (!isPipelineProcessingRun(res)) setEstimateData(selectPreviewRunEstimate(res)) }, }, ) @@ -130,13 +147,16 @@ const PipelineSettings = ({ datasetId, documentId }: PipelineSettingsProps) => { datasourceInfoList.push(documentInfo) await runPublishedPipeline( { - pipeline_id: pipelineId!, - inputs: data, - start_node_id: lastRunData.datasource_node_id, - datasource_type: lastRunData.datasource_type, - datasource_info_list: datasourceInfoList, - original_document_id: documentId, - is_preview: false, + params: { pipeline_id: pipelineId! }, + body: { + inputs: data, + start_node_id: lastRunData.datasource_node_id, + datasource_type: lastRunData.datasource_type, + datasource_info_list: datasourceInfoList, + original_document_id: documentId, + is_preview: false, + response_mode: 'blocking', + }, }, { onSuccess: () => { diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts index bd4e5488e6a4d2..abacd44aba673e 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts @@ -19,10 +19,19 @@ const mockUsePublishedPipelineProcessingParams = vi.fn( (_params: { pipeline_id: string; node_id: string }) => mockParamsReturn, ) -vi.mock('@/service/use-pipeline', () => ({ - usePublishedPipelineProcessingParams: (params: { pipeline_id: string; node_id: string }) => - mockUsePublishedPipelineProcessingParams(params), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.workflows.published.processing.parameters.get': (o: { + input: { params: { pipeline_id: string }; query: { node_id: string } } + enabled?: boolean + }) => + mockUsePublishedPipelineProcessingParams({ + pipeline_id: o.input.params.pipeline_id, + node_id: o.input.query.node_id, + }), + }) +}) describe('useInputVariables', () => { beforeEach(() => { diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/index.spec.tsx index 95a9ce32367ea5..e5e708024cc14a 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/index.spec.tsx @@ -14,12 +14,15 @@ vi.mock('@/context/dataset-detail', () => ({ // Mock API call for pipeline processing params const mockParamsConfig = vi.fn() -vi.mock('@/service/use-pipeline', () => ({ - usePublishedPipelineProcessingParams: () => ({ - data: mockParamsConfig(), - isFetching: false, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.workflows.published.processing.parameters.get': () => ({ + data: mockParamsConfig(), + isFetching: false, + }), + }) +}) // Mock Form component - internal dependencies (useAppForm, BaseField) are too complex // Keep the mock minimal and focused on testing the integration diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/hooks.ts b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/hooks.ts index dd5655d062b7e8..08d60249be5082 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/hooks.ts +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/hooks.ts @@ -1,13 +1,17 @@ +import { useQuery } from '@tanstack/react-query' +import { selectPipelineStepParameters } from '@/app/components/rag-pipeline/utils/query-select' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import { usePublishedPipelineProcessingParams } from '@/service/use-pipeline' +import { consoleQuery } from '@/service/console' export const useInputVariables = (datasourceNodeId: string) => { const pipelineId = useDatasetDetailContextWithSelector((state) => state.dataset?.pipeline_id) - const { data: paramsConfig, isFetching: isFetchingParams } = usePublishedPipelineProcessingParams( - { - pipeline_id: pipelineId!, - node_id: datasourceNodeId, - }, + const { data: paramsConfig, isFetching: isFetchingParams } = useQuery( + consoleQuery.rag.pipelines.byPipelineId.workflows.published.processing.parameters.get.queryOptions( + { + input: { params: { pipeline_id: pipelineId! }, query: { node_id: datasourceNodeId } }, + select: selectPipelineStepParameters, + }, + ), ) return { diff --git a/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts b/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts index 78f58ea34070d7..b882c73a89a08d 100644 --- a/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts +++ b/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts @@ -19,9 +19,12 @@ vi.mock('@/service/base', () => ({ sseGeneratorPost: vi.fn(), })) -vi.mock('@/service/use-pipeline', () => ({ - useExportPipelineDSL: () => ({ mutateAsync: vi.fn() }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.exports.get': () => ({ mutateAsync: vi.fn() }), + }) +}) const dataset = { id: 'dataset-1', diff --git a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts b/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts index 979891f50fdec3..6e0dfe3eba3478 100644 --- a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts +++ b/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts @@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/console' -import { useExportPipelineDSL } from '@/service/use-pipeline' import { downloadBlob } from '@/utils/download' type ModalState = { @@ -61,7 +60,9 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO const { mutateAsync: deleteDatasetMutation } = useMutation( consoleQuery.datasets.byDatasetId.delete.mutationOptions(), ) - const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL() + const { mutateAsync: exportPipelineConfig } = useMutation( + consoleQuery.rag.pipelines.byPipelineId.exports.get.mutationOptions(), + ) // Export pipeline handler const handleExportPipeline = useCallback( @@ -72,8 +73,8 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO try { setExporting(true) const { data } = await exportPipelineConfig({ - pipelineId: pipeline_id, - include, + params: { pipeline_id }, + query: { include_secret: String(include) }, }) const file = new Blob([data], { type: 'application/yaml' }) downloadBlob({ data: file, fileName: `${name}.pipeline` }) diff --git a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx index 2f3bcf7cf8c4cd..8a5c201d563b07 100644 --- a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx @@ -28,12 +28,15 @@ vi.mock('@/context/dataset-detail', () => ({ ) => selector(mockDatasetDetailState), })) -vi.mock('@/service/use-pipeline', () => ({ - useConvertDatasetToPipeline: () => ({ - mutateAsync: mockConvert, - isPending: false, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.transform.datasets.byDatasetId.post': () => ({ + mutateAsync: mockConvert, + isPending: false, + }), + }) +}) vi.mock('@/service/knowledge/use-dataset', () => ({ datasetDetailQueryKeyPrefix: ['dataset-detail'], @@ -161,7 +164,7 @@ describe('Conversion', () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(mockConvert).toHaveBeenCalledWith( - 'ds-123', + { params: { dataset_id: 'ds-123' } }, expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function), diff --git a/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx index 361e0d7b324e52..94de2f13c5b80c 100644 --- a/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx @@ -75,10 +75,13 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ const mockImportDSL = vi.fn() const mockImportDSLConfirm = vi.fn() -vi.mock('@/service/use-pipeline', () => ({ - useImportPipelineDSL: () => ({ mutateAsync: mockImportDSL }), - useImportPipelineDSLConfirm: () => ({ mutateAsync: mockImportDSLConfirm }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.imports.post': () => ({ mutateAsync: mockImportDSL }), + 'rag.pipelines.imports.byImportId.confirm.post': () => ({ mutateAsync: mockImportDSLConfirm }), + }) +}) vi.mock('@/service/workflow', () => ({ fetchWorkflowDraft: vi.fn().mockResolvedValue({ @@ -695,7 +698,7 @@ describe('UpdateDSLModal', () => { fireEvent.click(confirmButton) await waitFor(() => { - expect(mockImportDSLConfirm).toHaveBeenCalledWith('import-id') + expect(mockImportDSLConfirm).toHaveBeenCalledWith({ params: { import_id: 'import-id' } }) }) vi.useRealTimers() diff --git a/web/app/components/rag-pipeline/components/conversion.tsx b/web/app/components/rag-pipeline/components/conversion.tsx index 0329fd163b0241..e898a742a35eba 100644 --- a/web/app/components/rag-pipeline/components/conversion.tsx +++ b/web/app/components/rag-pipeline/components/conversion.tsx @@ -8,7 +8,7 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import * as React from 'react' import { useCallback, useState } from 'react' @@ -18,9 +18,9 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import { useParams } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { datasetDetailQueryKeyPrefix } from '@/service/knowledge/use-dataset' import { useInvalid } from '@/service/use-base' -import { useConvertDatasetToPipeline } from '@/service/use-pipeline' import { getDatasetACLCapabilities } from '@/utils/permission' import PipelineScreenShot from './screenshot' @@ -34,7 +34,9 @@ const Conversion = () => { }) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const [showConfirmModal, setShowConfirmModal] = useState(false) - const { mutateAsync: convert, isPending } = useConvertDatasetToPipeline() + const { mutateAsync: convert, isPending } = useMutation( + consoleQuery.rag.pipelines.transform.datasets.byDatasetId.post.mutationOptions(), + ) const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, datasetId]) const datasetACLCapabilities = React.useMemo( () => @@ -49,20 +51,23 @@ const Conversion = () => { const handleConvert = useCallback(() => { if (!canConvertDataset || !datasetId) return - convert(datasetId as string, { - onSuccess: (res) => { - if (res.status === 'success') { - toast.success(t(($) => $['conversion.successMessage'], { ns: 'datasetPipeline' })) - setShowConfirmModal(false) - invalidDatasetDetail() - } else if (res.status === 'failed') { + convert( + { params: { dataset_id: datasetId as string } }, + { + onSuccess: (res) => { + if (res.status === 'success') { + toast.success(t(($) => $['conversion.successMessage'], { ns: 'datasetPipeline' })) + setShowConfirmModal(false) + invalidDatasetDetail() + } else if (res.status === 'failed') { + toast.error(t(($) => $['conversion.errorMessage'], { ns: 'datasetPipeline' })) + } + }, + onError: () => { toast.error(t(($) => $['conversion.errorMessage'], { ns: 'datasetPipeline' })) - } - }, - onError: () => { - toast.error(t(($) => $['conversion.errorMessage'], { ns: 'datasetPipeline' })) + }, }, - }) + ) }, [canConvertDataset, convert, datasetId, invalidDatasetDetail, t]) const handleShowConfirmModal = useCallback(() => { if (!canConvertDataset) return diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/utils.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/utils.ts index c3921e7911be94..1dba8c5006d600 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/utils.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/utils.ts @@ -35,7 +35,9 @@ export const convertToInputFieldFormData = (data?: InputVar): FormData => { allowedTypesAndExtensions: {}, } - if (default_value !== undefined && default_value !== null) formData.default = default_value + // Number and checkbox variables keep a typed default; the editor edits it as text. + if (default_value !== undefined && default_value !== null) + formData.default = String(default_value) if (tooltips !== undefined && tooltips !== null) formData.tooltips = tooltips if (placeholder !== undefined && placeholder !== null) formData.placeholder = placeholder if (unit !== undefined && unit !== null) formData.unit = unit diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx index c838b2acd771a9..ec2dfc0e5ef003 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx @@ -4,7 +4,7 @@ import DataSource from '../data-source' const { mockOnSelect, mockUseDraftPipelinePreProcessingParams } = vi.hoisted(() => ({ mockOnSelect: vi.fn(), - mockUseDraftPipelinePreProcessingParams: vi.fn(() => ({ + mockUseDraftPipelinePreProcessingParams: vi.fn((_params?: unknown, _enabled?: boolean) => ({ data: { variables: [{ variable: 'source' }], }, @@ -16,9 +16,19 @@ vi.mock('@/app/components/workflow/store', () => ({ selector({ pipelineId: 'pipeline-1' }), })) -vi.mock('@/service/use-pipeline', () => ({ - useDraftPipelinePreProcessingParams: mockUseDraftPipelinePreProcessingParams, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.workflows.draft.preProcessing.parameters.get': (o: { + input: { params: { pipeline_id: string }; query: { node_id: string } } + enabled?: boolean + }) => + mockUseDraftPipelinePreProcessingParams( + { pipeline_id: o.input.params.pipeline_id, node_id: o.input.query.node_id }, + o.enabled, + ), + }) +}) vi.mock('../../../test-run/preparation/data-source-options', () => ({ default: ({ diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/index.spec.tsx index c34c535c3f9eb9..22ebb8163be88d 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/index.spec.tsx @@ -60,18 +60,29 @@ vi.mock('zustand/react/shallow', () => ({ let mockPreProcessingParamsData: { variables: RAGPipelineVariables } | undefined let mockProcessingParamsData: { variables: RAGPipelineVariables } | undefined -vi.mock('@/service/use-pipeline', () => ({ - useDraftPipelinePreProcessingParams: (_params: unknown, enabled: boolean) => ({ - data: enabled ? mockPreProcessingParamsData : undefined, - isLoading: false, - error: null, - }), - useDraftPipelineProcessingParams: (_params: unknown, enabled: boolean) => ({ - data: enabled ? mockProcessingParamsData : undefined, - isLoading: false, - error: null, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.workflows.draft.preProcessing.parameters.get': ({ + enabled, + }: { + enabled?: boolean + }) => ({ + data: enabled ? mockPreProcessingParamsData : undefined, + isLoading: false, + error: null, + }), + 'rag.pipelines.byPipelineId.workflows.draft.processing.parameters.get': ({ + enabled, + }: { + enabled?: boolean + }) => ({ + data: enabled ? mockProcessingParamsData : undefined, + isLoading: false, + error: null, + }), + }) +}) let mockDatasourceOptions: DataSourceOption[] = [] diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx index f3fa9c24b78e5b..a71f4ab9f90c1d 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react' import ProcessDocuments from '../process-documents' const mockUseDraftPipelineProcessingParams = vi.hoisted(() => - vi.fn(() => ({ + vi.fn((_params?: unknown, _enabled?: boolean) => ({ data: { variables: [{ variable: 'chunkSize' }], }, @@ -14,9 +14,19 @@ vi.mock('@/app/components/workflow/store', () => ({ selector({ pipelineId: 'pipeline-1' }), })) -vi.mock('@/service/use-pipeline', () => ({ - useDraftPipelineProcessingParams: mockUseDraftPipelineProcessingParams, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.workflows.draft.processing.parameters.get': (o: { + input: { params: { pipeline_id: string }; query: { node_id: string } } + enabled?: boolean + }) => + mockUseDraftPipelineProcessingParams( + { pipeline_id: o.input.params.pipeline_id, node_id: o.input.query.node_id }, + o.enabled, + ), + }) +}) vi.mock('../form', () => ({ default: ({ variables }: { variables: Array<{ variable: string }> }) => ( diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/data-source.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/data-source.tsx index 84bbb983782a9a..cd38f2a3ab59a5 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/data-source.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/data-source.tsx @@ -1,8 +1,10 @@ import type { Datasource } from '../../test-run/types' +import { useQuery } from '@tanstack/react-query' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { selectPipelineStepParameters } from '@/app/components/rag-pipeline/utils/query-select' import { useStore } from '@/app/components/workflow/store' -import { useDraftPipelinePreProcessingParams } from '@/service/use-pipeline' +import { consoleQuery } from '@/service/console' import DataSourceOptions from '../../test-run/preparation/data-source-options' import Form from './form' @@ -14,12 +16,14 @@ type DatasourceProps = { const DataSource = ({ onSelect: setDatasource, dataSourceNodeId }: DatasourceProps) => { const { t } = useTranslation(['datasetPipeline']) const pipelineId = useStore((state) => state.pipelineId) - const { data: paramsConfig } = useDraftPipelinePreProcessingParams( - { - pipeline_id: pipelineId!, - node_id: dataSourceNodeId, - }, - !!pipelineId && !!dataSourceNodeId, + const { data: paramsConfig } = useQuery( + consoleQuery.rag.pipelines.byPipelineId.workflows.draft.preProcessing.parameters.get.queryOptions( + { + input: { params: { pipeline_id: pipelineId! }, query: { node_id: dataSourceNodeId } }, + enabled: !!pipelineId && !!dataSourceNodeId, + select: selectPipelineStepParameters, + }, + ), ) return ( diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/process-documents.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/process-documents.tsx index 8ac1caed240aa1..6a8f209fe60643 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/process-documents.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/process-documents.tsx @@ -1,7 +1,9 @@ +import { useQuery } from '@tanstack/react-query' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { selectPipelineStepParameters } from '@/app/components/rag-pipeline/utils/query-select' import { useStore } from '@/app/components/workflow/store' -import { useDraftPipelineProcessingParams } from '@/service/use-pipeline' +import { consoleQuery } from '@/service/console' import Form from './form' type ProcessDocumentsProps = { @@ -11,12 +13,12 @@ type ProcessDocumentsProps = { const ProcessDocuments = ({ dataSourceNodeId }: ProcessDocumentsProps) => { const { t } = useTranslation(['datasetPipeline']) const pipelineId = useStore((state) => state.pipelineId) - const { data: paramsConfig } = useDraftPipelineProcessingParams( - { - pipeline_id: pipelineId!, - node_id: dataSourceNodeId, - }, - !!pipelineId && !!dataSourceNodeId, + const { data: paramsConfig } = useQuery( + consoleQuery.rag.pipelines.byPipelineId.workflows.draft.processing.parameters.get.queryOptions({ + input: { params: { pipeline_id: pipelineId! }, query: { node_id: dataSourceNodeId } }, + enabled: !!pipelineId && !!dataSourceNodeId, + select: selectPipelineStepParameters, + }), ) return ( diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts index 61e68cd1f90e3e..6b1b8dbe850bd8 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts @@ -2,7 +2,7 @@ import { renderHook } from '@testing-library/react' import { useInputVariables } from '../hooks' const mockUseDraftPipelineProcessingParams = vi.hoisted(() => - vi.fn(() => ({ + vi.fn((_params?: unknown, _enabled?: boolean) => ({ data: { variables: [{ variable: 'chunkSize' }] }, isFetching: true, })), @@ -13,9 +13,19 @@ vi.mock('@/app/components/workflow/store', () => ({ selector({ pipelineId: 'pipeline-1' }), })) -vi.mock('@/service/use-pipeline', () => ({ - useDraftPipelineProcessingParams: mockUseDraftPipelineProcessingParams, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.workflows.draft.processing.parameters.get': (o: { + input: { params: { pipeline_id: string }; query: { node_id: string } } + enabled?: boolean + }) => + mockUseDraftPipelineProcessingParams({ + pipeline_id: o.input.params.pipeline_id, + node_id: o.input.query.node_id, + }), + }) +}) describe('useInputVariables', () => { beforeEach(() => { diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx index d30eef3e949e85..c830871e4e191a 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx @@ -38,12 +38,15 @@ vi.mock('@/app/components/workflow/store', () => ({ let mockParamsConfig: PipelineProcessingParamsResponse | undefined let mockIsFetchingParams = false -vi.mock('@/service/use-pipeline', () => ({ - useDraftPipelineProcessingParams: () => ({ - data: mockParamsConfig, - isFetching: mockIsFetchingParams, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.workflows.draft.processing.parameters.get': () => ({ + data: mockParamsConfig, + isFetching: mockIsFetchingParams, + }), + }) +}) const mockUseInitialData = vi.fn() const mockUseConfigurations = vi.fn() diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/hooks.ts b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/hooks.ts index 529932792c0834..22afb70a0cc954 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/hooks.ts +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/hooks.ts @@ -1,12 +1,16 @@ +import { useQuery } from '@tanstack/react-query' +import { selectPipelineStepParameters } from '@/app/components/rag-pipeline/utils/query-select' import { useStore } from '@/app/components/workflow/store' -import { useDraftPipelineProcessingParams } from '@/service/use-pipeline' +import { consoleQuery } from '@/service/console' export const useInputVariables = (datasourceNodeId: string) => { const pipelineId = useStore((state) => state.pipelineId) - const { data: paramsConfig, isFetching: isFetchingParams } = useDraftPipelineProcessingParams({ - pipeline_id: pipelineId!, - node_id: datasourceNodeId, - }) + const { data: paramsConfig, isFetching: isFetchingParams } = useQuery( + consoleQuery.rag.pipelines.byPipelineId.workflows.draft.processing.parameters.get.queryOptions({ + input: { params: { pipeline_id: pipelineId! }, query: { node_id: datasourceNodeId } }, + select: selectPipelineStepParameters, + }), + ) return { isFetchingParams, diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx index c2bd364bb11a46..76cc430f4076ef 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx @@ -142,25 +142,25 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ const mockPublishWorkflow = vi.fn() const mockPublishAsCustomizedPipeline = vi.fn() -const mockInvalidPublishedPipelineInfo = vi.fn() const mockInvalidDatasetList = vi.fn() -const mockInvalidCustomizedTemplateList = vi.fn() vi.mock('@/service/knowledge/use-dataset', () => ({ useInvalidDatasetList: () => mockInvalidDatasetList, })) -vi.mock('@/service/use-base', () => ({ - useInvalid: () => mockInvalidPublishedPipelineInfo, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.customized.publish.post': () => ({ + mutateAsync: mockPublishAsCustomizedPipeline, + }), + }) +}) -vi.mock('@/service/use-pipeline', () => ({ - publishedPipelineInfoQueryKeyPrefix: ['pipeline', 'published'], - useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList, - usePublishAsCustomizedPipeline: () => ({ - mutateAsync: mockPublishAsCustomizedPipeline, - }), -})) +const expectInvalidated = (...path: string[]) => + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: expect.arrayContaining([['console', ...path]]), + }) vi.mock('@/service/use-workflow', () => ({ usePublishWorkflow: () => ({ @@ -232,6 +232,7 @@ function publishWithKeyboard() { describe('publisher', () => { beforeEach(() => { + vi.spyOn(QueryClient.prototype, 'invalidateQueries') vi.clearAllMocks() vi.spyOn(console, 'error').mockImplementation(() => {}) mockPublishedAt.mockReturnValue(null) @@ -549,10 +550,12 @@ describe('publisher', () => { await waitFor(() => { expect(mockPublishAsCustomizedPipeline).toHaveBeenCalledWith({ - pipelineId: 'test-pipeline-id', - name: 'Test Pipeline', - icon_info: { type: 'emoji', emoji: '📚', background: '#fff' }, - description: 'Test description', + params: { pipeline_id: 'test-pipeline-id' }, + body: { + name: 'Test Pipeline', + icon_info: { type: 'emoji', emoji: '📚', background: '#fff' }, + description: 'Test description', + }, }) }) }) @@ -578,10 +581,12 @@ describe('publisher', () => { await waitFor(() => { expect(mockPublishAsCustomizedPipeline).toHaveBeenCalledWith({ - pipelineId: '', - name: 'Test Pipeline', - icon_info: { type: 'emoji', emoji: '📚', background: '#fff' }, - description: 'Test description', + params: { pipeline_id: '' }, + body: { + name: 'Test Pipeline', + icon_info: { type: 'emoji', emoji: '📚', background: '#fff' }, + description: 'Test description', + }, }) }) }) @@ -646,7 +651,7 @@ describe('publisher', () => { await waitFor(() => { expect(mockMutateDatasetRes).toHaveBeenCalled() - expect(mockInvalidPublishedPipelineInfo).toHaveBeenCalled() + expectInvalidated('rag', 'pipelines', 'byPipelineId', 'workflows', 'publish', 'get') expect(mockInvalidDatasetList).toHaveBeenCalled() }) }) @@ -698,7 +703,7 @@ describe('publisher', () => { fireEvent.click(screen.getByTestId('modal-confirm')) await waitFor(() => { - expect(mockInvalidCustomizedTemplateList).toHaveBeenCalled() + expectInvalidated('rag', 'pipeline', 'templates', 'get') }) }) }) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx index 7642b08b6411b2..beb0785f8012c1 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx @@ -49,9 +49,7 @@ const mockHandleCheckBeforePublish = vi.fn().mockResolvedValue(true) const mockSetPublishedAt = vi.fn() const mockMutateDatasetRes = vi.fn() -const mockInvalidPublishedPipelineInfo = vi.fn() const mockInvalidDatasetList = vi.fn() -const mockInvalidCustomizedTemplateList = vi.fn() let mockPublishedAt: string | undefined = '2024-01-01T00:00:00Z' let mockDraftUpdatedAt: string | undefined = '2024-06-01T00:00:00Z' @@ -159,17 +157,14 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ useInvalidDatasetList: () => mockInvalidDatasetList, })) -vi.mock('@/service/use-base', () => ({ - useInvalid: () => mockInvalidPublishedPipelineInfo, -})) - -vi.mock('@/service/use-pipeline', () => ({ - publishedPipelineInfoQueryKeyPrefix: ['published-pipeline'], - useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList, - usePublishAsCustomizedPipeline: () => ({ - mutateAsync: mockPublishAsCustomizedPipeline, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.customized.publish.post': () => ({ + mutateAsync: mockPublishAsCustomizedPipeline, + }), + }) +}) vi.mock('@/service/use-workflow', () => ({ usePublishWorkflow: () => ({ diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx index d97c96fbe32191..92468352ffd27b 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx @@ -3,6 +3,7 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiArrowDownSLine } from '@remixicon/react' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useBoolean } from 'ahooks' import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -12,10 +13,7 @@ import { useStore } from '@/app/components/workflow/store' import { toast } from '@/app/notifications' import { useDocLink } from '@/context/i18n' import Link from '@/next/link' -import { - useInvalidCustomizedTemplateList, - usePublishAsCustomizedPipeline, -} from '@/service/use-pipeline' +import { consoleQuery } from '@/service/console' import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal' import { Popup } from './popup' @@ -27,8 +25,10 @@ const Publisher = () => { const canPipelineRelease = useHooksStore((s) => s.accessControl.canReleaseAndVersion) const docLink = useDocLink() const pipelineId = useStore((s) => s.pipelineId) - const { mutateAsync: publishAsCustomizedPipeline } = usePublishAsCustomizedPipeline() - const invalidCustomizedTemplateList = useInvalidCustomizedTemplateList() + const queryClient = useQueryClient() + const { mutateAsync: publishAsCustomizedPipeline } = useMutation( + consoleQuery.rag.pipelines.byPipelineId.customized.publish.post.mutationOptions(), + ) const [showPublishAsKnowledgePipelineModal, setShowPublishAsKnowledgePipelineModal] = useState(false) const [isPublishingAsCustomizedPipeline, setIsPublishingAsCustomizedPipeline] = useState(false) @@ -55,10 +55,8 @@ const Publisher = () => { try { setIsPublishingAsCustomizedPipeline(true) await publishAsCustomizedPipeline({ - pipelineId: pipelineId || '', - name, - icon_info: icon, - description, + params: { pipeline_id: pipelineId || '' }, + body: { name, icon_info: icon, description }, }) toast.success( t(($) => $['publishTemplate.success.message'], { ns: 'datasetPipeline' }), @@ -79,7 +77,11 @@ const Publisher = () => { ), }, ) - invalidCustomizedTemplateList() + queryClient.invalidateQueries({ + queryKey: consoleQuery.rag.pipeline.templates.get.queryKey({ + input: { query: { type: 'customized' } }, + }), + }) } catch { toast.error(t(($) => $['publishTemplate.error.message'], { ns: 'datasetPipeline' })) } finally { @@ -90,7 +92,7 @@ const Publisher = () => { [ docLink, hidePublishAsKnowledgePipelineModal, - invalidCustomizedTemplateList, + queryClient, pipelineId, publishAsCustomizedPipeline, t, diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx index 50b7b6527c1c20..d66ca487bc3c9b 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx @@ -14,7 +14,7 @@ import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Separator } from '@langgenius/dify-ui/separator' import { RiArrowRightUpLine, RiPlayCircleLine, RiTerminalBoxLine } from '@remixicon/react' import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' -import { useQuery, useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useBoolean } from 'ahooks' import { useAtomValue } from 'jotai' import { useQueryState } from 'nuqs' @@ -42,8 +42,6 @@ import Link from '@/next/link' import { useParams } from '@/next/navigation' import { consoleQuery } from '@/service/console' import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' -import { useInvalid } from '@/service/use-base' -import { publishedPipelineInfoQueryKeyPrefix } from '@/service/use-pipeline' import { usePublishWorkflow } from '@/service/use-workflow' import { getDatasetACLCapabilities } from '@/utils/permission' import { RAG_PIPELINE_PUBLISH_HOTKEY } from '../hotkeys' @@ -108,10 +106,7 @@ export function Popup({ const showConfirm = onShowConfirm ?? showLocalConfirm const hideConfirm = onHideConfirm ?? hideLocalConfirm const [publishing, { setFalse: hidePublishing, setTrue: showPublishing }] = useBoolean(false) - const invalidPublishedPipelineInfo = useInvalid([ - ...publishedPipelineInfoQueryKeyPrefix, - pipelineId, - ]) + const queryClient = useQueryClient() const invalidDatasetList = useInvalidDatasetList() const handleHideConfirm = useCallback(() => { hideConfirm() @@ -165,7 +160,11 @@ export function Popup({ ) workflowStore.getState().setPublishedAt(res.created_at) mutateDatasetRes?.() - invalidPublishedPipelineInfo() + queryClient.invalidateQueries({ + queryKey: consoleQuery.rag.pipelines.byPipelineId.workflows.publish.get.queryKey({ + input: { params: { pipeline_id: pipelineId || '' } }, + }), + }) invalidDatasetList() } } @@ -189,7 +188,7 @@ export function Popup({ t, workflowStore, mutateDatasetRes, - invalidPublishedPipelineInfo, + queryClient, invalidDatasetList, hidePublishing, handleHideConfirm, diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts index 847702e82bee11..2abf3327b9c06c 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts @@ -78,10 +78,13 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ }), })) -vi.mock('@/service/use-pipeline', () => ({ - useImportPipelineDSL: () => ({ mutateAsync: mockImportDSL }), - useImportPipelineDSLConfirm: () => ({ mutateAsync: mockImportDSLConfirm }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.imports.post': () => ({ mutateAsync: mockImportDSL }), + 'rag.pipelines.imports.byImportId.confirm.post': () => ({ mutateAsync: mockImportDSLConfirm }), + }) +}) vi.mock('@/service/workflow', () => ({ fetchWorkflowDraft: vi.fn().mockResolvedValue({ @@ -186,9 +189,11 @@ describe('useUpdateDSLModal', () => { }) expect(mockImportDSL).toHaveBeenCalledWith({ - mode: DSLImportMode.YAML_CONTENT, - yaml_content: 'test content', - pipeline_id: 'test-pipeline-id', + body: { + mode: DSLImportMode.YAML_CONTENT, + yaml_content: 'test content', + pipeline_id: 'test-pipeline-id', + }, }) }) @@ -473,7 +478,7 @@ describe('useUpdateDSLModal', () => { await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() }) - expect(mockImportDSLConfirm).toHaveBeenCalledWith('import-id') + expect(mockImportDSLConfirm).toHaveBeenCalledWith({ params: { import_id: 'import-id' } }) }) it('should notify success and call onCancel after successful confirm', async () => { diff --git a/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts index 0815e7d8c7ae9d..d52861d3368022 100644 --- a/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts +++ b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts @@ -1,4 +1,5 @@ import type { MouseEventHandler } from 'react' +import { useMutation } from '@tanstack/react-query' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { WORKFLOW_DATA_UPDATE } from '@/app/components/workflow/constants' @@ -8,7 +9,7 @@ import { initialEdges, initialNodes } from '@/app/components/workflow/utils' import { toast } from '@/app/notifications' import { useEventEmitterContextContext } from '@/context/event-emitter' import { DSLImportMode, DSLImportStatus } from '@/models/app' -import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '@/service/use-pipeline' +import { consoleQuery } from '@/service/console' import { fetchWorkflowDraft } from '@/service/workflow' import { getErrorMessage } from '@/utils/error-message' @@ -27,8 +28,12 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam const { eventEmitter } = useEventEmitterContextContext() const workflowStore = useWorkflowStore() const { handleCheckPluginDependencies } = usePluginDependencies() - const { mutateAsync: importDSL } = useImportPipelineDSL() - const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm() + const { mutateAsync: importDSL } = useMutation( + consoleQuery.rag.pipelines.imports.post.mutationOptions(), + ) + const { mutateAsync: importDSLConfirm } = useMutation( + consoleQuery.rag.pipelines.imports.byImportId.confirm.post.mutationOptions(), + ) // File state const [currentFile, setDSLFile] = useState() const [fileContent, setFileContent] = useState() @@ -79,7 +84,10 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam [eventEmitter], ) const completeImport = useCallback( - async (pipelineId: string | undefined, status: DSLImportStatus = DSLImportStatus.COMPLETED) => { + async ( + pipelineId: string | null | undefined, + status: DSLImportStatus = DSLImportStatus.COMPLETED, + ) => { if (!pipelineId) { notifyError() return @@ -123,9 +131,11 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam if (!pipelineId || !fileContent) return setLoading(true) const response = await importDSL({ - mode: DSLImportMode.YAML_CONTENT, - yaml_content: fileContent, - pipeline_id: pipelineId, + body: { + mode: DSLImportMode.YAML_CONTENT, + yaml_content: fileContent, + pipeline_id: pipelineId, + }, }) const { id, status, pipeline_id, imported_dsl_version, current_dsl_version } = response if (isCompletedStatus(status)) await completeImport(pipeline_id, status) @@ -148,7 +158,9 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam const onUpdateDSLConfirm: MouseEventHandler = useCallback(async () => { if (!importId) return try { - const { status, pipeline_id, error } = await importDSLConfirm(importId) + const { status, pipeline_id, error } = await importDSLConfirm({ + params: { import_id: importId }, + }) if (status === DSLImportStatus.COMPLETED) { await completeImport(pipeline_id) return diff --git a/web/app/components/rag-pipeline/utils/__tests__/query-select.spec.ts b/web/app/components/rag-pipeline/utils/__tests__/query-select.spec.ts new file mode 100644 index 00000000000000..e16a155a2fc960 --- /dev/null +++ b/web/app/components/rag-pipeline/utils/__tests__/query-select.spec.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vite-plus/test' +import { BlockEnum } from '@/app/components/workflow/types' +import { ChunkingMode } from '@/models/datasets' +import { DatasourceType, PipelineInputVarType } from '@/models/pipeline' +import { + isPipelineProcessingRun, + selectDatasourceSingleRun, + selectPipelineExecutionLog, + selectPipelineStepParameters, + selectPipelineTemplate, + selectPipelineTemplateList, + selectPreviewRunEstimate, + selectPublishedPipelineNodes, +} from '../query-select' + +const graphNode = { id: 'node-1', data: { type: 'datasource' }, position: { x: 0, y: 0 } } + +describe('pipeline query selectors', () => { + it('should narrow template icons and chunk structures', () => { + const { pipeline_templates } = selectPipelineTemplateList({ + pipeline_templates: [ + { + id: 'template-1', + name: 'Template', + description: '', + position: 1, + chunk_structure: 'hierarchical_model', + icon: { icon_type: 'emoji', icon: '📚', icon_background: '#fff' }, + }, + { + id: 'template-2', + name: 'Legacy', + description: '', + position: 2, + chunk_structure: '', + icon: { icon_type: 'unknown' }, + }, + ], + }) + + expect(pipeline_templates[0]).toMatchObject({ + chunk_structure: ChunkingMode.parentChild, + icon: { icon_type: 'emoji', icon: '📚', icon_background: '#fff' }, + }) + expect(pipeline_templates[1]).toMatchObject({ + chunk_structure: ChunkingMode.text, + icon: { icon_type: 'emoji', icon: '' }, + }) + }) + + it('should keep only placeable graph nodes and edges', () => { + const template = selectPipelineTemplate({ + id: 'template-1', + name: 'Template', + description: '', + export_data: 'dsl', + chunk_structure: 'text_model', + icon_info: {}, + graph: { + nodes: [graphNode, { id: 'broken' }], + edges: [{ id: 'edge-1', source: 'a', target: 'b' }, { id: 'broken' }], + }, + }) + + expect(template.graph.nodes).toEqual([graphNode]) + expect(template.graph.edges).toEqual([{ id: 'edge-1', source: 'a', target: 'b' }]) + expect(template.graph.viewport).toEqual({ x: 0, y: 0, zoom: 1 }) + expect(template.created_by).toBe('') + }) + + it('should read published pipeline nodes from the workflow graph', () => { + expect( + selectPublishedPipelineNodes({ + id: 'workflow-1', + graph: { nodes: [graphNode] }, + features: {}, + hash: 'hash', + version: 'v1', + marked_name: '', + marked_comment: '', + created_at: 0, + updated_at: 0, + tool_published: false, + environment_variables: [], + conversation_variables: [], + rag_pipeline_variables: [], + }), + ).toEqual([graphNode]) + }) + + it('should drop variables of unsupported types and unknown upload settings', () => { + const { variables } = selectPipelineStepParameters({ + variables: [ + { + belong_to_node_id: 'shared', + type: 'file', + label: 'File', + variable: 'file', + required: true, + default_value: 3, + allowed_file_types: ['document', 'spreadsheet'], + allowed_file_upload_methods: ['local_file', 'ftp'], + placeholder: null, + }, + { + belong_to_node_id: 'shared', + type: 'json', + label: 'JSON', + variable: 'json', + required: false, + }, + ], + }) + + expect(variables).toEqual([ + expect.objectContaining({ + type: PipelineInputVarType.singleFile, + default_value: 3, + allowed_file_types: ['document'], + allowed_file_upload_methods: ['local_file'], + placeholder: undefined, + }), + ]) + }) + + it('should default missing execution log fields', () => { + expect( + selectPipelineExecutionLog({ datasource_info: 'x', datasource_type: 'unknown' }), + ).toEqual({ + datasource_info: {}, + input_data: {}, + datasource_node_id: '', + datasource_type: DatasourceType.localFile, + }) + }) + + it('should tell processing runs from preview runs', () => { + const batch = { + batch: 'batch-1', + dataset: { id: 'dataset-1', name: 'Dataset', chunk_structure: 'text_model' }, + documents: [], + } + const preview = { + task_id: 'task-1', + workflow_run_id: 'run-1', + data: { + id: 'run-1', + workflow_id: 'workflow-1', + status: 'succeeded' as const, + elapsed_time: 1, + total_tokens: 0, + total_steps: 1, + created_at: 0, + finished_at: 1, + outputs: { total_nodes: 1, total_segments: 2, preview: [] }, + }, + } + + expect(isPipelineProcessingRun(batch)).toBe(true) + expect(isPipelineProcessingRun(preview)).toBe(false) + expect(selectPreviewRunEstimate(preview)).toEqual(preview.data.outputs) + expect( + selectPreviewRunEstimate({ ...preview, data: { ...preview.data, outputs: { text: 'x' } } }), + ).toBeUndefined() + }) + + it('should map a datasource single run to a node run result', () => { + const result = selectDatasourceSingleRun({ + id: 'execution-1', + node_type: 'not-a-block', + status: 'succeeded', + outputs: ['not', 'a', 'record'], + execution_metadata: { total_tokens: 3 }, + }) + + expect(result).toMatchObject({ + id: 'execution-1', + node_type: BlockEnum.DataSource, + status: 'succeeded', + outputs: undefined, + execution_metadata: { total_tokens: 3, total_price: 0, currency: '' }, + created_by: { id: '', name: '', email: '' }, + }) + }) +}) diff --git a/web/app/components/rag-pipeline/utils/query-select.ts b/web/app/components/rag-pipeline/utils/query-select.ts new file mode 100644 index 00000000000000..ad12dfaa859cfd --- /dev/null +++ b/web/app/components/rag-pipeline/utils/query-select.ts @@ -0,0 +1,231 @@ +import type { DocumentPipelineExecutionLogResponse } from '@dify/contracts/api/console/datasets/types.gen' +import type { + PipelineGenerateResponse, + PipelineTemplateDetailResponse, + PipelineTemplateListResponse as PipelineTemplateListContract, + PipelineVariableResponse, + PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewResponse, + RagPipelineImportCheckDependenciesResponse, + RagPipelinePublishedRunResponse, + RagPipelineStepParametersResponse, + WorkflowAppBlockingResponse, + WorkflowResponse, + WorkflowRunNodeExecutionResponse, +} from '@dify/contracts/api/console/rag/types.gen' +import type { Viewport } from 'reactflow' +import type { Dependency } from '@/app/components/plugins/types' +import type { Edge, Node } from '@/app/components/workflow/types' +import type { FileIndexingEstimateResponse, IconInfo } from '@/models/datasets' +import type { + PipelineExecutionLogResponse, + PipelineTemplate, + PipelineTemplateByIdResponse, + RAGPipelineVariable, +} from '@/models/pipeline' +import type { AppIconType } from '@/types/app' +import type { NodeRunResult } from '@/types/workflow' +import { BlockEnum, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { ChunkingMode } from '@/models/datasets' +import { DatasourceType, PipelineInputVarType } from '@/models/pipeline' +import { TransferMethod } from '@/types/app' + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const getString = (record: Record, key: string) => { + const value = record[key] + return typeof value === 'string' ? value : undefined +} + +const getNumber = (record: Record, key: string) => { + const value = record[key] + return typeof value === 'number' ? value : undefined +} + +const isOneOf = (values: Record, value: unknown): value is T => + typeof value === 'string' && Object.values(values).includes(value) + +const APP_ICON_TYPES = { image: 'image', emoji: 'emoji', link: 'link' } satisfies Record< + AppIconType, + AppIconType +> + +const toIconInfo = (icon: Record): IconInfo => { + const iconType = icon.icon_type + return { + icon: getString(icon, 'icon') ?? '', + icon_type: isOneOf(APP_ICON_TYPES, iconType) ? iconType : 'emoji', + icon_background: getString(icon, 'icon_background'), + icon_url: getString(icon, 'icon_url'), + } +} + +// Templates exported before a chunk structure was recorded default to general text chunks. +const toChunkingMode = (chunkStructure: string) => + isOneOf(ChunkingMode, chunkStructure) ? chunkStructure : ChunkingMode.text + +// The workflow graph is stored as free-form JSON; keep the nodes and edges React Flow can place. +const isGraphNode = (value: unknown): value is Node => + isRecord(value) && + typeof value.id === 'string' && + isRecord(value.data) && + isRecord(value.position) + +const isGraphEdge = (value: unknown): value is Edge => + isRecord(value) && typeof value.source === 'string' && typeof value.target === 'string' + +const toGraphNodes = (graph: Record) => + Array.isArray(graph.nodes) ? graph.nodes.filter(isGraphNode) : [] + +const toGraphEdges = (graph: Record) => + Array.isArray(graph.edges) ? graph.edges.filter(isGraphEdge) : [] + +const toViewport = (graph: Record): Viewport => { + const viewport = isRecord(graph.viewport) ? graph.viewport : {} + return { + x: getNumber(viewport, 'x') ?? 0, + y: getNumber(viewport, 'y') ?? 0, + zoom: getNumber(viewport, 'zoom') ?? 1, + } +} + +export const selectPipelineTemplateList = (data: PipelineTemplateListContract) => ({ + pipeline_templates: data.pipeline_templates.map((template): PipelineTemplate => ({ + id: template.id, + name: template.name, + description: template.description, + position: template.position, + icon: toIconInfo(template.icon), + chunk_structure: toChunkingMode(template.chunk_structure), + })), +}) + +export const selectPipelineTemplate = ( + template: PipelineTemplateDetailResponse, +): PipelineTemplateByIdResponse => ({ + id: template.id, + name: template.name, + description: template.description, + export_data: template.export_data, + created_by: template.created_by ?? '', + icon_info: toIconInfo(template.icon_info), + chunk_structure: toChunkingMode(template.chunk_structure), + graph: { + nodes: toGraphNodes(template.graph), + edges: toGraphEdges(template.graph), + viewport: toViewport(template.graph), + }, +}) + +export const selectPublishedPipelineNodes = (workflow: WorkflowResponse) => + toGraphNodes(workflow.graph) + +const toDefaultValue = (value: unknown) => + typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' + ? value + : undefined + +const toPipelineVariable = (variable: PipelineVariableResponse): RAGPipelineVariable[] => { + if (!isOneOf(PipelineInputVarType, variable.type)) return [] + return [ + { + belong_to_node_id: variable.belong_to_node_id, + type: variable.type, + label: variable.label, + variable: variable.variable, + required: variable.required, + max_length: variable.max_length ?? undefined, + default_value: toDefaultValue(variable.default_value), + placeholder: variable.placeholder ?? undefined, + unit: variable.unit ?? undefined, + tooltips: variable.tooltips ?? undefined, + options: variable.options ?? undefined, + allowed_file_types: variable.allowed_file_types?.filter((type) => + isOneOf(SupportUploadFileTypes, type), + ), + allowed_file_extensions: variable.allowed_file_extensions ?? undefined, + allowed_file_upload_methods: variable.allowed_file_upload_methods?.filter((method) => + isOneOf(TransferMethod, method), + ), + }, + ] +} + +// Variables of a type the input forms cannot render are left out. +export const selectPipelineStepParameters = (data: RagPipelineStepParametersResponse) => ({ + variables: data.variables.flatMap(toPipelineVariable), +}) + +export const selectPipelineExecutionLog = ( + log: DocumentPipelineExecutionLogResponse, +): PipelineExecutionLogResponse => ({ + datasource_info: isRecord(log.datasource_info) ? log.datasource_info : {}, + input_data: isRecord(log.input_data) ? log.input_data : {}, + datasource_node_id: log.datasource_node_id ?? '', + datasource_type: isOneOf(DatasourceType, log.datasource_type) + ? log.datasource_type + : DatasourceType.localFile, +}) + +export const selectPipelineDependencies = ( + data: RagPipelineImportCheckDependenciesResponse, +): Dependency[] => + (data.leaked_dependencies ?? []).map((dependency) => ({ + ...dependency, + // The dependency store represents an absent version with undefined. + value: { ...dependency.value, version: dependency.value.version ?? undefined }, + })) + +export const isPipelineProcessingRun = ( + response: RagPipelinePublishedRunResponse, +): response is PipelineGenerateResponse => 'batch' in response + +// Preview runs return the indexing estimate as the workflow outputs. +const isFileIndexingEstimate = (value: unknown): value is FileIndexingEstimateResponse => + isRecord(value) && typeof value.total_segments === 'number' && Array.isArray(value.preview) + +export const selectPreviewRunEstimate = (response: WorkflowAppBlockingResponse) => + isFileIndexingEstimate(response.data.outputs) ? response.data.outputs : undefined + +export const selectOnlineDocumentPreview = ( + data: PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewResponse, +) => ({ content: getString(data, 'content') ?? '' }) + +const BLOCKS: Record = BlockEnum + +export const selectDatasourceSingleRun = ( + execution: WorkflowRunNodeExecutionResponse, +): NodeRunResult => { + const metadata = isRecord(execution.execution_metadata) ? execution.execution_metadata : undefined + return { + id: execution.id, + index: execution.index ?? 0, + predecessor_node_id: execution.predecessor_node_id ?? '', + node_id: execution.node_id ?? '', + node_type: isOneOf(BLOCKS, execution.node_type) ? execution.node_type : BlockEnum.DataSource, + title: execution.title ?? '', + inputs: execution.inputs, + inputs_truncated: execution.inputs_truncated ?? false, + process_data: execution.process_data, + process_data_truncated: execution.process_data_truncated ?? false, + outputs: isRecord(execution.outputs) ? execution.outputs : undefined, + outputs_truncated: execution.outputs_truncated ?? false, + status: execution.status ?? '', + error: execution.error ?? undefined, + elapsed_time: execution.elapsed_time ?? 0, + execution_metadata: metadata + ? { + total_tokens: getNumber(metadata, 'total_tokens') ?? 0, + total_price: getNumber(metadata, 'total_price') ?? 0, + currency: getString(metadata, 'currency') ?? '', + } + : undefined, + // Datasource nodes never run inside an iteration or loop. + metadata: { iterator_length: 0, iterator_index: 0, loop_length: 0, loop_index: 0 }, + created_at: execution.created_at ?? 0, + created_by: execution.created_by_account ?? { id: '', name: '', email: '' }, + finished_at: execution.finished_at ?? 0, + extras: execution.extras, + retry_index: execution.retry_index ?? undefined, + } +} diff --git a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.branches.spec.tsx b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.branches.spec.tsx index bb05f8dc2a3df1..a4ed4557d32242 100644 --- a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.branches.spec.tsx +++ b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.branches.spec.tsx @@ -1,5 +1,6 @@ +import type { WorkflowRunNodeExecutionResponse } from '@dify/contracts/api/console/rag/types.gen' import type { CustomRunFormProps, DataSourceNodeType } from '../../types' -import type { NodeRunResult, VarInInspect } from '@/types/workflow' +import type { VarInInspect } from '@/types/workflow' import { act, renderHook } from '@testing-library/react' import { useStoreApi } from 'reactflow' import { @@ -8,7 +9,6 @@ import { } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' import { DatasourceType } from '@/models/pipeline' -import { useDatasourceSingleRun } from '@/service/use-pipeline' import { useInvalidLastRun } from '@/service/use-workflow' import { fetchNodeInspectVars } from '@/service/workflow' import { FlowType } from '@/types/common' @@ -40,7 +40,7 @@ type DataSourceStoreState = { type DatasourceSingleRunOptions = { onError?: () => void - onSettled?: (data?: NodeRunResult) => void + onSettled?: (data?: WorkflowRunNodeExecutionResponse) => void } const mockHandleNodeDataUpdate = vi.hoisted(() => vi.fn()) @@ -77,9 +77,15 @@ vi.mock('../../../../hooks/use-nodes-sync-draft', async (importOriginal) => { } }) -vi.mock('@/service/use-pipeline', () => ({ - useDatasourceSingleRun: vi.fn(), -})) +const mockUseDatasourceSingleRunHook = vi.hoisted(() => vi.fn()) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.workflows.draft.datasource.variablesInspect.post': () => + mockUseDatasourceSingleRunHook(), + }) +}) vi.mock('@/service/use-workflow', () => ({ useInvalidLastRun: vi.fn(), @@ -97,7 +103,6 @@ vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/st const mockUseStoreApi = vi.mocked(useStoreApi) const mockUseNodeDataUpdateHook = vi.mocked(useNodeDataUpdate) const mockUseNodesSyncDraftHook = vi.mocked(useNodesSyncDraft) -const mockUseDatasourceSingleRunHook = vi.mocked(useDatasourceSingleRun) const mockUseInvalidLastRunHook = vi.mocked(useInvalidLastRun) const mockFetchNodeInspectVarsFn = vi.mocked(fetchNodeInspectVars) const mockUseDataSourceStoreHook = vi.mocked(useDataSourceStore) @@ -172,7 +177,7 @@ describe('data-source/hooks/use-before-run-form branches', () => { mockUseDatasourceSingleRunHook.mockReturnValue({ mutateAsync: (...args: unknown[]) => mockMutateAsync(...args), isPending: false, - } as ReturnType) + }) mockUseInvalidLastRunHook.mockReturnValue(mockInvalidLastRun) mockFetchNodeInspectVarsFn.mockImplementation((...args: unknown[]) => mockFetchNodeInspectVars(...args), @@ -238,7 +243,7 @@ describe('data-source/hooks/use-before-run-form branches', () => { ] mockMutateAsync.mockImplementation((_payload: unknown, options: DatasourceSingleRunOptions) => { - options.onSettled?.({ status: NodeRunningStatus.Succeeded } as NodeRunResult) + options.onSettled?.({ id: 'node-run-1', status: NodeRunningStatus.Succeeded }) return Promise.resolve(undefined) }) @@ -255,7 +260,9 @@ describe('data-source/hooks/use-before-run-form branches', () => { await Promise.resolve() }) - expect(props.setRunResult).toHaveBeenCalledWith({ status: NodeRunningStatus.Succeeded }) + expect(props.setRunResult).toHaveBeenCalledWith( + expect.objectContaining({ id: 'node-run-1', status: NodeRunningStatus.Succeeded }), + ) expect(mockFetchNodeInspectVars).not.toHaveBeenCalled() expect(props.onSuccess).not.toHaveBeenCalled() }) @@ -271,7 +278,7 @@ describe('data-source/hooks/use-before-run-form branches', () => { ] mockMutateAsync.mockImplementation((payload: unknown, options: DatasourceSingleRunOptions) => { - options.onSettled?.({ status: NodeRunningStatus.Succeeded } as NodeRunResult) + options.onSettled?.({ id: 'node-run-1', status: NodeRunningStatus.Succeeded }) return Promise.resolve(payload) }) @@ -290,16 +297,18 @@ describe('data-source/hooks/use-before-run-form branches', () => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ - datasource_type: DatasourceType.onlineDocument, - datasource_info: { - workspace_id: 'workspace-1', - page: { - id: 'doc-1', - title: 'Document', - url: 'https://example.com/doc', + body: expect.objectContaining({ + datasource_type: DatasourceType.onlineDocument, + datasource_info: { + workspace_id: 'workspace-1', + page: { + id: 'doc-1', + title: 'Document', + url: 'https://example.com/doc', + }, + credential_id: 'credential-1', }, - credential_id: 'credential-1', - }, + }), }), expect.any(Object), ) @@ -334,12 +343,14 @@ describe('data-source/hooks/use-before-run-form branches', () => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ - datasource_type: DatasourceType.websiteCrawl, - datasource_info: { - url: 'https://example.com', - title: 'Example', - credential_id: 'credential-1', - }, + body: expect.objectContaining({ + datasource_type: DatasourceType.websiteCrawl, + datasource_info: { + url: 'https://example.com', + title: 'Example', + credential_id: 'credential-1', + }, + }), }), expect.any(Object), ) diff --git a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx index 9c73404978a23d..d606ec52ab11d4 100644 --- a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx +++ b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx @@ -1,5 +1,6 @@ +import type { WorkflowRunNodeExecutionResponse } from '@dify/contracts/api/console/rag/types.gen' import type { CustomRunFormProps, DataSourceNodeType } from '../../types' -import type { NodeRunResult, VarInInspect } from '@/types/workflow' +import type { VarInInspect } from '@/types/workflow' import { act, renderHook } from '@testing-library/react' import { useStoreApi } from 'reactflow' import { @@ -8,7 +9,6 @@ import { } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' import { DatasourceType } from '@/models/pipeline' -import { useDatasourceSingleRun } from '@/service/use-pipeline' import { useInvalidLastRun } from '@/service/use-workflow' import { fetchNodeInspectVars } from '@/service/workflow' import { TransferMethod } from '@/types/app' @@ -41,7 +41,7 @@ type DataSourceStoreState = { type DatasourceSingleRunOptions = { onError?: () => void - onSettled?: (data?: NodeRunResult) => void + onSettled?: (data?: WorkflowRunNodeExecutionResponse) => void } const mockHandleNodeDataUpdate = vi.hoisted(() => vi.fn()) @@ -78,9 +78,15 @@ vi.mock('../../../../hooks/use-nodes-sync-draft', async (importOriginal) => { } }) -vi.mock('@/service/use-pipeline', () => ({ - useDatasourceSingleRun: vi.fn(), -})) +const mockUseDatasourceSingleRunHook = vi.hoisted(() => vi.fn()) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.byPipelineId.workflows.draft.datasource.variablesInspect.post': () => + mockUseDatasourceSingleRunHook(), + }) +}) vi.mock('@/service/use-workflow', () => ({ useInvalidLastRun: vi.fn(), @@ -98,7 +104,6 @@ vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/st const mockUseStoreApi = vi.mocked(useStoreApi) const mockUseNodeDataUpdateHook = vi.mocked(useNodeDataUpdate) const mockUseNodesSyncDraftHook = vi.mocked(useNodesSyncDraft) -const mockUseDatasourceSingleRunHook = vi.mocked(useDatasourceSingleRun) const mockUseInvalidLastRunHook = vi.mocked(useInvalidLastRun) const mockFetchNodeInspectVarsFn = vi.mocked(fetchNodeInspectVars) const mockUseDataSourceStoreHook = vi.mocked(useDataSourceStore) @@ -180,7 +185,7 @@ describe('data-source/hooks/use-before-run-form', () => { mockUseDatasourceSingleRunHook.mockReturnValue({ mutateAsync: (...args: unknown[]) => mockMutateAsync(...args), isPending: false, - } as ReturnType) + }) mockUseInvalidLastRunHook.mockReturnValue(mockInvalidLastRun) mockFetchNodeInspectVarsFn.mockImplementation((...args: unknown[]) => mockFetchNodeInspectVars(...args), @@ -252,7 +257,7 @@ describe('data-source/hooks/use-before-run-form', () => { ] mockMutateAsync.mockImplementation((payload: unknown, options: DatasourceSingleRunOptions) => { - options.onSettled?.({ status: NodeRunningStatus.Succeeded } as NodeRunResult) + options.onSettled?.({ id: 'node-run-1', status: NodeRunningStatus.Succeeded }) return Promise.resolve(payload) }) @@ -272,15 +277,17 @@ describe('data-source/hooks/use-before-run-form', () => { }), }) expect(mockMutateAsync).toHaveBeenCalledWith( - expect.objectContaining({ - pipeline_id: 'flow-id', - start_node_id: 'data-source-node', - datasource_type: DatasourceType.localFile, - datasource_info: expect.objectContaining({ - related_id: 'file-1', - transfer_method: TransferMethod.local_file, + { + params: { pipeline_id: 'flow-id' }, + body: expect.objectContaining({ + start_node_id: 'data-source-node', + datasource_type: DatasourceType.localFile, + datasource_info: expect.objectContaining({ + related_id: 'file-1', + transfer_method: TransferMethod.local_file, + }), }), - }), + }, expect.any(Object), ) expect(mockFetchNodeInspectVars).toHaveBeenCalledWith( diff --git a/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts b/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts index 9489071ff4402b..50d053b955a02d 100644 --- a/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts +++ b/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts @@ -1,5 +1,6 @@ import type { CustomRunFormProps, DataSourceNodeType } from '../types' import type { NodeRunResult } from '@/types/workflow' +import { useMutation } from '@tanstack/react-query' import { useEffect, useMemo, useRef } from 'react' import { useStoreApi } from 'reactflow' import { useShallow } from 'zustand/react/shallow' @@ -7,8 +8,9 @@ import { useDataSourceStore, useDataSourceStoreWithSelector, } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' +import { selectDatasourceSingleRun } from '@/app/components/rag-pipeline/utils/query-select' import { DatasourceType } from '@/models/pipeline' -import { useDatasourceSingleRun } from '@/service/use-pipeline' +import { consoleQuery } from '@/service/console' import { useInvalidLastRun } from '@/service/use-workflow' import { fetchNodeInspectVars } from '@/service/workflow' import { TransferMethod } from '@/types/app' @@ -102,7 +104,9 @@ const useBeforeRunForm = ({ if (data?.status === NodeRunningStatus.Succeeded) onSuccess() } - const { mutateAsync: handleDatasourceSingleRun, isPending } = useDatasourceSingleRun() + const { mutateAsync: handleDatasourceSingleRun, isPending } = useMutation( + consoleQuery.rag.pipelines.byPipelineId.workflows.draft.datasource.variablesInspect.post.mutationOptions(), + ) const handleRun = () => { let datasourceInfo: Record = {} @@ -152,11 +156,13 @@ const useBeforeRunForm = ({ let hasError = false handleDatasourceSingleRun( { - pipeline_id: flowId, - start_node_id: nodeId, - start_node_title: datasourceNodeData.title, - datasource_type: datasourceType, - datasource_info: datasourceInfo, + params: { pipeline_id: flowId }, + body: { + start_node_id: nodeId, + start_node_title: datasourceNodeData.title, + datasource_type: datasourceType, + datasource_info: datasourceInfo, + }, }, { onError: () => { @@ -173,7 +179,7 @@ const useBeforeRunForm = ({ }) }, onSettled: (data) => { - updateRunResult(data!) + updateRunResult(selectDatasourceSingleRun(data!)) if (!hasError && !isPausedRef.current) { handleNodeDataUpdate({ id: nodeId, diff --git a/web/app/components/workflow/plugin-dependency/__tests__/hooks.spec.tsx b/web/app/components/workflow/plugin-dependency/__tests__/hooks.spec.tsx index 4624e967ab3d24..ad8d79aca4615c 100644 --- a/web/app/components/workflow/plugin-dependency/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/plugin-dependency/__tests__/hooks.spec.tsx @@ -11,8 +11,21 @@ const mocks = vi.hoisted(() => ({ pipeline: vi.fn(), })) -vi.mock('@/service/console', () => ({ +vi.mock('@/service/console', async () => ({ consoleQuery: { + rag: { + pipelines: { + imports: { + byPipelineId: { + checkDependencies: { + get: (await import('@/test/console/operation-hooks')).consoleOperationStub( + 'rag.pipelines.imports.byPipelineId.checkDependencies.get', + ), + }, + }, + }, + }, + }, apps: { imports: { byAppId: { @@ -28,9 +41,14 @@ vi.mock('@/service/console', () => ({ }, }, })) -vi.mock('@/service/use-pipeline', () => ({ - useCheckPipelineDependencies: () => ({ mutateAsync: mocks.pipeline }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipelines.imports.byPipelineId.checkDependencies.get': () => ({ + mutateAsync: mocks.pipeline, + }), + }) +}) vi.mock('@/app/notifications', () => ({ toast: { error: mocks.error } })) function renderDependencies() { diff --git a/web/app/components/workflow/plugin-dependency/hooks.ts b/web/app/components/workflow/plugin-dependency/hooks.ts index 3180798601c88d..0e9f2fb0515dbe 100644 --- a/web/app/components/workflow/plugin-dependency/hooks.ts +++ b/web/app/components/workflow/plugin-dependency/hooks.ts @@ -1,9 +1,9 @@ import { useMutation } from '@tanstack/react-query' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { selectPipelineDependencies } from '@/app/components/rag-pipeline/utils/query-select' import { toast } from '@/app/notifications' import { consoleQuery } from '@/service/console' -import { useCheckPipelineDependencies } from '@/service/use-pipeline' import { getErrorMessage } from '@/utils/error-message' import { useStore as usePluginDependenciesStore } from './store' @@ -14,14 +14,19 @@ export const usePluginDependencies = () => { context: { silent: true }, }), ) - const { mutateAsync: checkPipelineDependencies } = useCheckPipelineDependencies() + const { mutateAsync: checkPipelineDependencies } = useMutation( + consoleQuery.rag.pipelines.imports.byPipelineId.checkDependencies.get.mutationOptions(), + ) const handleCheckPluginDependencies = useCallback( async (id: string, isPipeline = false) => { const { setDependencies } = usePluginDependenciesStore.getState() if (isPipeline) { - const { leaked_dependencies } = await checkPipelineDependencies(id) - setDependencies(leaked_dependencies) + setDependencies( + selectPipelineDependencies( + await checkPipelineDependencies({ params: { pipeline_id: id } }), + ), + ) return true } diff --git a/web/models/pipeline.ts b/web/models/pipeline.ts index bea17fe7b321b9..0885a0c5fc5842 100644 --- a/web/models/pipeline.ts +++ b/web/models/pipeline.ts @@ -1,20 +1,11 @@ -import type { Viewport } from 'reactflow' -import type { DSLImportMode, DSLImportStatus } from './app' -import type { - ChunkingMode, - DocumentIndexingStatus, - FileIndexingEstimateResponse, - IconInfo, -} from './datasets' -import type { Dependency } from '@/app/components/plugins/types' import type { - Edge, - EnvironmentVariable, - Node, - SupportUploadFileTypes, -} from '@/app/components/workflow/types' + PipelineDocument, + PipelineGenerateResponse, +} from '@dify/contracts/api/console/rag/types.gen' +import type { Viewport } from 'reactflow' +import type { ChunkingMode, IconInfo } from './datasets' +import type { Edge, Node, SupportUploadFileTypes } from '@/app/components/workflow/types' import type { TransferMethod } from '@/types/app' -import type { NodeRunResult } from '@/types/workflow' import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' export const DatasourceType = { @@ -26,11 +17,6 @@ export const DatasourceType = { export type DatasourceType = (typeof DatasourceType)[keyof typeof DatasourceType] -export type PipelineTemplateListParams = { - type: 'built-in' | 'customized' - language?: string -} - export type PipelineTemplate = { id: string name: string @@ -40,15 +26,6 @@ export type PipelineTemplate = { chunk_structure: ChunkingMode } -export type PipelineTemplateListResponse = { - pipeline_templates: PipelineTemplate[] -} - -export type PipelineTemplateByIdRequest = { - template_id: string - type: 'built-in' | 'customized' -} - export type PipelineTemplateByIdResponse = { id: string name: string @@ -64,59 +41,6 @@ export type PipelineTemplateByIdResponse = { created_by: string } -export type UpdateTemplateInfoRequest = { - template_id: string - name: string - icon_info: IconInfo - description: string -} - -export type UpdateTemplateInfoResponse = { - pipeline_id: string - name: string - icon: IconInfo - description: string - position: number -} - -export type DeleteTemplateResponse = { - code: number -} - -export type ExportTemplateDSLResponse = { - data: string -} - -export type ImportPipelineDSLRequest = { - mode: DSLImportMode - yaml_content?: string - yaml_url?: string - pipeline_id?: string -} - -export type ImportPipelineDSLResponse = { - id: string - status: DSLImportStatus - pipeline_id: string - dataset_id: string - current_dsl_version: string - imported_dsl_version: string - error?: string -} - -export type ImportPipelineDSLConfirmResponse = { - status: DSLImportStatus - pipeline_id: string - dataset_id: string - current_dsl_version: string - imported_dsl_version: string - error: string -} - -export type PipelineCheckDependenciesResponse = { - leaked_dependencies: Dependency[] -} - export const PipelineInputVarType = { textInput: 'text-input', paragraph: 'paragraph', @@ -145,7 +69,7 @@ export type RAGPipelineVariable = { label: string variable: string max_length?: number - default_value?: string + default_value?: string | number | boolean placeholder?: string unit?: string required: boolean @@ -159,104 +83,13 @@ export type RAGPipelineVariable = { export type InputVar = Omit export type RAGPipelineVariables = RAGPipelineVariable[] -export type PipelineProcessingParamsRequest = { - pipeline_id: string - node_id: string -} - export type PipelineProcessingParamsResponse = { variables: RAGPipelineVariables } -export type PipelinePreProcessingParamsRequest = { - pipeline_id: string - node_id: string -} +export type PublishedPipelineRunResponse = PipelineGenerateResponse -export type PipelinePreProcessingParamsResponse = { - variables: RAGPipelineVariables -} - -export type PublishedPipelineInfoResponse = { - id: string - graph: { - nodes: Node[] - edges: Edge[] - viewport: Viewport - } - created_at: number - created_by: { - id: string - name: string - email: string - } - hash: string - updated_at: number - updated_by: { - id: string - name: string - email: string - } - environment_variables?: EnvironmentVariable[] - rag_pipeline_variables?: RAGPipelineVariables - version: string - marked_name: string - marked_comment: string -} - -export type PublishedPipelineRunRequest = { - pipeline_id: string - inputs: Record - start_node_id: string - datasource_type: DatasourceType - datasource_info_list: Array> - original_document_id?: string - is_preview: boolean -} - -export type PublishedPipelineRunPreviewResponse = { - task_iod: string - workflow_run_id: string - data: { - id: string - status: string - created_at: number - elapsed_time: number - error: string - finished_at: number - outputs: FileIndexingEstimateResponse - total_steps: number - total_tokens: number - workflow_id: string - } -} - -export type PublishedPipelineRunResponse = { - batch: string - dataset: { - chunk_structure: ChunkingMode - description: string - id: string - name: string - } - documents: InitialDocumentDetail[] -} - -export type InitialDocumentDetail = { - data_source_info: Record - data_source_type: DatasourceType - enable: boolean - error: string - id: string - indexing_status: DocumentIndexingStatus - name: string - position: number -} - -export type PipelineExecutionLogRequest = { - dataset_id: string - document_id: string -} +export type InitialDocumentDetail = PipelineDocument export type PipelineExecutionLogResponse = { datasource_info: Record @@ -265,25 +98,6 @@ export type PipelineExecutionLogResponse = { datasource_node_id: string } -export type OnlineDocumentPreviewRequest = { - workspaceID: string - pageID: string - pageType: string - pipelineId: string - datasourceNodeId: string - credentialId: string -} - -export type OnlineDocumentPreviewResponse = { - content: string -} - -export type ConversionResponse = { - pipeline_id: string - dataset_id: string - status: 'success' | 'failed' -} - export const OnlineDriveFileType = { file: 'file', folder: 'folder', @@ -298,13 +112,3 @@ export type OnlineDriveFile = { size?: number type: OnlineDriveFileType } - -export type DatasourceNodeSingleRunRequest = { - pipeline_id: string - start_node_id: string - start_node_title: string - datasource_type: DatasourceType - datasource_info: Record -} - -export type DatasourceNodeSingleRunResponse = NodeRunResult diff --git a/web/service/__tests__/use-pipeline.spec.tsx b/web/service/__tests__/use-pipeline.spec.tsx deleted file mode 100644 index 999028b5d73d12..00000000000000 --- a/web/service/__tests__/use-pipeline.spec.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { ReactNode } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, renderHook } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { DSLImportMode, DSLImportStatus } from '@/models/app' -import { consoleClient } from '../console' -import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '../use-pipeline' - -vi.mock('../console', () => ({ - consoleClient: { - rag: { - pipelines: { - imports: { - post: vi.fn(), - byImportId: { confirm: { post: vi.fn() } }, - }, - }, - }, - }, -})) - -const importPost = vi.mocked(consoleClient.rag.pipelines.imports.post) -const confirmPost = vi.mocked(consoleClient.rag.pipelines.imports.byImportId.confirm.post) - -const createWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }) - - return ({ children }: { children: ReactNode }) => ( - {children} - ) -} - -describe('use-pipeline imports', () => { - beforeEach(() => { - vi.clearAllMocks() - const importResult = { - id: 'import-id', - status: DSLImportStatus.COMPLETED, - pipeline_id: 'pipeline-id', - dataset_id: 'dataset-id', - current_dsl_version: '0.1.0', - imported_dsl_version: '0.1.0', - } - importPost.mockResolvedValue(importResult as never) - confirmPost.mockResolvedValue(importResult as never) - }) - - it('should import pipeline DSL silently so callers can own error toasts', async () => { - const { result } = renderHook(() => useImportPipelineDSL(), { wrapper: createWrapper() }) - const request = { - mode: DSLImportMode.YAML_CONTENT, - yaml_content: 'rag_pipeline: {}', - } - - await act(async () => { - await result.current.mutateAsync(request) - }) - - expect(importPost).toHaveBeenCalledWith({ body: request }, { context: { silent: true } }) - }) - - it('should confirm pipeline DSL import silently so callers can own error toasts', async () => { - const { result } = renderHook(() => useImportPipelineDSLConfirm(), { wrapper: createWrapper() }) - - await act(async () => { - await result.current.mutateAsync('import-id') - }) - - expect(confirmPost).toHaveBeenCalledWith( - { params: { import_id: 'import-id' } }, - { context: { silent: true } }, - ) - }) -}) diff --git a/web/service/console/query-policies.ts b/web/service/console/query-policies.ts index 2b661c121a7e0a..409e66ead02b60 100644 --- a/web/service/console/query-policies.ts +++ b/web/service/console/query-policies.ts @@ -85,12 +85,36 @@ export function createConsoleQuery(consoleClient: ConsoleClient) { }, }, rag: { + pipeline: { + templates: { + byTemplateId: { get: { queryOptions: { staleTime: 0 } } }, + }, + }, pipelines: { datasourcePlugins: { get: { queryOptions: { staleTime: 0, retry: false }, }, }, + // DSL import failures are reported by the import flow. + imports: { + post: { mutationOptions: { context: { silent: true } } }, + byImportId: { + confirm: { post: { mutationOptions: { context: { silent: true } } } }, + }, + }, + byPipelineId: { + workflows: { + draft: { + processing: { parameters: { get: { queryOptions: { staleTime: 0 } } } }, + preProcessing: { parameters: { get: { queryOptions: { staleTime: 0 } } } }, + }, + published: { + processing: { parameters: { get: { queryOptions: { staleTime: 0 } } } }, + preProcessing: { parameters: { get: { queryOptions: { staleTime: 0 } } } }, + }, + }, + }, }, }, workspaces: { @@ -893,6 +917,13 @@ export function createConsoleQuery(consoleClient: ConsoleClient) { }, }, datasets: { + byDatasetId: { + documents: { + byDocumentId: { + pipelineExecutionLog: { get: { queryOptions: { staleTime: 0 } } }, + }, + }, + }, apiKeys: { post: { mutationOptions: { diff --git a/web/service/use-pipeline.ts b/web/service/use-pipeline.ts deleted file mode 100644 index dd6ddf43e84f60..00000000000000 --- a/web/service/use-pipeline.ts +++ /dev/null @@ -1,416 +0,0 @@ -import type { MutationOptions } from '@tanstack/react-query' -import type { IconInfo } from '@/models/datasets' -import type { - ConversionResponse, - DatasourceNodeSingleRunRequest, - DatasourceNodeSingleRunResponse, - DeleteTemplateResponse, - ExportTemplateDSLResponse, - ImportPipelineDSLConfirmResponse, - ImportPipelineDSLRequest, - ImportPipelineDSLResponse, - OnlineDocumentPreviewRequest, - OnlineDocumentPreviewResponse, - PipelineCheckDependenciesResponse, - PipelineExecutionLogRequest, - PipelineExecutionLogResponse, - PipelinePreProcessingParamsRequest, - PipelinePreProcessingParamsResponse, - PipelineProcessingParamsRequest, - PipelineProcessingParamsResponse, - PipelineTemplateByIdRequest, - PipelineTemplateByIdResponse, - PipelineTemplateListParams, - PipelineTemplateListResponse, - PublishedPipelineInfoResponse, - PublishedPipelineRunPreviewResponse, - PublishedPipelineRunRequest, - PublishedPipelineRunResponse, - UpdateTemplateInfoRequest, - UpdateTemplateInfoResponse, -} from '@/models/pipeline' -import { useMutation, useQuery } from '@tanstack/react-query' -import { DatasourceType } from '@/models/pipeline' -import { consoleClient } from './console' -import { narrowResponse } from './console/narrow-response' -import { useInvalid } from './use-base' - -const NAME_SPACE = 'pipeline' - -const PipelineTemplateListQueryKeyPrefix = [NAME_SPACE, 'template-list'] -export const usePipelineTemplateList = (params: PipelineTemplateListParams, enabled = true) => { - return useQuery({ - queryKey: [...PipelineTemplateListQueryKeyPrefix, params], - queryFn: () => { - return narrowResponse( - consoleClient.rag.pipeline.templates.get({ query: params }), - ) - }, - enabled, - }) -} - -export const useInvalidCustomizedTemplateList = () => { - return useInvalid([...PipelineTemplateListQueryKeyPrefix, 'customized']) -} - -export const usePipelineTemplateById = (params: PipelineTemplateByIdRequest, enabled: boolean) => { - const { template_id, type } = params - return useQuery({ - queryKey: [NAME_SPACE, 'template', type, template_id], - queryFn: () => { - return narrowResponse( - consoleClient.rag.pipeline.templates.byTemplateId.get({ - params: { template_id }, - query: { - type, - }, - }), - ) - }, - enabled, - staleTime: 0, - }) -} - -export const useUpdateTemplateInfo = ( - mutationOptions: MutationOptions< - UpdateTemplateInfoResponse, - Error, - UpdateTemplateInfoRequest - > = {}, -) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'template-update'], - mutationFn: (request: UpdateTemplateInfoRequest) => { - const { template_id, ...rest } = request - return narrowResponse( - consoleClient.rag.pipeline.customized.templates.byTemplateId.patch({ - params: { template_id }, - body: rest, - }), - ) - }, - ...mutationOptions, - }) -} - -export const useDeleteTemplate = ( - mutationOptions: MutationOptions = {}, -) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'template-delete'], - mutationFn: (templateId: string) => { - return narrowResponse( - consoleClient.rag.pipeline.customized.templates.byTemplateId.delete({ - params: { template_id: templateId }, - }), - ) - }, - ...mutationOptions, - }) -} - -export const useExportTemplateDSL = ( - mutationOptions: MutationOptions = {}, -) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'template-dsl-export'], - mutationFn: (templateId: string) => { - return narrowResponse( - consoleClient.rag.pipeline.customized.templates.byTemplateId.post({ - params: { template_id: templateId }, - }), - ) - }, - ...mutationOptions, - }) -} - -export const useImportPipelineDSL = ( - mutationOptions: MutationOptions = {}, -) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'dsl-import'], - mutationFn: (request: ImportPipelineDSLRequest) => { - return narrowResponse( - consoleClient.rag.pipelines.imports.post({ body: request }, { context: { silent: true } }), - ) - }, - ...mutationOptions, - }) -} - -export const useImportPipelineDSLConfirm = ( - mutationOptions: MutationOptions = {}, -) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'dsl-import-confirm'], - mutationFn: (importId: string) => { - return narrowResponse( - consoleClient.rag.pipelines.imports.byImportId.confirm.post( - { params: { import_id: importId } }, - { context: { silent: true } }, - ), - ) - }, - ...mutationOptions, - }) -} - -export const useCheckPipelineDependencies = ( - mutationOptions: MutationOptions = {}, -) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'check-dependencies'], - mutationFn: (pipelineId: string) => { - return narrowResponse( - consoleClient.rag.pipelines.imports.byPipelineId.checkDependencies.get({ - params: { pipeline_id: pipelineId }, - }), - ) - }, - ...mutationOptions, - }) -} - -export const useDraftPipelineProcessingParams = ( - params: PipelineProcessingParamsRequest, - enabled = true, -) => { - const { pipeline_id, node_id } = params - return useQuery({ - queryKey: [NAME_SPACE, 'draft-pipeline-processing-params', pipeline_id, node_id], - queryFn: () => { - return narrowResponse( - consoleClient.rag.pipelines.byPipelineId.workflows.draft.processing.parameters.get({ - params: { pipeline_id }, - query: { - node_id, - }, - }), - ) - }, - staleTime: 0, - enabled, - }) -} - -export const usePublishedPipelineProcessingParams = (params: PipelineProcessingParamsRequest) => { - const { pipeline_id, node_id } = params - return useQuery({ - queryKey: [NAME_SPACE, 'published-pipeline-processing-params', pipeline_id, node_id], - queryFn: () => { - return narrowResponse( - consoleClient.rag.pipelines.byPipelineId.workflows.published.processing.parameters.get({ - params: { pipeline_id }, - query: { - node_id, - }, - }), - ) - }, - staleTime: 0, - }) -} - -export const publishedPipelineInfoQueryKeyPrefix = [NAME_SPACE, 'published-pipeline'] - -export const usePublishedPipelineInfo = (pipelineId: string) => { - return useQuery({ - queryKey: [...publishedPipelineInfoQueryKeyPrefix, pipelineId], - queryFn: () => { - return narrowResponse( - consoleClient.rag.pipelines.byPipelineId.workflows.publish.get({ - params: { pipeline_id: pipelineId }, - }), - ) - }, - enabled: !!pipelineId, - }) -} - -export const useRunPublishedPipeline = ( - mutationOptions: MutationOptions< - PublishedPipelineRunPreviewResponse | PublishedPipelineRunResponse, - Error, - PublishedPipelineRunRequest - > = {}, -) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'run-published-pipeline'], - mutationFn: (request: PublishedPipelineRunRequest) => { - const { pipeline_id: pipelineId, is_preview, ...rest } = request - return narrowResponse( - consoleClient.rag.pipelines.byPipelineId.workflows.published.run.post({ - params: { pipeline_id: pipelineId }, - body: { - ...rest, - is_preview, - response_mode: 'blocking', - }, - }), - ) - }, - ...mutationOptions, - }) -} - -export const useDraftPipelinePreProcessingParams = ( - params: PipelinePreProcessingParamsRequest, - enabled = true, -) => { - const { pipeline_id, node_id } = params - return useQuery({ - queryKey: [NAME_SPACE, 'draft-pipeline-pre-processing-params', pipeline_id, node_id], - queryFn: () => { - return narrowResponse( - consoleClient.rag.pipelines.byPipelineId.workflows.draft.preProcessing.parameters.get({ - params: { pipeline_id }, - query: { - node_id, - }, - }), - ) - }, - staleTime: 0, - enabled, - }) -} - -export const usePublishedPipelinePreProcessingParams = ( - params: PipelinePreProcessingParamsRequest, - enabled = true, -) => { - const { pipeline_id, node_id } = params - return useQuery({ - queryKey: [NAME_SPACE, 'published-pipeline-pre-processing-params', pipeline_id, node_id], - queryFn: () => { - return narrowResponse( - consoleClient.rag.pipelines.byPipelineId.workflows.published.preProcessing.parameters.get({ - params: { pipeline_id }, - query: { - node_id, - }, - }), - ) - }, - staleTime: 0, - enabled, - }) -} - -export const useExportPipelineDSL = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'export-pipeline-dsl'], - mutationFn: ({ pipelineId, include = false }: { pipelineId: string; include?: boolean }) => { - return narrowResponse( - consoleClient.rag.pipelines.byPipelineId.exports.get({ - params: { pipeline_id: pipelineId }, - query: { include_secret: String(include) }, - }), - ) - }, - }) -} - -export const usePublishAsCustomizedPipeline = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'publish-as-customized-pipeline'], - mutationFn: ({ - pipelineId, - name, - icon_info, - description, - }: { - pipelineId: string - name: string - icon_info: IconInfo - description?: string - }) => { - return consoleClient.rag.pipelines.byPipelineId.customized.publish.post({ - params: { pipeline_id: pipelineId }, - body: { - name, - icon_info, - description, - }, - }) - }, - }) -} - -export const usePipelineExecutionLog = (params: PipelineExecutionLogRequest) => { - const { dataset_id, document_id } = params - return useQuery({ - queryKey: [NAME_SPACE, 'pipeline-execution-log', dataset_id, document_id], - queryFn: () => { - return narrowResponse( - consoleClient.datasets.byDatasetId.documents.byDocumentId.pipelineExecutionLog.get({ - params: { dataset_id, document_id }, - }), - ) - }, - staleTime: 0, - }) -} - -export const usePreviewOnlineDocument = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'preview-online-document'], - mutationFn: (params: OnlineDocumentPreviewRequest) => { - const { pipelineId, datasourceNodeId, workspaceID, pageID, pageType, credentialId } = params - return narrowResponse( - consoleClient.rag.pipelines.byPipelineId.workflows.published.datasource.nodes.byNodeId.preview.post( - { - params: { pipeline_id: pipelineId, node_id: datasourceNodeId }, - body: { - datasource_type: DatasourceType.onlineDocument, - credential_id: credentialId, - inputs: { - workspace_id: workspaceID, - page_id: pageID, - type: pageType, - }, - }, - }, - ), - ) - }, - }) -} - -export const useConvertDatasetToPipeline = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'convert-dataset-to-pipeline'], - mutationFn: (datasetId: string) => { - return narrowResponse( - consoleClient.rag.pipelines.transform.datasets.byDatasetId.post({ - params: { dataset_id: datasetId }, - }), - ) - }, - }) -} - -export const useDatasourceSingleRun = ( - mutationOptions: MutationOptions< - DatasourceNodeSingleRunResponse, - Error, - DatasourceNodeSingleRunRequest - > = {}, -) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'datasource-node-single-run'], - mutationFn: (params: DatasourceNodeSingleRunRequest) => { - const { pipeline_id: pipelineId, ...rest } = params - return narrowResponse( - consoleClient.rag.pipelines.byPipelineId.workflows.draft.datasource.variablesInspect.post({ - params: { pipeline_id: pipelineId }, - body: rest, - }), - ) - }, - ...mutationOptions, - }) -} From 9038b78eee809466f73d71b86bc4ceb07b0aca87 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:56:21 +0800 Subject: [PATCH 38/60] refactor(web): narrow RAG plugin recommendations from the contract The recommended plugins endpoint now declares its response, so installed recommendations reuse the tool provider mapping and uninstalled ones keep the marketplace manifests that identify an installable package, instead of asserting the whole response. Co-Authored-By: Claude Opus 5.5 --- .../utils/__tests__/query-select.spec.ts | 18 ++++++++++++++++ .../components/tools/utils/query-select.ts | 21 +++++++++++++++---- web/app/components/workflow/types.ts | 5 +++-- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/web/app/components/tools/utils/__tests__/query-select.spec.ts b/web/app/components/tools/utils/__tests__/query-select.spec.ts index f495a959a2ec9c..798bcb075e8ecd 100644 --- a/web/app/components/tools/utils/__tests__/query-select.spec.ts +++ b/web/app/components/tools/utils/__tests__/query-select.spec.ts @@ -2,6 +2,7 @@ import type { ToolProviderApiEntityResponse } from '@dify/contracts/api/console/ import { describe, expect, it } from 'vite-plus/test' import { selectMCPServerDetail, + selectRAGRecommendedPlugins, selectToolsWithProvider, selectWorkflowToolDetail, } from '../query-select' @@ -136,4 +137,21 @@ describe('tool query selectors', () => { properties: { text: { type: 'string', description: 'Text' } }, }) }) + + it('should keep installable marketplace recommendations', () => { + const plugin = { + type: 'plugin', + org: 'langgenius', + name: 'notion', + plugin_id: 'langgenius/notion', + latest_package_identifier: 'langgenius/notion:1.0.0@hash', + } + const recommendations = selectRAGRecommendedPlugins({ + installed_recommended_plugins: [createProvider()], + uninstalled_recommended_plugins: [plugin, { plugin_id: 'broken' }], + }) + + expect(recommendations.installed_recommended_plugins[0]!.tools).toEqual([]) + expect(recommendations.uninstalled_recommended_plugins).toEqual([plugin]) + }) }) diff --git a/web/app/components/tools/utils/query-select.ts b/web/app/components/tools/utils/query-select.ts index a6f3bc31e7cd4d..274034abeaebbd 100644 --- a/web/app/components/tools/utils/query-select.ts +++ b/web/app/components/tools/utils/query-select.ts @@ -1,5 +1,5 @@ import type { AppMcpServerResponse } from '@dify/contracts/api/console/apps/types.gen' -import type { RagPipelineOpaqueResponse } from '@dify/contracts/api/console/rag/types.gen' +import type { RagPipelineRecommendedPluginsResponse } from '@dify/contracts/api/console/rag/types.gen' import type { ToolParameter as ContractToolParameter, I18nObject, @@ -9,6 +9,7 @@ import type { ToolProviderListResponse, WorkflowToolDetailResponse, } from '@dify/contracts/api/console/workspaces/types.gen' +import type { MarketplacePlugin } from '@dify/contracts/marketplace' import type { Collection, Emoji, @@ -164,6 +165,18 @@ export const selectWorkflowToolDetail = ( }, }) -// The recommended plugins endpoint declares no response schema, so there is nothing to narrow. -export const selectRAGRecommendedPlugins = (data: RagPipelineOpaqueResponse) => - data as RAGRecommendedPlugins +// Uninstalled recommendations are marketplace manifests the console proxies unchanged; keep the +// ones that identify an installable package. +const isMarketplacePlugin = (value: Record): value is MarketplacePlugin => + typeof value.type === 'string' && + typeof value.org === 'string' && + typeof value.name === 'string' && + typeof value.plugin_id === 'string' && + typeof value.latest_package_identifier === 'string' + +export const selectRAGRecommendedPlugins = ( + data: RagPipelineRecommendedPluginsResponse, +): RAGRecommendedPlugins => ({ + installed_recommended_plugins: data.installed_recommended_plugins.map(selectToolProvider), + uninstalled_recommended_plugins: data.uninstalled_recommended_plugins.filter(isMarketplacePlugin), +}) diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 6c1c6f82547a92..5d5782ebe6db82 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -1,6 +1,7 @@ import type { RagPipelineDatasourceProviderResponse } from '@dify/contracts/api/console/rag/types.gen' +import type { MarketplacePlugin } from '@dify/contracts/marketplace' import type { Edge as ReactFlowEdge, Node as ReactFlowNode, Viewport, XYPosition } from 'reactflow' -import type { Plugin, PluginMeta } from '@/app/components/plugins/types' +import type { PluginMeta } from '@/app/components/plugins/types' import type { Collection, Tool } from '@/app/components/tools/types' import type { BlockClassification, @@ -519,7 +520,7 @@ export type ToolWithProvider = Collection & { export type RAGRecommendedPlugins = { installed_recommended_plugins: ToolWithProvider[] - uninstalled_recommended_plugins: Plugin[] + uninstalled_recommended_plugins: MarketplacePlugin[] } export const SupportUploadFileTypes = { From cd6c270c5556e172993b4fd8266c7bf4014eda1c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:59:47 +0000 Subject: [PATCH 39/60] [autofix.ci] apply automated fixes --- api/openapi/markdown/console-openapi.md | 88 +++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 4 deletions(-) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index ea865f7ac00c9d..fb51d8037d7caa 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -8434,7 +8434,7 @@ Update account-level Step-by-step Tour state | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| +| 200 | Success | **application/json**: [RagPipelineRecommendedPluginsResponse](#ragpipelinerecommendedpluginsresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [POST] /rag/pipelines/transform/datasets/{dataset_id} @@ -8448,7 +8448,7 @@ Update account-level Step-by-step Tour state | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| +| 200 | Success | **application/json**: [RagPipelineTransformResponse](#ragpipelinetransformresponse)
| | 404 | Dataset or pipeline not found | **application/json**: [ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| @@ -9162,7 +9162,7 @@ Update account-level Step-by-step Tour state | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| +| 200 | Success | **application/json**: [RagPipelinePublishedRunResponse](#ragpipelinepublishedrunresponse)
| | 404 | Not Found | **application/json**: [PipelineNotFoundError](#pipelinenotfounderror)
[ErrorResponse](#errorresponse)
| | 429 | Too Many Requests | **application/json**: [RateLimitError](#ratelimiterror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| @@ -24153,6 +24153,36 @@ Shared permission levels for resources (datasets, credentials, etc.) | ---- | ---- | ----------- | -------- | | PermissionEnum | string | Shared permission levels for resources (datasets, credentials, etc.) | | +#### PipelineDataset + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| chunk_structure | string | | Yes | +| description | string | knowledge dataset description | No | +| id | string | | Yes | +| name | string | | Yes | + +#### PipelineDocument + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data_source_info | object | | No | +| data_source_type | string | | Yes | +| enabled | boolean | | Yes | +| error | string | | No | +| id | string | | Yes | +| indexing_status | string | | Yes | +| name | string | | Yes | +| position | integer | | Yes | + +#### PipelineGenerateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| batch | string | | Yes | +| dataset | [PipelineDataset](#pipelinedataset) | | Yes | +| documents | [ [PipelineDocument](#pipelinedocument) ] | | Yes | + #### PipelineNotFoundError Pipeline not found. @@ -25177,17 +25207,40 @@ Resource types understood by access policies. | ---- | ---- | ----------- | -------- | | RagPipelineOpaqueResponse | | | | +#### RagPipelinePublishedRunResponse + +Blocking run result: a preview run returns the workflow result, a processing run the queued batch. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| RagPipelinePublishedRunResponse | [PipelineGenerateResponse](#pipelinegenerateresponse)
[WorkflowAppBlockingResponse](#workflowappblockingresponse) | Blocking run result: a preview run returns the workflow result, a processing run the queued batch. | | + #### RagPipelineRecommendedPluginQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | type | string,
**Default:** all | | No | +#### RagPipelineRecommendedPluginsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| installed_recommended_plugins | [ [ToolProviderApiEntityResponse](#toolproviderapientityresponse) ] | | Yes | +| uninstalled_recommended_plugins | [ object ] | | Yes | + #### RagPipelineStepParametersResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| variables | | | Yes | +| variables | [ [PipelineVariableResponse](#pipelinevariableresponse) ] | | Yes | + +#### RagPipelineTransformResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| dataset_id | string | | Yes | +| pipeline_id | string | | Yes | +| status | string | | Yes | #### RagPipelineWorkflowPublishResponse @@ -28128,6 +28181,33 @@ How a workflow node is bound to an Agent. | node_execution_id | string | Workflow node execution ID | Yes | | path | string | Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; `~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside Workspace are governed by backend isolation, not a Workspace-root restriction | Yes | +#### WorkflowAppBlockingResponse + +WorkflowAppBlockingResponse entity + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [WorkflowAppBlockingResponseData](#workflowappblockingresponsedata) | | Yes | +| task_id | string | | Yes | +| workflow_run_id | string | | Yes | + +#### WorkflowAppBlockingResponseData + +Data entity + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | Yes | +| elapsed_time | number | | Yes | +| error | string | | No | +| finished_at | integer | | Yes | +| id | string | | Yes | +| outputs | object | | No | +| status | [WorkflowExecutionStatus](#workflowexecutionstatus) | | Yes | +| total_steps | integer | | Yes | +| total_tokens | integer | | Yes | +| workflow_id | string | | Yes | + #### WorkflowAppLogPaginationResponse | Name | Type | Description | Required | From ae6410fd4a6e1a9d907d089edba0d4500f966881 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:00:33 +0800 Subject: [PATCH 40/60] fix(api): declare dataset metadata value types Dataset metadata is only created through MetadataArgs and built-in fields are either string or time, so the metadata response models declare the same string, number and time literals the request accepts. The console and service contracts now type metadata entries the way the knowledge UI reads them. Co-Authored-By: Claude Opus 5.5 --- api/fields/dataset_fields.py | 10 +++++++--- .../generated/api/console/datasets/types.gen.ts | 6 +++--- .../generated/api/console/datasets/zod.gen.ts | 6 +++--- packages/contracts/generated/api/service/types.gen.ts | 6 +++--- packages/contracts/generated/api/service/zod.gen.ts | 6 +++--- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/api/fields/dataset_fields.py b/api/fields/dataset_fields.py index e6902f1cab25a5..4b366bf653979f 100644 --- a/api/fields/dataset_fields.py +++ b/api/fields/dataset_fields.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Literal from pydantic import BaseModel, Field, field_validator @@ -18,16 +19,19 @@ class NotionEstimateWorkspacePayload(BaseModel): pages: list[NotionEstimatePagePayload] = Field(min_length=1) +MetadataValueType = Literal["string", "number", "time"] + + class DatasetMetadataResponse(ResponseModel): id: str - type: str + type: MetadataValueType name: str class DatasetMetadataListItemResponse(ResponseModel): id: str name: str - type: str + type: MetadataValueType count: int = 0 @@ -38,7 +42,7 @@ class DatasetMetadataListResponse(ResponseModel): class DatasetMetadataBuiltInFieldResponse(ResponseModel): name: str - type: str + type: MetadataValueType class DatasetMetadataBuiltInFieldsResponse(ResponseModel): diff --git a/packages/contracts/generated/api/console/datasets/types.gen.ts b/packages/contracts/generated/api/console/datasets/types.gen.ts index 3749c3b2c089f6..3684c8679c6659 100644 --- a/packages/contracts/generated/api/console/datasets/types.gen.ts +++ b/packages/contracts/generated/api/console/datasets/types.gen.ts @@ -622,7 +622,7 @@ export type MetadataArgs = { export type DatasetMetadataResponse = { id: string name: string - type: string + type: 'number' | 'string' | 'time' } export type MetadataUpdatePayload = { @@ -797,7 +797,7 @@ export type DatasetResponse = { export type DatasetMetadataBuiltInFieldResponse = { name: string - type: string + type: 'number' | 'string' | 'time' } export type NotionEstimateWorkspacePayload = { @@ -958,7 +958,7 @@ export type DatasetMetadataListItemResponse = { count?: number id: string name: string - type: string + type: 'number' | 'string' | 'time' } export type DatasetQueryDetailResponse = { diff --git a/packages/contracts/generated/api/console/datasets/zod.gen.ts b/packages/contracts/generated/api/console/datasets/zod.gen.ts index 26133098a3484e..0eb7ade22786ad 100644 --- a/packages/contracts/generated/api/console/datasets/zod.gen.ts +++ b/packages/contracts/generated/api/console/datasets/zod.gen.ts @@ -461,7 +461,7 @@ export const zMetadataArgs = z.object({ export const zDatasetMetadataResponse = z.object({ id: z.string(), name: z.string(), - type: z.string(), + type: z.enum(['number', 'string', 'time']), }) /** @@ -679,7 +679,7 @@ export const zDatasetResponse = z.object({ */ export const zDatasetMetadataBuiltInFieldResponse = z.object({ name: z.string(), - type: z.string(), + type: z.enum(['number', 'string', 'time']), }) /** @@ -964,7 +964,7 @@ export const zDatasetMetadataListItemResponse = z.object({ count: z.int().optional().default(0), id: z.string(), name: z.string(), - type: z.string(), + type: z.enum(['number', 'string', 'time']), }) /** diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index fcb5b2b0674854..e607173e32d9a7 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -717,7 +717,7 @@ export type DatasetMetadataActionResponse = { export type DatasetMetadataBuiltInFieldResponse = { name: string - type: string + type: 'number' | 'string' | 'time' } export type DatasetMetadataBuiltInFieldsResponse = { @@ -728,7 +728,7 @@ export type DatasetMetadataListItemResponse = { count?: number id: string name: string - type: string + type: 'number' | 'string' | 'time' } export type DatasetMetadataListResponse = { @@ -739,7 +739,7 @@ export type DatasetMetadataListResponse = { export type DatasetMetadataResponse = { id: string name: string - type: string + type: 'number' | 'string' | 'time' } export type DatasetNameDuplicateError = ErrorResponse & { diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index e93dc52b427e0a..587be01c3a3188 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -679,7 +679,7 @@ export const zDatasetMetadataActionResponse = z.object({ */ export const zDatasetMetadataBuiltInFieldResponse = z.object({ name: z.string(), - type: z.string(), + type: z.enum(['number', 'string', 'time']), }) /** @@ -696,7 +696,7 @@ export const zDatasetMetadataListItemResponse = z.object({ count: z.int().optional().default(0), id: z.string(), name: z.string(), - type: z.string(), + type: z.enum(['number', 'string', 'time']), }) /** @@ -713,7 +713,7 @@ export const zDatasetMetadataListResponse = z.object({ export const zDatasetMetadataResponse = z.object({ id: z.string(), name: z.string(), - type: z.string(), + type: z.enum(['number', 'string', 'time']), }) /** From d889f6ad87b2aa3962304fd26991fe391aba116b Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:01:51 +0800 Subject: [PATCH 41/60] docs(api): regenerate OpenAPI markdown for dataset metadata types Co-Authored-By: Claude Opus 5.5 --- api/openapi/markdown/console-openapi.md | 6 +++--- api/openapi/markdown/service-openapi.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index fb51d8037d7caa..a2c9c051f05f6d 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -20194,7 +20194,7 @@ Dataset access denied. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | name | string | | Yes | -| type | string | | Yes | +| type | string,
**Available values:** "number", "string", "time" | *Enum:* `"number"`, `"string"`, `"time"` | Yes | #### DatasetMetadataBuiltInFieldsResponse @@ -20209,7 +20209,7 @@ Dataset access denied. | count | integer | | No | | id | string | | Yes | | name | string | | Yes | -| type | string | | Yes | +| type | string,
**Available values:** "number", "string", "time" | *Enum:* `"number"`, `"string"`, `"time"` | Yes | #### DatasetMetadataListResponse @@ -20224,7 +20224,7 @@ Dataset access denied. | ---- | ---- | ----------- | -------- | | id | string | | Yes | | name | string | | Yes | -| type | string | | Yes | +| type | string,
**Available values:** "number", "string", "time" | *Enum:* `"number"`, `"string"`, `"time"` | Yes | #### DatasetNameDuplicateError diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index 0f236bb5d06c39..716b65bea30872 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -3093,7 +3093,7 @@ Enum class for custom configuration status. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | name | string | | Yes | -| type | string | | Yes | +| type | string,
**Available values:** "number", "string", "time" | *Enum:* `"number"`, `"string"`, `"time"` | Yes | #### DatasetMetadataBuiltInFieldsResponse @@ -3108,7 +3108,7 @@ Enum class for custom configuration status. | count | integer | | No | | id | string | | Yes | | name | string | | Yes | -| type | string | | Yes | +| type | string,
**Available values:** "number", "string", "time" | *Enum:* `"number"`, `"string"`, `"time"` | Yes | #### DatasetMetadataListResponse @@ -3123,7 +3123,7 @@ Enum class for custom configuration status. | ---- | ---- | ----------- | -------- | | id | string | | Yes | | name | string | | Yes | -| type | string | | Yes | +| type | string,
**Available values:** "number", "string", "time" | *Enum:* `"number"`, `"string"`, `"time"` | Yes | #### DatasetNameDuplicateError From 4ae641cc0fe6b1f61c8a5d9504b2ad861b6d28f1 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:03:23 +0800 Subject: [PATCH 42/60] fix(web): skip the datasource run response mapping when the request fails A failed single run settles with no data, so mapping it threw a TypeError and skipped the inspect var refresh. Map the response only when it exists and keep refreshing inspect vars on failure, as before the migration. Co-Authored-By: Claude Opus 5.5 --- .../hooks/__tests__/use-before-run-form.spec.tsx | 6 +++++- .../nodes/data-source/hooks/use-before-run-form.ts | 7 ++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx index d606ec52ab11d4..0d30c2a175605b 100644 --- a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx +++ b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx @@ -1,7 +1,7 @@ import type { WorkflowRunNodeExecutionResponse } from '@dify/contracts/api/console/rag/types.gen' import type { CustomRunFormProps, DataSourceNodeType } from '../../types' import type { VarInInspect } from '@/types/workflow' -import { act, renderHook } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' import { useStoreApi } from 'reactflow' import { useDataSourceStore, @@ -326,8 +326,10 @@ describe('data-source/hooks/use-before-run-form', () => { }, ] + // TanStack Query settles a failed mutation with no data after onError. mockMutateAsync.mockImplementation((_payload: unknown, options: DatasourceSingleRunOptions) => { options.onError?.() + options.onSettled?.(undefined) return Promise.resolve(undefined) }) @@ -354,5 +356,7 @@ describe('data-source/hooks/use-before-run-form', () => { _singleRunningStatus: NodeRunningStatus.Failed, }), }) + // A failed run can still write default error outputs, so inspect vars refresh. + await waitFor(() => expect(mockFetchNodeInspectVars).toHaveBeenCalled()) }) }) diff --git a/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts b/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts index 50d053b955a02d..c5b552f28fbc4b 100644 --- a/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts +++ b/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts @@ -84,7 +84,7 @@ const useBeforeRunForm = ({ const invalidLastRun = useInvalidLastRun(flowType, flowId, nodeId) - const updateRunResult = async (data: NodeRunResult) => { + const updateRunResult = async (data: NodeRunResult | undefined) => { const isPaused = isPausedRef.current // The backend don't support pause the single run, so the frontend handle the pause state. @@ -92,7 +92,7 @@ const useBeforeRunForm = ({ const canRunLastRun = !isRunAfterSingleRun || runningStatus === NodeRunningStatus.Succeeded if (!canRunLastRun) { - setRunResult(data) + setRunResult(data ?? null) return } @@ -179,7 +179,8 @@ const useBeforeRunForm = ({ }) }, onSettled: (data) => { - updateRunResult(selectDatasourceSingleRun(data!)) + // A failed request settles without data; still refresh inspect vars. + updateRunResult(data ? selectDatasourceSingleRun(data) : undefined) if (!hasError && !isPausedRef.current) { handleNodeDataUpdate({ id: nodeId, From 4337c246cbfcb1f8f6f0f22f9714b2d75738fe84 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:11:43 +0800 Subject: [PATCH 43/60] fix(api): type document process rules and source details Document responses now declare the process rule (mode, pre-processing rules, segmentation with the legacy delimiter read as separator, parent mode and subchunk segmentation), the process rule limits, and the source detail with its uploaded file. A document without its own process rule reports null instead of an empty object. data_source_info stays a JSON object because the importing data source owns its keys. Co-Authored-By: Claude Opus 5.5 --- .../console/datasets/datasets_document.py | 18 +- api/fields/document_fields.py | 51 ++- api/openapi/markdown/console-openapi.md | 74 +++- api/openapi/markdown/service-openapi.md | 22 +- api/services/knowledge/documents/adapters.py | 2 +- .../datasets/test_datasets_document.py | 6 +- .../test_document_process_rule_fields.py | 29 ++ .../api/console/datasets/types.gen.ts | 74 +++- .../generated/api/console/datasets/zod.gen.ts | 326 +++++++++++------- .../generated/api/service/types.gen.ts | 21 +- .../generated/api/service/zod.gen.ts | 120 ++++--- 11 files changed, 520 insertions(+), 223 deletions(-) create mode 100644 api/tests/unit_tests/fields/test_document_process_rule_fields.py diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py index 232d2fb039c8bb..1469db61600922 100644 --- a/api/controllers/console/datasets/datasets_document.py +++ b/api/controllers/console/datasets/datasets_document.py @@ -43,7 +43,9 @@ from fields.base import ResponseModel from fields.document_fields import ( DocumentMetadataResponse, + DocumentProcessRuleResponse, DocumentResponse, + DocumentSourceDetailResponse, DocumentStatusListResponse, DocumentStatusResponse, normalize_enum, @@ -148,7 +150,7 @@ def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: class DocumentWithSegmentsResponse(DocumentResponse): - process_rule_dict: Any = None + process_rule_dict: DocumentProcessRuleResponse | None = None completed_segments: int | None = Field(default=None, exclude_if=lambda value: value is None) total_segments: int | None = Field(default=None, exclude_if=lambda value: value is None) @@ -215,11 +217,11 @@ class DocumentDetailResponse(ResponseModel): id: str position: int | None = None data_source_type: str | None = None - data_source_info: Any = None - data_source_detail_dict: Any = None + data_source_info: dict[str, JsonValue] | None = None + data_source_detail_dict: DocumentSourceDetailResponse | None = None dataset_process_rule_id: str | None = None - dataset_process_rule: Any = None - document_process_rule: Any = None + dataset_process_rule: DocumentProcessRuleResponse | None = None + document_process_rule: DocumentProcessRuleResponse | None = None name: str | None = None created_from: str | None = None created_by: str | None = None @@ -279,10 +281,14 @@ class DocumentSummaryStatusResponse(ResponseModel): summaries: list[SummaryEntryResponse] +class ProcessRuleLimitsResponse(ResponseModel): + indexing_max_segmentation_tokens_length: int + + class ProcessRuleResponse(ResponseModel): mode: ProcessRuleMode rules: Rule | None = None - limits: dict[str, Any] + limits: ProcessRuleLimitsResponse class DocumentPipelineExecutionLogResponse(ResponseModel): diff --git a/api/fields/document_fields.py b/api/fields/document_fields.py index d0122c36e08f25..0290a9c619acab 100644 --- a/api/fields/document_fields.py +++ b/api/fields/document_fields.py @@ -1,9 +1,9 @@ """Response schemas for dataset document endpoints.""" from datetime import datetime -from typing import Any +from typing import Any, Literal -from pydantic import Field, field_validator +from pydantic import AliasChoices, ConfigDict, Field, JsonValue, field_validator from fields.base import ResponseModel from libs.helper import to_timestamp @@ -15,6 +15,48 @@ def normalize_enum(value: Any) -> Any: return getattr(value, "value", value) +class DocumentPreProcessingRuleResponse(ResponseModel): + id: str + enabled: bool + + +class DocumentSegmentationResponse(ResponseModel): + # Older rules store the separator as `delimiter`. + separator: str = Field(default="\n", validation_alias=AliasChoices("separator", "delimiter")) + max_tokens: int + chunk_overlap: int = 0 + + +class DocumentProcessRulesResponse(ResponseModel): + pre_processing_rules: list[DocumentPreProcessingRuleResponse] | None = None + segmentation: DocumentSegmentationResponse | None = None + parent_mode: Literal["full-doc", "paragraph"] | None = None + subchunk_segmentation: DocumentSegmentationResponse | None = None + + +class DocumentProcessRuleResponse(ResponseModel): + mode: str + rules: DocumentProcessRulesResponse | None = None + + +class DocumentUploadFileDetailResponse(ResponseModel): + id: str + name: str + size: int + extension: str + mime_type: str | None = None + created_by: str + created_at: float + + +class DocumentSourceDetailResponse(ResponseModel): + """The uploaded file for file documents; Notion and website documents repeat their source info.""" + + model_config = ConfigDict(extra="allow") + + upload_file: DocumentUploadFileDetailResponse | None = Field(default=None, exclude_if=lambda value: value is None) + + class DocumentMetadataResponse(ResponseModel): id: str name: str @@ -26,8 +68,9 @@ class DocumentResponse(ResponseModel): id: str position: int | None = None data_source_type: str | None = None - data_source_info: dict[str, Any] | None = Field(default=None, validation_alias="data_source_info_dict") - data_source_detail_dict: dict[str, Any] + # Source-specific settings, written by the data source that imported the document. + data_source_info: dict[str, JsonValue] | None = Field(default=None, validation_alias="data_source_info_dict") + data_source_detail_dict: DocumentSourceDetailResponse dataset_process_rule_id: str | None = None name: str created_from: str | None = None diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index a2c9c051f05f6d..63eab136e37680 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -20748,10 +20748,10 @@ Request payload for bulk downloading documents as a zip archive. | created_at | integer | | No | | created_by | string | | No | | created_from | string | | No | -| data_source_detail_dict | | | No | -| data_source_info | | | No | +| data_source_detail_dict | [DocumentSourceDetailResponse](#documentsourcedetailresponse) | | No | +| data_source_info | object | | No | | data_source_type | string | | No | -| dataset_process_rule | | | No | +| dataset_process_rule | [DocumentProcessRuleResponse](#documentprocessruleresponse) | | No | | dataset_process_rule_id | string | | No | | disabled_at | integer | | No | | disabled_by | string | | No | @@ -20760,7 +20760,7 @@ Request payload for bulk downloading documents as a zip archive. | doc_language | string | | No | | doc_metadata | [ [DocumentMetadataResponse](#documentmetadataresponse) ] | | No | | doc_type | string | | No | -| document_process_rule | | | No | +| document_process_rule | [DocumentProcessRuleResponse](#documentprocessruleresponse) | | No | | enabled | boolean | | No | | error | string | | No | | hit_count | integer | | No | @@ -20815,6 +20815,29 @@ The document is being processed and cannot be edited. | datasource_type | string | | No | | input_data | [JsonValue](#jsonvalue) | | No | +#### DocumentPreProcessingRuleResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enabled | boolean | | Yes | +| id | string | | Yes | + +#### DocumentProcessRuleResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| mode | string | | Yes | +| rules | [DocumentProcessRulesResponse](#documentprocessrulesresponse) | | No | + +#### DocumentProcessRulesResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| parent_mode | string,
**Available values:** "full-doc", "paragraph" | | No | +| pre_processing_rules | [ [DocumentPreProcessingRuleResponse](#documentpreprocessingruleresponse) ] | | No | +| segmentation | [DocumentSegmentationResponse](#documentsegmentationresponse) | | No | +| subchunk_segmentation | [DocumentSegmentationResponse](#documentsegmentationresponse) | | No | + #### DocumentRenamePayload | Name | Type | Description | Required | @@ -20829,7 +20852,7 @@ The document is being processed and cannot be edited. | created_at | integer | | No | | created_by | string | | No | | created_from | string | | No | -| data_source_detail_dict | object | | Yes | +| data_source_detail_dict | [DocumentSourceDetailResponse](#documentsourcedetailresponse) | | Yes | | data_source_info | object | | No | | data_source_type | string | | No | | dataset_process_rule_id | string | | No | @@ -20856,6 +20879,23 @@ The document is being processed and cannot be edited. | ---- | ---- | ----------- | -------- | | document_ids | [ string ] | | Yes | +#### DocumentSegmentationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| chunk_overlap | integer | | No | +| max_tokens | integer | | Yes | +| separator | string,
**Default:** + | | No | + +#### DocumentSourceDetailResponse + +The uploaded file for file documents; Notion and website documents repeat their source info. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| upload_file | [DocumentUploadFileDetailResponse](#documentuploadfiledetailresponse) | | No | + #### DocumentStatusListResponse | Name | Type | Description | Required | @@ -20887,6 +20927,18 @@ The document is being processed and cannot be edited. | summary_status | [SummaryStatusResponse](#summarystatusresponse) | | Yes | | total_segments | integer | | Yes | +#### DocumentUploadFileDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | number | | Yes | +| created_by | string | | Yes | +| extension | string | | Yes | +| id | string | | Yes | +| mime_type | string | | No | +| name | string | | Yes | +| size | integer | | Yes | + #### DocumentWithSegmentsListResponse | Name | Type | Description | Required | @@ -20906,7 +20958,7 @@ The document is being processed and cannot be edited. | created_at | integer | | No | | created_by | string | | No | | created_from | string | | No | -| data_source_detail_dict | object | | Yes | +| data_source_detail_dict | [DocumentSourceDetailResponse](#documentsourcedetailresponse) | | Yes | | data_source_info | object | | No | | data_source_type | string | | No | | dataset_process_rule_id | string | | No | @@ -20923,7 +20975,7 @@ The document is being processed and cannot be edited. | name | string | | Yes | | need_summary | boolean | | No | | position | integer | | No | -| process_rule_dict | | | No | +| process_rule_dict | [DocumentProcessRuleResponse](#documentprocessruleresponse) | | No | | summary_index_status | string | | No | | tokens | integer | | No | | total_segments | integer | | No | @@ -24822,6 +24874,12 @@ Serialized pricing info with codegen-safe decimal string patterns. | mode | [ProcessRuleMode](#processrulemode) | `automatic` uses built-in rules, `custom` allows manual configuration, `hierarchical` enables parent-child chunk structure (use with `doc_form: hierarchical_model`). | Yes | | rules | [Rule](#rule) | Custom processing rules. | No | +#### ProcessRuleLimitsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| indexing_max_segmentation_tokens_length | integer | | Yes | + #### ProcessRuleMode Dataset Process Rule Mode @@ -24834,7 +24892,7 @@ Dataset Process Rule Mode | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| limits | object | | Yes | +| limits | [ProcessRuleLimitsResponse](#processrulelimitsresponse) | | Yes | | mode | [ProcessRuleMode](#processrulemode) | | Yes | | rules | [Rule](#rule) | | No | diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index 716b65bea30872..4984147e66cf92 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -3345,7 +3345,7 @@ Request payload for bulk downloading documents as a zip archive. | created_at | integer | | No | | created_by | string | | No | | created_from | string | | No | -| data_source_detail_dict | object | | Yes | +| data_source_detail_dict | [DocumentSourceDetailResponse](#documentsourcedetailresponse) | | Yes | | data_source_info | object | | No | | data_source_type | string | | No | | dataset_process_rule_id | string | | No | @@ -3366,6 +3366,14 @@ Request payload for bulk downloading documents as a zip archive. | tokens | integer | | No | | word_count | integer | | No | +#### DocumentSourceDetailResponse + +The uploaded file for file documents; Notion and website documents repeat their source info. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| upload_file | [DocumentUploadFileDetailResponse](#documentuploadfiledetailresponse) | | No | + #### DocumentStatusListResponse | Name | Type | Description | Required | @@ -3424,6 +3432,18 @@ Request payload for bulk downloading documents as a zip archive. | retrieval_model | [RetrievalModel](#retrievalmodel) | Controls how chunks are searched and ranked when querying this knowledge base. | No | | text | string | Document text content. | No | +#### DocumentUploadFileDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | number | | Yes | +| created_by | string | | Yes | +| extension | string | | Yes | +| id | string | | Yes | +| mime_type | string | | No | +| name | string | | Yes | +| size | integer | | Yes | + #### EndUserDetail Full end-user detail returned by the Service API. diff --git a/api/services/knowledge/documents/adapters.py b/api/services/knowledge/documents/adapters.py index e61a702bcf0ac3..6caa431621c1e4 100644 --- a/api/services/knowledge/documents/adapters.py +++ b/api/services/knowledge/documents/adapters.py @@ -259,7 +259,7 @@ def get_detail(self, ref: DocumentRef, *, metadata_only: bool) -> dict[str, Any] data_source_info=document.data_source_info_dict, data_source_detail_dict=get_document_source_detail(document, session=session), dataset_process_rule=DatasetService.get_process_rules(ref.dataset.dataset_id, session), - document_process_rule=dict(rule.to_dict()) if rule else {}, + document_process_rule=dict(rule.to_dict()) if rule else None, segment_count=segment_count, average_segment_length=(document.word_count or 0) // segment_count if segment_count else 0, hit_count=get_document_hit_count(document, session=session), diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py index ef38dec1344a5e..b4b33fe7528404 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py @@ -104,7 +104,11 @@ def test_process_rule_authorizes_document_dataset_before_read(app, documents): def test_default_process_rule_does_not_require_dataset_permission(app, documents): - documents.get_process_rule.return_value = {"mode": "automatic", "rules": {}, "limits": {}} + documents.get_process_rule.return_value = { + "mode": "automatic", + "rules": {}, + "limits": {"indexing_max_segmentation_tokens_length": 4000}, + } with app.test_request_context("/"), patch.object(controller, "enforce_rbac_checks") as enforce_checks: unwrap(GetProcessRuleApi.get)(GetProcessRuleApi(), CONTEXT) enforce_checks.assert_not_called() diff --git a/api/tests/unit_tests/fields/test_document_process_rule_fields.py b/api/tests/unit_tests/fields/test_document_process_rule_fields.py new file mode 100644 index 00000000000000..39a9bef5a7744b --- /dev/null +++ b/api/tests/unit_tests/fields/test_document_process_rule_fields.py @@ -0,0 +1,29 @@ +from fields.document_fields import DocumentProcessRuleResponse, DocumentSourceDetailResponse + + +def test_process_rule_reads_the_legacy_delimiter_as_separator() -> None: + rule = DocumentProcessRuleResponse.model_validate( + { + "mode": "custom", + "rules": { + "pre_processing_rules": [{"id": "remove_extra_spaces", "enabled": True}], + "segmentation": {"delimiter": "\n\n", "max_tokens": 500}, + }, + } + ) + + assert rule.model_dump(mode="json") == { + "mode": "custom", + "rules": { + "pre_processing_rules": [{"id": "remove_extra_spaces", "enabled": True}], + "segmentation": {"separator": "\n\n", "max_tokens": 500, "chunk_overlap": 0}, + "parent_mode": None, + "subchunk_segmentation": None, + }, + } + + +def test_source_detail_keeps_notion_and_website_source_info() -> None: + detail = DocumentSourceDetailResponse.model_validate({"url": "https://example.com", "title": "Example"}) + + assert detail.model_dump(mode="json") == {"url": "https://example.com", "title": "Example"} diff --git a/packages/contracts/generated/api/console/datasets/types.gen.ts b/packages/contracts/generated/api/console/datasets/types.gen.ts index 3684c8679c6659..f0bb70d4028a58 100644 --- a/packages/contracts/generated/api/console/datasets/types.gen.ts +++ b/packages/contracts/generated/api/console/datasets/types.gen.ts @@ -286,9 +286,7 @@ export type IndexingEstimate = { } export type ProcessRuleResponse = { - limits: { - [key: string]: unknown - } + limits: ProcessRuleLimitsResponse mode: ProcessRuleMode rules?: Rule | null } @@ -374,10 +372,12 @@ export type DocumentDetailResponse = { created_at?: number | null created_by?: string | null created_from?: string | null - data_source_detail_dict?: unknown - data_source_info?: unknown + data_source_detail_dict?: DocumentSourceDetailResponse | null + data_source_info?: { + [key: string]: JsonValue + } | null data_source_type?: string | null - dataset_process_rule?: unknown + dataset_process_rule?: DocumentProcessRuleResponse | null dataset_process_rule_id?: string | null disabled_at?: number | null disabled_by?: string | null @@ -386,7 +386,7 @@ export type DocumentDetailResponse = { doc_language?: string | null doc_metadata?: Array | null doc_type?: string | null - document_process_rule?: unknown + document_process_rule?: DocumentProcessRuleResponse | null enabled?: boolean | null error?: string | null hit_count?: number | null @@ -461,11 +461,9 @@ export type DocumentResponse = { created_at?: number | null created_by?: string | null created_from?: string | null - data_source_detail_dict: { - [key: string]: unknown - } + data_source_detail_dict: DocumentSourceDetailResponse data_source_info?: { - [key: string]: unknown + [key: string]: JsonValue } | null data_source_type?: string | null dataset_process_rule_id?: string | null @@ -806,6 +804,10 @@ export type NotionEstimateWorkspacePayload = { workspace_id: string } +export type ProcessRuleLimitsResponse = { + indexing_max_segmentation_tokens_length: number +} + export type ProcessRuleMode = 'automatic' | 'custom' | 'hierarchical' export type Rule = { @@ -821,11 +823,9 @@ export type DocumentWithSegmentsResponse = { created_at?: number | null created_by?: string | null created_from?: string | null - data_source_detail_dict: { - [key: string]: unknown - } + data_source_detail_dict: DocumentSourceDetailResponse data_source_info?: { - [key: string]: unknown + [key: string]: JsonValue } | null data_source_type?: string | null dataset_process_rule_id?: string | null @@ -842,7 +842,7 @@ export type DocumentWithSegmentsResponse = { name: string need_summary?: boolean | null position?: number | null - process_rule_dict?: unknown + process_rule_dict?: DocumentProcessRuleResponse | null summary_index_status?: string | null tokens?: number | null total_segments?: number | null @@ -855,6 +855,18 @@ export type DocumentMetadataOperation = { partial_update?: boolean } +export type DocumentSourceDetailResponse = { + upload_file?: DocumentUploadFileDetailResponse | null + [key: string]: unknown +} + +export type JsonValue = unknown + +export type DocumentProcessRuleResponse = { + mode: string + rules?: DocumentProcessRulesResponse | null +} + export type DocumentMetadataResponse = { id: string name: string @@ -862,8 +874,6 @@ export type DocumentMetadataResponse = { value?: string | number | number | boolean | null } -export type JsonValue = unknown - export type SegmentResponse = { answer: string | null attachments: Array @@ -1044,6 +1054,23 @@ export type MetadataDetail = { value?: string | number | number | null } +export type DocumentUploadFileDetailResponse = { + created_at: number + created_by: string + extension: string + id: string + mime_type?: string | null + name: string + size: number +} + +export type DocumentProcessRulesResponse = { + parent_mode?: 'full-doc' | 'paragraph' | null + pre_processing_rules?: Array | null + segmentation?: DocumentSegmentationResponse | null + subchunk_segmentation?: DocumentSegmentationResponse | null +} + export type SegmentAttachmentResponse = { extension: string id: string @@ -1166,6 +1193,17 @@ export type NotionPageType = 'database' | 'page' export type PreProcessingRuleKey = 'remove_extra_spaces' | 'remove_stopwords' | 'remove_urls_emails' +export type DocumentPreProcessingRuleResponse = { + enabled: boolean + id: string +} + +export type DocumentSegmentationResponse = { + chunk_overlap?: number + max_tokens: number + separator?: string +} + export type HitTestingDocument = { data_source_type: string doc_metadata: unknown | null diff --git a/packages/contracts/generated/api/console/datasets/zod.gen.ts b/packages/contracts/generated/api/console/datasets/zod.gen.ts index 0eb7ade22786ad..f93e0b78b589ed 100644 --- a/packages/contracts/generated/api/console/datasets/zod.gen.ts +++ b/packages/contracts/generated/api/console/datasets/zod.gen.ts @@ -690,141 +690,18 @@ export const zDatasetMetadataBuiltInFieldsResponse = z.object({ }) /** - * ProcessRuleMode - * - * Dataset Process Rule Mode - */ -export const zProcessRuleMode = z.enum(['automatic', 'custom', 'hierarchical']) - -/** - * DocumentMetadataResponse - */ -export const zDocumentMetadataResponse = z.object({ - id: z.string(), - name: z.string(), - type: z.string(), - value: z.union([z.string(), z.int(), z.number(), z.boolean()]).nullish(), -}) - -/** - * DocumentDetailResponse - */ -export const zDocumentDetailResponse = z.object({ - archived: z.boolean().nullish(), - average_segment_length: z.number().nullish(), - completed_at: z.int().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - created_from: z.string().nullish(), - data_source_detail_dict: z.unknown().optional(), - data_source_info: z.unknown().optional(), - data_source_type: z.string().nullish(), - dataset_process_rule: z.unknown().optional(), - dataset_process_rule_id: z.string().nullish(), - disabled_at: z.int().nullish(), - disabled_by: z.string().nullish(), - display_status: z.string().nullish(), - doc_form: z.string().nullish(), - doc_language: z.string().nullish(), - doc_metadata: z.array(zDocumentMetadataResponse).nullish(), - doc_type: z.string().nullish(), - document_process_rule: z.unknown().optional(), - enabled: z.boolean().nullish(), - error: z.string().nullish(), - hit_count: z.int().nullish(), - id: z.string(), - indexing_latency: z.number().nullish(), - indexing_status: z.string().nullish(), - name: z.string().nullish(), - need_summary: z.boolean().nullish(), - position: z.int().nullish(), - segment_count: z.int().nullish(), - tokens: z.int().nullish(), - updated_at: z.int().nullish(), -}) - -/** - * DocumentResponse - */ -export const zDocumentResponse = z.object({ - archived: z.boolean().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - created_from: z.string().nullish(), - data_source_detail_dict: z.record(z.string(), z.unknown()), - data_source_info: z.record(z.string(), z.unknown()).nullish(), - data_source_type: z.string().nullish(), - dataset_process_rule_id: z.string().nullish(), - disabled_at: z.int().nullish(), - disabled_by: z.string().nullish(), - display_status: z.string().nullish(), - doc_form: z.string().nullish(), - doc_metadata: z.array(zDocumentMetadataResponse).optional(), - enabled: z.boolean().nullish(), - error: z.string().nullish(), - hit_count: z.int().nullish(), - id: z.string(), - indexing_status: z.string().nullish(), - name: z.string(), - need_summary: z.boolean().nullish(), - position: z.int().nullish(), - summary_index_status: z.string().nullish(), - tokens: z.int().nullish(), - word_count: z.int().nullish(), -}) - -/** - * DatasetAndDocumentResponse + * ProcessRuleLimitsResponse */ -export const zDatasetAndDocumentResponse = z.object({ - batch: z.string(), - dataset: zDatasetResponse, - documents: z.array(zDocumentResponse), +export const zProcessRuleLimitsResponse = z.object({ + indexing_max_segmentation_tokens_length: z.int(), }) /** - * DocumentWithSegmentsResponse - */ -export const zDocumentWithSegmentsResponse = z.object({ - archived: z.boolean().nullish(), - completed_segments: z.int().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - created_from: z.string().nullish(), - data_source_detail_dict: z.record(z.string(), z.unknown()), - data_source_info: z.record(z.string(), z.unknown()).nullish(), - data_source_type: z.string().nullish(), - dataset_process_rule_id: z.string().nullish(), - disabled_at: z.int().nullish(), - disabled_by: z.string().nullish(), - display_status: z.string().nullish(), - doc_form: z.string().nullish(), - doc_metadata: z.array(zDocumentMetadataResponse).optional(), - enabled: z.boolean().nullish(), - error: z.string().nullish(), - hit_count: z.int().nullish(), - id: z.string(), - indexing_status: z.string().nullish(), - name: z.string(), - need_summary: z.boolean().nullish(), - position: z.int().nullish(), - process_rule_dict: z.unknown().optional(), - summary_index_status: z.string().nullish(), - tokens: z.int().nullish(), - total_segments: z.int().nullish(), - word_count: z.int().nullish(), -}) - -/** - * DocumentWithSegmentsListResponse + * ProcessRuleMode + * + * Dataset Process Rule Mode */ -export const zDocumentWithSegmentsListResponse = z.object({ - data: z.array(zDocumentWithSegmentsResponse), - has_more: z.boolean(), - limit: z.int(), - page: z.int(), - total: z.int(), -}) +export const zProcessRuleMode = z.enum(['automatic', 'custom', 'hierarchical']) export const zJsonValue = z.unknown() @@ -838,6 +715,16 @@ export const zDocumentPipelineExecutionLogResponse = z.object({ input_data: zJsonValue.nullish(), }) +/** + * DocumentMetadataResponse + */ +export const zDocumentMetadataResponse = z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + value: z.union([z.string(), z.int(), z.number(), z.boolean()]).nullish(), +}) + /** * ChildChunkResponse */ @@ -1059,6 +946,67 @@ export const zMetadataOperationData = z.object({ operation_data: z.array(zDocumentMetadataOperation), }) +/** + * DocumentUploadFileDetailResponse + */ +export const zDocumentUploadFileDetailResponse = z.object({ + created_at: z.number(), + created_by: z.string(), + extension: z.string(), + id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + size: z.int(), +}) + +/** + * DocumentSourceDetailResponse + * + * The uploaded file for file documents; Notion and website documents repeat their source info. + */ +export const zDocumentSourceDetailResponse = z.object({ + upload_file: zDocumentUploadFileDetailResponse.nullish(), +}) + +/** + * DocumentResponse + */ +export const zDocumentResponse = z.object({ + archived: z.boolean().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + created_from: z.string().nullish(), + data_source_detail_dict: zDocumentSourceDetailResponse, + data_source_info: z.record(z.string(), zJsonValue).nullish(), + data_source_type: z.string().nullish(), + dataset_process_rule_id: z.string().nullish(), + disabled_at: z.int().nullish(), + disabled_by: z.string().nullish(), + display_status: z.string().nullish(), + doc_form: z.string().nullish(), + doc_metadata: z.array(zDocumentMetadataResponse).optional(), + enabled: z.boolean().nullish(), + error: z.string().nullish(), + hit_count: z.int().nullish(), + id: z.string(), + indexing_status: z.string().nullish(), + name: z.string(), + need_summary: z.boolean().nullish(), + position: z.int().nullish(), + summary_index_status: z.string().nullish(), + tokens: z.int().nullish(), + word_count: z.int().nullish(), +}) + +/** + * DatasetAndDocumentResponse + */ +export const zDatasetAndDocumentResponse = z.object({ + batch: z.string(), + dataset: zDatasetResponse, + documents: z.array(zDocumentResponse), +}) + /** * SegmentAttachmentResponse */ @@ -1499,7 +1447,7 @@ export const zRule = z.object({ * ProcessRuleResponse */ export const zProcessRuleResponse = z.object({ - limits: z.record(z.string(), z.unknown()), + limits: zProcessRuleLimitsResponse, mode: zProcessRuleMode, rules: zRule.nullish(), }) @@ -1512,6 +1460,122 @@ export const zProcessRule = z.object({ rules: zRule.nullish(), }) +/** + * DocumentPreProcessingRuleResponse + */ +export const zDocumentPreProcessingRuleResponse = z.object({ + enabled: z.boolean(), + id: z.string(), +}) + +/** + * DocumentSegmentationResponse + */ +export const zDocumentSegmentationResponse = z.object({ + chunk_overlap: z.int().optional().default(0), + max_tokens: z.int(), + separator: z.string().optional().default('\n'), +}) + +/** + * DocumentProcessRulesResponse + */ +export const zDocumentProcessRulesResponse = z.object({ + parent_mode: z.enum(['full-doc', 'paragraph']).nullish(), + pre_processing_rules: z.array(zDocumentPreProcessingRuleResponse).nullish(), + segmentation: zDocumentSegmentationResponse.nullish(), + subchunk_segmentation: zDocumentSegmentationResponse.nullish(), +}) + +/** + * DocumentProcessRuleResponse + */ +export const zDocumentProcessRuleResponse = z.object({ + mode: z.string(), + rules: zDocumentProcessRulesResponse.nullish(), +}) + +/** + * DocumentDetailResponse + */ +export const zDocumentDetailResponse = z.object({ + archived: z.boolean().nullish(), + average_segment_length: z.number().nullish(), + completed_at: z.int().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + created_from: z.string().nullish(), + data_source_detail_dict: zDocumentSourceDetailResponse.nullish(), + data_source_info: z.record(z.string(), zJsonValue).nullish(), + data_source_type: z.string().nullish(), + dataset_process_rule: zDocumentProcessRuleResponse.nullish(), + dataset_process_rule_id: z.string().nullish(), + disabled_at: z.int().nullish(), + disabled_by: z.string().nullish(), + display_status: z.string().nullish(), + doc_form: z.string().nullish(), + doc_language: z.string().nullish(), + doc_metadata: z.array(zDocumentMetadataResponse).nullish(), + doc_type: z.string().nullish(), + document_process_rule: zDocumentProcessRuleResponse.nullish(), + enabled: z.boolean().nullish(), + error: z.string().nullish(), + hit_count: z.int().nullish(), + id: z.string(), + indexing_latency: z.number().nullish(), + indexing_status: z.string().nullish(), + name: z.string().nullish(), + need_summary: z.boolean().nullish(), + position: z.int().nullish(), + segment_count: z.int().nullish(), + tokens: z.int().nullish(), + updated_at: z.int().nullish(), +}) + +/** + * DocumentWithSegmentsResponse + */ +export const zDocumentWithSegmentsResponse = z.object({ + archived: z.boolean().nullish(), + completed_segments: z.int().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + created_from: z.string().nullish(), + data_source_detail_dict: zDocumentSourceDetailResponse, + data_source_info: z.record(z.string(), zJsonValue).nullish(), + data_source_type: z.string().nullish(), + dataset_process_rule_id: z.string().nullish(), + disabled_at: z.int().nullish(), + disabled_by: z.string().nullish(), + display_status: z.string().nullish(), + doc_form: z.string().nullish(), + doc_metadata: z.array(zDocumentMetadataResponse).optional(), + enabled: z.boolean().nullish(), + error: z.string().nullish(), + hit_count: z.int().nullish(), + id: z.string(), + indexing_status: z.string().nullish(), + name: z.string(), + need_summary: z.boolean().nullish(), + position: z.int().nullish(), + process_rule_dict: zDocumentProcessRuleResponse.nullish(), + summary_index_status: z.string().nullish(), + tokens: z.int().nullish(), + total_segments: z.int().nullish(), + word_count: z.int().nullish(), +}) + +/** + * DocumentWithSegmentsListResponse + */ +export const zDocumentWithSegmentsListResponse = z.object({ + data: z.array(zDocumentWithSegmentsResponse), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + /** * HitTestingDocument */ diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index e607173e32d9a7..f6b17ffc730b5e 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -941,11 +941,9 @@ export type DocumentResponse = { created_at?: number | null created_by?: string | null created_from?: string | null - data_source_detail_dict: { - [key: string]: unknown - } + data_source_detail_dict: DocumentSourceDetailResponse data_source_info?: { - [key: string]: unknown + [key: string]: JsonValue } | null data_source_type?: string | null dataset_process_rule_id?: string | null @@ -967,6 +965,11 @@ export type DocumentResponse = { word_count?: number | null } +export type DocumentSourceDetailResponse = { + upload_file?: DocumentUploadFileDetailResponse | null + [key: string]: unknown +} + export type DocumentStatusListResponse = { data: Array } @@ -1032,6 +1035,16 @@ export type DocumentTextUpdate = ( text?: string | null } +export type DocumentUploadFileDetailResponse = { + created_at: number + created_by: string + extension: string + id: string + mime_type?: string | null + name: string + size: number +} + export type EndUserDetail = { app_id?: string | null created_at: string diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index 587be01c3a3188..eb1c4363607790 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -993,55 +993,6 @@ export const zDocumentDetailResponse = z.object({ updated_at: z.int().nullish(), }) -/** - * DocumentResponse - */ -export const zDocumentResponse = z.object({ - archived: z.boolean().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - created_from: z.string().nullish(), - data_source_detail_dict: z.record(z.string(), z.unknown()), - data_source_info: z.record(z.string(), z.unknown()).nullish(), - data_source_type: z.string().nullish(), - dataset_process_rule_id: z.string().nullish(), - disabled_at: z.int().nullish(), - disabled_by: z.string().nullish(), - display_status: z.string().nullish(), - doc_form: z.string().nullish(), - doc_metadata: z.array(zDocumentMetadataResponse).optional(), - enabled: z.boolean().nullish(), - error: z.string().nullish(), - hit_count: z.int().nullish(), - id: z.string(), - indexing_status: z.string().nullish(), - name: z.string(), - need_summary: z.boolean().nullish(), - position: z.int().nullish(), - summary_index_status: z.string().nullish(), - tokens: z.int().nullish(), - word_count: z.int().nullish(), -}) - -/** - * DocumentAndBatchResponse - */ -export const zDocumentAndBatchResponse = z.object({ - batch: z.string(), - document: zDocumentResponse, -}) - -/** - * DocumentListResponse - */ -export const zDocumentListResponse = z.object({ - data: z.array(zDocumentResponse), - has_more: z.boolean(), - limit: z.int(), - page: z.int(), - total: z.int(), -}) - /** * DocumentStatusPayload */ @@ -1077,6 +1028,28 @@ export const zDocumentStatusListResponse = z.object({ data: z.array(zDocumentStatusResponse), }) +/** + * DocumentUploadFileDetailResponse + */ +export const zDocumentUploadFileDetailResponse = z.object({ + created_at: z.number(), + created_by: z.string(), + extension: z.string(), + id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + size: z.int(), +}) + +/** + * DocumentSourceDetailResponse + * + * The uploaded file for file documents; Notion and website documents repeat their source info. + */ +export const zDocumentSourceDetailResponse = z.object({ + upload_file: zDocumentUploadFileDetailResponse.nullish(), +}) + /** * EndUserDetail * @@ -1588,6 +1561,55 @@ export const zJsonValueType = z.unknown() export const zJsonValue = z.unknown() +/** + * DocumentResponse + */ +export const zDocumentResponse = z.object({ + archived: z.boolean().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + created_from: z.string().nullish(), + data_source_detail_dict: zDocumentSourceDetailResponse, + data_source_info: z.record(z.string(), zJsonValue).nullish(), + data_source_type: z.string().nullish(), + dataset_process_rule_id: z.string().nullish(), + disabled_at: z.int().nullish(), + disabled_by: z.string().nullish(), + display_status: z.string().nullish(), + doc_form: z.string().nullish(), + doc_metadata: z.array(zDocumentMetadataResponse).optional(), + enabled: z.boolean().nullish(), + error: z.string().nullish(), + hit_count: z.int().nullish(), + id: z.string(), + indexing_status: z.string().nullish(), + name: z.string(), + need_summary: z.boolean().nullish(), + position: z.int().nullish(), + summary_index_status: z.string().nullish(), + tokens: z.int().nullish(), + word_count: z.int().nullish(), +}) + +/** + * DocumentAndBatchResponse + */ +export const zDocumentAndBatchResponse = z.object({ + batch: z.string(), + document: zDocumentResponse, +}) + +/** + * DocumentListResponse + */ +export const zDocumentListResponse = z.object({ + data: z.array(zDocumentResponse), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + /** * HumanInputFormSubmissionData */ From 98f57d8f6d25a7143a6a54b481d6647ee53f4658 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:13:45 +0800 Subject: [PATCH 44/60] fix(web): keep typed defaults when editing pipeline input fields The editor converted number and checkbox defaults to strings, so re-editing a number field failed the z.number() submit schema even when only the label changed. Widen the form default type and keep the original value. Also drop the PipelineProcessingParamsResponse type that only a spec still referenced. Co-Authored-By: Claude Opus 5.5 --- .../editor/__tests__/utils.spec.ts | 22 +++++++++++++++++++ .../panel/input-field/editor/form/types.ts | 2 +- .../panel/input-field/editor/utils.ts | 5 ++--- .../__tests__/index.spec.tsx | 13 +++++------ web/models/pipeline.ts | 4 ---- 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/utils.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/utils.spec.ts index e4e53a4c5ba4a5..4bbf6b57d9e519 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/utils.spec.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/utils.spec.ts @@ -45,6 +45,28 @@ describe('input-field editor utils', () => { }) }) + it('keeps number and checkbox defaults typed for the submit schema', () => { + const numberField = convertToInputFieldFormData({ + type: PipelineInputVarType.number, + label: 'Chunk size', + variable: 'chunk_size', + default_value: 5, + required: true, + }) + const checkboxField = convertToInputFieldFormData({ + type: PipelineInputVarType.checkbox, + label: 'Enabled', + variable: 'enabled', + default_value: false, + required: true, + }) + + expect(numberField.default).toBe(5) + expect(checkboxField.default).toBe(false) + expect(convertFormDataToINputField(numberField).default_value).toBe(5) + expect(convertFormDataToINputField(checkboxField).default_value).toBe(false) + }) + it('should fall back to the default input variable template', () => { const result = convertToInputFieldFormData() diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/types.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/types.ts index f0412078a41e0c..28f2007fdc78bf 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/types.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/types.ts @@ -7,7 +7,7 @@ export type FormData = { label: string variable: string maxLength?: number - default?: string + default?: string | number | boolean required: boolean tooltips?: string options?: string[] diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/utils.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/utils.ts index 1dba8c5006d600..e81f9edb196e38 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/utils.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/utils.ts @@ -35,9 +35,8 @@ export const convertToInputFieldFormData = (data?: InputVar): FormData => { allowedTypesAndExtensions: {}, } - // Number and checkbox variables keep a typed default; the editor edits it as text. - if (default_value !== undefined && default_value !== null) - formData.default = String(default_value) + // Number and checkbox variables keep their typed default for the submit schema. + if (default_value !== undefined && default_value !== null) formData.default = default_value if (tooltips !== undefined && tooltips !== null) formData.tooltips = tooltips if (placeholder !== undefined && placeholder !== null) formData.placeholder = placeholder if (unit !== undefined && unit !== null) formData.unit = unit diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx index c830871e4e191a..4eb3bb74821dfc 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx @@ -1,11 +1,8 @@ import type { ZodSchema } from 'zod' +import type { useInputVariables } from '../hooks' import type { CustomActionsProps } from '@/app/components/base/form/components/form/actions' import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types' -import type { - PipelineProcessingParamsResponse, - RAGPipelineVariable, - RAGPipelineVariables, -} from '@/models/pipeline' +import type { RAGPipelineVariable, RAGPipelineVariables } from '@/models/pipeline' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' @@ -35,7 +32,9 @@ vi.mock('@/app/components/workflow/store', () => ({ }, })) -let mockParamsConfig: PipelineProcessingParamsResponse | undefined +type ProcessingParams = ReturnType['paramsConfig'] + +let mockParamsConfig: ProcessingParams | undefined let mockIsFetchingParams = false vi.mock('@tanstack/react-query', async (importOriginal) => { @@ -170,7 +169,7 @@ const createMockSchema = (): ZodSchema => const setupMocks = (options?: { pipelineId?: string | null - paramsConfig?: PipelineProcessingParamsResponse + paramsConfig?: ProcessingParams isFetchingParams?: boolean initialData?: Record configurations?: BaseConfiguration[] diff --git a/web/models/pipeline.ts b/web/models/pipeline.ts index 0885a0c5fc5842..41a8d15aebcea1 100644 --- a/web/models/pipeline.ts +++ b/web/models/pipeline.ts @@ -83,10 +83,6 @@ export type RAGPipelineVariable = { export type InputVar = Omit export type RAGPipelineVariables = RAGPipelineVariable[] -export type PipelineProcessingParamsResponse = { - variables: RAGPipelineVariables -} - export type PublishedPipelineRunResponse = PipelineGenerateResponse export type InitialDocumentDetail = PipelineDocument From 588a2325cc94e1d3cbd7019c702d503015a6b17b Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:47:01 +0800 Subject: [PATCH 45/60] refactor(web): use generated document options directly Remove the forwarding hooks in service/knowledge/use-document. The document list, detail and metadata queries and the status, delete, sync, summary, pause, resume, download and retry mutations use the generated console options directly, and invalidate generated keys inline. Only the zip download stays, because it streams a file outside the console contract. The document list item and detail UI types now alias the contract. Fields the backend can omit are handled where they are read: source info goes through guards in documents/document-source, stored process rules are restored only when they carry segmentation (with the backend's documented separator and overlap defaults), and display statuses are checked with isDocumentDisplayStatus. The create flow keeps its own InitialDocumentDetail type instead of borrowing the detail type. Document metadata entries declare the dataset metadata value types and always carry a value. Co-Authored-By: Claude Opus 5.5 --- api/fields/document_fields.py | 5 +- api/openapi/markdown/console-openapi.md | 4 +- api/openapi/markdown/service-openapi.md | 4 +- oxlint-suppressions.json | 9 +- .../api/console/datasets/types.gen.ts | 4 +- .../generated/api/console/datasets/zod.gen.ts | 4 +- .../generated/api/service/types.gen.ts | 4 +- .../generated/api/service/zod.gen.ts | 4 +- .../chat/citation/__tests__/popup.spec.tsx | 24 +- .../base/chat/chat/citation/popup.tsx | 11 +- .../__tests__/document-list.spec.tsx | 9 +- .../document-picker/__tests__/index.spec.tsx | 24 +- .../common/document-picker/document-list.tsx | 6 +- .../datasets/common/document-picker/index.tsx | 60 +++-- .../__tests__/auto-disabled-document.spec.tsx | 199 +++++---------- .../auto-disabled-document.tsx | 25 +- .../__tests__/index.spec.tsx | 12 +- .../embedding-process/__tests__/utils.spec.ts | 10 +- .../create/embedding-process/index.tsx | 13 +- .../create/embedding-process/utils.ts | 4 +- .../datasets/create/step-three/index.tsx | 4 +- .../create/step-two/__tests__/index.spec.tsx | 7 +- .../step-two/__tests__/process-rule.spec.ts | 35 +++ .../step-two/hooks/use-indexing-estimate.ts | 8 +- .../step-two/hooks/use-preview-state.ts | 14 +- .../datasets/create/step-two/index.tsx | 20 +- .../datasets/create/step-two/process-rule.ts | 40 +++ .../__tests__/document-source.spec.ts | 32 +++ .../documents/__tests__/index.spec.tsx | 153 +++++++----- .../components/__tests__/operations.spec.tsx | 96 +++++-- .../components/document-source-icon.tsx | 36 ++- .../components/document-table-row.tsx | 18 +- ...ocument-actions-with-query-client.spec.tsx | 46 ++-- .../__tests__/use-document-actions.spec.ts | 35 ++- .../hooks/use-document-actions.ts | 78 +++--- .../documents/components/operations.tsx | 164 ++++++------ .../__tests__/index.spec.tsx | 13 +- .../processing/embedding-process/index.tsx | 9 +- .../documents/detail/__tests__/index.spec.tsx | 41 +-- .../documents/detail/document-title.tsx | 5 +- .../datasets/documents/detail/index.tsx | 104 ++++---- .../__tests__/document-settings.spec.tsx | 39 ++- .../detail/settings/document-settings.tsx | 51 ++-- .../__tests__/index.spec.tsx | 24 +- .../settings/pipeline-settings/index.tsx | 16 +- .../datasets/documents/document-source.ts | 39 +++ .../components/datasets/documents/index.tsx | 69 ++--- .../datasets/documents/status-filter.ts | 19 ++ .../status-item/__tests__/index.spec.tsx | 29 ++- .../datasets/documents/status-item/index.tsx | 34 ++- .../metadata/hooks/use-metadata-document.ts | 3 +- .../__tests__/document-information.spec.tsx | 10 +- web/models/datasets.ts | 92 ++----- web/service/datasets.ts | 15 -- web/service/knowledge/use-document.ts | 236 +----------------- web/service/knowledge/use-metadata.ts | 15 +- 56 files changed, 1028 insertions(+), 1056 deletions(-) create mode 100644 web/app/components/datasets/create/step-two/__tests__/process-rule.spec.ts create mode 100644 web/app/components/datasets/create/step-two/process-rule.ts create mode 100644 web/app/components/datasets/documents/__tests__/document-source.spec.ts create mode 100644 web/app/components/datasets/documents/document-source.ts diff --git a/api/fields/document_fields.py b/api/fields/document_fields.py index 0290a9c619acab..3204f976a40230 100644 --- a/api/fields/document_fields.py +++ b/api/fields/document_fields.py @@ -6,6 +6,7 @@ from pydantic import AliasChoices, ConfigDict, Field, JsonValue, field_validator from fields.base import ResponseModel +from fields.dataset_fields import MetadataValueType from libs.helper import to_timestamp @@ -60,8 +61,8 @@ class DocumentSourceDetailResponse(ResponseModel): class DocumentMetadataResponse(ResponseModel): id: str name: str - type: str - value: str | int | float | bool | None = None + type: MetadataValueType + value: str | int | float | None class DocumentResponse(ResponseModel): diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 63eab136e37680..6a0325aae797fb 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -20796,8 +20796,8 @@ The document is being processed and cannot be edited. | ---- | ---- | ----------- | -------- | | id | string | | Yes | | name | string | | Yes | -| type | string | | Yes | -| value | string
integer
number
boolean | | No | +| type | string,
**Available values:** "number", "string", "time" | *Enum:* `"number"`, `"string"`, `"time"` | Yes | +| value | string
integer
number | | Yes | #### DocumentMetadataUpdatePayload diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index 4984147e66cf92..ea83940422ae2c 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -3334,8 +3334,8 @@ Request payload for bulk downloading documents as a zip archive. | ---- | ---- | ----------- | -------- | | id | string | | Yes | | name | string | | Yes | -| type | string | | Yes | -| value | string
integer
number
boolean | | No | +| type | string,
**Available values:** "number", "string", "time" | *Enum:* `"number"`, `"string"`, `"time"` | Yes | +| value | string
integer
number | | Yes | #### DocumentResponse diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index b5b378dc499066..a9b3da7ad0aeff 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -3968,7 +3968,7 @@ }, "web/models/datasets.ts": { "typescript/no-explicit-any": { - "count": 5 + "count": 4 } }, "web/models/debug.ts": { @@ -4076,14 +4076,9 @@ "count": 8 } }, - "web/service/knowledge/use-document.ts": { - "@tanstack/query/prefer-query-options": { - "count": 4 - } - }, "web/service/knowledge/use-metadata.ts": { "@tanstack/query/prefer-query-options": { - "count": 8 + "count": 7 } }, "web/service/knowledge/use-segment.ts": { diff --git a/packages/contracts/generated/api/console/datasets/types.gen.ts b/packages/contracts/generated/api/console/datasets/types.gen.ts index f0bb70d4028a58..63e2626a3a177b 100644 --- a/packages/contracts/generated/api/console/datasets/types.gen.ts +++ b/packages/contracts/generated/api/console/datasets/types.gen.ts @@ -870,8 +870,8 @@ export type DocumentProcessRuleResponse = { export type DocumentMetadataResponse = { id: string name: string - type: string - value?: string | number | number | boolean | null + type: 'number' | 'string' | 'time' + value: string | number | number | null } export type SegmentResponse = { diff --git a/packages/contracts/generated/api/console/datasets/zod.gen.ts b/packages/contracts/generated/api/console/datasets/zod.gen.ts index f93e0b78b589ed..93e030cb2741a7 100644 --- a/packages/contracts/generated/api/console/datasets/zod.gen.ts +++ b/packages/contracts/generated/api/console/datasets/zod.gen.ts @@ -721,8 +721,8 @@ export const zDocumentPipelineExecutionLogResponse = z.object({ export const zDocumentMetadataResponse = z.object({ id: z.string(), name: z.string(), - type: z.string(), - value: z.union([z.string(), z.int(), z.number(), z.boolean()]).nullish(), + type: z.enum(['number', 'string', 'time']), + value: z.union([z.string(), z.int(), z.number()]).nullable(), }) /** diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index f6b17ffc730b5e..79d005a1aec9fa 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -932,8 +932,8 @@ export type DocumentMetadataOperation = { export type DocumentMetadataResponse = { id: string name: string - type: string - value?: string | number | number | boolean | null + type: 'number' | 'string' | 'time' + value: string | number | number | null } export type DocumentResponse = { diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index eb1c4363607790..37fb234133b9ba 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -950,8 +950,8 @@ export const zDocumentListQuery = z.object({ export const zDocumentMetadataResponse = z.object({ id: z.string(), name: z.string(), - type: z.string(), - value: z.union([z.string(), z.int(), z.number(), z.boolean()]).nullish(), + type: z.enum(['number', 'string', 'time']), + value: z.union([z.string(), z.int(), z.number()]).nullable(), }) /** diff --git a/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx b/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx index 16b18b00ba38e5..351442be5ed282 100644 --- a/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx +++ b/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx @@ -1,13 +1,16 @@ import type { Resources } from '../index' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useDocumentDownload } from '@/service/knowledge/use-document' import { downloadUrl } from '@/utils/download' import Popup from '../popup' -vi.mock('@/service/knowledge/use-document', () => ({ - useDocumentDownload: vi.fn(), -})) +const { mockUseDocumentDownload } = vi.hoisted(() => ({ mockUseDocumentDownload: vi.fn() })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.byDocumentId.download.get': () => mockUseDocumentDownload(), + }) +}) vi.mock('@/utils/download', () => ({ downloadUrl: vi.fn(), @@ -30,7 +33,6 @@ vi.mock('../tooltip', () => ({ })) const mockDownloadDocument = vi.fn() -const mockUseDocumentDownload = vi.mocked(useDocumentDownload) const mockDownloadUrl = vi.mocked(downloadUrl) const makeSource = ( @@ -76,7 +78,7 @@ describe('Popup', () => { mockUseDocumentDownload.mockReturnValue({ mutateAsync: mockDownloadDocument, isPending: false, - } as unknown as ReturnType) + }) }) describe('Rendering – Trigger', () => { @@ -210,7 +212,7 @@ describe('Popup', () => { mockUseDocumentDownload.mockReturnValue({ mutateAsync: mockDownloadDocument, isPending: true, - } as unknown as ReturnType) + }) const user = userEvent.setup() render() @@ -504,8 +506,7 @@ describe('Popup', () => { await waitFor(() => { expect(mockDownloadDocument).toHaveBeenCalledWith({ - datasetId: 'ds-1', - documentId: 'doc-1', + params: { dataset_id: 'ds-1', document_id: 'doc-1' }, }) expect(mockDownloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/file.pdf', @@ -547,7 +548,7 @@ describe('Popup', () => { mockUseDocumentDownload.mockReturnValue({ mutateAsync: mockDownloadDocument, isPending: true, - } as unknown as ReturnType) + }) const user = userEvent.setup() render() @@ -575,8 +576,7 @@ describe('Popup', () => { await waitFor(() => { expect(mockDownloadDocument).toHaveBeenCalledWith({ - datasetId: 'ds-1', - documentId: 'primary-doc-id', + params: { dataset_id: 'ds-1', document_id: 'primary-doc-id' }, }) }) }) diff --git a/web/app/components/base/chat/chat/citation/popup.tsx b/web/app/components/base/chat/chat/citation/popup.tsx index 1afd685ca498a6..ab76b7777a31c5 100644 --- a/web/app/components/base/chat/chat/citation/popup.tsx +++ b/web/app/components/base/chat/chat/citation/popup.tsx @@ -1,11 +1,12 @@ import type { FC, MouseEvent } from 'react' import type { Resources } from './index' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useMutation } from '@tanstack/react-query' import { Fragment } from 'react' import { useTranslation } from 'react-i18next' import FileIcon from '@/app/components/base/file-icon' import Link from '@/next/link' -import { useDocumentDownload } from '@/service/knowledge/use-document' +import { consoleQuery } from '@/service/console' import { downloadUrl } from '@/utils/download' import ProgressTooltip from './progress-tooltip' import Tooltip from './tooltip' @@ -20,7 +21,9 @@ const Popup: FC = ({ data, showHitInfo = false }) => { const fileType = data.dataSourceType !== 'notion' ? /\.([^.]*)$/.exec(data.documentName)?.[1] || '' : 'notion' - const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload() + const { mutateAsync: downloadDocument, isPending: isDownloading } = useMutation( + consoleQuery.datasets.byDatasetId.documents.byDocumentId.download.get.mutationOptions(), + ) const handleDownloadUploadFile = async (e: MouseEvent) => { e.preventDefault() @@ -31,7 +34,9 @@ const Popup: FC = ({ data, showHitInfo = false }) => { const documentId = data.documentId || data.sources?.[0]?.document_id if (!isUploadFile || !datasetId || !documentId || isDownloading) return - const res = await downloadDocument({ datasetId, documentId }) + const res = await downloadDocument({ + params: { dataset_id: datasetId, document_id: documentId }, + }) if (res?.url) downloadUrl({ url: res.url, fileName: data.documentName }) } diff --git a/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx b/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx index 55d161c9506a08..318ce3457c3d99 100644 --- a/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx +++ b/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx @@ -15,9 +15,7 @@ vi.mock('../../document-file-icon', () => ({ const createDocument = (overrides: Partial = {}): SimpleDocumentDetail => ({ id: 'doc-1', - batch: 'batch-1', position: 1, - dataset_id: 'dataset-1', data_source_type: DataSourceType.FILE, data_source_info: { upload_file: { @@ -40,16 +38,19 @@ const createDocument = (overrides: Partial = {}): SimpleDo indexing_status: 'completed', display_status: 'enabled', doc_form: ChunkingMode.text, - doc_language: 'en', enabled: true, word_count: 1000, archived: false, - updated_at: Date.now(), hit_count: 0, data_source_detail_dict: { upload_file: { + id: 'file-1', name: 'report.pdf', + size: 1024, extension: 'pdf', + mime_type: 'application/pdf', + created_by: 'user-1', + created_at: 1700000000, }, }, ...overrides, diff --git a/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx b/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx index ed34b8c9e93b96..eb735992cfd246 100644 --- a/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx @@ -11,15 +11,22 @@ const { mockUseDocumentList } = vi.hoisted(() => ({ mockUseDocumentList: vi.fn(), })) -vi.mock('@/service/knowledge/use-document', () => ({ - useDocumentList: mockUseDocumentList, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.get': (options: { + input: { params: { dataset_id: string }; query: Record } + }) => + mockUseDocumentList({ + datasetId: options.input.params.dataset_id, + query: options.input.query, + }), + }) +}) const createDocument = (overrides: Partial = {}): SimpleDocumentDetail => ({ id: 'doc-1', - batch: 'batch-1', position: 1, - dataset_id: 'dataset-1', data_source_type: DataSourceType.FILE, data_source_info: { upload_file: { @@ -42,16 +49,19 @@ const createDocument = (overrides: Partial = {}): SimpleDo indexing_status: 'completed', display_status: 'enabled', doc_form: ChunkingMode.text, - doc_language: 'en', enabled: true, word_count: 1000, archived: false, - updated_at: Date.now(), hit_count: 0, data_source_detail_dict: { upload_file: { + id: 'file-1', name: 'document.pdf', + size: 1024, extension: 'pdf', + mime_type: 'application/pdf', + created_by: 'user-1', + created_at: 1700000000, }, }, ...overrides, diff --git a/web/app/components/datasets/common/document-picker/document-list.tsx b/web/app/components/datasets/common/document-picker/document-list.tsx index 0dca1538f50f77..fbb0a573a6dd58 100644 --- a/web/app/components/datasets/common/document-picker/document-list.tsx +++ b/web/app/components/datasets/common/document-picker/document-list.tsx @@ -2,6 +2,7 @@ import type { SimpleDocumentDetail } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' import { ComboboxItem, ComboboxItemText, ComboboxList } from '@langgenius/dify-ui/combobox' +import { getUploadFileExtension } from '@/app/components/datasets/documents/document-source' import FileIcon from '../document-file-icon' type Props = Readonly<{ @@ -12,10 +13,7 @@ function getDocumentExtension(document: SimpleDocumentDetail) { const detailExtension = document.data_source_detail_dict?.upload_file?.extension if (detailExtension) return detailExtension - const dataSourceInfo = document.data_source_info - if (dataSourceInfo && 'upload_file' in dataSourceInfo) return dataSourceInfo.upload_file.extension - - return '' + return getUploadFileExtension(document.data_source_info) ?? '' } export default function DocumentList({ className }: Props) { diff --git a/web/app/components/datasets/common/document-picker/index.tsx b/web/app/components/datasets/common/document-picker/index.tsx index 46531e7a5bb879..0302eab536e63a 100644 --- a/web/app/components/datasets/common/document-picker/index.tsx +++ b/web/app/components/datasets/common/document-picker/index.tsx @@ -15,50 +15,55 @@ import { ComboboxValue, } from '@langgenius/dify-ui/combobox' import { SpinnerIcon } from '@langgenius/dify-ui/spinner' +import { keepPreviousData, useQuery } from '@tanstack/react-query' import { useDebounce } from 'ahooks' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { getUploadFileExtension } from '@/app/components/datasets/documents/document-source' import { ChunkingMode } from '@/models/datasets' -import { useDocumentList } from '@/service/knowledge/use-document' +import { consoleQuery } from '@/service/console' import FileIcon from '../document-file-icon' import DocumentList from './document-list' +// The list query and the document detail query both describe the picked document. +export type PickerDocument = Pick & { + name?: string | null + data_source_detail_dict?: SimpleDocumentDetail['data_source_detail_dict'] | null +} + type Props = Readonly<{ datasetId: string - value?: SimpleDocumentDetail | null + value?: PickerDocument | null parentMode?: ParentMode - onChange: (value: SimpleDocumentDetail) => void + onChange: (value: PickerDocument) => void }> -function getDocumentLabel(document: SimpleDocumentDetail) { - return document.name +function getDocumentLabel(document: PickerDocument) { + return document.name ?? '' } -function getDocumentValue(document: SimpleDocumentDetail) { +function getDocumentValue(document: PickerDocument) { return document.id } -function isSameDocument(item: SimpleDocumentDetail, value: SimpleDocumentDetail) { +function isSameDocument(item: PickerDocument, value: PickerDocument) { return item.id === value.id } -function getDocumentExtension(document?: SimpleDocumentDetail | null) { +function getDocumentExtension(document?: PickerDocument | null) { if (!document) return '' const detailExtension = document.data_source_detail_dict?.upload_file?.extension if (detailExtension) return detailExtension - const dataSourceInfo = document.data_source_info - if (dataSourceInfo && 'upload_file' in dataSourceInfo) return dataSourceInfo.upload_file.extension - - return '' + return getUploadFileExtension(document.data_source_info) ?? '' } function DocumentPickerTriggerValue({ document, parentMode, }: { - document?: SimpleDocumentDetail | null + document?: PickerDocument | null parentMode?: ParentMode }) { const { t } = useTranslation(['dataset']) @@ -74,7 +79,11 @@ function DocumentPickerTriggerValue({ return ( - + @@ -112,14 +121,15 @@ export function DocumentPicker({ datasetId, value, parentMode, onChange }: Props const [searchValue, setSearchValue] = useState('') const debouncedSearchValue = useDebounce(searchValue, { wait: 500 }) - const { data } = useDocumentList({ - datasetId, - query: { - keyword: debouncedSearchValue, - page: 1, - limit: 20, - }, - }) + const { data } = useQuery( + consoleQuery.datasets.byDatasetId.documents.get.queryOptions({ + input: { + params: { dataset_id: datasetId }, + query: { keyword: debouncedSearchValue, page: 1, limit: 20 }, + }, + placeholderData: keepPreviousData, + }), + ) const documentsList = data?.data ?? [] const handleInputValueChange = (inputValue: string, details: ComboboxChangeEventDetails) => { @@ -130,7 +140,7 @@ export function DocumentPicker({ datasetId, value, parentMode, onChange }: Props if (!nextOpen) setSearchValue('') } - const handleDocumentChange = (document: SimpleDocumentDetail | null) => { + const handleDocumentChange = (document: PickerDocument | null) => { if (!document) return onChange(document) @@ -138,7 +148,7 @@ export function DocumentPicker({ datasetId, value, parentMode, onChange }: Props } return ( - + items={documentsList} value={value ?? null} inputValue={searchValue} @@ -154,7 +164,7 @@ export function DocumentPicker({ datasetId, value, parentMode, onChange }: Props aria-label={value?.name || t(($) => $['operation.search'], { ns: 'common' })} className="ml-1 flex min-w-0 items-center gap-0.5 rounded-lg px-2 py-1 text-start transition-colors hover:bg-state-base-hover focus-visible:bg-state-base-hover data-popup-open:bg-state-base-hover motion-reduce:transition-none" > - > + > {(document) => } diff --git a/web/app/components/datasets/common/document-status-with-action/__tests__/auto-disabled-document.spec.tsx b/web/app/components/datasets/common/document-status-with-action/__tests__/auto-disabled-document.spec.tsx index 23d795c26fad5d..a4a0058fcd3546 100644 --- a/web/app/components/datasets/common/document-status-with-action/__tests__/auto-disabled-document.spec.tsx +++ b/web/app/components/datasets/common/document-status-with-action/__tests__/auto-disabled-document.spec.tsx @@ -1,37 +1,24 @@ -import { queryOptions, useQuery } from '@tanstack/react-query' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { toast } from '@/app/notifications' -import { useAutoDisabledDocuments } from '@/service/knowledge/use-document' -import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import AutoDisabledDocument from '../auto-disabled-document' -const { mockToastSuccess } = vi.hoisted(() => ({ +const { mockToastSuccess, mockEnableDocuments } = vi.hoisted(() => ({ mockToastSuccess: vi.fn(), + mockEnableDocuments: vi.fn(), })) -type AutoDisabledDocumentsResponse = { document_ids: string[] } - -const createMockQueryResult = ( - data: AutoDisabledDocumentsResponse | undefined, - isLoading: boolean, -) => - ({ - data, - isLoading, - }) as ReturnType - -const mockMutateAsync = vi.fn() -const mockInvalidDisabledDocument = vi.fn() - -vi.mock('@/service/knowledge/use-document', () => ({ - useAutoDisabledDocuments: vi.fn(), - useDocumentEnable: vi.fn(() => ({ - mutateAsync: mockMutateAsync, - })), - useInvalidDisabledDocument: vi.fn(() => mockInvalidDisabledDocument), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.status.byAction.batch.patch': () => ({ + mutateAsync: mockEnableDocuments, + }), + }) +}) vi.mock('@/app/notifications', () => ({ toast: { @@ -39,149 +26,75 @@ vi.mock('@/app/notifications', () => ({ }, })) -const mockUseAutoDisabledDocuments = vi.mocked(useAutoDisabledDocuments) +const autoDisableLogsKey = (datasetId: string) => + consoleQuery.datasets.byDatasetId.autoDisableLogs.get.queryKey({ + input: { params: { dataset_id: datasetId } }, + }) + +const renderWithDisabledDocuments = (datasetId: string, documentIds?: string[]) => { + const queryClient = createConsoleQueryClient() + if (documentIds) { + queryClient.setQueryData(autoDisableLogsKey(datasetId), { + document_ids: documentIds, + count: documentIds.length, + }) + } + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue() + return { + ...renderWithConsoleQuery(, { queryClient }), + invalidateQueries, + } +} describe('AutoDisabledDocument', () => { beforeEach(() => { vi.clearAllMocks() - mockMutateAsync.mockResolvedValue({}) + mockEnableDocuments.mockResolvedValue({ result: 'success' }) }) describe('Rendering', () => { - it('should render nothing when loading', () => { - mockUseAutoDisabledDocuments.mockReturnValue(createMockQueryResult(undefined, true)) - - const { container } = render() - expect(container.firstChild).toBeNull() - }) - - it('should render nothing when no disabled documents', () => { - mockUseAutoDisabledDocuments.mockReturnValue( - createMockQueryResult({ document_ids: [] }, false), - ) - - const { container } = render() + it('should render nothing while the disabled documents are loading', () => { + vi.mocked(globalThis.fetch).mockImplementationOnce(() => new Promise(() => {})) + const { container } = renderWithDisabledDocuments('test-dataset') expect(container.firstChild).toBeNull() }) - it('should render nothing when document_ids is undefined', () => { - mockUseAutoDisabledDocuments.mockReturnValue(createMockQueryResult(undefined, false)) - - const { container } = render() + it('should render nothing when no documents were disabled', () => { + const { container } = renderWithDisabledDocuments('test-dataset', []) expect(container.firstChild).toBeNull() }) - it('should render StatusWithAction when disabled documents exist', () => { - mockUseAutoDisabledDocuments.mockReturnValue( - createMockQueryResult({ document_ids: ['doc1', 'doc2'] }, false), - ) - - render() - expect(screen.getByText(/enable/i)).toBeInTheDocument() - }) - }) - - describe('Props', () => { - it('should pass datasetId to useAutoDisabledDocuments', () => { - mockUseAutoDisabledDocuments.mockReturnValue( - createMockQueryResult({ document_ids: [] }, false), - ) - - render() - expect(mockUseAutoDisabledDocuments).toHaveBeenCalledWith('my-dataset-id') - }) - }) - - it('enables the current document IDs after the initial query completes', async () => { - const user = userEvent.setup() - mockUseAutoDisabledDocuments.mockImplementation(() => - useQuery( - queryOptions({ - queryKey: ['disabled-documents'], - queryFn: () => new Promise(() => {}), - }), - ), - ) - const { wrapper, queryClient } = createConsoleQueryWrapper() - render(, { wrapper }) - expect(screen.queryByRole('button', { name: /enable/i })).not.toBeInTheDocument() - await act(async () => { - queryClient.setQueryData(['disabled-documents'], { document_ids: ['returned-document'] }) - }) - await user.click(await screen.findByRole('button', { name: /enable/i })) - expect(mockMutateAsync).toHaveBeenCalledWith({ - datasetId: 'dataset', - documentIds: ['returned-document'], + it('should offer to enable the disabled documents', async () => { + renderWithDisabledDocuments('test-dataset', ['doc1', 'doc2']) + expect(await screen.findByRole('button', { name: /enable/i })).toBeInTheDocument() }) }) describe('User Interactions', () => { - it('should call enableDocument when action button is clicked', async () => { - mockUseAutoDisabledDocuments.mockReturnValue( - createMockQueryResult({ document_ids: ['doc1', 'doc2'] }, false), - ) - - render() + it('should enable every disabled document of the dataset', async () => { + const user = userEvent.setup() + renderWithDisabledDocuments('test-dataset', ['doc1', 'doc2']) - const actionButton = screen.getByText(/enable/i) - fireEvent.click(actionButton) + await user.click(await screen.findByRole('button', { name: /enable/i })) - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledWith({ - datasetId: 'test-dataset', - documentIds: ['doc1', 'doc2'], - }) + expect(mockEnableDocuments).toHaveBeenCalledWith({ + params: { dataset_id: 'test-dataset', action: 'enable' }, + query: { document_id: ['doc1', 'doc2'] }, }) }) - it('should invalidate cache after enabling documents', async () => { - mockUseAutoDisabledDocuments.mockReturnValue( - createMockQueryResult({ document_ids: ['doc1'] }, false), - ) - - render() + it('should refresh the disabled documents and confirm after enabling', async () => { + const user = userEvent.setup() + const { invalidateQueries } = renderWithDisabledDocuments('test-dataset', ['doc1']) - const actionButton = screen.getByText(/enable/i) - fireEvent.click(actionButton) + await user.click(await screen.findByRole('button', { name: /enable/i })) await waitFor(() => { - expect(mockInvalidDisabledDocument).toHaveBeenCalled() - }) - }) - - it('should show success toast after enabling documents', async () => { - mockUseAutoDisabledDocuments.mockReturnValue( - createMockQueryResult({ document_ids: ['doc1'] }, false), - ) - - render() - - const actionButton = screen.getByText(/enable/i) - fireEvent.click(actionButton) - - await waitFor(() => { - expect(toast.success).toHaveBeenCalledWith(expect.any(String)) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: autoDisableLogsKey('test-dataset'), + }) }) - }) - }) - - describe('Edge Cases', () => { - it('should handle single disabled document', () => { - mockUseAutoDisabledDocuments.mockReturnValue( - createMockQueryResult({ document_ids: ['doc1'] }, false), - ) - - render() - expect(screen.getByText(/enable/i)).toBeInTheDocument() - }) - - it('should handle multiple disabled documents', () => { - mockUseAutoDisabledDocuments.mockReturnValue( - createMockQueryResult({ document_ids: ['doc1', 'doc2', 'doc3', 'doc4', 'doc5'] }, false), - ) - - render() - expect(screen.getByText(/enable/i)).toBeInTheDocument() + expect(toast.success).toHaveBeenCalledWith(expect.any(String)) }) }) }) diff --git a/web/app/components/datasets/common/document-status-with-action/auto-disabled-document.tsx b/web/app/components/datasets/common/document-status-with-action/auto-disabled-document.tsx index cba6d20fd1217d..e5574167473662 100644 --- a/web/app/components/datasets/common/document-status-with-action/auto-disabled-document.tsx +++ b/web/app/components/datasets/common/document-status-with-action/auto-disabled-document.tsx @@ -1,13 +1,10 @@ 'use client' import type { FC } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import * as React from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' -import { - useAutoDisabledDocuments, - useDocumentEnable, - useInvalidDisabledDocument, -} from '@/service/knowledge/use-document' +import { consoleQuery } from '@/service/console' import StatusWithAction from './status-with-action' type Props = Readonly<{ @@ -16,15 +13,23 @@ type Props = Readonly<{ const AutoDisabledDocument: FC = ({ datasetId }) => { const { t } = useTranslation(['common', 'dataset']) - const { data, isLoading } = useAutoDisabledDocuments(datasetId) - const invalidDisabledDocument = useInvalidDisabledDocument() + const queryClient = useQueryClient() + const autoDisableLogsQuery = consoleQuery.datasets.byDatasetId.autoDisableLogs.get.queryOptions({ + input: { params: { dataset_id: datasetId } }, + }) + const { data, isLoading } = useQuery(autoDisableLogsQuery) const documentIds = data?.document_ids - const { mutateAsync: enableDocument } = useDocumentEnable() + const { mutateAsync: changeDocumentStatus } = useMutation( + consoleQuery.datasets.byDatasetId.documents.status.byAction.batch.patch.mutationOptions(), + ) if (!documentIds?.length || isLoading) return null const handleEnableDocuments = async () => { - await enableDocument({ datasetId, documentIds }) - invalidDisabledDocument() + await changeDocumentStatus({ + params: { dataset_id: datasetId, action: 'enable' }, + query: { document_id: documentIds }, + }) + queryClient.invalidateQueries({ queryKey: autoDisableLogsQuery.queryKey }) toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) } diff --git a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx index 1d1d14c008cfb9..665781814e944f 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx @@ -1,11 +1,12 @@ import type { ReactElement } from 'react' import type { IndexingStatusResponse } from '@/models/datasets' +import { QueryClient } from '@tanstack/react-query' import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { consoleQuery } from '@/service/console' import { renderWithConsoleQuery } from '@/test/console/query-data' import EmbeddingProcess from '../index' -const mockInvalidDocumentList = vi.fn() let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'COMMUNITY' let mockPlanType: 'sandbox' | 'professional' | 'team' = 'sandbox' let mockPollingState: { @@ -39,10 +40,6 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ useProcessRule: () => ({ data: undefined }), })) -vi.mock('@/service/knowledge/use-document', () => ({ - useInvalidDocumentList: () => mockInvalidDocumentList, -})) - vi.mock('@/hooks/use-api-access-url', () => ({ useDatasetApiAccessUrl: () => 'https://api.example.com/docs', })) @@ -88,6 +85,7 @@ function render(ui: ReactElement) { describe('EmbeddingProcess', () => { beforeEach(() => { + vi.spyOn(QueryClient.prototype, 'invalidateQueries') vi.clearAllMocks() deploymentEdition = 'COMMUNITY' mockPlanType = 'sandbox' @@ -189,7 +187,9 @@ describe('EmbeddingProcess', () => { expect(link).toHaveAttribute('href', '/datasets/dataset-1/documents') await user.click(link) - expect(mockInvalidDocumentList).toHaveBeenCalledOnce() + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key(), + }) }) it('links to the dataset API reference', () => { diff --git a/web/app/components/datasets/create/embedding-process/__tests__/utils.spec.ts b/web/app/components/datasets/create/embedding-process/__tests__/utils.spec.ts index 5fc81ccc70a71c..35e2da78e6e134 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/utils.spec.ts +++ b/web/app/components/datasets/create/embedding-process/__tests__/utils.spec.ts @@ -1,4 +1,8 @@ -import type { DataSourceInfo, FullDocumentDetail, IndexingStatusResponse } from '@/models/datasets' +import type { + DataSourceInfo, + IndexingStatusResponse, + InitialDocumentDetail, +} from '@/models/datasets' import { describe, expect, it } from 'vite-plus/test' import { createDocumentLookup, @@ -110,7 +114,7 @@ describe('createDocumentLookup', () => { notion_page_icon: 'https://icon.url', }, }, - ] as unknown as FullDocumentDetail[] + ] as unknown as InitialDocumentDetail[] it('should get document by id', () => { const lookup = createDocumentLookup(documents) @@ -143,7 +147,7 @@ describe('createDocumentLookup', () => { id: 'doc-3', data_source_info: { some_other: 'field' }, }, - ] as unknown as FullDocumentDetail[] + ] as unknown as InitialDocumentDetail[] const lookup = createDocumentLookup(docs) expect(lookup.getNotionIcon('doc-3')).toBeUndefined() }) diff --git a/web/app/components/datasets/create/embedding-process/index.tsx b/web/app/components/datasets/create/embedding-process/index.tsx index 9f1a93266bda52..5d7f1b55269496 100644 --- a/web/app/components/datasets/create/embedding-process/index.tsx +++ b/web/app/components/datasets/create/embedding-process/index.tsx @@ -1,11 +1,11 @@ import type { FC } from 'react' -import type { FullDocumentDetail } from '@/models/datasets' +import type { InitialDocumentDetail } from '@/models/datasets' import type { RETRIEVE_METHOD } from '@/types/app' import { buttonVariants } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Separator } from '@langgenius/dify-ui/separator' import { RiArrowRightLine, RiLoader2Fill, RiTerminalBoxLine } from '@remixicon/react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -15,7 +15,6 @@ import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' import Link from '@/next/link' import { consoleQuery } from '@/service/console' import { useProcessRule } from '@/service/knowledge/use-dataset' -import { useInvalidDocumentList } from '@/service/knowledge/use-document' import IndexingProgressItem from './indexing-progress-item' import RuleDetail from './rule-detail' import UpgradeBanner from './upgrade-banner' @@ -25,7 +24,7 @@ import { createDocumentLookup } from './utils' type EmbeddingProcessProps = { datasetId: string batchId: string - documents?: FullDocumentDetail[] + documents?: InitialDocumentDetail[] indexingType?: string retrievalMethod?: RETRIEVE_METHOD } @@ -99,7 +98,11 @@ const EmbeddingProcess: FC = ({ select: (data) => data.billing.subscription.plan, }), ) - const invalidDocumentList = useInvalidDocumentList() + const queryClient = useQueryClient() + const invalidDocumentList = () => + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key(), + }) const apiReferenceUrl = useDatasetApiAccessUrl() // Polling hook for indexing status diff --git a/web/app/components/datasets/create/embedding-process/utils.ts b/web/app/components/datasets/create/embedding-process/utils.ts index 0f0fbfd84d7cd7..a61e96872c8333 100644 --- a/web/app/components/datasets/create/embedding-process/utils.ts +++ b/web/app/components/datasets/create/embedding-process/utils.ts @@ -1,8 +1,8 @@ import type { DataSourceInfo, DataSourceType, - FullDocumentDetail, IndexingStatusResponse, + InitialDocumentDetail, LegacyDataSourceInfo, } from '@/models/datasets' @@ -42,7 +42,7 @@ export const getFileType = (name?: string): string => name?.split('.').pop() || /** * Document lookup utilities - provides document info by ID from a list */ -export const createDocumentLookup = (documents: FullDocumentDetail[]) => { +export const createDocumentLookup = (documents: InitialDocumentDetail[]) => { const documentMap = new Map(documents.map((doc) => [doc.id, doc])) return { diff --git a/web/app/components/datasets/create/step-three/index.tsx b/web/app/components/datasets/create/step-three/index.tsx index 6c15653d95b340..16ea2f23c4ca8f 100644 --- a/web/app/components/datasets/create/step-three/index.tsx +++ b/web/app/components/datasets/create/step-three/index.tsx @@ -1,5 +1,5 @@ 'use client' -import type { createDocumentResponse, FullDocumentDetail } from '@/models/datasets' +import type { createDocumentResponse } from '@/models/datasets' import type { RETRIEVE_METHOD } from '@/types/app' import { Separator } from '@langgenius/dify-ui/separator' import { RiBookOpenLine } from '@remixicon/react' @@ -73,7 +73,7 @@ const StepThree = ({ ): Full id: 'doc-1', doc_form: ChunkingMode.text, doc_language: 'English', - file: { id: 'file-1', name: 'test.pdf', extension: 'pdf' }, - notion_page: createMockNotionPage(), - website_page: createMockWebsitePage(), dataset_process_rule: { mode: ProcessMode.general, rules: { @@ -1025,7 +1022,7 @@ describe('usePreviewState', () => { expect(result.current.previewNotionPage).toEqual(defaultOptions.notionPages[0]) }) - it('should initialize with document detail when provided', () => { + it('should start without a preview file when editing an existing document', () => { const documentDetail = createMockDocumentDetail() const { result } = renderHook(() => usePreviewState({ @@ -1035,7 +1032,7 @@ describe('usePreviewState', () => { }), ) - expect(result.current.previewFile).toEqual(documentDetail.file) + expect(result.current.previewFile).toBeUndefined() }) }) diff --git a/web/app/components/datasets/create/step-two/__tests__/process-rule.spec.ts b/web/app/components/datasets/create/step-two/__tests__/process-rule.spec.ts new file mode 100644 index 00000000000000..5eb9a4a63ae546 --- /dev/null +++ b/web/app/components/datasets/create/step-two/__tests__/process-rule.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vite-plus/test' +import { isProcessMode, toSegmentationRules } from '../process-rule' + +describe('toSegmentationRules', () => { + it('should restore stored segmentation with the backend defaults', () => { + expect( + toSegmentationRules({ + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'legacy_rule', enabled: true }, + ], + segmentation: { max_tokens: 500 }, + parent_mode: 'paragraph', + subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 10 }, + }), + ).toEqual({ + pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }], + segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 0 }, + parent_mode: 'paragraph', + subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 10 }, + }) + }) + + it('should restore nothing for automatic rules without segmentation', () => { + expect(toSegmentationRules({ pre_processing_rules: null })).toBeUndefined() + }) +}) + +describe('isProcessMode', () => { + it('should accept only the editable process modes', () => { + expect(isProcessMode('custom')).toBe(true) + expect(isProcessMode('hierarchical')).toBe(true) + expect(isProcessMode('automatic')).toBe(false) + }) +}) diff --git a/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts b/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts index f2f9ea6089e11d..b7a762aa504988 100644 --- a/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts +++ b/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts @@ -26,10 +26,10 @@ type UseIndexingEstimateOptions = { files: CustomFile[] previewFileName?: string // Notion data source - previewNotionPage: NotionPage + previewNotionPage?: NotionPage notionCredentialId: string // Website data source - previewWebsitePage: CrawlResultItem + previewWebsitePage?: CrawlResultItem crawlOptions?: CrawlOptions websiteCrawlProvider?: DataSourceProvider websiteCrawlJobId?: string @@ -72,7 +72,7 @@ export const useIndexingEstimate = (options: UseIndexingEstimateOptions) => { docForm: currentDocForm, docLanguage, dataSourceType: DataSourceType.NOTION, - notionPages: [previewNotionPage], + notionPages: previewNotionPage ? [previewNotionPage] : [], indexingTechnique, processRule, dataset_id: datasetId || '', @@ -84,7 +84,7 @@ export const useIndexingEstimate = (options: UseIndexingEstimateOptions) => { docForm: currentDocForm, docLanguage, dataSourceType: DataSourceType.WEB, - websitePages: [previewWebsitePage], + websitePages: previewWebsitePage ? [previewWebsitePage] : [], crawlOptions, websiteCrawlProvider: websiteCrawlProvider ?? DataSourceProvider.jinaReader, websiteCrawlJobId: websiteCrawlJobId ?? '', diff --git a/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts b/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts index f7eda67405a20d..64144f8f85c75c 100644 --- a/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts +++ b/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts @@ -21,18 +21,20 @@ export const usePreviewState = (options: UsePreviewStateOptions) => { const { dataSourceType, files, notionPages, websitePages, documentDetail, datasetId } = options // File preview state - const [previewFile, setPreviewFile] = useState( - datasetId && documentDetail ? documentDetail.file : files[0], + // Editing an existing document has no preview item until one is picked. + const isEditing = !!datasetId && !!documentDetail + const [previewFile, setPreviewFile] = useState( + isEditing ? undefined : files[0], ) // Notion page preview state - const [previewNotionPage, setPreviewNotionPage] = useState( - datasetId && documentDetail ? documentDetail.notion_page : notionPages[0], + const [previewNotionPage, setPreviewNotionPage] = useState( + isEditing ? undefined : notionPages[0], ) // Website page preview state - const [previewWebsitePage, setPreviewWebsitePage] = useState( - datasetId && documentDetail ? documentDetail.website_page : websitePages[0], + const [previewWebsitePage, setPreviewWebsitePage] = useState( + isEditing ? undefined : websitePages[0], ) // Get preview items for document picker based on data source type diff --git a/web/app/components/datasets/create/step-two/index.tsx b/web/app/components/datasets/create/step-two/index.tsx index 4a88d4eec75bc0..faccc3b20fba29 100644 --- a/web/app/components/datasets/create/step-two/index.tsx +++ b/web/app/components/datasets/create/step-two/index.tsx @@ -31,6 +31,7 @@ import { usePreviewState, useSegmentationState, } from './hooks' +import { isProcessMode, toSegmentationRules } from './process-rule' // oxlint-disable-next-line no-barrel-files/no-barrel-files export { IndexingType } @@ -84,7 +85,7 @@ const StepTwo: FC = ({ datasetId && documentDetail ? (documentDetail.doc_form as ChunkingMode) : ChunkingMode.text, ) const [docLanguage, setDocLanguage] = useState(() => - datasetId && documentDetail + datasetId && documentDetail?.doc_language ? documentDetail.doc_language : locale !== LanguagesSupported[1] ? 'English' @@ -253,13 +254,16 @@ const StepTwo: FC = ({ useEffect(() => { if (!isSetting) { fetchDefaultProcessRuleMutation.mutate() - } else if (documentDetail) { - const rules = documentDetail.dataset_process_rule.rules - const isHierarchical = - documentDetail.doc_form === ChunkingMode.parentChild || - Boolean(rules.parent_mode && rules.subchunk_segmentation) - segmentation.applyConfigFromRules(rules, isHierarchical) - segmentation.setSegmentationType(documentDetail.dataset_process_rule.mode) + } else if (documentDetail?.dataset_process_rule) { + const { mode, rules } = documentDetail.dataset_process_rule + const segmentationRules = rules ? toSegmentationRules(rules) : undefined + if (segmentationRules) { + const isHierarchical = + documentDetail.doc_form === ChunkingMode.parentChild || + Boolean(segmentationRules.parent_mode && segmentationRules.subchunk_segmentation) + segmentation.applyConfigFromRules(segmentationRules, isHierarchical) + } + if (isProcessMode(mode)) segmentation.setSegmentationType(mode) } // oxlint-disable-next-line react/exhaustive-deps }, []) diff --git a/web/app/components/datasets/create/step-two/process-rule.ts b/web/app/components/datasets/create/step-two/process-rule.ts new file mode 100644 index 00000000000000..f65dc8881740e0 --- /dev/null +++ b/web/app/components/datasets/create/step-two/process-rule.ts @@ -0,0 +1,40 @@ +import type { DocumentProcessRulesResponse } from '@dify/contracts/api/console/datasets/types.gen' +import type { PreProcessingRule, Rules } from '@/models/datasets' +import { ProcessMode } from '@/models/datasets' + +const PRE_PROCESSING_RULE_IDS = new Set([ + 'remove_extra_spaces', + 'remove_stopwords', + 'remove_urls_emails', +] satisfies PreProcessingRule['id'][]) + +const isPreProcessingRule = (rule: { id: string; enabled: boolean }): rule is PreProcessingRule => + PRE_PROCESSING_RULE_IDS.has(rule.id) + +export const isProcessMode = (mode: string): mode is ProcessMode => + Object.values(ProcessMode).includes(mode) + +const toSegmentation = ( + segmentation: NonNullable, +) => ({ + // The backend defaults an unspecified separator to a newline and overlap to zero. + separator: segmentation.separator ?? '\n', + max_tokens: segmentation.max_tokens, + chunk_overlap: segmentation.chunk_overlap ?? 0, +}) + +/** + * Segmentation settings from a stored process rule. Automatic-mode rules store no segmentation, + * so there is nothing to restore for them. + */ +export const toSegmentationRules = (rules: DocumentProcessRulesResponse): Rules | undefined => { + if (!rules.segmentation) return undefined + return { + pre_processing_rules: (rules.pre_processing_rules ?? []).filter(isPreProcessingRule), + segmentation: toSegmentation(rules.segmentation), + parent_mode: rules.parent_mode ?? undefined, + subchunk_segmentation: rules.subchunk_segmentation + ? toSegmentation(rules.subchunk_segmentation) + : undefined, + } +} diff --git a/web/app/components/datasets/documents/__tests__/document-source.spec.ts b/web/app/components/datasets/documents/__tests__/document-source.spec.ts new file mode 100644 index 00000000000000..e01f78eb846248 --- /dev/null +++ b/web/app/components/datasets/documents/__tests__/document-source.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vite-plus/test' +import { + getLocalFileExtension, + getNotionPageIcon, + getOnlineDriveFileName, + getUploadFileExtension, +} from '../document-source' + +describe('document source info', () => { + it('should read the uploaded file extension of legacy file documents', () => { + expect(getUploadFileExtension({ upload_file: { extension: 'pdf' } })).toBe('pdf') + expect(getUploadFileExtension({ upload_file: 'broken' })).toBeUndefined() + expect(getUploadFileExtension(null)).toBeUndefined() + }) + + it('should read pipeline file details', () => { + expect(getLocalFileExtension({ extension: 'md' })).toBe('md') + expect(getOnlineDriveFileName({ name: 'report.docx' })).toBe('report.docx') + expect(getOnlineDriveFileName({ name: 3 })).toBeUndefined() + }) + + it('should read the Notion page icon for imports and pipeline pages', () => { + const icon = { type: 'emoji', emoji: '📄', url: null } + + expect(getNotionPageIcon({ notion_page_icon: 'https://icon.url' }, false)).toBe( + 'https://icon.url', + ) + expect(getNotionPageIcon({ page: { page_icon: icon } }, true)).toEqual(icon) + expect(getNotionPageIcon({ page: { page_icon: 42 } }, true)).toBeUndefined() + expect(getNotionPageIcon({ notion_page_icon: icon }, true)).toBeUndefined() + }) +}) diff --git a/web/app/components/datasets/documents/__tests__/index.spec.tsx b/web/app/components/datasets/documents/__tests__/index.spec.tsx index ba98e6ef6c7fa2..3f075a99d4775a 100644 --- a/web/app/components/datasets/documents/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/__tests__/index.spec.tsx @@ -1,15 +1,33 @@ import type { ReactNode } from 'react' import type { DocumentListResponse } from '@/models/datasets' -import { QueryClientProvider } from '@tanstack/react-query' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, screen } from '@testing-library/react' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { DataSourceType } from '@/models/datasets' -import { useDocumentList } from '@/service/knowledge/use-document' +import { consoleQuery } from '@/service/console' import { createAccountProfileQueryClient } from '@/test/console/account-profile' import { render as renderWithConsoleState } from '@/test/console/render' import { useDocumentsPageState } from '../hooks/use-documents-page-state' import Documents from '../index' +type DocumentListQueryOptions = { + input: { query: { status?: string } } + refetchInterval?: (query: { + state: { data?: { data: Array<{ indexing_status?: string | null }> } } + }) => number | false +} + +const expectDocumentsInvalidated = () => { + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ + input: { params: { dataset_id: 'test-dataset-id' } }, + }), + }) + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.key(), + }) +} + const render = (ui: Parameters[0]) => { const queryClient = createAccountProfileQueryClient({ id: 'test-user' }) return renderWithConsoleState(ui, { @@ -78,43 +96,49 @@ vi.mock('@/features/system-features/state', async () => { })) }) -// Mock document service hooks -const mockInvalidDocumentList = vi.fn() -const mockInvalidDocumentDetail = vi.fn() - -vi.mock('@/service/knowledge/use-document', () => ({ - useDocumentList: vi.fn(() => ({ - data: { - data: [ - { - id: 'doc-1', - name: 'Document 1', - indexing_status: 'completed', - data_source_type: 'upload_file', - position: 1, - enabled: true, - }, - { - id: 'doc-2', - name: 'Document 2', - indexing_status: 'indexing', - data_source_type: 'upload_file', - position: 2, - enabled: true, - }, - ], - total: 2, - page: 1, - limit: 10, - has_more: false, - } as DocumentListResponse, - isLoading: false, - refetch: vi.fn(), - })), - useInvalidDocumentList: vi.fn(() => mockInvalidDocumentList), - useInvalidDocumentDetail: vi.fn(() => mockInvalidDocumentDetail), +// Mock the generated document list query +const { mockDocumentListQuery } = vi.hoisted(() => ({ + mockDocumentListQuery: vi.fn( + ( + _options?: unknown, + ): { data?: DocumentListResponse; isLoading: boolean; refetch: () => void } => ({ + data: { + data: [ + { + id: 'doc-1', + name: 'Document 1', + indexing_status: 'completed', + data_source_type: 'upload_file', + position: 1, + enabled: true, + }, + { + id: 'doc-2', + name: 'Document 2', + indexing_status: 'indexing', + data_source_type: 'upload_file', + position: 2, + enabled: true, + }, + ], + total: 2, + page: 1, + limit: 10, + has_more: false, + } as DocumentListResponse, + isLoading: false, + refetch: vi.fn(), + }), + ), })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.get': (options: unknown) => mockDocumentListQuery(options), + }) +}) + // Mock segment service hooks vi.mock('@/service/knowledge/use-segment', () => ({ useSegmentListKey: 'segment-list-key', @@ -303,6 +327,7 @@ describe('Documents', () => { } beforeEach(() => { + vi.spyOn(QueryClient.prototype, 'invalidateQueries') vi.clearAllMocks() mockPush.mockClear() // Reset context mocks to default @@ -336,18 +361,18 @@ describe('Documents', () => { }) it('should render loading state when isLoading is true', () => { - vi.mocked(useDocumentList).mockReturnValueOnce({ + mockDocumentListQuery.mockReturnValueOnce({ data: undefined, isLoading: true, refetch: vi.fn(), - } as unknown as ReturnType) + }) render() expect(screen.queryByTestId('documents-list')).not.toBeInTheDocument() }) it('should keep rendering list when loading with existing data', () => { - vi.mocked(useDocumentList).mockReturnValueOnce({ + mockDocumentListQuery.mockReturnValueOnce({ data: { data: [ { @@ -366,7 +391,7 @@ describe('Documents', () => { } as DocumentListResponse, isLoading: true, refetch: vi.fn(), - } as unknown as ReturnType) + }) render() expect(screen.getByTestId('documents-list')).toBeInTheDocument() @@ -374,11 +399,11 @@ describe('Documents', () => { }) it('should render empty element when no documents exist', () => { - vi.mocked(useDocumentList).mockReturnValueOnce({ + mockDocumentListQuery.mockReturnValueOnce({ data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, isLoading: false, refetch: vi.fn(), - } as unknown as ReturnType) + }) render() expect(screen.getByTestId('empty-element')).toBeInTheDocument() @@ -403,11 +428,11 @@ describe('Documents', () => { return selector(mockState as MockState) }, ) - vi.mocked(useDocumentList).mockReturnValueOnce({ + mockDocumentListQuery.mockReturnValueOnce({ data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, isLoading: false, refetch: vi.fn(), - } as unknown as ReturnType) + }) render() expect(screen.getByTestId('empty-type')).toHaveTextContent('sync') @@ -538,11 +563,11 @@ describe('Documents', () => { return selector(mockState as MockState) }, ) - vi.mocked(useDocumentList).mockReturnValueOnce({ + mockDocumentListQuery.mockReturnValueOnce({ data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, isLoading: false, refetch: vi.fn(), - } as unknown as ReturnType) + }) render() @@ -553,10 +578,12 @@ describe('Documents', () => { }) describe('Query Options', () => { - it('should pass function refetchInterval to useDocumentList', () => { + it('should pass function refetchInterval to the document list query', () => { render() - const payload = vi.mocked(useDocumentList).mock.calls.at(-1)?.[0] + const payload = mockDocumentListQuery.mock.calls.at(-1)?.[0] as + | DocumentListQueryOptions + | undefined expect(payload).toBeDefined() expect(typeof payload?.refetchInterval).toBe('function') }) @@ -564,7 +591,9 @@ describe('Documents', () => { it('should stop polling when all documents are in terminal statuses', () => { render() - const payload = vi.mocked(useDocumentList).mock.calls.at(-1)?.[0] + const payload = mockDocumentListQuery.mock.calls.at(-1)?.[0] as + | DocumentListQueryOptions + | undefined const refetchInterval = payload?.refetchInterval expect(typeof refetchInterval).toBe('function') if (typeof refetchInterval !== 'function') @@ -580,7 +609,7 @@ describe('Documents', () => { ], }, }, - } as unknown as Parameters[0]) + }) expect(interval).toBe(false) }) @@ -606,9 +635,9 @@ describe('Documents', () => { render() - const payload = vi - .mocked(useDocumentList) - .mock.calls.find(([options]) => options.query.status === 'indexing')?.[0] + const payload = mockDocumentListQuery.mock.calls.find( + ([options]) => (options as DocumentListQueryOptions).input.query.status === 'indexing', + )?.[0] as DocumentListQueryOptions | undefined const refetchInterval = payload?.refetchInterval expect(typeof refetchInterval).toBe('function') if (typeof refetchInterval !== 'function') @@ -620,7 +649,7 @@ describe('Documents', () => { data: [{ indexing_status: 'completed' }], }, }, - } as unknown as Parameters[0]) + }) expect(interval).toBe(2500) }) @@ -632,8 +661,7 @@ describe('Documents', () => { screen.getByTestId('update-btn').click() - expect(mockInvalidDocumentList).toHaveBeenCalled() - expect(mockInvalidDocumentDetail).toHaveBeenCalled() + expectDocumentsInvalidated() }) it('should handle update with delayed chunk invalidation', async () => { @@ -642,8 +670,7 @@ describe('Documents', () => { render() screen.getByTestId('update-btn').click() - expect(mockInvalidDocumentList).toHaveBeenCalled() - expect(mockInvalidDocumentDetail).toHaveBeenCalled() + expectDocumentsInvalidated() await act(async () => { vi.advanceTimersByTime(5000) @@ -668,11 +695,11 @@ describe('Documents', () => { }) it('should handle empty documents array', () => { - vi.mocked(useDocumentList).mockReturnValueOnce({ + mockDocumentListQuery.mockReturnValueOnce({ data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, isLoading: false, refetch: vi.fn(), - } as unknown as ReturnType) + }) render() @@ -680,11 +707,11 @@ describe('Documents', () => { }) it('should handle undefined documentsRes', () => { - vi.mocked(useDocumentList).mockReturnValueOnce({ + mockDocumentListQuery.mockReturnValueOnce({ data: undefined, isLoading: false, refetch: vi.fn(), - } as unknown as ReturnType) + }) render() diff --git a/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx b/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx index da2b79c295f373..231b12d79e0a86 100644 --- a/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx @@ -42,19 +42,41 @@ const mockResume = vi.fn() let isDownloadPending = false const mockGenerateSummary = vi.fn() -vi.mock('@/service/knowledge/use-document', () => ({ - useDocumentArchive: () => ({ mutateAsync: mockArchive }), - useDocumentUnArchive: () => ({ mutateAsync: mockUnArchive }), - useDocumentEnable: () => ({ mutateAsync: mockEnable }), - useDocumentDisable: () => ({ mutateAsync: mockDisable }), - useDocumentDelete: () => ({ mutateAsync: mockDelete }), - useDocumentDownload: () => ({ mutateAsync: mockDownload, isPending: isDownloadPending }), - useSyncDocument: () => ({ mutateAsync: mockSync }), - useSyncWebsite: () => ({ mutateAsync: mockSyncWebsite }), - useDocumentPause: () => ({ mutateAsync: mockPause }), - useDocumentResume: () => ({ mutateAsync: mockResume }), - useDocumentSummary: () => ({ mutateAsync: mockGenerateSummary }), -})) +const statusMocks: Record unknown> = { + archive: (input) => mockArchive(input), + un_archive: (input) => mockUnArchive(input), + enable: (input) => mockEnable(input), + disable: (input) => mockDisable(input), +} +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.status.byAction.batch.patch': () => ({ + mutateAsync: (input: { params: { action: string } }) => + statusMocks[input.params.action]!(input), + }), + 'datasets.byDatasetId.documents.delete': () => ({ mutateAsync: mockDelete }), + 'datasets.byDatasetId.documents.byDocumentId.download.get': () => ({ + mutateAsync: mockDownload, + isPending: isDownloadPending, + }), + 'datasets.byDatasetId.documents.byDocumentId.notion.sync.get': () => ({ + mutateAsync: mockSync, + }), + 'datasets.byDatasetId.documents.byDocumentId.websiteSync.get': () => ({ + mutateAsync: mockSyncWebsite, + }), + 'datasets.byDatasetId.documents.byDocumentId.processing.pause.patch': () => ({ + mutateAsync: mockPause, + }), + 'datasets.byDatasetId.documents.byDocumentId.processing.resume.patch': () => ({ + mutateAsync: mockResume, + }), + 'datasets.byDatasetId.documents.generateSummary.post': () => ({ + mutateAsync: mockGenerateSummary, + }), + }) +}) // Mock downloadUrl utility const mockDownloadUrl = vi.fn() @@ -229,7 +251,10 @@ describe('Operations', () => { await act(async () => { vi.advanceTimersByTime(600) }) - expect(mockEnable).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' }) + expect(mockEnable).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', action: 'enable' }, + query: { document_id: ['doc-1'] }, + }) vi.useRealTimers() }) @@ -246,7 +271,10 @@ describe('Operations', () => { await act(async () => { vi.advanceTimersByTime(600) }) - expect(mockDisable).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' }) + expect(mockDisable).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', action: 'disable' }, + query: { document_id: ['doc-1'] }, + }) vi.useRealTimers() }) @@ -351,7 +379,10 @@ describe('Operations', () => { fireEvent.click(archiveButton) }) await waitFor(() => { - expect(mockArchive).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' }) + expect(mockArchive).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', action: 'archive' }, + query: { document_id: ['doc-1'] }, + }) }) }) @@ -363,7 +394,10 @@ describe('Operations', () => { fireEvent.click(unarchiveButton) }) await waitFor(() => { - expect(mockUnArchive).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' }) + expect(mockUnArchive).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', action: 'un_archive' }, + query: { document_id: ['doc-1'] }, + }) }) }) @@ -391,7 +425,10 @@ describe('Operations', () => { fireEvent.click(confirmButton) }) await waitFor(() => { - expect(mockDelete).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' }) + expect(mockDelete).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1' }, + query: { document_id: ['doc-1'] }, + }) }) }) @@ -463,7 +500,9 @@ describe('Operations', () => { fireEvent.click(syncButton) }) await waitFor(() => { - expect(mockSync).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' }) + expect(mockSync).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', document_id: 'doc-1' }, + }) }) }) @@ -481,8 +520,7 @@ describe('Operations', () => { }) await waitFor(() => { expect(mockSyncWebsite).toHaveBeenCalledWith({ - datasetId: 'dataset-1', - documentId: 'doc-1', + params: { dataset_id: 'dataset-1', document_id: 'doc-1' }, }) }) }) @@ -497,7 +535,9 @@ describe('Operations', () => { fireEvent.click(pauseButton) }) await waitFor(() => { - expect(mockPause).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' }) + expect(mockPause).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', document_id: 'doc-1' }, + }) }) }) @@ -511,7 +551,9 @@ describe('Operations', () => { fireEvent.click(resumeButton) }) await waitFor(() => { - expect(mockResume).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' }) + expect(mockResume).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', document_id: 'doc-1' }, + }) }) }) @@ -523,7 +565,9 @@ describe('Operations', () => { fireEvent.click(downloadButton) }) await waitFor(() => { - expect(mockDownload).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' }) + expect(mockDownload).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', document_id: 'doc-1' }, + }) expect(mockDownloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/download', fileName: 'Test Document', @@ -578,7 +622,9 @@ describe('Operations', () => { fireEvent.click(downloadButton) }) await waitFor(() => { - expect(mockDownload).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' }) + expect(mockDownload).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', document_id: 'doc-1' }, + }) }) }) }) diff --git a/web/app/components/datasets/documents/components/document-list/components/document-source-icon.tsx b/web/app/components/datasets/documents/components/document-list/components/document-source-icon.tsx index 6a43e2edfe03b3..57d4a131fd77c7 100644 --- a/web/app/components/datasets/documents/components/document-list/components/document-source-icon.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/document-source-icon.tsx @@ -1,11 +1,5 @@ import type { FC } from 'react' -import type { - LegacyDataSourceInfo, - LocalFileInfo, - OnlineDocumentInfo, - OnlineDriveInfo, - SimpleDocumentDetail, -} from '@/models/datasets' +import type { SimpleDocumentDetail } from '@/models/datasets' import { RiGlobalLine } from '@remixicon/react' import * as React from 'react' import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon' @@ -13,31 +7,37 @@ import NotionIcon from '@/app/components/base/notion-icon' import { extensionToFileType } from '@/app/components/datasets/hit-testing/utils/extension-to-file-type' import { DataSourceType } from '@/models/datasets' import { DatasourceType } from '@/models/pipeline' +import { + getLocalFileExtension, + getNotionPageIcon, + getOnlineDriveFileName, + getUploadFileExtension, +} from '../../../document-source' type DocumentSourceIconProps = { doc: SimpleDocumentDetail fileType?: string } -const isLocalFile = (dataSourceType: DataSourceType | DatasourceType) => { +const isLocalFile = (dataSourceType?: string | null) => { return dataSourceType === DatasourceType.localFile || dataSourceType === DataSourceType.FILE } -const isOnlineDocument = (dataSourceType: DataSourceType | DatasourceType) => { +const isOnlineDocument = (dataSourceType?: string | null) => { return ( dataSourceType === DatasourceType.onlineDocument || dataSourceType === DataSourceType.NOTION ) } -const isWebsiteCrawl = (dataSourceType: DataSourceType | DatasourceType) => { +const isWebsiteCrawl = (dataSourceType?: string | null) => { return dataSourceType === DatasourceType.websiteCrawl } -const isOnlineDrive = (dataSourceType: DataSourceType | DatasourceType) => { +const isOnlineDrive = (dataSourceType?: string | null) => { return dataSourceType === DatasourceType.onlineDrive } -const isCreateFromRAGPipeline = (createdFrom: string) => { +const isCreateFromRAGPipeline = (createdFrom?: string | null) => { return createdFrom === 'rag-pipeline' } @@ -54,11 +54,7 @@ const DocumentSourceIcon: FC = React.memo(({ doc, fileT ) } @@ -68,8 +64,8 @@ const DocumentSourceIcon: FC = React.memo(({ doc, fileT @@ -80,7 +76,7 @@ const DocumentSourceIcon: FC = React.memo(({ doc, fileT return ( diff --git a/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx b/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx index 2537af270c2f7d..ffd42ac1a751c4 100644 --- a/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx @@ -16,7 +16,7 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import useTimestamp from '@/hooks/use-timestamp' -import { DataSourceType } from '@/models/datasets' +import { DataSourceType, isDocumentDisplayStatus } from '@/models/datasets' import Link from '@/next/link' import { useRouter, useSearchParams } from '@/next/navigation' import { formatNumber } from '@/utils/format' @@ -39,7 +39,7 @@ type DocumentTableRowProps = { onUpdate: () => void } -const renderCount = (count: number | undefined) => { +const renderCount = (count?: number | null) => { if (!count) return renderTdValue(0, true) if (count < 1000) return count @@ -169,13 +169,17 @@ const DocumentTableRow = React.memo( {renderCount(doc.word_count)} {renderCount(doc.hit_count)} - {formatTime( - doc.created_at, - t(($) => $.dateTimeFormat, { ns: 'datasetHitTesting' }) as string, - )} + {doc.created_at + ? formatTime( + doc.created_at, + t(($) => $.dateTimeFormat, { ns: 'datasetHitTesting' }) as string, + ) + : '-'} - + {isDocumentDisplayStatus(doc.display_status) && ( + + )} ({ mockMutateAsync: vi.fn() })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + const mutation = () => ({ mutateAsync: mockMutateAsync, isPending: false }) + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.status.byAction.batch.patch': mutation, + 'datasets.byDatasetId.documents.generateSummary.post': mutation, + 'datasets.byDatasetId.documents.delete': mutation, + 'datasets.byDatasetId.retry.post': mutation, + }) +}) + const mockUseDocumentDownloadZip = vi.mocked(useDocument.useDocumentDownloadZip) const createConsoleQueryClient = () => @@ -32,8 +38,6 @@ const createWrapper = () => { } describe('useDocumentActions', () => { - const mockMutateAsync = vi.fn() - beforeEach(() => { vi.clearAllMocks() @@ -55,24 +59,6 @@ describe('useDocumentActions', () => { submittedAt: 0, }) - mockUseDocumentArchive.mockReturnValue( - createMockMutation() as unknown as ReturnType, - ) - mockUseDocumentSummary.mockReturnValue( - createMockMutation() as unknown as ReturnType, - ) - mockUseDocumentEnable.mockReturnValue( - createMockMutation() as unknown as ReturnType, - ) - mockUseDocumentDisable.mockReturnValue( - createMockMutation() as unknown as ReturnType, - ) - mockUseDocumentDelete.mockReturnValue( - createMockMutation() as unknown as ReturnType, - ) - mockUseDocumentBatchRetryIndex.mockReturnValue( - createMockMutation() as unknown as ReturnType, - ) mockUseDocumentDownloadZip.mockReturnValue({ ...createMockMutation(), isPending: false, @@ -102,8 +88,8 @@ describe('useDocumentActions', () => { }) expect(mockMutateAsync).toHaveBeenCalledWith({ - datasetId: 'ds1', - documentIds: ['doc1'], + params: { dataset_id: 'ds1', action: 'archive' }, + query: { document_id: ['doc1'] }, }) }) @@ -183,8 +169,8 @@ describe('useDocumentActions', () => { }) expect(mockMutateAsync).toHaveBeenCalledWith({ - datasetId: 'ds1', - documentIds: ['doc1', 'doc2'], + params: { dataset_id: 'ds1' }, + body: { document_ids: ['doc1', 'doc2'] }, }) }) diff --git a/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts index 97052acdf353b9..72d0f330740276 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts +++ b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts @@ -17,13 +17,24 @@ const mockRetryIndex = vi.fn() const mockDownloadZip = vi.fn() let mockIsDownloadingZip = false +const statusMocks: Record unknown> = { + archive: (input) => mockArchive(input), + enable: (input) => mockEnable(input), + disable: (input) => mockDisable(input), +} +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.status.byAction.batch.patch': () => ({ + mutateAsync: (input: { params: { action: string } }) => + statusMocks[input.params.action]!(input), + }), + 'datasets.byDatasetId.documents.generateSummary.post': () => ({ mutateAsync: mockSummary }), + 'datasets.byDatasetId.documents.delete': () => ({ mutateAsync: mockDelete }), + 'datasets.byDatasetId.retry.post': () => ({ mutateAsync: mockRetryIndex }), + }) +}) vi.mock('@/service/knowledge/use-document', () => ({ - useDocumentArchive: () => ({ mutateAsync: mockArchive }), - useDocumentSummary: () => ({ mutateAsync: mockSummary }), - useDocumentEnable: () => ({ mutateAsync: mockEnable }), - useDocumentDisable: () => ({ mutateAsync: mockDisable }), - useDocumentDelete: () => ({ mutateAsync: mockDelete }), - useDocumentBatchRetryIndex: () => ({ mutateAsync: mockRetryIndex }), useDocumentDownloadZip: () => ({ mutateAsync: mockDownloadZip, isPending: mockIsDownloadingZip }), })) @@ -71,8 +82,8 @@ describe('useDocumentActions', () => { }) expect(mockArchive).toHaveBeenCalledWith({ - datasetId: 'ds-1', - documentIds: ['doc-1', 'doc-2'], + params: { dataset_id: 'ds-1', action: 'archive' }, + query: { document_id: ['doc-1', 'doc-2'] }, }) expect(mockToastSuccess).toHaveBeenCalledWith(expect.any(String)) expect(defaultOptions.onUpdate).toHaveBeenCalled() @@ -87,8 +98,8 @@ describe('useDocumentActions', () => { }) expect(mockEnable).toHaveBeenCalledWith({ - datasetId: 'ds-1', - documentIds: ['doc-1', 'doc-2'], + params: { dataset_id: 'ds-1', action: 'enable' }, + query: { document_id: ['doc-1', 'doc-2'] }, }) expect(defaultOptions.onUpdate).toHaveBeenCalled() }) @@ -162,8 +173,8 @@ describe('useDocumentActions', () => { }) expect(mockRetryIndex).toHaveBeenCalledWith({ - datasetId: 'ds-1', - documentIds: ['doc-1', 'doc-2'], + params: { dataset_id: 'ds-1' }, + body: { document_ids: ['doc-1', 'doc-2'] }, }) expect(defaultOptions.onClearSelection).toHaveBeenCalled() expect(defaultOptions.onUpdate).toHaveBeenCalled() diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts b/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts index 676ba234b80bca..f4beceaf0706e0 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts +++ b/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts @@ -1,16 +1,10 @@ -import { useCallback, useMemo } from 'react' +import { useMutation } from '@tanstack/react-query' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' import { DocumentActionType } from '@/models/datasets' -import { - useDocumentArchive, - useDocumentBatchRetryIndex, - useDocumentDelete, - useDocumentDisable, - useDocumentDownloadZip, - useDocumentEnable, - useDocumentSummary, -} from '@/service/knowledge/use-document' +import { consoleQuery } from '@/service/console' +import { useDocumentDownloadZip } from '@/service/knowledge/use-document' import { asyncRunSafe } from '@/utils' import { downloadBlob } from '@/utils/download' @@ -43,12 +37,17 @@ export const useDocumentActions = ({ }: UseDocumentActionsOptions) => { const { t } = useTranslation(['common']) - const { mutateAsync: archiveDocument } = useDocumentArchive() - const { mutateAsync: generateSummary } = useDocumentSummary() - const { mutateAsync: enableDocument } = useDocumentEnable() - const { mutateAsync: disableDocument } = useDocumentDisable() - const { mutateAsync: deleteDocument } = useDocumentDelete() - const { mutateAsync: retryIndexDocument } = useDocumentBatchRetryIndex() + const documents = consoleQuery.datasets.byDatasetId.documents + const { mutateAsync: changeDocumentStatus } = useMutation( + documents.status.byAction.batch.patch.mutationOptions(), + ) + const { mutateAsync: generateSummary } = useMutation( + documents.generateSummary.post.mutationOptions(), + ) + const { mutateAsync: deleteDocuments } = useMutation(documents.delete.mutationOptions()) + const { mutateAsync: retryIndexDocuments } = useMutation( + consoleQuery.datasets.byDatasetId.retry.post.mutationOptions(), + ) const { mutateAsync: requestDocumentsZip, isPending: isDownloadingZip } = useDocumentDownloadZip() type SupportedActionType = @@ -58,25 +57,33 @@ export const useDocumentActions = ({ | typeof DocumentActionType.disable | typeof DocumentActionType.delete - const actionMutationMap = useMemo( - () => - ({ - [DocumentActionType.archive]: archiveDocument, - [DocumentActionType.summary]: generateSummary, - [DocumentActionType.enable]: enableDocument, - [DocumentActionType.disable]: disableDocument, - [DocumentActionType.delete]: deleteDocument, - }) as const, - [archiveDocument, generateSummary, enableDocument, disableDocument, deleteDocument], + const runAction = useCallback( + (actionName: SupportedActionType): Promise => { + switch (actionName) { + case DocumentActionType.summary: + return generateSummary({ + params: { dataset_id: datasetId }, + body: { document_list: selectedIds }, + }) + case DocumentActionType.delete: + return deleteDocuments({ + params: { dataset_id: datasetId }, + query: { document_id: selectedIds }, + }) + default: + return changeDocumentStatus({ + params: { dataset_id: datasetId, action: actionName }, + query: { document_id: selectedIds }, + }) + } + }, + [changeDocumentStatus, datasetId, deleteDocuments, generateSummary, selectedIds], ) const handleAction = useCallback( (actionName: SupportedActionType) => { return async () => { - const opApi = actionMutationMap[actionName] - if (!opApi) return - - const [e] = await asyncRunSafe(opApi({ datasetId, documentIds: selectedIds })) + const [e] = await asyncRunSafe(runAction(actionName)) if (!e) { if (actionName === DocumentActionType.delete) onClearSelection() @@ -87,12 +94,15 @@ export const useDocumentActions = ({ } } }, - [actionMutationMap, datasetId, selectedIds, onClearSelection, onUpdate, t], + [runAction, onClearSelection, onUpdate, t], ) const handleBatchReIndex = useCallback(async () => { - const [e] = await asyncRunSafe( - retryIndexDocument({ datasetId, documentIds: selectedIds }), + const [e] = await asyncRunSafe( + retryIndexDocuments({ + params: { dataset_id: datasetId }, + body: { document_ids: selectedIds }, + }), ) if (!e) { onClearSelection() @@ -101,7 +111,7 @@ export const useDocumentActions = ({ } else { toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) } - }, [retryIndexDocument, datasetId, selectedIds, onClearSelection, onUpdate, t]) + }, [retryIndexDocuments, datasetId, selectedIds, onClearSelection, onUpdate, t]) const handleBatchDownload = useCallback(async () => { if (isDownloadingZip) return diff --git a/web/app/components/datasets/documents/components/operations.tsx b/web/app/components/datasets/documents/components/operations.tsx index 177985f78136ac..28eb214d2d540b 100644 --- a/web/app/components/datasets/documents/components/operations.tsx +++ b/web/app/components/datasets/documents/components/operations.tsx @@ -1,6 +1,4 @@ import type { OperationName } from '../types' -import type { CommonResponse } from '@/models/common' -import type { DocumentDownloadResponse } from '@/service/datasets' import { AlertDialog, AlertDialogCancelButton, @@ -21,7 +19,7 @@ import { import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Separator } from '@langgenius/dify-ui/separator' import { Switch } from '@langgenius/dify-ui/switch' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useBoolean, useDebounceFn } from 'ahooks' import { noop } from 'es-toolkit/function' import * as React from 'react' @@ -31,19 +29,7 @@ import { toast } from '@/app/notifications' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { DataSourceType, DocumentActionType } from '@/models/datasets' import { useRouter } from '@/next/navigation' -import { - useDocumentArchive, - useDocumentDelete, - useDocumentDisable, - useDocumentDownload, - useDocumentEnable, - useDocumentPause, - useDocumentResume, - useDocumentSummary, - useDocumentUnArchive, - useSyncDocument, - useSyncWebsite, -} from '@/service/knowledge/use-document' +import { consoleQuery } from '@/service/console' import { asyncRunSafe } from '@/utils' import { downloadUrl } from '@/utils/download' import s from '../style.module.css' @@ -52,13 +38,13 @@ import RenameModal from './rename-modal' type OperationsProps = { embeddingAvailable: boolean detail: { - name: string - enabled: boolean - archived: boolean id: string - data_source_type: string - doc_form: string - display_status?: string + name?: string | null + enabled?: boolean | null + archived?: boolean | null + data_source_type?: string | null + doc_form?: string | null + display_status?: string | null } selectedIds?: string[] onSelectedIdChange?: (ids: string[]) => void @@ -90,36 +76,45 @@ const Operations = ({ select: ({ deployment_edition }) => deployment_edition, }) const isNonCloudEdition = deploymentEdition === 'COMMUNITY' || deploymentEdition === 'ENTERPRISE' - const { - id, - name, - enabled = false, - archived = false, - data_source_type, - display_status, - } = detail || {} + const { id, name, data_source_type, display_status } = detail + const enabled = detail.enabled ?? false + const archived = detail.archived ?? false const [showModal, setShowModal] = useState(false) const [deleting, setDeleting] = useState(false) const { t } = useTranslation(['common', 'datasetDocuments']) const router = useRouter() - const { mutateAsync: archiveDocument } = useDocumentArchive() - const { mutateAsync: unArchiveDocument } = useDocumentUnArchive() - const { mutateAsync: enableDocument } = useDocumentEnable() - const { mutateAsync: disableDocument } = useDocumentDisable() - const { mutateAsync: deleteDocument } = useDocumentDelete() - const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload() - const { mutateAsync: syncDocument } = useSyncDocument() - const { mutateAsync: syncWebsite } = useSyncWebsite() - const { mutateAsync: generateSummary } = useDocumentSummary() - const { mutateAsync: pauseDocument } = useDocumentPause() - const { mutateAsync: resumeDocument } = useDocumentResume() + const documents = consoleQuery.datasets.byDatasetId.documents + const { mutateAsync: changeDocumentStatus } = useMutation( + documents.status.byAction.batch.patch.mutationOptions(), + ) + const { mutateAsync: deleteDocuments } = useMutation(documents.delete.mutationOptions()) + const { mutateAsync: downloadDocument, isPending: isDownloading } = useMutation( + documents.byDocumentId.download.get.mutationOptions(), + ) + const { mutateAsync: syncNotionDocument } = useMutation( + documents.byDocumentId.notion.sync.get.mutationOptions(), + ) + const { mutateAsync: syncWebsiteDocument } = useMutation( + documents.byDocumentId.websiteSync.get.mutationOptions(), + ) + const { mutateAsync: generateSummary } = useMutation( + documents.generateSummary.post.mutationOptions(), + ) + const { mutateAsync: pauseDocument } = useMutation( + documents.byDocumentId.processing.pause.patch.mutationOptions(), + ) + const { mutateAsync: resumeDocument } = useMutation( + documents.byDocumentId.processing.resume.patch.mutationOptions(), + ) const isListScene = scene === 'list' const canShowRenameAction = canEdit && !archived const canShowSummaryAction = canEdit && !archived && isNonCloudEdition const canShowSettingsAction = canViewSettings const canShowDownloadAction = canDownload && data_source_type === DataSourceType.FILE const canShowSyncAction = - canEdit && !archived && ['notion_import', DataSourceType.WEB].includes(data_source_type) + canEdit && + !archived && + (data_source_type === DataSourceType.NOTION || data_source_type === DataSourceType.WEB) const canShowPauseAction = canEdit && !archived && display_status?.toLowerCase() === 'indexing' const canShowResumeAction = canEdit && !archived && display_status?.toLowerCase() === 'paused' const canShowArchiveAction = canEdit && !archived @@ -147,41 +142,39 @@ const Operations = ({ async (operationName: OperationName) => { if (!canOperate(operationName)) return - let opApi - switch (operationName) { - case 'archive': - opApi = archiveDocument - break - case 'un_archive': - opApi = unArchiveDocument - break - case 'enable': - opApi = enableDocument - break - case 'disable': - opApi = disableDocument - break - case 'sync': - if (data_source_type === 'notion_import') opApi = syncDocument - else opApi = syncWebsite - break - case 'summary': - opApi = generateSummary - break - case 'pause': - opApi = pauseDocument - break - case 'resume': - opApi = resumeDocument - break - default: - opApi = deleteDocument - setDeleting(true) - break + const documentParams = { dataset_id: datasetId, document_id: id } + const runOperation = (): Promise => { + switch (operationName) { + case 'archive': + case 'un_archive': + case 'enable': + case 'disable': + return changeDocumentStatus({ + params: { dataset_id: datasetId, action: operationName }, + query: { document_id: [id] }, + }) + case 'sync': + return data_source_type === 'notion_import' + ? syncNotionDocument({ params: documentParams }) + : syncWebsiteDocument({ params: documentParams }) + case 'summary': + return generateSummary({ + params: { dataset_id: datasetId }, + body: { document_list: [id] }, + }) + case 'pause': + return pauseDocument({ params: documentParams }) + case 'resume': + return resumeDocument({ params: documentParams }) + default: + setDeleting(true) + return deleteDocuments({ + params: { dataset_id: datasetId }, + query: { document_id: [id] }, + }) + } } - const [e] = await asyncRunSafe( - opApi({ datasetId, documentId: id }) as Promise, - ) + const [e] = await asyncRunSafe(runOperation()) if (!e) { // The switch already shows the result of enable and disable. if (operationName !== 'enable' && operationName !== 'disable') @@ -196,13 +189,11 @@ const Operations = ({ if (operationName === DocumentActionType.delete) setDeleting(false) }, [ - archiveDocument, canOperate, + changeDocumentStatus, data_source_type, datasetId, - deleteDocument, - disableDocument, - enableDocument, + deleteDocuments, generateSummary, id, onSelectedIdChange, @@ -210,10 +201,9 @@ const Operations = ({ pauseDocument, resumeDocument, selectedIds, - syncDocument, - syncWebsite, + syncNotionDocument, + syncWebsiteDocument, t, - unArchiveDocument, ], ) const { run: handleSwitch } = useDebounceFn( @@ -253,20 +243,20 @@ const Operations = ({ // Avoid repeated clicks while the signed URL request is in-flight. if (isDownloading) return // Request a signed URL first (it points to `/files//file-preview?...&as_attachment=true`). - const [e, res] = await asyncRunSafe( - downloadDocument({ datasetId, documentId: id }) as Promise, + const [e, res] = await asyncRunSafe( + downloadDocument({ params: { dataset_id: datasetId, document_id: id } }), ) if (e || !res?.url) { toast.error(t(($) => $['actionMsg.downloadUnsuccessfully'], { ns: 'common' })) return } // Trigger download without navigating away (helps avoid duplicate downloads in some browsers). - downloadUrl({ url: res.url, fileName: name }) + downloadUrl({ url: res.url, fileName: name ?? undefined }) }, [canDownload, datasetId, downloadDocument, id, isDownloading, name, t]) const handleShowRename = useCallback(() => { handleShowRenameModal({ id: detail.id, - name: detail.name, + name: detail.name ?? '', }) }, [detail.id, detail.name, handleShowRenameModal]) const handleMenuOperation = useCallback( diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx index a0940f19631904..6bac35fb9a82d6 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx @@ -3,11 +3,13 @@ import type { ReactElement } from 'react' import type { Mock } from 'vite-plus/test' import type { DocumentIndexingStatus, IndexingStatusResponse } from '@/models/datasets' import type { InitialDocumentDetail } from '@/models/pipeline' +import { QueryClient } from '@tanstack/react-query' import { fireEvent, screen, waitFor } from '@testing-library/react' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import * as React from 'react' import { IndexingType } from '@/app/components/datasets/create/step-two' import { DatasourceType } from '@/models/pipeline' +import { consoleQuery } from '@/service/console' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { render as renderWithConsoleState } from '@/test/console/render' import { RETRIEVE_METHOD } from '@/types/app' @@ -66,12 +68,6 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ }), })) -// Mock useInvalidDocumentList hook -const mockInvalidDocumentList = vi.fn() -vi.mock('@/service/knowledge/use-document', () => ({ - useInvalidDocumentList: () => mockInvalidDocumentList, -})) - // Mock useDatasetApiAccessUrl hook vi.mock('@/hooks/use-api-access-url', () => ({ useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets', @@ -155,6 +151,7 @@ function render(ui: ReactElement) { describe('EmbeddingProcess', () => { beforeEach(() => { + vi.spyOn(QueryClient.prototype, 'invalidateQueries') vi.clearAllMocks() vi.useFakeTimers({ shouldAdvanceTime: true }) @@ -662,7 +659,9 @@ describe('EmbeddingProcess', () => { expect(link).toHaveAttribute('href', '/datasets/my-dataset-123/documents') fireEvent.click(link) - expect(mockInvalidDocumentList).toHaveBeenCalled() + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key(), + }) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx index abb8c43719c6a0..dc0c9d8c4350e1 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx @@ -15,7 +15,7 @@ import { RiLoader2Fill, RiTerminalBoxLine, } from '@remixicon/react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import * as React from 'react' import { useEffect, useMemo, useState } from 'react' @@ -31,7 +31,6 @@ import { DatasourceType } from '@/models/pipeline' import Link from '@/next/link' import { consoleQuery } from '@/service/console' import { useIndexingStatusBatch, useProcessRule } from '@/service/knowledge/use-dataset' -import { useInvalidDocumentList } from '@/service/knowledge/use-document' import RuleDetail from './rule-detail' const isNotionPageIcon = (value: unknown): value is DataSourceNotionPage['page_icon'] => @@ -96,7 +95,11 @@ const EmbeddingProcess = ({ const firstDocument = documents[0] const { data: ruleDetail } = useProcessRule(firstDocument!.id) - const invalidDocumentList = useInvalidDocumentList() + const queryClient = useQueryClient() + const invalidDocumentList = () => + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key(), + }) const documentsHref = `/datasets/${datasetId}/documents` const apiReferenceUrl = useDatasetApiAccessUrl() diff --git a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx index 242e66e6ccfe96..9e5d37de3378e4 100644 --- a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx @@ -1,5 +1,7 @@ +import { QueryClient } from '@tanstack/react-query' import { act, fireEvent, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { consoleQuery } from '@/service/console' import { renderWithAccountProfile as render } from '@/test/console/account-profile' import { DatasetACLPermission } from '@/utils/permission' @@ -22,7 +24,6 @@ const mocks = vi.hoisted(() => { detailRefetch: vi.fn(), checkProgress: vi.fn(), batchImport: vi.fn(), - invalidDocumentList: vi.fn(), invalidSegmentList: vi.fn(), invalidChildSegmentList: vi.fn(), useDocumentTitle: vi.fn(), @@ -57,17 +58,21 @@ vi.mock('@/context/dataset-detail', () => ({ selector({ dataset: mocks.state.dataset }), })) -vi.mock('@/service/knowledge/use-document', () => ({ - useDocumentDetail: () => ({ - data: mocks.state.documentDetail, - error: mocks.state.documentError, - refetch: mocks.detailRefetch, - }), - useDocumentMetadata: () => ({ - data: mocks.state.documentMetadata, - }), - useInvalidDocumentList: () => mocks.invalidDocumentList, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.byDocumentId.get': (options: { + input: { query: { metadata: string } } + }) => + options.input.query.metadata === 'only' + ? { data: mocks.state.documentMetadata } + : { + data: mocks.state.documentDetail, + error: mocks.state.documentError, + refetch: mocks.detailRefetch, + }, + }) +}) vi.mock('@/service/knowledge/use-segment', () => ({ useCheckSegmentBatchImportProgress: () => ({ @@ -255,6 +260,13 @@ vi.mock('@/app/components/base/float-right-container', () => ({ ) : null, })) +const expectDocumentListInvalidated = () => + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ + input: { params: { dataset_id: 'ds-1' } }, + }), + }) + // --- Lazy import (after all vi.mock calls) --- const { default: DocumentDetail } = await import('../index') @@ -284,6 +296,7 @@ vi.mock('@/context/permission-state', async () => { describe('DocumentDetail', () => { beforeEach(() => { vi.clearAllMocks() + vi.spyOn(QueryClient.prototype, 'invalidateQueries') vi.useFakeTimers() mocks.state.dataset = { embedding_available: true, @@ -468,13 +481,13 @@ describe('DocumentDetail', () => { it('should invalidate document list on any operation', () => { render() fireEvent.click(screen.getByTestId('op-rename')) - expect(mocks.invalidDocumentList).toHaveBeenCalled() + expectDocumentListInvalidated() }) it('should navigate back on delete operation', () => { render() fireEvent.click(screen.getByTestId('op-delete')) - expect(mocks.invalidDocumentList).toHaveBeenCalled() + expectDocumentListInvalidated() expect(mocks.push).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/documents/detail/document-title.tsx b/web/app/components/datasets/documents/detail/document-title.tsx index 7326102bdd4c84..45f6a65a849a54 100644 --- a/web/app/components/datasets/documents/detail/document-title.tsx +++ b/web/app/components/datasets/documents/detail/document-title.tsx @@ -1,11 +1,12 @@ -import type { ParentMode, SimpleDocumentDetail } from '@/models/datasets' +import type { PickerDocument } from '../../common/document-picker' +import type { ParentMode } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' import { useRouter } from '@/next/navigation' import { DocumentPicker } from '../../common/document-picker' type DocumentTitleProps = { datasetId: string - document?: SimpleDocumentDetail | null + document?: PickerDocument | null parentMode?: ParentMode wrapperCls?: string } diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx index 27b9d31457404f..60124dae9d1185 100644 --- a/web/app/components/datasets/documents/detail/index.tsx +++ b/web/app/components/datasets/documents/detail/index.tsx @@ -1,12 +1,12 @@ 'use client' import type { FC } from 'react' -import type { DocumentDisplayStatus, FileItem, FullDocumentDetail } from '@/models/datasets' +import type { FileItem, FullDocumentDetail } from '@/models/datasets' import type { SegmentImportStatus } from '@/types/dataset' import { cn } from '@langgenius/dify-ui/cn' import { IconButton } from '@langgenius/dify-ui/icon-button' import { Separator } from '@langgenius/dify-ui/separator' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' @@ -20,13 +20,9 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' -import { ChunkingMode, DisplayStatusList } from '@/models/datasets' +import { ChunkingMode, DisplayStatusList, isDocumentDisplayStatus } from '@/models/datasets' import { useRouter, useSearchParams } from '@/next/navigation' -import { - useDocumentDetail, - useDocumentMetadata, - useInvalidDocumentList, -} from '@/service/knowledge/use-document' +import { consoleQuery } from '@/service/console' import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, @@ -140,26 +136,33 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => { data: documentDetail, error, refetch: detailMutate, - } = useDocumentDetail({ - datasetId, - documentId, - params: { metadata: 'without' }, - refetchInterval: (query) => { - const status = query.state.data?.display_status - if (!status || NON_TERMINAL_DISPLAY_STATUSES.has(status)) return 2500 - return false - }, - }) + } = useQuery( + consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.queryOptions({ + input: { + params: { dataset_id: datasetId, document_id: documentId }, + query: { metadata: 'without' }, + }, + refetchInterval: (query) => { + const status = query.state.data?.display_status + if (!isDocumentDisplayStatus(status) || NON_TERMINAL_DISPLAY_STATUSES.has(status)) + return 2500 + return false + }, + }), + ) const documentTitle = documentDetail?.name || t(($) => $['datasetMenus.documents'], { ns: 'common' }) const datasetTitle = dataset?.name || t(($) => $['menus.datasets'], { ns: 'navigation' }) useDocumentTitle(`${documentTitle} · ${datasetTitle}`) - const { data: documentMetadata } = useDocumentMetadata({ - datasetId, - documentId, - params: { metadata: 'only' }, - }) + const { data: documentMetadata } = useQuery( + consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.queryOptions({ + input: { + params: { dataset_id: datasetId, document_id: documentId }, + query: { metadata: 'only' }, + }, + }), + ) const backToPrev = useCallback(() => { const queryString = searchParams.toString() @@ -169,13 +172,23 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => { const isDetailLoading = !documentDetail && !error - const embedding = NON_TERMINAL_DISPLAY_STATUSES.has( - documentDetail?.display_status as DocumentDisplayStatus, - ) + const displayStatus = isDocumentDisplayStatus(documentDetail?.display_status) + ? documentDetail.display_status + : undefined + const embedding = !!displayStatus && NON_TERMINAL_DISPLAY_STATUSES.has(displayStatus) const invalidChunkList = useInvalid(useSegmentListKey) const invalidChildChunkList = useInvalid(useChildSegmentListKey) - const invalidDocumentList = useInvalidDocumentList(datasetId) + const queryClient = useQueryClient() + const invalidDocumentList = useCallback( + () => + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ + input: { params: { dataset_id: datasetId } }, + }), + }), + [datasetId, queryClient], + ) const handleOperate = useCallback( (operateName?: string) => { @@ -251,15 +264,14 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => { ], ) - const docDetail = useMemo( - () => - ({ - ...documentDetail, - ...documentMetadata, - doc_type: documentMetadata?.doc_type === 'others' ? '' : documentMetadata?.doc_type, - }) as FullDocumentDetail, - [documentDetail, documentMetadata], - ) + const docDetail = useMemo((): FullDocumentDetail | undefined => { + if (!documentDetail) return undefined + return { + ...documentDetail, + ...documentMetadata, + doc_type: documentMetadata?.doc_type === 'others' ? '' : documentMetadata?.doc_type, + } + }, [documentDetail, documentMetadata]) const backButtonLabel = t(($) => $['operation.back'], { ns: 'common' }) const metadataToggleLabel = `${ @@ -310,7 +322,7 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => { )} {documentDetail && ( = ({ datasetId, documentId }) => { showNewSegmentModal={newSegmentModalVisible} onNewSegmentModalChange={setNewSegmentModalVisible} importStatus={importStatus} - archived={documentDetail?.archived} + archived={documentDetail?.archived ?? undefined} /> )}
@@ -390,13 +402,15 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => { isMobile={isMobile} panelClassName="justify-start!" > - + {docDetail && ( + + )}
({ default: mockUseDocumentTitle, })) -const mockInvalidDocumentList = vi.fn() -const mockInvalidDocumentDetail = vi.fn() let mockDocumentDetail: Record | null = { name: 'test-document', data_source_type: 'upload_file', @@ -40,14 +41,26 @@ let mockDocumentDetail: Record | null = { } let mockError: Error | null = null -vi.mock('@/service/knowledge/use-document', () => ({ - useDocumentDetail: () => ({ - data: mockDocumentDetail, - error: mockError, - }), - useInvalidDocumentList: () => mockInvalidDocumentList, - useInvalidDocumentDetail: () => mockInvalidDocumentDetail, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.byDocumentId.get': () => ({ + data: mockDocumentDetail, + error: mockError, + }), + }) +}) + +const expectDocumentsInvalidated = (datasetId: string) => { + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ + input: { params: { dataset_id: datasetId } }, + }), + }) + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.key(), + }) +} // Mock useDefaultModel vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ @@ -119,6 +132,7 @@ vi.mock('nuqs', async (importOriginal) => { describe('DocumentSettings', () => { beforeEach(() => { + vi.spyOn(QueryClient.prototype, 'invalidateQueries') vi.clearAllMocks() mockDocumentDetail = { name: 'test-document', @@ -209,8 +223,7 @@ describe('DocumentSettings', () => { fireEvent.click(screen.getByTestId('save-btn')) - expect(mockInvalidDocumentList).toHaveBeenCalled() - expect(mockInvalidDocumentDetail).toHaveBeenCalled() + expectDocumentsInvalidated(defaultProps.datasetId) expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/document-1') }) diff --git a/web/app/components/datasets/documents/detail/settings/document-settings.tsx b/web/app/components/datasets/documents/detail/settings/document-settings.tsx index e8ee6f6ec9244a..c5c424310d0ad3 100644 --- a/web/app/components/datasets/documents/detail/settings/document-settings.tsx +++ b/web/app/components/datasets/documents/detail/settings/document-settings.tsx @@ -2,7 +2,6 @@ import type { DataSourceProvider, NotionPage } from '@/models/common' import type { CrawlOptions, CustomFile, - DataSourceInfo, DataSourceType, LegacyDataSourceInfo, LocalFileInfo, @@ -10,6 +9,7 @@ import type { UploadFileIdInfo, WebsiteCrawlInfo, } from '@/models/datasets' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQueryState } from 'nuqs' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -26,11 +26,10 @@ import { import DatasetDetailContext from '@/context/dataset-detail' import useDocumentTitle from '@/hooks/use-document-title' import { useRouter } from '@/next/navigation' -import { - useDocumentDetail, - useInvalidDocumentDetail, - useInvalidDocumentList, -} from '@/service/knowledge/use-document' +import { consoleQuery } from '@/service/console' + +// data_source_info is written by the data source that imported the document. +type DocumentSourceInfo = Record | null | undefined type DocumentSettingsProps = { datasetId: string @@ -47,8 +46,17 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => { setSettingsDestination('provider') }, [setSettingsDestination]) - const invalidDocumentList = useInvalidDocumentList(datasetId) - const invalidDocumentDetail = useInvalidDocumentDetail() + const queryClient = useQueryClient() + const invalidDocumentList = () => + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ + input: { params: { dataset_id: datasetId } }, + }), + }) + const invalidDocumentDetail = () => + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.key(), + }) const saveHandler = () => { invalidDocumentList() invalidDocumentDetail() @@ -57,11 +65,14 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => { const cancelHandler = () => router.back() - const { data: documentDetail, error } = useDocumentDetail({ - datasetId, - documentId, - params: { metadata: 'without' }, - }) + const { data: documentDetail, error } = useQuery( + consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.queryOptions({ + input: { + params: { dataset_id: datasetId, document_id: documentId }, + query: { metadata: 'without' }, + }, + }), + ) const settingsTitle = t(($) => $['documentSettings.title'], { ns: 'datasetPipeline' }) const documentTitle = documentDetail?.name || t(($) => $['datasetMenus.documents'], { ns: 'common' }) @@ -70,22 +81,20 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => { const dataSourceInfo = documentDetail?.data_source_info - // Type guards for DataSourceInfo union - const isLegacyDataSourceInfo = ( - info: DataSourceInfo | undefined, - ): info is LegacyDataSourceInfo => { + // Type guards for the data source info shapes + const isLegacyDataSourceInfo = (info: DocumentSourceInfo): info is LegacyDataSourceInfo => { return !!info && 'upload_file' in info } - const isWebsiteCrawlInfo = (info: DataSourceInfo | undefined): info is WebsiteCrawlInfo => { + const isWebsiteCrawlInfo = (info: DocumentSourceInfo): info is WebsiteCrawlInfo => { return !!info && 'source_url' in info && 'title' in info } - const isOnlineDocumentInfo = (info: DataSourceInfo | undefined): info is OnlineDocumentInfo => { + const isOnlineDocumentInfo = (info: DocumentSourceInfo): info is OnlineDocumentInfo => { return !!info && 'page' in info } - const isLocalFileInfo = (info: DataSourceInfo | undefined): info is LocalFileInfo => { + const isLocalFileInfo = (info: DocumentSourceInfo): info is LocalFileInfo => { return !!info && 'related_id' in info && 'transfer_method' in info } - const isUploadFileIdInfo = (info: DataSourceInfo | undefined): info is UploadFileIdInfo => { + const isUploadFileIdInfo = (info: DocumentSourceInfo): info is UploadFileIdInfo => { return !!info && 'upload_file_id' in info } diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx index 320993865d4a02..61c1d944e38f8f 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx @@ -2,6 +2,7 @@ import type { PipelineExecutionLogResponse } from '@/models/pipeline' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { DatasourceType } from '@/models/pipeline' +import { consoleQuery } from '@/service/console' import PipelineSettings from '../index' // Mock Next.js router @@ -41,13 +42,16 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { }) }) -// Mock document invalidation hooks -const mockInvalidDocumentList = vi.fn() -const mockInvalidDocumentDetail = vi.fn() -vi.mock('@/service/knowledge/use-document', () => ({ - useInvalidDocumentList: () => mockInvalidDocumentList, - useInvalidDocumentDetail: () => mockInvalidDocumentDetail, -})) +const expectDocumentsInvalidated = (datasetId: string) => { + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ + input: { params: { dataset_id: datasetId } }, + }), + }) + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.key(), + }) +} // Mock Form component in ProcessDocuments - internal dependencies are too complex vi.mock('../../../../create-from-pipeline/process-documents/form', () => ({ @@ -168,8 +172,7 @@ describe('PipelineSettings', () => { mockPush.mockClear() mockBack.mockClear() mockMutateAsync.mockClear() - mockInvalidDocumentList.mockClear() - mockInvalidDocumentDetail.mockClear() + vi.spyOn(QueryClient.prototype, 'invalidateQueries') // Default: successful data fetch mockUsePipelineExecutionLog.mockReturnValue({ @@ -422,8 +425,7 @@ describe('PipelineSettings', () => { ) await waitFor(() => { - expect(mockInvalidDocumentList).toHaveBeenCalled() - expect(mockInvalidDocumentDetail).toHaveBeenCalled() + expectDocumentsInvalidated('dataset-123') }) }) }) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx index 90f6952a0f6991..0e39765dbd5b77 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx @@ -1,7 +1,7 @@ import type { NotionPage } from '@/models/common' import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse } from '@/models/datasets' import type { OnlineDriveFile } from '@/models/pipeline' -import { useMutation, useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { noop } from 'es-toolkit/function' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -16,7 +16,6 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { DatasourceType } from '@/models/pipeline' import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/console' -import { useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document' import ChunkPreview from '../../../create-from-pipeline/preview/chunk-preview' import LeftHeader from './left-header' import ProcessDocuments from './process-documents' @@ -137,8 +136,17 @@ const PipelineSettings = ({ datasetId, documentId }: PipelineSettingsProps) => { [lastRunData, pipelineId, runPublishedPipeline], ) - const invalidDocumentList = useInvalidDocumentList(datasetId) - const invalidDocumentDetail = useInvalidDocumentDetail() + const queryClient = useQueryClient() + const invalidDocumentList = () => + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ + input: { params: { dataset_id: datasetId } }, + }), + }) + const invalidDocumentDetail = () => + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.key(), + }) const handleProcess = useCallback( async (data: Record) => { if (!lastRunData) return diff --git a/web/app/components/datasets/documents/document-source.ts b/web/app/components/datasets/documents/document-source.ts new file mode 100644 index 00000000000000..575b00b43302ab --- /dev/null +++ b/web/app/components/datasets/documents/document-source.ts @@ -0,0 +1,39 @@ +import type { DataSourceNotionPage } from '@/models/common' + +// data_source_info is written by the data source that imported the document, so its keys are +// read defensively for each source type. +type DocumentSourceInfo = Record | null | undefined + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const getString = (record: Record | undefined, key: string) => { + const value = record?.[key] + return typeof value === 'string' ? value : undefined +} + +const isNotionPageIcon = ( + value: unknown, +): value is NonNullable => + isRecord(value) && typeof value.type === 'string' + +const toNotionIcon = (value: unknown) => + typeof value === 'string' || isNotionPageIcon(value) ? value : undefined + +/** The file extension of a legacy uploaded-file document. */ +export const getUploadFileExtension = (info: DocumentSourceInfo) => + isRecord(info?.upload_file) ? getString(info.upload_file, 'extension') : undefined + +/** The file extension recorded by a pipeline local-file data source. */ +export const getLocalFileExtension = (info: DocumentSourceInfo) => + getString(info ?? undefined, 'extension') + +/** The file name recorded by a pipeline online-drive data source. */ +export const getOnlineDriveFileName = (info: DocumentSourceInfo) => + getString(info ?? undefined, 'name') + +/** The page icon of a Notion import, or of a pipeline online-document page. */ +export const getNotionPageIcon = (info: DocumentSourceInfo, fromPipeline: boolean) => { + if (!fromPipeline) return toNotionIcon(info?.notion_page_icon) + return isRecord(info?.page) ? toNotionIcon(info.page.page_icon) : undefined +} diff --git a/web/app/components/datasets/documents/index.tsx b/web/app/components/datasets/documents/index.tsx index c07d66c5ab3993..2de4178682ff00 100644 --- a/web/app/components/datasets/documents/index.tsx +++ b/web/app/components/datasets/documents/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' -import { useSuspenseQuery } from '@tanstack/react-query' +import { keepPreviousData, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useCallback } from 'react' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' @@ -10,11 +10,7 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import { DataSourceType } from '@/models/datasets' import { useRouter } from '@/next/navigation' -import { - useDocumentList, - useInvalidDocumentDetail, - useInvalidDocumentList, -} from '@/service/knowledge/use-document' +import { consoleQuery } from '@/service/console' import { useChildSegmentListKey, useSegmentListKey } from '@/service/knowledge/use-segment' import { useInvalid } from '@/service/use-base' import { getDatasetACLCapabilities } from '@/utils/permission' @@ -23,6 +19,7 @@ import DocumentsHeader from './components/documents-header' import EmptyElement from './components/empty-element' import List from './components/list' import { useDocumentsPageState } from './hooks/use-documents-page-state' +import { toDocumentListQuery } from './status-filter' type IDocumentsProps = { datasetId: string @@ -68,31 +65,45 @@ const Documents: FC = ({ datasetId }) => { } = useDocumentsPageState() // Fetch document list - const { data: documentsRes, isLoading: isListLoading } = useDocumentList({ - datasetId, - query: { - page: currPage + 1, - limit, - keyword: debouncedSearchValue, - status: normalizedStatusFilterValue, - sort: sortValue, - }, - refetchInterval: (query) => { - const shouldForcePolling = - normalizedStatusFilterValue !== 'all' && - FORCED_POLLING_STATUSES.has(normalizedStatusFilterValue) - const documents = query.state.data?.data - if (!documents) return POLLING_INTERVAL - const hasIncompleteDocuments = documents.some( - ({ indexing_status }) => !TERMINAL_INDEXING_STATUSES.has(indexing_status), - ) - return shouldForcePolling || hasIncompleteDocuments ? POLLING_INTERVAL : false - }, - }) + const { data: documentsRes, isLoading: isListLoading } = useQuery( + consoleQuery.datasets.byDatasetId.documents.get.queryOptions({ + input: { + params: { dataset_id: datasetId }, + query: toDocumentListQuery({ + page: currPage + 1, + limit, + keyword: debouncedSearchValue, + status: normalizedStatusFilterValue, + sort: sortValue, + }), + }, + placeholderData: keepPreviousData, + refetchInterval: (query) => { + const shouldForcePolling = + normalizedStatusFilterValue !== 'all' && + FORCED_POLLING_STATUSES.has(normalizedStatusFilterValue) + const documents = query.state.data?.data + if (!documents) return POLLING_INTERVAL + const hasIncompleteDocuments = documents.some( + ({ indexing_status }) => !TERMINAL_INDEXING_STATUSES.has(indexing_status ?? ''), + ) + return shouldForcePolling || hasIncompleteDocuments ? POLLING_INTERVAL : false + }, + }), + ) // Invalidation hooks - const invalidDocumentList = useInvalidDocumentList(datasetId) - const invalidDocumentDetail = useInvalidDocumentDetail() + const queryClient = useQueryClient() + const invalidDocumentList = () => + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ + input: { params: { dataset_id: datasetId } }, + }), + }) + const invalidDocumentDetail = () => + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.key(), + }) const invalidChunkList = useInvalid(useSegmentListKey) const invalidChildChunkList = useInvalid(useChildSegmentListKey) diff --git a/web/app/components/datasets/documents/status-filter.ts b/web/app/components/datasets/documents/status-filter.ts index 13fe50c1c9fb11..a96d5151e567b5 100644 --- a/web/app/components/datasets/documents/status-filter.ts +++ b/web/app/components/datasets/documents/status-filter.ts @@ -28,3 +28,22 @@ export const normalizeStatusForQuery = (value?: string | null) => { return QUERY_STATUS_ALIASES[sanitized] || sanitized } + +export const toDocumentListQuery = ({ + status, + sort, + ...query +}: { + keyword: string + page: number + limit: number + sort?: string + status?: string +}) => { + const normalizedStatus = normalizeStatusForQuery(status) + return { + ...query, + ...(sort && { sort }), + ...(normalizedStatus && normalizedStatus !== 'all' && { status: normalizedStatus }), + } +} diff --git a/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx index 6dd7d1fafd132d..ef47e12b20859b 100644 --- a/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx @@ -59,11 +59,20 @@ const mockEnable = vi.fn() const mockDisable = vi.fn() const mockDelete = vi.fn() -vi.mock('@/service/knowledge/use-document', () => ({ - useDocumentEnable: () => ({ mutateAsync: mockEnable }), - useDocumentDisable: () => ({ mutateAsync: mockDisable }), - useDocumentDelete: () => ({ mutateAsync: mockDelete }), -})) +const statusMocks: Record unknown> = { + enable: (input) => mockEnable(input), + disable: (input) => mockDisable(input), +} +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.status.byAction.batch.patch': () => ({ + mutateAsync: (input: { params: { action: string } }) => + statusMocks[input.params.action]!(input), + }), + 'datasets.byDatasetId.documents.delete': () => ({ mutateAsync: mockDelete }), + }) +}) beforeEach(() => { vi.clearAllMocks() @@ -274,7 +283,10 @@ describe('StatusItem', () => { await act(async () => { vi.advanceTimersByTime(600) }) - expect(mockEnable).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' }) + expect(mockEnable).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', action: 'enable' }, + query: { document_id: ['doc-1'] }, + }) vi.useRealTimers() }) @@ -302,7 +314,10 @@ describe('StatusItem', () => { await act(async () => { vi.advanceTimersByTime(600) }) - expect(mockDisable).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' }) + expect(mockDisable).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', action: 'disable' }, + query: { document_id: ['doc-1'] }, + }) vi.useRealTimers() }) diff --git a/web/app/components/datasets/documents/status-item/index.tsx b/web/app/components/datasets/documents/status-item/index.tsx index fcd1755d864d30..fe1b7e7413e5b2 100644 --- a/web/app/components/datasets/documents/status-item/index.tsx +++ b/web/app/components/datasets/documents/status-item/index.tsx @@ -1,22 +1,18 @@ import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot' import type { OperationName } from '../types' -import type { CommonResponse } from '@/models/common' import type { DocumentDisplayStatus } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/infotip' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Switch } from '@langgenius/dify-ui/switch' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useMutation } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' -import { - useDocumentDelete, - useDocumentDisable, - useDocumentEnable, -} from '@/service/knowledge/use-document' +import { consoleQuery } from '@/service/console' import { asyncRunSafe } from '@/utils' import s from '../style.module.css' import { useIndexStatus } from './hooks' @@ -61,23 +57,21 @@ const StatusItem = ({ const localStatus = status.toLowerCase() as keyof typeof DOC_INDEX_STATUS_MAP const statusItem = DOC_INDEX_STATUS_MAP[localStatus] const { enabled = false, archived = false, id = '' } = detail || {} - const { mutateAsync: enableDocument } = useDocumentEnable() - const { mutateAsync: disableDocument } = useDocumentDisable() - const { mutateAsync: deleteDocument } = useDocumentDelete() + const documents = consoleQuery.datasets.byDatasetId.documents + const { mutateAsync: changeDocumentStatus } = useMutation( + documents.status.byAction.batch.patch.mutationOptions(), + ) + const { mutateAsync: deleteDocuments } = useMutation(documents.delete.mutationOptions()) const onOperate = async (operationName: OperationName) => { if (!canEdit) return - let opApi = deleteDocument - switch (operationName) { - case 'enable': - opApi = enableDocument - break - case 'disable': - opApi = disableDocument - break - } - const [e] = await asyncRunSafe( - opApi({ datasetId, documentId: id }) as Promise, + const [e] = await asyncRunSafe( + operationName === 'enable' || operationName === 'disable' + ? changeDocumentStatus({ + params: { dataset_id: datasetId, action: operationName }, + query: { document_id: [id] }, + }) + : deleteDocuments({ params: { dataset_id: datasetId }, query: { document_id: [id] } }), ) if (!e) { onUpdate?.(operationName) diff --git a/web/app/components/datasets/metadata/hooks/use-metadata-document.ts b/web/app/components/datasets/metadata/hooks/use-metadata-document.ts index f80bfeecb306ff..1d2a71e527f270 100644 --- a/web/app/components/datasets/metadata/hooks/use-metadata-document.ts +++ b/web/app/components/datasets/metadata/hooks/use-metadata-document.ts @@ -91,7 +91,8 @@ const useMetadataDocument = ({ datasetId, documentId, docDetail }: Props) => { return {} as any } const getTargetValue = (field: string) => { - const val = get(sourceData, field, '') + const val: unknown = get(sourceData, field, '') + if (typeof val !== 'string' && typeof val !== 'number') return '-' if (!val && val !== 0) return '-' if (fieldMap[field]?.inputType === 'select') return getTargetMap(field)[val] if (fieldMap[field]?.render) diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/document-information.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/document-information.spec.tsx index 4e0ce4e8e68ec4..e8da262e2e2a29 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/document-information.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/document-information.spec.tsx @@ -33,7 +33,15 @@ vi.mock('react-i18next', async () => { type DocumentDetail = Parameters[0]['docDetail'] -const uploadFile = { name: 'handbook.txt', extension: 'txt', size: 497 } +const uploadFile = { + id: 'upload-1', + name: 'handbook.txt', + extension: 'txt', + size: 497, + mime_type: 'text/plain', + created_by: 'user-1', + created_at: Date.parse('2026-09-23T10:30:00Z') / 1000, +} // The Console detail endpoint separates the upload reference from its details. const documentDetail: Partial = { diff --git a/web/models/datasets.ts b/web/models/datasets.ts index de5902e53776fe..813765614ba17a 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -1,4 +1,7 @@ import type { + DocumentDetailResponse as ContractDocumentDetailResponse, + DocumentWithSegmentsListResponse, + DocumentWithSegmentsResponse, ExternalHitTestingRecordResponse, ExternalHitTestingResponse, HitTestingChildChunk as GeneratedHitTestingChildChunk, @@ -11,7 +14,6 @@ import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types. import type { DataSourceNotionPage, DataSourceProvider } from './common' import type { DatasourceType } from './pipeline' import type { IndexingType } from '@/app/components/datasets/create/step-two' -import type { MetadataItemWithValue } from '@/app/components/datasets/metadata/types' import type { MetadataFilteringVariableType } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import type { AppIconType, AppModeEnum, RetrievalConfig, TransferMethod } from '@/types/app' import type { SegmentImportStatus } from '@/types/dataset' @@ -285,8 +287,8 @@ export type ProcessRuleResponse = { export type Rules = { pre_processing_rules: PreProcessingRule[] segmentation: Segmentation - parent_mode: ParentMode - subchunk_segmentation: Segmentation + parent_mode?: ParentMode + subchunk_segmentation?: Segmentation } type Limits = { @@ -324,6 +326,9 @@ export const DisplayStatusList = [ export type DocumentDisplayStatus = (typeof DisplayStatusList)[number] +export const isDocumentDisplayStatus = (value: unknown): value is DocumentDisplayStatus => + DisplayStatusList.some((status) => status === value) + export type LegacyDataSourceInfo = { upload_file: { id: string @@ -376,14 +381,6 @@ export type OnlineDocumentInfo = { } } -export type OnlineDriveInfo = { - bucket: string - credential_id: string - id: string - name: string - type: 'file' | 'folder' -} - export type UploadFileIdInfo = { upload_file_id: string } @@ -395,7 +392,7 @@ export type DataSourceInfo = | WebsiteCrawlInfo | UploadFileIdInfo -type InitialDocumentDetail = { +export type InitialDocumentDetail = { id: string batch: string position: number @@ -416,30 +413,10 @@ type InitialDocumentDetail = { summary_index_status?: string } -export type SimpleDocumentDetail = InitialDocumentDetail & { - enabled: boolean - word_count: number - error?: string | null - archived: boolean - updated_at: number - hit_count: number - dataset_process_rule_id?: string - data_source_detail_dict?: { - upload_file: { - name: string - extension: string - } - } - doc_metadata?: MetadataItemWithValue[] -} +// A document in the dataset document list. +export type SimpleDocumentDetail = DocumentWithSegmentsResponse -export type DocumentListResponse = { - data: SimpleDocumentDetail[] - has_more: boolean - total: number - page: number - limit: number -} +export type DocumentListResponse = DocumentWithSegmentsListResponse type DocumentReq = { original_document_id?: string @@ -488,43 +465,8 @@ export type createDocumentResponse = { documents: InitialDocumentDetail[] } -export type FullDocumentDetail = SimpleDocumentDetail & { - batch: string - created_api_request_id: string - processing_started_at: number - parsing_completed_at: number - cleaning_completed_at: number - splitting_completed_at: number - tokens: number - indexing_latency: number - completed_at: number - paused_by: string - paused_at: number - stopped_at: number - indexing_status: string - disabled_at: number - disabled_by: string - archived_reason: 'rule_modified' | 're_upload' - archived_by: string - archived_at: number - doc_type?: DocType | null | 'others' - doc_metadata?: DocMetadata | null - segment_count: number - dataset_process_rule: ProcessRule - document_process_rule: ProcessRule - [key: string]: any -} - -type DocMetadata = { - title: string - language: string - author: string - publisher: string - publicationDate: string - ISBN: string - category: string - [key: string]: string -} +// A dataset document as the document detail query returns it. +export type FullDocumentDetail = ContractDocumentDetailResponse type CustomizableDocType = | 'book' @@ -716,12 +658,6 @@ export const DocumentActionType = { export type DocumentActionType = (typeof DocumentActionType)[keyof typeof DocumentActionType] -export type UpdateDocumentBatchParams = { - datasetId: string - documentId?: string - documentIds?: string[] | string -} - export type BatchImportResponse = { job_id: string job_status: SegmentImportStatus diff --git a/web/service/datasets.ts b/web/service/datasets.ts index 5f53c27d9b6b6a..94899744b58043 100644 --- a/web/service/datasets.ts +++ b/web/service/datasets.ts @@ -32,10 +32,6 @@ type CommonDocReq = { documentId: string } -export type DocumentDownloadResponse = { - url: string -} - export type DocumentDownloadZipRequest = { datasetId: string documentIds: string[] @@ -48,8 +44,6 @@ type BatchReq = { export type SortType = 'created_at' | 'hit_count' | '-created_at' | '-hit_count' -export type MetadataType = 'all' | 'only' | 'without' - export const updateDatasetSetting = ({ datasetId, body, @@ -211,15 +205,6 @@ export const resumeDocIndexing = ({ datasetId, documentId }: CommonDocReq) => { }) } -export const fetchDocumentDownloadUrl = ({ - datasetId, - documentId, -}: CommonDocReq): Promise => { - return consoleClient.datasets.byDatasetId.documents.byDocumentId.download.get({ - params: { dataset_id: datasetId, document_id: documentId }, - }) -} - // Not migrated to the console contract: the endpoint streams a zip file, which the // JSON-based console client cannot return as a Blob. export const downloadDocumentsZip = ({ diff --git a/web/service/knowledge/use-document.ts b/web/service/knowledge/use-document.ts index 70771999f1e2a6..a74c7463aca31a 100644 --- a/web/service/knowledge/use-document.ts +++ b/web/service/knowledge/use-document.ts @@ -1,228 +1,6 @@ -import type { UseQueryOptions } from '@tanstack/react-query' -import type { DocumentDownloadZipRequest, MetadataType, SortType } from '../datasets' -import type { - DocumentDetailResponse, - DocumentListResponse, - UpdateDocumentBatchParams, -} from '@/models/datasets' -import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query' -import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter' -import { DocumentActionType } from '@/models/datasets' -import { consoleClient } from '../console' -import { narrowResponse } from '../console/narrow-response' -import { - downloadDocumentsZip, - fetchDocumentDownloadUrl, - pauseDocIndexing, - resumeDocIndexing, - retryErrorDocs, -} from '../datasets' -import { useInvalid } from '../use-base' - -const NAME_SPACE = 'knowledge/document' - -export const useDocumentListKey = [NAME_SPACE, 'documentList'] -type DocumentListRefetchInterval = UseQueryOptions['refetchInterval'] - -export const useDocumentList = (payload: { - datasetId: string - query: { - keyword: string - page: number - limit: number - sort?: SortType - status?: string - } - refetchInterval?: DocumentListRefetchInterval -}) => { - const { query, datasetId, refetchInterval } = payload - const { keyword, page, limit, sort, status } = query - const normalizedStatus = normalizeStatusForQuery(status) - const params: { keyword: string; page: number; limit: number; sort?: string; status?: string } = { - keyword, - page, - limit, - } - if (sort) params.sort = sort - if (normalizedStatus && normalizedStatus !== 'all') params.status = normalizedStatus - return useQuery({ - queryKey: [...useDocumentListKey, datasetId, params], - queryFn: () => - narrowResponse( - consoleClient.datasets.byDatasetId.documents.get({ - params: { dataset_id: datasetId }, - query: params, - }), - ), - placeholderData: keepPreviousData, - refetchInterval, - }) -} - -export const useInvalidDocumentList = (datasetId?: string) => { - return useInvalid(datasetId ? [...useDocumentListKey, datasetId] : useDocumentListKey) -} - -const useAutoDisabledDocumentKey = [NAME_SPACE, 'autoDisabledDocument'] -export const useAutoDisabledDocuments = (datasetId: string) => { - return useQuery({ - queryKey: [...useAutoDisabledDocumentKey, datasetId], - queryFn: () => - consoleClient.datasets.byDatasetId.autoDisableLogs.get({ params: { dataset_id: datasetId } }), - }) -} - -export const useInvalidDisabledDocument = () => { - return useInvalid(useAutoDisabledDocumentKey) -} - -const toBatchDocumentIds = (documentIds: string[] | string) => - Array.isArray(documentIds) ? documentIds : [documentIds] - -const useDocumentBatchAction = (action: DocumentActionType) => { - return useMutation({ - mutationFn: ({ datasetId, documentIds, documentId }: UpdateDocumentBatchParams) => { - return consoleClient.datasets.byDatasetId.documents.status.byAction.batch.patch({ - params: { dataset_id: datasetId, action }, - query: { document_id: toBatchDocumentIds(documentId || documentIds!) }, - }) - }, - }) -} - -export const useDocumentEnable = () => { - return useDocumentBatchAction(DocumentActionType.enable) -} - -export const useDocumentDisable = () => { - return useDocumentBatchAction(DocumentActionType.disable) -} - -export const useDocumentArchive = () => { - return useDocumentBatchAction(DocumentActionType.archive) -} - -export const useDocumentUnArchive = () => { - return useDocumentBatchAction(DocumentActionType.unArchive) -} - -export const useDocumentDelete = () => { - return useMutation({ - mutationFn: ({ datasetId, documentIds, documentId }: UpdateDocumentBatchParams) => { - return consoleClient.datasets.byDatasetId.documents.delete({ - params: { dataset_id: datasetId }, - query: { document_id: toBatchDocumentIds(documentId || documentIds!) }, - }) - }, - }) -} - -export const useSyncDocument = () => { - return useMutation({ - mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { - if (!documentId) throw new Error('documentId is required') - return consoleClient.datasets.byDatasetId.documents.byDocumentId.notion.sync.get({ - params: { dataset_id: datasetId, document_id: documentId }, - }) - }, - }) -} - -export const useDocumentSummary = () => { - return useMutation({ - mutationFn: ({ datasetId, documentIds, documentId }: UpdateDocumentBatchParams) => { - return consoleClient.datasets.byDatasetId.documents.generateSummary.post({ - params: { dataset_id: datasetId }, - body: { - document_list: toBatchDocumentIds(documentId || documentIds!), - }, - }) - }, - }) -} - -export const useSyncWebsite = () => { - return useMutation({ - mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { - if (!documentId) throw new Error('documentId is required') - return consoleClient.datasets.byDatasetId.documents.byDocumentId.websiteSync.get({ - params: { dataset_id: datasetId, document_id: documentId }, - }) - }, - }) -} - -const useDocumentDetailKey = [NAME_SPACE, 'documentDetail', 'withoutMetaData'] -type DocumentDetailRefetchInterval = UseQueryOptions['refetchInterval'] - -export const useDocumentDetail = (payload: { - datasetId: string - documentId: string - params: { metadata: MetadataType } - refetchInterval?: DocumentDetailRefetchInterval -}) => { - const { datasetId, documentId, params, refetchInterval } = payload - return useQuery({ - queryKey: [...useDocumentDetailKey, 'withoutMetaData', datasetId, documentId, params], - queryFn: () => - narrowResponse( - consoleClient.datasets.byDatasetId.documents.byDocumentId.get({ - params: { dataset_id: datasetId, document_id: documentId }, - query: params, - }), - ), - refetchInterval, - }) -} - -export const useDocumentMetadata = (payload: { - datasetId: string - documentId: string - params: { metadata: MetadataType } -}) => { - const { datasetId, documentId, params } = payload - return useQuery({ - queryKey: [...useDocumentDetailKey, 'onlyMetaData', datasetId, documentId, params], - queryFn: () => - narrowResponse( - consoleClient.datasets.byDatasetId.documents.byDocumentId.get({ - params: { dataset_id: datasetId, document_id: documentId }, - query: params, - }), - ), - }) -} - -export const useInvalidDocumentDetail = () => { - return useInvalid(useDocumentDetailKey) -} - -export const useDocumentPause = () => { - return useMutation({ - mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { - if (!datasetId || !documentId) throw new Error('datasetId and documentId are required') - return pauseDocIndexing({ datasetId, documentId }) - }, - }) -} - -export const useDocumentResume = () => { - return useMutation({ - mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { - if (!datasetId || !documentId) throw new Error('datasetId and documentId are required') - return resumeDocIndexing({ datasetId, documentId }) - }, - }) -} - -export const useDocumentDownload = () => { - return useMutation({ - mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { - if (!datasetId || !documentId) throw new Error('datasetId and documentId are required') - return fetchDocumentDownloadUrl({ datasetId, documentId }) - }, - }) -} +import type { DocumentDownloadZipRequest } from '../datasets' +import { useMutation } from '@tanstack/react-query' +import { downloadDocumentsZip } from '../datasets' export const useDocumentDownloadZip = () => { return useMutation({ @@ -233,11 +11,3 @@ export const useDocumentDownloadZip = () => { }, }) } - -export const useDocumentBatchRetryIndex = () => { - return useMutation({ - mutationFn: ({ datasetId, documentIds }: { datasetId: string; documentIds: string[] }) => { - return retryErrorDocs({ datasetId, document_ids: documentIds }) - }, - }) -} diff --git a/web/service/knowledge/use-metadata.ts b/web/service/knowledge/use-metadata.ts index 0f60aa0c2dfa52..ba524ceea502c3 100644 --- a/web/service/knowledge/use-metadata.ts +++ b/web/service/knowledge/use-metadata.ts @@ -5,10 +5,9 @@ import type { } from '@/app/components/datasets/metadata/types' import type { DocumentDetailResponse } from '@/models/datasets' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { consoleClient } from '../console' +import { consoleClient, consoleQuery } from '../console' import { narrowResponse } from '../console/narrow-response' import { useInvalid } from '../use-base' -import { useDocumentListKey, useInvalidDocumentList } from './use-document' const NAME_SPACE = 'dataset-metadata' @@ -54,7 +53,13 @@ const useInvalidAllDocumentMetaData = (datasetId: string) => { const useInvalidAllMetaData = (datasetId: string) => { const invalidDatasetMetaData = useInvalidDatasetMetaData(datasetId) - const invalidDocumentList = useInvalidDocumentList(datasetId) + const queryClient = useQueryClient() + const invalidDocumentList = () => + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ + input: { params: { dataset_id: datasetId } }, + }), + }) const invalidateAllDocumentMetaData = useInvalidAllDocumentMetaData(datasetId) return async () => { @@ -147,7 +152,9 @@ export const useBatchUpdateDocMetadata = () => { queryKey: [NAME_SPACE, 'document', payload.dataset_id], }) await queryClient.invalidateQueries({ - queryKey: [...useDocumentListKey, payload.dataset_id], + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ + input: { params: { dataset_id: payload.dataset_id } }, + }), }) // meta data in single document From 3c2b31dfcb97e7b3ae5684702d12b124281ada17 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:50:35 +0800 Subject: [PATCH 46/60] fix(web): keep the document list invalidation stable Co-Authored-By: Claude Opus 5.5 --- .../components/datasets/documents/index.tsx | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/web/app/components/datasets/documents/index.tsx b/web/app/components/datasets/documents/index.tsx index 2de4178682ff00..a8bb2f51b02d77 100644 --- a/web/app/components/datasets/documents/index.tsx +++ b/web/app/components/datasets/documents/index.tsx @@ -94,27 +94,28 @@ const Documents: FC = ({ datasetId }) => { // Invalidation hooks const queryClient = useQueryClient() - const invalidDocumentList = () => - queryClient.invalidateQueries({ - queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ - input: { params: { dataset_id: datasetId } }, + const invalidDocumentList = useCallback( + () => + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ + input: { params: { dataset_id: datasetId } }, + }), }), - }) - const invalidDocumentDetail = () => - queryClient.invalidateQueries({ - queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.key(), - }) + [queryClient, datasetId], + ) const invalidChunkList = useInvalid(useSegmentListKey) const invalidChildChunkList = useInvalid(useChildSegmentListKey) const handleUpdate = useCallback(() => { invalidDocumentList() - invalidDocumentDetail() + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.key(), + }) setTimeout(() => { invalidChunkList() invalidChildChunkList() }, 5000) - }, [invalidDocumentList, invalidDocumentDetail, invalidChunkList, invalidChildChunkList]) + }, [queryClient, invalidDocumentList, invalidChunkList, invalidChildChunkList]) // Metadata editing hook const { From 58cf9f43a836884120aa97d263951f7666eff001 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:56:58 +0800 Subject: [PATCH 47/60] refactor(web): use generated metadata options directly Remove the forwarding hooks in service/knowledge/use-metadata. Metadata fields, built-in fields and document metadata read the generated console queries, and the create, rename, delete, built-in toggle and document value mutations call the generated options at their call sites. The cache dependencies move to the shared mutation policies: creating a field or toggling built-in fields refreshes the dataset fields, while renaming, deleting or editing values also refreshes the dataset's document list and document details. A failed write leaves the cache alone, as before. Co-Authored-By: Claude Opus 5.5 --- oxlint-suppressions.json | 5 - .../__tests__/modal.spec.tsx | 29 +-- .../metadata/edit-metadata-batch/modal.tsx | 11 +- .../use-batch-edit-document-metadata.spec.ts | 109 ++++++----- .../use-edit-dataset-metadata.spec.ts | 73 +++---- .../__tests__/use-metadata-document.spec.ts | 47 ++--- .../hooks/use-batch-edit-document-metadata.ts | 13 +- .../hooks/use-edit-dataset-metadata.ts | 55 ++++-- .../metadata/hooks/use-metadata-document.ts | 49 ++--- .../dataset-metadata-drawer.spec.tsx | 17 +- .../dataset-metadata-picker.spec.tsx | 17 +- .../dataset-metadata-picker.tsx | 9 +- .../__tests__/document-information.spec.tsx | 15 +- .../__tests__/index.spec.tsx | 17 +- .../__tests__/info-group.spec.tsx | 17 +- web/models/datasets.ts | 2 - .../dataset-metadata-policies.spec.ts | 116 +++++++++++ web/service/console/query-policies.ts | 111 +++++++++++ web/service/knowledge/use-metadata.ts | 182 ------------------ 19 files changed, 500 insertions(+), 394 deletions(-) create mode 100644 web/service/console/__tests__/dataset-metadata-policies.spec.ts delete mode 100644 web/service/knowledge/use-metadata.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 4fdfb07df8fd09..1c1573cb89f84a 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4071,11 +4071,6 @@ "count": 8 } }, - "web/service/knowledge/use-metadata.ts": { - "@tanstack/query/prefer-query-options": { - "count": 7 - } - }, "web/service/knowledge/use-segment.ts": { "@tanstack/query/prefer-query-options": { "count": 2 diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx index b848e9e69c54d6..ec5f3fb5cc8c87 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx @@ -7,19 +7,22 @@ import EditMetadataBatchModal from '../modal' // Mock service/API calls const mockDoAddMetaData = vi.fn().mockResolvedValue({}) -vi.mock('@/service/knowledge/use-metadata', () => ({ - useCreateMetaData: () => ({ - mutate: mockDoAddMetaData, - }), - useDatasetMetaData: () => ({ - data: { - doc_metadata: [ - { id: 'existing-1', name: 'existing_field', type: DataType.string }, - { id: 'existing-2', name: 'another_field', type: DataType.number }, - ], - }, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.metadata.post': () => ({ + mutateAsync: mockDoAddMetaData, + }), + 'datasets.byDatasetId.metadata.get': () => ({ + data: { + doc_metadata: [ + { id: 'existing-1', name: 'existing_field', type: DataType.string }, + { id: 'existing-2', name: 'another_field', type: DataType.number }, + ], + }, + }), + }) +}) // Mock check name hook to control validation let mockCheckNameResult = { errorMsg: '' } diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx index b1151d4b045788..6ddb341938ceae 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx @@ -7,12 +7,13 @@ import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dif import { IconButton } from '@langgenius/dify-ui/icon-button' import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/infotip' import { Separator } from '@langgenius/dify-ui/separator' +import { useMutation } from '@tanstack/react-query' import { produce } from 'immer' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' -import { useCreateMetaData } from '@/service/knowledge/use-metadata' +import { consoleQuery } from '@/service/console' import useCheckMetadataName from '../hooks/use-check-metadata-name' import { DatasetMetadataPicker } from '../metadata-dataset/dataset-metadata-picker' import { UpdateType } from '../types' @@ -86,7 +87,9 @@ const EditMetadataBatchModal: FC = ({ [list, templeList], ) const { checkName } = useCheckMetadataName() - const { mutate: doAddMetaData } = useCreateMetaData(datasetId) + const { mutateAsync: createMetaData } = useMutation( + consoleQuery.datasets.byDatasetId.metadata.post.mutationOptions(), + ) const handleAddMetaData = useCallback( async (payload: BuiltInMetadataItem) => { const errorMsg = checkName(payload.name).errorMsg @@ -94,10 +97,10 @@ const EditMetadataBatchModal: FC = ({ toast.error(errorMsg) return Promise.reject(new Error(errorMsg)) } - await doAddMetaData(payload) + await createMetaData({ params: { dataset_id: datasetId }, body: payload }) toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) }, - [checkName, doAddMetaData, t], + [checkName, createMetaData, datasetId, t], ) const [addedList, setAddedList] = useState([]) const handleAddedListChange = useCallback( diff --git a/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts index 540d7a5c68ff56..634bd60798a177 100644 --- a/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts @@ -28,11 +28,14 @@ type MetadataItemWithEdit = { // Mock useBatchUpdateDocMetadata const mockMutateAsync = vi.fn().mockResolvedValue({}) -vi.mock('@/service/knowledge/use-metadata', () => ({ - useBatchUpdateDocMetadata: () => ({ - mutateAsync: mockMutateAsync, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.metadata.post': () => ({ + mutateAsync: mockMutateAsync, + }), + }) +}) vi.mock('@/app/notifications', () => ({ toast: { @@ -315,17 +318,19 @@ describe('useBatchEditDocumentMetadata', () => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ - metadata_list: expect.arrayContaining([ - expect.objectContaining({ - document_id: 'doc-1', - metadata_list: expect.arrayContaining([ - expect.objectContaining({ - id: '1', - value: 'New Value', - }), - ]), - }), - ]), + body: expect.objectContaining({ + operation_data: expect.arrayContaining([ + expect.objectContaining({ + document_id: 'doc-1', + metadata_list: expect.arrayContaining([ + expect.objectContaining({ + id: '1', + value: 'New Value', + }), + ]), + }), + ]), + }), }), ) }) @@ -400,15 +405,17 @@ describe('useBatchEditDocumentMetadata', () => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ - metadata_list: expect.arrayContaining([ - expect.objectContaining({ - metadata_list: expect.arrayContaining([ - expect.objectContaining({ - name: 'new_field', - }), - ]), - }), - ]), + body: expect.objectContaining({ + operation_data: expect.arrayContaining([ + expect.objectContaining({ + metadata_list: expect.arrayContaining([ + expect.objectContaining({ + name: 'new_field', + }), + ]), + }), + ]), + }), }), ) }) @@ -453,7 +460,7 @@ describe('useBatchEditDocumentMetadata', () => { // Both documents should have the field after applying to all expect(mockMutateAsync).toHaveBeenCalled() const callArgs = mockMutateAsync.mock.calls[0]![0] - expect(callArgs.metadata_list.length).toBe(2) + expect(callArgs.body.operation_data.length).toBe(2) }) it('should not add missing metadata for multiple value items when isApplyToAllSelectDocument is true', async () => { @@ -545,14 +552,16 @@ describe('useBatchEditDocumentMetadata', () => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ - metadata_list: expect.arrayContaining([ - expect.objectContaining({ - metadata_list: expect.arrayContaining([ - expect.objectContaining({ id: '1', value: 'New Value 1' }), - expect.objectContaining({ id: '2', value: 200 }), - ]), - }), - ]), + body: expect.objectContaining({ + operation_data: expect.arrayContaining([ + expect.objectContaining({ + metadata_list: expect.arrayContaining([ + expect.objectContaining({ id: '1', value: 'New Value 1' }), + expect.objectContaining({ id: '2', value: 200 }), + ]), + }), + ]), + }), }), ) }) @@ -574,12 +583,14 @@ describe('useBatchEditDocumentMetadata', () => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ - dataset_id: 'ds-1', - metadata_list: expect.arrayContaining([ - expect.objectContaining({ - document_id: 'doc-1', - }), - ]), + params: { dataset_id: 'ds-1' }, + body: expect.objectContaining({ + operation_data: expect.arrayContaining([ + expect.objectContaining({ + document_id: 'doc-1', + }), + ]), + }), }), ) }) @@ -600,12 +611,14 @@ describe('useBatchEditDocumentMetadata', () => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ - metadata_list: expect.arrayContaining([ - expect.objectContaining({ - document_id: 'doc-not-in-list', - partial_update: true, - }), - ]), + body: expect.objectContaining({ + operation_data: expect.arrayContaining([ + expect.objectContaining({ + document_id: 'doc-not-in-list', + partial_update: true, + }), + ]), + }), }), ) }) @@ -646,7 +659,7 @@ describe('useBatchEditDocumentMetadata', () => { }) const callArgs = mockMutateAsync.mock.calls[0]![0] - const sentItem = callArgs.metadata_list[0].metadata_list[0] + const sentItem = callArgs.body.operation_data[0].metadata_list[0] // Only id, name, type, value should be present expect(Object.keys(sentItem).sort()).toEqual(['id', 'name', 'type', 'value'].sort()) @@ -688,7 +701,7 @@ describe('useBatchEditDocumentMetadata', () => { }) const callArgs = mockMutateAsync.mock.calls[0]![0] - const sentItem = callArgs.metadata_list[0].metadata_list[0] + const sentItem = callArgs.body.operation_data[0].metadata_list[0] // value should be null, not undefined expect(sentItem.value).toBeNull() diff --git a/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts index dea5dc81291bfa..77150858d2d455 100644 --- a/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts @@ -8,37 +8,40 @@ const mockDoRenameMetaData = vi.fn().mockResolvedValue({}) const mockDoDeleteMetaData = vi.fn().mockResolvedValue({}) const mockToggleBuiltInStatus = vi.fn().mockResolvedValue({}) -vi.mock('@/service/knowledge/use-metadata', () => ({ - useDatasetMetaData: () => ({ - data: { - doc_metadata: [ - { id: '1', name: 'field_one', type: DataType.string, count: 5 }, - { id: '2', name: 'field_two', type: DataType.number, count: 3 }, - ], - built_in_field_enabled: false, - }, - }), - useCreateMetaData: () => ({ - mutate: mockDoAddMetaData, - }), - useRenameMeta: () => ({ - mutate: mockDoRenameMetaData, - }), - useDeleteMetaData: () => ({ - mutateAsync: mockDoDeleteMetaData, - }), - useUpdateBuiltInStatus: () => ({ - mutateAsync: mockToggleBuiltInStatus, - }), - useBuiltInMetaDataFields: () => ({ - data: { - fields: [ - { name: 'created_at', type: DataType.time }, - { name: 'modified_at', type: DataType.time }, - ], - }, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.metadata.get': () => ({ + data: { + doc_metadata: [ + { id: '1', name: 'field_one', type: DataType.string, count: 5 }, + { id: '2', name: 'field_two', type: DataType.number, count: 3 }, + ], + built_in_field_enabled: false, + }, + }), + 'datasets.byDatasetId.metadata.post': () => ({ + mutateAsync: mockDoAddMetaData, + }), + 'datasets.byDatasetId.metadata.byMetadataId.patch': () => ({ + mutateAsync: mockDoRenameMetaData, + }), + 'datasets.byDatasetId.metadata.byMetadataId.delete': () => ({ + mutateAsync: mockDoDeleteMetaData, + }), + 'datasets.byDatasetId.metadata.builtIn.byAction.post': () => ({ + mutateAsync: mockToggleBuiltInStatus, + }), + 'datasets.metadata.builtIn.get': () => ({ + data: { + fields: [ + { name: 'created_at', type: DataType.time }, + { name: 'modified_at', type: DataType.time }, + ], + }, + }), + }) +}) vi.mock('@/app/notifications', () => ({ default: { @@ -264,7 +267,9 @@ describe('useEditDatasetMetadata', () => { await result.current.handleDeleteMetaData('1') }) - expect(mockDoDeleteMetaData).toHaveBeenCalledWith('1') + expect(mockDoDeleteMetaData).toHaveBeenCalledWith({ + params: { dataset_id: 'ds-1', metadata_id: '1' }, + }) }) it('should call onUpdateDocList after delete', async () => { @@ -291,7 +296,9 @@ describe('useEditDatasetMetadata', () => { await result.current.setBuiltInEnabled(true) }) - expect(mockToggleBuiltInStatus).toHaveBeenCalledWith(true) + expect(mockToggleBuiltInStatus).toHaveBeenCalledWith({ + params: { dataset_id: 'ds-1', action: 'enable' }, + }) }) }) diff --git a/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts index 9d1ae7459596e9..513d1eca1573ad 100644 --- a/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts @@ -16,28 +16,31 @@ type DocDetail = { const mockMutateAsync = vi.fn().mockResolvedValue({}) const mockDoAddMetaData = vi.fn().mockResolvedValue({}) -vi.mock('@/service/knowledge/use-metadata', () => ({ - useBatchUpdateDocMetadata: () => ({ - mutateAsync: mockMutateAsync, - }), - useCreateMetaData: () => ({ - mutateAsync: mockDoAddMetaData, - }), - useDocumentMetaData: () => ({ - data: { - doc_metadata: [ - { id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' }, - { id: '2', name: 'field_two', type: DataType.number, value: 42 }, - { id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 }, - ], - }, - }), - useDatasetMetaData: () => ({ - data: { - built_in_field_enabled: true, - }, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.metadata.post': () => ({ + mutateAsync: mockMutateAsync, + }), + 'datasets.byDatasetId.metadata.post': () => ({ + mutateAsync: mockDoAddMetaData, + }), + 'datasets.byDatasetId.documents.byDocumentId.get': () => ({ + data: { + doc_metadata: [ + { id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' }, + { id: '2', name: 'field_two', type: DataType.number, value: 42 }, + { id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 }, + ], + }, + }), + 'datasets.byDatasetId.metadata.get': () => ({ + data: { + built_in_field_enabled: true, + }, + }), + }) +}) // Mock useDatasetDetailContext vi.mock('@/context/dataset-detail', () => ({ diff --git a/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.ts b/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.ts index d08b4d75fef9ef..fb528975de98de 100644 --- a/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.ts +++ b/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.ts @@ -5,11 +5,12 @@ import type { MetadataItemWithValue, } from '../types' import type { SimpleDocumentDetail } from '@/models/datasets' +import { useMutation } from '@tanstack/react-query' import { useBoolean } from 'ahooks' import { t } from 'i18next' import { useMemo } from 'react' import { toast } from '@/app/notifications' -import { useBatchUpdateDocMetadata } from '@/service/knowledge/use-metadata' +import { consoleQuery } from '@/service/console' import { UpdateType } from '../types' type Props = Readonly<{ @@ -126,16 +127,18 @@ const useBatchEditDocumentMetadata = ({ }) return res } - const { mutateAsync } = useBatchUpdateDocMetadata() + const { mutateAsync: updateDocumentMetadata } = useMutation( + consoleQuery.datasets.byDatasetId.documents.metadata.post.mutationOptions(), + ) const handleSave = async ( editedList: MetadataItemInBatchEdit[], addedList: MetadataItemInBatchEdit[], isApplyToAllSelectDocument: boolean, ) => { const backendList = formateToBackendList(editedList, addedList, isApplyToAllSelectDocument) - await mutateAsync({ - dataset_id: datasetId, - metadata_list: backendList, + await updateDocumentMetadata({ + params: { dataset_id: datasetId }, + body: { operation_data: backendList }, }) onUpdate() hideEditModal() diff --git a/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts b/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts index 333127acb5009a..e882146f980779 100644 --- a/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts +++ b/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts @@ -1,16 +1,10 @@ import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '../types' import type { DataSet } from '@/models/datasets' +import { useMutation, useQuery } from '@tanstack/react-query' import { useBoolean } from 'ahooks' import { useCallback, useEffect, useState } from 'react' import { toast } from '@/app/notifications' -import { - useBuiltInMetaDataFields, - useCreateMetaData, - useDatasetMetaData, - useDeleteMetaData, - useRenameMeta, - useUpdateBuiltInStatus, -} from '@/service/knowledge/use-metadata' +import { consoleQuery } from '@/service/console' import { isShowManageMetadataLocalStorageKey } from '../types' import useCheckMetadataName from './use-check-metadata-name' @@ -31,8 +25,14 @@ const useEditDatasetMetadata = ({ localStorage.removeItem(isShowManageMetadataLocalStorageKey) } }, []) - const { data: datasetMetaData } = useDatasetMetaData(datasetId) - const { mutate: doAddMetaData } = useCreateMetaData(datasetId) + const { data: datasetMetaData } = useQuery( + consoleQuery.datasets.byDatasetId.metadata.get.queryOptions({ + input: { params: { dataset_id: datasetId } }, + }), + ) + const { mutateAsync: createMetaData } = useMutation( + consoleQuery.datasets.byDatasetId.metadata.post.mutationOptions(), + ) const { checkName } = useCheckMetadataName() const handleAddMetaData = useCallback( async (payload: BuiltInMetadataItem) => { @@ -41,11 +41,13 @@ const useEditDatasetMetadata = ({ toast.error(errorMsg) return Promise.reject(new Error(errorMsg)) } - await doAddMetaData(payload) + await createMetaData({ params: { dataset_id: datasetId }, body: payload }) }, - [checkName, doAddMetaData], + [checkName, createMetaData, datasetId], + ) + const { mutateAsync: renameMetaData } = useMutation( + consoleQuery.datasets.byDatasetId.metadata.byMetadataId.patch.mutationOptions(), ) - const { mutate: doRenameMetaData } = useRenameMeta(datasetId) const handleRename = useCallback( async (payload: MetadataItemWithValueLength) => { const errorMsg = checkName(payload.name).errorMsg @@ -53,25 +55,34 @@ const useEditDatasetMetadata = ({ toast.error(errorMsg) return Promise.reject(new Error(errorMsg)) } - await doRenameMetaData(payload) + await renameMetaData({ + params: { dataset_id: datasetId, metadata_id: payload.id }, + body: { name: payload.name }, + }) onUpdateDocList() }, - [checkName, doRenameMetaData, onUpdateDocList], + [checkName, renameMetaData, datasetId, onUpdateDocList], + ) + const { mutateAsync: deleteMetaData } = useMutation( + consoleQuery.datasets.byDatasetId.metadata.byMetadataId.delete.mutationOptions(), ) - const { mutateAsync: doDeleteMetaData } = useDeleteMetaData(datasetId) const handleDeleteMetaData = useCallback( async (metaDataId: string) => { - await doDeleteMetaData(metaDataId) + await deleteMetaData({ params: { dataset_id: datasetId, metadata_id: metaDataId } }) onUpdateDocList() }, - [doDeleteMetaData, onUpdateDocList], + [deleteMetaData, datasetId, onUpdateDocList], ) const [builtInEnabled, setBuiltInEnabled] = useState(datasetMetaData?.built_in_field_enabled) useEffect(() => { setBuiltInEnabled(datasetMetaData?.built_in_field_enabled) }, [datasetMetaData]) - const { mutateAsync: toggleBuiltInStatus } = useUpdateBuiltInStatus(datasetId) - const { data: builtInMetaData } = useBuiltInMetaDataFields() + const { mutateAsync: updateBuiltInStatus } = useMutation( + consoleQuery.datasets.byDatasetId.metadata.builtIn.byAction.post.mutationOptions(), + ) + const { data: builtInMetaData } = useQuery( + consoleQuery.datasets.metadata.builtIn.get.queryOptions(), + ) return { isShowEditModal, showEditModal, @@ -83,7 +94,9 @@ const useEditDatasetMetadata = ({ builtInMetaData: builtInMetaData?.fields, builtInEnabled, setBuiltInEnabled: async (enable: boolean) => { - await toggleBuiltInStatus(enable) + await updateBuiltInStatus({ + params: { dataset_id: datasetId, action: enable ? 'enable' : 'disable' }, + }) setBuiltInEnabled(enable) }, } diff --git a/web/app/components/datasets/metadata/hooks/use-metadata-document.ts b/web/app/components/datasets/metadata/hooks/use-metadata-document.ts index 1d2a71e527f270..1922ad44b646b9 100644 --- a/web/app/components/datasets/metadata/hooks/use-metadata-document.ts +++ b/web/app/components/datasets/metadata/hooks/use-metadata-document.ts @@ -1,17 +1,13 @@ import type { BuiltInMetadataItem, MetadataItemWithValue } from '../types' import type { FullDocumentDetail } from '@/models/datasets' +import { useMutation, useQuery } from '@tanstack/react-query' import { get } from 'es-toolkit/compat' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' import { useDatasetDetailContext } from '@/context/dataset-detail' import { useLanguages, useMetadataMap } from '@/hooks/use-metadata' -import { - useBatchUpdateDocMetadata, - useCreateMetaData, - useDatasetMetaData, - useDocumentMetaData, -} from '@/service/knowledge/use-metadata' +import { consoleQuery } from '@/service/console' import { DataType } from '../types' import useCheckMetadataName from './use-check-metadata-name' @@ -24,18 +20,26 @@ const useMetadataDocument = ({ datasetId, documentId, docDetail }: Props) => { const { t } = useTranslation(['common']) const { dataset } = useDatasetDetailContext() const embeddingAvailable = !!dataset?.embedding_available - const { mutateAsync } = useBatchUpdateDocMetadata() + const { mutateAsync: updateDocumentMetadata } = useMutation( + consoleQuery.datasets.byDatasetId.documents.metadata.post.mutationOptions(), + ) const { checkName } = useCheckMetadataName() const [isEdit, setIsEdit] = useState(false) - const { data: documentDetail } = useDocumentMetaData({ - datasetId, - documentId, - }) + const { data: documentDetail } = useQuery( + consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.queryOptions({ + input: { + params: { dataset_id: datasetId, document_id: documentId }, + query: { metadata: 'only' }, + }, + }), + ) const allList = documentDetail?.doc_metadata || [] const list = allList.filter((item) => item.id !== 'built-in') const builtList = allList.filter((item) => item.id === 'built-in') const [tempList, setTempList] = useState(list) - const { mutateAsync: doAddMetaData } = useCreateMetaData(datasetId) + const { mutateAsync: createMetaData } = useMutation( + consoleQuery.datasets.byDatasetId.metadata.post.mutationOptions(), + ) const handleSelectMetaData = useCallback((metaData: MetadataItemWithValue) => { setTempList((prev) => { const index = prev.findIndex((item) => item.id === metaData.id) @@ -50,21 +54,16 @@ const useMetadataDocument = ({ datasetId, documentId, docDetail }: Props) => { toast.error(errorMsg) return Promise.reject(new Error(errorMsg)) } - await doAddMetaData(payload) + await createMetaData({ params: { dataset_id: datasetId }, body: payload }) toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) }, - [checkName, doAddMetaData, t], + [checkName, createMetaData, datasetId, t], ) const hasData = list.length > 0 const handleSave = async () => { - await mutateAsync({ - dataset_id: datasetId, - metadata_list: [ - { - document_id: documentId, - metadata_list: tempList, - }, - ], + await updateDocumentMetadata({ + params: { dataset_id: datasetId }, + body: { operation_data: [{ document_id: documentId, metadata_list: tempList }] }, }) setIsEdit(false) toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) @@ -78,7 +77,11 @@ const useMetadataDocument = ({ datasetId, documentId, docDetail }: Props) => { setIsEdit(true) } // built in enabled is set in dataset - const { data: datasetMetaData } = useDatasetMetaData(datasetId) + const { data: datasetMetaData } = useQuery( + consoleQuery.datasets.byDatasetId.metadata.get.queryOptions({ + input: { params: { dataset_id: datasetId } }, + }), + ) const builtInEnabled = datasetMetaData?.built_in_field_enabled // old metadata and technical params const metadataMap = useMetadataMap() diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx index 51642381c0a21e..953c34ddff680f 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx @@ -6,13 +6,16 @@ import { DataType } from '../../types' import DatasetMetadataDrawer from '../dataset-metadata-drawer' // Mock service/API calls -vi.mock('@/service/knowledge/use-metadata', () => ({ - useDatasetMetaData: () => ({ - data: { - doc_metadata: [{ id: '1', name: 'existing_field', type: DataType.string }], - }, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.metadata.get': () => ({ + data: { + doc_metadata: [{ id: '1', name: 'existing_field', type: DataType.string }], + }, + }), + }) +}) // Mock check name hook vi.mock('../../hooks/use-check-metadata-name', () => ({ diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-picker.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-picker.spec.tsx index b8cc289f1a2985..471bb51bd1e492 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-picker.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-picker.spec.tsx @@ -5,13 +5,16 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { DataType } from '../../types' import { DatasetMetadataPicker } from '../dataset-metadata-picker' -const { mockUseDatasetMetaData } = vi.hoisted(() => ({ - mockUseDatasetMetaData: vi.fn(), +const { mockDatasetMetadataQuery } = vi.hoisted(() => ({ + mockDatasetMetadataQuery: vi.fn(), })) -vi.mock('@/service/knowledge/use-metadata', () => ({ - useDatasetMetaData: mockUseDatasetMetaData, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.metadata.get': mockDatasetMetadataQuery, + }) +}) const metadataItems: MetadataItem[] = [ { id: '1', name: 'field_one', type: DataType.string }, @@ -39,7 +42,7 @@ function renderDatasetMetadataPicker( describe('DatasetMetadataPicker', () => { beforeEach(() => { vi.clearAllMocks() - mockUseDatasetMetaData.mockReturnValue({ + mockDatasetMetadataQuery.mockReturnValue({ data: { doc_metadata: metadataItems, }, @@ -244,7 +247,7 @@ describe('DatasetMetadataPicker', () => { describe('Edge Cases', () => { it('should keep action buttons available when metadata list is empty', async () => { const user = userEvent.setup() - mockUseDatasetMetaData.mockReturnValue({ + mockDatasetMetadataQuery.mockReturnValue({ data: { doc_metadata: [], }, diff --git a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-picker.tsx b/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-picker.tsx index f95662e65850c5..65a2df81bc93c1 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-picker.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-picker.tsx @@ -15,9 +15,10 @@ import { ComboboxSeparator, } from '@langgenius/dify-ui/combobox' import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useDatasetMetaData } from '@/service/knowledge/use-metadata' +import { consoleQuery } from '@/service/console' import { getIconClassName } from '../utils/get-icon' import { CreateContent } from './create-content' @@ -58,7 +59,11 @@ export function DatasetMetadataPicker({ onOpenMetadataManagement, }: DatasetMetadataPickerProps) { const { t } = useTranslation(['dataset']) - const { data: datasetMetaData } = useDatasetMetaData(datasetId) + const { data: datasetMetaData } = useQuery( + consoleQuery.datasets.byDatasetId.metadata.get.queryOptions({ + input: { params: { dataset_id: datasetId } }, + }), + ) const metadataItems = datasetMetaData?.doc_metadata ?? [] const [open, setOpen] = useState(false) const [view, setView] = useState(PickerView.select) diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/document-information.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/document-information.spec.tsx index e8da262e2e2a29..51bb7aab4804d6 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/document-information.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/document-information.spec.tsx @@ -6,12 +6,15 @@ vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContext: () => ({ dataset: { embedding_available: true } }), })) -vi.mock('@/service/knowledge/use-metadata', () => ({ - useBatchUpdateDocMetadata: () => ({ mutateAsync: vi.fn() }), - useCreateMetaData: () => ({ mutateAsync: vi.fn() }), - useDocumentMetaData: () => ({ data: { doc_metadata: [] } }), - useDatasetMetaData: () => ({ data: { built_in_field_enabled: false } }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.metadata.post': () => ({ mutateAsync: vi.fn() }), + 'datasets.byDatasetId.metadata.post': () => ({ mutateAsync: vi.fn() }), + 'datasets.byDatasetId.documents.byDocumentId.get': () => ({ data: { doc_metadata: [] } }), + 'datasets.byDatasetId.metadata.get': () => ({ data: { built_in_field_enabled: false } }), + }) +}) vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx index 7f90e0ed95bd0d..4257edfc761328 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx @@ -30,13 +30,16 @@ vi.mock('../../hooks/use-metadata-document', () => ({ })) // Mock service calls -vi.mock('@/service/knowledge/use-metadata', () => ({ - useDatasetMetaData: () => ({ - data: { - doc_metadata: [], - }, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.metadata.get': () => ({ + data: { + doc_metadata: [], + }, + }), + }) +}) // Mock check name hook vi.mock('../../hooks/use-check-metadata-name', () => ({ diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx index d5a27763110a6b..6a3ebddb1b50b6 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx @@ -21,13 +21,16 @@ vi.mock('@/next/navigation', () => ({ }), })) -vi.mock('@/service/knowledge/use-metadata', () => ({ - useDatasetMetaData: () => ({ - data: { - doc_metadata: [{ id: '1', name: 'test', type: DataType.string }], - }, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.metadata.get': () => ({ + data: { + doc_metadata: [{ id: '1', name: 'test', type: DataType.string }], + }, + }), + }) +}) // Mock useTimestamp vi.mock('@/hooks/use-timestamp', () => ({ diff --git a/web/models/datasets.ts b/web/models/datasets.ts index ebae613d41d77b..d0051c7e2bfe27 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -480,8 +480,6 @@ type CustomizableDocType = type FixedDocType = 'synced_from_github' | 'synced_from_notion' | 'wikipedia_entry' export type DocType = CustomizableDocType | FixedDocType -export type DocumentDetailResponse = FullDocumentDetail - type SegmentStatus = 'waiting' | 'completed' | 'error' | 'indexing' export type Attachment = { diff --git a/web/service/console/__tests__/dataset-metadata-policies.spec.ts b/web/service/console/__tests__/dataset-metadata-policies.spec.ts new file mode 100644 index 00000000000000..2e64d8bcaa44e0 --- /dev/null +++ b/web/service/console/__tests__/dataset-metadata-policies.spec.ts @@ -0,0 +1,116 @@ +import type { QueryClient, QueryKey } from '@tanstack/react-query' +import { MutationObserver } from '@tanstack/react-query' +import { consoleQuery } from '@/service/console' +import { createTestQueryClient } from '@/test/query-client' + +const { mockCall } = vi.hoisted(() => ({ mockCall: vi.fn() })) + +vi.mock('@/service/console/browser', () => ({ + consoleBrowserLink: { call: mockCall }, +})) + +const params = { dataset_id: 'dataset-1' } + +const datasetMetadataKey = consoleQuery.datasets.byDatasetId.metadata.get.queryKey({ + input: { params }, +}) +const documentListKey = consoleQuery.datasets.byDatasetId.documents.get.queryKey({ + input: { params, query: { page: 1, limit: 10 } }, +}) +const documentMetadataKey = consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.queryKey({ + input: { params: { ...params, document_id: 'doc-1' }, query: { metadata: 'only' } }, +}) +const otherDatasetMetadataKey = consoleQuery.datasets.byDatasetId.metadata.get.queryKey({ + input: { params: { dataset_id: 'dataset-2' } }, +}) + +const seed = (queryClient: QueryClient, keys: QueryKey[]) => { + keys.forEach((key) => queryClient.setQueryData(key, {})) +} + +const isInvalidated = (queryClient: QueryClient, key: QueryKey) => + queryClient.getQueryState(key)?.isInvalidated ?? false + +describe('dataset metadata cache policies', () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.clearAllMocks() + mockCall.mockResolvedValue({}) + queryClient = createTestQueryClient() + seed(queryClient, [ + datasetMetadataKey, + documentListKey, + documentMetadataKey, + otherDatasetMetadataKey, + ]) + }) + + it('refreshes the dataset fields after a field is created', async () => { + await new MutationObserver( + queryClient, + consoleQuery.datasets.byDatasetId.metadata.post.mutationOptions(), + ).mutate({ params, body: { name: 'author', type: 'string' } }) + + expect(isInvalidated(queryClient, datasetMetadataKey)).toBe(true) + expect(isInvalidated(queryClient, documentListKey)).toBe(false) + expect(isInvalidated(queryClient, otherDatasetMetadataKey)).toBe(false) + }) + + it.each([ + [ + 'renaming a field', + () => + new MutationObserver( + queryClient, + consoleQuery.datasets.byDatasetId.metadata.byMetadataId.patch.mutationOptions(), + ).mutate({ params: { ...params, metadata_id: 'field-1' }, body: { name: 'writer' } }), + ], + [ + 'deleting a field', + () => + new MutationObserver( + queryClient, + consoleQuery.datasets.byDatasetId.metadata.byMetadataId.delete.mutationOptions(), + ).mutate({ params: { ...params, metadata_id: 'field-1' } }), + ], + [ + 'editing document values', + () => + new MutationObserver( + queryClient, + consoleQuery.datasets.byDatasetId.documents.metadata.post.mutationOptions(), + ).mutate({ params, body: { operation_data: [] } }), + ], + ])('refreshes the fields, document list and document details after %s', async (_, run) => { + await run() + + expect(isInvalidated(queryClient, datasetMetadataKey)).toBe(true) + expect(isInvalidated(queryClient, documentListKey)).toBe(true) + expect(isInvalidated(queryClient, documentMetadataKey)).toBe(true) + expect(isInvalidated(queryClient, otherDatasetMetadataKey)).toBe(false) + }) + + it('refreshes the dataset fields after the built-in fields are toggled', async () => { + await new MutationObserver( + queryClient, + consoleQuery.datasets.byDatasetId.metadata.builtIn.byAction.post.mutationOptions(), + ).mutate({ params: { ...params, action: 'enable' } }) + + expect(isInvalidated(queryClient, datasetMetadataKey)).toBe(true) + expect(isInvalidated(queryClient, documentListKey)).toBe(false) + }) + + it('keeps the cache when the request fails', async () => { + mockCall.mockRejectedValueOnce(new Error('failed')) + + await expect( + new MutationObserver( + queryClient, + consoleQuery.datasets.byDatasetId.metadata.byMetadataId.delete.mutationOptions(), + ).mutate({ params: { ...params, metadata_id: 'field-1' } }), + ).rejects.toThrow('failed') + + expect(isInvalidated(queryClient, datasetMetadataKey)).toBe(false) + }) +}) diff --git a/web/service/console/query-policies.ts b/web/service/console/query-policies.ts index 409e66ead02b60..0d91ac50a05919 100644 --- a/web/service/console/query-policies.ts +++ b/web/service/console/query-policies.ts @@ -922,6 +922,117 @@ export function createConsoleQuery(consoleClient: ConsoleClient) { byDocumentId: { pipelineExecutionLog: { get: { queryOptions: { staleTime: 0 } } }, }, + // Document metadata shows in the dataset fields, the document list and each + // document's detail. + metadata: { + post: { + mutationOptions: { + onSettled: (_data, error, variables, _result, context) => { + if (error) return + const params = { dataset_id: variables.params.dataset_id } + return Promise.all([ + context.client.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.metadata.get.queryKey({ + input: { params }, + }), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ + input: { params }, + }), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.key({ + input: { params }, + }), + }), + ]) + }, + }, + }, + }, + }, + metadata: { + post: { + mutationOptions: { + onSettled: (_data, error, variables, _result, context) => { + if (error) return + return context.client.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.metadata.get.queryKey({ + input: { params: variables.params }, + }), + }) + }, + }, + }, + byMetadataId: { + patch: { + mutationOptions: { + onSettled: (_data, error, variables, _result, context) => { + if (error) return + const params = { dataset_id: variables.params.dataset_id } + return Promise.all([ + context.client.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.metadata.get.queryKey({ + input: { params }, + }), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ + input: { params }, + }), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.key({ + input: { params }, + }), + }), + ]) + }, + }, + }, + delete: { + mutationOptions: { + onSettled: (_data, error, variables, _result, context) => { + if (error) return + const params = { dataset_id: variables.params.dataset_id } + return Promise.all([ + context.client.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.metadata.get.queryKey({ + input: { params }, + }), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ + input: { params }, + }), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.key({ + input: { params }, + }), + }), + ]) + }, + }, + }, + }, + builtIn: { + byAction: { + post: { + mutationOptions: { + onSettled: (_data, error, variables, _result, context) => { + if (error) return + return context.client.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.metadata.get.queryKey({ + input: { params: { dataset_id: variables.params.dataset_id } }, + }), + }) + }, + }, + }, + }, + }, }, }, apiKeys: { diff --git a/web/service/knowledge/use-metadata.ts b/web/service/knowledge/use-metadata.ts deleted file mode 100644 index ba524ceea502c3..00000000000000 --- a/web/service/knowledge/use-metadata.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { - BuiltInMetadataItem, - MetadataBatchEditToServer, - MetadataItemWithValueLength, -} from '@/app/components/datasets/metadata/types' -import type { DocumentDetailResponse } from '@/models/datasets' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { consoleClient, consoleQuery } from '../console' -import { narrowResponse } from '../console/narrow-response' -import { useInvalid } from '../use-base' - -const NAME_SPACE = 'dataset-metadata' - -export const useDatasetMetaData = (datasetId: string) => { - return useQuery<{ doc_metadata: MetadataItemWithValueLength[]; built_in_field_enabled: boolean }>( - { - queryKey: [NAME_SPACE, 'dataset', datasetId], - queryFn: () => { - return narrowResponse( - consoleClient.datasets.byDatasetId.metadata.get({ params: { dataset_id: datasetId } }), - ) - }, - }, - ) -} - -const useInvalidDatasetMetaData = (datasetId: string) => { - return useInvalid([NAME_SPACE, 'dataset', datasetId]) -} - -export const useCreateMetaData = (datasetId: string) => { - const invalidDatasetMetaData = useInvalidDatasetMetaData(datasetId) - return useMutation({ - mutationFn: async (payload: BuiltInMetadataItem) => { - await consoleClient.datasets.byDatasetId.metadata.post({ - params: { dataset_id: datasetId }, - body: payload, - }) - await invalidDatasetMetaData() - return Promise.resolve(true) - }, - }) -} -const useInvalidAllDocumentMetaData = (datasetId: string) => { - const queryClient = useQueryClient() - return () => { - queryClient.invalidateQueries({ - queryKey: [NAME_SPACE, 'document', datasetId], - exact: false, // invalidate all document metadata: [NAME_SPACE, 'document', datasetId, documentId] - }) - } -} - -const useInvalidAllMetaData = (datasetId: string) => { - const invalidDatasetMetaData = useInvalidDatasetMetaData(datasetId) - const queryClient = useQueryClient() - const invalidDocumentList = () => - queryClient.invalidateQueries({ - queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ - input: { params: { dataset_id: datasetId } }, - }), - }) - const invalidateAllDocumentMetaData = useInvalidAllDocumentMetaData(datasetId) - - return async () => { - // meta data in dataset - await invalidDatasetMetaData() - // meta data in document list - invalidDocumentList() - // meta data in single document - await invalidateAllDocumentMetaData() // meta data in document - } -} - -export const useRenameMeta = (datasetId: string) => { - const invalidateAllMetaData = useInvalidAllMetaData(datasetId) - return useMutation({ - mutationFn: async (payload: MetadataItemWithValueLength) => { - await consoleClient.datasets.byDatasetId.metadata.byMetadataId.patch({ - params: { dataset_id: datasetId, metadata_id: payload.id }, - body: { - name: payload.name, - }, - }) - await invalidateAllMetaData() - }, - }) -} - -export const useDeleteMetaData = (datasetId: string) => { - const invalidateAllMetaData = useInvalidAllMetaData(datasetId) - return useMutation({ - mutationFn: async (metaDataId: string) => { - // datasetMetaData = datasetMetaData.filter(item => item.id !== metaDataId) - await consoleClient.datasets.byDatasetId.metadata.byMetadataId.delete({ - params: { dataset_id: datasetId, metadata_id: metaDataId }, - }) - await invalidateAllMetaData() - }, - }) -} - -export const useBuiltInMetaDataFields = () => { - return useQuery<{ fields: BuiltInMetadataItem[] }>({ - queryKey: [NAME_SPACE, 'built-in'], - queryFn: () => { - return narrowResponse(consoleClient.datasets.metadata.builtIn.get()) - }, - }) -} - -export const useDocumentMetaData = ({ - datasetId, - documentId, -}: { - datasetId: string - documentId: string -}) => { - return useQuery({ - queryKey: [NAME_SPACE, 'document', datasetId, documentId], - queryFn: () => { - return narrowResponse( - consoleClient.datasets.byDatasetId.documents.byDocumentId.get({ - params: { dataset_id: datasetId, document_id: documentId }, - query: { metadata: 'only' }, - }), - ) - }, - }) -} - -export const useBatchUpdateDocMetadata = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async (payload: { - dataset_id: string - metadata_list: MetadataBatchEditToServer - }) => { - const documentIds = payload.metadata_list.map((item) => item.document_id) - await consoleClient.datasets.byDatasetId.documents.metadata.post({ - params: { dataset_id: payload.dataset_id }, - body: { - operation_data: payload.metadata_list, - }, - }) - // meta data in dataset - await queryClient.invalidateQueries({ - queryKey: [NAME_SPACE, 'dataset', payload.dataset_id], - }) - // meta data in document list - await queryClient.invalidateQueries({ - queryKey: [NAME_SPACE, 'document', payload.dataset_id], - }) - await queryClient.invalidateQueries({ - queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ - input: { params: { dataset_id: payload.dataset_id } }, - }), - }) - - // meta data in single document - await Promise.all( - documentIds.map((documentId) => - queryClient.invalidateQueries({ - queryKey: [NAME_SPACE, 'document', payload.dataset_id, documentId], - }), - ), - ) - }, - }) -} - -export const useUpdateBuiltInStatus = (datasetId: string) => { - const invalidDatasetMetaData = useInvalidDatasetMetaData(datasetId) - return useMutation({ - mutationFn: async (enabled: boolean) => { - await consoleClient.datasets.byDatasetId.metadata.builtIn.byAction.post({ - params: { dataset_id: datasetId, action: enabled ? 'enable' : 'disable' }, - }) - invalidDatasetMetaData() - }, - }) -} From 2290bc54b2fea3366810d30a477ded9af0663c63 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:09:14 +0800 Subject: [PATCH 48/60] refactor(web): use generated segment options directly Remove the forwarding hooks in service/knowledge/use-segment. The chunk and child chunk lists read the generated console queries, and the add, update, enable/disable, delete, batch import and import progress calls use the generated mutation options. Segment and child chunk UI types alias the contract. Segment responses declare the stored segment status and child chunk type enums. The batch import job status is read from a cache entry, so it stays a string in the contract and is narrowed with isSegmentImportStatus; polling stops on a status the page does not know. The enabled/disabled/all chunk list invalidations never matched a list query. After an optimistic edit, the document's other status-filtered lists are now marked stale without refetching the one being edited, and the delayed refresh after a document operation targets that document's or dataset's lists. Co-Authored-By: Claude Opus 5.5 --- api/fields/segment_fields.py | 5 +- api/openapi/markdown/console-openapi.md | 20 +- api/openapi/markdown/service-openapi.md | 20 +- oxlint-suppressions.json | 5 - .../api/console/datasets/types.gen.ts | 8 +- .../generated/api/console/datasets/zod.gen.ts | 101 ++++--- .../generated/api/service/types.gen.ts | 8 +- .../generated/api/service/zod.gen.ts | 87 +++--- .../documents/__tests__/index.spec.tsx | 10 - .../documents/detail/__tests__/index.spec.tsx | 108 ++++++-- .../detail/__tests__/new-segment.spec.tsx | 16 +- .../detail/completed/__tests__/index.spec.tsx | 243 ++++------------ .../__tests__/new-child-segment.spec.tsx | 21 +- .../completed/__tests__/permissions.spec.tsx | 3 + .../__tests__/use-child-segment-data.spec.ts | 66 +++-- .../__tests__/use-segment-list-data.spec.ts | 106 ++++--- .../completed/hooks/use-child-segment-data.ts | 77 ++++-- .../completed/hooks/use-segment-list-data.ts | 132 +++++---- .../documents/detail/completed/index.tsx | 36 +-- .../detail/completed/new-child-segment.tsx | 12 +- .../segment-card/__tests__/index.spec.tsx | 3 + .../detail/completed/segment-card/index.tsx | 5 +- .../detail/completed/segment-detail.tsx | 3 +- .../datasets/documents/detail/index.tsx | 54 ++-- .../datasets/documents/detail/new-segment.tsx | 9 +- .../components/datasets/documents/index.tsx | 20 +- web/models/datasets.ts | 71 +---- web/service/knowledge/use-segment.ts | 260 ------------------ web/types/dataset.ts | 3 + 29 files changed, 606 insertions(+), 906 deletions(-) delete mode 100644 web/service/knowledge/use-segment.ts diff --git a/api/fields/segment_fields.py b/api/fields/segment_fields.py index c4f73c3c08cb1d..aeb01ea8a90276 100644 --- a/api/fields/segment_fields.py +++ b/api/fields/segment_fields.py @@ -4,6 +4,7 @@ from fields.base import ResponseModel from libs.helper import to_timestamp +from models.enums import SegmentStatus, SegmentType class SegmentAttachmentResponse(ResponseModel): @@ -21,7 +22,7 @@ class ChildChunkResponse(ResponseModel): content: str position: int word_count: int - type: str + type: SegmentType created_at: datetime | int updated_at: datetime | int @@ -46,7 +47,7 @@ class SegmentResponse(ResponseModel): enabled: bool disabled_at: datetime | int | None disabled_by: str | None - status: str + status: SegmentStatus created_by: str created_at: datetime | int updated_at: datetime | int diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 483498f91625d0..4a319c8eb9ccf3 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -19214,7 +19214,7 @@ Create child chunk index failed: {message} | id | string | | Yes | | position | integer | | Yes | | segment_id | string | | Yes | -| type | string | | Yes | +| type | [SegmentType](#segmenttype) | | Yes | | updated_at | integer | | Yes | | word_count | integer | | Yes | @@ -25987,7 +25987,7 @@ The Roster Agent package exceeds the size limit. | keywords | [ string ] | | Yes | | position | integer | | Yes | | sign_content | string | | Yes | -| status | string | | Yes | +| status | [SegmentStatus](#segmentstatus) | | Yes | | stopped_at | integer | | Yes | | summary | string | | Yes | | tokens | integer | | Yes | @@ -25995,6 +25995,22 @@ The Roster Agent package exceeds the size limit. | updated_by | string | | Yes | | word_count | integer | | Yes | +#### SegmentStatus + +Document segment status + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| SegmentStatus | string | Document segment status | | + +#### SegmentType + +Document segment type + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| SegmentType | string | Document segment type | | + #### SegmentUpdatePayload | Name | Type | Description | Required | diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index ea83940422ae2c..e3f33ba317e2e9 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -2769,7 +2769,7 @@ Public pause reason emitted by a blocking Chatflow execution. | id | string | | Yes | | position | integer | | Yes | | segment_id | string | | Yes | -| type | string | | Yes | +| type | [SegmentType](#segmenttype) | | Yes | | updated_at | integer | | Yes | | word_count | integer | | Yes | @@ -4413,7 +4413,7 @@ Rate Limit Error | keywords | [ string ] | | Yes | | position | integer | | Yes | | sign_content | string | | Yes | -| status | string | | Yes | +| status | [SegmentStatus](#segmentstatus) | | Yes | | stopped_at | integer | | Yes | | summary | string | | Yes | | tokens | integer | | Yes | @@ -4421,6 +4421,22 @@ Rate Limit Error | updated_by | string | | Yes | | word_count | integer | | Yes | +#### SegmentStatus + +Document segment status + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| SegmentStatus | string | Document segment status | | + +#### SegmentType + +Document segment type + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| SegmentType | string | Document segment type | | + #### SegmentUpdateArgs | Name | Type | Description | Required | diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 1c1573cb89f84a..e925b2f86df349 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4071,11 +4071,6 @@ "count": 8 } }, - "web/service/knowledge/use-segment.ts": { - "@tanstack/query/prefer-query-options": { - "count": 2 - } - }, "web/service/log.ts": { "no-restricted-imports": { "count": 1 diff --git a/packages/contracts/generated/api/console/datasets/types.gen.ts b/packages/contracts/generated/api/console/datasets/types.gen.ts index 63e2626a3a177b..1898975f26bc17 100644 --- a/packages/contracts/generated/api/console/datasets/types.gen.ts +++ b/packages/contracts/generated/api/console/datasets/types.gen.ts @@ -895,7 +895,7 @@ export type SegmentResponse = { keywords: Array | null position: number sign_content: string - status: string + status: SegmentStatus stopped_at: number | null summary: string | null tokens: number @@ -910,7 +910,7 @@ export type ChildChunkResponse = { id: string position: number segment_id: string - type: string + type: SegmentType updated_at: number word_count: number } @@ -1080,6 +1080,10 @@ export type SegmentAttachmentResponse = { source_url: string } +export type SegmentStatus = 'completed' | 'error' | 'indexing' | 'paused' | 're_segment' | 'waiting' + +export type SegmentType = 'automatic' | 'customized' + export type HitTestingChildChunk = { content: string id: string diff --git a/packages/contracts/generated/api/console/datasets/zod.gen.ts b/packages/contracts/generated/api/console/datasets/zod.gen.ts index 93e030cb2741a7..9214e67b6386dc 100644 --- a/packages/contracts/generated/api/console/datasets/zod.gen.ts +++ b/packages/contracts/generated/api/console/datasets/zod.gen.ts @@ -725,45 +725,6 @@ export const zDocumentMetadataResponse = z.object({ value: z.union([z.string(), z.int(), z.number()]).nullable(), }) -/** - * ChildChunkResponse - */ -export const zChildChunkResponse = z.object({ - content: z.string(), - created_at: z.int(), - id: z.string(), - position: z.int(), - segment_id: z.string(), - type: z.string(), - updated_at: z.int(), - word_count: z.int(), -}) - -/** - * ChildChunkListResponse - */ -export const zChildChunkListResponse = z.object({ - data: z.array(zChildChunkResponse), - limit: z.int(), - page: z.int(), - total: z.int(), - total_pages: z.int(), -}) - -/** - * ChildChunkBatchUpdateResponse - */ -export const zChildChunkBatchUpdateResponse = z.object({ - data: z.array(zChildChunkResponse), -}) - -/** - * ChildChunkDetailResponse - */ -export const zChildChunkDetailResponse = z.object({ - data: zChildChunkResponse, -}) - /** * ChildChunkUpdateArgs */ @@ -1019,6 +980,66 @@ export const zSegmentAttachmentResponse = z.object({ source_url: z.string(), }) +/** + * SegmentStatus + * + * Document segment status + */ +export const zSegmentStatus = z.enum([ + 'completed', + 'error', + 'indexing', + 'paused', + 're_segment', + 'waiting', +]) + +/** + * SegmentType + * + * Document segment type + */ +export const zSegmentType = z.enum(['automatic', 'customized']) + +/** + * ChildChunkResponse + */ +export const zChildChunkResponse = z.object({ + content: z.string(), + created_at: z.int(), + id: z.string(), + position: z.int(), + segment_id: z.string(), + type: zSegmentType, + updated_at: z.int(), + word_count: z.int(), +}) + +/** + * ChildChunkListResponse + */ +export const zChildChunkListResponse = z.object({ + data: z.array(zChildChunkResponse), + limit: z.int(), + page: z.int(), + total: z.int(), + total_pages: z.int(), +}) + +/** + * ChildChunkBatchUpdateResponse + */ +export const zChildChunkBatchUpdateResponse = z.object({ + data: z.array(zChildChunkResponse), +}) + +/** + * ChildChunkDetailResponse + */ +export const zChildChunkDetailResponse = z.object({ + data: zChildChunkResponse, +}) + /** * SegmentResponse */ @@ -1043,7 +1064,7 @@ export const zSegmentResponse = z.object({ keywords: z.array(z.string()).nullable(), position: z.int(), sign_content: z.string(), - status: z.string(), + status: zSegmentStatus, stopped_at: z.int().nullable(), summary: z.string().nullable(), tokens: z.int(), diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index 79d005a1aec9fa..a5d0c739b84522 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -354,7 +354,7 @@ export type ChildChunkResponse = { id: string position: number segment_id: string - type: string + type: SegmentType updated_at: number word_count: number } @@ -2120,7 +2120,7 @@ export type SegmentResponse = { keywords: Array | null position: number sign_content: string - status: string + status: SegmentStatus stopped_at: number | null summary: string | null tokens: number @@ -2129,6 +2129,10 @@ export type SegmentResponse = { word_count: number } +export type SegmentStatus = 'completed' | 'error' | 'indexing' | 'paused' | 're_segment' | 'waiting' + +export type SegmentType = 'automatic' | 'customized' + export type SegmentUpdateArgs = { answer?: string | null attachment_ids?: Array | null diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index 37fb234133b9ba..c336227097f178 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -323,38 +323,6 @@ export const zChildChunkListQuery = z.object({ page: z.int().gte(1).optional().default(1), }) -/** - * ChildChunkResponse - */ -export const zChildChunkResponse = z.object({ - content: z.string(), - created_at: z.int(), - id: z.string(), - position: z.int(), - segment_id: z.string(), - type: z.string(), - updated_at: z.int(), - word_count: z.int(), -}) - -/** - * ChildChunkDetailResponse - */ -export const zChildChunkDetailResponse = z.object({ - data: zChildChunkResponse, -}) - -/** - * ChildChunkListResponse - */ -export const zChildChunkListResponse = z.object({ - data: z.array(zChildChunkResponse), - limit: z.int(), - page: z.int(), - total: z.int(), - total_pages: z.int(), -}) - /** * ChildChunkUpdatePayload */ @@ -2200,6 +2168,59 @@ export const zSegmentListQuery = z.object({ status: z.array(z.string()).optional(), }) +/** + * SegmentStatus + * + * Document segment status + */ +export const zSegmentStatus = z.enum([ + 'completed', + 'error', + 'indexing', + 'paused', + 're_segment', + 'waiting', +]) + +/** + * SegmentType + * + * Document segment type + */ +export const zSegmentType = z.enum(['automatic', 'customized']) + +/** + * ChildChunkResponse + */ +export const zChildChunkResponse = z.object({ + content: z.string(), + created_at: z.int(), + id: z.string(), + position: z.int(), + segment_id: z.string(), + type: zSegmentType, + updated_at: z.int(), + word_count: z.int(), +}) + +/** + * ChildChunkDetailResponse + */ +export const zChildChunkDetailResponse = z.object({ + data: zChildChunkResponse, +}) + +/** + * ChildChunkListResponse + */ +export const zChildChunkListResponse = z.object({ + data: z.array(zChildChunkResponse), + limit: z.int(), + page: z.int(), + total: z.int(), + total_pages: z.int(), +}) + /** * SegmentResponse */ @@ -2224,7 +2245,7 @@ export const zSegmentResponse = z.object({ keywords: z.array(z.string()).nullable(), position: z.int(), sign_content: z.string(), - status: z.string(), + status: zSegmentStatus, stopped_at: z.int().nullable(), summary: z.string().nullable(), tokens: z.int(), diff --git a/web/app/components/datasets/documents/__tests__/index.spec.tsx b/web/app/components/datasets/documents/__tests__/index.spec.tsx index 3f075a99d4775a..ae7be9b2b8b42b 100644 --- a/web/app/components/datasets/documents/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/__tests__/index.spec.tsx @@ -140,16 +140,6 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { }) // Mock segment service hooks -vi.mock('@/service/knowledge/use-segment', () => ({ - useSegmentListKey: 'segment-list-key', - useChildSegmentListKey: 'child-segment-list-key', -})) - -// Mock base service hooks -vi.mock('@/service/use-base', () => ({ - useInvalid: vi.fn(() => vi.fn()), -})) - // Mock metadata hook vi.mock('../../metadata/hooks/use-edit-dataset-metadata', () => ({ default: vi.fn(() => ({ diff --git a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx index 9e5d37de3378e4..b0ab3e348ff7f2 100644 --- a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx @@ -24,8 +24,6 @@ const mocks = vi.hoisted(() => { detailRefetch: vi.fn(), checkProgress: vi.fn(), batchImport: vi.fn(), - invalidSegmentList: vi.fn(), - invalidChildSegmentList: vi.fn(), useDocumentTitle: vi.fn(), toastNotify: vi.fn(), } @@ -71,29 +69,13 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { error: mocks.state.documentError, refetch: mocks.detailRefetch, }, + 'datasets.batchImportStatus.byJobId.get': () => ({ mutateAsync: mocks.checkProgress }), + 'datasets.byDatasetId.documents.byDocumentId.segments.batchImport.post': () => ({ + mutateAsync: mocks.batchImport, + }), }) }) -vi.mock('@/service/knowledge/use-segment', () => ({ - useCheckSegmentBatchImportProgress: () => ({ - mutateAsync: mocks.checkProgress, - }), - useSegmentBatchImport: () => ({ - mutateAsync: mocks.batchImport, - }), - useSegmentListKey: ['segment-list'], - useChildSegmentListKey: ['child-segment-list'], -})) - -vi.mock('@/service/use-base', () => ({ - useInvalid: (key: unknown) => { - const keyStr = JSON.stringify(key) - if (keyStr === JSON.stringify(['segment-list'])) return mocks.invalidSegmentList - if (keyStr === JSON.stringify(['child-segment-list'])) return mocks.invalidChildSegmentList - return vi.fn() - }, -})) - vi.mock('@/app/notifications', () => ({ default: { notify: mocks.toastNotify }, })) @@ -267,6 +249,16 @@ const expectDocumentListInvalidated = () => }), }) +const segmentListKey = consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.get.key({ + input: { params: { dataset_id: 'ds-1', document_id: 'doc-1' } }, +}) +const childChunkListKey = + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.get.key( + { + input: { params: { dataset_id: 'ds-1', document_id: 'doc-1' } }, + }, + ) + // --- Lazy import (after all vi.mock calls) --- const { default: DocumentDetail } = await import('../index') @@ -501,12 +493,18 @@ describe('DocumentDetail', () => { render() fireEvent.click(screen.getByTestId('op-rename')) - expect(mocks.invalidSegmentList).not.toHaveBeenCalled() + expect(QueryClient.prototype.invalidateQueries).not.toHaveBeenCalledWith({ + queryKey: segmentListKey, + }) act(() => { vi.advanceTimersByTime(5000) }) - expect(mocks.invalidSegmentList).toHaveBeenCalled() - expect(mocks.invalidChildSegmentList).toHaveBeenCalled() + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: segmentListKey, + }) + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: childChunkListKey, + }) }) it('should not invalidate chunk lists on operation with no name', () => { @@ -517,7 +515,9 @@ describe('DocumentDetail', () => { act(() => { vi.advanceTimersByTime(5000) }) - expect(mocks.invalidSegmentList).not.toHaveBeenCalled() + expect(QueryClient.prototype.invalidateQueries).not.toHaveBeenCalledWith({ + queryKey: segmentListKey, + }) }) }) @@ -548,8 +548,7 @@ describe('DocumentDetail', () => { expect(mocks.batchImport).toHaveBeenCalledWith( { - datasetId: 'ds-1', - documentId: 'doc-1', + params: { dataset_id: 'ds-1', document_id: 'doc-1' }, body: { upload_file_id: 'file-1' }, }, expect.objectContaining({ @@ -558,6 +557,59 @@ describe('DocumentDetail', () => { }), ) }) + + type JobCallbacks = { onSuccess: (job: { job_id: string; job_status: string }) => void } + + it('should poll the import job until it finishes', async () => { + mocks.batchImport.mockImplementation((_input: unknown, callbacks: JobCallbacks) => + callbacks.onSuccess({ job_id: 'job-1', job_status: 'waiting' }), + ) + mocks.checkProgress + .mockImplementationOnce((_input: unknown, callbacks: JobCallbacks) => + callbacks.onSuccess({ job_id: 'job-1', job_status: 'processing' }), + ) + .mockImplementationOnce((_input: unknown, callbacks: JobCallbacks) => + callbacks.onSuccess({ job_id: 'job-1', job_status: 'completed' }), + ) + render() + fireEvent.click(screen.getByTestId('batch-btn')) + + await act(async () => { + fireEvent.click(screen.getByTestId('batch-confirm')) + }) + await act(async () => { + vi.advanceTimersByTime(2500) + }) + await act(async () => { + vi.advanceTimersByTime(2500) + }) + + expect(mocks.checkProgress).toHaveBeenCalledTimes(2) + expect(mocks.checkProgress).toHaveBeenCalledWith( + { params: { job_id: 'job-1' } }, + expect.any(Object), + ) + }) + + it('should stop polling when the job reports an unknown status', async () => { + mocks.batchImport.mockImplementation((_input: unknown, callbacks: JobCallbacks) => + callbacks.onSuccess({ job_id: 'job-1', job_status: 'waiting' }), + ) + mocks.checkProgress.mockImplementation((_input: unknown, callbacks: JobCallbacks) => + callbacks.onSuccess({ job_id: 'job-1', job_status: 'queued' }), + ) + render() + fireEvent.click(screen.getByTestId('batch-btn')) + + await act(async () => { + fireEvent.click(screen.getByTestId('batch-confirm')) + }) + await act(async () => { + vi.advanceTimersByTime(5000) + }) + + expect(mocks.checkProgress).toHaveBeenCalledTimes(1) + }) }) describe('isFullDocMode', () => { diff --git a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx index b8af9cf95877e5..e64e26f100710a 100644 --- a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx @@ -43,11 +43,14 @@ vi.mock('../completed', () => ({ // Mock useAddSegment const mockAddSegment = vi.fn() -vi.mock('@/service/knowledge/use-segment', () => ({ - useAddSegment: () => ({ - mutateAsync: mockAddSegment, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.byDocumentId.segment.post': () => ({ + mutateAsync: mockAddSegment, + }), + }) +}) vi.mock('../completed/common/action-buttons', () => ({ ActionButtons: ({ @@ -333,8 +336,7 @@ describe('NewSegmentModal', () => { await waitFor(() => { expect(mockAddSegment).toHaveBeenCalledWith( expect.objectContaining({ - datasetId: 'test-dataset-id', - documentId: 'test-document-id', + params: { dataset_id: 'test-dataset-id', document_id: 'test-document-id' }, body: expect.objectContaining({ content: 'Valid content', }), diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx index 5fff6576e2a3a1..eba3633d12f8b3 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx @@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets' +import { consoleQuery } from '@/service/console' import Completed from '../index' import { SegmentListContext, useSegmentListContext } from '../segment-list-context' @@ -21,9 +22,6 @@ const { mockEventEmitter, mockSegmentListData, mockChildSegmentListData, - mockInvalidChunkListAll, - mockInvalidChunkListEnabled, - mockInvalidChunkListDisabled, mockOnChangeSwitch, mockOnDelete, } = vi.hoisted(() => ({ @@ -55,9 +53,6 @@ const { total: 0, total_pages: 0, }, - mockInvalidChunkListAll: vi.fn(), - mockInvalidChunkListEnabled: vi.fn(), - mockInvalidChunkListDisabled: vi.fn(), mockOnChangeSwitch: vi.fn(), mockOnDelete: vi.fn(), })) @@ -86,37 +81,28 @@ vi.mock('@/context/event-emitter', () => ({ useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }), })) -vi.mock('@/service/knowledge/use-segment', () => ({ - useSegmentList: () => ({ - isLoading: false, - data: mockSegmentListData, - }), - useChildSegmentList: () => ({ - isLoading: false, - data: mockChildSegmentListData, - }), - useSegmentListKey: ['segment', 'chunkList'], - useChunkListAllKey: ['segment', 'chunkList', { enabled: 'all' }], - useChunkListEnabledKey: ['segment', 'chunkList', { enabled: true }], - useChunkListDisabledKey: ['segment', 'chunkList', { enabled: false }], - useChildSegmentListKey: ['segment', 'childChunkList'], - useEnableSegment: () => ({ mutateAsync: mockOnChangeSwitch }), - useDisableSegment: () => ({ mutateAsync: mockOnChangeSwitch }), - useDeleteSegment: () => ({ mutateAsync: mockOnDelete }), - useUpdateSegment: () => ({ mutateAsync: vi.fn() }), - useDeleteChildSegment: () => ({ mutateAsync: vi.fn() }), - useUpdateChildSegment: () => ({ mutateAsync: vi.fn() }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.byDocumentId.segments.get': () => ({ + isLoading: false, + data: mockSegmentListData, + }), + 'datasets.byDatasetId.documents.byDocumentId.segment.byAction.patch': () => ({ + mutateAsync: mockOnChangeSwitch, + }), + 'datasets.byDatasetId.documents.byDocumentId.segments.delete': () => ({ + mutateAsync: mockOnDelete, + }), + 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.patch': () => ({ + mutateAsync: vi.fn(), + }), + }) +}) -vi.mock('@/service/use-base', () => ({ - useInvalid: (key: unknown[]) => { - const keyStr = JSON.stringify(key) - if (keyStr.includes('"enabled":"all"')) return mockInvalidChunkListAll - if (keyStr.includes('"enabled":true')) return mockInvalidChunkListEnabled - if (keyStr.includes('"enabled":false')) return mockInvalidChunkListDisabled - return vi.fn() - }, -})) +const segmentListsKey = consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.get.key({ + input: { params: { dataset_id: 'test-dataset-id', document_id: 'test-document-id' } }, +}) let capturedRefreshCallback: (() => void) | null = null vi.mock('../hooks/use-child-segment-data', () => ({ @@ -268,6 +254,9 @@ const createMockSegmentDetail = ( completed_at: 1700000200, error: null, stopped_at: 0, + answer: null, + summary: null, + updated_by: null, updated_at: 1700000000, attachments: [], child_chunks: [], @@ -728,6 +717,9 @@ describe('Batch Action Callbacks', () => { completed_at: 1700000002, error: null, stopped_at: 0, + answer: null, + summary: null, + updated_by: null, updated_at: 1700000003, attachments: [], child_chunks: [], @@ -879,130 +871,39 @@ describe('refreshChunkListDataWithDetailChanged callback', () => { expect(capturedRefreshCallback).toBeDefined() }) - it('should call invalidation functions when triggered with default status "all"', () => { - render(, { wrapper: createWrapper() }) - - // Call the captured callback - status is 'all' by default - if (capturedRefreshCallback) capturedRefreshCallback() - - // With status 'all', should call both disabled and enabled invalidation - expect(mockInvalidChunkListDisabled).toHaveBeenCalled() - expect(mockInvalidChunkListEnabled).toHaveBeenCalled() - }) - - it('should handle multiple callback invocations', () => { - render(, { wrapper: createWrapper() }) - - // Call the captured callback multiple times - if (capturedRefreshCallback) { - capturedRefreshCallback() - capturedRefreshCallback() - capturedRefreshCallback() - } - - // Should be called multiple times - expect(mockInvalidChunkListDisabled).toHaveBeenCalledTimes(3) - expect(mockInvalidChunkListEnabled).toHaveBeenCalledTimes(3) - }) - - it('should call correct invalidation functions when status is changed to enabled', async () => { - // Use general mode which has the status filter - mockDocForm.current = ChunkingModeEnum.text - mockParentMode.current = 'paragraph' - - render(, { wrapper: createWrapper() }) - - // Change status to enabled - const statusEnabledButton = screen.getByTestId('status-enabled') - fireEvent.click(statusEnabledButton) - - // Wait for state to update and re-render - await waitFor(() => { - // The callback should be re-captured with new status - expect(capturedRefreshCallback).toBeDefined() + const expectOtherStatusListsMarkedStale = () => + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: segmentListsKey, + refetchType: 'none', }) - // Call the callback with status 'true' - if (capturedRefreshCallback) capturedRefreshCallback() - - // With status true, should call all and disabled invalidation - expect(mockInvalidChunkListAll).toHaveBeenCalled() - expect(mockInvalidChunkListDisabled).toHaveBeenCalled() - }) - - it('should call correct invalidation functions when status is changed to disabled', async () => { - // Use general mode which has the status filter - mockDocForm.current = ChunkingModeEnum.text - mockParentMode.current = 'paragraph' - + it('should mark the other status lists stale with the default status "all"', () => { + vi.spyOn(QueryClient.prototype, 'invalidateQueries') render(, { wrapper: createWrapper() }) - // Change status to disabled - const statusDisabledButton = screen.getByTestId('status-disabled') - fireEvent.click(statusDisabledButton) + capturedRefreshCallback?.() - // Wait for state to update and re-render - await waitFor(() => { - // The callback should be re-captured with new status - expect(capturedRefreshCallback).toBeDefined() - }) - - // Call the callback with status 'false' - if (capturedRefreshCallback) capturedRefreshCallback() - - // With status false, should call all and enabled invalidation - expect(mockInvalidChunkListAll).toHaveBeenCalled() - expect(mockInvalidChunkListEnabled).toHaveBeenCalled() + expectOtherStatusListsMarkedStale() }) -}) -// refreshChunkListDataWithDetailChanged Branch Coverage Tests - -describe('refreshChunkListDataWithDetailChanged branch coverage', () => { - // This test simulates the behavior of refreshChunkListDataWithDetailChanged - // with different selectedStatus values to ensure branch coverage - - it('should handle status "true" branch correctly', () => { - // Simulate the behavior when selectedStatus is true - const mockInvalidAll = vi.fn() - const mockInvalidDisabled = vi.fn() - - // Create a refreshMap similar to the component - const refreshMap: Record void> = { - true: (() => { - mockInvalidAll() - mockInvalidDisabled() - })!, - } - - // Execute the 'true' branch - // Execute the 'true' branch - refreshMap.true!() - - expect(mockInvalidAll).toHaveBeenCalled() - expect(mockInvalidDisabled).toHaveBeenCalled() - }) - - it('should handle status "false" branch correctly', () => { - // Simulate the behavior when selectedStatus is false - const mockInvalidAll = vi.fn() - const mockInvalidEnabled = vi.fn() - - // Create a refreshMap similar to the component - const refreshMap: Record void> = { - false: (() => { - mockInvalidAll() - mockInvalidEnabled() - })!, - } + it.each(['status-enabled', 'status-disabled'])( + 'should mark the other status lists stale after filtering by %s', + async (statusButton) => { + // General mode has the status filter + mockDocForm.current = ChunkingModeEnum.text + mockParentMode.current = 'paragraph' + vi.spyOn(QueryClient.prototype, 'invalidateQueries') + render(, { wrapper: createWrapper() }) - // Execute the 'false' branch - // Execute the 'false' branch - refreshMap.false!() + fireEvent.click(screen.getByTestId(statusButton)) + await waitFor(() => { + expect(capturedRefreshCallback).toBeDefined() + }) + capturedRefreshCallback?.() - expect(mockInvalidAll).toHaveBeenCalled() - expect(mockInvalidEnabled).toHaveBeenCalled() - }) + expectOtherStatusListsMarkedStale() + }, + ) }) // Batch Action Callback Coverage Tests @@ -1093,46 +994,6 @@ describe('Inline callback and hook initialization coverage', () => { expect(screen.getByTestId('drawer-group'))!.toBeInTheDocument() }) - // Covers lines 74-90: refreshChunkListDataWithDetailChanged with status true - it('should invoke correct invalidation for enabled status', async () => { - render(, { wrapper: createWrapper() }) - - fireEvent.click(screen.getByTestId('status-enabled')) - - await waitFor(() => { - expect(capturedRefreshCallback).toBeDefined() - }) - - mockInvalidChunkListAll.mockClear() - mockInvalidChunkListDisabled.mockClear() - mockInvalidChunkListEnabled.mockClear() - - capturedRefreshCallback!() - - expect(mockInvalidChunkListAll).toHaveBeenCalled() - expect(mockInvalidChunkListDisabled).toHaveBeenCalled() - }) - - // Covers lines 74-90: refreshChunkListDataWithDetailChanged with status false - it('should invoke correct invalidation for disabled status', async () => { - render(, { wrapper: createWrapper() }) - - fireEvent.click(screen.getByTestId('status-disabled')) - - await waitFor(() => { - expect(capturedRefreshCallback).toBeDefined() - }) - - mockInvalidChunkListAll.mockClear() - mockInvalidChunkListDisabled.mockClear() - mockInvalidChunkListEnabled.mockClear() - - capturedRefreshCallback!() - - expect(mockInvalidChunkListAll).toHaveBeenCalled() - expect(mockInvalidChunkListEnabled).toHaveBeenCalled() - }) - // Covers line 101: clearSelection callback it('should clear selection via batch cancel', async () => { render(, { wrapper: createWrapper() }) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx index 4b476364495c82..8c0d24a8dd6a20 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx @@ -39,11 +39,14 @@ vi.mock('../index', () => ({ // Mock useAddChildSegment const mockAddChildSegment = vi.fn() -vi.mock('@/service/knowledge/use-segment', () => ({ - useAddChildSegment: () => ({ - mutateAsync: mockAddChildSegment, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.post': () => ({ + mutateAsync: mockAddChildSegment, + }), + }) +}) vi.mock('../common/action-buttons', () => ({ ActionButtons: ({ @@ -238,9 +241,11 @@ describe('NewChildSegmentModal', () => { await waitFor(() => { expect(mockAddChildSegment).toHaveBeenCalledWith( expect.objectContaining({ - datasetId: 'test-dataset-id', - documentId: 'test-document-id', - segmentId: 'chunk-1', + params: { + dataset_id: 'test-dataset-id', + document_id: 'test-document-id', + segment_id: 'chunk-1', + }, body: expect.objectContaining({ content: 'Valid content', }), diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/permissions.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/permissions.spec.tsx index e596021277ecd8..75fb658241adae 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/permissions.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/permissions.spec.tsx @@ -40,6 +40,9 @@ const segment: SegmentDetailModel = { completed_at: 1700000000, error: null, stopped_at: 0, + answer: null, + summary: null, + updated_by: null, updated_at: 1700000000, attachments: [], child_chunks: [childChunk], diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts index df266158f865c1..036ebf35ea8147 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts @@ -9,6 +9,7 @@ import type { import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook } from '@testing-library/react' import * as React from 'react' +import { consoleQuery } from '@/service/console' import { useChildSegmentData } from '../use-child-segment-data' // Type for mutation callbacks @@ -31,14 +32,13 @@ const { 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() }, + mockQueryClient: { setQueryData: vi.fn(), invalidateQueries: vi.fn() }, mockChildSegmentListData: { current: { data: [] as ChildChunkDetail[], total: 0, total_pages: 0 } as | ChildSegmentsResponse @@ -46,13 +46,22 @@ const { }, mockDeleteChildSegment: vi.fn(), mockUpdateChildSegment: vi.fn(), - mockInvalidChildSegmentList: vi.fn(), })) -vi.mock('@tanstack/react-query', async () => { - const actual = await vi.importActual('@tanstack/react-query') +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return { - ...actual, + ...mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.get': + (options: { enabled?: boolean }) => ({ + isLoading: false, + data: options.enabled === false ? undefined : mockChildSegmentListData.current, + }), + 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.byChildChunkId.delete': + () => ({ mutateAsync: mockDeleteChildSegment }), + 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.byChildChunkId.patch': + () => ({ mutateAsync: mockUpdateChildSegment }), + }), useQueryClient: () => mockQueryClient, } }) @@ -80,19 +89,15 @@ 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 }), -})) +const childChunkListsKey = + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.get.key( + { + input: { params: { dataset_id: 'test-dataset-id', document_id: 'test-document-id' } }, + }, + ) -vi.mock('@/service/use-base', () => ({ - useInvalid: () => mockInvalidChildSegmentList, -})) +const expectChildChunkListsRefetched = () => + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: childChunkListsKey }) const createQueryClient = () => new QueryClient({ @@ -142,6 +147,9 @@ const createMockSegment = (overrides: Partial = {}): Segment completed_at: 1700000200, error: null, stopped_at: 0, + answer: null, + summary: null, + updated_by: null, updated_at: 1700000000, attachments: [], child_chunks: [], @@ -192,7 +200,7 @@ describe('useChildSegmentData', () => { result.current.resetChildList() }) - expect(mockInvalidChildSegmentList).toHaveBeenCalled() + expectChildChunkListsRefetched() }) }) @@ -247,7 +255,7 @@ describe('useChildSegmentData', () => { await result.current.onDeleteChildChunk('seg-1', 'child-1') }) - expect(mockInvalidChildSegmentList).toHaveBeenCalled() + expectChildChunkListsRefetched() }) it('should notify error on failure', async () => { @@ -409,7 +417,7 @@ describe('useChildSegmentData', () => { result.current.onSaveNewChildChunk(createMockChildChunk()) }) - expect(mockInvalidChildSegmentList).toHaveBeenCalled() + expectChildChunkListsRefetched() }) }) @@ -453,12 +461,19 @@ describe('useChildSegmentData', () => { result.current.viewNewlyAddedChildChunk() }) - expect(mockInvalidChildSegmentList).toHaveBeenCalled() + expectChildChunkListsRefetched() }) }) describe('Query disabled states', () => { it('should disable query when not in fullDocMode', () => { + mockChildSegmentListData.current = { + data: [createMockChildChunk()], + total: 1, + total_pages: 1, + page: 1, + limit: 20, + } const { result } = renderHook( () => useChildSegmentData({ @@ -475,6 +490,13 @@ describe('useChildSegmentData', () => { }) it('should disable query when segments is empty', () => { + mockChildSegmentListData.current = { + data: [createMockChildChunk()], + total: 1, + total_pages: 1, + page: 1, + limit: 20, + } const { result } = renderHook( () => useChildSegmentData({ diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts index 54689dd37864f7..eff3a316c65ba2 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts @@ -11,6 +11,7 @@ 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 { consoleQuery } from '@/service/console' import { segmentImportStatus } from '@/types/dataset' import { useSegmentListData } from '../use-segment-list-data' @@ -49,10 +50,6 @@ const { mockDisableSegment, mockDeleteSegment, mockUpdateSegment, - mockInvalidSegmentList, - mockInvalidChunkListAll, - mockInvalidChunkListEnabled, - mockInvalidChunkListDisabled, mockPathname, } = vi.hoisted(() => ({ mockDocForm: { current: 'text' as ChunkingMode }, @@ -61,13 +58,12 @@ const { mockDocumentId: { current: 'test-document-id' }, mockNotify: vi.fn(), mockEventEmitter: { emit: vi.fn(), on: vi.fn(), off: vi.fn() }, - mockQueryClient: { setQueryData: vi.fn() }, + mockQueryClient: { setQueryData: vi.fn(), invalidateQueries: vi.fn() }, mockSegmentListData: { current: { data: [] as SegmentDetailModel[], total: 0, total_pages: 0, - has_more: false, limit: 20, page: 1, } as SegmentsResponse | undefined, @@ -76,10 +72,6 @@ const { 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' }, })) @@ -87,10 +79,27 @@ vi.mock('@/next/navigation', () => ({ usePathname: () => mockPathname.current, })) -vi.mock('@tanstack/react-query', async () => { - const actual = await vi.importActual('@tanstack/react-query') +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return { - ...actual, + ...mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.documents.byDocumentId.segments.get': () => ({ + isLoading: false, + data: mockSegmentListData.current, + }), + 'datasets.byDatasetId.documents.byDocumentId.segment.byAction.patch': () => ({ + mutateAsync: (input: { params: { action: string } }, callbacks: unknown) => + input.params.action === 'enable' + ? mockEnableSegment(input, callbacks) + : mockDisableSegment(input, callbacks), + }), + 'datasets.byDatasetId.documents.byDocumentId.segments.delete': () => ({ + mutateAsync: mockDeleteSegment, + }), + 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.patch': () => ({ + mutateAsync: mockUpdateSegment, + }), + }), useQueryClient: () => mockQueryClient, } }) @@ -118,30 +127,18 @@ 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 }), -})) +const segmentListsKey = consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.get.key({ + input: { params: { dataset_id: 'test-dataset-id', document_id: 'test-document-id' } }, +}) -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 - }, -})) +const expectSegmentListsRefetched = () => + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: segmentListsKey }) + +const expectOtherStatusListsMarkedStale = () => + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: segmentListsKey, + refetchType: 'none', + }) const createQueryClient = () => new QueryClient({ @@ -179,6 +176,9 @@ const createMockSegment = (overrides: Partial = {}): Segment completed_at: 1700000200, error: null, stopped_at: 0, + answer: null, + summary: null, + updated_by: null, updated_at: 1700000000, attachments: [], child_chunks: [], @@ -209,7 +209,6 @@ describe('useSegmentListData', () => { data: [], total: 0, total_pages: 0, - has_more: false, limit: 20, page: 1, } @@ -254,7 +253,6 @@ describe('useSegmentListData', () => { data: [], total: 10, total_pages: 1, - has_more: false, limit: 20, page: 1, } @@ -272,7 +270,6 @@ describe('useSegmentListData', () => { data: [], total: 5, total_pages: 1, - has_more: false, limit: 20, page: 1, } @@ -297,7 +294,6 @@ describe('useSegmentListData', () => { data: [], total: 3, total_pages: 1, - has_more: false, limit: 20, page: 1, } @@ -323,7 +319,6 @@ describe('useSegmentListData', () => { data: [], total: 7, total_pages: 1, - has_more: false, limit: 20, page: 1, } @@ -366,12 +361,12 @@ describe('useSegmentListData', () => { }) expect(clearSelection).toHaveBeenCalled() - expect(mockInvalidSegmentList).toHaveBeenCalled() + expectSegmentListsRefetched() }) }) describe('refreshChunkListWithStatusChanged', () => { - it('should invalidate disabled and enabled when status is all', async () => { + it('should mark the other status lists stale when status is all', async () => { mockEnableSegment.mockImplementation( async (_params, { onSuccess }: { onSuccess: () => void }) => { onSuccess() @@ -389,12 +384,15 @@ describe('useSegmentListData', () => { }, ) + mockQueryClient.invalidateQueries.mockClear() await act(async () => { await result.current.onChangeSwitch(true, 'seg-1') }) - expect(mockInvalidChunkListDisabled).toHaveBeenCalled() - expect(mockInvalidChunkListEnabled).toHaveBeenCalled() + expectOtherStatusListsMarkedStale() + expect(mockQueryClient.invalidateQueries).not.toHaveBeenCalledWith({ + queryKey: segmentListsKey, + }) }) it('should invalidate segment list when status is not all', async () => { @@ -415,11 +413,12 @@ describe('useSegmentListData', () => { }, ) + mockQueryClient.invalidateQueries.mockClear() await act(async () => { await result.current.onChangeSwitch(true, 'seg-1') }) - expect(mockInvalidSegmentList).toHaveBeenCalled() + expectSegmentListsRefetched() }) }) @@ -484,7 +483,7 @@ describe('useSegmentListData', () => { }) expect(mockEnableSegment).toHaveBeenCalledWith( - expect.objectContaining({ segmentIds: ['seg-1', 'seg-2'] }), + expect.objectContaining({ query: { segment_id: ['seg-1', 'seg-2'] } }), expect.any(Object), ) expect(mockNotify).toHaveBeenCalledWith({ @@ -767,7 +766,6 @@ describe('useSegmentListData', () => { data: [], total: 10, total_pages: 1, - has_more: false, limit: 20, page: 1, } @@ -795,7 +793,6 @@ describe('useSegmentListData', () => { data: [], total: 5, total_pages: 1, - has_more: false, limit: 20, page: 1, } @@ -853,7 +850,7 @@ describe('useSegmentListData', () => { // Initial call from effect expect(clearSelection).toHaveBeenCalled() - expect(mockInvalidSegmentList).toHaveBeenCalled() + expectSegmentListsRefetched() }) }) @@ -901,8 +898,7 @@ describe('useSegmentListData', () => { await result.current.handleUpdateSegment('seg-1', 'content', '', [], []) }) - expect(mockInvalidChunkListDisabled).toHaveBeenCalled() - expect(mockInvalidChunkListEnabled).toHaveBeenCalled() + expectOtherStatusListsMarkedStale() }) it('should call correct invalidation for status true', async () => { @@ -928,8 +924,7 @@ describe('useSegmentListData', () => { await result.current.handleUpdateSegment('seg-1', 'content', '', [], []) }) - expect(mockInvalidChunkListAll).toHaveBeenCalled() - expect(mockInvalidChunkListDisabled).toHaveBeenCalled() + expectOtherStatusListsMarkedStale() }) it('should call correct invalidation for status false', async () => { @@ -955,8 +950,7 @@ describe('useSegmentListData', () => { await result.current.handleUpdateSegment('seg-1', 'content', '', [], []) }) - expect(mockInvalidChunkListAll).toHaveBeenCalled() - expect(mockInvalidChunkListEnabled).toHaveBeenCalled() + expectOtherStatusListsMarkedStale() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.ts index 50aded92da4f1c..068e97cdf5e5f0 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.ts @@ -4,18 +4,12 @@ import type { SegmentDetailModel, SegmentUpdater, } from '@/models/datasets' -import { useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' import { useEventEmitterContextContext } from '@/context/event-emitter' -import { - useChildSegmentList, - useChildSegmentListKey, - useDeleteChildSegment, - useUpdateChildSegment, -} from '@/service/knowledge/use-segment' -import { useInvalid } from '@/service/use-base' +import { consoleQuery } from '@/service/console' import { useDocumentContext } from '../../context' type UseChildSegmentDataOptions = { @@ -35,7 +29,7 @@ type UseChildSegmentDataOptions = { type UseChildSegmentDataReturn = { childSegments: ChildChunkDetail[] isLoadingChildSegmentList: boolean - childChunkListData: ReturnType['data'] + childChunkListData: ChildSegmentsResponse | undefined childSegmentListRef: React.RefObject needScrollToBottom: React.RefObject // Operations @@ -81,24 +75,30 @@ export const useChildSegmentData = ( [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, - ) + const childSegmentListQuery = + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.get.queryOptions( + { + input: { + params: { dataset_id: datasetId, document_id: documentId, segment_id: segmentId }, + query: queryParams, + }, + enabled: isFullDocMode && segments.length > 0, + }, + ) + // Query key for optimistic updates + const currentQueryKey = childSegmentListQuery.queryKey + const { isLoading: isLoadingChildSegmentList, data: childChunkListData } = + useQuery(childSegmentListQuery) // Derive child segments from query data const childSegments = useMemo(() => childChunkListData?.data || [], [childChunkListData]) - const invalidChildSegmentList = useInvalid(useChildSegmentListKey) + const invalidChildSegmentList = useCallback(() => { + queryClient.invalidateQueries({ + queryKey: + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.get.key( + { input: { params: { dataset_id: datasetId, document_id: documentId } } }, + ), + }) + }, [queryClient, datasetId, documentId]) // Scroll to bottom when child segments change useEffect(() => { if (childSegmentListRef.current && needScrollToBottom.current) { @@ -126,12 +126,23 @@ export const useChildSegmentData = ( [queryClient, currentQueryKey], ) // Mutations - const { mutateAsync: deleteChildSegment } = useDeleteChildSegment() - const { mutateAsync: updateChildSegment } = useUpdateChildSegment() + const { mutateAsync: deleteChildSegment } = useMutation( + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.byChildChunkId.delete.mutationOptions(), + ) + const { mutateAsync: updateChildSegment } = useMutation( + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.byChildChunkId.patch.mutationOptions(), + ) const onDeleteChildChunk = useCallback( async (segmentIdParam: string, childChunkId: string) => { await deleteChildSegment( - { datasetId, documentId, segmentId: segmentIdParam, childChunkId }, + { + params: { + dataset_id: datasetId, + document_id: documentId, + segment_id: segmentIdParam, + child_chunk_id: childChunkId, + }, + }, { onSuccess: () => { toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) @@ -171,7 +182,15 @@ export const useChildSegmentData = ( params.content = content eventEmitter?.emit('update-child-segment') await updateChildSegment( - { datasetId, documentId, segmentId: segmentIdParam, childChunkId, body: params }, + { + params: { + dataset_id: datasetId, + document_id: documentId, + segment_id: segmentIdParam, + child_chunk_id: childChunkId, + }, + body: params, + }, { onSuccess: (res) => { onCloseChildSegmentDetail() diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts index e702db61890e37..861f7094e9ecf6 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts @@ -1,25 +1,14 @@ import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { SegmentDetailModel, SegmentsResponse, SegmentUpdater } from '@/models/datasets' import type { SegmentImportStatus } from '@/types/dataset' -import { useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' import { useEventEmitterContextContext } from '@/context/event-emitter' import { ChunkingMode } from '@/models/datasets' import { usePathname } from '@/next/navigation' -import { - useChunkListAllKey, - useChunkListDisabledKey, - useChunkListEnabledKey, - useDeleteSegment, - useDisableSegment, - useEnableSegment, - useSegmentList, - useSegmentListKey, - useUpdateSegment, -} from '@/service/knowledge/use-segment' -import { useInvalid } from '@/service/use-base' +import { consoleQuery } from '@/service/console' import { segmentImportStatus } from '@/types/dataset' import { formatNumber } from '@/utils/format' import { useDocumentContext } from '../../context' @@ -38,7 +27,7 @@ type UseSegmentListDataOptions = { type UseSegmentListDataReturn = { segments: SegmentDetailModel[] isLoadingSegmentList: boolean - segmentListData: ReturnType['data'] + segmentListData: SegmentsResponse | undefined totalText: string isFullDocMode: boolean segmentListRef: React.RefObject @@ -58,6 +47,7 @@ type UseSegmentListDataReturn = { resetList: () => void viewNewlyAddedChunk: () => void invalidSegmentList: () => void + markOtherStatusListsStale: () => void updateSegmentInCache: ( segmentId: string, updater: (seg: SegmentDetailModel) => SegmentDetailModel, @@ -99,24 +89,35 @@ export const useSegmentListData = ( }), [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, - }) + const segmentListQuery = + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.get.queryOptions({ + input: { + params: { dataset_id: datasetId, document_id: documentId }, + query: { ...queryParams, enabled: String(queryParams.enabled) }, + }, + }) + // Query key for optimistic updates + const currentQueryKey = segmentListQuery.queryKey + const { isLoading: isLoadingSegmentList, data: segmentListData } = useQuery(segmentListQuery) // 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) + const invalidSegmentList = useCallback(() => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.get.key({ + input: { params: { dataset_id: datasetId, document_id: documentId } }, + }), + }) + }, [queryClient, datasetId, documentId]) + // After an optimistic edit, the lists under the other status filters are stale; the current + // list already shows the change, so it is not refetched. + const markOtherStatusListsStale = useCallback(() => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.get.key({ + input: { params: { dataset_id: datasetId, document_id: documentId } }, + }), + refetchType: 'none', + }) + }, [queryClient, datasetId, documentId]) // Scroll to bottom when needed useEffect(() => { if (segmentListRef.current && needScrollToBottom.current) { @@ -143,31 +144,11 @@ export const useSegmentListData = ( clearSelection() invalidSegmentList() }, [clearSelection, invalidSegmentList]) + // A toggled segment leaves a filtered list, so that list is refetched. const refreshChunkListWithStatusChanged = useCallback(() => { - if (selectedStatus === 'all') { - invalidChunkListDisabled() - invalidChunkListEnabled() - } else { - invalidSegmentList() - } - }, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidSegmentList]) - const refreshChunkListDataWithDetailChanged = useCallback(() => { - const refreshMap: Record void> = { - all: () => { - invalidChunkListDisabled() - invalidChunkListEnabled() - }, - true: () => { - invalidChunkListAll() - invalidChunkListDisabled() - }, - false: () => { - invalidChunkListAll() - invalidChunkListEnabled() - }, - } - refreshMap[String(selectedStatus)]?.() - }, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidChunkListAll]) + if (selectedStatus === 'all') markOtherStatusListsStale() + else invalidSegmentList() + }, [selectedStatus, markOtherStatusListsStale, invalidSegmentList]) // Optimistic update helper using React Query's setQueryData const updateSegmentInCache = useCallback( (segmentId: string, updater: (seg: SegmentDetailModel) => SegmentDetailModel) => { @@ -195,16 +176,27 @@ export const useSegmentListData = ( [queryClient, currentQueryKey], ) // Mutations - const { mutateAsync: enableSegment } = useEnableSegment() - const { mutateAsync: disableSegment } = useDisableSegment() - const { mutateAsync: deleteSegment } = useDeleteSegment() - const { mutateAsync: updateSegment } = useUpdateSegment() + const { mutateAsync: switchSegments } = useMutation( + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segment.byAction.patch.mutationOptions(), + ) + const { mutateAsync: deleteSegment } = useMutation( + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.delete.mutationOptions(), + ) + const { mutateAsync: updateSegment } = useMutation( + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.patch.mutationOptions(), + ) const onChangeSwitch = useCallback( async (enable: boolean, segId?: string) => { - const operationApi = enable ? enableSegment : disableSegment const targetIds = segId ? [segId] : selectedSegmentIds - await operationApi( - { datasetId, documentId, segmentIds: targetIds }, + await switchSegments( + { + params: { + dataset_id: datasetId, + document_id: documentId, + action: enable ? 'enable' : 'disable', + }, + query: { segment_id: targetIds }, + }, { onSuccess: () => { // A single segment is toggled from its switch, which already shows the result. @@ -223,8 +215,7 @@ export const useSegmentListData = ( datasetId, documentId, selectedSegmentIds, - disableSegment, - enableSegment, + switchSegments, t, updateSegmentsInCache, refreshChunkListWithStatusChanged, @@ -234,7 +225,10 @@ export const useSegmentListData = ( async (segId?: string) => { const targetIds = segId ? [segId] : selectedSegmentIds await deleteSegment( - { datasetId, documentId, segmentIds: targetIds }, + { + params: { dataset_id: datasetId, document_id: documentId }, + query: { segment_id: targetIds }, + }, { onSuccess: () => { toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) @@ -292,7 +286,10 @@ export const useSegmentListData = ( if (needRegenerate) params.regenerate_child_chunks = needRegenerate eventEmitter?.emit('update-segment') await updateSegment( - { datasetId, documentId, segmentId, body: params }, + { + params: { dataset_id: datasetId, document_id: documentId, segment_id: segmentId }, + body: params, + }, { onSuccess(res) { toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) @@ -311,7 +308,7 @@ export const useSegmentListData = ( updated_at: res.data.updated_at, child_chunks: res.data.child_chunks, })) - refreshChunkListDataWithDetailChanged() + markOtherStatusListsStale() eventEmitter?.emit('update-segment-success') }, onSettled() { @@ -328,7 +325,7 @@ export const useSegmentListData = ( eventEmitter, onCloseSegmentDetail, updateSegmentInCache, - refreshChunkListDataWithDetailChanged, + markOtherStatusListsStale, t, ], ) @@ -371,6 +368,7 @@ export const useSegmentListData = ( resetList, viewNewlyAddedChunk, invalidSegmentList, + markOtherStatusListsStale, updateSegmentInCache, } } diff --git a/web/app/components/datasets/documents/detail/completed/index.tsx b/web/app/components/datasets/documents/detail/completed/index.tsx index 7d88f4dec09d85..dccd170b5861e2 100644 --- a/web/app/components/datasets/documents/detail/completed/index.tsx +++ b/web/app/components/datasets/documents/detail/completed/index.tsx @@ -7,12 +7,6 @@ import { Pagination } from '@langgenius/dify-ui/pagination' import { Separator } from '@langgenius/dify-ui/separator' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - useChunkListAllKey, - useChunkListDisabledKey, - useChunkListEnabledKey, -} from '@/service/knowledge/use-segment' -import { useInvalid } from '@/service/use-base' import { useDocumentContext } from '../context' import BatchAction from './common/batch-action' import { DrawerGroup } from './components/drawer-group' @@ -70,34 +64,6 @@ const Completed: FC = ({ // Selection state const selectionState = useSegmentSelection() - // Invalidation hooks for child segment data - const invalidChunkListAll = useInvalid(useChunkListAllKey) - const invalidChunkListEnabled = useInvalid(useChunkListEnabledKey) - const invalidChunkListDisabled = useInvalid(useChunkListDisabledKey) - - const refreshChunkListDataWithDetailChanged = useCallback(() => { - const refreshMap: Record void> = { - all: () => { - invalidChunkListDisabled() - invalidChunkListEnabled() - }, - true: () => { - invalidChunkListAll() - invalidChunkListDisabled() - }, - false: () => { - invalidChunkListAll() - invalidChunkListEnabled() - }, - } - refreshMap[String(searchFilter.selectedStatus)]?.() - }, [ - searchFilter.selectedStatus, - invalidChunkListDisabled, - invalidChunkListEnabled, - invalidChunkListAll, - ]) - // Segment list data const segmentListDataHook = useSegmentListData({ searchValue: searchFilter.searchValue, @@ -142,7 +108,7 @@ const Completed: FC = ({ currChunkId: modalState.currChunkId, isFullDocMode: segmentListDataHook.isFullDocMode, onCloseChildSegmentDetail: modalState.onCloseChildSegmentDetail, - refreshChunkListDataWithDetailChanged, + refreshChunkListDataWithDetailChanged: segmentListDataHook.markOtherStatusListsStale, updateSegmentInCache: segmentListDataHook.updateSegmentInCache, }) diff --git a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx index ca125802639487..fb7f20463c8069 100644 --- a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx +++ b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx @@ -3,12 +3,13 @@ import type { ChildChunkDetail, SegmentUpdater } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' import { Separator } from '@langgenius/dify-ui/separator' import { RiCloseLine, RiExpandDiagonalLine } from '@remixicon/react' +import { useMutation } from '@tanstack/react-query' import { memo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' import { ChunkingMode } from '@/models/datasets' import { useParams } from '@/next/navigation' -import { useAddChildSegment } from '@/service/knowledge/use-segment' +import { consoleQuery } from '@/service/console' import { formatNumber } from '@/utils/format' import { useDocumentContext } from '../context' import { ActionButtons } from './common/action-buttons' @@ -47,7 +48,9 @@ const NewChildSegmentModal: FC = ({ if (actionType === 'esc' || !addAnother) onCancel() } - const { mutateAsync: addChildSegment } = useAddChildSegment() + const { mutateAsync: addChildSegment } = useMutation( + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.post.mutationOptions(), + ) const handleSave = async () => { const params: SegmentUpdater = { content: '' } @@ -59,7 +62,10 @@ const NewChildSegmentModal: FC = ({ setLoading(true) await addChildSegment( - { datasetId, documentId, segmentId: chunkId, body: params }, + { + params: { dataset_id: datasetId, document_id: documentId, segment_id: chunkId }, + body: params, + }, { onSuccess(res) { toast.success( diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx index 9b87c5ac2e9e64..f32adf16b997de 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx @@ -143,6 +143,9 @@ const createMockSegmentDetail = ( completed_at: 1700000200, error: null, stopped_at: 0, + answer: null, + summary: null, + updated_by: null, updated_at: 1700000000, attachments: [], child_chunks: [], diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx index d863dee4cdd089..0870dfaea24ac5 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx @@ -131,7 +131,8 @@ const SegmentCard: FC = ({ const images = useMemo(() => { return attachments.map((attachment) => ({ name: attachment.name, - mimeType: attachment.mime_type, + // An unknown MIME type is empty, as in File.type. + mimeType: attachment.mime_type ?? '', sourceUrl: attachment.source_url, size: attachment.size, extension: attachment.extension, @@ -276,7 +277,7 @@ const SegmentCard: FC = ({
= ({ datasetId, documentId }) => { const hideBatchModal = () => setBatchModalVisible(false) const resetImportStatus = () => setImportStatus(undefined) - const { mutateAsync: checkSegmentBatchImportProgress } = useCheckSegmentBatchImportProgress() + const { mutateAsync: checkSegmentBatchImportProgress } = useMutation( + consoleQuery.datasets.batchImportStatus.byJobId.get.mutationOptions(), + ) const checkProcess = async (jobID: string) => { await checkSegmentBatchImportProgress( - { jobID }, + { params: { job_id: jobID } }, { onSuccess: (res) => { - setImportStatus(res.job_status) - if ( - res.job_status === segmentImportStatus.waiting || - res.job_status === segmentImportStatus.processing - ) + const status = isSegmentImportStatus(res.job_status) ? res.job_status : undefined + setImportStatus(status) + if (status === segmentImportStatus.waiting || status === segmentImportStatus.processing) setTimeout(() => checkProcess(res.job_id), 2500) - if (res.job_status === segmentImportStatus.error) + if (status === segmentImportStatus.error) toast.error(`${t(($) => $['list.batchModal.runError'], { ns: 'datasetDocuments' })}`) }, onError: (e) => { @@ -109,17 +102,18 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => { ) } - const { mutateAsync: segmentBatchImport } = useSegmentBatchImport() + const { mutateAsync: segmentBatchImport } = useMutation( + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.batchImport.post.mutationOptions(), + ) const runBatch = async (csv: FileItem) => { await segmentBatchImport( { - datasetId, - documentId, + params: { dataset_id: datasetId, document_id: documentId }, body: { upload_file_id: csv.file.id! }, }, { onSuccess: (res) => { - setImportStatus(res.job_status) + setImportStatus(isSegmentImportStatus(res.job_status) ? res.job_status : undefined) checkProcess(res.job_id) }, onError: (e) => { @@ -177,8 +171,6 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => { : undefined const embedding = !!displayStatus && NON_TERMINAL_DISPLAY_STATUSES.has(displayStatus) - const invalidChunkList = useInvalid(useSegmentListKey) - const invalidChildChunkList = useInvalid(useChildSegmentListKey) const queryClient = useQueryClient() const invalidDocumentList = useCallback( () => @@ -200,13 +192,23 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => { // If operation is not rename, refresh the chunk list after 5 seconds if (operateName) { setTimeout(() => { - invalidChunkList() - invalidChildChunkList() + const params = { dataset_id: datasetId, document_id: documentId } + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.get.key({ + input: { params }, + }), + }) + queryClient.invalidateQueries({ + queryKey: + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.get.key( + { input: { params } }, + ), + }) }, 5000) } } }, - [invalidDocumentList, backToPrev, detailMutate, invalidChunkList, invalidChildChunkList], + [invalidDocumentList, backToPrev, detailMutate, queryClient, datasetId, documentId], ) const parentMode = useMemo(() => { diff --git a/web/app/components/datasets/documents/detail/new-segment.tsx b/web/app/components/datasets/documents/detail/new-segment.tsx index 2f3f1c32716d2c..b8f4683a45e3b9 100644 --- a/web/app/components/datasets/documents/detail/new-segment.tsx +++ b/web/app/components/datasets/documents/detail/new-segment.tsx @@ -4,6 +4,7 @@ import type { SegmentUpdater } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' import { Separator } from '@langgenius/dify-ui/separator' import { RiCloseLine, RiExpandDiagonalLine } from '@remixicon/react' +import { useMutation } from '@tanstack/react-query' import { memo, useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import ImageUploaderInChunk from '@/app/components/datasets/common/image-uploader/image-uploader-in-chunk' @@ -11,7 +12,7 @@ import { toast } from '@/app/notifications' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { ChunkingMode } from '@/models/datasets' import { useParams } from '@/next/navigation' -import { useAddSegment } from '@/service/knowledge/use-segment' +import { consoleQuery } from '@/service/console' import { formatNumber } from '@/utils/format' import { IndexingType } from '../../create/step-two' import { useSegmentListContext } from './completed' @@ -62,7 +63,9 @@ const NewSegmentModal: FC = ({ setAttachments(attachments) }, []) - const { mutateAsync: addSegment } = useAddSegment() + const { mutateAsync: addSegment } = useMutation( + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segment.post.mutationOptions(), + ) const handleSave = useCallback(async () => { const params: SegmentUpdater = { content: '', attachment_ids: [] } @@ -93,7 +96,7 @@ const NewSegmentModal: FC = ({ setLoading(true) await addSegment( - { datasetId, documentId, body: params }, + { params: { dataset_id: datasetId, document_id: documentId }, body: params }, { onSuccess() { toast.success( diff --git a/web/app/components/datasets/documents/index.tsx b/web/app/components/datasets/documents/index.tsx index a8bb2f51b02d77..3e01e90e768b43 100644 --- a/web/app/components/datasets/documents/index.tsx +++ b/web/app/components/datasets/documents/index.tsx @@ -11,8 +11,6 @@ import { userProfileQueryOptions } from '@/features/account-profile/client' import { DataSourceType } from '@/models/datasets' import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/console' -import { useChildSegmentListKey, useSegmentListKey } from '@/service/knowledge/use-segment' -import { useInvalid } from '@/service/use-base' import { getDatasetACLCapabilities } from '@/utils/permission' import useEditDocumentMetadata from '../metadata/hooks/use-edit-dataset-metadata' import DocumentsHeader from './components/documents-header' @@ -103,8 +101,6 @@ const Documents: FC = ({ datasetId }) => { }), [queryClient, datasetId], ) - const invalidChunkList = useInvalid(useSegmentListKey) - const invalidChildChunkList = useInvalid(useChildSegmentListKey) const handleUpdate = useCallback(() => { invalidDocumentList() @@ -112,10 +108,20 @@ const Documents: FC = ({ datasetId }) => { queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.get.key(), }) setTimeout(() => { - invalidChunkList() - invalidChildChunkList() + const params = { dataset_id: datasetId } + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.get.key({ + input: { params }, + }), + }) + queryClient.invalidateQueries({ + queryKey: + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.get.key( + { input: { params } }, + ), + }) }, 5000) - }, [queryClient, invalidDocumentList, invalidChunkList, invalidChildChunkList]) + }, [queryClient, datasetId, invalidDocumentList]) // Metadata editing hook const { diff --git a/web/models/datasets.ts b/web/models/datasets.ts index d0051c7e2bfe27..29392da7643310 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -1,5 +1,8 @@ import type { AppMode } from '@dify/contracts/api/console/apps/types.gen' import type { + ChildChunkListResponse, + ChildChunkResponse, + ConsoleSegmentListResponse, DocumentDetailResponse as ContractDocumentDetailResponse, DocumentWithSegmentsListResponse, DocumentWithSegmentsResponse, @@ -10,6 +13,7 @@ import type { HitTestingRecord as HitTestingRecordResponse, NotionInfo as NotionInfoPayload, PreProcessingRule as PreProcessingRulePayload, + SegmentResponse, } from '@dify/contracts/api/console/datasets/types.gen' import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen' import type { DataSourceNotionPage, DataSourceProvider } from './common' @@ -17,7 +21,6 @@ import type { DatasourceType } from './pipeline' import type { IndexingType } from '@/app/components/datasets/create/step-two' import type { MetadataFilteringVariableType } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import type { AppIconType, RetrievalConfig, TransferMethod } from '@/types/app' -import type { SegmentImportStatus } from '@/types/dataset' import type { I18nKeysByPrefix } from '@/types/i18n' import { PermissionLevel } from './permission' @@ -480,8 +483,6 @@ type CustomizableDocType = type FixedDocType = 'synced_from_github' | 'synced_from_notion' | 'wikipedia_entry' export type DocType = CustomizableDocType | FixedDocType -type SegmentStatus = 'waiting' | 'completed' | 'error' | 'indexing' - export type Attachment = { id: string name: string @@ -491,43 +492,9 @@ export type Attachment = { source_url: string } -export type SegmentDetailModel = { - id: string - position: number - document_id: string - content: string - sign_content: string - word_count: number - tokens: number - keywords: string[] - index_node_id: string - index_node_hash: string - hit_count: number - enabled: boolean - disabled_at: number - disabled_by: string - status: SegmentStatus - created_by: string - created_at: number - indexing_at: number - completed_at: number - error: string | null - stopped_at: number - answer?: string - summary?: string - child_chunks?: ChildChunkDetail[] - updated_at: number - attachments: Attachment[] -} +export type SegmentDetailModel = SegmentResponse -export type SegmentsResponse = { - data: SegmentDetailModel[] - has_more: boolean - limit: number - total: number - total_pages: number - page: number -} +export type SegmentsResponse = ConsoleSegmentListResponse export type Query = { content: string @@ -624,26 +591,9 @@ export const DEFAULT_WEIGHTED_SCORE = { }, } -type ChildChunkType = 'automatic' | 'customized' - -export type ChildChunkDetail = { - id: string - position: number - segment_id: string - content: string - word_count: number - created_at: number - updated_at: number - type: ChildChunkType -} +export type ChildChunkDetail = ChildChunkResponse -export type ChildSegmentsResponse = { - data: ChildChunkDetail[] - total: number - total_pages: number - page: number - limit: number -} +export type ChildSegmentsResponse = ChildChunkListResponse // Used in api url export const DocumentActionType = { @@ -657,11 +607,6 @@ export const DocumentActionType = { export type DocumentActionType = (typeof DocumentActionType)[keyof typeof DocumentActionType] -export type BatchImportResponse = { - job_id: string - job_status: SegmentImportStatus -} - export const DOC_FORM_ICON_CLASS_WITH_BG: Record = { [ChunkingMode.text]: 'i-custom-public-knowledge-dataset-card-general', [ChunkingMode.qa]: 'i-custom-public-knowledge-dataset-card-qa', diff --git a/web/service/knowledge/use-segment.ts b/web/service/knowledge/use-segment.ts deleted file mode 100644 index ad371f24d30dd7..00000000000000 --- a/web/service/knowledge/use-segment.ts +++ /dev/null @@ -1,260 +0,0 @@ -import type { - BatchImportResponse, - ChildChunkDetail, - ChildSegmentsResponse, - ChunkingMode, - SegmentDetailModel, - SegmentsResponse, - SegmentUpdater, -} from '@/models/datasets' -import { useMutation, useQuery } from '@tanstack/react-query' -import { consoleClient } from '../console' -import { narrowResponse } from '../console/narrow-response' - -const NAME_SPACE = 'segment' - -export const useSegmentListKey = [NAME_SPACE, 'chunkList'] -export const useChunkListEnabledKey = [NAME_SPACE, 'chunkList', { enabled: true }] -export const useChunkListDisabledKey = [NAME_SPACE, 'chunkList', { enabled: false }] -export const useChunkListAllKey = [NAME_SPACE, 'chunkList', { enabled: 'all' }] - -export const useSegmentList = ( - payload: { - datasetId: string - documentId: string - params: { - page: number - limit: number - keyword: string - enabled: boolean | 'all' | '' - } - }, - disable?: boolean, -) => { - const { datasetId, documentId, params } = payload - - return useQuery({ - queryKey: [...useSegmentListKey, datasetId, documentId, params], - queryFn: () => { - return narrowResponse( - consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.get({ - params: { dataset_id: datasetId, document_id: documentId }, - query: { ...params, enabled: String(params.enabled) }, - }), - ) - }, - enabled: !disable, - }) -} - -export const useUpdateSegment = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'update'], - mutationFn: (payload: { - datasetId: string - documentId: string - segmentId: string - body: SegmentUpdater - }) => { - const { datasetId, documentId, segmentId, body } = payload - return narrowResponse<{ data: SegmentDetailModel; doc_form: ChunkingMode }>( - consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.patch({ - params: { dataset_id: datasetId, document_id: documentId, segment_id: segmentId }, - body, - }), - ) - }, - }) -} - -export const useAddSegment = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'add'], - mutationFn: (payload: { datasetId: string; documentId: string; body: SegmentUpdater }) => { - const { datasetId, documentId, body } = payload - return narrowResponse<{ data: SegmentDetailModel; doc_form: ChunkingMode }>( - consoleClient.datasets.byDatasetId.documents.byDocumentId.segment.post({ - params: { dataset_id: datasetId, document_id: documentId }, - body, - }), - ) - }, - }) -} - -export const useEnableSegment = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'enable'], - mutationFn: (payload: { datasetId: string; documentId: string; segmentIds: string[] }) => { - const { datasetId, documentId, segmentIds } = payload - return consoleClient.datasets.byDatasetId.documents.byDocumentId.segment.byAction.patch({ - params: { dataset_id: datasetId, document_id: documentId, action: 'enable' }, - query: { segment_id: segmentIds }, - }) - }, - }) -} - -export const useDisableSegment = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'disable'], - mutationFn: (payload: { datasetId: string; documentId: string; segmentIds: string[] }) => { - const { datasetId, documentId, segmentIds } = payload - return consoleClient.datasets.byDatasetId.documents.byDocumentId.segment.byAction.patch({ - params: { dataset_id: datasetId, document_id: documentId, action: 'disable' }, - query: { segment_id: segmentIds }, - }) - }, - }) -} - -export const useDeleteSegment = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'delete'], - mutationFn: (payload: { datasetId: string; documentId: string; segmentIds: string[] }) => { - const { datasetId, documentId, segmentIds } = payload - return consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.delete({ - params: { dataset_id: datasetId, document_id: documentId }, - query: { segment_id: segmentIds }, - }) - }, - }) -} - -export const useChildSegmentListKey = [NAME_SPACE, 'childChunkList'] - -export const useChildSegmentList = ( - payload: { - datasetId: string - documentId: string - segmentId: string - params: { - page: number - limit: number - keyword: string - } - }, - disable?: boolean, -) => { - const { datasetId, documentId, segmentId, params } = payload - - return useQuery({ - queryKey: [...useChildSegmentListKey, datasetId, documentId, segmentId, params], - queryFn: () => { - return narrowResponse( - consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.get( - { - params: { dataset_id: datasetId, document_id: documentId, segment_id: segmentId }, - query: params, - }, - ), - ) - }, - enabled: !disable, - }) -} - -export const useDeleteChildSegment = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'childChunk', 'delete'], - mutationFn: (payload: { - datasetId: string - documentId: string - segmentId: string - childChunkId: string - }) => { - const { datasetId, documentId, segmentId, childChunkId } = payload - return consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.byChildChunkId.delete( - { - params: { - dataset_id: datasetId, - document_id: documentId, - segment_id: segmentId, - child_chunk_id: childChunkId, - }, - }, - ) - }, - }) -} - -export const useAddChildSegment = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'childChunk', 'add'], - mutationFn: (payload: { - datasetId: string - documentId: string - segmentId: string - body: { content: string } - }) => { - const { datasetId, documentId, segmentId, body } = payload - return narrowResponse<{ data: ChildChunkDetail }>( - consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.post( - { - params: { dataset_id: datasetId, document_id: documentId, segment_id: segmentId }, - body, - }, - ), - ) - }, - }) -} - -export const useUpdateChildSegment = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'childChunk', 'update'], - mutationFn: (payload: { - datasetId: string - documentId: string - segmentId: string - childChunkId: string - body: { content: string } - }) => { - const { datasetId, documentId, segmentId, childChunkId, body } = payload - return narrowResponse<{ data: ChildChunkDetail }>( - consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.byChildChunkId.patch( - { - params: { - dataset_id: datasetId, - document_id: documentId, - segment_id: segmentId, - child_chunk_id: childChunkId, - }, - body, - }, - ), - ) - }, - }) -} - -export const useSegmentBatchImport = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'batchImport'], - mutationFn: (payload: { - datasetId: string - documentId: string - body: { upload_file_id: string } - }) => { - const { datasetId, documentId, body } = payload - return narrowResponse( - consoleClient.datasets.byDatasetId.documents.byDocumentId.segments.batchImport.post({ - params: { dataset_id: datasetId, document_id: documentId }, - body, - }), - ) - }, - }) -} - -export const useCheckSegmentBatchImportProgress = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'batchImport', 'checkProgress'], - mutationFn: (payload: { jobID: string }) => { - const { jobID } = payload - return narrowResponse( - consoleClient.datasets.batchImportStatus.byJobId.get({ params: { job_id: jobID } }), - ) - }, - }) -} diff --git a/web/types/dataset.ts b/web/types/dataset.ts index 637c9df706a3b2..b063dd138871a2 100644 --- a/web/types/dataset.ts +++ b/web/types/dataset.ts @@ -6,3 +6,6 @@ export const segmentImportStatus = { } as const export type SegmentImportStatus = (typeof segmentImportStatus)[keyof typeof segmentImportStatus] + +export const isSegmentImportStatus = (value: string): value is SegmentImportStatus => + Object.values(segmentImportStatus).includes(value) From 5bacd67a36c957180ff9fdfc388b536cfe717ddf Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:17:48 +0800 Subject: [PATCH 49/60] fix(api): keep segment response schemas free of model imports The knowledge response schema import contract forbids fields.segment_fields from importing models. Declare the segment status and type as Literal values mirroring models.enums. Co-Authored-By: Claude Opus 5.5 --- api/fields/segment_fields.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/fields/segment_fields.py b/api/fields/segment_fields.py index aeb01ea8a90276..76765e981cb8b0 100644 --- a/api/fields/segment_fields.py +++ b/api/fields/segment_fields.py @@ -1,10 +1,14 @@ from datetime import datetime +from typing import Literal from pydantic import field_serializer from fields.base import ResponseModel from libs.helper import to_timestamp -from models.enums import SegmentStatus, SegmentType + +# Mirrors models.enums.SegmentStatus / SegmentType; response schemas do not import models. +SegmentStatus = Literal["waiting", "indexing", "completed", "error", "paused", "re_segment"] +SegmentType = Literal["automatic", "customized"] class SegmentAttachmentResponse(ResponseModel): From 15c81f3771e5f7e63c4b07dadd3f512545305151 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:22:26 +0000 Subject: [PATCH 50/60] [autofix.ci] apply automated fixes --- api/openapi/markdown/console-openapi.md | 20 +--- api/openapi/markdown/service-openapi.md | 20 +--- .../api/console/datasets/types.gen.ts | 8 +- .../generated/api/console/datasets/zod.gen.ts | 101 +++++++----------- .../generated/api/service/types.gen.ts | 8 +- .../generated/api/service/zod.gen.ts | 87 ++++++--------- 6 files changed, 81 insertions(+), 163 deletions(-) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 4a319c8eb9ccf3..08ac62bf4a8a42 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -19214,7 +19214,7 @@ Create child chunk index failed: {message} | id | string | | Yes | | position | integer | | Yes | | segment_id | string | | Yes | -| type | [SegmentType](#segmenttype) | | Yes | +| type | string,
**Available values:** "automatic", "customized" | *Enum:* `"automatic"`, `"customized"` | Yes | | updated_at | integer | | Yes | | word_count | integer | | Yes | @@ -25987,7 +25987,7 @@ The Roster Agent package exceeds the size limit. | keywords | [ string ] | | Yes | | position | integer | | Yes | | sign_content | string | | Yes | -| status | [SegmentStatus](#segmentstatus) | | Yes | +| status | string,
**Available values:** "completed", "error", "indexing", "paused", "re_segment", "waiting" | *Enum:* `"completed"`, `"error"`, `"indexing"`, `"paused"`, `"re_segment"`, `"waiting"` | Yes | | stopped_at | integer | | Yes | | summary | string | | Yes | | tokens | integer | | Yes | @@ -25995,22 +25995,6 @@ The Roster Agent package exceeds the size limit. | updated_by | string | | Yes | | word_count | integer | | Yes | -#### SegmentStatus - -Document segment status - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| SegmentStatus | string | Document segment status | | - -#### SegmentType - -Document segment type - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| SegmentType | string | Document segment type | | - #### SegmentUpdatePayload | Name | Type | Description | Required | diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index e3f33ba317e2e9..2f58cf0c815e90 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -2769,7 +2769,7 @@ Public pause reason emitted by a blocking Chatflow execution. | id | string | | Yes | | position | integer | | Yes | | segment_id | string | | Yes | -| type | [SegmentType](#segmenttype) | | Yes | +| type | string,
**Available values:** "automatic", "customized" | *Enum:* `"automatic"`, `"customized"` | Yes | | updated_at | integer | | Yes | | word_count | integer | | Yes | @@ -4413,7 +4413,7 @@ Rate Limit Error | keywords | [ string ] | | Yes | | position | integer | | Yes | | sign_content | string | | Yes | -| status | [SegmentStatus](#segmentstatus) | | Yes | +| status | string,
**Available values:** "completed", "error", "indexing", "paused", "re_segment", "waiting" | *Enum:* `"completed"`, `"error"`, `"indexing"`, `"paused"`, `"re_segment"`, `"waiting"` | Yes | | stopped_at | integer | | Yes | | summary | string | | Yes | | tokens | integer | | Yes | @@ -4421,22 +4421,6 @@ Rate Limit Error | updated_by | string | | Yes | | word_count | integer | | Yes | -#### SegmentStatus - -Document segment status - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| SegmentStatus | string | Document segment status | | - -#### SegmentType - -Document segment type - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| SegmentType | string | Document segment type | | - #### SegmentUpdateArgs | Name | Type | Description | Required | diff --git a/packages/contracts/generated/api/console/datasets/types.gen.ts b/packages/contracts/generated/api/console/datasets/types.gen.ts index 1898975f26bc17..ada304734326c7 100644 --- a/packages/contracts/generated/api/console/datasets/types.gen.ts +++ b/packages/contracts/generated/api/console/datasets/types.gen.ts @@ -895,7 +895,7 @@ export type SegmentResponse = { keywords: Array | null position: number sign_content: string - status: SegmentStatus + status: 'completed' | 'error' | 'indexing' | 'paused' | 're_segment' | 'waiting' stopped_at: number | null summary: string | null tokens: number @@ -910,7 +910,7 @@ export type ChildChunkResponse = { id: string position: number segment_id: string - type: SegmentType + type: 'automatic' | 'customized' updated_at: number word_count: number } @@ -1080,10 +1080,6 @@ export type SegmentAttachmentResponse = { source_url: string } -export type SegmentStatus = 'completed' | 'error' | 'indexing' | 'paused' | 're_segment' | 'waiting' - -export type SegmentType = 'automatic' | 'customized' - export type HitTestingChildChunk = { content: string id: string diff --git a/packages/contracts/generated/api/console/datasets/zod.gen.ts b/packages/contracts/generated/api/console/datasets/zod.gen.ts index 9214e67b6386dc..c803b37a4494a4 100644 --- a/packages/contracts/generated/api/console/datasets/zod.gen.ts +++ b/packages/contracts/generated/api/console/datasets/zod.gen.ts @@ -725,6 +725,45 @@ export const zDocumentMetadataResponse = z.object({ value: z.union([z.string(), z.int(), z.number()]).nullable(), }) +/** + * ChildChunkResponse + */ +export const zChildChunkResponse = z.object({ + content: z.string(), + created_at: z.int(), + id: z.string(), + position: z.int(), + segment_id: z.string(), + type: z.enum(['automatic', 'customized']), + updated_at: z.int(), + word_count: z.int(), +}) + +/** + * ChildChunkListResponse + */ +export const zChildChunkListResponse = z.object({ + data: z.array(zChildChunkResponse), + limit: z.int(), + page: z.int(), + total: z.int(), + total_pages: z.int(), +}) + +/** + * ChildChunkBatchUpdateResponse + */ +export const zChildChunkBatchUpdateResponse = z.object({ + data: z.array(zChildChunkResponse), +}) + +/** + * ChildChunkDetailResponse + */ +export const zChildChunkDetailResponse = z.object({ + data: zChildChunkResponse, +}) + /** * ChildChunkUpdateArgs */ @@ -980,66 +1019,6 @@ export const zSegmentAttachmentResponse = z.object({ source_url: z.string(), }) -/** - * SegmentStatus - * - * Document segment status - */ -export const zSegmentStatus = z.enum([ - 'completed', - 'error', - 'indexing', - 'paused', - 're_segment', - 'waiting', -]) - -/** - * SegmentType - * - * Document segment type - */ -export const zSegmentType = z.enum(['automatic', 'customized']) - -/** - * ChildChunkResponse - */ -export const zChildChunkResponse = z.object({ - content: z.string(), - created_at: z.int(), - id: z.string(), - position: z.int(), - segment_id: z.string(), - type: zSegmentType, - updated_at: z.int(), - word_count: z.int(), -}) - -/** - * ChildChunkListResponse - */ -export const zChildChunkListResponse = z.object({ - data: z.array(zChildChunkResponse), - limit: z.int(), - page: z.int(), - total: z.int(), - total_pages: z.int(), -}) - -/** - * ChildChunkBatchUpdateResponse - */ -export const zChildChunkBatchUpdateResponse = z.object({ - data: z.array(zChildChunkResponse), -}) - -/** - * ChildChunkDetailResponse - */ -export const zChildChunkDetailResponse = z.object({ - data: zChildChunkResponse, -}) - /** * SegmentResponse */ @@ -1064,7 +1043,7 @@ export const zSegmentResponse = z.object({ keywords: z.array(z.string()).nullable(), position: z.int(), sign_content: z.string(), - status: zSegmentStatus, + status: z.enum(['completed', 'error', 'indexing', 'paused', 're_segment', 'waiting']), stopped_at: z.int().nullable(), summary: z.string().nullable(), tokens: z.int(), diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index a5d0c739b84522..0c6ab2a2cee3bc 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -354,7 +354,7 @@ export type ChildChunkResponse = { id: string position: number segment_id: string - type: SegmentType + type: 'automatic' | 'customized' updated_at: number word_count: number } @@ -2120,7 +2120,7 @@ export type SegmentResponse = { keywords: Array | null position: number sign_content: string - status: SegmentStatus + status: 'completed' | 'error' | 'indexing' | 'paused' | 're_segment' | 'waiting' stopped_at: number | null summary: string | null tokens: number @@ -2129,10 +2129,6 @@ export type SegmentResponse = { word_count: number } -export type SegmentStatus = 'completed' | 'error' | 'indexing' | 'paused' | 're_segment' | 'waiting' - -export type SegmentType = 'automatic' | 'customized' - export type SegmentUpdateArgs = { answer?: string | null attachment_ids?: Array | null diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index c336227097f178..5d9f787e814236 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -323,6 +323,38 @@ export const zChildChunkListQuery = z.object({ page: z.int().gte(1).optional().default(1), }) +/** + * ChildChunkResponse + */ +export const zChildChunkResponse = z.object({ + content: z.string(), + created_at: z.int(), + id: z.string(), + position: z.int(), + segment_id: z.string(), + type: z.enum(['automatic', 'customized']), + updated_at: z.int(), + word_count: z.int(), +}) + +/** + * ChildChunkDetailResponse + */ +export const zChildChunkDetailResponse = z.object({ + data: zChildChunkResponse, +}) + +/** + * ChildChunkListResponse + */ +export const zChildChunkListResponse = z.object({ + data: z.array(zChildChunkResponse), + limit: z.int(), + page: z.int(), + total: z.int(), + total_pages: z.int(), +}) + /** * ChildChunkUpdatePayload */ @@ -2168,59 +2200,6 @@ export const zSegmentListQuery = z.object({ status: z.array(z.string()).optional(), }) -/** - * SegmentStatus - * - * Document segment status - */ -export const zSegmentStatus = z.enum([ - 'completed', - 'error', - 'indexing', - 'paused', - 're_segment', - 'waiting', -]) - -/** - * SegmentType - * - * Document segment type - */ -export const zSegmentType = z.enum(['automatic', 'customized']) - -/** - * ChildChunkResponse - */ -export const zChildChunkResponse = z.object({ - content: z.string(), - created_at: z.int(), - id: z.string(), - position: z.int(), - segment_id: z.string(), - type: zSegmentType, - updated_at: z.int(), - word_count: z.int(), -}) - -/** - * ChildChunkDetailResponse - */ -export const zChildChunkDetailResponse = z.object({ - data: zChildChunkResponse, -}) - -/** - * ChildChunkListResponse - */ -export const zChildChunkListResponse = z.object({ - data: z.array(zChildChunkResponse), - limit: z.int(), - page: z.int(), - total: z.int(), - total_pages: z.int(), -}) - /** * SegmentResponse */ @@ -2245,7 +2224,7 @@ export const zSegmentResponse = z.object({ keywords: z.array(z.string()).nullable(), position: z.int(), sign_content: z.string(), - status: zSegmentStatus, + status: z.enum(['completed', 'error', 'indexing', 'paused', 're_segment', 'waiting']), stopped_at: z.int().nullable(), summary: z.string().nullable(), tokens: z.int(), From 83d5a7454a7efd14bf1c6dc74f0711f04149cabc Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:30:43 +0800 Subject: [PATCH 51/60] chore(web): drop the test-only DocumentListResponse alias Co-Authored-By: Claude Opus 5.5 --- web/app/components/datasets/documents/__tests__/index.spec.tsx | 2 +- web/models/datasets.ts | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/web/app/components/datasets/documents/__tests__/index.spec.tsx b/web/app/components/datasets/documents/__tests__/index.spec.tsx index ae7be9b2b8b42b..925b6da12274e5 100644 --- a/web/app/components/datasets/documents/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ +import type { DocumentWithSegmentsListResponse as DocumentListResponse } from '@dify/contracts/api/console/datasets/types.gen' import type { ReactNode } from 'react' -import type { DocumentListResponse } from '@/models/datasets' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, screen } from '@testing-library/react' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' diff --git a/web/models/datasets.ts b/web/models/datasets.ts index 29392da7643310..058191f2760c2e 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -4,7 +4,6 @@ import type { ChildChunkResponse, ConsoleSegmentListResponse, DocumentDetailResponse as ContractDocumentDetailResponse, - DocumentWithSegmentsListResponse, DocumentWithSegmentsResponse, ExternalHitTestingRecordResponse, ExternalHitTestingResponse, @@ -420,8 +419,6 @@ export type InitialDocumentDetail = { // A document in the dataset document list. export type SimpleDocumentDetail = DocumentWithSegmentsResponse -export type DocumentListResponse = DocumentWithSegmentsListResponse - type DocumentReq = { original_document_id?: string indexing_technique?: IndexingType From 7fe5440b23495430ea608d9f898d1b6229760966 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:38:19 +0800 Subject: [PATCH 52/60] test(web): route the recipient spec members query through the console hooks mock The recipient now reads members from the generated members query, so the spec's old useMembers mock let a real request through. Co-Authored-By: Claude Opus 5.5 --- .../recipient/__tests__/index.spec.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx index d2a95ffd123537..17a3bf3d114de3 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx @@ -7,7 +7,7 @@ import Recipient from '../index' const mockUseTranslation = vi.hoisted(() => vi.fn()) const mockConsoleStateReader = vi.hoisted(() => vi.fn()) -const mockUseMembers = vi.hoisted(() => vi.fn()) +const mockMembersQuery = vi.hoisted(() => vi.fn()) const mockConsoleState = vi.hoisted(() => ({ userProfile: { email: 'owner@example.com' }, currentWorkspace: { name: "Dify's Lab" }, @@ -27,9 +27,12 @@ vi.mock('@/context/workspace-state', async () => { return createWorkspaceStateModuleMock(() => mockConsoleState) }) -vi.mock('@/service/use-common', () => ({ - useMembers: () => mockUseMembers(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.members.get': () => mockMembersQuery(), + }) +}) vi.mock('../member-selector', () => ({ __esModule: true, @@ -78,7 +81,7 @@ describe('Recipient', () => { ), }) mockConsoleStateReader.mockReturnValue(mockConsoleState) - mockUseMembers.mockReturnValue({ + mockMembersQuery.mockReturnValue({ data: { accounts: [ { id: 'member-1', email: 'member-1@example.com', name: 'Member One' }, From 4784fafd83fa88186958a2df7bb20daca903f2d4 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:02:48 +0800 Subject: [PATCH 53/60] refactor(web): use generated dataset options directly Remove the forwarding hooks in service/knowledge/use-dataset. Dataset lists, detail, related apps, process rules, API base info, retrieval records, error documents and batch indexing status read the generated console queries, and the service API toggle uses the generated mutation. Dataset list and detail invalidation targets the generated keys inline. The detail page's no-retry on 403/404 and the process rule's single read move to the shared query policies. DataSet and the related list, query record, error document, indexing status and process rule UI types now alias the contract. The dataset responses declare the stored permission, data source, indexing technique, runtime mode, search method, metadata, query source and role, related app mode and icon type values, and include the keyword number the settings form edits (the Service API keeps it out of its public shape). Response schemas mirror model enums as Literals so they keep to the import boundary. The console datasets controller now reuses the document indexing status models instead of registering a second DocumentStatusResponse, so the contract carries the vector space admission fields the batch status returns. Fields the backend can leave unset are read through datasets/dataset-fields: chunk structure, creation data source, icon, summary settings and the retrieval form state. The settings form and hit testing wait for the loaded dataset instead of casting a missing one. Co-Authored-By: Claude Opus 5.5 --- api/controllers/console/datasets/datasets.py | 47 +-- .../service_api/dataset/dataset.py | 5 +- api/fields/dataset_fields.py | 22 +- api/fields/document_fields.py | 7 +- api/openapi/markdown/console-openapi.md | 92 +++-- api/openapi/markdown/service-openapi.md | 48 +-- .../knowledge/dataset_read_service.py | 1 + api/services/knowledge/entities/datasets.py | 1 + .../unit_tests/fields/test_dataset_fields.py | 1 + oxlint-suppressions.json | 14 +- .../api/console/datasets/types.gen.ts | 115 ++++-- .../generated/api/console/datasets/zod.gen.ts | 280 ++++++++------ .../generated/api/console/rag/types.gen.ts | 28 +- .../generated/api/console/rag/zod.gen.ts | 37 +- .../generated/api/service/types.gen.ts | 56 ++- .../generated/api/service/zod.gen.ts | 345 +++++++++--------- web/__tests__/api-key-management.spec.tsx | 24 +- .../__tests__/layout-main.spec.tsx | 47 ++- .../[datasetId]/layout-main.tsx | 17 +- .../api-key/dataset-scope-dialog.tsx | 15 +- .../__tests__/dataset-detail-section.spec.tsx | 11 +- .../app-sidebar/dataset-detail-section.tsx | 22 +- .../__tests__/dropdown-callbacks.spec.tsx | 34 +- .../dataset-info/__tests__/index.spec.tsx | 37 +- .../app-sidebar/dataset-info/dropdown.tsx | 17 +- .../app-sidebar/dataset-info/index.tsx | 6 +- .../dataset-config/__tests__/index.spec.tsx | 37 +- .../card-item/__tests__/index.spec.tsx | 11 +- .../__tests__/config-content.spec.tsx | 11 +- .../select-dataset/__tests__/index.spec.tsx | 25 +- .../dataset-config/select-dataset/index.tsx | 15 +- .../settings-modal/__tests__/index.spec.tsx | 11 +- .../__tests__/retrieval-section.spec.tsx | 8 +- .../dataset-config/settings-modal/index.tsx | 38 +- .../settings-modal/retrieval-section.tsx | 9 +- .../__tests__/index.spec.tsx | 3 + .../base/linked-apps-panel/index.stories.tsx | 3 + .../context-block-replacement-block.spec.tsx | 2 +- .../plugins/context-block/index.tsx | 2 +- .../datasets/__tests__/dataset-fields.spec.ts | 87 +++++ .../__tests__/index-failed.spec.tsx | 107 +++--- .../index-failed.tsx | 17 +- .../__tests__/footer.spec.tsx | 37 +- .../datasets/create-from-pipeline/footer.tsx | 8 +- .../list/__tests__/create-card.spec.tsx | 16 +- .../create-from-pipeline/list/create-card.tsx | 8 +- .../__tests__/focus.browser.spec.tsx | 1 - .../template-card/__tests__/index.spec.tsx | 11 +- .../list/template-card/index.tsx | 5 +- .../datasets/create/__tests__/index.spec.tsx | 14 +- .../__tests__/index.spec.tsx | 9 +- .../create/embedding-process/index.tsx | 8 +- .../indexing-progress-item.tsx | 2 +- .../__tests__/index.spec.tsx | 109 +++--- .../empty-dataset-creation-modal/index.tsx | 10 +- web/app/components/datasets/create/index.tsx | 2 +- .../datasets/create/step-one/index.tsx | 3 +- .../create/step-two/__tests__/index.spec.tsx | 189 +++++----- .../__tests__/use-document-creation.spec.ts | 39 +- .../step-two/hooks/use-document-creation.ts | 8 +- .../datasets/create/step-two/index.tsx | 44 ++- .../datasets/create/step-two/process-rule.ts | 13 +- web/app/components/datasets/dataset-fields.ts | 93 +++++ .../documents/create-from-pipeline/index.tsx | 2 +- .../__tests__/index.spec.tsx | 44 +-- .../processing/embedding-process/index.tsx | 43 ++- .../create-from-pipeline/processing/index.tsx | 4 +- .../detail/embedding/__tests__/index.spec.tsx | 14 +- .../embedding/hooks/use-embedding-status.ts | 5 +- .../documents/detail/embedding/index.tsx | 14 +- .../datasets/documents/detail/index.tsx | 2 +- .../components/datasets/documents/index.tsx | 3 +- .../api-access/__tests__/card.spec.tsx | 21 +- .../datasets/extra-info/api-access/card.tsx | 20 +- .../hit-testing/__tests__/index.spec.tsx | 63 ++-- .../components/datasets/hit-testing/index.tsx | 78 ++-- .../datasets/list/__tests__/datasets.spec.tsx | 82 ++--- .../datasets/list/__tests__/index.spec.tsx | 91 +++-- .../dataset-card/__tests__/index.spec.tsx | 8 +- .../__tests__/dataset-card-header.spec.tsx | 7 +- .../__tests__/dataset-card-modals.spec.tsx | 5 +- .../components/dataset-card-header.tsx | 19 +- .../dataset-card/components/description.tsx | 2 +- .../datasets/list/dataset-card/index.tsx | 2 +- web/app/components/datasets/list/datasets.tsx | 29 +- web/app/components/datasets/list/index.tsx | 39 +- .../rename-modal/__tests__/index.spec.tsx | 5 +- .../datasets/rename-modal/index.tsx | 6 +- .../settings/form/__tests__/index.spec.tsx | 20 +- .../__tests__/basic-info-section.spec.tsx | 15 +- .../external-knowledge-section.spec.tsx | 5 +- .../__tests__/indexing-section.spec.tsx | 5 +- .../__tests__/reflow.browser.spec.tsx | 5 +- .../components/external-knowledge-section.tsx | 6 +- .../form/components/indexing-section.tsx | 11 +- .../hooks/__tests__/use-form-state.spec.ts | 111 +++--- .../settings/form/hooks/use-form-state.ts | 56 +-- .../datasets/settings/form/index.tsx | 14 +- .../datasets/settings/index-method/index.tsx | 3 +- .../components/__tests__/conversion.spec.tsx | 19 +- .../rag-pipeline/components/conversion.tsx | 17 +- .../publisher/__tests__/index.spec.tsx | 11 +- .../publisher/__tests__/popup.spec.tsx | 6 - .../rag-pipeline-header/publisher/popup.tsx | 5 +- .../hooks/__tests__/use-pipeline-init.spec.ts | 9 +- .../rag-pipeline/hooks/use-pipeline-init.ts | 6 +- .../__tests__/store.spec.tsx | 7 +- .../recipient/__tests__/index.spec.tsx | 13 +- .../__tests__/integration.spec.tsx | 16 +- .../__tests__/node.spec.tsx | 15 +- .../__tests__/panel.spec.tsx | 16 +- .../__tests__/use-config.spec.ts | 22 +- .../condition-list/condition-item.tsx | 6 +- .../use-knowledge-dataset-selection.spec.ts | 21 +- .../use-knowledge-metadata-config.spec.ts | 2 - .../use-knowledge-model-config.spec.ts | 15 +- .../nodes/knowledge-retrieval/utils.ts | 4 +- .../orchestrate/knowledge/dialog.tsx | 21 +- web/models/datasets.ts | 170 ++------- .../__tests__/dataset-query-policies.spec.ts | 33 ++ web/service/console/query-policies.ts | 15 +- web/service/knowledge/use-dataset.spec.ts | 94 ----- web/service/knowledge/use-dataset.ts | 196 ---------- web/test/fixtures/dataset.ts | 54 +++ web/types/app.ts | 2 +- 125 files changed, 1974 insertions(+), 2024 deletions(-) create mode 100644 web/app/components/datasets/__tests__/dataset-fields.spec.ts create mode 100644 web/app/components/datasets/dataset-fields.ts create mode 100644 web/service/console/__tests__/dataset-query-policies.spec.ts delete mode 100644 web/service/knowledge/use-dataset.spec.ts delete mode 100644 web/service/knowledge/use-dataset.ts create mode 100644 web/test/fixtures/dataset.ts diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index a938edfb21de3f..67e04dd7afecf5 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -29,6 +29,7 @@ ) from core.entities.knowledge_entities import IndexingEstimate from core.rag.index_processor.constant.index_type import IndexStructureType +from core.rag.index_processor.constant.query_type import QueryType from extensions.ext_application_services import application_services from fields.api_key_fields import ApiKeyItem, ApiKeyList, build_masked_api_key_list from fields.base import ResponseModel @@ -36,11 +37,14 @@ DatasetDetailResponse, NotionEstimateWorkspacePayload, ) +from fields.document_fields import DocumentStatusListResponse from libs.external_api import error_responses from libs.helper import build_icon_url, dump_response, to_timestamp from machinery.context import RequestContext from models.account import TenantAccountRole +from models.enums import CreatorUserRole, DatasetQuerySource from models.enums import PermissionEnum as DatasetPermissionEnum +from models.model import AppMode, IconType from services.errors.dataset import DatasetNameDuplicateError as DatasetNameDuplicateFailure from services.knowledge.dataset_access import DatasetAccessDeniedError, DatasetNotFoundError from services.knowledge.datasets.application import DatasetListFilter @@ -247,7 +251,7 @@ class DatasetQueryFileInfoResponse(ResponseModel): class DatasetQueryContentResponse(ResponseModel): - content_type: str + content_type: QueryType content: str file_info: DatasetQueryFileInfoResponse | None = None @@ -255,9 +259,9 @@ class DatasetQueryContentResponse(ResponseModel): class DatasetQueryDetailResponse(ResponseModel): id: str queries: list[DatasetQueryContentResponse] - source: str + source: DatasetQuerySource source_app_id: str | None - created_by_role: str + created_by_role: CreatorUserRole created_by: str created_at: int @@ -279,8 +283,8 @@ class RelatedAppResponse(ResponseModel): id: str name: str description: str - mode: str = Field(validation_alias="mode_compatible_with_agent") - icon_type: str | None + mode: AppMode = Field(validation_alias="mode_compatible_with_agent") + icon_type: IconType | None icon: str | None icon_background: str | None icon_url: str | None = None @@ -296,39 +300,6 @@ class RelatedAppListResponse(ResponseModel): total: int -class DocumentStatusResponse(ResponseModel): - id: str - indexing_status: str - processing_started_at: int | None - parsing_completed_at: int | None - cleaning_completed_at: int | None - splitting_completed_at: int | None - completed_at: int | None - paused_at: int | None - error: str | None - stopped_at: int | None - completed_segments: int | None = None - total_segments: int | None = None - - @field_validator( - "processing_started_at", - "parsing_completed_at", - "cleaning_completed_at", - "splitting_completed_at", - "completed_at", - "paused_at", - "stopped_at", - mode="before", - ) - @classmethod - def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return to_timestamp(value) - - -class DocumentStatusListResponse(ResponseModel): - data: list[DocumentStatusResponse] - - class ErrorDocsResponse(DocumentStatusListResponse): total: int diff --git a/api/controllers/service_api/dataset/dataset.py b/api/controllers/service_api/dataset/dataset.py index 50a311079ab7ca..086638c5710749 100644 --- a/api/controllers/service_api/dataset/dataset.py +++ b/api/controllers/service_api/dataset/dataset.py @@ -95,11 +95,12 @@ class DatasetDetailResponse(BaseDatasetDetailResponse): - # The Service API dump helpers exclude Console permission metadata. + # The Service API dump helpers exclude Console permission metadata and form settings. permission_keys: list[str] = Field(default_factory=list, exclude=True) + keyword_number: int | None = Field(default=None, exclude=True) -_SERVICE_DATASET_DETAIL_EXCLUDE = {"permission_keys"} +_SERVICE_DATASET_DETAIL_EXCLUDE = {"permission_keys", "keyword_number"} _SERVICE_DATASET_LIST_EXCLUDE = {"data": {"__all__": _SERVICE_DATASET_DETAIL_EXCLUDE}} diff --git a/api/fields/dataset_fields.py b/api/fields/dataset_fields.py index 4b366bf653979f..6624a32aac8a70 100644 --- a/api/fields/dataset_fields.py +++ b/api/fields/dataset_fields.py @@ -3,6 +3,8 @@ from pydantic import BaseModel, Field, field_validator +from core.rag.index_processor.constant.index_type import IndexTechniqueType +from core.rag.retrieval.retrieval_methods import RetrievalMethod from fields.base import ResponseModel from libs.helper import to_timestamp from services.data_source.entities.notion_import import NotionPageType @@ -20,6 +22,13 @@ class NotionEstimateWorkspacePayload(BaseModel): MetadataValueType = Literal["string", "number", "time"] +# Mirror models.enums.PermissionEnum / DataSourceType / DatasetRuntimeMode; response schemas do not +# import models. +DatasetPermission = Literal["only_me", "all_team_members", "partial_members"] +DatasetDataSourceType = Literal[ + "upload_file", "notion_import", "website_crawl", "local_file", "online_document", "online_drive" +] +DatasetRuntimeMode = Literal["general", "rag_pipeline"] class DatasetMetadataResponse(ResponseModel): @@ -86,7 +95,7 @@ def _expand_null_nested(cls, value: object) -> object: class DatasetRetrievalModelResponse(ResponseModel): - search_method: str + search_method: RetrievalMethod reranking_enable: bool reranking_mode: str | None = None reranking_model: DatasetRerankingModelResponse = Field( @@ -133,7 +142,7 @@ class DatasetExternalRetrievalModelResponse(ResponseModel): class DatasetDocMetadataResponse(ResponseModel): id: str name: str - type: str + type: MetadataValueType class DatasetIconInfoResponse(ResponseModel): @@ -148,9 +157,10 @@ class DatasetDetailResponse(ResponseModel): name: str description: str | None provider: str - permission: str - data_source_type: str | None - indexing_technique: str | None + permission: DatasetPermission + data_source_type: DatasetDataSourceType | None + indexing_technique: IndexTechniqueType | None + keyword_number: int | None = Field(default=None, description="Keywords extracted per chunk by the economy index.") app_count: int document_count: int word_count: int @@ -182,7 +192,7 @@ class DatasetDetailResponse(ResponseModel): doc_metadata: list[DatasetDocMetadataResponse] built_in_field_enabled: bool pipeline_id: str | None - runtime_mode: str | None + runtime_mode: DatasetRuntimeMode | None chunk_structure: str | None icon_info: DatasetIconInfoResponse = Field( default_factory=DatasetIconInfoResponse, diff --git a/api/fields/document_fields.py b/api/fields/document_fields.py index 3204f976a40230..2a831c99b8f4db 100644 --- a/api/fields/document_fields.py +++ b/api/fields/document_fields.py @@ -9,6 +9,9 @@ from fields.dataset_fields import MetadataValueType from libs.helper import to_timestamp +# Mirrors models.enums.IndexingStatus; response schemas do not import models. +IndexingStatus = Literal["waiting", "parsing", "cleaning", "splitting", "indexing", "paused", "completed", "error"] + def normalize_enum(value: Any) -> Any: if isinstance(value, str) or value is None: @@ -120,7 +123,7 @@ class DocumentListResponse(ResponseModel): class DocumentStatusResponse(ResponseModel): id: str - indexing_status: str + indexing_status: IndexingStatus processing_started_at: int | None parsing_completed_at: int | None cleaning_completed_at: int | None @@ -128,7 +131,7 @@ class DocumentStatusResponse(ResponseModel): completed_at: int | None paused_at: int | None error: str | None - error_code: str | None = None + error_code: Literal["vector_space_estimate_exceeded"] | None = None estimated_vector_space_mb: int | None = None vector_space_limit_mb: int | None = None stopped_at: int | None diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 4a319c8eb9ccf3..14dafbb7e3f531 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -19214,7 +19214,7 @@ Create child chunk index failed: {message} | id | string | | Yes | | position | integer | | Yes | | segment_id | string | | Yes | -| type | [SegmentType](#segmenttype) | | Yes | +| type | string,
**Available values:** "automatic", "customized" | *Enum:* `"automatic"`, `"customized"` | Yes | | updated_at | integer | | Yes | | word_count | integer | | Yes | @@ -19749,6 +19749,12 @@ Payload for creating a new snippet. | name | string | | Yes | | type | string,
**Available values:** "group", "node",
**Default:** node | *Enum:* `"group"`, `"node"` | No | +#### CreatorUserRole + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| CreatorUserRole | string | | | + #### CredentialConfiguration Model class for credential configuration. @@ -20022,7 +20028,7 @@ Dataset access denied. | chunk_structure | string | | Yes | | created_at | integer | | Yes | | created_by | string | | Yes | -| data_source_type | string | | Yes | +| data_source_type | string,
**Available values:** "local_file", "notion_import", "online_document", "online_drive", "upload_file", "website_crawl" | | Yes | | description | string | | Yes | | doc_form | string | | Yes | | doc_metadata | [ [DatasetDocMetadataResponse](#datasetdocmetadataresponse) ] | | Yes | @@ -20035,17 +20041,18 @@ Dataset access denied. | external_retrieval_model | [DatasetExternalRetrievalModelResponse](#datasetexternalretrievalmodelresponse) | | Yes | | icon_info | [DatasetIconInfoResponse](#dataseticoninforesponse) | Icon display configuration for the knowledge base. | No | | id | string | | Yes | -| indexing_technique | string | | Yes | +| indexing_technique | [IndexTechniqueType](#indextechniquetype) | | Yes | | is_multimodal | boolean | | Yes | | is_published | boolean | | Yes | +| keyword_number | integer | Keywords extracted per chunk by the economy index. | No | | maintainer | string | | No | | name | string | | Yes | -| permission | string | | Yes | +| permission | string,
**Available values:** "all_team_members", "only_me", "partial_members" | *Enum:* `"all_team_members"`, `"only_me"`, `"partial_members"` | Yes | | permission_keys | [ string ] | | No | | pipeline_id | string | | Yes | | provider | string | | Yes | | retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | Retrieval configuration for the knowledge base. | Yes | -| runtime_mode | string | | Yes | +| runtime_mode | string,
**Available values:** "general", "rag_pipeline" | | Yes | | summary_index_setting | [DatasetSummaryIndexSettingResponse](#datasetsummaryindexsettingresponse) | Summary index configuration. | No | | tags | [ [DatasetTagResponse](#datasettagresponse) ] | | Yes | | total_available_documents | integer | | Yes | @@ -20064,7 +20071,7 @@ Dataset access denied. | chunk_structure | string | | Yes | | created_at | integer | | Yes | | created_by | string | | Yes | -| data_source_type | string | | Yes | +| data_source_type | string,
**Available values:** "local_file", "notion_import", "online_document", "online_drive", "upload_file", "website_crawl" | | Yes | | description | string | | Yes | | doc_form | string | | Yes | | doc_metadata | [ [DatasetDocMetadataResponse](#datasetdocmetadataresponse) ] | | Yes | @@ -20077,18 +20084,19 @@ Dataset access denied. | external_retrieval_model | [DatasetExternalRetrievalModelResponse](#datasetexternalretrievalmodelresponse) | | Yes | | icon_info | [DatasetIconInfoResponse](#dataseticoninforesponse) | Icon display configuration for the knowledge base. | No | | id | string | | Yes | -| indexing_technique | string | | Yes | +| indexing_technique | [IndexTechniqueType](#indextechniquetype) | | Yes | | is_multimodal | boolean | | Yes | | is_published | boolean | | Yes | +| keyword_number | integer | Keywords extracted per chunk by the economy index. | No | | maintainer | string | | No | | name | string | | Yes | | partial_member_list | [ string ] | | No | -| permission | string | | Yes | +| permission | string,
**Available values:** "all_team_members", "only_me", "partial_members" | *Enum:* `"all_team_members"`, `"only_me"`, `"partial_members"` | Yes | | permission_keys | [ string ] | | No | | pipeline_id | string | | Yes | | provider | string | | Yes | | retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | Retrieval configuration for the knowledge base. | Yes | -| runtime_mode | string | | Yes | +| runtime_mode | string,
**Available values:** "general", "rag_pipeline" | | Yes | | summary_index_setting | [DatasetSummaryIndexSettingResponse](#datasetsummaryindexsettingresponse) | Summary index configuration. | No | | tags | [ [DatasetTagResponse](#datasettagresponse) ] | | Yes | | total_available_documents | integer | | Yes | @@ -20103,7 +20111,7 @@ Dataset access denied. | ---- | ---- | ----------- | -------- | | id | string | | Yes | | name | string | | Yes | -| type | string | | Yes | +| type | string,
**Available values:** "number", "string", "time" | *Enum:* `"number"`, `"string"`, `"time"` | Yes | #### DatasetExternalKnowledgeInfoResponse @@ -20147,7 +20155,7 @@ Dataset access denied. | chunk_structure | string | | Yes | | created_at | integer | | Yes | | created_by | string | | Yes | -| data_source_type | string | | Yes | +| data_source_type | string,
**Available values:** "local_file", "notion_import", "online_document", "online_drive", "upload_file", "website_crawl" | | Yes | | description | string | | Yes | | doc_form | string | | Yes | | doc_metadata | [ [DatasetDocMetadataResponse](#datasetdocmetadataresponse) ] | | Yes | @@ -20160,18 +20168,19 @@ Dataset access denied. | external_retrieval_model | [DatasetExternalRetrievalModelResponse](#datasetexternalretrievalmodelresponse) | | Yes | | icon_info | [DatasetIconInfoResponse](#dataseticoninforesponse) | Icon display configuration for the knowledge base. | No | | id | string | | Yes | -| indexing_technique | string | | Yes | +| indexing_technique | [IndexTechniqueType](#indextechniquetype) | | Yes | | is_multimodal | boolean | | Yes | | is_published | boolean | | Yes | +| keyword_number | integer | Keywords extracted per chunk by the economy index. | No | | maintainer | string | | No | | name | string | | Yes | | partial_member_list | [ string ] | | Yes | -| permission | string | | Yes | +| permission | string,
**Available values:** "all_team_members", "only_me", "partial_members" | *Enum:* `"all_team_members"`, `"only_me"`, `"partial_members"` | Yes | | permission_keys | [ string ] | | No | | pipeline_id | string | | Yes | | provider | string | | Yes | | retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | Retrieval configuration for the knowledge base. | Yes | -| runtime_mode | string | | Yes | +| runtime_mode | string,
**Available values:** "general", "rag_pipeline" | | Yes | | summary_index_setting | [DatasetSummaryIndexSettingResponse](#datasetsummaryindexsettingresponse) | Summary index configuration. | No | | tags | [ [DatasetTagResponse](#datasettagresponse) ] | | Yes | | total_available_documents | integer | | Yes | @@ -20240,7 +20249,7 @@ The dataset name already exists. Please modify your dataset name. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | content | string | | Yes | -| content_type | string | | Yes | +| content_type | [QueryType](#querytype) | | Yes | | file_info | [DatasetQueryFileInfoResponse](#datasetqueryfileinforesponse) | | No | #### DatasetQueryDetailResponse @@ -20249,10 +20258,10 @@ The dataset name already exists. Please modify your dataset name. | ---- | ---- | ----------- | -------- | | created_at | integer | | Yes | | created_by | string | | Yes | -| created_by_role | string | | Yes | +| created_by_role | [CreatorUserRole](#creatoruserrole) | | Yes | | id | string | | Yes | | queries | [ [DatasetQueryContentResponse](#datasetquerycontentresponse) ] | | Yes | -| source | string | | Yes | +| source | [DatasetQuerySource](#datasetquerysource) | | Yes | | source_app_id | string | | Yes | #### DatasetQueryFileInfoResponse @@ -20276,6 +20285,14 @@ The dataset name already exists. Please modify your dataset name. | page | integer | | Yes | | total | integer | | Yes | +#### DatasetQuerySource + +Dataset query source + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| DatasetQuerySource | string | Dataset query source | | + #### DatasetRerankingModelResponse | Name | Type | Description | Required | @@ -20305,7 +20322,7 @@ The dataset name already exists. Please modify your dataset name. | reranking_model | [DatasetRerankingModelResponse](#datasetrerankingmodelresponse) | Reranking model configuration. | No | | score_threshold | number | | No | | score_threshold_enabled | boolean | | Yes | -| search_method | string | | Yes | +| search_method | [RetrievalMethod](#retrievalmethod) | | Yes | | top_k | integer | | Yes | | weights | [DatasetWeightedScoreResponse](#datasetweightedscoreresponse) | | No | @@ -20911,14 +20928,17 @@ The uploaded file for file documents; Notion and website documents repeat their | completed_at | integer | | Yes | | completed_segments | integer | | No | | error | string | | Yes | +| error_code | string | | No | +| estimated_vector_space_mb | integer | | No | | id | string | | Yes | -| indexing_status | string | | Yes | +| indexing_status | string,
**Available values:** "cleaning", "completed", "error", "indexing", "parsing", "paused", "splitting", "waiting" | *Enum:* `"cleaning"`, `"completed"`, `"error"`, `"indexing"`, `"parsing"`, `"paused"`, `"splitting"`, `"waiting"` | Yes | | parsing_completed_at | integer | | Yes | | paused_at | integer | | Yes | | processing_started_at | integer | | Yes | | splitting_completed_at | integer | | Yes | | stopped_at | integer | | Yes | | total_segments | integer | | No | +| vector_space_limit_mb | integer | | No | #### DocumentSummaryStatusResponse @@ -22244,6 +22264,12 @@ How Dify forwards the end-user's identity to an MCP server. | ---- | ---- | ----------- | -------- | | include_secret | string,
**Default:** false | Whether to include secret values in the exported DSL | No | +#### IndexTechniqueType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| IndexTechniqueType | string | | | + #### IndexingEstimate | Name | Type | Description | Required | @@ -25139,6 +25165,12 @@ Model class for provider with models response. | answer | string | | Yes | | question | string | | Yes | +#### QueryType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| QueryType | string | | | + #### Quota | Name | Type | Description | Required | @@ -25454,10 +25486,10 @@ Recommended app not found. | description | string | | Yes | | icon | string | | Yes | | icon_background | string | | Yes | -| icon_type | string | | Yes | +| icon_type | [IconType](#icontype) | | Yes | | icon_url | string | | No | | id | string | | Yes | -| mode | string | | Yes | +| mode | [AppMode](#appmode) | | Yes | | name | string | | Yes | #### RemoteFileAccessDeniedError @@ -25987,7 +26019,7 @@ The Roster Agent package exceeds the size limit. | keywords | [ string ] | | Yes | | position | integer | | Yes | | sign_content | string | | Yes | -| status | [SegmentStatus](#segmentstatus) | | Yes | +| status | string,
**Available values:** "completed", "error", "indexing", "paused", "re_segment", "waiting" | *Enum:* `"completed"`, `"error"`, `"indexing"`, `"paused"`, `"re_segment"`, `"waiting"` | Yes | | stopped_at | integer | | Yes | | summary | string | | Yes | | tokens | integer | | Yes | @@ -25995,22 +26027,6 @@ The Roster Agent package exceeds the size limit. | updated_by | string | | Yes | | word_count | integer | | Yes | -#### SegmentStatus - -Document segment status - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| SegmentStatus | string | Document segment status | | - -#### SegmentType - -Document segment type - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| SegmentType | string | Document segment type | | - #### SegmentUpdatePayload | Name | Type | Description | Required | diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index e3f33ba317e2e9..17e6fcec188ea1 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -2769,7 +2769,7 @@ Public pause reason emitted by a blocking Chatflow execution. | id | string | | Yes | | position | integer | | Yes | | segment_id | string | | Yes | -| type | [SegmentType](#segmenttype) | | Yes | +| type | string,
**Available values:** "automatic", "customized" | *Enum:* `"automatic"`, `"customized"` | Yes | | updated_at | integer | | Yes | | word_count | integer | | Yes | @@ -2949,7 +2949,7 @@ Enum class for custom configuration status. | chunk_structure | string | | Yes | | created_at | integer | | Yes | | created_by | string | | Yes | -| data_source_type | string | | Yes | +| data_source_type | string,
**Available values:** "local_file", "notion_import", "online_document", "online_drive", "upload_file", "website_crawl" | | Yes | | description | string | | Yes | | doc_form | string | | Yes | | doc_metadata | [ [DatasetDocMetadataResponse](#datasetdocmetadataresponse) ] | | Yes | @@ -2962,16 +2962,16 @@ Enum class for custom configuration status. | external_retrieval_model | [DatasetExternalRetrievalModelResponse](#datasetexternalretrievalmodelresponse) | | Yes | | icon_info | [DatasetIconInfoResponse](#dataseticoninforesponse) | Icon display configuration for the knowledge base. | No | | id | string | | Yes | -| indexing_technique | string | | Yes | +| indexing_technique | [IndexTechniqueType](#indextechniquetype) | | Yes | | is_multimodal | boolean | | Yes | | is_published | boolean | | Yes | | maintainer | string | | No | | name | string | | Yes | -| permission | string | | Yes | +| permission | string,
**Available values:** "all_team_members", "only_me", "partial_members" | *Enum:* `"all_team_members"`, `"only_me"`, `"partial_members"` | Yes | | pipeline_id | string | | Yes | | provider | string | | Yes | | retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | Retrieval configuration for the knowledge base. | Yes | -| runtime_mode | string | | Yes | +| runtime_mode | string,
**Available values:** "general", "rag_pipeline" | | Yes | | summary_index_setting | [DatasetSummaryIndexSettingResponse](#datasetsummaryindexsettingresponse) | Summary index configuration. | No | | tags | [ [DatasetTagResponse](#datasettagresponse) ] | | Yes | | total_available_documents | integer | | Yes | @@ -2990,7 +2990,7 @@ Enum class for custom configuration status. | chunk_structure | string | | Yes | | created_at | integer | | Yes | | created_by | string | | Yes | -| data_source_type | string | | Yes | +| data_source_type | string,
**Available values:** "local_file", "notion_import", "online_document", "online_drive", "upload_file", "website_crawl" | | Yes | | description | string | | Yes | | doc_form | string | | Yes | | doc_metadata | [ [DatasetDocMetadataResponse](#datasetdocmetadataresponse) ] | | Yes | @@ -3003,17 +3003,17 @@ Enum class for custom configuration status. | external_retrieval_model | [DatasetExternalRetrievalModelResponse](#datasetexternalretrievalmodelresponse) | | Yes | | icon_info | [DatasetIconInfoResponse](#dataseticoninforesponse) | Icon display configuration for the knowledge base. | No | | id | string | | Yes | -| indexing_technique | string | | Yes | +| indexing_technique | [IndexTechniqueType](#indextechniquetype) | | Yes | | is_multimodal | boolean | | Yes | | is_published | boolean | | Yes | | maintainer | string | | No | | name | string | | Yes | | partial_member_list | [ string ] | | No | -| permission | string | | Yes | +| permission | string,
**Available values:** "all_team_members", "only_me", "partial_members" | *Enum:* `"all_team_members"`, `"only_me"`, `"partial_members"` | Yes | | pipeline_id | string | | Yes | | provider | string | | Yes | | retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | Retrieval configuration for the knowledge base. | Yes | -| runtime_mode | string | | Yes | +| runtime_mode | string,
**Available values:** "general", "rag_pipeline" | | Yes | | summary_index_setting | [DatasetSummaryIndexSettingResponse](#datasetsummaryindexsettingresponse) | Summary index configuration. | No | | tags | [ [DatasetTagResponse](#datasettagresponse) ] | | Yes | | total_available_documents | integer | | Yes | @@ -3028,7 +3028,7 @@ Enum class for custom configuration status. | ---- | ---- | ----------- | -------- | | id | string | | Yes | | name | string | | Yes | -| type | string | | Yes | +| type | string,
**Available values:** "number", "string", "time" | *Enum:* `"number"`, `"string"`, `"time"` | Yes | #### DatasetExternalKnowledgeInfoResponse @@ -3149,7 +3149,7 @@ The dataset name already exists. Please modify your dataset name. | reranking_model | [DatasetRerankingModelResponse](#datasetrerankingmodelresponse) | Reranking model configuration. | No | | score_threshold | number | | No | | score_threshold_enabled | boolean | | Yes | -| search_method | string | | Yes | +| search_method | [RetrievalMethod](#retrievalmethod) | | Yes | | top_k | integer | | Yes | | weights | [DatasetWeightedScoreResponse](#datasetweightedscoreresponse) | | No | @@ -3397,7 +3397,7 @@ The uploaded file for file documents; Notion and website documents repeat their | error_code | string | | No | | estimated_vector_space_mb | integer | | No | | id | string | | Yes | -| indexing_status | string | | Yes | +| indexing_status | string,
**Available values:** "cleaning", "completed", "error", "indexing", "parsing", "paused", "splitting", "waiting" | *Enum:* `"cleaning"`, `"completed"`, `"error"`, `"indexing"`, `"parsing"`, `"paused"`, `"splitting"`, `"waiting"` | Yes | | parsing_completed_at | integer | | Yes | | paused_at | integer | | Yes | | processing_started_at | integer | | Yes | @@ -3803,6 +3803,12 @@ This form has already been submitted by another user, form_id={form_id} | server_version | string | | Yes | | welcome | string | | Yes | +#### IndexTechniqueType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| IndexTechniqueType | string | | | + #### InvalidActionError Invalid action. @@ -4413,7 +4419,7 @@ Rate Limit Error | keywords | [ string ] | | Yes | | position | integer | | Yes | | sign_content | string | | Yes | -| status | [SegmentStatus](#segmentstatus) | | Yes | +| status | string,
**Available values:** "completed", "error", "indexing", "paused", "re_segment", "waiting" | *Enum:* `"completed"`, `"error"`, `"indexing"`, `"paused"`, `"re_segment"`, `"waiting"` | Yes | | stopped_at | integer | | Yes | | summary | string | | Yes | | tokens | integer | | Yes | @@ -4421,22 +4427,6 @@ Rate Limit Error | updated_by | string | | Yes | | word_count | integer | | Yes | -#### SegmentStatus - -Document segment status - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| SegmentStatus | string | Document segment status | | - -#### SegmentType - -Document segment type - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| SegmentType | string | Document segment type | | - #### SegmentUpdateArgs | Name | Type | Description | Required | diff --git a/api/services/knowledge/dataset_read_service.py b/api/services/knowledge/dataset_read_service.py index 4702dea90eb26e..a2d738ce7a95a2 100644 --- a/api/services/knowledge/dataset_read_service.py +++ b/api/services/knowledge/dataset_read_service.py @@ -52,6 +52,7 @@ def _dataset_detail_values(dataset: Dataset, prefetch: DatasetDetailPrefetch) -> "permission": dataset.permission, "data_source_type": dataset.data_source_type, "indexing_technique": dataset.indexing_technique, + "keyword_number": dataset.keyword_number, "app_count": prefetch.app_counts.get(dataset.id, 0), "document_count": prefetch.document_counts.get(dataset.id, 0), "word_count": prefetch.word_counts.get(dataset.id, 0), diff --git a/api/services/knowledge/entities/datasets.py b/api/services/knowledge/entities/datasets.py index a769dacac840a4..c6b5c3246c953e 100644 --- a/api/services/knowledge/entities/datasets.py +++ b/api/services/knowledge/entities/datasets.py @@ -25,6 +25,7 @@ class DatasetDetailRecord(TypedDict): permission: str data_source_type: str | None indexing_technique: str | None + keyword_number: int | None app_count: int document_count: int word_count: int diff --git a/api/tests/unit_tests/fields/test_dataset_fields.py b/api/tests/unit_tests/fields/test_dataset_fields.py index 02f0692079b547..f08875f342dbe6 100644 --- a/api/tests/unit_tests/fields/test_dataset_fields.py +++ b/api/tests/unit_tests/fields/test_dataset_fields.py @@ -425,6 +425,7 @@ def test_dataset_detail_prefetch_matches_per_dataset_lookups(sqlite_session: Ses assert plain["total_documents"] == 3 assert plain["total_available_documents"] == 1 assert plain["word_count"] == 35 + assert plain["keyword_number"] == 10 assert plain["author_name"] == "Ada" assert [tag["name"] for tag in plain["tags"]] == ["tag"] assert plain["doc_form"] == IndexStructureType.PARAGRAPH_INDEX diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index e925b2f86df349..8e9f1033d1f464 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -305,7 +305,7 @@ }, "web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx": { "typescript/no-explicit-any": { - "count": 36 + "count": 32 } }, "web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx": { @@ -331,7 +331,7 @@ }, "web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx": { "typescript/no-explicit-any": { - "count": 2 + "count": 1 } }, "web/app/components/app/configuration/dataset-config/settings-modal/index.tsx": { @@ -3961,11 +3961,6 @@ "count": 1 } }, - "web/models/datasets.ts": { - "typescript/no-explicit-any": { - "count": 4 - } - }, "web/models/debug.ts": { "typescript/no-explicit-any": { "count": 3 @@ -4066,11 +4061,6 @@ "count": 2 } }, - "web/service/knowledge/use-dataset.ts": { - "@tanstack/query/prefer-query-options": { - "count": 8 - } - }, "web/service/log.ts": { "no-restricted-imports": { "count": 1 diff --git a/packages/contracts/generated/api/console/datasets/types.gen.ts b/packages/contracts/generated/api/console/datasets/types.gen.ts index 1898975f26bc17..ac129a37ef63db 100644 --- a/packages/contracts/generated/api/console/datasets/types.gen.ts +++ b/packages/contracts/generated/api/console/datasets/types.gen.ts @@ -39,7 +39,14 @@ export type DatasetDetailWithPartialMembersResponse = { chunk_structure: string | null created_at: number created_by: string - data_source_type: string | null + data_source_type: + | 'local_file' + | 'notion_import' + | 'online_document' + | 'online_drive' + | 'upload_file' + | 'website_crawl' + | null description: string | null doc_form: string | null doc_metadata: Array @@ -52,18 +59,19 @@ export type DatasetDetailWithPartialMembersResponse = { external_retrieval_model: DatasetExternalRetrievalModelResponse | null icon_info?: DatasetIconInfoResponse id: string - indexing_technique: string | null + indexing_technique: IndexTechniqueType | null is_multimodal: boolean is_published: boolean + keyword_number?: number | null maintainer?: string | null name: string partial_member_list?: Array | null - permission: string + permission: 'all_team_members' | 'only_me' | 'partial_members' permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse - runtime_mode: string | null + runtime_mode: 'general' | 'rag_pipeline' | null summary_index_setting?: DatasetSummaryIndexSettingResponse tags: Array total_available_documents: number @@ -126,7 +134,14 @@ export type DatasetDetailResponse = { chunk_structure: string | null created_at: number created_by: string - data_source_type: string | null + data_source_type: + | 'local_file' + | 'notion_import' + | 'online_document' + | 'online_drive' + | 'upload_file' + | 'website_crawl' + | null description: string | null doc_form: string | null doc_metadata: Array @@ -139,17 +154,18 @@ export type DatasetDetailResponse = { external_retrieval_model: DatasetExternalRetrievalModelResponse | null icon_info?: DatasetIconInfoResponse id: string - indexing_technique: string | null + indexing_technique: IndexTechniqueType | null is_multimodal: boolean is_published: boolean + keyword_number?: number | null maintainer?: string | null name: string - permission: string + permission: 'all_team_members' | 'only_me' | 'partial_members' permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse - runtime_mode: string | null + runtime_mode: 'general' | 'rag_pipeline' | null summary_index_setting?: DatasetSummaryIndexSettingResponse tags: Array total_available_documents: number @@ -415,14 +431,25 @@ export type DocumentStatusResponse = { completed_at: number | null completed_segments?: number | null error: string | null + error_code?: 'vector_space_estimate_exceeded' | null + estimated_vector_space_mb?: number | null id: string - indexing_status: string + indexing_status: + | 'cleaning' + | 'completed' + | 'error' + | 'indexing' + | 'parsing' + | 'paused' + | 'splitting' + | 'waiting' parsing_completed_at: number | null paused_at: number | null processing_started_at: number | null splitting_completed_at: number | null stopped_at: number | null total_segments?: number | null + vector_space_limit_mb?: number | null } export type DocumentMetadataUpdatePayload = { @@ -659,7 +686,14 @@ export type DatasetListItemResponse = { chunk_structure: string | null created_at: number created_by: string - data_source_type: string | null + data_source_type: + | 'local_file' + | 'notion_import' + | 'online_document' + | 'online_drive' + | 'upload_file' + | 'website_crawl' + | null description: string | null doc_form: string | null doc_metadata: Array @@ -672,18 +706,19 @@ export type DatasetListItemResponse = { external_retrieval_model: DatasetExternalRetrievalModelResponse | null icon_info?: DatasetIconInfoResponse id: string - indexing_technique: string | null + indexing_technique: IndexTechniqueType | null is_multimodal: boolean is_published: boolean + keyword_number?: number | null maintainer?: string | null name: string partial_member_list: Array - permission: string + permission: 'all_team_members' | 'only_me' | 'partial_members' permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse - runtime_mode: string | null + runtime_mode: 'general' | 'rag_pipeline' | null summary_index_setting?: DatasetSummaryIndexSettingResponse tags: Array total_available_documents: number @@ -698,7 +733,7 @@ export type PermissionEnum = 'all_team_members' | 'only_me' | 'partial_members' export type DatasetDocMetadataResponse = { id: string name: string - type: string + type: 'number' | 'string' | 'time' } export type DatasetExternalKnowledgeInfoResponse = { @@ -721,13 +756,15 @@ export type DatasetIconInfoResponse = { icon_url?: string | null } +export type IndexTechniqueType = 'economy' | 'high_quality' + export type DatasetRetrievalModelResponse = { reranking_enable: boolean reranking_mode?: string | null reranking_model?: DatasetRerankingModelResponse score_threshold?: number | null score_threshold_enabled: boolean - search_method: string + search_method: RetrievalMethod top_k: number weights?: DatasetWeightedScoreResponse | null } @@ -895,7 +932,7 @@ export type SegmentResponse = { keywords: Array | null position: number sign_content: string - status: SegmentStatus + status: 'completed' | 'error' | 'indexing' | 'paused' | 're_segment' | 'waiting' stopped_at: number | null summary: string | null tokens: number @@ -910,7 +947,7 @@ export type ChildChunkResponse = { id: string position: number segment_id: string - type: SegmentType + type: 'automatic' | 'customized' updated_at: number word_count: number } @@ -974,10 +1011,10 @@ export type DatasetMetadataListItemResponse = { export type DatasetQueryDetailResponse = { created_at: number created_by: string - created_by_role: string + created_by_role: CreatorUserRole id: string queries: Array - source: string + source: DatasetQuerySource source_app_id: string | null } @@ -985,10 +1022,10 @@ export type RelatedAppResponse = { description: string icon: string | null icon_background: string | null - icon_type: string | null + icon_type: IconType | null icon_url?: string | null id: string - mode: string + mode: AppMode name: string } @@ -997,6 +1034,12 @@ export type DatasetRerankingModelResponse = { reranking_provider_name?: string | null } +export type RetrievalMethod = + | 'full_text_search' + | 'hybrid_search' + | 'keyword_search' + | 'semantic_search' + export type DatasetWeightedScoreResponse = { keyword_setting?: DatasetKeywordSettingResponse vector_setting?: DatasetVectorSettingResponse @@ -1020,12 +1063,6 @@ export type RerankingModel = { reranking_provider_name?: string | null } -export type RetrievalMethod = - | 'full_text_search' - | 'hybrid_search' - | 'keyword_search' - | 'semantic_search' - export type WeightModel = { keyword_setting?: WeightKeywordSetting | null vector_setting?: WeightVectorSetting | null @@ -1080,10 +1117,6 @@ export type SegmentAttachmentResponse = { source_url: string } -export type SegmentStatus = 'completed' | 'error' | 'indexing' | 'paused' | 're_segment' | 'waiting' - -export type SegmentType = 'automatic' | 'customized' - export type HitTestingChildChunk = { content: string id: string @@ -1126,12 +1159,28 @@ export type HitTestingSegment = { word_count: number } +export type CreatorUserRole = 'account' | 'end_user' + export type DatasetQueryContentResponse = { content: string - content_type: string + content_type: QueryType file_info?: DatasetQueryFileInfoResponse | null } +export type DatasetQuerySource = 'app' | 'hit_testing' + +export type IconType = 'emoji' | 'image' | 'link' + +export type AppMode = + | 'advanced-chat' + | 'agent' + | 'agent-chat' + | 'channel' + | 'chat' + | 'completion' + | 'rag-pipeline' + | 'workflow' + export type DatasetKeywordSettingResponse = { keyword_weight?: number | null } @@ -1216,6 +1265,8 @@ export type HitTestingDocument = { name: string } +export type QueryType = 'image_query' | 'text_query' + export type DatasetQueryFileInfoResponse = { extension: string id: string diff --git a/packages/contracts/generated/api/console/datasets/zod.gen.ts b/packages/contracts/generated/api/console/datasets/zod.gen.ts index 9214e67b6386dc..e6729446699eae 100644 --- a/packages/contracts/generated/api/console/datasets/zod.gen.ts +++ b/packages/contracts/generated/api/console/datasets/zod.gen.ts @@ -278,14 +278,26 @@ export const zDocumentStatusResponse = z.object({ completed_at: z.int().nullable(), completed_segments: z.int().nullish(), error: z.string().nullable(), + error_code: z.literal('vector_space_estimate_exceeded').nullish(), + estimated_vector_space_mb: z.int().nullish(), id: z.string(), - indexing_status: z.string(), + indexing_status: z.enum([ + 'cleaning', + 'completed', + 'error', + 'indexing', + 'parsing', + 'paused', + 'splitting', + 'waiting', + ]), parsing_completed_at: z.int().nullable(), paused_at: z.int().nullable(), processing_started_at: z.int().nullable(), splitting_completed_at: z.int().nullable(), stopped_at: z.int().nullable(), total_segments: z.int().nullish(), + vector_space_limit_mb: z.int().nullish(), }) /** @@ -538,7 +550,7 @@ export const zDatasetUpdatePayload = z.object({ export const zDatasetDocMetadataResponse = z.object({ id: z.string(), name: z.string(), - type: z.string(), + type: z.enum(['number', 'string', 'time']), }) /** @@ -570,6 +582,11 @@ export const zDatasetIconInfoResponse = z.object({ icon_url: z.string().nullish(), }) +/** + * IndexTechniqueType + */ +export const zIndexTechniqueType = z.enum(['economy', 'high_quality']) + /** * DatasetSummaryIndexSettingResponse */ @@ -725,6 +742,45 @@ export const zDocumentMetadataResponse = z.object({ value: z.union([z.string(), z.int(), z.number()]).nullable(), }) +/** + * ChildChunkResponse + */ +export const zChildChunkResponse = z.object({ + content: z.string(), + created_at: z.int(), + id: z.string(), + position: z.int(), + segment_id: z.string(), + type: z.enum(['automatic', 'customized']), + updated_at: z.int(), + word_count: z.int(), +}) + +/** + * ChildChunkListResponse + */ +export const zChildChunkListResponse = z.object({ + data: z.array(zChildChunkResponse), + limit: z.int(), + page: z.int(), + total: z.int(), + total_pages: z.int(), +}) + +/** + * ChildChunkBatchUpdateResponse + */ +export const zChildChunkBatchUpdateResponse = z.object({ + data: z.array(zChildChunkResponse), +}) + +/** + * ChildChunkDetailResponse + */ +export const zChildChunkDetailResponse = z.object({ + data: zChildChunkResponse, +}) + /** * ChildChunkUpdateArgs */ @@ -823,28 +879,6 @@ export const zDatasetMetadataListResponse = z.object({ doc_metadata: z.array(zDatasetMetadataListItemResponse), }) -/** - * RelatedAppResponse - */ -export const zRelatedAppResponse = z.object({ - description: z.string(), - icon: z.string().nullable(), - icon_background: z.string().nullable(), - icon_type: z.string().nullable(), - icon_url: z.string().nullish(), - id: z.string(), - mode: z.string(), - name: z.string(), -}) - -/** - * RelatedAppListResponse - */ -export const zRelatedAppListResponse = z.object({ - data: z.array(zRelatedAppResponse), - total: z.int(), -}) - /** * DatasetRerankingModelResponse */ @@ -853,14 +887,6 @@ export const zDatasetRerankingModelResponse = z.object({ reranking_provider_name: z.string().nullish(), }) -/** - * RerankingModel - */ -export const zRerankingModel = z.object({ - reranking_model_name: z.string().nullish(), - reranking_provider_name: z.string().nullish(), -}) - /** * RetrievalMethod */ @@ -871,6 +897,14 @@ export const zRetrievalMethod = z.enum([ 'semantic_search', ]) +/** + * RerankingModel + */ +export const zRerankingModel = z.object({ + reranking_model_name: z.string().nullish(), + reranking_provider_name: z.string().nullish(), +}) + /** * Segmentation */ @@ -980,66 +1014,6 @@ export const zSegmentAttachmentResponse = z.object({ source_url: z.string(), }) -/** - * SegmentStatus - * - * Document segment status - */ -export const zSegmentStatus = z.enum([ - 'completed', - 'error', - 'indexing', - 'paused', - 're_segment', - 'waiting', -]) - -/** - * SegmentType - * - * Document segment type - */ -export const zSegmentType = z.enum(['automatic', 'customized']) - -/** - * ChildChunkResponse - */ -export const zChildChunkResponse = z.object({ - content: z.string(), - created_at: z.int(), - id: z.string(), - position: z.int(), - segment_id: z.string(), - type: zSegmentType, - updated_at: z.int(), - word_count: z.int(), -}) - -/** - * ChildChunkListResponse - */ -export const zChildChunkListResponse = z.object({ - data: z.array(zChildChunkResponse), - limit: z.int(), - page: z.int(), - total: z.int(), - total_pages: z.int(), -}) - -/** - * ChildChunkBatchUpdateResponse - */ -export const zChildChunkBatchUpdateResponse = z.object({ - data: z.array(zChildChunkResponse), -}) - -/** - * ChildChunkDetailResponse - */ -export const zChildChunkDetailResponse = z.object({ - data: zChildChunkResponse, -}) - /** * SegmentResponse */ @@ -1064,7 +1038,7 @@ export const zSegmentResponse = z.object({ keywords: z.array(z.string()).nullable(), position: z.int(), sign_content: z.string(), - status: zSegmentStatus, + status: z.enum(['completed', 'error', 'indexing', 'paused', 're_segment', 'waiting']), stopped_at: z.int().nullable(), summary: z.string().nullable(), tokens: z.int(), @@ -1114,6 +1088,59 @@ export const zHitTestingFile = z.object({ source_url: z.string(), }) +/** + * CreatorUserRole + */ +export const zCreatorUserRole = z.enum(['account', 'end_user']) + +/** + * DatasetQuerySource + * + * Dataset query source + */ +export const zDatasetQuerySource = z.enum(['app', 'hit_testing']) + +/** + * IconType + */ +export const zIconType = z.enum(['emoji', 'image', 'link']) + +/** + * AppMode + */ +export const zAppMode = z.enum([ + 'advanced-chat', + 'agent', + 'agent-chat', + 'channel', + 'chat', + 'completion', + 'rag-pipeline', + 'workflow', +]) + +/** + * RelatedAppResponse + */ +export const zRelatedAppResponse = z.object({ + description: z.string(), + icon: z.string().nullable(), + icon_background: z.string().nullable(), + icon_type: zIconType.nullable(), + icon_url: z.string().nullish(), + id: z.string(), + mode: zAppMode, + name: z.string(), +}) + +/** + * RelatedAppListResponse + */ +export const zRelatedAppListResponse = z.object({ + data: z.array(zRelatedAppResponse), + total: z.int(), +}) + /** * DatasetKeywordSettingResponse */ @@ -1148,7 +1175,7 @@ export const zDatasetRetrievalModelResponse = z.object({ reranking_model: zDatasetRerankingModelResponse.optional(), score_threshold: z.number().nullish(), score_threshold_enabled: z.boolean(), - search_method: z.string(), + search_method: zRetrievalMethod, top_k: z.int(), weights: zDatasetWeightedScoreResponse.nullish(), }) @@ -1163,7 +1190,16 @@ export const zDatasetDetailWithPartialMembersResponse = z.object({ chunk_structure: z.string().nullable(), created_at: z.int(), created_by: z.string(), - data_source_type: z.string().nullable(), + data_source_type: z + .enum([ + 'local_file', + 'notion_import', + 'online_document', + 'online_drive', + 'upload_file', + 'website_crawl', + ]) + .nullable(), description: z.string().nullable(), doc_form: z.string().nullable(), doc_metadata: z.array(zDatasetDocMetadataResponse), @@ -1176,18 +1212,19 @@ export const zDatasetDetailWithPartialMembersResponse = z.object({ external_retrieval_model: zDatasetExternalRetrievalModelResponse.nullable(), icon_info: zDatasetIconInfoResponse.optional(), id: z.string(), - indexing_technique: z.string().nullable(), + indexing_technique: zIndexTechniqueType.nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + keyword_number: z.int().nullish(), maintainer: z.string().nullish(), name: z.string(), partial_member_list: z.array(z.string()).nullish(), - permission: z.string(), + permission: z.enum(['all_team_members', 'only_me', 'partial_members']), permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, - runtime_mode: z.string().nullable(), + runtime_mode: z.enum(['general', 'rag_pipeline']).nullable(), summary_index_setting: zDatasetSummaryIndexSettingResponse.optional(), tags: z.array(zDatasetTagResponse), total_available_documents: z.int(), @@ -1207,7 +1244,16 @@ export const zDatasetDetailResponse = z.object({ chunk_structure: z.string().nullable(), created_at: z.int(), created_by: z.string(), - data_source_type: z.string().nullable(), + data_source_type: z + .enum([ + 'local_file', + 'notion_import', + 'online_document', + 'online_drive', + 'upload_file', + 'website_crawl', + ]) + .nullable(), description: z.string().nullable(), doc_form: z.string().nullable(), doc_metadata: z.array(zDatasetDocMetadataResponse), @@ -1220,17 +1266,18 @@ export const zDatasetDetailResponse = z.object({ external_retrieval_model: zDatasetExternalRetrievalModelResponse.nullable(), icon_info: zDatasetIconInfoResponse.optional(), id: z.string(), - indexing_technique: z.string().nullable(), + indexing_technique: zIndexTechniqueType.nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + keyword_number: z.int().nullish(), maintainer: z.string().nullish(), name: z.string(), - permission: z.string(), + permission: z.enum(['all_team_members', 'only_me', 'partial_members']), permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, - runtime_mode: z.string().nullable(), + runtime_mode: z.enum(['general', 'rag_pipeline']).nullable(), summary_index_setting: zDatasetSummaryIndexSettingResponse.optional(), tags: z.array(zDatasetTagResponse), total_available_documents: z.int(), @@ -1250,7 +1297,16 @@ export const zDatasetListItemResponse = z.object({ chunk_structure: z.string().nullable(), created_at: z.int(), created_by: z.string(), - data_source_type: z.string().nullable(), + data_source_type: z + .enum([ + 'local_file', + 'notion_import', + 'online_document', + 'online_drive', + 'upload_file', + 'website_crawl', + ]) + .nullable(), description: z.string().nullable(), doc_form: z.string().nullable(), doc_metadata: z.array(zDatasetDocMetadataResponse), @@ -1263,18 +1319,19 @@ export const zDatasetListItemResponse = z.object({ external_retrieval_model: zDatasetExternalRetrievalModelResponse.nullable(), icon_info: zDatasetIconInfoResponse.optional(), id: z.string(), - indexing_technique: z.string().nullable(), + indexing_technique: zIndexTechniqueType.nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + keyword_number: z.int().nullish(), maintainer: z.string().nullish(), name: z.string(), partial_member_list: z.array(z.string()), - permission: z.string(), + permission: z.enum(['all_team_members', 'only_me', 'partial_members']), permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, - runtime_mode: z.string().nullable(), + runtime_mode: z.enum(['general', 'rag_pipeline']).nullable(), summary_index_setting: zDatasetSummaryIndexSettingResponse.optional(), tags: z.array(zDatasetTagResponse), total_available_documents: z.int(), @@ -1657,6 +1714,11 @@ export const zHitTestingResponse = z.object({ records: z.array(zHitTestingRecord), }) +/** + * QueryType + */ +export const zQueryType = z.enum(['image_query', 'text_query']) + /** * DatasetQueryFileInfoResponse */ @@ -1674,7 +1736,7 @@ export const zDatasetQueryFileInfoResponse = z.object({ */ export const zDatasetQueryContentResponse = z.object({ content: z.string(), - content_type: z.string(), + content_type: zQueryType, file_info: zDatasetQueryFileInfoResponse.nullish(), }) @@ -1684,10 +1746,10 @@ export const zDatasetQueryContentResponse = z.object({ export const zDatasetQueryDetailResponse = z.object({ created_at: z.int(), created_by: z.string(), - created_by_role: z.string(), + created_by_role: zCreatorUserRole, id: z.string(), queries: z.array(zDatasetQueryContentResponse), - source: z.string(), + source: zDatasetQuerySource, source_app_id: z.string().nullable(), }) diff --git a/packages/contracts/generated/api/console/rag/types.gen.ts b/packages/contracts/generated/api/console/rag/types.gen.ts index 5f1c737dcb5c7e..d8639a3ef1ece1 100644 --- a/packages/contracts/generated/api/console/rag/types.gen.ts +++ b/packages/contracts/generated/api/console/rag/types.gen.ts @@ -52,7 +52,14 @@ export type DatasetDetailResponse = { chunk_structure: string | null created_at: number created_by: string - data_source_type: string | null + data_source_type: + | 'local_file' + | 'notion_import' + | 'online_document' + | 'online_drive' + | 'upload_file' + | 'website_crawl' + | null description: string | null doc_form: string | null doc_metadata: Array @@ -65,17 +72,18 @@ export type DatasetDetailResponse = { external_retrieval_model: DatasetExternalRetrievalModelResponse | null icon_info?: DatasetIconInfoResponse id: string - indexing_technique: string | null + indexing_technique: IndexTechniqueType | null is_multimodal: boolean is_published: boolean + keyword_number?: number | null maintainer?: string | null name: string - permission: string + permission: 'all_team_members' | 'only_me' | 'partial_members' permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse - runtime_mode: string | null + runtime_mode: 'general' | 'rag_pipeline' | null summary_index_setting?: DatasetSummaryIndexSettingResponse tags: Array total_available_documents: number @@ -394,7 +402,7 @@ export type ImportStatus = 'completed' | 'completed-with-warnings' | 'failed' | export type DatasetDocMetadataResponse = { id: string name: string - type: string + type: 'number' | 'string' | 'time' } export type DatasetExternalKnowledgeInfoResponse = { @@ -417,13 +425,15 @@ export type DatasetIconInfoResponse = { icon_url?: string | null } +export type IndexTechniqueType = 'economy' | 'high_quality' + export type DatasetRetrievalModelResponse = { reranking_enable: boolean reranking_mode?: string | null reranking_model?: DatasetRerankingModelResponse score_threshold?: number | null score_threshold_enabled: boolean - search_method: string + search_method: RetrievalMethod top_k: number weights?: DatasetWeightedScoreResponse | null } @@ -630,6 +640,12 @@ export type DatasetRerankingModelResponse = { reranking_provider_name?: string | null } +export type RetrievalMethod = + | 'full_text_search' + | 'hybrid_search' + | 'keyword_search' + | 'semantic_search' + export type DatasetWeightedScoreResponse = { keyword_setting?: DatasetKeywordSettingResponse vector_setting?: DatasetVectorSettingResponse diff --git a/packages/contracts/generated/api/console/rag/zod.gen.ts b/packages/contracts/generated/api/console/rag/zod.gen.ts index f759c04d0cb28b..b0ea3725a061dc 100644 --- a/packages/contracts/generated/api/console/rag/zod.gen.ts +++ b/packages/contracts/generated/api/console/rag/zod.gen.ts @@ -295,7 +295,7 @@ export const zRagPipelineImportResponse = z.object({ export const zDatasetDocMetadataResponse = z.object({ id: z.string(), name: z.string(), - type: z.string(), + type: z.enum(['number', 'string', 'time']), }) /** @@ -327,6 +327,11 @@ export const zDatasetIconInfoResponse = z.object({ icon_url: z.string().nullish(), }) +/** + * IndexTechniqueType + */ +export const zIndexTechniqueType = z.enum(['economy', 'high_quality']) + /** * DatasetSummaryIndexSettingResponse */ @@ -653,6 +658,16 @@ export const zDatasetRerankingModelResponse = z.object({ reranking_provider_name: z.string().nullish(), }) +/** + * RetrievalMethod + */ +export const zRetrievalMethod = z.enum([ + 'full_text_search', + 'hybrid_search', + 'keyword_search', + 'semantic_search', +]) + /** * PluginDependencyType */ @@ -810,7 +825,7 @@ export const zDatasetRetrievalModelResponse = z.object({ reranking_model: zDatasetRerankingModelResponse.optional(), score_threshold: z.number().nullish(), score_threshold_enabled: z.boolean(), - search_method: z.string(), + search_method: zRetrievalMethod, top_k: z.int(), weights: zDatasetWeightedScoreResponse.nullish(), }) @@ -825,7 +840,16 @@ export const zDatasetDetailResponse = z.object({ chunk_structure: z.string().nullable(), created_at: z.int(), created_by: z.string(), - data_source_type: z.string().nullable(), + data_source_type: z + .enum([ + 'local_file', + 'notion_import', + 'online_document', + 'online_drive', + 'upload_file', + 'website_crawl', + ]) + .nullable(), description: z.string().nullable(), doc_form: z.string().nullable(), doc_metadata: z.array(zDatasetDocMetadataResponse), @@ -838,17 +862,18 @@ export const zDatasetDetailResponse = z.object({ external_retrieval_model: zDatasetExternalRetrievalModelResponse.nullable(), icon_info: zDatasetIconInfoResponse.optional(), id: z.string(), - indexing_technique: z.string().nullable(), + indexing_technique: zIndexTechniqueType.nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + keyword_number: z.int().nullish(), maintainer: z.string().nullish(), name: z.string(), - permission: z.string(), + permission: z.enum(['all_team_members', 'only_me', 'partial_members']), permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, - runtime_mode: z.string().nullable(), + runtime_mode: z.enum(['general', 'rag_pipeline']).nullable(), summary_index_setting: zDatasetSummaryIndexSettingResponse.optional(), tags: z.array(zDatasetTagResponse), total_available_documents: z.int(), diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index a5d0c739b84522..2f479e7204fca3 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -354,7 +354,7 @@ export type ChildChunkResponse = { id: string position: number segment_id: string - type: SegmentType + type: 'automatic' | 'customized' updated_at: number word_count: number } @@ -593,7 +593,14 @@ export type DatasetDetailResponse = { chunk_structure: string | null created_at: number created_by: string - data_source_type: string | null + data_source_type: + | 'local_file' + | 'notion_import' + | 'online_document' + | 'online_drive' + | 'upload_file' + | 'website_crawl' + | null description: string | null doc_form: string | null doc_metadata: Array @@ -606,16 +613,16 @@ export type DatasetDetailResponse = { external_retrieval_model: DatasetExternalRetrievalModelResponse | null icon_info?: DatasetIconInfoResponse id: string - indexing_technique: string | null + indexing_technique: IndexTechniqueType | null is_multimodal: boolean is_published: boolean maintainer?: string | null name: string - permission: string + permission: 'all_team_members' | 'only_me' | 'partial_members' pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse - runtime_mode: string | null + runtime_mode: 'general' | 'rag_pipeline' | null summary_index_setting?: DatasetSummaryIndexSettingResponse tags: Array total_available_documents: number @@ -632,7 +639,14 @@ export type DatasetDetailWithPartialMembersResponse = { chunk_structure: string | null created_at: number created_by: string - data_source_type: string | null + data_source_type: + | 'local_file' + | 'notion_import' + | 'online_document' + | 'online_drive' + | 'upload_file' + | 'website_crawl' + | null description: string | null doc_form: string | null doc_metadata: Array @@ -645,17 +659,17 @@ export type DatasetDetailWithPartialMembersResponse = { external_retrieval_model: DatasetExternalRetrievalModelResponse | null icon_info?: DatasetIconInfoResponse id: string - indexing_technique: string | null + indexing_technique: IndexTechniqueType | null is_multimodal: boolean is_published: boolean maintainer?: string | null name: string partial_member_list?: Array | null - permission: string + permission: 'all_team_members' | 'only_me' | 'partial_members' pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse - runtime_mode: string | null + runtime_mode: 'general' | 'rag_pipeline' | null summary_index_setting?: DatasetSummaryIndexSettingResponse tags: Array total_available_documents: number @@ -668,7 +682,7 @@ export type DatasetDetailWithPartialMembersResponse = { export type DatasetDocMetadataResponse = { id: string name: string - type: string + type: 'number' | 'string' | 'time' } export type DatasetExternalKnowledgeInfoResponse = { @@ -758,7 +772,7 @@ export type DatasetRetrievalModelResponse = { reranking_model?: DatasetRerankingModelResponse score_threshold?: number | null score_threshold_enabled: boolean - search_method: string + search_method: RetrievalMethod top_k: number weights?: DatasetWeightedScoreResponse | null } @@ -983,10 +997,18 @@ export type DocumentStatusResponse = { completed_at: number | null completed_segments?: number | null error: string | null - error_code?: string | null + error_code?: 'vector_space_estimate_exceeded' | null estimated_vector_space_mb?: number | null id: string - indexing_status: string + indexing_status: + | 'cleaning' + | 'completed' + | 'error' + | 'indexing' + | 'parsing' + | 'paused' + | 'splitting' + | 'waiting' parsing_completed_at: number | null paused_at: number | null processing_started_at: number | null @@ -1328,6 +1350,8 @@ export type IndexInfoResponse = { welcome: string } +export type IndexTechniqueType = 'economy' | 'high_quality' + export type InvalidActionError = ErrorResponse & { code: 'invalid_action' status: 400 @@ -2120,7 +2144,7 @@ export type SegmentResponse = { keywords: Array | null position: number sign_content: string - status: SegmentStatus + status: 'completed' | 'error' | 'indexing' | 'paused' | 're_segment' | 'waiting' stopped_at: number | null summary: string | null tokens: number @@ -2129,10 +2153,6 @@ export type SegmentResponse = { word_count: number } -export type SegmentStatus = 'completed' | 'error' | 'indexing' | 'paused' | 're_segment' | 'waiting' - -export type SegmentType = 'automatic' | 'customized' - export type SegmentUpdateArgs = { answer?: string | null attachment_ids?: Array | null diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index c336227097f178..dc091de05887b2 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -323,6 +323,38 @@ export const zChildChunkListQuery = z.object({ page: z.int().gte(1).optional().default(1), }) +/** + * ChildChunkResponse + */ +export const zChildChunkResponse = z.object({ + content: z.string(), + created_at: z.int(), + id: z.string(), + position: z.int(), + segment_id: z.string(), + type: z.enum(['automatic', 'customized']), + updated_at: z.int(), + word_count: z.int(), +}) + +/** + * ChildChunkDetailResponse + */ +export const zChildChunkDetailResponse = z.object({ + data: zChildChunkResponse, +}) + +/** + * ChildChunkListResponse + */ +export const zChildChunkListResponse = z.object({ + data: z.array(zChildChunkResponse), + limit: z.int(), + page: z.int(), + total: z.int(), + total_pages: z.int(), +}) + /** * ChildChunkUpdatePayload */ @@ -585,7 +617,7 @@ export const zDatasetBoundTagListResponse = z.object({ export const zDatasetDocMetadataResponse = z.object({ id: z.string(), name: z.string(), - type: z.string(), + type: z.enum(['number', 'string', 'time']), }) /** @@ -729,116 +761,6 @@ export const zDatasetWeightedScoreResponse = z.object({ weight_type: z.string().nullish(), }) -/** - * DatasetRetrievalModelResponse - */ -export const zDatasetRetrievalModelResponse = z.object({ - reranking_enable: z.boolean(), - reranking_mode: z.string().nullish(), - reranking_model: zDatasetRerankingModelResponse.optional(), - score_threshold: z.number().nullish(), - score_threshold_enabled: z.boolean(), - search_method: z.string(), - top_k: z.int(), - weights: zDatasetWeightedScoreResponse.nullish(), -}) - -/** - * DatasetDetailResponse - */ -export const zDatasetDetailResponse = z.object({ - app_count: z.int(), - author_name: z.string().nullable(), - built_in_field_enabled: z.boolean(), - chunk_structure: z.string().nullable(), - created_at: z.int(), - created_by: z.string(), - data_source_type: z.string().nullable(), - description: z.string().nullable(), - doc_form: z.string().nullable(), - doc_metadata: z.array(zDatasetDocMetadataResponse), - document_count: z.int(), - embedding_available: z.boolean().nullish(), - embedding_model: z.string().nullable(), - embedding_model_provider: z.string().nullable(), - enable_api: z.boolean(), - external_knowledge_info: zDatasetExternalKnowledgeInfoResponse.optional(), - external_retrieval_model: zDatasetExternalRetrievalModelResponse.nullable(), - icon_info: zDatasetIconInfoResponse.optional(), - id: z.string(), - indexing_technique: z.string().nullable(), - is_multimodal: z.boolean(), - is_published: z.boolean(), - maintainer: z.string().nullish(), - name: z.string(), - permission: z.string(), - pipeline_id: z.string().nullable(), - provider: z.string(), - retrieval_model_dict: zDatasetRetrievalModelResponse, - runtime_mode: z.string().nullable(), - summary_index_setting: zDatasetSummaryIndexSettingResponse.optional(), - tags: z.array(zDatasetTagResponse), - total_available_documents: z.int(), - total_documents: z.int(), - updated_at: z.int(), - updated_by: z.string().nullable(), - word_count: z.int(), -}) - -/** - * DatasetDetailWithPartialMembersResponse - */ -export const zDatasetDetailWithPartialMembersResponse = z.object({ - app_count: z.int(), - author_name: z.string().nullable(), - built_in_field_enabled: z.boolean(), - chunk_structure: z.string().nullable(), - created_at: z.int(), - created_by: z.string(), - data_source_type: z.string().nullable(), - description: z.string().nullable(), - doc_form: z.string().nullable(), - doc_metadata: z.array(zDatasetDocMetadataResponse), - document_count: z.int(), - embedding_available: z.boolean().nullish(), - embedding_model: z.string().nullable(), - embedding_model_provider: z.string().nullable(), - enable_api: z.boolean(), - external_knowledge_info: zDatasetExternalKnowledgeInfoResponse.optional(), - external_retrieval_model: zDatasetExternalRetrievalModelResponse.nullable(), - icon_info: zDatasetIconInfoResponse.optional(), - id: z.string(), - indexing_technique: z.string().nullable(), - is_multimodal: z.boolean(), - is_published: z.boolean(), - maintainer: z.string().nullish(), - name: z.string(), - partial_member_list: z.array(z.string()).nullish(), - permission: z.string(), - pipeline_id: z.string().nullable(), - provider: z.string(), - retrieval_model_dict: zDatasetRetrievalModelResponse, - runtime_mode: z.string().nullable(), - summary_index_setting: zDatasetSummaryIndexSettingResponse.optional(), - tags: z.array(zDatasetTagResponse), - total_available_documents: z.int(), - total_documents: z.int(), - updated_at: z.int(), - updated_by: z.string().nullable(), - word_count: z.int(), -}) - -/** - * DatasetListResponse - */ -export const zDatasetListResponse = z.object({ - data: z.array(zDatasetDetailResponse), - has_more: z.boolean(), - limit: z.int(), - page: z.int(), - total: z.int(), -}) - /** * DatasourceCredentialInfoResponse */ @@ -976,10 +898,19 @@ export const zDocumentStatusResponse = z.object({ completed_at: z.int().nullable(), completed_segments: z.int().nullish(), error: z.string().nullable(), - error_code: z.string().nullish(), + error_code: z.literal('vector_space_estimate_exceeded').nullish(), estimated_vector_space_mb: z.int().nullish(), id: z.string(), - indexing_status: z.string(), + indexing_status: z.enum([ + 'cleaning', + 'completed', + 'error', + 'indexing', + 'parsing', + 'paused', + 'splitting', + 'waiting', + ]), parsing_completed_at: z.int().nullable(), paused_at: z.int().nullable(), processing_started_at: z.int().nullable(), @@ -1421,6 +1352,11 @@ export const zIndexInfoResponse = z.object({ welcome: z.string(), }) +/** + * IndexTechniqueType + */ +export const zIndexTechniqueType = z.enum(['economy', 'high_quality']) + /** * InvalidActionError * @@ -2094,6 +2030,134 @@ export const zRetrievalMethod = z.enum([ 'semantic_search', ]) +/** + * DatasetRetrievalModelResponse + */ +export const zDatasetRetrievalModelResponse = z.object({ + reranking_enable: z.boolean(), + reranking_mode: z.string().nullish(), + reranking_model: zDatasetRerankingModelResponse.optional(), + score_threshold: z.number().nullish(), + score_threshold_enabled: z.boolean(), + search_method: zRetrievalMethod, + top_k: z.int(), + weights: zDatasetWeightedScoreResponse.nullish(), +}) + +/** + * DatasetDetailResponse + */ +export const zDatasetDetailResponse = z.object({ + app_count: z.int(), + author_name: z.string().nullable(), + built_in_field_enabled: z.boolean(), + chunk_structure: z.string().nullable(), + created_at: z.int(), + created_by: z.string(), + data_source_type: z + .enum([ + 'local_file', + 'notion_import', + 'online_document', + 'online_drive', + 'upload_file', + 'website_crawl', + ]) + .nullable(), + description: z.string().nullable(), + doc_form: z.string().nullable(), + doc_metadata: z.array(zDatasetDocMetadataResponse), + document_count: z.int(), + embedding_available: z.boolean().nullish(), + embedding_model: z.string().nullable(), + embedding_model_provider: z.string().nullable(), + enable_api: z.boolean(), + external_knowledge_info: zDatasetExternalKnowledgeInfoResponse.optional(), + external_retrieval_model: zDatasetExternalRetrievalModelResponse.nullable(), + icon_info: zDatasetIconInfoResponse.optional(), + id: z.string(), + indexing_technique: zIndexTechniqueType.nullable(), + is_multimodal: z.boolean(), + is_published: z.boolean(), + maintainer: z.string().nullish(), + name: z.string(), + permission: z.enum(['all_team_members', 'only_me', 'partial_members']), + pipeline_id: z.string().nullable(), + provider: z.string(), + retrieval_model_dict: zDatasetRetrievalModelResponse, + runtime_mode: z.enum(['general', 'rag_pipeline']).nullable(), + summary_index_setting: zDatasetSummaryIndexSettingResponse.optional(), + tags: z.array(zDatasetTagResponse), + total_available_documents: z.int(), + total_documents: z.int(), + updated_at: z.int(), + updated_by: z.string().nullable(), + word_count: z.int(), +}) + +/** + * DatasetDetailWithPartialMembersResponse + */ +export const zDatasetDetailWithPartialMembersResponse = z.object({ + app_count: z.int(), + author_name: z.string().nullable(), + built_in_field_enabled: z.boolean(), + chunk_structure: z.string().nullable(), + created_at: z.int(), + created_by: z.string(), + data_source_type: z + .enum([ + 'local_file', + 'notion_import', + 'online_document', + 'online_drive', + 'upload_file', + 'website_crawl', + ]) + .nullable(), + description: z.string().nullable(), + doc_form: z.string().nullable(), + doc_metadata: z.array(zDatasetDocMetadataResponse), + document_count: z.int(), + embedding_available: z.boolean().nullish(), + embedding_model: z.string().nullable(), + embedding_model_provider: z.string().nullable(), + enable_api: z.boolean(), + external_knowledge_info: zDatasetExternalKnowledgeInfoResponse.optional(), + external_retrieval_model: zDatasetExternalRetrievalModelResponse.nullable(), + icon_info: zDatasetIconInfoResponse.optional(), + id: z.string(), + indexing_technique: zIndexTechniqueType.nullable(), + is_multimodal: z.boolean(), + is_published: z.boolean(), + maintainer: z.string().nullish(), + name: z.string(), + partial_member_list: z.array(z.string()).nullish(), + permission: z.enum(['all_team_members', 'only_me', 'partial_members']), + pipeline_id: z.string().nullable(), + provider: z.string(), + retrieval_model_dict: zDatasetRetrievalModelResponse, + runtime_mode: z.enum(['general', 'rag_pipeline']).nullable(), + summary_index_setting: zDatasetSummaryIndexSettingResponse.optional(), + tags: z.array(zDatasetTagResponse), + total_available_documents: z.int(), + total_documents: z.int(), + updated_at: z.int(), + updated_by: z.string().nullable(), + word_count: z.int(), +}) + +/** + * DatasetListResponse + */ +export const zDatasetListResponse = z.object({ + data: z.array(zDatasetDetailResponse), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + /** * RetrieverResource */ @@ -2168,59 +2232,6 @@ export const zSegmentListQuery = z.object({ status: z.array(z.string()).optional(), }) -/** - * SegmentStatus - * - * Document segment status - */ -export const zSegmentStatus = z.enum([ - 'completed', - 'error', - 'indexing', - 'paused', - 're_segment', - 'waiting', -]) - -/** - * SegmentType - * - * Document segment type - */ -export const zSegmentType = z.enum(['automatic', 'customized']) - -/** - * ChildChunkResponse - */ -export const zChildChunkResponse = z.object({ - content: z.string(), - created_at: z.int(), - id: z.string(), - position: z.int(), - segment_id: z.string(), - type: zSegmentType, - updated_at: z.int(), - word_count: z.int(), -}) - -/** - * ChildChunkDetailResponse - */ -export const zChildChunkDetailResponse = z.object({ - data: zChildChunkResponse, -}) - -/** - * ChildChunkListResponse - */ -export const zChildChunkListResponse = z.object({ - data: z.array(zChildChunkResponse), - limit: z.int(), - page: z.int(), - total: z.int(), - total_pages: z.int(), -}) - /** * SegmentResponse */ @@ -2245,7 +2256,7 @@ export const zSegmentResponse = z.object({ keywords: z.array(z.string()).nullable(), position: z.int(), sign_content: z.string(), - status: zSegmentStatus, + status: z.enum(['completed', 'error', 'indexing', 'paused', 're_segment', 'waiting']), stopped_at: z.int().nullable(), summary: z.string().nullable(), tokens: z.int(), diff --git a/web/__tests__/api-key-management.spec.tsx b/web/__tests__/api-key-management.spec.tsx index 140d82ff6f14ec..0c34726d3be8fa 100644 --- a/web/__tests__/api-key-management.spec.tsx +++ b/web/__tests__/api-key-management.spec.tsx @@ -8,6 +8,7 @@ import { ApiSecretKeyButton } from '@/app/components/app/access-point/built-in-a import { EnvironmentApiKeyButton } from '@/app/components/app/access-point/deployed-environment-access-points/environment-api-key-button' import { ServiceApi } from '@/app/components/datasets/extra-info/service-api' import { render } from '@/test/console/render' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { createTestQueryClient } from '@/test/query-client' vi.mock('@/context/permission-state', async () => { @@ -46,6 +47,14 @@ vi.mock('@/service/console/browser', () => ({ return apiMocks.createApp(input) case 'apps.byResourceId.apiKeys.byApiKeyId.delete': return apiMocks.deleteApp(input) + case 'datasets.get': { + const keyword = (input as { query?: { keyword?: string } }).query?.keyword ?? '' + const data = [ + createDatasetFixture({ id: 'engineering', name: 'Engineering' }), + createDatasetFixture({ id: 'support', name: 'Support' }), + ].filter((dataset) => dataset.name.toLowerCase().includes(keyword.toLowerCase())) + return { data, has_more: false, limit: 20, page: 1, total: data.length } + } case 'datasets.apiKeys.get': await apiMocks.listDataset() return { data: apiMocks.datasetKeys } @@ -67,21 +76,6 @@ vi.mock('@/service/console/browser', () => ({ }, })) -vi.mock('@/service/knowledge/use-dataset', () => ({ - useInfiniteDatasets: ({ keyword = '' }: { keyword?: string }) => ({ - data: { - pages: [ - { - data: [ - { id: 'engineering', name: 'Engineering' }, - { id: 'support', name: 'Support' }, - ].filter((dataset) => dataset.name.toLowerCase().includes(keyword.toLowerCase())), - }, - ], - }, - }), -})) - const mockCurrentWorkspace = vi.fn().mockReturnValue({ id: 'workspace-1', name: 'Test Workspace', diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx index 6896f16bc1749e..ece92fc09d44e5 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx @@ -1,6 +1,5 @@ import { screen, waitFor } from '@testing-library/react' import { createHttpError } from '@/service/http-error' -import { useDatasetDetail } from '@/service/knowledge/use-dataset' import { renderWithConsoleQuery } from '@/test/console/query-data' import { DatasetACLPermission } from '@/utils/permission' import CreateDocumentsPage from '../documents/create/page' @@ -22,9 +21,7 @@ const render = (ui: Parameters[0]) => vi.mock('@/next/navigation', () => mockNavigation) -vi.mock('@/service/knowledge/use-dataset', () => ({ - useDatasetDetail: vi.fn(), -})) +const { mockDatasetDetailQuery } = vi.hoisted(() => ({ mockDatasetDetailQuery: vi.fn() })) vi.mock('nuqs', async (importOriginal) => ({ ...(await importOriginal()), @@ -38,6 +35,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.get': mockDatasetDetailQuery, 'auth.plugin.datasource.defaultList.get': () => ({ data: { result: [] }, isLoading: false, @@ -82,7 +80,6 @@ vi.mock('@/context/event-emitter', () => ({ const mockUseRouter = mockNavigation.useRouter const mockUsePathname = mockNavigation.usePathname -const mockUseDatasetDetail = vi.mocked(useDatasetDetail) describe('DatasetDetailLayout', () => { beforeEach(() => { @@ -107,7 +104,7 @@ describe('DatasetDetailLayout', () => { ['/datasets/dataset-1/api', 'common.appMenus.apiAccess'], ])('identifies the current detail page for %s', async (pathname, pageTitle) => { mockUsePathname.mockReturnValue(pathname) - mockUseDatasetDetail.mockReturnValue({ + mockDatasetDetailQuery.mockReturnValue({ data: { id: 'dataset-1', name: 'Dataset 1', @@ -118,7 +115,7 @@ describe('DatasetDetailLayout', () => { }, error: null, refetch: vi.fn(), - } as unknown as ReturnType) + }) render( @@ -136,7 +133,7 @@ describe('DatasetDetailLayout', () => { '/datasets/dataset-1/documents/document-1/settings', ])('delegates the document title for %s to the document page', (pathname) => { mockUsePathname.mockReturnValue(pathname) - mockUseDatasetDetail.mockReturnValue({ + mockDatasetDetailQuery.mockReturnValue({ data: { id: 'dataset-1', name: 'Dataset 1', @@ -147,7 +144,7 @@ describe('DatasetDetailLayout', () => { }, error: null, refetch: vi.fn(), - } as unknown as ReturnType) + }) render( @@ -160,7 +157,7 @@ describe('DatasetDetailLayout', () => { it('keeps the dataset title when the document creation route is composed', async () => { mockUsePathname.mockReturnValue('/datasets/dataset-1/documents/create') - mockUseDatasetDetail.mockReturnValue({ + mockDatasetDetailQuery.mockReturnValue({ data: { id: 'dataset-1', name: 'Dataset 1', @@ -171,7 +168,7 @@ describe('DatasetDetailLayout', () => { }, error: null, refetch: vi.fn(), - } as unknown as ReturnType) + }) const page = await CreateDocumentsPage({ params: Promise.resolve({ datasetId: 'dataset-1' }), }) @@ -190,11 +187,11 @@ describe('DatasetDetailLayout', () => { 'should redirect to datasets page when dataset detail returns %s', async (status) => { // Arrange - mockUseDatasetDetail.mockReturnValue({ + mockDatasetDetailQuery.mockReturnValue({ data: undefined, error: createHttpError(new Response(null, { status })), refetch: vi.fn(), - } as unknown as ReturnType) + }) // Act render( @@ -215,7 +212,7 @@ describe('DatasetDetailLayout', () => { describe('Rendering', () => { it('should render children when dataset detail is available', () => { // Arrange - mockUseDatasetDetail.mockReturnValue({ + mockDatasetDetailQuery.mockReturnValue({ data: { id: 'dataset-1', name: 'Dataset 1', @@ -225,7 +222,7 @@ describe('DatasetDetailLayout', () => { }, error: null, refetch: vi.fn(), - } as unknown as ReturnType) + }) // Act render( @@ -242,7 +239,7 @@ describe('DatasetDetailLayout', () => { it('should render document creation route content without owning the main skip target', () => { // Arrange mockUsePathname.mockReturnValue('/datasets/dataset-1/documents/create') - mockUseDatasetDetail.mockReturnValue({ + mockDatasetDetailQuery.mockReturnValue({ data: { id: 'dataset-1', name: 'Dataset 1', @@ -252,7 +249,7 @@ describe('DatasetDetailLayout', () => { }, error: null, refetch: vi.fn(), - } as unknown as ReturnType) + }) // Act render( @@ -271,7 +268,7 @@ describe('DatasetDetailLayout', () => { it('should redirect from hit testing when retrieval recall permission is missing', async () => { // Arrange mockUsePathname.mockReturnValue('/datasets/dataset-1/hitTesting') - mockUseDatasetDetail.mockReturnValue({ + mockDatasetDetailQuery.mockReturnValue({ data: { id: 'dataset-1', name: 'Dataset 1', @@ -282,7 +279,7 @@ describe('DatasetDetailLayout', () => { }, error: null, refetch: vi.fn(), - } as unknown as ReturnType) + }) // Act render( @@ -301,7 +298,7 @@ describe('DatasetDetailLayout', () => { it('should redirect from access config when access config permission is missing', async () => { // Arrange mockUsePathname.mockReturnValue('/datasets/dataset-1/access-config') - mockUseDatasetDetail.mockReturnValue({ + mockDatasetDetailQuery.mockReturnValue({ data: { id: 'dataset-1', name: 'Dataset 1', @@ -312,7 +309,7 @@ describe('DatasetDetailLayout', () => { }, error: null, refetch: vi.fn(), - } as unknown as ReturnType) + }) // Act render( @@ -331,7 +328,7 @@ describe('DatasetDetailLayout', () => { it('should render access config when access config permission is granted', () => { // Arrange mockUsePathname.mockReturnValue('/datasets/dataset-1/access-config') - mockUseDatasetDetail.mockReturnValue({ + mockDatasetDetailQuery.mockReturnValue({ data: { id: 'dataset-1', name: 'Dataset 1', @@ -342,7 +339,7 @@ describe('DatasetDetailLayout', () => { }, error: null, refetch: vi.fn(), - } as unknown as ReturnType) + }) // Act render( @@ -360,7 +357,7 @@ describe('DatasetDetailLayout', () => { // Arrange mockIsRbacEnabled = false mockUsePathname.mockReturnValue('/datasets/dataset-1/access-config') - mockUseDatasetDetail.mockReturnValue({ + mockDatasetDetailQuery.mockReturnValue({ data: { id: 'dataset-1', name: 'Dataset 1', @@ -371,7 +368,7 @@ describe('DatasetDetailLayout', () => { }, error: null, refetch: vi.fn(), - } as unknown as ReturnType) + }) // Act render( diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index aa434d00f23d08..d0ec9948e1ee94 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import type { DataSet } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import * as React from 'react' import { useEffect } from 'react' @@ -19,8 +19,8 @@ import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useDocumentTitle from '@/hooks/use-document-title' import { usePathname, useRouter } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { getHttpErrorStatus } from '@/service/http-error' -import { useDatasetDetail } from '@/service/knowledge/use-dataset' import { getDatasetACLCapabilities } from '@/utils/permission' type IAppDetailLayoutProps = { @@ -96,7 +96,16 @@ const DatasetDetailLayout: FC = (props) => { }) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId) + const { + data: datasetRes, + error, + refetch: mutateDatasetRes, + } = useQuery( + consoleQuery.datasets.byDatasetId.get.queryOptions({ + input: { params: { dataset_id: datasetId } }, + enabled: !!datasetId, + }), + ) const shouldRedirect = shouldRedirectToDatasetList(error) const datasetACLCapabilities = React.useMemo( () => @@ -158,7 +167,7 @@ const DatasetDetailLayout: FC = (props) => { > ([]) const [keyword, setKeyword] = useState('') - const { data: datasetsPages, isLoading } = useInfiniteDatasets( - { keyword }, - { enabled: scope === 'specific' }, + const { data: datasetsPages, isLoading } = useInfiniteQuery( + consoleQuery.datasets.get.infiniteOptions({ + input: (page: number) => ({ query: { page, ...(keyword ? { keyword } : {}) } }), + getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), + initialPageParam: 1, + enabled: scope === 'specific', + staleTime: 0, + refetchOnMount: 'always', + }), ) const datasets = useMemo( () => diff --git a/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx index 889a176b9082c6..52d5bde5d8a652 100644 --- a/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx @@ -31,10 +31,13 @@ vi.mock('@/context/permission-state', async () => { return createPermissionStateModuleMock(() => mockConsoleState.current) }) -vi.mock('@/service/knowledge/use-dataset', () => ({ - useDatasetDetail: () => ({ data: mockDataset, refetch: vi.fn() }), - useDatasetRelatedApps: () => ({ data: mockRelatedApps }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.get': () => ({ data: mockDataset, refetch: vi.fn() }), + 'datasets.byDatasetId.relatedApps.get': () => ({ data: mockRelatedApps }), + }) +}) vi.mock('../dataset-info', () => ({ default: () =>
dataset info
, diff --git a/web/app/components/app-sidebar/dataset-detail-section.tsx b/web/app/components/app-sidebar/dataset-detail-section.tsx index fe3dbcb670e0fd..b27c7addd8941f 100644 --- a/web/app/components/app-sidebar/dataset-detail-section.tsx +++ b/web/app/components/app-sidebar/dataset-detail-section.tsx @@ -2,7 +2,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { Separator } from '@langgenius/dify-ui/separator' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -12,7 +12,7 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { usePathname } from '@/next/navigation' -import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset' +import { consoleQuery } from '@/service/console' import { getDatasetACLCapabilities } from '@/utils/permission' import DatasetInfo from './dataset-info' import NavLink from './nav-link' @@ -37,10 +37,18 @@ const DatasetDetailSection = ({ expand = true }: DatasetDetailSectionProps) => { }) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = systemFeatures.rbac_enabled - const { data: datasetRes, refetch: mutateDatasetRes } = useDatasetDetail(datasetId ?? '') - const { data: relatedApps } = useDatasetRelatedApps(datasetId ?? '', { - enabled: !!datasetId && !!datasetRes, - }) + const { data: datasetRes, refetch: mutateDatasetRes } = useQuery( + consoleQuery.datasets.byDatasetId.get.queryOptions({ + input: { params: { dataset_id: datasetId ?? '' } }, + enabled: !!datasetId, + }), + ) + const { data: relatedApps } = useQuery( + consoleQuery.datasets.byDatasetId.relatedApps.get.queryOptions({ + input: { params: { dataset_id: datasetId ?? '' } }, + enabled: !!datasetId && !!datasetRes, + }), + ) const datasetACLCapabilities = useMemo( () => getDatasetACLCapabilities(datasetRes?.permission_keys, { @@ -127,7 +135,7 @@ const DatasetDetailSection = ({ expand = true }: DatasetDetailSectionProps) => { return ( [0]) => }) const createDataset = (overrides: Partial = {}): DataSet => ({ + ...createDatasetFixture(), id: 'dataset-1', name: 'Dataset Name', - indexing_status: 'completed', icon_info: { icon: '📙', icon_background: '#FFF4ED', @@ -52,7 +58,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ app_count: 0, doc_form: ChunkingMode.text, document_count: 1, - total_document_count: 1, word_count: 1000, provider: 'internal', embedding_model: 'text-embedding-3', @@ -66,14 +71,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ score_threshold_enabled: false, score_threshold: 0, }, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, - top_k: 5, - score_threshold_enabled: false, - score_threshold: 0, - }, tags: [], external_knowledge_info: { external_knowledge_id: '', @@ -112,15 +109,6 @@ vi.mock('@/context/permission-state', async () => { return createPermissionStateModuleMock(() => mockConsoleState.current) }) -vi.mock('@/service/knowledge/use-dataset', () => ({ - datasetDetailQueryKeyPrefix: ['dataset', 'detail'], - useInvalidDatasetList: () => mockInvalidDatasetList, -})) - -vi.mock('@/service/use-base', () => ({ - useInvalid: () => mockInvalidDatasetDetail, -})) - vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { @@ -184,8 +172,8 @@ describe('Dropdown callback coverage', () => { await user.click(screen.getByText('Success')) await waitFor(() => { - expect(mockInvalidDatasetList).toHaveBeenCalled() - expect(mockInvalidDatasetDetail).toHaveBeenCalled() + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: datasetListKey }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: datasetDetailKey }) }) }) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx index de3e9fa948ea66..0ac8030d0d6b11 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx @@ -1,9 +1,13 @@ import type { DataSet } from '@/models/datasets' +import { QueryClient } from '@tanstack/react-query' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { isEqual } from 'es-toolkit/compat' import * as React from 'react' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { consoleQuery } from '@/service/console' import { renderWithConsoleQuery } from '@/test/console/query-data' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD } from '@/types/app' import { DatasetACLPermission } from '@/utils/permission' import DatasetInfo from '..' @@ -12,8 +16,8 @@ import Dropdown from '../dropdown' let mockDataset: DataSet const mockPush = vi.fn() const mockReplace = vi.fn() -const mockInvalidDatasetList = vi.fn() -const mockInvalidDatasetDetail = vi.fn() +const invalidateQueries = vi.spyOn(QueryClient.prototype, 'invalidateQueries') +const datasetListKey = consoleQuery.datasets.get.key() const mockExportPipeline = vi.fn() const mockCheckIsUsedInApp = vi.fn() const mockDeleteDataset = vi.fn() @@ -33,9 +37,9 @@ const render = (ui: Parameters[0]) => }) const createDataset = (overrides: Partial = {}): DataSet => ({ + ...createDatasetFixture(), id: 'dataset-1', name: 'Dataset Name', - indexing_status: 'completed', icon_info: { icon: '📙', icon_background: '#FFF4ED', @@ -52,7 +56,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ app_count: 0, doc_form: ChunkingMode.text, document_count: 1, - total_document_count: 1, word_count: 1000, provider: 'internal', embedding_model: 'text-embedding-3', @@ -69,17 +72,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ score_threshold_enabled: false, score_threshold: 0, }, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 5, - score_threshold_enabled: false, - score_threshold: 0, - }, tags: [], external_knowledge_info: { external_knowledge_id: '', @@ -121,15 +113,6 @@ vi.mock('@/context/permission-state', async () => { return createPermissionStateModuleMock(() => mockConsoleState.current) }) -vi.mock('@/service/knowledge/use-dataset', () => ({ - datasetDetailQueryKeyPrefix: ['dataset', 'detail'], - useInvalidDatasetList: () => mockInvalidDatasetList, -})) - -vi.mock('@/service/use-base', () => ({ - useInvalid: () => mockInvalidDatasetDetail, -})) - vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { @@ -366,7 +349,11 @@ describe('Dropdown', () => { await waitFor(() => { expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1') }) - expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1) + expect( + invalidateQueries.mock.calls.filter(([filters]) => + isEqual(filters, { queryKey: datasetListKey }), + ), + ).toHaveLength(1) expect(mockReplace).toHaveBeenCalledWith('/datasets') }) diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index a496212f9c2678..d8b73f3f3eaf32 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -17,7 +17,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { IconButton } from '@langgenius/dify-ui/icon-button' -import { useMutation, useSuspenseQuery } from '@tanstack/react-query' +import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import * as React from 'react' import { useCallback, useState } from 'react' @@ -30,8 +30,6 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/console' import { checkIsUsedInApp, deleteDataset } from '@/service/datasets' -import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/knowledge/use-dataset' -import { useInvalid } from '@/service/use-base' import { downloadBlob } from '@/utils/download' import { getErrorMessage } from '@/utils/error-message' import { getDatasetACLCapabilities } from '@/utils/permission' @@ -79,8 +77,17 @@ const DropDown = ({ expand, triggerClassName }: DropDownProps) => { datasetACLCapabilities.canAccessConfig || datasetACLCapabilities.canDelete - const invalidDatasetList = useInvalidDatasetList() - const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, dataset.id]) + const queryClient = useQueryClient() + const invalidDatasetList = useCallback(() => { + queryClient.invalidateQueries({ queryKey: consoleQuery.datasets.get.key() }) + }, [queryClient]) + const invalidDatasetDetail = useCallback(() => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.get.queryKey({ + input: { params: { dataset_id: dataset.id } }, + }), + }) + }, [queryClient, dataset.id]) const refreshDataset = useCallback(() => { invalidDatasetList() diff --git a/web/app/components/app-sidebar/dataset-info/index.tsx b/web/app/components/app-sidebar/dataset-info/index.tsx index 32e70c8a39af61..f8c9241975cd30 100644 --- a/web/app/components/app-sidebar/dataset-info/index.tsx +++ b/web/app/components/app-sidebar/dataset-info/index.tsx @@ -3,6 +3,7 @@ import type { DataSet } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { toChunkingMode } from '@/app/components/datasets/dataset-fields' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useKnowledge } from '@/hooks/use-knowledge' import { DOC_FORM_TEXT } from '@/models/datasets' @@ -23,6 +24,7 @@ const DatasetInfo = ({ expand }: DatasetInfoProps) => { icon_url: '', } const isExternalProvider = dataset.provider === 'external' + const chunkingMode = toChunkingMode(dataset.doc_form) const { formatIndexingTechniqueAndMethod } = useKnowledge() return ( @@ -56,10 +58,10 @@ const DatasetInfo = ({ expand }: DatasetInfoProps) => { {isExternalProvider && ( {t(($) => $.externalTag, { ns: 'dataset' })} )} - {!!(!isExternalProvider && dataset.doc_form && dataset.indexing_technique) && ( + {!!(!isExternalProvider && chunkingMode && dataset.indexing_technique) && ( <> - {t(($) => $[`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`], { + {t(($) => $[`chunkingMode.${DOC_FORM_TEXT[chunkingMode]}`], { ns: 'dataset', })} diff --git a/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx index 75f3ac0ae2a8c6..adbc1cde66e208 100644 --- a/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx @@ -11,6 +11,7 @@ import { getSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowled import { DatasetPermission, DataSourceType } from '@/models/datasets' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { render as renderWithConsoleState } from '@/test/console/render' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { ModelModeType, RETRIEVE_TYPE } from '@/types/app' import { DatasetACLPermission, getDatasetACLCapabilities } from '@/utils/permission' import DatasetConfig from '../index' @@ -243,9 +244,9 @@ vi.mock('use-context-selector', () => ({ const createMockDataset = (overrides: Partial = {}): DataSet => { const defaultDataset: DataSet = { + ...createDatasetFixture(), id: 'dataset-1', name: 'Test Dataset', - indexing_status: 'completed' as any, icon_info: { icon: '📘', icon_type: 'emoji', @@ -264,7 +265,6 @@ const createMockDataset = (overrides: Partial = {}): DataSet => { app_count: 0, doc_form: 'text' as any, document_count: 10, - total_document_count: 10, total_available_documents: 10, word_count: 1000, provider: 'dify', @@ -282,17 +282,6 @@ const createMockDataset = (overrides: Partial = {}): DataSet => { score_threshold_enabled: false, score_threshold: 0.7, }, - retrieval_model: { - search_method: 'semantic_search' as any, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 4, - score_threshold_enabled: false, - score_threshold: 0.7, - }, tags: [], external_knowledge_info: { external_knowledge_id: '', @@ -1057,31 +1046,9 @@ describe('DatasetConfig', () => { const datasets = [ createMockDataset({ id: 'ds1', - retrieval_model: { - search_method: 'semantic_search' as any, - reranking_enable: true, - reranking_model: { - reranking_provider_name: 'cohere', - reranking_model_name: 'rerank-v3.5', - }, - top_k: 5, - score_threshold_enabled: true, - score_threshold: 0.8, - }, }), createMockDataset({ id: 'ds2', - retrieval_model: { - search_method: 'full_text_search' as any, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, - }, }), ] diff --git a/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx index 105d77835d7567..67724ec317d5aa 100644 --- a/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx @@ -7,6 +7,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea import userEvent from '@testing-library/user-event' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD } from '@/types/app' import Item from '../index' @@ -55,16 +56,12 @@ const baseRetrievalConfig: RetrievalConfig = { const defaultIndexingTechnique: IndexingType = 'high_quality' as IndexingType const createDataset = (overrides: Partial = {}): DataSet => { - const { retrieval_model, retrieval_model_dict, icon_info, ...restOverrides } = overrides + const { retrieval_model_dict, icon_info, ...restOverrides } = overrides const resolvedRetrievalModelDict = { ...baseRetrievalConfig, ...retrieval_model_dict, } - const resolvedRetrievalModel = { - ...baseRetrievalConfig, - ...(retrieval_model ?? retrieval_model_dict), - } const defaultIconInfo = { icon: '📘', @@ -76,9 +73,9 @@ const createDataset = (overrides: Partial = {}): DataSet => { const resolvedIconInfo = 'icon_info' in overrides ? icon_info : defaultIconInfo return { + ...createDatasetFixture(), id: 'dataset-id', name: 'Dataset Name', - indexing_status: 'completed', icon_info: resolvedIconInfo as DataSet['icon_info'], description: 'A test dataset', permission: DatasetPermission.onlyMe, @@ -91,7 +88,6 @@ const createDataset = (overrides: Partial = {}): DataSet => { app_count: 0, doc_form: ChunkingMode.text, document_count: 0, - total_document_count: 0, total_available_documents: 0, word_count: 0, provider: 'dify', @@ -99,7 +95,6 @@ const createDataset = (overrides: Partial = {}): DataSet => { embedding_model_provider: 'openai', embedding_available: true, retrieval_model_dict: resolvedRetrievalModelDict, - retrieval_model: resolvedRetrievalModel, tags: [], external_knowledge_info: { external_knowledge_id: 'external-id', diff --git a/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx index 4098f23d302cda..0af86900dedc7f 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx @@ -17,6 +17,7 @@ import { RerankingModeEnum, WeightedScoreEnum, } from '@/models/datasets' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' import ConfigContent from '../config-content' @@ -77,16 +78,12 @@ const baseRetrievalConfig: RetrievalConfig = { const defaultIndexingTechnique: IndexingType = 'high_quality' as IndexingType const createDataset = (overrides: Partial = {}): DataSet => { - const { retrieval_model, retrieval_model_dict, icon_info, ...restOverrides } = overrides + const { retrieval_model_dict, icon_info, ...restOverrides } = overrides const resolvedRetrievalModelDict = { ...baseRetrievalConfig, ...retrieval_model_dict, } - const resolvedRetrievalModel = { - ...baseRetrievalConfig, - ...(retrieval_model ?? retrieval_model_dict), - } const defaultIconInfo = { icon: '📘', @@ -98,9 +95,9 @@ const createDataset = (overrides: Partial = {}): DataSet => { const resolvedIconInfo = 'icon_info' in overrides ? icon_info : defaultIconInfo return { + ...createDatasetFixture(), id: 'dataset-id', name: 'Dataset Name', - indexing_status: 'completed', icon_info: resolvedIconInfo as DataSet['icon_info'], description: 'A test dataset', permission: DatasetPermission.onlyMe, @@ -113,7 +110,6 @@ const createDataset = (overrides: Partial = {}): DataSet => { app_count: 0, doc_form: ChunkingMode.text, document_count: 0, - total_document_count: 0, total_available_documents: 0, word_count: 0, provider: 'dify', @@ -121,7 +117,6 @@ const createDataset = (overrides: Partial = {}): DataSet => { embedding_model_provider: 'openai', embedding_available: true, retrieval_model_dict: resolvedRetrievalModelDict, - retrieval_model: resolvedRetrievalModel, tags: [], external_knowledge_info: { external_knowledge_id: 'external-id', diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx index 2fcc3bf14fe0cd..f8fac8852141ef 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx @@ -17,10 +17,13 @@ vi.mock('ahooks', async (importOriginal) => { } }) -const mockUseInfiniteDatasets = vi.fn() -vi.mock('@/service/knowledge/use-dataset', () => ({ - useInfiniteDatasets: (...args: any[]) => mockUseInfiniteDatasets(...args), -})) +const { mockDatasetsInfiniteQuery } = vi.hoisted(() => ({ mockDatasetsInfiniteQuery: vi.fn() })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.get': mockDatasetsInfiniteQuery, + }) +}) let mockWorkspacePermissionKeys = ['dataset.create_and_management'] @@ -93,7 +96,7 @@ describe('SelectDataSet', () => { embedding_available: false, provider: 'external', }) - mockUseInfiniteDatasets.mockReturnValue({ + mockDatasetsInfiniteQuery.mockReturnValue({ data: { pages: [{ data: [datasetOne, datasetTwo] }] }, isLoading: false, isFetchingNextPage: false, @@ -122,7 +125,7 @@ describe('SelectDataSet', () => { }) it('shows empty state when no datasets are available and disables add', async () => { - mockUseInfiniteDatasets.mockReturnValue({ + mockDatasetsInfiniteQuery.mockReturnValue({ data: { pages: [{ data: [] }] }, isLoading: false, isFetchingNextPage: false, @@ -144,7 +147,7 @@ describe('SelectDataSet', () => { it('should hide the create dataset link when dataset.create_and_management is unavailable', async () => { mockWorkspacePermissionKeys = [] - mockUseInfiniteDatasets.mockReturnValue({ + mockDatasetsInfiniteQuery.mockReturnValue({ data: { pages: [{ data: [] }] }, isLoading: false, isFetchingNextPage: false, @@ -168,7 +171,7 @@ describe('SelectDataSet', () => { id: 'set-1', name: 'Dataset One', }) - mockUseInfiniteDatasets.mockReturnValue({ + mockDatasetsInfiniteQuery.mockReturnValue({ data: { pages: [{ data: [datasetOne] }] }, isLoading: false, isFetchingNextPage: false, @@ -201,7 +204,7 @@ describe('SelectDataSet', () => { embedding_available: false, }) const onSelect = vi.fn() - mockUseInfiniteDatasets.mockReturnValue({ + mockDatasetsInfiniteQuery.mockReturnValue({ data: { pages: [{ data: [datasetOne, datasetTwo] }] }, isLoading: false, isFetchingNextPage: false, @@ -233,7 +236,7 @@ describe('SelectDataSet', () => { it('should fetch the next page when infinite scroll can continue', async () => { const fetchNextPage = vi.fn() - mockUseInfiniteDatasets.mockReturnValue({ + mockDatasetsInfiniteQuery.mockReturnValue({ data: { pages: [{ data: [makeDataset({ id: 'set-1' })] }] }, isLoading: false, isFetchingNextPage: false, @@ -257,7 +260,7 @@ describe('SelectDataSet', () => { it('should skip infinite scroll fetches when a next page is unavailable or already loading', async () => { const fetchNextPage = vi.fn() - mockUseInfiniteDatasets.mockReturnValue({ + mockDatasetsInfiniteQuery.mockReturnValue({ data: { pages: [{ data: [makeDataset({ id: 'set-1' })] }] }, isLoading: false, isFetchingNextPage: true, diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx index 28c65f75d8eba8..4f66898bffee49 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx @@ -5,6 +5,7 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { IconButton } from '@langgenius/dify-ui/icon-button' +import { useInfiniteQuery } from '@tanstack/react-query' import { useInfiniteScroll } from 'ahooks' import { useAtomValue } from 'jotai' import * as React from 'react' @@ -18,7 +19,7 @@ import FeatureIcon from '@/app/components/header/account-setting/model-provider- import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useKnowledge } from '@/hooks/use-knowledge' import Link from '@/next/link' -import { useInfiniteDatasets } from '@/service/knowledge/use-dataset' +import { consoleQuery } from '@/service/console' import { hasPermission } from '@/utils/permission' type ISelectDataSetProps = { @@ -42,9 +43,15 @@ const SelectDataSet: FC = ({ const { formatIndexingTechniqueAndMethod } = useKnowledge() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canCreateDataset = hasPermission(workspacePermissionKeys, 'dataset.create_and_management') - const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteDatasets( - { page: 1 }, - { enabled: isShow, staleTime: 0, refetchOnMount: 'always' }, + const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteQuery( + consoleQuery.datasets.get.infiniteOptions({ + input: (page: number) => ({ query: { page } }), + getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), + initialPageParam: 1, + enabled: isShow, + staleTime: 0, + refetchOnMount: 'always', + }), ) const datasets = useMemo(() => { const pages = data?.pages || [] diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx index e3924c15b88dff..1c28cb2af25b0e 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx @@ -20,6 +20,7 @@ import { updateDatasetSetting } from '@/service/datasets' import { seedAccountProfileQuery } from '@/test/console/account-profile' import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { createSystemFeaturesFixture } from '@/test/console/system-features' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD } from '@/types/app' import { DatasetACLPermission } from '@/utils/permission' import SettingsModal from '../index' @@ -138,9 +139,9 @@ const createDataset = ( ): DataSet => { const retrievalConfig = createRetrievalConfig(retrievalOverrides) return { + ...createDatasetFixture(), id: 'dataset-id', name: 'Test Dataset', - indexing_status: 'completed', icon_info: { icon: 'icon', icon_type: 'emoji', @@ -156,7 +157,6 @@ const createDataset = ( app_count: 0, doc_form: ChunkingMode.text, document_count: 0, - total_document_count: 0, total_available_documents: 0, word_count: 0, provider: 'internal', @@ -190,10 +190,6 @@ const createDataset = ( ...retrievalConfig, ...overrides.retrieval_model_dict, }, - retrieval_model: { - ...retrievalConfig, - ...overrides.retrieval_model, - }, } } @@ -473,7 +469,6 @@ describe('SettingsModal', () => { }, }) const dataset = createDataset({ - retrieval_model: rerankRetrieval, retrieval_model_dict: rerankRetrieval, }) @@ -525,7 +520,6 @@ describe('SettingsModal', () => { }, }) const dataset = createDataset({ - retrieval_model: hybridRetrieval, retrieval_model_dict: hybridRetrieval, }) @@ -571,7 +565,6 @@ describe('SettingsModal', () => { }, } as Partial) const dataset = createDataset({ - retrieval_model: hybridRetrieval, retrieval_model_dict: hybridRetrieval, }) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/retrieval-section.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/retrieval-section.spec.tsx index d83522ff973399..d32da33f2c2155 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/retrieval-section.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/retrieval-section.spec.tsx @@ -17,6 +17,7 @@ import { } from '@/models/datasets' import { consoleQuery } from '@/service/console' import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { withSelectorKey } from '@/test/i18n-mock' import { RETRIEVE_METHOD } from '@/types/app' import { RetrievalChangeTip, RetrievalSection } from '../retrieval-section' @@ -84,9 +85,9 @@ const createDataset = ( ): DataSet => { const retrievalConfig = createRetrievalConfig(retrievalOverrides) return { + ...createDatasetFixture(), id: 'dataset-id', name: 'Test Dataset', - indexing_status: 'completed', icon_info: { icon: 'icon', icon_type: 'emoji', @@ -102,7 +103,6 @@ const createDataset = ( app_count: 0, doc_form: ChunkingMode.text, document_count: 0, - total_document_count: 0, total_available_documents: 0, word_count: 0, provider: 'internal', @@ -135,10 +135,6 @@ const createDataset = ( ...retrievalConfig, ...overrides.retrieval_model_dict, }, - retrieval_model: { - ...retrievalConfig, - ...overrides.retrieval_model, - }, } } diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index cc58cc807d29f5..3d33c1abfbd596 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -18,6 +18,7 @@ import { normalizeRetrievalConfigForSave, } from '@/app/components/datasets/common/check-rerank-model' import { IndexingType } from '@/app/components/datasets/create/step-two' +import { toRetrievalConfig } from '@/app/components/datasets/dataset-fields' import IndexMethod from '@/app/components/datasets/settings/index-method' import PermissionSelector from '@/app/components/datasets/settings/permission-selector' import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils' @@ -74,12 +75,12 @@ const SettingsModal: FC = ({ const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser) const [loading, setLoading] = useState(false) const [localeCurrentDataset, setLocaleCurrentDataset] = useState({ ...currentDataset }) - const [topK, setTopK] = useState(localeCurrentDataset?.external_retrieval_model.top_k ?? 2) + const [topK, setTopK] = useState(localeCurrentDataset.external_retrieval_model?.top_k ?? 2) const [scoreThreshold, setScoreThreshold] = useState( - localeCurrentDataset?.external_retrieval_model.score_threshold ?? 0.5, + localeCurrentDataset.external_retrieval_model?.score_threshold ?? 0.5, ) const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState( - localeCurrentDataset?.external_retrieval_model.score_threshold_enabled ?? false, + localeCurrentDataset.external_retrieval_model?.score_threshold_enabled ?? false, ) const [selectedMemberIDs, setSelectedMemberIDs] = useState( currentDataset.partial_member_list || [], @@ -89,9 +90,9 @@ const SettingsModal: FC = ({ consoleQuery.workspaces.current.members.get.queryOptions({ input: {} }), ) - const [indexMethod, setIndexMethod] = useState(currentDataset.indexing_technique) - const [retrievalConfig, setRetrievalConfig] = useState( - localeCurrentDataset?.retrieval_model_dict as RetrievalConfig, + const [indexMethod, setIndexMethod] = useState(currentDataset.indexing_technique ?? undefined) + const [retrievalConfig, setRetrievalConfig] = useState(() => + toRetrievalConfig(currentDataset.retrieval_model_dict), ) const [keywordNumber, setKeywordNumber] = useState(currentDataset.keyword_number ?? 10) @@ -100,8 +101,8 @@ const SettingsModal: FC = ({ } const [isHideChangedTip, setIsHideChangedTip] = useState(false) const isRetrievalChanged = - !isEqual(retrievalConfig, localeCurrentDataset?.retrieval_model_dict) || - indexMethod !== localeCurrentDataset?.indexing_technique + !isEqual(retrievalConfig, toRetrievalConfig(localeCurrentDataset.retrieval_model_dict)) || + indexMethod !== (localeCurrentDataset.indexing_technique ?? undefined) const handleSettingsChange = (data: { top_k?: number @@ -116,8 +117,9 @@ const SettingsModal: FC = ({ setLocaleCurrentDataset({ ...localeCurrentDataset, external_retrieval_model: { - ...localeCurrentDataset?.external_retrieval_model, - ...data, + top_k: data.top_k ?? topK, + score_threshold: data.score_threshold ?? scoreThreshold, + score_threshold_enabled: data.score_threshold_enabled ?? scoreThresholdEnabled, }, }) } @@ -161,9 +163,9 @@ const SettingsModal: FC = ({ embedding_model: localeCurrentDataset.embedding_model, embedding_model_provider: localeCurrentDataset.embedding_model_provider, ...(isExternal && { - external_knowledge_id: currentDataset!.external_knowledge_info.external_knowledge_id, + external_knowledge_id: currentDataset.external_knowledge_info?.external_knowledge_id, external_knowledge_api_id: - currentDataset!.external_knowledge_info.external_knowledge_api_id, + currentDataset.external_knowledge_info?.external_knowledge_api_id, external_retrieval_model: { top_k: topK, score_threshold: scoreThreshold, @@ -184,7 +186,7 @@ const SettingsModal: FC = ({ toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) onSave({ ...localeCurrentDataset, - indexing_technique: indexMethod, + indexing_technique: indexMethod ?? null, retrieval_model_dict: retrievalConfig, }) } catch { @@ -202,8 +204,8 @@ const SettingsModal: FC = ({ const showMultiModalTip = useMemo(() => { return checkShowMultiModalTip({ embeddingModel: { - provider: localeCurrentDataset.embedding_model_provider, - model: localeCurrentDataset.embedding_model, + provider: localeCurrentDataset.embedding_model_provider ?? '', + model: localeCurrentDataset.embedding_model ?? '', }, rerankingEnable: retrievalConfig.reranking_enable, rerankModel: { @@ -306,7 +308,7 @@ const SettingsModal: FC = ({ disabled={!localeCurrentDataset?.embedding_available} value={indexMethod} onChange={setIndexMethod} - currentValue={currentDataset.indexing_technique} + currentValue={currentDataset.indexing_technique ?? undefined} keywordNumber={keywordNumber} onKeywordNumberChange={setKeywordNumber} /> @@ -325,8 +327,8 @@ const SettingsModal: FC = ({ diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx index 9667f2f21af944..fef660c7fdc95e 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx @@ -75,11 +75,11 @@ const ExternalRetrievalSection: FC = ({ className="i-custom-vender-solid-development-api-connection-mod size-4 text-text-secondary" />
- {currentDataset?.external_knowledge_info.external_knowledge_api_name} + {currentDataset?.external_knowledge_info?.external_knowledge_api_name}
·
- {currentDataset?.external_knowledge_info.external_knowledge_api_endpoint} + {currentDataset?.external_knowledge_info?.external_knowledge_api_endpoint}
@@ -93,7 +93,7 @@ const ExternalRetrievalSection: FC = ({
- {currentDataset?.external_knowledge_info.external_knowledge_id} + {currentDataset?.external_knowledge_info?.external_knowledge_id}
@@ -105,7 +105,8 @@ const ExternalRetrievalSection: FC = ({ ) type InternalRetrievalSectionProps = CommonSectionProps & { - indexMethod: IndexingType + // Unset until the knowledge base indexes its first document. + indexMethod?: IndexingType retrievalConfig: RetrievalConfig showMultiModalTip: boolean onRetrievalConfigChange: (value: RetrievalConfig) => void diff --git a/web/app/components/base/linked-apps-panel/__tests__/index.spec.tsx b/web/app/components/base/linked-apps-panel/__tests__/index.spec.tsx index 518a468a010a98..198da5ac081d14 100644 --- a/web/app/components/base/linked-apps-panel/__tests__/index.spec.tsx +++ b/web/app/components/base/linked-apps-panel/__tests__/index.spec.tsx @@ -24,6 +24,7 @@ describe('LinkedAppsPanel Component', () => { { id: 'app-1', name: 'Chatbot App', + description: '', mode: 'chat' as const, icon_type: 'emoji' as const, icon: '🤖', @@ -33,6 +34,7 @@ describe('LinkedAppsPanel Component', () => { { id: 'app-2', name: 'Workflow App', + description: '', mode: 'workflow' as const, icon_type: 'image' as const, icon: 'file-id', @@ -42,6 +44,7 @@ describe('LinkedAppsPanel Component', () => { { id: 'app-3', name: '', + description: '', mode: 'agent-chat' as const, icon_type: 'emoji' as const, icon: '🕵️', diff --git a/web/app/components/base/linked-apps-panel/index.stories.tsx b/web/app/components/base/linked-apps-panel/index.stories.tsx index 4b2517ec227225..80a92d5d2ac3b9 100644 --- a/web/app/components/base/linked-apps-panel/index.stories.tsx +++ b/web/app/components/base/linked-apps-panel/index.stories.tsx @@ -6,6 +6,7 @@ const mockRelatedApps: RelatedApp[] = [ { id: 'app-cx', name: 'Customer Support Assistant', + description: '', mode: 'chat' as const, icon_type: 'emoji', icon: '\u{1F4AC}', @@ -15,6 +16,7 @@ const mockRelatedApps: RelatedApp[] = [ { id: 'app-ops', name: 'Ops Workflow Orchestrator', + description: '', mode: 'workflow' as const, icon_type: 'emoji', icon: '\u{1F6E0}\u{FE0F}', @@ -24,6 +26,7 @@ const mockRelatedApps: RelatedApp[] = [ { id: 'app-research', name: 'Research Synthesizer', + description: '', mode: 'advanced-chat' as const, icon_type: 'emoji', icon: '\u{1F9E0}', diff --git a/web/app/components/base/prompt-editor/plugins/context-block/__tests__/context-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/context-block/__tests__/context-block-replacement-block.spec.tsx index c86c37be3d22c1..b3547cea169ba1 100644 --- a/web/app/components/base/prompt-editor/plugins/context-block/__tests__/context-block-replacement-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/context-block/__tests__/context-block-replacement-block.spec.tsx @@ -53,7 +53,7 @@ function EditorCapture({ type ReadResult = { count: number - datasets: Array<{ id: string; name: string; type: string }> + datasets: Array<{ id: string; name: string; type: string | null }> canNotAddContext: boolean } diff --git a/web/app/components/base/prompt-editor/plugins/context-block/index.tsx b/web/app/components/base/prompt-editor/plugins/context-block/index.tsx index 972fc5314a69b5..e601716ea85b95 100644 --- a/web/app/components/base/prompt-editor/plugins/context-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/context-block/index.tsx @@ -12,7 +12,7 @@ export const DELETE_CONTEXT_BLOCK_COMMAND = createCommand('DELETE_CONTEXT_BLOCK_ export type Dataset = { id: string name: string - type: string + type: string | null } const ContextBlock = memo( diff --git a/web/app/components/datasets/__tests__/dataset-fields.spec.ts b/web/app/components/datasets/__tests__/dataset-fields.spec.ts new file mode 100644 index 00000000000000..51980385012b0c --- /dev/null +++ b/web/app/components/datasets/__tests__/dataset-fields.spec.ts @@ -0,0 +1,87 @@ +import { + toChunkingMode, + toCreationDataSourceType, + toIconInfo, + toRetrievalConfig, + toSummaryIndexSetting, +} from '../dataset-fields' + +describe('dataset fields', () => { + it('keeps chunk structures the console renders', () => { + expect(toChunkingMode('hierarchical_model')).toBe('hierarchical_model') + expect(toChunkingMode('graph_model')).toBeUndefined() + expect(toChunkingMode(null)).toBeUndefined() + }) + + it('keeps only the classic creation data sources', () => { + expect(toCreationDataSourceType('notion_import')).toBe('notion_import') + expect(toCreationDataSourceType('online_drive')).toBeUndefined() + }) + + it('reads an icon only when one was chosen', () => { + expect( + toIconInfo({ icon: '📙', icon_type: 'emoji', icon_background: '#FFF', icon_url: null }), + ).toEqual({ icon: '📙', icon_type: 'emoji', icon_background: '#FFF', icon_url: undefined }) + expect(toIconInfo({ icon: null, icon_type: 'emoji' })).toBeUndefined() + expect(toIconInfo({ icon: 'file-1', icon_type: 'svg' })).toBeUndefined() + expect(toIconInfo(undefined)).toBeUndefined() + }) + + it('leaves unset summary settings unset', () => { + expect(toSummaryIndexSetting({ enable: true, model_name: null })).toEqual({ + enable: true, + model_name: undefined, + model_provider_name: undefined, + summary_prompt: undefined, + }) + }) + + describe('toRetrievalConfig', () => { + const retrieval = { + search_method: 'hybrid_search', + reranking_enable: true, + top_k: 4, + score_threshold_enabled: false, + } as const + + it('reads unset reranking models as no model selected', () => { + expect(toRetrievalConfig(retrieval)).toEqual({ + ...retrieval, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + score_threshold: undefined, + reranking_mode: undefined, + weights: undefined, + }) + }) + + it('keeps weighted scores only when both weights were stored', () => { + const weights = { + weight_type: 'customized', + vector_setting: { vector_weight: 0.7, embedding_model_name: 'embed' }, + keyword_setting: { keyword_weight: 0.3 }, + } + + expect( + toRetrievalConfig({ ...retrieval, reranking_mode: 'weighted_score', weights }), + ).toMatchObject({ + reranking_mode: 'weighted_score', + weights: { + weight_type: 'customized', + vector_setting: { + vector_weight: 0.7, + embedding_provider_name: '', + embedding_model_name: 'embed', + }, + keyword_setting: { keyword_weight: 0.3 }, + }, + }) + expect( + toRetrievalConfig({ ...retrieval, weights: { ...weights, keyword_setting: {} } }).weights, + ).toBeUndefined() + expect( + toRetrievalConfig({ ...retrieval, weights: { ...weights, weight_type: null } }).weights + ?.weight_type, + ).toBe('customized') + }) + }) +}) diff --git a/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx b/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx index 108a699c3a09c5..ccb2f02bed2648 100644 --- a/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx +++ b/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx @@ -2,20 +2,23 @@ import type { ErrorDocsResponse } from '@/models/datasets' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { retryErrorDocs } from '@/service/datasets' -import { useDatasetErrorDocs } from '@/service/knowledge/use-dataset' import RetryButton from '../index-failed' const mockRefetch = vi.fn() -vi.mock('@/service/knowledge/use-dataset', () => ({ - useDatasetErrorDocs: vi.fn(), -})) +const { mockErrorDocsQuery } = vi.hoisted(() => ({ mockErrorDocsQuery: vi.fn() })) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.errorDocs.get': mockErrorDocsQuery, + }) +}) vi.mock('@/service/datasets', () => ({ retryErrorDocs: vi.fn(), })) -const mockUseDatasetErrorDocs = vi.mocked(useDatasetErrorDocs) const mockRetryErrorDocs = vi.mocked(retryErrorDocs) afterEach(() => { @@ -24,35 +27,34 @@ afterEach(() => { }) // Helper to create mock query result -const createMockQueryResult = (data: ErrorDocsResponse | undefined, isLoading: boolean) => - ({ - data, - isLoading, - refetch: mockRefetch, - // Required query result properties - error: null, - isError: false, - isFetched: true, - isFetching: false, - isSuccess: !isLoading && !!data, - status: isLoading ? 'pending' : 'success', - dataUpdatedAt: Date.now(), - errorUpdatedAt: 0, - failureCount: 0, - failureReason: null, - errorUpdateCount: 0, - isLoadingError: false, - isPaused: false, - isPlaceholderData: false, - isPending: isLoading, - isRefetchError: false, - isRefetching: false, - isStale: false, - fetchStatus: 'idle', - promise: Promise.resolve(data as ErrorDocsResponse), - isFetchedAfterMount: true, - isInitialLoading: false, - }) as unknown as ReturnType +const createMockQueryResult = (data: ErrorDocsResponse | undefined, isLoading: boolean) => ({ + data, + isLoading, + refetch: mockRefetch, + // Required query result properties + error: null, + isError: false, + isFetched: true, + isFetching: false, + isSuccess: !isLoading && !!data, + status: isLoading ? 'pending' : 'success', + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isLoadingError: false, + isPaused: false, + isPlaceholderData: false, + isPending: isLoading, + isRefetchError: false, + isRefetching: false, + isStale: false, + fetchStatus: 'idle', + promise: Promise.resolve(data as ErrorDocsResponse), + isFetchedAfterMount: true, + isInitialLoading: false, +}) describe('RetryButton (IndexFailed)', () => { beforeEach(() => { @@ -62,21 +64,21 @@ describe('RetryButton (IndexFailed)', () => { describe('Rendering', () => { it('should render nothing when loading', () => { - mockUseDatasetErrorDocs.mockReturnValue(createMockQueryResult(undefined, true)) + mockErrorDocsQuery.mockReturnValue(createMockQueryResult(undefined, true)) const { container } = render() expect(container.firstChild).toBeNull() }) it('should render nothing when no error documents', () => { - mockUseDatasetErrorDocs.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) + mockErrorDocsQuery.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) const { container } = render() expect(container.firstChild).toBeNull() }) it('should render StatusWithAction when error documents exist', () => { - mockUseDatasetErrorDocs.mockReturnValue( + mockErrorDocsQuery.mockReturnValue( createMockQueryResult( { total: 3, @@ -91,7 +93,7 @@ describe('RetryButton (IndexFailed)', () => { }) it('should display error count in description', () => { - mockUseDatasetErrorDocs.mockReturnValue( + mockErrorDocsQuery.mockReturnValue( createMockQueryResult( { total: 5, @@ -107,17 +109,22 @@ describe('RetryButton (IndexFailed)', () => { }) describe('Props', () => { - it('should pass datasetId to useDatasetErrorDocs', () => { - mockUseDatasetErrorDocs.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) + it('should load the error documents of the given dataset', () => { + mockErrorDocsQuery.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) render() - expect(mockUseDatasetErrorDocs).toHaveBeenCalledWith('my-dataset-id') + expect(mockErrorDocsQuery).toHaveBeenCalledWith( + expect.objectContaining({ + input: { params: { dataset_id: 'my-dataset-id' } }, + enabled: true, + }), + ) }) }) describe('User Interactions', () => { it('should call retryErrorDocs when retry button is clicked', async () => { - mockUseDatasetErrorDocs.mockReturnValue( + mockErrorDocsQuery.mockReturnValue( createMockQueryResult( { total: 2, @@ -148,7 +155,7 @@ describe('RetryButton (IndexFailed)', () => { }) it('should refetch error docs after successful retry', async () => { - mockUseDatasetErrorDocs.mockReturnValue( + mockErrorDocsQuery.mockReturnValue( createMockQueryResult( { total: 1, @@ -171,7 +178,7 @@ describe('RetryButton (IndexFailed)', () => { }) it('should disable button while retrying', async () => { - mockUseDatasetErrorDocs.mockReturnValue( + mockErrorDocsQuery.mockReturnValue( createMockQueryResult( { total: 1, @@ -210,7 +217,7 @@ describe('RetryButton (IndexFailed)', () => { describe('State Management', () => { it('should transition to error state when retry fails', async () => { - mockUseDatasetErrorDocs.mockReturnValue( + mockErrorDocsQuery.mockReturnValue( createMockQueryResult( { total: 1, @@ -242,7 +249,7 @@ describe('RetryButton (IndexFailed)', () => { const { rerender } = render() // Initially has errors - mockUseDatasetErrorDocs.mockReturnValue( + mockErrorDocsQuery.mockReturnValue( createMockQueryResult( { total: 1, @@ -256,7 +263,7 @@ describe('RetryButton (IndexFailed)', () => { expect(screen.getByText(/retry/i)).toBeInTheDocument() // Now no errors - mockUseDatasetErrorDocs.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) + mockErrorDocsQuery.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) rerender() @@ -268,7 +275,7 @@ describe('RetryButton (IndexFailed)', () => { describe('Edge Cases', () => { it('should handle empty data array', () => { - mockUseDatasetErrorDocs.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) + mockErrorDocsQuery.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) const { container } = render() expect(container.firstChild).toBeNull() @@ -277,7 +284,7 @@ describe('RetryButton (IndexFailed)', () => { it('should handle undefined data by showing error state', () => { // When data is undefined but not loading, the component shows error state // because errorDocs?.total is not strictly equal to 0 - mockUseDatasetErrorDocs.mockReturnValue(createMockQueryResult(undefined, false)) + mockErrorDocsQuery.mockReturnValue(createMockQueryResult(undefined, false)) render() // Component renders with undefined count @@ -285,7 +292,7 @@ describe('RetryButton (IndexFailed)', () => { }) it('should handle retry with empty document list', async () => { - mockUseDatasetErrorDocs.mockReturnValue(createMockQueryResult({ total: 1, data: [] }, false)) + mockErrorDocsQuery.mockReturnValue(createMockQueryResult({ total: 1, data: [] }, false)) mockRetryErrorDocs.mockResolvedValue({ result: 'success' }) diff --git a/web/app/components/datasets/common/document-status-with-action/index-failed.tsx b/web/app/components/datasets/common/document-status-with-action/index-failed.tsx index 4c69ee8a8fe600..dc88e92834e459 100644 --- a/web/app/components/datasets/common/document-status-with-action/index-failed.tsx +++ b/web/app/components/datasets/common/document-status-with-action/index-failed.tsx @@ -1,12 +1,12 @@ 'use client' import type { FC } from 'react' -import type { IndexingStatusResponse } from '@/models/datasets' +import { useQuery } from '@tanstack/react-query' import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect, useReducer } from 'react' import { useTranslation } from 'react-i18next' +import { consoleQuery } from '@/service/console' import { retryErrorDocs } from '@/service/datasets' -import { useDatasetErrorDocs } from '@/service/knowledge/use-dataset' import StatusWithAction from './status-with-action' type Props = Readonly<{ @@ -36,11 +36,20 @@ const indexStateReducer = (state: IIndexState, action: IAction) => { const RetryButton: FC = ({ datasetId }) => { const { t } = useTranslation(['dataset']) const [indexState, dispatch] = useReducer(indexStateReducer, { value: 'success' }) - const { data: errorDocs, isLoading, refetch: refetchErrorDocs } = useDatasetErrorDocs(datasetId) + const { + data: errorDocs, + isLoading, + refetch: refetchErrorDocs, + } = useQuery( + consoleQuery.datasets.byDatasetId.errorDocs.get.queryOptions({ + input: { params: { dataset_id: datasetId } }, + enabled: !!datasetId, + }), + ) const onRetryErrorDocs = async () => { dispatch({ type: 'retry' }) - const document_ids = errorDocs?.data.map((doc: IndexingStatusResponse) => doc.id) || [] + const document_ids = errorDocs?.data.map((doc) => doc.id) || [] try { await retryErrorDocs({ datasetId, document_ids }) refetchErrorDocs() diff --git a/web/app/components/datasets/create-from-pipeline/__tests__/footer.spec.tsx b/web/app/components/datasets/create-from-pipeline/__tests__/footer.spec.tsx index 66422db74a9f74..a2d8157cf48cb9 100644 --- a/web/app/components/datasets/create-from-pipeline/__tests__/footer.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/__tests__/footer.spec.tsx @@ -1,7 +1,13 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { QueryClient } from '@tanstack/react-query' +import { fireEvent, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { consoleQuery } from '@/service/console' +import { renderWithConsoleQuery } from '@/test/console/query-data' import Footer from '../footer' +const invalidateQueries = vi.spyOn(QueryClient.prototype, 'invalidateQueries') +const datasetListKey = consoleQuery.datasets.get.key() + // Configurable mock for search params let mockSearchParams = new URLSearchParams() const mockReplace = vi.fn() @@ -11,11 +17,6 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: () => mockSearchParams, })) -const mockInvalidDatasetList = vi.fn() -vi.mock('@/service/knowledge/use-dataset', () => ({ - useInvalidDatasetList: () => mockInvalidDatasetList, -})) - // Mock CreateFromDSLModal to capture props let capturedActiveTab: string | undefined let capturedDslUrl: string | undefined @@ -65,19 +66,19 @@ describe('Footer', () => { describe('Rendering', () => { it('should render import button with icon', () => { - const { container } = render(
) + const { container } = renderWithConsoleQuery(
) const button = screen.getByRole('button') expect(button).toBeInTheDocument() expect(container.querySelector('svg')).toBeInTheDocument() }) it('should not show modal initially', () => { - render(
) + renderWithConsoleQuery(
) expect(screen.queryByTestId('dsl-modal')).not.toBeInTheDocument() }) it('should render divider', () => { - const { container } = render(
) + const { container } = renderWithConsoleQuery(
) const divider = container.querySelector('[class*="w-8"]') expect(divider).toBeInTheDocument() }) @@ -85,7 +86,7 @@ describe('Footer', () => { describe('User Interactions', () => { it('should open modal when import button is clicked', () => { - render(
) + renderWithConsoleQuery(
) const importButton = screen.getByText(/importDSL/i) fireEvent.click(importButton) @@ -94,7 +95,7 @@ describe('Footer', () => { }) it('should close modal when onClose is called', () => { - render(
) + renderWithConsoleQuery(
) const importButton = screen.getByText(/importDSL/i) fireEvent.click(importButton) @@ -106,7 +107,7 @@ describe('Footer', () => { }) it('should call invalidDatasetList on success', () => { - render(
) + renderWithConsoleQuery(
) const importButton = screen.getByText(/importDSL/i) fireEvent.click(importButton) @@ -115,13 +116,13 @@ describe('Footer', () => { const successButton = screen.getByTestId('success-modal') fireEvent.click(successButton) - expect(mockInvalidDatasetList).toHaveBeenCalled() + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: datasetListKey }) }) }) describe('Layout', () => { it('should have backdrop blur-sm effect', () => { - const { container } = render(
) + const { container } = renderWithConsoleQuery(
) const footerDiv = container.firstChild as HTMLElement expect(footerDiv).toHaveClass('backdrop-blur-[6px]') }) @@ -132,7 +133,7 @@ describe('Footer', () => { it('should set activeTab to FROM_URL when dslUrl is present', () => { mockSearchParams = new URLSearchParams('remoteInstallUrl=https://example.com/dsl') - render(
) + renderWithConsoleQuery(
) // Open modal to trigger prop capture const importButton = screen.getByText(/importDSL/i) @@ -145,7 +146,7 @@ describe('Footer', () => { it('should set activeTab to undefined when dslUrl is not present', () => { mockSearchParams = new URLSearchParams() - render(
) + renderWithConsoleQuery(
) // Open modal to trigger prop capture const importButton = screen.getByText(/importDSL/i) @@ -158,7 +159,7 @@ describe('Footer', () => { it('should call replace when closing modal with dslUrl present', () => { mockSearchParams = new URLSearchParams('remoteInstallUrl=https://example.com/dsl') - render(
) + renderWithConsoleQuery(
) const importButton = screen.getByText(/importDSL/i) fireEvent.click(importButton) @@ -173,7 +174,7 @@ describe('Footer', () => { it('should not call replace when closing modal without dslUrl', () => { mockSearchParams = new URLSearchParams() - render(
) + renderWithConsoleQuery(
) const importButton = screen.getByText(/importDSL/i) fireEvent.click(importButton) diff --git a/web/app/components/datasets/create-from-pipeline/footer.tsx b/web/app/components/datasets/create-from-pipeline/footer.tsx index 60a56059f90462..753d708271551f 100644 --- a/web/app/components/datasets/create-from-pipeline/footer.tsx +++ b/web/app/components/datasets/create-from-pipeline/footer.tsx @@ -1,10 +1,11 @@ import { Separator } from '@langgenius/dify-ui/separator' import { RiFileUploadLine } from '@remixicon/react' +import { useQueryClient } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useRouter, useSearchParams } from '@/next/navigation' -import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' +import { consoleQuery } from '@/service/console' import CreateFromDSLModal, { CreateFromDSLModalTab } from './create-options/create-from-dsl-modal' const Footer = () => { @@ -15,7 +16,10 @@ const Footer = () => { const searchParams = useSearchParams() const { replace } = useRouter() const dslUrl = searchParams.get('remoteInstallUrl') || undefined - const invalidDatasetList = useInvalidDatasetList() + const queryClient = useQueryClient() + const invalidDatasetList = useCallback(() => { + queryClient.invalidateQueries({ queryKey: consoleQuery.datasets.get.key() }) + }, [queryClient]) const activeTab = useMemo(() => { if (dslUrl) return CreateFromDSLModalTab.FROM_URL diff --git a/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx index e773173da73b10..e1bf0a9dd3be40 100644 --- a/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx @@ -1,5 +1,6 @@ -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { renderWithConsoleQuery } from '@/test/console/query-data' import CreateCard from '../create-card' const mockPush = vi.fn() @@ -30,7 +31,6 @@ vi.mock('@/app/notifications', async (importOriginal) => { }) const mockCreateEmptyDataset = vi.fn() -const mockInvalidDatasetList = vi.fn() vi.mock('@/service/knowledge/use-create-dataset', () => ({ useCreatePipelineDataset: () => ({ @@ -38,10 +38,6 @@ vi.mock('@/service/knowledge/use-create-dataset', () => ({ }), })) -vi.mock('@/service/knowledge/use-dataset', () => ({ - useInvalidDatasetList: () => mockInvalidDatasetList, -})) - // CreateCard Component Tests describe('CreateCard', () => { @@ -53,13 +49,13 @@ describe('CreateCard', () => { describe('Rendering', () => { it('should render title and description', () => { - render() + renderWithConsoleQuery() expect(screen.getByText(/createFromScratch\.title/i)).toBeInTheDocument() expect(screen.getByText(/createFromScratch\.description/i)).toBeInTheDocument() }) it('should render add icon', () => { - const { container } = render() + const { container } = renderWithConsoleQuery() const icon = container.querySelector('svg') expect(icon).toBeInTheDocument() }) @@ -67,13 +63,13 @@ describe('CreateCard', () => { describe('Layout', () => { it('should have fixed height', () => { - const { container } = render() + const { container } = renderWithConsoleQuery() const card = container.firstChild as HTMLElement expect(card).toHaveClass('h-33') }) it('should have shadow and border', () => { - const { container } = render() + const { container } = renderWithConsoleQuery() const card = container.firstChild as HTMLElement expect(card).toHaveClass('border-[0.5px]', 'shadow-xs') }) diff --git a/web/app/components/datasets/create-from-pipeline/list/create-card.tsx b/web/app/components/datasets/create-from-pipeline/list/create-card.tsx index 3cb501b67cb9d6..df2eecaf3e51ce 100644 --- a/web/app/components/datasets/create-from-pipeline/list/create-card.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/create-card.tsx @@ -1,19 +1,23 @@ import { RiAddCircleLine } from '@remixicon/react' +import { useQueryClient } from '@tanstack/react-query' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import { toast } from '@/app/notifications' import { useRouter } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { useCreatePipelineDataset } from '@/service/knowledge/use-create-dataset' -import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' const CreateCard = () => { const { t } = useTranslation(['datasetPipeline']) const { push } = useRouter() const { mutateAsync: createEmptyDataset } = useCreatePipelineDataset() - const invalidDatasetList = useInvalidDatasetList() + const queryClient = useQueryClient() + const invalidDatasetList = useCallback(() => { + queryClient.invalidateQueries({ queryKey: consoleQuery.datasets.get.key() }) + }, [queryClient]) const handleCreate = useCallback(async () => { await createEmptyDataset(undefined, { diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/focus.browser.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/focus.browser.spec.tsx index bb14c8b25bae6d..fb3cd912083af5 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/focus.browser.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/focus.browser.spec.tsx @@ -25,7 +25,6 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ vi.mock('@/service/knowledge/use-create-dataset', () => ({ useCreatePipelineDatasetFromCustomized: () => ({ mutateAsync: vi.fn() }), })) -vi.mock('@/service/knowledge/use-dataset', () => ({ useInvalidDatasetList: () => vi.fn() })) vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx index 0e66b27bb4adf1..16ecc14b78de4f 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx @@ -4,9 +4,13 @@ import { QueryClient } from '@tanstack/react-query' import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { ChunkingMode } from '@/models/datasets' +import { consoleQuery } from '@/service/console' import { renderWithConsoleQuery as render } from '@/test/console/query-data' import TemplateCard from '../index' +const invalidateQueries = vi.spyOn(QueryClient.prototype, 'invalidateQueries') +const datasetListKey = consoleQuery.datasets.get.key() + const mockPush = vi.fn() vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush }), @@ -102,7 +106,6 @@ vi.mock('@/app/components/workflow/workflow-preview', () => ({ })) const mockCreateDataset = vi.fn() -const mockInvalidDatasetList = vi.fn() const mockGetPipelineTemplateInfo = vi.fn() const mockDeletePipeline = vi.fn() const mockExportPipelineDSL = vi.fn() @@ -117,10 +120,6 @@ vi.mock('@/service/knowledge/use-create-dataset', () => ({ }), })) -vi.mock('@/service/knowledge/use-dataset', () => ({ - useInvalidDatasetList: () => mockInvalidDatasetList, -})) - vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { @@ -279,7 +278,7 @@ describe('TemplateCard', () => { fireEvent.click(chooseButton) await waitFor(() => { - expect(mockInvalidDatasetList).toHaveBeenCalled() + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: datasetListKey }) }) }) diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx index 1539811046775f..9e89682c7af645 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx @@ -20,7 +20,6 @@ import { toast } from '@/app/notifications' import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/console' import { useCreatePipelineDatasetFromCustomized } from '@/service/knowledge/use-create-dataset' -import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' import { downloadBlob } from '@/utils/download' import Actions from './actions' import Content from './content' @@ -49,7 +48,9 @@ const TemplateCard = ({ pipeline, showMoreOperations = true, type }: TemplateCar ) const { mutateAsync: createDataset } = useCreatePipelineDatasetFromCustomized() const { handleCheckPluginDependencies } = usePluginDependencies() - const invalidDatasetList = useInvalidDatasetList() + const invalidDatasetList = useCallback(() => { + queryClient.invalidateQueries({ queryKey: consoleQuery.datasets.get.key() }) + }, [queryClient]) const handleUseTemplate = useCallback(async () => { const { data: pipelineTemplateInfo } = await getPipelineTemplateInfo() diff --git a/web/app/components/datasets/create/__tests__/index.spec.tsx b/web/app/components/datasets/create/__tests__/index.spec.tsx index 7d5d6fe3e684b1..a6f3769673e9b0 100644 --- a/web/app/components/datasets/create/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/__tests__/index.spec.tsx @@ -13,6 +13,7 @@ import { DataSourceProvider } from '@/models/common' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { createAccountProfileQueryClient } from '@/test/console/account-profile' import { render as renderWithConsoleState } from '@/test/console/render' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD } from '@/types/app' import DatasetUpdateForm from '../index' @@ -293,9 +294,9 @@ vi.mock('../top-bar', () => ({ })) const createMockDataset = (overrides?: Partial): DataSet => ({ + ...createDatasetFixture(), id: 'dataset-123', name: 'Test Dataset', - indexing_status: 'completed', icon_info: { icon: '', icon_background: '', icon_type: 'emoji' as const }, description: 'Test description', permission: DatasetPermission.onlyMe, @@ -307,7 +308,6 @@ const createMockDataset = (overrides?: Partial): DataSet => ({ app_count: 0, doc_form: ChunkingMode.text, document_count: 0, - total_document_count: 0, word_count: 0, provider: 'openai', embedding_model: 'text-embedding-ada-002', @@ -323,16 +323,6 @@ const createMockDataset = (overrides?: Partial): DataSet => ({ score_threshold_enabled: false, score_threshold: 0, }, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_mode: undefined, - reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, - weights: undefined, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0, - }, tags: [], external_knowledge_info: { external_knowledge_id: '', diff --git a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx index 665781814e944f..e1a5e0fe6e88c2 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx @@ -36,9 +36,12 @@ vi.mock('@/next/link', () => ({ ), })) -vi.mock('@/service/knowledge/use-dataset', () => ({ - useProcessRule: () => ({ data: undefined }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.processRule.get': () => ({ data: undefined }), + }) +}) vi.mock('@/hooks/use-api-access-url', () => ({ useDatasetApiAccessUrl: () => 'https://api.example.com/docs', diff --git a/web/app/components/datasets/create/embedding-process/index.tsx b/web/app/components/datasets/create/embedding-process/index.tsx index 5d7f1b55269496..382adf60671ffe 100644 --- a/web/app/components/datasets/create/embedding-process/index.tsx +++ b/web/app/components/datasets/create/embedding-process/index.tsx @@ -14,7 +14,6 @@ import { deploymentEditionAtom } from '@/features/system-features/state' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' import Link from '@/next/link' import { consoleQuery } from '@/service/console' -import { useProcessRule } from '@/service/knowledge/use-dataset' import IndexingProgressItem from './indexing-progress-item' import RuleDetail from './rule-detail' import UpgradeBanner from './upgrade-banner' @@ -113,7 +112,12 @@ const EmbeddingProcess: FC = ({ // Get process rule for the first document const firstDocumentId = documents[0]?.id - const { data: ruleDetail } = useProcessRule(firstDocumentId) + const { data: ruleDetail } = useQuery( + consoleQuery.datasets.processRule.get.queryOptions({ + input: { query: { document_id: firstDocumentId } }, + enabled: !!firstDocumentId, + }), + ) // Document lookup utilities - memoized for performance const documentLookup = useMemo(() => createDocumentLookup(documents), [documents]) diff --git a/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx b/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx index 039cd69baacd6f..049542db6702ea 100644 --- a/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx +++ b/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx @@ -119,7 +119,7 @@ const IndexingProgressItem: FC = ({
{isEmbedding &&
{`${percent}%`}
} - +
) diff --git a/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx b/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx index ffc7cbf5dfc292..375cdaeda14439 100644 --- a/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx @@ -1,10 +1,15 @@ import type { MockedFunction } from 'vite-plus/test' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient } from '@tanstack/react-query' +import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { consoleQuery } from '@/service/console' import { createEmptyDataset } from '@/service/datasets' -import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' +import { renderWithConsoleQuery } from '@/test/console/query-data' import EmptyDatasetCreationModal from '../index' +const invalidateQueries = vi.spyOn(QueryClient.prototype, 'invalidateQueries') +const datasetListKey = consoleQuery.datasets.get.key() + const { mockNotify, mockToast } = vi.hoisted(() => { const mockNotify = vi.fn() const mockToast = Object.assign(mockNotify, { @@ -36,17 +41,8 @@ vi.mock('@/service/datasets', () => ({ createEmptyDataset: vi.fn(), })) -// Mock useInvalidDatasetList hook -vi.mock('@/service/knowledge/use-dataset', () => ({ - useInvalidDatasetList: vi.fn(), -})) - // Type cast mocked functions const mockCreateEmptyDataset = createEmptyDataset as MockedFunction -const mockInvalidDatasetList = vi.fn() -const mockUseInvalidDatasetList = useInvalidDatasetList as MockedFunction< - typeof useInvalidDatasetList -> // Test data builder for props const createDefaultProps = (overrides?: Partial<{ show: boolean; onHide: () => void }>) => ({ @@ -58,7 +54,6 @@ const createDefaultProps = (overrides?: Partial<{ show: boolean; onHide: () => v describe('EmptyDatasetCreationModal', () => { beforeEach(() => { vi.clearAllMocks() - mockUseInvalidDatasetList.mockReturnValue(mockInvalidDatasetList) mockCreateEmptyDataset.mockResolvedValue({ id: 'dataset-123', name: 'Test Dataset', @@ -70,7 +65,7 @@ describe('EmptyDatasetCreationModal', () => { it('should render modal with correct elements', () => { const props = createDefaultProps() - render() + renderWithConsoleQuery() expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepOne.modal.tip')).toBeInTheDocument() @@ -84,7 +79,7 @@ describe('EmptyDatasetCreationModal', () => { it('should render input with empty value initially', () => { const props = createDefaultProps() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText( 'datasetCreation.stepOne.modal.placeholder', @@ -95,7 +90,7 @@ describe('EmptyDatasetCreationModal', () => { it('should not render modal content when show is false', () => { const props = createDefaultProps({ show: false }) - render() + renderWithConsoleQuery() // Assert - Modal should not be visible (check for absence of title) expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument() @@ -106,20 +101,22 @@ describe('EmptyDatasetCreationModal', () => { describe('Props', () => { describe('show prop', () => { it('should show modal when show is true', () => { - render() + renderWithConsoleQuery() expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument() }) it('should hide modal when show is false', () => { - render() + renderWithConsoleQuery() expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument() }) it('should toggle visibility when show prop changes', () => { const onHide = vi.fn() - const { rerender } = render() + const { rerender } = renderWithConsoleQuery( + , + ) // Act & Assert - Initially hidden expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument() @@ -133,7 +130,7 @@ describe('EmptyDatasetCreationModal', () => { describe('onHide prop', () => { it('should call onHide when cancel button is clicked', () => { const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const cancelButton = screen.getByText('datasetCreation.stepOne.modal.cancelButton') fireEvent.click(cancelButton) @@ -143,7 +140,7 @@ describe('EmptyDatasetCreationModal', () => { it('should call onHide when close icon is clicked', async () => { const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const closeButton = await screen.findByRole('button', { name: /operation\.close$/ }) fireEvent.click(closeButton) @@ -157,7 +154,7 @@ describe('EmptyDatasetCreationModal', () => { describe('State Management', () => { it('should update input value when user types', () => { const props = createDefaultProps() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText( 'datasetCreation.stepOne.modal.placeholder', ) as HTMLInputElement @@ -169,7 +166,9 @@ describe('EmptyDatasetCreationModal', () => { it('should persist input value when modal is hidden and shown again via rerender', () => { const onHide = vi.fn() - const { rerender } = render() + const { rerender } = renderWithConsoleQuery( + , + ) const input = screen.getByPlaceholderText( 'datasetCreation.stepOne.modal.placeholder', ) as HTMLInputElement @@ -191,7 +190,7 @@ describe('EmptyDatasetCreationModal', () => { it('should handle consecutive input changes', () => { const props = createDefaultProps() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText( 'datasetCreation.stepOne.modal.placeholder', ) as HTMLInputElement @@ -211,7 +210,7 @@ describe('EmptyDatasetCreationModal', () => { describe('User Interactions', () => { it('should submit form when confirm button is clicked with valid input', async () => { const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -225,7 +224,7 @@ describe('EmptyDatasetCreationModal', () => { it('should show error notification when input is empty', async () => { const props = createDefaultProps() - render() + renderWithConsoleQuery() const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') // Act - Click confirm without entering a name @@ -242,7 +241,7 @@ describe('EmptyDatasetCreationModal', () => { it('should show error notification when input exceeds 40 characters', async () => { const props = createDefaultProps() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -262,7 +261,7 @@ describe('EmptyDatasetCreationModal', () => { it('should allow exactly 40 characters', async () => { const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -278,7 +277,7 @@ describe('EmptyDatasetCreationModal', () => { it('should close modal on cancel button click', () => { const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const cancelButton = screen.getByText('datasetCreation.stepOne.modal.cancelButton') fireEvent.click(cancelButton) @@ -291,7 +290,7 @@ describe('EmptyDatasetCreationModal', () => { describe('API Calls', () => { it('should submit from the dataset name input with Enter', async () => { const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const user = userEvent.setup() const input = screen.getByRole('textbox', { name: 'datasetCreation.stepOne.modal.input' }) @@ -312,7 +311,7 @@ describe('EmptyDatasetCreationModal', () => { }), ) const onHide = vi.fn() - render() + renderWithConsoleQuery() const user = userEvent.setup() const input = screen.getByRole('textbox', { name: 'datasetCreation.stepOne.modal.input' }) @@ -334,7 +333,7 @@ describe('EmptyDatasetCreationModal', () => { it('should call invalidDatasetList after successful creation', async () => { const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -342,13 +341,13 @@ describe('EmptyDatasetCreationModal', () => { fireEvent.click(confirmButton) await waitFor(() => { - expect(mockInvalidDatasetList).toHaveBeenCalled() + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: datasetListKey }) }) }) it('should call onHide after successful creation', async () => { const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -363,7 +362,7 @@ describe('EmptyDatasetCreationModal', () => { it('should show error notification on API failure', async () => { mockCreateEmptyDataset.mockRejectedValue(new Error('API Error')) const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -381,7 +380,7 @@ describe('EmptyDatasetCreationModal', () => { it('should not call onHide on API failure', async () => { mockCreateEmptyDataset.mockRejectedValue(new Error('API Error')) const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -399,7 +398,7 @@ describe('EmptyDatasetCreationModal', () => { it('should not invalidate dataset list on API failure', async () => { mockCreateEmptyDataset.mockRejectedValue(new Error('API Error')) const props = createDefaultProps() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -409,7 +408,7 @@ describe('EmptyDatasetCreationModal', () => { await waitFor(() => { expect(mockNotify).toHaveBeenCalled() }) - expect(mockInvalidDatasetList).not.toHaveBeenCalled() + expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: datasetListKey }) }) }) @@ -421,7 +420,7 @@ describe('EmptyDatasetCreationModal', () => { name: 'Test', } as ReturnType extends Promise ? T : never) const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -435,7 +434,7 @@ describe('EmptyDatasetCreationModal', () => { it('should not navigate on validation error', async () => { const props = createDefaultProps() - render() + renderWithConsoleQuery() const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') // Act - Click confirm with empty input @@ -450,7 +449,7 @@ describe('EmptyDatasetCreationModal', () => { it('should not navigate on API error', async () => { mockCreateEmptyDataset.mockRejectedValue(new Error('API Error')) const props = createDefaultProps() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -468,7 +467,7 @@ describe('EmptyDatasetCreationModal', () => { describe('Edge Cases', () => { it('should handle whitespace-only input as valid (component behavior)', async () => { const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -484,7 +483,7 @@ describe('EmptyDatasetCreationModal', () => { it('should handle special characters in input', async () => { const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -498,7 +497,7 @@ describe('EmptyDatasetCreationModal', () => { it('should handle Unicode characters in input', async () => { const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -512,7 +511,7 @@ describe('EmptyDatasetCreationModal', () => { it('should handle input at exactly 40 character boundary', async () => { const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -528,7 +527,7 @@ describe('EmptyDatasetCreationModal', () => { it('should reject input at 41 character boundary', async () => { const props = createDefaultProps() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -548,7 +547,7 @@ describe('EmptyDatasetCreationModal', () => { it('should handle rapid consecutive submits', async () => { const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -566,7 +565,7 @@ describe('EmptyDatasetCreationModal', () => { it('should handle input with leading/trailing spaces', async () => { const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -581,7 +580,7 @@ describe('EmptyDatasetCreationModal', () => { it('should handle newline characters in input (browser strips newlines)', async () => { const mockOnHide = vi.fn() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -599,7 +598,7 @@ describe('EmptyDatasetCreationModal', () => { describe('Validation', () => { it('should not submit when input is empty string', async () => { const props = createDefaultProps() - render() + renderWithConsoleQuery() const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') fireEvent.click(confirmButton) @@ -614,7 +613,7 @@ describe('EmptyDatasetCreationModal', () => { it('should validate length before calling API', async () => { const props = createDefaultProps() - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -633,7 +632,7 @@ describe('EmptyDatasetCreationModal', () => { it('should validate empty string before length check', async () => { const props = createDefaultProps() - render() + renderWithConsoleQuery() const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') // Act - Don't enter anything @@ -657,7 +656,7 @@ describe('EmptyDatasetCreationModal', () => { id: 'new-id-789', name: 'Complete Flow Test', } as ReturnType extends Promise ? T : never) - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -669,7 +668,7 @@ describe('EmptyDatasetCreationModal', () => { // 1. API called expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Complete Flow Test' }) // 2. Dataset list invalidated - expect(mockInvalidDatasetList).toHaveBeenCalled() + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: datasetListKey }) // 3. Modal closed expect(mockOnHide).toHaveBeenCalled() // 4. Navigation happened @@ -680,7 +679,7 @@ describe('EmptyDatasetCreationModal', () => { it('should handle error flow correctly', async () => { const mockOnHide = vi.fn() mockCreateEmptyDataset.mockRejectedValue(new Error('Server Error')) - render() + renderWithConsoleQuery() const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton') @@ -699,7 +698,7 @@ describe('EmptyDatasetCreationModal', () => { }) // 3. These should NOT happen on error - expect(mockInvalidDatasetList).not.toHaveBeenCalled() + expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: datasetListKey }) expect(mockOnHide).not.toHaveBeenCalled() expect(mockPush).not.toHaveBeenCalled() }) diff --git a/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx b/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx index be333a7070a2ca..501853ce987fad 100644 --- a/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx +++ b/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx @@ -5,14 +5,15 @@ import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { Field, FieldLabel } from '@langgenius/dify-ui/field' import { Form } from '@langgenius/dify-ui/form' import { Input } from '@langgenius/dify-ui/input' +import { useQueryClient } from '@tanstack/react-query' import * as React from 'react' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import { toast } from '@/app/notifications' import { useRouter } from '@/next/navigation' +import { consoleQuery } from '@/service/console' import { createEmptyDataset } from '@/service/datasets' -import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' import s from './index.module.css' type IProps = { @@ -24,7 +25,10 @@ const EmptyDatasetCreationModal = ({ show = false, onHide }: IProps) => { const [isSubmitting, setIsSubmitting] = useState(false) const { t } = useTranslation(['common', 'datasetCreation']) const router = useRouter() - const invalidDatasetList = useInvalidDatasetList() + const queryClient = useQueryClient() + const invalidDatasetList = useCallback(() => { + queryClient.invalidateQueries({ queryKey: consoleQuery.datasets.get.key() }) + }, [queryClient]) const submit = async () => { if (isSubmitting) return diff --git a/web/app/components/datasets/create/index.tsx b/web/app/components/datasets/create/index.tsx index afbb4b1da5ce03..660acaffe182b2 100644 --- a/web/app/components/datasets/create/index.tsx +++ b/web/app/components/datasets/create/index.tsx @@ -192,7 +192,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => { setSettingsDestination('provider')} - indexingType={datasetDetail?.indexing_technique} + indexingType={datasetDetail?.indexing_technique ?? undefined} datasetId={datasetId} dataSourceType={dataSourceType} files={fileList.map((file) => file.file)} diff --git a/web/app/components/datasets/create/step-one/index.tsx b/web/app/components/datasets/create/step-one/index.tsx index 760bdb083345a6..910445322d99d7 100644 --- a/web/app/components/datasets/create/step-one/index.tsx +++ b/web/app/components/datasets/create/step-one/index.tsx @@ -14,6 +14,7 @@ import NotionConnector from '@/app/components/base/notion-connector' import { NotionPageSelector } from '@/app/components/base/notion-page-selector' import VectorSpaceFull from '@/app/components/billing/vector-space-full' import VectorSpaceUnavailable from '@/app/components/billing/vector-space-unavailable' +import { toCreationDataSourceType } from '@/app/components/datasets/dataset-fields' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { deploymentEditionAtom } from '@/features/system-features/state' import { DataSourceType } from '@/models/datasets' @@ -138,7 +139,7 @@ const StepOne = ({ // Default to FILE type when no type is provided from either source const dataSourceType = isInCreatePage ? (inCreatePageDataSourceType ?? DataSourceType.FILE) - : (dataset?.data_source_type ?? DataSourceType.FILE) + : (toCreationDataSourceType(dataset?.data_source_type) ?? DataSourceType.FILE) const allFileLoaded = files.length > 0 && files.every((file) => file.file.id) const hasNotion = notionPages.length > 0 diff --git a/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx index 46d940f7ee5cc1..b3e489899dcc30 100644 --- a/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx @@ -15,15 +15,7 @@ import type { Rules, } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' -import { - act, - cleanup, - fireEvent, - renderHook, - screen, - waitFor, - within, -} from '@testing-library/react' +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { ConfigurationMethodEnum, @@ -33,7 +25,10 @@ import { import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets' import { expectLoadingButton } from '@/test/button' -import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { + renderWithConsoleQuery as render, + renderHookWithConsoleQuery, +} from '@/test/console/query-data' import { RETRIEVE_METHOD } from '@/types/app' import { PreviewPanel } from '../components/preview-panel' import { StepTwoFooter } from '../components/step-two-footer' @@ -218,10 +213,6 @@ vi.mock('@/service/knowledge/use-create-dataset', () => ({ .mockReturnValue({ provider: 'jinaReader', job_id: 'job-123', urls: ['https://test.com'] }), })) -vi.mock('@/service/knowledge/use-dataset', () => ({ - useInvalidDatasetList: () => vi.fn(), -})) - // Mock amplitude tracking (external service) vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), @@ -487,7 +478,7 @@ describe('useSegmentationState', () => { // Tests for initial state describe('Initial State', () => { it('should initialize with default values', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) expect(result.current.segmentationType).toBe(ProcessMode.general) expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER) @@ -498,7 +489,7 @@ describe('useSegmentationState', () => { }) it('should initialize with custom segmentation type', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useSegmentationState({ initialSegmentationType: ProcessMode.parentChild }), ) @@ -509,7 +500,7 @@ describe('useSegmentationState', () => { // Tests for state setters describe('State Management', () => { it('should update segmentation type', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.setSegmentationType(ProcessMode.parentChild) @@ -519,7 +510,7 @@ describe('useSegmentationState', () => { }) it('should update max chunk length', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.setMaxChunkLength(2048) @@ -529,7 +520,7 @@ describe('useSegmentationState', () => { }) it('should update overlap', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.setOverlap(100) @@ -539,7 +530,7 @@ describe('useSegmentationState', () => { }) it('should update rules', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) const newRules: PreProcessingRule[] = [{ id: 'remove_stopwords', enabled: true }] act(() => { @@ -553,7 +544,7 @@ describe('useSegmentationState', () => { // Tests for setSegmentIdentifier with escape describe('setSegmentIdentifier', () => { it('should escape special characters', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.setSegmentIdentifier('\n\n') @@ -563,7 +554,7 @@ describe('useSegmentationState', () => { }) it('should use default when empty and canEmpty is false', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.setSegmentIdentifier('') @@ -573,7 +564,7 @@ describe('useSegmentationState', () => { }) it('should allow empty when canEmpty is true', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.setSegmentIdentifier('', true) @@ -586,7 +577,7 @@ describe('useSegmentationState', () => { // Tests for toggleRule describe('toggleRule', () => { it('should toggle rule enabled state', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.setRules([ @@ -604,7 +595,7 @@ describe('useSegmentationState', () => { }) it('should not affect other rules', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.setRules([ @@ -625,7 +616,7 @@ describe('useSegmentationState', () => { // Tests for parent-child config describe('Parent-Child Configuration', () => { it('should update parent config delimiter with truthy value', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.updateParentConfig('delimiter', '\n\n\n') @@ -635,7 +626,7 @@ describe('useSegmentationState', () => { }) it('should update parent config delimiter with empty value', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.updateParentConfig('delimiter', '') @@ -645,7 +636,7 @@ describe('useSegmentationState', () => { }) it('should update parent config maxLength', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.updateParentConfig('maxLength', 2048) @@ -655,7 +646,7 @@ describe('useSegmentationState', () => { }) it('should update child config delimiter with truthy value', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.updateChildConfig('delimiter', '\n') @@ -665,7 +656,7 @@ describe('useSegmentationState', () => { }) it('should update child config delimiter with empty value', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.updateChildConfig('delimiter', '') @@ -675,7 +666,7 @@ describe('useSegmentationState', () => { }) it('should update child config maxLength', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.updateChildConfig('maxLength', 256) @@ -685,7 +676,7 @@ describe('useSegmentationState', () => { }) it('should set chunk for context mode', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.setChunkForContext('full-doc') @@ -698,7 +689,7 @@ describe('useSegmentationState', () => { // Tests for resetToDefaults describe('resetToDefaults', () => { it('should reset to default config when available', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) // Set non-default values and default config act(() => { @@ -718,7 +709,7 @@ describe('useSegmentationState', () => { }) it('should only reset parentChildConfig when no default config', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) // Set non-default values without setting defaultConfig act(() => { @@ -743,7 +734,7 @@ describe('useSegmentationState', () => { // Tests for applyConfigFromRules describe('applyConfigFromRules', () => { it('should apply general config from rules', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) const rules = createMockRules({ segmentation: { separator: '---', max_tokens: 512, chunk_overlap: 25 }, }) @@ -757,7 +748,7 @@ describe('useSegmentationState', () => { }) it('should apply hierarchical config from rules', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) const rules = createMockRules({ parent_mode: 'paragraph', subchunk_segmentation: { separator: '\n', max_tokens: 256 }, @@ -772,7 +763,7 @@ describe('useSegmentationState', () => { }) it('should apply full hierarchical parent-child config from rules', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) const rules = createMockRules({ segmentation: { separator: '\n\n', max_tokens: 1024, chunk_overlap: 50 }, parent_mode: 'full-doc', @@ -797,7 +788,7 @@ describe('useSegmentationState', () => { // Tests for getProcessRule describe('getProcessRule', () => { it('should return general process rule', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) const processRule = result.current.getProcessRule(ChunkingMode.text) @@ -806,7 +797,7 @@ describe('useSegmentationState', () => { }) it('should return hierarchical process rule for parent-child', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) const processRule = result.current.getProcessRule(ChunkingMode.parentChild) @@ -829,7 +820,7 @@ describe('useIndexingConfig', () => { // Note: Hook has useEffect that syncs state, so we test the state after effects settle describe('Initial State', () => { it('should initialize with QUALIFIED when API key is set', async () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false }), ) @@ -840,7 +831,7 @@ describe('useIndexingConfig', () => { }) it('should initialize with ECONOMICAL when API key is not set', async () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useIndexingConfig({ isAPIKeySet: false, hasSetIndexType: false }), ) @@ -850,7 +841,7 @@ describe('useIndexingConfig', () => { }) it('should use initial index type when provided', async () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useIndexingConfig({ isAPIKeySet: false, hasSetIndexType: true, @@ -867,7 +858,7 @@ describe('useIndexingConfig', () => { // Tests for state setters describe('State Management', () => { it('should update index type', async () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false }), ) @@ -884,7 +875,7 @@ describe('useIndexingConfig', () => { }) it('should update embedding model', async () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false }), ) @@ -900,7 +891,7 @@ describe('useIndexingConfig', () => { }) it('should update retrieval config', async () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false }), ) @@ -928,7 +919,7 @@ describe('useIndexingConfig', () => { // Tests for getIndexingTechnique describe('getIndexingTechnique', () => { it('should return initial type when set', async () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: true, @@ -942,7 +933,7 @@ describe('useIndexingConfig', () => { }) it('should return current type when no initial type', async () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false }), ) @@ -973,7 +964,7 @@ describe('useIndexingConfig', () => { score_threshold: 0.8, } - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false, @@ -1009,13 +1000,13 @@ describe('usePreviewState', () => { // Tests for initial state describe('Initial State', () => { it('should initialize with first file for FILE data source', () => { - const { result } = renderHook(() => usePreviewState(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => usePreviewState(defaultOptions)) expect(result.current.previewFile).toEqual(defaultOptions.files[0]) }) it('should initialize with first notion page for NOTION data source', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.NOTION }), ) @@ -1024,7 +1015,7 @@ describe('usePreviewState', () => { it('should start without a preview file when editing an existing document', () => { const documentDetail = createMockDocumentDetail() - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ ...defaultOptions, documentDetail, @@ -1039,14 +1030,14 @@ describe('usePreviewState', () => { // Tests for getPreviewPickerItems describe('getPreviewPickerItems', () => { it('should return files for FILE data source', () => { - const { result } = renderHook(() => usePreviewState(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => usePreviewState(defaultOptions)) const items = result.current.getPreviewPickerItems() expect(items).toEqual(defaultOptions.files) }) it('should return mapped notion pages for NOTION data source', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.NOTION }), ) @@ -1059,7 +1050,7 @@ describe('usePreviewState', () => { }) it('should return mapped website pages for WEB data source', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.WEB }), ) @@ -1072,7 +1063,7 @@ describe('usePreviewState', () => { }) it('should return empty array for unknown data source', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ ...defaultOptions, dataSourceType: 'unknown' as DataSourceType }), ) @@ -1084,7 +1075,7 @@ describe('usePreviewState', () => { // Tests for getPreviewPickerValue describe('getPreviewPickerValue', () => { it('should return file value for FILE data source', () => { - const { result } = renderHook(() => usePreviewState(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => usePreviewState(defaultOptions)) const value = result.current.getPreviewPickerValue() expect(value).toEqual(defaultOptions.files[0]) @@ -1092,7 +1083,7 @@ describe('usePreviewState', () => { it('should return mapped notion page value for NOTION data source', () => { const notionPage = createMockNotionPage({ page_id: 'page-123', page_name: 'My Page' }) - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.NOTION, @@ -1113,7 +1104,7 @@ describe('usePreviewState', () => { source_url: 'https://test.com', title: 'Test Title', }) - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.WEB, @@ -1130,7 +1121,7 @@ describe('usePreviewState', () => { }) it('should return empty value for unknown data source', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ ...defaultOptions, dataSourceType: 'unknown' as DataSourceType }), ) @@ -1139,7 +1130,7 @@ describe('usePreviewState', () => { }) it('should handle undefined notion page gracefully', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.NOTION, @@ -1156,7 +1147,7 @@ describe('usePreviewState', () => { }) it('should handle undefined website page gracefully', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.WEB, @@ -1177,7 +1168,9 @@ describe('usePreviewState', () => { describe('handlePreviewChange', () => { it('should update preview file for FILE data source', () => { const files = [createMockFile(), createMockFile({ id: 'file-2', name: 'second.pdf' })] - const { result } = renderHook(() => usePreviewState({ ...defaultOptions, files })) + const { result } = renderHookWithConsoleQuery(() => + usePreviewState({ ...defaultOptions, files }), + ) act(() => { result.current.handlePreviewChange({ id: 'file-2', name: 'second.pdf' }) @@ -1191,7 +1184,7 @@ describe('usePreviewState', () => { createMockNotionPage(), createMockNotionPage({ page_id: 'notion-page-2', page_name: 'Second Page' }), ] - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.NOTION, notionPages }), ) @@ -1207,7 +1200,7 @@ describe('usePreviewState', () => { createMockWebsitePage(), createMockWebsitePage({ source_url: 'https://example.com/page2', title: 'Second Page' }), ] - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.WEB, websitePages }), ) @@ -1238,7 +1231,7 @@ describe('useDocumentCreation', () => { // Tests for validateParams describe('validateParams', () => { it('should return false when overlap exceeds max chunk length', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) const isValid = result.current.validateParams({ segmentationType: 'general', @@ -1262,7 +1255,7 @@ describe('useDocumentCreation', () => { }) it('should return false when max chunk length exceeds limit', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) const isValid = result.current.validateParams({ segmentationType: 'general', @@ -1286,7 +1279,7 @@ describe('useDocumentCreation', () => { }) it('should return true for valid params', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) const isValid = result.current.validateParams({ segmentationType: 'general', @@ -1313,7 +1306,7 @@ describe('useDocumentCreation', () => { // Tests for buildCreationParams describe('buildCreationParams', () => { it('should build params for file upload', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) const params = result.current.buildCreationParams( ChunkingMode.text, @@ -1339,7 +1332,7 @@ describe('useDocumentCreation', () => { it('should build params for setting mode', () => { const documentDetail = createMockDocumentDetail() - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation({ ...defaultOptions, isSetting: true, @@ -1367,7 +1360,7 @@ describe('useDocumentCreation', () => { }) it('should build params for notion_import data source', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation({ ...defaultOptions, dataSourceType: DataSourceType.NOTION, @@ -1398,7 +1391,7 @@ describe('useDocumentCreation', () => { }) it('should build params for website_crawl data source', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation({ ...defaultOptions, dataSourceType: DataSourceType.WEB, @@ -1434,7 +1427,7 @@ describe('useDocumentCreation', () => { // Tests for validateParams edge cases describe('validateParams - additional cases', () => { it('should return false when embedding model is missing for QUALIFIED index type', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) const isValid = result.current.validateParams({ segmentationType: 'general', @@ -1458,7 +1451,7 @@ describe('useDocumentCreation', () => { }) it('should return false when rerank model is required but not selected', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) // isReRankModelSelected returns false when: // - indexMethod === 'high_quality' (IndexingType.QUALIFIED) @@ -1498,7 +1491,7 @@ describe('useDocumentCreation', () => { const mockUpdateRetrievalMethodCache = vi.fn() const mockOnSave = vi.fn() - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation({ ...defaultOptions, datasetId: undefined, @@ -1542,7 +1535,7 @@ describe('useDocumentCreation', () => { it('should call createDocumentMutation when datasetId is provided', async () => { const mockOnStepChange = vi.fn() - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation({ ...defaultOptions, datasetId: 'existing-dataset-id', @@ -1583,7 +1576,7 @@ describe('useDocumentCreation', () => { it('should call onSave when in setting mode', async () => { const mockOnSave = vi.fn() const documentDetail = createMockDocumentDetail() - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation({ ...defaultOptions, datasetId: 'existing-dataset-id', @@ -1627,14 +1620,14 @@ describe('useDocumentCreation', () => { // Tests for validatePreviewParams describe('validatePreviewParams', () => { it('should return true for valid max chunk length', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) const isValid = result.current.validatePreviewParams(1000) expect(isValid).toBe(true) }) it('should return false when max chunk length exceeds maximum', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) const isValid = result.current.validatePreviewParams(10000) expect(isValid).toBe(false) @@ -1664,7 +1657,7 @@ describe('useIndexingEstimate', () => { // Tests for initial state describe('Initial State', () => { it('should initialize with idle state', () => { - const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useIndexingEstimate(defaultOptions)) expect(result.current.isIdle).toBe(true) expect(result.current.isPending).toBe(false) @@ -1675,7 +1668,7 @@ describe('useIndexingEstimate', () => { // Tests for fetchEstimate describe('fetchEstimate', () => { it('should call fetchEstimate for FILE data source', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useIndexingEstimate({ ...defaultOptions, dataSourceType: DataSourceType.FILE, @@ -1692,7 +1685,7 @@ describe('useIndexingEstimate', () => { }) it('should call fetchEstimate for NOTION data source', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useIndexingEstimate({ ...defaultOptions, dataSourceType: DataSourceType.NOTION, @@ -1709,7 +1702,7 @@ describe('useIndexingEstimate', () => { }) it('should call fetchEstimate for WEB data source', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useIndexingEstimate({ ...defaultOptions, dataSourceType: DataSourceType.WEB, @@ -1731,7 +1724,7 @@ describe('useIndexingEstimate', () => { // Tests for getCurrentMutation based on data source type describe('Data Source Selection', () => { it('should use file query for FILE data source', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useIndexingEstimate({ ...defaultOptions, dataSourceType: DataSourceType.FILE, @@ -1743,7 +1736,7 @@ describe('useIndexingEstimate', () => { }) it('should use notion query for NOTION data source', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useIndexingEstimate({ ...defaultOptions, dataSourceType: DataSourceType.NOTION, @@ -1755,7 +1748,7 @@ describe('useIndexingEstimate', () => { }) it('should use website query for WEB data source', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useIndexingEstimate({ ...defaultOptions, dataSourceType: DataSourceType.WEB, @@ -2097,7 +2090,7 @@ describe('Edge Cases', () => { describe('Empty/Null Values', () => { it('should handle empty files array in usePreviewState', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ dataSourceType: DataSourceType.FILE, files: [], @@ -2110,7 +2103,7 @@ describe('Edge Cases', () => { }) it('should handle empty notion pages array', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ dataSourceType: DataSourceType.NOTION, files: [], @@ -2123,7 +2116,7 @@ describe('Edge Cases', () => { }) it('should handle empty website pages array', () => { - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ dataSourceType: DataSourceType.WEB, files: [], @@ -2138,7 +2131,7 @@ describe('Edge Cases', () => { describe('Boundary Conditions', () => { it('should handle very large chunk length', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.setMaxChunkLength(999999) @@ -2148,7 +2141,7 @@ describe('Edge Cases', () => { }) it('should handle zero overlap', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.setOverlap(0) @@ -2158,7 +2151,7 @@ describe('Edge Cases', () => { }) it('should handle special characters in segment identifier', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.setSegmentIdentifier('<<>>') @@ -2170,7 +2163,7 @@ describe('Edge Cases', () => { describe('Callback Stability', () => { it('should maintain stable setSegmentIdentifier reference', () => { - const { result, rerender } = renderHook(() => useSegmentationState()) + const { result, rerender } = renderHookWithConsoleQuery(() => useSegmentationState()) const initialSetter = result.current.setSegmentIdentifier rerender() @@ -2179,7 +2172,7 @@ describe('Edge Cases', () => { }) it('should maintain stable toggleRule reference', () => { - const { result, rerender } = renderHook(() => useSegmentationState()) + const { result, rerender } = renderHookWithConsoleQuery(() => useSegmentationState()) const initialToggle = result.current.toggleRule rerender() @@ -2188,7 +2181,7 @@ describe('Edge Cases', () => { }) it('should maintain stable getProcessRule reference', () => { - const { result, rerender } = renderHook(() => useSegmentationState()) + const { result, rerender } = renderHookWithConsoleQuery(() => useSegmentationState()) // Update some state to trigger re-render act(() => { @@ -2215,8 +2208,8 @@ describe('Integration Scenarios', () => { it('should build and validate params for file upload workflow', () => { const files = [createMockFile()] - const { result: segResult } = renderHook(() => useSegmentationState()) - const { result: creationResult } = renderHook(() => + const { result: segResult } = renderHookWithConsoleQuery(() => useSegmentationState()) + const { result: creationResult } = renderHookWithConsoleQuery(() => useDocumentCreation({ dataSourceType: DataSourceType.FILE, files, @@ -2248,7 +2241,7 @@ describe('Integration Scenarios', () => { }) it('should handle parent-child document form', () => { - const { result } = renderHook(() => useSegmentationState()) + const { result } = renderHookWithConsoleQuery(() => useSegmentationState()) act(() => { result.current.setSegmentationType(ProcessMode.parentChild) @@ -2273,7 +2266,7 @@ describe('Integration Scenarios', () => { createMockFile({ id: 'file-2', name: 'second.pdf' }), ] - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => usePreviewState({ dataSourceType: DataSourceType.FILE, files, diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts index f2065bc673ac64..4325edb962663e 100644 --- a/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts @@ -5,11 +5,16 @@ import type { ProcessRule, } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' -import { renderHook } from '@testing-library/react' +import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { ChunkingMode, DataSourceType } from '@/models/datasets' +import { consoleQuery } from '@/service/console' +import { renderHookWithConsoleQuery } from '@/test/console/query-data' import { RETRIEVE_METHOD } from '@/types/app' +const invalidateQueries = vi.spyOn(QueryClient.prototype, 'invalidateQueries') +const datasetListKey = consoleQuery.datasets.get.key() + // Hoisted mocks const mocks = vi.hoisted(() => ({ toastNotify: vi.fn(), @@ -44,10 +49,6 @@ vi.mock('@/service/knowledge/use-create-dataset', () => ({ getWebsiteInfo: vi.fn(() => ({})), })) -vi.mock('@/service/knowledge/use-dataset', () => ({ - useInvalidDatasetList: () => mocks.invalidDatasetList, -})) - const { useDocumentCreation } = await import('../use-document-creation') const { IndexingType } = await import('../use-indexing-config') @@ -85,19 +86,19 @@ describe('useDocumentCreation', () => { describe('validateParams', () => { it('should return true for valid params', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) expect(result.current.validateParams(defaultValidationParams)).toBe(true) }) it('should return false when overlap > maxChunkLength', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) const invalid = { ...defaultValidationParams, overlap: 2000, maxChunkLength: 1000 } expect(result.current.validateParams(invalid)).toBe(false) expect(mocks.toastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('should return false when maxChunkLength > limitMaxChunkLength', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) const invalid = { ...defaultValidationParams, maxChunkLength: 5000, @@ -107,7 +108,7 @@ describe('useDocumentCreation', () => { }) it('should return false when qualified but no embedding model', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) const invalid = { ...defaultValidationParams, indexType: IndexingType.QUALIFIED, @@ -118,13 +119,13 @@ describe('useDocumentCreation', () => { it('should return false when rerank model not selected', () => { mocks.isReRankModelSelected.mockReturnValue(false) - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) expect(result.current.validateParams(defaultValidationParams)).toBe(false) }) it('should skip embedding/rerank checks when isSetting is true', () => { mocks.isReRankModelSelected.mockReturnValue(false) - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation({ ...defaultOptions, isSetting: true }), ) const params = { @@ -137,7 +138,7 @@ describe('useDocumentCreation', () => { describe('buildCreationParams', () => { it('should build params for FILE data source', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) const processRule = { mode: 'custom', rules: {} } as unknown as ProcessRule const retrievalConfig = defaultValidationParams.retrievalConfig const embeddingModel = { provider: 'openai', model: 'text-embedding-3-small' } @@ -160,7 +161,7 @@ describe('useDocumentCreation', () => { it('should build params for isSetting mode', () => { const detail = { id: 'doc-1' } as FullDocumentDetail - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation({ ...defaultOptions, isSetting: true, documentDetail: detail }), ) const params = result.current.buildCreationParams( @@ -179,12 +180,12 @@ describe('useDocumentCreation', () => { describe('validatePreviewParams', () => { it('should return true when maxChunkLength is within limit', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) expect(result.current.validatePreviewParams(1024)).toBe(true) }) it('should return false when maxChunkLength exceeds limit', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) expect(result.current.validatePreviewParams(999999)).toBe(false) expect(mocks.toastNotify).toHaveBeenCalled() }) @@ -193,7 +194,7 @@ describe('useDocumentCreation', () => { describe('executeCreation', () => { it('should not create document when existing dataset cannot add documents', async () => { const onStepChange = vi.fn() - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation({ ...defaultOptions, datasetId: 'dataset-1', @@ -209,13 +210,13 @@ describe('useDocumentCreation', () => { ) expect(mocks.mutateAsync).not.toHaveBeenCalled() - expect(mocks.invalidDatasetList).not.toHaveBeenCalled() + expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: datasetListKey }) expect(onStepChange).not.toHaveBeenCalled() }) it('should allow setting saves even when document creation permission is false', async () => { const onSave = vi.fn() - const { result } = renderHook(() => + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation({ ...defaultOptions, datasetId: 'dataset-1', @@ -238,7 +239,7 @@ describe('useDocumentCreation', () => { describe('isCreating', () => { it('should reflect mutation pending state', () => { - const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) expect(result.current.isCreating).toBe(false) }) }) diff --git a/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts b/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts index 841ce841dd2b2f..2d99fd9889acb4 100644 --- a/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts +++ b/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts @@ -13,6 +13,7 @@ import type { SummaryIndexSetting as SummaryIndexSettingType, } from '@/models/datasets' import type { RetrievalConfig, RETRIEVE_METHOD } from '@/types/app' +import { useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' @@ -20,13 +21,13 @@ import { isReRankModelSelected } from '@/app/components/datasets/common/check-re import { toast } from '@/app/notifications' import { DataSourceProvider } from '@/models/common' import { DataSourceType } from '@/models/datasets' +import { consoleQuery } from '@/service/console' import { getNotionInfo, getWebsiteInfo, useCreateDocument, useCreateFirstDocument, } from '@/service/knowledge/use-create-dataset' -import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' import { IndexingType } from './use-indexing-config' import { MAXIMUM_CHUNK_TOKEN_LENGTH } from './use-segmentation-state' @@ -85,7 +86,10 @@ export const useDocumentCreation = (options: UseDocumentCreationOptions) => { } = options const createFirstDocumentMutation = useCreateFirstDocument() const createDocumentMutation = useCreateDocument(datasetId!) - const invalidDatasetList = useInvalidDatasetList() + const queryClient = useQueryClient() + const invalidDatasetList = useCallback(() => { + queryClient.invalidateQueries({ queryKey: consoleQuery.datasets.get.key() }) + }, [queryClient]) const isCreating = createFirstDocumentMutation.isPending || createDocumentMutation.isPending // Validate creation params const validateParams = useCallback( diff --git a/web/app/components/datasets/create/step-two/index.tsx b/web/app/components/datasets/create/step-two/index.tsx index faccc3b20fba29..daa631137a193f 100644 --- a/web/app/components/datasets/create/step-two/index.tsx +++ b/web/app/components/datasets/create/step-two/index.tsx @@ -8,6 +8,12 @@ import { Separator } from '@langgenius/dify-ui/separator' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' +import { + toChunkingMode, + toCreationDataSourceType, + toRetrievalConfig, + toSummaryIndexSetting, +} from '@/app/components/datasets/dataset-fields' import { toast } from '@/app/notifications' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' @@ -76,13 +82,13 @@ const StepTwo: FC = ({ const isInCreatePage = !datasetId || (datasetId && !currentDataset?.data_source_type) const dataSourceType = isInCreatePage ? inCreatePageDataSourceType - : (currentDataset?.data_source_type ?? inCreatePageDataSourceType) + : (toCreationDataSourceType(currentDataset?.data_source_type) ?? inCreatePageDataSourceType) const hasSetIndexType = !!propsIndexingType const isModelAndRetrievalConfigDisabled = !!datasetId && !!currentDataset?.data_source_type // Document form state const [docForm, setDocForm] = useState( - datasetId && documentDetail ? (documentDetail.doc_form as ChunkingMode) : ChunkingMode.text, + (datasetId && toChunkingMode(documentDetail?.doc_form)) || ChunkingMode.text, ) const [docLanguage, setDocLanguage] = useState(() => datasetId && documentDetail?.doc_language @@ -92,23 +98,30 @@ const StepTwo: FC = ({ : 'Chinese Simplified', ) const [isQAConfirmDialogOpen, setIsQAConfirmDialogOpen] = useState(false) - const currentDocForm = currentDataset?.doc_form || docForm + const datasetChunkingMode = toChunkingMode(currentDataset?.doc_form) + const currentDocForm = datasetChunkingMode || docForm // Custom hooks const segmentation = useSegmentationState({ initialSegmentationType: - currentDataset?.doc_form === ChunkingMode.parentChild + datasetChunkingMode === ChunkingMode.parentChild ? ProcessMode.parentChild : ProcessMode.general, - initialSummaryIndexSetting: currentDataset?.summary_index_setting, + initialSummaryIndexSetting: + currentDataset?.summary_index_setting && + toSummaryIndexSetting(currentDataset.summary_index_setting), }) const showSummaryIndexSetting = !currentDataset const indexing = useIndexingConfig({ initialIndexType: propsIndexingType, initialEmbeddingModel: currentDataset?.embedding_model - ? { provider: currentDataset.embedding_model_provider, model: currentDataset.embedding_model } + ? { + provider: currentDataset.embedding_model_provider ?? '', + model: currentDataset.embedding_model, + } : undefined, - initialRetrievalConfig: currentDataset?.retrieval_model_dict, + initialRetrievalConfig: + currentDataset && toRetrievalConfig(currentDataset.retrieval_model_dict), isAPIKeySet, hasSetIndexType, }) @@ -160,11 +173,14 @@ const StepTwo: FC = ({ // Fetch default process rule const fetchDefaultProcessRuleMutation = useFetchDefaultProcessRule({ onSuccess(data) { - segmentation.setSegmentIdentifier(data.rules.segmentation.separator) - segmentation.setMaxChunkLength(data.rules.segmentation.max_tokens) - segmentation.setOverlap(data.rules.segmentation.chunk_overlap!) - segmentation.setRules(data.rules.pre_processing_rules) - segmentation.setDefaultConfig(data.rules) + const defaultRules = data.rules ? toSegmentationRules(data.rules) : undefined + if (defaultRules) { + segmentation.setSegmentIdentifier(defaultRules.segmentation.separator) + segmentation.setMaxChunkLength(defaultRules.segmentation.max_tokens) + segmentation.setOverlap(defaultRules.segmentation.chunk_overlap!) + segmentation.setRules(defaultRules.pre_processing_rules) + segmentation.setDefaultConfig(defaultRules) + } segmentation.setLimitMaxChunkLength(data.limits.indexing_max_segmentation_tokens_length) }, }) @@ -270,11 +286,11 @@ const StepTwo: FC = ({ // Show options conditions const showGeneralOption = - (isInUpload && generalChunkingModes.includes(currentDataset!.doc_form)) || + (isInUpload && !!datasetChunkingMode && generalChunkingModes.includes(datasetChunkingMode)) || isUploadInEmptyDataset || isInInit const showParentChildOption = - (isInUpload && currentDataset!.doc_form === ChunkingMode.parentChild) || + (isInUpload && datasetChunkingMode === ChunkingMode.parentChild) || isUploadInEmptyDataset || isInInit diff --git a/web/app/components/datasets/create/step-two/process-rule.ts b/web/app/components/datasets/create/step-two/process-rule.ts index f65dc8881740e0..7c23e97302bb12 100644 --- a/web/app/components/datasets/create/step-two/process-rule.ts +++ b/web/app/components/datasets/create/step-two/process-rule.ts @@ -1,4 +1,7 @@ -import type { DocumentProcessRulesResponse } from '@dify/contracts/api/console/datasets/types.gen' +import type { + DocumentProcessRulesResponse, + Rule, +} from '@dify/contracts/api/console/datasets/types.gen' import type { PreProcessingRule, Rules } from '@/models/datasets' import { ProcessMode } from '@/models/datasets' @@ -14,9 +17,9 @@ const isPreProcessingRule = (rule: { id: string; enabled: boolean }): rule is Pr export const isProcessMode = (mode: string): mode is ProcessMode => Object.values(ProcessMode).includes(mode) -const toSegmentation = ( - segmentation: NonNullable, -) => ({ +type StoredRules = DocumentProcessRulesResponse | Rule + +const toSegmentation = (segmentation: NonNullable) => ({ // The backend defaults an unspecified separator to a newline and overlap to zero. separator: segmentation.separator ?? '\n', max_tokens: segmentation.max_tokens, @@ -27,7 +30,7 @@ const toSegmentation = ( * Segmentation settings from a stored process rule. Automatic-mode rules store no segmentation, * so there is nothing to restore for them. */ -export const toSegmentationRules = (rules: DocumentProcessRulesResponse): Rules | undefined => { +export const toSegmentationRules = (rules: StoredRules): Rules | undefined => { if (!rules.segmentation) return undefined return { pre_processing_rules: (rules.pre_processing_rules ?? []).filter(isPreProcessingRule), diff --git a/web/app/components/datasets/dataset-fields.ts b/web/app/components/datasets/dataset-fields.ts new file mode 100644 index 00000000000000..b70b54d1c197b4 --- /dev/null +++ b/web/app/components/datasets/dataset-fields.ts @@ -0,0 +1,93 @@ +import type { + DatasetIconInfoResponse, + DatasetRetrievalModelResponse, + DatasetSummaryIndexSettingResponse, +} from '@dify/contracts/api/console/datasets/types.gen' +import type { IconInfo, SummaryIndexSetting } from '@/models/datasets' +import type { AppIconType, RetrievalConfig } from '@/types/app' +import { + ChunkingMode, + DataSourceType, + RerankingModeEnum, + WeightedScoreEnum, +} from '@/models/datasets' + +const isOneOf = (values: Record, value: unknown): value is T => + typeof value === 'string' && Object.values(values).includes(value) + +/** A dataset's chunk structure, when it is one the console can render. */ +export const toChunkingMode = (docForm: string | null | undefined) => + isOneOf(ChunkingMode, docForm) ? docForm : undefined + +/** The data sources of the classic document creation flow. */ +export const toCreationDataSourceType = (dataSourceType: string | null | undefined) => + isOneOf(DataSourceType, dataSourceType) ? dataSourceType : undefined + +const APP_ICON_TYPES = { image: 'image', emoji: 'emoji', link: 'link' } satisfies Record< + AppIconType, + AppIconType +> + +/** The dataset icon, when one was chosen. */ +export const toIconInfo = (iconInfo: DatasetIconInfoResponse | undefined): IconInfo | undefined => { + if (!iconInfo?.icon || !isOneOf(APP_ICON_TYPES, iconInfo.icon_type)) return undefined + return { + icon: iconInfo.icon, + icon_type: iconInfo.icon_type, + icon_background: iconInfo.icon_background ?? undefined, + icon_url: iconInfo.icon_url ?? undefined, + } +} + +/** The summary index settings form state; unset fields stay unset. */ +export const toSummaryIndexSetting = ({ + enable, + model_name, + model_provider_name, + summary_prompt, +}: DatasetSummaryIndexSettingResponse): SummaryIndexSetting => ({ + enable: enable ?? undefined, + model_name: model_name ?? undefined, + model_provider_name: model_provider_name ?? undefined, + summary_prompt: summary_prompt ?? undefined, +}) + +/** + * The retrieval settings form state for a stored retrieval model. Unset model names stay empty, + * as in the backend's default retrieval model, which the form reads as no model selected. + */ +export const toRetrievalConfig = (retrieval: DatasetRetrievalModelResponse): RetrievalConfig => { + const weights = retrieval.weights + const vectorWeight = weights?.vector_setting?.vector_weight + const keywordWeight = weights?.keyword_setting?.keyword_weight + return { + search_method: retrieval.search_method, + reranking_enable: retrieval.reranking_enable, + reranking_model: { + reranking_provider_name: retrieval.reranking_model?.reranking_provider_name ?? '', + reranking_model_name: retrieval.reranking_model?.reranking_model_name ?? '', + }, + top_k: retrieval.top_k, + score_threshold_enabled: retrieval.score_threshold_enabled, + score_threshold: retrieval.score_threshold ?? undefined, + reranking_mode: isOneOf(RerankingModeEnum, retrieval.reranking_mode) + ? retrieval.reranking_mode + : undefined, + // Weighted scores are kept only when both weights were stored. The backend ignores the weight + // type, and the console writes customized weights. + weights: + weights && vectorWeight != null && keywordWeight != null + ? { + weight_type: isOneOf(WeightedScoreEnum, weights.weight_type) + ? weights.weight_type + : WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: vectorWeight, + embedding_provider_name: weights.vector_setting?.embedding_provider_name ?? '', + embedding_model_name: weights.vector_setting?.embedding_model_name ?? '', + }, + keyword_setting: { keyword_weight: keywordWeight }, + } + : undefined, + } +} diff --git a/web/app/components/datasets/documents/create-from-pipeline/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/index.tsx index 88ef6330131544..263f83825c135c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/index.tsx @@ -49,7 +49,7 @@ const CreateFormPipeline = () => { }), ) const dataset = useDatasetDetailContextWithSelector((s) => s.dataset) - const pipelineId = dataset?.pipeline_id + const pipelineId = dataset?.pipeline_id ?? undefined const { data: currentUserId } = useSuspenseQuery({ ...userProfileQueryOptions(), select: (data) => data.profile.id, diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx index 6bac35fb9a82d6..6a2a51f8e4dd3b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx @@ -53,20 +53,23 @@ vi.mock('@/app/components/datasets/common/vector-space-admission-alert', () => ( ), })) -// Mock useIndexingStatusBatch hook +// Mock the batch indexing status request let mockFetchIndexingStatus: Mock let mockIndexingStatusData: IndexingStatusResponse[] = [] -vi.mock('@/service/knowledge/use-dataset', () => ({ - useIndexingStatusBatch: () => ({ - mutateAsync: mockFetchIndexingStatus, - }), - useProcessRule: () => ({ - data: { - mode: 'custom', - rules: { parent_mode: 'paragraph' }, - }, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.batch.byBatch.indexingStatus.get': () => ({ + mutateAsync: (...args: unknown[]) => mockFetchIndexingStatus(...args), + }), + 'datasets.processRule.get': () => ({ + data: { + mode: 'custom', + rules: { parent_mode: 'paragraph' }, + }, + }), + }) +}) // Mock useDatasetApiAccessUrl hook vi.mock('@/hooks/use-api-access-url', () => ({ @@ -846,23 +849,10 @@ describe('EmbeddingProcess', () => { describe('Edge Cases', () => { // Tests for boundary conditions - it('should throw error when documents array is empty', () => { - // The component accesses documents[0].id for useProcessRule (line 81-82), - // which throws TypeError when documents array is empty. - // This test documents this known limitation. + it('should render without documents', () => { const props = createDefaultProps({ documents: [] }) - // Suppress console errors for expected error - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(Function.prototype as () => void) - - // Act & Assert - explicitly assert the error behavior - expect(() => { - render() - }).toThrow(TypeError) - - consoleError.mockRestore() + expect(() => render()).not.toThrow() }) it('should handle empty indexing status response', async () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx index dc0c9d8c4350e1..0dabd932adf1c6 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx @@ -15,7 +15,7 @@ import { RiLoader2Fill, RiTerminalBoxLine, } from '@remixicon/react' -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import * as React from 'react' import { useEffect, useMemo, useState } from 'react' @@ -30,7 +30,6 @@ import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' import { DatasourceType } from '@/models/pipeline' import Link from '@/next/link' import { consoleQuery } from '@/service/console' -import { useIndexingStatusBatch, useProcessRule } from '@/service/knowledge/use-dataset' import RuleDetail from './rule-detail' const isNotionPageIcon = (value: unknown): value is DataSourceNotionPage['page_icon'] => @@ -63,25 +62,30 @@ const EmbeddingProcess = ({ [], ) const [shouldPoll, setShouldPoll] = useState(true) - const { mutateAsync: fetchIndexingStatus } = useIndexingStatusBatch({ datasetId, batchId }) + const { mutateAsync: fetchIndexingStatus } = useMutation( + consoleQuery.datasets.byDatasetId.batch.byBatch.indexingStatus.get.mutationOptions(), + ) useEffect(() => { let timeoutId: ReturnType const fetchData = async () => { - await fetchIndexingStatus(undefined, { - onSuccess: (res) => { - const indexingStatusDetailList = res.data - setIndexingStatusDetail(indexingStatusDetailList) - const isCompleted = indexingStatusDetailList.every((indexingStatusDetail) => - ['completed', 'error', 'paused'].includes(indexingStatusDetail.indexing_status), - ) - if (isCompleted) setShouldPoll(false) - }, - onSettled: () => { - if (shouldPoll) timeoutId = setTimeout(fetchData, 2500) + await fetchIndexingStatus( + { params: { dataset_id: datasetId, batch: batchId } }, + { + onSuccess: (res) => { + const indexingStatusDetailList = res.data + setIndexingStatusDetail(indexingStatusDetailList) + const isCompleted = indexingStatusDetailList.every((indexingStatusDetail) => + ['completed', 'error', 'paused'].includes(indexingStatusDetail.indexing_status), + ) + if (isCompleted) setShouldPoll(false) + }, + onSettled: () => { + if (shouldPoll) timeoutId = setTimeout(fetchData, 2500) + }, }, - }) + ) } fetchData() @@ -93,7 +97,12 @@ const EmbeddingProcess = ({ // get rule const firstDocument = documents[0] - const { data: ruleDetail } = useProcessRule(firstDocument!.id) + const { data: ruleDetail } = useQuery( + consoleQuery.datasets.processRule.get.queryOptions({ + input: { query: { document_id: firstDocument?.id } }, + enabled: !!firstDocument, + }), + ) const queryClient = useQueryClient() const invalidDocumentList = () => @@ -243,7 +252,7 @@ const EmbeddingProcess = ({ diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx index 8034b74fe3cfd1..8915459f609405 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx @@ -16,7 +16,9 @@ const Processing = ({ batchId, documents }: ProcessingProps) => { const { t } = useTranslation(['datasetCreation', 'datasetPipeline']) const docLink = useDocLink() const datasetId = useDatasetDetailContextWithSelector((s) => s.dataset?.id) - const indexingType = useDatasetDetailContextWithSelector((s) => s.dataset?.indexing_technique) + const indexingType = useDatasetDetailContextWithSelector( + (s) => s.dataset?.indexing_technique ?? undefined, + ) const retrievalMethod = useDatasetDetailContextWithSelector( (s) => s.dataset?.retrieval_model_dict?.search_method, ) diff --git a/web/app/components/datasets/documents/detail/embedding/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/embedding/__tests__/index.spec.tsx index 77c25e20dd8ef4..0ce9cd9d972d47 100644 --- a/web/app/components/datasets/documents/detail/embedding/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/__tests__/index.spec.tsx @@ -7,7 +7,6 @@ import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { ProcessMode } from '@/models/datasets' import * as datasetsService from '@/service/datasets' -import * as useDataset from '@/service/knowledge/use-dataset' import { RETRIEVE_METHOD } from '@/types/app' import { IndexingType } from '../../../../create/step-two' import { DocumentContext } from '../../context' @@ -27,7 +26,13 @@ const { mockToast } = vi.hoisted(() => { }) vi.mock('@/service/datasets') -vi.mock('@/service/knowledge/use-dataset') +const { mockProcessRuleQuery } = vi.hoisted(() => ({ mockProcessRuleQuery: vi.fn() })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.processRule.get': mockProcessRuleQuery, + }) +}) vi.mock('@/app/notifications', () => ({ toast: mockToast, })) @@ -35,7 +40,6 @@ vi.mock('@/app/notifications', () => ({ const mockFetchIndexingStatus = vi.mocked(datasetsService.fetchIndexingStatus) const mockPauseDocIndexing = vi.mocked(datasetsService.pauseDocIndexing) const mockResumeDocIndexing = vi.mocked(datasetsService.resumeDocIndexing) -const mockUseProcessRule = vi.mocked(useDataset.useProcessRule) const createConsoleQueryClient = () => new QueryClient({ @@ -98,11 +102,11 @@ describe('EmbeddingDetail', () => { beforeEach(() => { vi.clearAllMocks() - mockUseProcessRule.mockReturnValue({ + mockProcessRuleQuery.mockReturnValue({ data: mockProcessRule(), isLoading: false, error: null, - } as ReturnType) + }) }) describe('Rendering', () => { diff --git a/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts b/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts index 92d5940ba88be5..0deec51d98e54d 100644 --- a/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts +++ b/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts @@ -67,7 +67,10 @@ export const useEmbeddingStatus = ({ const isCompleted = status === 'completed' const isPaused = status === 'paused' const isError = status === 'error' - const percent = calculatePercent(query.data?.completed_segments, query.data?.total_segments) + const percent = calculatePercent( + query.data?.completed_segments ?? undefined, + query.data?.total_segments ?? undefined, + ) // Handle completion callback useEffect(() => { diff --git a/web/app/components/datasets/documents/detail/embedding/index.tsx b/web/app/components/datasets/documents/detail/embedding/index.tsx index bf32cf5c5b88e0..c38dfad2a150f1 100644 --- a/web/app/components/datasets/documents/detail/embedding/index.tsx +++ b/web/app/components/datasets/documents/detail/embedding/index.tsx @@ -1,11 +1,12 @@ import type { FC } from 'react' import type { IndexingType } from '../../../create/step-two' import type { RETRIEVE_METHOD } from '@/types/app' +import { useQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' -import { useProcessRule } from '@/service/knowledge/use-dataset' +import { consoleQuery } from '@/service/console' import { useDocumentContext } from '../context' import { ProgressBar, RuleDetail, SegmentProgress, StatusHeader } from './components' import { useEmbeddingStatus, usePauseIndexing, useResumeIndexing } from './hooks' @@ -44,7 +45,12 @@ const EmbeddingDetail: FC = ({ documentId, onComplete: detailUpdate, }) - const { data: ruleDetail } = useProcessRule(documentId) + const { data: ruleDetail } = useQuery( + consoleQuery.datasets.processRule.get.queryOptions({ + input: { query: { document_id: documentId } }, + enabled: !!documentId, + }), + ) const handleSuccess = useCallback(() => { toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) }, [t]) @@ -97,8 +103,8 @@ const EmbeddingDetail: FC = ({ isError={isError} /> = ({ datasetId, documentId }) => { {embedding ? ( ) : ( diff --git a/web/app/components/datasets/documents/index.tsx b/web/app/components/datasets/documents/index.tsx index 3e01e90e768b43..2d9e9db045994d 100644 --- a/web/app/components/datasets/documents/index.tsx +++ b/web/app/components/datasets/documents/index.tsx @@ -5,6 +5,7 @@ import { keepPreviousData, useQuery, useQueryClient, useSuspenseQuery } from '@t import { useAtomValue } from 'jotai' import { useCallback } from 'react' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' +import { toCreationDataSourceType } from '@/app/components/datasets/dataset-fields' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' @@ -195,7 +196,7 @@ const Documents: FC = ({ datasetId }) => {
({ }), })) -vi.mock('@/service/knowledge/use-dataset', () => ({ - useEnableDatasetServiceApi: () => ({ mutateAsync: mockEnableApi }), - useDisableDatasetServiceApi: () => ({ mutateAsync: mockDisableApi }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.byDatasetId.apiKeys.byStatus.post': () => ({ + mutateAsync: (input: { params: { status: string } }) => + input.params.status === 'enable' ? mockEnableApi(input) : mockDisableApi(input), + }), + }) +}) vi.mock('@/hooks/use-api-access-url', () => ({ useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets', @@ -69,7 +74,9 @@ describe('API access card', () => { await user.click(screen.getByRole('switch')) - expect(mockEnableApi).toHaveBeenCalledWith('dataset-123') + expect(mockEnableApi).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-123', status: 'enable' }, + }) expect(mockMutateDatasetRes).toHaveBeenCalledOnce() }) @@ -80,7 +87,9 @@ describe('API access card', () => { await user.click(screen.getByRole('switch')) - expect(mockDisableApi).toHaveBeenCalledWith('dataset-123') + expect(mockDisableApi).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-123', status: 'disable' }, + }) expect(mockMutateDatasetRes).toHaveBeenCalledOnce() }) diff --git a/web/app/components/datasets/extra-info/api-access/card.tsx b/web/app/components/datasets/extra-info/api-access/card.tsx index 86d598c2ca65b2..2564e9e551249a 100644 --- a/web/app/components/datasets/extra-info/api-access/card.tsx +++ b/web/app/components/datasets/extra-info/api-access/card.tsx @@ -1,7 +1,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Switch } from '@langgenius/dify-ui/switch' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import * as React from 'react' import { useCallback } from 'react' @@ -11,10 +11,7 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' import Link from '@/next/link' -import { - useDisableDatasetServiceApi, - useEnableDatasetServiceApi, -} from '@/service/knowledge/use-dataset' +import { consoleQuery } from '@/service/console' import { getDatasetACLCapabilities } from '@/utils/permission' type CardProps = { @@ -31,8 +28,9 @@ const Card = ({ apiEnabled }: CardProps) => { select: (data) => data.profile.id, }) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi() - const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi() + const { mutateAsync: updateDatasetServiceApiStatus } = useMutation( + consoleQuery.datasets.byDatasetId.apiKeys.byStatus.post.mutationOptions(), + ) const datasetACLCapabilities = React.useMemo( () => @@ -49,12 +47,12 @@ const Card = ({ apiEnabled }: CardProps) => { const onToggle = useCallback( async (state: boolean) => { - let result: string - if (state) result = (await enableDatasetServiceApi(datasetId ?? '')).result - else result = (await disableDatasetServiceApi(datasetId ?? '')).result + const { result } = await updateDatasetServiceApiStatus({ + params: { dataset_id: datasetId ?? '', status: state ? 'enable' : 'disable' }, + }) if (result === 'success') mutateDatasetRes?.() }, - [datasetId, enableDatasetServiceApi, mutateDatasetRes, disableDatasetServiceApi], + [datasetId, updateDatasetServiceApiStatus, mutateDatasetRes], ) return ( diff --git a/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx index 62e402a73917e3..6b4695b4eed7ad 100644 --- a/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx +++ b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx @@ -147,19 +147,7 @@ const mockRecordsRefetch = vi.fn() const mockHitTestingMutateAsync = vi.fn() const mockExternalHitTestingMutateAsync = vi.fn() -vi.mock('@/service/knowledge/use-dataset', () => ({ - useDatasetTestingRecords: vi.fn(() => ({ - data: { - data: [], - total: 0, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - })), -})) +const { mockRecordsQuery } = vi.hoisted(() => ({ mockRecordsQuery: vi.fn() })) // The page owns one mutation per retrieval kind, told apart by mutation key. const { mockHitTestingState, mockExternalHitTestingState } = vi.hoisted(() => ({ @@ -385,13 +373,11 @@ describe('HitTestingPage', () => { userProfile: { id: 'user-1' }, workspacePermissionKeys: [], } - - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ + mockRecordsQuery.mockReturnValue({ data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, refetch: mockRecordsRefetch, isLoading: false, - } as unknown as ReturnType) + }) vi.mocked(mockHitTestingState).mockReturnValue({ mutateAsync: mockHitTestingMutateAsync, isLoading: false, @@ -408,26 +394,25 @@ describe('HitTestingPage', () => { }) it('disables record loading and query input without retrieval recall permission', async () => { - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') mockDataset.permission_keys = [] renderWithProviders() expect(screen.queryByRole('textbox')).not.toBeInTheDocument() - expect(vi.mocked(useDatasetTestingRecords)).toHaveBeenCalledWith( - 'dataset-1', - { limit: 10, page: 1 }, - { enabled: false }, + expect(mockRecordsQuery).toHaveBeenCalledWith( + expect.objectContaining({ + input: { params: { dataset_id: 'dataset-1' }, query: { limit: 10, page: 1 } }, + enabled: false, + }), ) }) it('shows the records loading state', async () => { - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ + mockRecordsQuery.mockReturnValue({ data: undefined, refetch: mockRecordsRefetch, isLoading: true, - } as unknown as ReturnType) + }) renderWithProviders() @@ -554,8 +539,7 @@ describe('HitTestingPage', () => { it('loads a history record into the query input when selected', async () => { const user = userEvent.setup() - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ + mockRecordsQuery.mockReturnValue({ data: { data: [ createMockRecord({ @@ -571,7 +555,7 @@ describe('HitTestingPage', () => { }, refetch: mockRecordsRefetch, isLoading: false, - } as unknown as ReturnType) + }) renderWithProviders() await user.click(screen.getByText('Record query text')) @@ -581,8 +565,7 @@ describe('HitTestingPage', () => { it('requests the next records page through pagination', async () => { const user = userEvent.setup() - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ + mockRecordsQuery.mockReturnValue({ data: { data: Array.from({ length: 10 }, (_, index) => createMockRecord({ id: `record-${index}` })), total: 25, @@ -592,15 +575,16 @@ describe('HitTestingPage', () => { }, refetch: mockRecordsRefetch, isLoading: false, - } as unknown as ReturnType) + }) renderWithProviders() await user.click(screen.getByRole('button', { name: /pagination.next/ })) - expect(vi.mocked(useDatasetTestingRecords)).toHaveBeenLastCalledWith( - 'dataset-1', - { limit: 10, page: 2 }, - { enabled: true }, + expect(mockRecordsQuery).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: { params: { dataset_id: 'dataset-1' }, query: { limit: 10, page: 2 } }, + enabled: true, + }), ) }) @@ -710,9 +694,10 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { 'query', { params: GetWorkspacesCurrentModelsModelTypesByModelTypeData['path'] } > - }) => - options.queryKey[0].includes('modelTypes') - ? { data: [], isPending: false } - : actual.useQuery(options), + }) => { + if (options.queryKey[0].includes('modelTypes')) return { data: [], isPending: false } + if (options.queryKey[0].includes('queries')) return mockRecordsQuery(options) + return actual.useQuery(options) + }, } }) diff --git a/web/app/components/datasets/hit-testing/index.tsx b/web/app/components/datasets/hit-testing/index.tsx index 0551f347bffcfb..88fe93b4f335b7 100644 --- a/web/app/components/datasets/hit-testing/index.tsx +++ b/web/app/components/datasets/hit-testing/index.tsx @@ -22,7 +22,7 @@ import { DrawerViewport, } from '@langgenius/dify-ui/drawer' import { Pagination } from '@langgenius/dify-ui/pagination' -import { useMutation, useSuspenseQuery } from '@tanstack/react-query' +import { keepPreviousData, useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useBoolean } from 'ahooks' import { useAtomValue } from 'jotai' import * as React from 'react' @@ -31,13 +31,13 @@ import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import FloatRightContainer from '@/app/components/base/float-right-container' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' +import { toRetrievalConfig } from '@/app/components/datasets/dataset-fields' import docStyle from '@/app/components/datasets/documents/detail/completed/style.module.css' import DatasetDetailContext from '@/context/dataset-detail' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' -import { consoleClient } from '@/service/console' -import { useDatasetTestingRecords } from '@/service/knowledge/use-dataset' +import { consoleClient, consoleQuery } from '@/service/console' import { getDatasetACLCapabilities } from '@/utils/permission' import { CardSkelton } from '../documents/detail/completed/skeleton/general-list-skeleton' import EmptyRecords from './components/empty-records' @@ -82,10 +82,12 @@ const HitTestingPage: FC = ({ datasetId }: Props) => { data: recordsRes, refetch: recordsRefetch, isLoading: isRecordsLoading, - } = useDatasetTestingRecords( - datasetId, - { limit, page: currPage + 1 }, - { enabled: canRunRetrievalRecall }, + } = useQuery( + consoleQuery.datasets.byDatasetId.queries.get.queryOptions({ + input: { params: { dataset_id: datasetId }, query: { limit, page: currPage + 1 } }, + enabled: !!datasetId && canRunRetrievalRecall, + placeholderData: keepPreviousData, + }), ) const total = recordsRes?.total || 0 @@ -93,8 +95,8 @@ const HitTestingPage: FC = ({ datasetId }: Props) => { const isExternal = currentDataset?.provider === 'external' - const [retrievalConfig, setRetrievalConfig] = useState( - currentDataset?.retrieval_model_dict as RetrievalConfig, + const [retrievalConfig, setRetrievalConfig] = useState( + () => currentDataset && toRetrievalConfig(currentDataset.retrieval_model_dict), ) const [isShowModifyRetrievalModal, setIsShowModifyRetrievalModal] = useState(false) const [ @@ -204,23 +206,25 @@ const HitTestingPage: FC = ({ datasetId }: Props) => { {t(($) => $.desc, { ns: 'datasetHitTesting' })}

- setIsShowModifyRetrievalModal(true)} - retrievalConfig={retrievalConfig} - isEconomy={currentDataset?.indexing_technique === 'economy'} - hitTestingMutation={hitTestingMutation} - externalKnowledgeBaseHitTestingMutation={externalKnowledgeBaseHitTestingMutation} - canRunRetrievalRecall={canRunRetrievalRecall} - /> + {retrievalConfig && ( + setIsShowModifyRetrievalModal(true)} + retrievalConfig={retrievalConfig} + isEconomy={currentDataset?.indexing_technique === 'economy'} + hitTestingMutation={hitTestingMutation} + externalKnowledgeBaseHitTestingMutation={externalKnowledgeBaseHitTestingMutation} + canRunRetrievalRecall={canRunRetrievalRecall} + /> + )}

{t(($) => $.records, { ns: 'datasetHitTesting' })}

@@ -292,16 +296,18 @@ const HitTestingPage: FC = ({ datasetId }: Props) => { - setIsShowModifyRetrievalModal(false)} - onSave={(value) => { - setRetrievalConfig(value) - setIsShowModifyRetrievalModal(false) - }} - /> + {retrievalConfig && ( + setIsShowModifyRetrievalModal(false)} + onSave={(value) => { + setRetrievalConfig(value) + setIsShowModifyRetrievalModal(false) + }} + /> + )} diff --git a/web/app/components/datasets/list/__tests__/datasets.spec.tsx b/web/app/components/datasets/list/__tests__/datasets.spec.tsx index 8e282b8ecc6240..d60b2a86e538ee 100644 --- a/web/app/components/datasets/list/__tests__/datasets.spec.tsx +++ b/web/app/components/datasets/list/__tests__/datasets.spec.tsx @@ -1,11 +1,12 @@ +import type { InfiniteData } from '@tanstack/react-query' import type { DataSet, DataSetListResponse } from '@/models/datasets' -import type { useDatasetList } from '@/service/knowledge/use-dataset' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { IndexingType } from '@/app/components/datasets/create/step-two' import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import { RETRIEVE_METHOD } from '@/types/app' +import { renderWithConsoleQuery } from '@/test/console/query-data' +import { createDatasetFixture } from '@/test/fixtures/dataset' import Datasets from '../datasets' vi.mock('@/next/navigation', () => ({ @@ -24,30 +25,9 @@ vi.mock('@/hooks/use-knowledge', () => ({ }), })) +type DatasetListItem = DataSetListResponse['data'][number] + const mockFetchNextPage = vi.fn() -const mockInvalidDatasetList = vi.fn() - -vi.mock('@/service/knowledge/use-dataset', () => ({ - useDatasetList: vi.fn(() => ({ - data: { - pages: [ - { - data: [ - createMockDataset({ id: 'dataset-1', name: 'Dataset 1' }), - createMockDataset({ id: 'dataset-2', name: 'Dataset 2' }), - ], - }, - ], - }, - fetchNextPage: mockFetchNextPage, - hasNextPage: false, - isFetching: false, - isFetchingNextPage: false, - isLoading: false, - isPlaceholderData: false, - })), - useInvalidDatasetList: () => mockInvalidDatasetList, -})) vi.mock('../dataset-card/hooks/use-dataset-card-state', () => ({ useDatasetCardState: () => ({ @@ -94,8 +74,9 @@ vi.mock('../dataset-card', () => ({ ), })) -function createMockDataset(overrides: Partial = {}): DataSet { +function createMockDataset(overrides: Partial = {}): DatasetListItem { return { + ...createDatasetFixture(), id: 'dataset-1', name: 'Test Dataset', description: 'Test description', @@ -123,12 +104,9 @@ function createMockDataset(overrides: Partial = {}): DataSet { icon_background: '#FFF4ED', icon_url: '', }, - retrieval_model_dict: { - search_method: RETRIEVE_METHOD.semantic, - }, author_name: 'Test User', ...overrides, - } as DataSet + } } let intersectionObserverCallback: IntersectionObserverCallback | null = null @@ -170,7 +148,7 @@ describe('Datasets', () => { ...page, })), pageParams: pages.map((_, index) => index + 1), - }) as unknown as ReturnType['data'] + }) satisfies InfiniteData const defaultProps = { datasetList: createDatasetListData(), @@ -194,19 +172,19 @@ describe('Datasets', () => { describe('Rendering', () => { it('should not render NewDatasetCard in dataset list', () => { - render() + renderWithConsoleQuery() expect(screen.queryByText(/createDataset/)).not.toBeInTheDocument() }) it('should render dataset cards from data', () => { - render() + renderWithConsoleQuery() expect(screen.getByText('Dataset 1')).toBeInTheDocument() expect(screen.getByText('Dataset 2')).toBeInTheDocument() }) it('should pass step-by-step tour targets to the first dataset card only', () => { - render( + renderWithConsoleQuery( { }) it('should render empty element when there are no datasets', () => { - render( + renderWithConsoleQuery( { }) it('should render anchor div for infinite scroll', () => { - render() + renderWithConsoleQuery() const anchor = document.querySelector('.h-0') expect(anchor).toBeInTheDocument() }) @@ -250,7 +228,7 @@ describe('Datasets', () => { describe('Loading States', () => { it('should show dataset card skeletons while initial dataset list is loading', () => { - render( + renderWithConsoleQuery( , ) @@ -259,7 +237,7 @@ describe('Datasets', () => { }) it('should not show dataset card skeletons after an empty dataset list has loaded', () => { - render( + renderWithConsoleQuery( { }) it('should show dataset card skeletons when placeholder data is empty and the next query is fetching', () => { - render( + renderWithConsoleQuery( { }) it('should keep rendered dataset cards when placeholder data has results during refetch', () => { - render( + renderWithConsoleQuery( { it.each([null, undefined, createDatasetListData([])])( 'shows the empty state when dataset data is unavailable or empty (%j)', (datasetList) => { - render( + renderWithConsoleQuery( { describe('IntersectionObserver', () => { it('should setup IntersectionObserver on mount', () => { - render() + renderWithConsoleQuery() expect(mockObserve).toHaveBeenCalled() }) it('should call fetchNextPage when isIntersecting, hasNextPage, and not isFetching', () => { - render() + renderWithConsoleQuery() if (intersectionObserverCallback) { intersectionObserverCallback( @@ -338,7 +316,7 @@ describe('Datasets', () => { }) it('should NOT call fetchNextPage when isIntersecting is false', () => { - render() + renderWithConsoleQuery() if (intersectionObserverCallback) { intersectionObserverCallback( @@ -351,7 +329,7 @@ describe('Datasets', () => { }) it('should NOT call fetchNextPage when hasNextPage is false', () => { - render() + renderWithConsoleQuery() if (intersectionObserverCallback) { intersectionObserverCallback( @@ -364,7 +342,7 @@ describe('Datasets', () => { }) it('should NOT call fetchNextPage when isFetching is true', () => { - render() + renderWithConsoleQuery() if (intersectionObserverCallback) { intersectionObserverCallback( @@ -377,7 +355,9 @@ describe('Datasets', () => { }) it('should NOT call fetchNextPage when placeholder data is showing', () => { - render() + renderWithConsoleQuery( + , + ) if (intersectionObserverCallback) { intersectionObserverCallback( @@ -390,7 +370,7 @@ describe('Datasets', () => { }) it('should disconnect observer on unmount', () => { - const { unmount } = render() + const { unmount } = renderWithConsoleQuery() unmount() @@ -400,7 +380,7 @@ describe('Datasets', () => { describe('Landmarks', () => { it('keeps knowledge cards in the page content without creating another navigation landmark', () => { - render( + renderWithConsoleQuery( { describe('Edge Cases', () => { it('should handle multiple pages of data', () => { - render( + renderWithConsoleQuery( + renderWithConsoleState(ui, { wrapper: createQueryClientWrapper(createTestQueryClient()) }) + const mockPush = vi.fn() const mockReplace = vi.fn() let mockConsoleState = { @@ -51,20 +56,28 @@ vi.mock('@/hooks/use-knowledge', () => ({ }), })) -vi.mock('@/service/knowledge/use-dataset', () => ({ - useDatasetList: vi.fn(() => ({ +const { mockDatasetListQuery } = vi.hoisted(() => ({ + mockDatasetListQuery: vi.fn((_query: { include_all?: boolean; keyword?: string }) => ({ data: { pages: [{ data: [], total: 1 }] }, fetchNextPage: vi.fn(), hasNextPage: false, isFetching: false, isFetchingNextPage: false, })), - useInvalidDatasetList: () => vi.fn(), - useDatasetApiBaseUrl: () => ({ - data: { api_base_url: 'https://api.example.com' }, - }), })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + // The page reads the list's first request. + 'datasets.get': (options: { input: (page: number) => { query: object } }) => + mockDatasetListQuery(options.input(1).query), + 'datasets.apiBaseInfo.get': () => ({ + data: { api_base_url: 'https://api.example.com' }, + }), + }) +}) + // Mock Datasets component vi.mock('../datasets', () => ({ default: ({ @@ -159,14 +172,13 @@ describe('List', () => { isCurrentWorkspaceOwner: true, workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'], } - const { useDatasetList } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetList).mockReturnValue({ + mockDatasetListQuery.mockReturnValue({ data: { pages: [{ data: [], total: 1 }] }, fetchNextPage: vi.fn(), hasNextPage: false, isFetching: false, isFetchingNextPage: false, - } as unknown as ReturnType) + }) }) describe('Rendering', () => { @@ -201,11 +213,9 @@ describe('List', () => { describe('Props', () => { it('should query datasets with includeAll disabled initially', async () => { - const { useDatasetList } = await import('@/service/knowledge/use-dataset') - render() - expect(useDatasetList).toHaveBeenCalledWith( + expect(mockDatasetListQuery).toHaveBeenCalledWith( expect.objectContaining({ include_all: false, }), @@ -213,11 +223,9 @@ describe('List', () => { }) it('should query datasets with empty keywords initially', async () => { - const { useDatasetList } = await import('@/service/knowledge/use-dataset') - render() - expect(useDatasetList).toHaveBeenCalledWith( + expect(mockDatasetListQuery).toHaveBeenCalledWith( expect.objectContaining({ keyword: '', }), @@ -225,11 +233,9 @@ describe('List', () => { }) it('should query datasets with empty tags initially', async () => { - const { useDatasetList } = await import('@/service/knowledge/use-dataset') - render() - expect(useDatasetList).toHaveBeenCalledWith( + expect(mockDatasetListQuery).toHaveBeenCalledWith( expect.objectContaining({ tag_ids: [], }), @@ -281,14 +287,13 @@ describe('List', () => { }) it('should render first empty state when there are no datasets and no active filters', async () => { - const { useDatasetList } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetList).mockReturnValue({ + mockDatasetListQuery.mockReturnValue({ data: { pages: [{ data: [], total: 0 }] }, fetchNextPage: vi.fn(), hasNextPage: false, isFetching: false, isFetchingNextPage: false, - } as unknown as ReturnType) + }) render() @@ -302,14 +307,13 @@ describe('List', () => { isCurrentWorkspaceOwner: true, workspacePermissionKeys: ['dataset.create_and_management'], } - const { useDatasetList } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetList).mockReturnValue({ + mockDatasetListQuery.mockReturnValue({ data: { pages: [{ data: [], total: 0 }] }, fetchNextPage: vi.fn(), hasNextPage: false, isFetching: false, isFetchingNextPage: false, - } as unknown as ReturnType) + }) render() @@ -325,14 +329,13 @@ describe('List', () => { isCurrentWorkspaceOwner: true, workspacePermissionKeys: [], } - const { useDatasetList } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetList).mockReturnValue({ + mockDatasetListQuery.mockReturnValue({ data: { pages: [{ data: [], total: 0 }] }, fetchNextPage: vi.fn(), hasNextPage: false, isFetching: false, isFetchingNextPage: false, - } as unknown as ReturnType) + }) render() @@ -342,14 +345,13 @@ describe('List', () => { }) it('should not render first empty state before the first dataset page resolves', async () => { - const { useDatasetList } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetList).mockReturnValue({ + mockDatasetListQuery.mockReturnValue({ data: { pages: [] }, fetchNextPage: vi.fn(), hasNextPage: false, isFetching: false, isFetchingNextPage: false, - } as unknown as ReturnType) + }) render() @@ -358,17 +360,13 @@ describe('List', () => { }) it('should keep the regular list for empty filtered results', async () => { - const { useDatasetList } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetList).mockImplementation( - (params) => - ({ - data: { pages: [{ data: [], total: params.include_all ? 0 : 1 }] }, - fetchNextPage: vi.fn(), - hasNextPage: false, - isFetching: false, - isFetchingNextPage: false, - }) as unknown as ReturnType, - ) + mockDatasetListQuery.mockImplementation((params) => ({ + data: { pages: [{ data: [], total: params.include_all ? 0 : 1 }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + })) render() @@ -381,7 +379,6 @@ describe('List', () => { it('announces empty search results only after the current filters finish loading', async () => { const user = userEvent.setup() - const { useDatasetList } = await import('@/service/knowledge/use-dataset') const result = { data: { pages: [{ data: [], total: 1 }] }, fetchNextPage: vi.fn(), @@ -391,9 +388,7 @@ describe('List', () => { isPlaceholderData: false, isError: false, } - vi.mocked(useDatasetList).mockImplementation( - () => result as unknown as ReturnType, - ) + mockDatasetListQuery.mockImplementation(() => result) const { rerender } = render() const status = screen.getByRole('status') const search = screen.getByRole('searchbox') @@ -402,7 +397,7 @@ describe('List', () => { await user.type(search, 'missing') expect(status).toBeEmptyDOMElement() await waitFor(() => - expect(useDatasetList).toHaveBeenLastCalledWith( + expect(mockDatasetListQuery).toHaveBeenLastCalledWith( expect.objectContaining({ keyword: 'missing' }), ), ) diff --git a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx index 83d1e32ce43331..1ac43e4522097e 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx @@ -243,14 +243,14 @@ describe('DatasetCard Integration', () => { it('should apply opacity-30 when embedding_available is false', () => { const dataset = createMockDataset({ embedding_available: false }) render() - const descDiv = screen.getByTitle(dataset.description) + const descDiv = screen.getByTitle(dataset.description ?? '') expect(descDiv).toHaveClass('opacity-30') }) it('should not apply opacity-30 when embedding_available is true', () => { const dataset = createMockDataset({ embedding_available: true }) render() - const descDiv = screen.getByTitle(dataset.description) + const descDiv = screen.getByTitle(dataset.description ?? '') expect(descDiv).not.toHaveClass('opacity-30') }) }) @@ -388,9 +388,7 @@ describe('DatasetCard Component', () => { const dataset = createMockDataset({ name: 'Preview Only Dataset', permission_keys: [DatasetACLPermission.Preview], - tags: [ - { id: 'tag-preview', name: 'Readonly Tag', type: 'knowledge' as const, binding_count: '' }, - ], + tags: [{ id: 'tag-preview', name: 'Readonly Tag', type: 'knowledge' as const }], }) render() diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-header.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-header.spec.tsx index 4cc4714082dcf0..1636b64759407a 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-header.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-header.spec.tsx @@ -2,6 +2,7 @@ import type { DataSet } from '@/models/datasets' import { render, screen } from '@testing-library/react' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD } from '@/types/app' import DatasetCardHeader from '../dataset-card-header' @@ -23,10 +24,10 @@ vi.mock('react-i18next', async () => { }) const createDataset = (overrides: Partial = {}): DataSet => ({ + ...createDatasetFixture(), id: 'dataset-1', name: 'Test Dataset', description: 'Test description', - indexing_status: 'completed', provider: 'vendor', permission: DatasetPermission.allTeamMembers, data_source_type: DataSourceType.FILE, @@ -34,7 +35,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ embedding_available: true, app_count: 5, document_count: 10, - total_document_count: 10, word_count: 1000, updated_at: 1609545600, updated_by: 'user-1', @@ -57,9 +57,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic, } as DataSet['retrieval_model_dict'], - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - } as DataSet['retrieval_model'], external_knowledge_info: { external_knowledge_id: '', external_knowledge_api_id: '', diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx index ae783f03a78242..cb8eac0ed1ff54 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vite-plus/test' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { createDatasetFixture } from '@/test/fixtures/dataset' import DatasetCardModals from '../dataset-card-modals' // Mock RenameDatasetModal since it's from a different feature folder @@ -26,10 +27,10 @@ vi.mock('../../../../rename-modal', () => ({ describe('DatasetCardModals', () => { const mockDataset: DataSet = { + ...createDatasetFixture(), id: 'dataset-1', name: 'Test Dataset', description: 'Test description', - indexing_status: 'completed', provider: 'vendor', permission: DatasetPermission.allTeamMembers, data_source_type: DataSourceType.FILE, @@ -37,7 +38,6 @@ describe('DatasetCardModals', () => { embedding_available: true, app_count: 5, document_count: 10, - total_document_count: 10, word_count: 1000, updated_at: 1609545600, updated_by: 'user-1', @@ -57,7 +57,6 @@ describe('DatasetCardModals', () => { icon_url: '', }, retrieval_model_dict: {} as DataSet['retrieval_model_dict'], - retrieval_model: {} as DataSet['retrieval_model'], external_knowledge_info: { external_knowledge_id: '', external_knowledge_api_id: '', diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-header.tsx b/web/app/components/datasets/list/dataset-card/components/dataset-card-header.tsx index 1ac1a0fad38cda..1c1230c75a4934 100644 --- a/web/app/components/datasets/list/dataset-card/components/dataset-card-header.tsx +++ b/web/app/components/datasets/list/dataset-card/components/dataset-card-header.tsx @@ -4,6 +4,7 @@ import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' +import { toChunkingMode } from '@/app/components/datasets/dataset-fields' import { useKnowledge } from '@/hooks/use-knowledge' import { DOC_FORM_ICON_CLASS_WITH_BG, DOC_FORM_TEXT } from '@/models/datasets' @@ -41,12 +42,11 @@ const DocModeInfo = ({ dataset, isExternalProvider, isShowDocModeInfo }: DocMode const indexingText = dataset.indexing_technique ? formatIndexingTechniqueAndMethod( - dataset.indexing_technique as 'economy' | 'high_quality', - dataset.retrieval_model_dict?.search_method as Parameters< - typeof formatIndexingTechniqueAndMethod - >[1], + dataset.indexing_technique, + dataset.retrieval_model_dict.search_method, ) : '' + const chunkingMode = toChunkingMode(dataset.doc_form) return (
@@ -55,12 +55,12 @@ const DocModeInfo = ({ dataset, isExternalProvider, isShowDocModeInfo }: DocMode {t(($) => $['cornerLabel.pipeline'], { ns: 'dataset' })} )} - {isShowDocModeInfo && !!dataset.doc_form && ( + {isShowDocModeInfo && !!chunkingMode && ( $[`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`], { ns: 'dataset' })} + title={t(($) => $[`chunkingMode.${DOC_FORM_TEXT[chunkingMode]}`], { ns: 'dataset' })} > - {t(($) => $[`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`], { ns: 'dataset' })} + {t(($) => $[`chunkingMode.${DOC_FORM_TEXT[chunkingMode]}`], { ns: 'dataset' })} )} {isShowDocModeInfo && dataset.indexing_technique && indexingText && ( @@ -93,9 +93,8 @@ const DatasetCardHeader = ({ dataset, nameId }: DatasetCardHeaderProps) => { (dataset.runtime_mode !== 'rag_pipeline' || dataset.is_published), ) - const chunkingModeIcon = dataset.doc_form - ? DOC_FORM_ICON_CLASS_WITH_BG[dataset.doc_form] - : React.Fragment + const chunkingMode = toChunkingMode(dataset.doc_form) + const chunkingModeIcon = chunkingMode ? DOC_FORM_ICON_CLASS_WITH_BG[chunkingMode] : React.Fragment const iconClassName = isExternalProvider ? DOC_FORM_ICON_CLASS_WITH_BG.external : chunkingModeIcon const iconInfo = useMemo( diff --git a/web/app/components/datasets/list/dataset-card/components/description.tsx b/web/app/components/datasets/list/dataset-card/components/description.tsx index c278c346429822..3980a305f96bdd 100644 --- a/web/app/components/datasets/list/dataset-card/components/description.tsx +++ b/web/app/components/datasets/list/dataset-card/components/description.tsx @@ -12,7 +12,7 @@ const Description = ({ dataset }: DescriptionProps) => ( 'line-clamp-2 h-10 px-4 py-1 system-xs-regular text-text-tertiary', !dataset.embedding_available && 'opacity-30', )} - title={dataset.description} + title={dataset.description ?? undefined} > {dataset.description}
diff --git a/web/app/components/datasets/list/dataset-card/index.tsx b/web/app/components/datasets/list/dataset-card/index.tsx index 4cb8787499ba04..778d59eb2d9e88 100644 --- a/web/app/components/datasets/list/dataset-card/index.tsx +++ b/web/app/components/datasets/list/dataset-card/index.tsx @@ -132,7 +132,7 @@ const DatasetCard = ({ )} > + type Props = Readonly<{ - datasetList: ReturnType['data'] | null - fetchNextPage: ReturnType['fetchNextPage'] - hasNextPage: ReturnType['hasNextPage'] - isFetching: ReturnType['isFetching'] - isFetchingNextPage: ReturnType['isFetchingNextPage'] - isLoading: ReturnType['isLoading'] - isPlaceholderData: ReturnType['isPlaceholderData'] + datasetList: DatasetListQuery['data'] | null + fetchNextPage: DatasetListQuery['fetchNextPage'] + hasNextPage: DatasetListQuery['hasNextPage'] + isFetching: DatasetListQuery['isFetching'] + isFetchingNextPage: DatasetListQuery['isFetchingNextPage'] + isLoading: DatasetListQuery['isLoading'] + isPlaceholderData: DatasetListQuery['isPlaceholderData'] emptyElement?: ReactNode onOpenTagManagement?: () => void stepByStepTourActionMenuHighlightPart?: string @@ -39,7 +43,10 @@ const Datasets = ({ stepByStepTourCardTarget, }: Props) => { const { t } = useTranslation(['common']) - const invalidDatasetList = useInvalidDatasetList() + const queryClient = useQueryClient() + const invalidDatasetList = useCallback(() => { + queryClient.invalidateQueries({ queryKey: consoleQuery.datasets.get.key() }) + }, [queryClient]) const anchorRef = useRef(null) const observerRef = useRef(null) const pages = datasetList?.pages ?? [] diff --git a/web/app/components/datasets/list/index.tsx b/web/app/components/datasets/list/index.tsx index 2ed69a90d75dc1..e8e33598e49652 100644 --- a/web/app/components/datasets/list/index.tsx +++ b/web/app/components/datasets/list/index.tsx @@ -1,10 +1,11 @@ 'use client' +import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' // Libraries import { useBoolean } from 'ahooks' import { useDebouncedValue } from 'foxact/use-debounced-value' import { useAtomValue, useSetAtom } from 'jotai' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { activeStepByStepTourGuideGroupAtom, @@ -21,11 +22,7 @@ import { isCurrentWorkspaceOwnerAtom } from '@/context/workspace-state' import { TagManagementModal } from '@/features/tag-management/components/tag-management-modal' import useDocumentTitle from '@/hooks/use-document-title' import { useRouter } from '@/next/navigation' -import { - useDatasetApiBaseUrl, - useDatasetList, - useInvalidDatasetList, -} from '@/service/knowledge/use-dataset' +import { consoleQuery } from '@/service/console' import { hasPermission } from '@/utils/permission' // Components import FilterEmptyState from '../../base/filter-empty-state' @@ -41,7 +38,10 @@ function List() { const [showTagManagementModal, setShowTagManagementModal] = useState(false) const [showExternalApiPanel, setShowExternalApiPanel] = useState(false) const [includeAll, { toggle: toggleIncludeAll }] = useBoolean(false) - const invalidDatasetList = useInvalidDatasetList() + const queryClient = useQueryClient() + const invalidDatasetList = useCallback(() => { + queryClient.invalidateQueries({ queryKey: consoleQuery.datasets.get.key() }) + }, [queryClient]) useDocumentTitle(t(($) => $.knowledge, { ns: 'dataset' })) const [keywords, setKeywords] = useState('') @@ -57,14 +57,23 @@ function List() { workspacePermissionKeys, 'dataset.external.connect', ) - const { data: apiBaseInfo } = useDatasetApiBaseUrl() - const datasetListQuery = useDatasetList({ - initialPage: 1, - tag_ids: tagIDs, - limit: 30, - include_all: includeAll, - keyword: searchKeywords, - }) + const { data: apiBaseInfo } = useQuery(consoleQuery.datasets.apiBaseInfo.get.queryOptions()) + const datasetListQuery = useInfiniteQuery( + consoleQuery.datasets.get.infiniteOptions({ + input: (page: number) => ({ + query: { + tag_ids: tagIDs, + limit: 30, + include_all: includeAll, + keyword: searchKeywords, + page, + }, + }), + getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), + initialPageParam: 1, + placeholderData: keepPreviousData, + }), + ) const pages = datasetListQuery.data?.pages ?? [] const hasResolvedFirstPage = pages.length > 0 const hasAnyDataset = (pages[0]?.total ?? 0) > 0 diff --git a/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx b/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx index aa9f211e521949..a2a0c3fe1c0343 100644 --- a/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { mockEmojiData, renderWithEmoji as render } from '@/test/emoji-picker' +import { createDatasetFixture } from '@/test/fixtures/dataset' import RenameDatasetModal from '../index' const { mockToast } = vi.hoisted(() => { @@ -33,10 +34,10 @@ vi.mock('@/service/datasets', () => ({ describe('RenameDatasetModal', () => { // Create a base dataset with emoji icon const createMockDataset = (overrides: Partial = {}): DataSet => ({ + ...createDatasetFixture(), id: 'dataset-1', name: 'Test Dataset', description: 'Test description', - indexing_status: 'completed', icon_info: { icon: '📊', icon_type: 'emoji', @@ -52,14 +53,12 @@ describe('RenameDatasetModal', () => { app_count: 0, doc_form: ChunkingMode.text, document_count: 5, - total_document_count: 5, word_count: 1000, provider: 'openai', embedding_model: 'text-embedding-ada-002', embedding_model_provider: 'openai', embedding_available: true, retrieval_model_dict: {} as DataSet['retrieval_model_dict'], - retrieval_model: {} as DataSet['retrieval_model'], tags: [], external_knowledge_info: { external_knowledge_id: '', diff --git a/web/app/components/datasets/rename-modal/index.tsx b/web/app/components/datasets/rename-modal/index.tsx index f507867b376773..14784af506d520 100644 --- a/web/app/components/datasets/rename-modal/index.tsx +++ b/web/app/components/datasets/rename-modal/index.tsx @@ -29,9 +29,9 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset const { t } = useTranslation(['common', 'datasetSettings']) const [loading, setLoading] = useState(false) const [name, setName] = useState(dataset.name) - const [description, setDescription] = useState(dataset.description) - const externalKnowledgeId = dataset.external_knowledge_info.external_knowledge_id - const externalKnowledgeApiId = dataset.external_knowledge_info.external_knowledge_api_id + const [description, setDescription] = useState(dataset.description ?? '') + const externalKnowledgeId = dataset.external_knowledge_info?.external_knowledge_id + const externalKnowledgeApiId = dataset.external_knowledge_info?.external_knowledge_api_id const [appIcon, setAppIcon] = useState( dataset.icon_info?.icon_type === 'image' ? { diff --git a/web/app/components/datasets/settings/form/__tests__/index.spec.tsx b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx index a000740938e33b..2d7e71b0d05052 100644 --- a/web/app/components/datasets/settings/form/__tests__/index.spec.tsx +++ b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx @@ -8,6 +8,7 @@ import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datase import { expectLoadingButton } from '@/test/button' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { render as renderWithConsoleState } from '@/test/console/render' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD } from '@/types/app' import { DatasetACLPermission } from '@/utils/permission' import { IndexingType } from '../../../create/step-two' @@ -19,7 +20,6 @@ const { mockToastError } = vi.hoisted(() => ({ // Mock contexts const mockMutateDatasets = vi.fn() -const mockInvalidDatasetList = vi.fn() const mockUserProfile = { id: 'user-1', @@ -55,6 +55,7 @@ function render(ui: ReactElement) { } const createMockDataset = (overrides: Partial = {}): DataSet => ({ + ...createDatasetFixture(), id: 'dataset-1', name: 'Test Dataset', description: 'Test description', @@ -66,7 +67,6 @@ const createMockDataset = (overrides: Partial = {}): DataSet => ({ icon_url: '', }, indexing_technique: IndexingType.QUALIFIED, - indexing_status: 'completed', data_source_type: DataSourceType.FILE, doc_form: ChunkingMode.text, embedding_model: 'text-embedding-ada-002', @@ -74,7 +74,6 @@ const createMockDataset = (overrides: Partial = {}): DataSet => ({ embedding_available: true, app_count: 0, document_count: 5, - total_document_count: 5, word_count: 1000, provider: 'vendor', tags: [], @@ -101,17 +100,6 @@ const createMockDataset = (overrides: Partial = {}): DataSet => ({ score_threshold_enabled: false, score_threshold: 0.5, } as RetrievalConfig, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, - } as RetrievalConfig, built_in_field_enabled: false, keyword_number: 10, created_by: 'user-1', @@ -143,10 +131,6 @@ vi.mock('@/service/datasets', () => ({ updateDatasetSetting: vi.fn().mockResolvedValue({}), })) -vi.mock('@/service/knowledge/use-dataset', () => ({ - useInvalidDatasetList: () => mockInvalidDatasetList, -})) - vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useCurrentProviderAndModel: () => ({ currentProvider: undefined, currentModel: undefined }), useDefaultModel: () => ({ data: undefined, mutate: vi.fn(), isLoading: false }), diff --git a/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx b/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx index d8be7403ba6b9b..95f58d64577f06 100644 --- a/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx +++ b/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx @@ -8,6 +8,7 @@ import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datase import { createConsoleQueryWrapper } from '@/test/console/query-data' import { render as renderWithConsoleState } from '@/test/console/render' import { mockEmojiData } from '@/test/emoji-picker' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD } from '@/types/app' import { IndexingType } from '../../../../create/step-two' import BasicInfoSection from '../basic-info-section' @@ -61,6 +62,7 @@ vi.mock('@/app/components/base/image-uploader/hooks', () => ({ describe('BasicInfoSection', () => { const mockDataset: DataSet = { + ...createDatasetFixture(), id: 'dataset-1', name: 'Test Dataset', description: 'Test description', @@ -72,7 +74,6 @@ describe('BasicInfoSection', () => { icon_url: '', }, indexing_technique: IndexingType.QUALIFIED, - indexing_status: 'completed', data_source_type: DataSourceType.FILE, doc_form: ChunkingMode.text, embedding_model: 'text-embedding-ada-002', @@ -80,7 +81,6 @@ describe('BasicInfoSection', () => { embedding_available: true, app_count: 0, document_count: 5, - total_document_count: 5, word_count: 1000, provider: 'vendor', tags: [], @@ -107,17 +107,6 @@ describe('BasicInfoSection', () => { score_threshold_enabled: false, score_threshold: 0.5, } as RetrievalConfig, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, - } as RetrievalConfig, built_in_field_enabled: false, keyword_number: 10, created_by: 'user-1', diff --git a/web/app/components/datasets/settings/form/components/__tests__/external-knowledge-section.spec.tsx b/web/app/components/datasets/settings/form/components/__tests__/external-knowledge-section.spec.tsx index 176829a9d3b544..6084cddaa0c563 100644 --- a/web/app/components/datasets/settings/form/components/__tests__/external-knowledge-section.spec.tsx +++ b/web/app/components/datasets/settings/form/components/__tests__/external-knowledge-section.spec.tsx @@ -2,6 +2,7 @@ import type { DataSet } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' import { render, screen } from '@testing-library/react' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD } from '@/types/app' import { IndexingType } from '../../../../create/step-two' import ExternalKnowledgeSection from '../external-knowledge-section' @@ -20,6 +21,7 @@ describe('ExternalKnowledgeSection', () => { } const mockDataset: DataSet = { + ...createDatasetFixture(), id: 'dataset-1', name: 'External Dataset', description: 'External dataset description', @@ -31,7 +33,6 @@ describe('ExternalKnowledgeSection', () => { icon_url: '', }, indexing_technique: IndexingType.QUALIFIED, - indexing_status: 'completed', data_source_type: DataSourceType.FILE, doc_form: ChunkingMode.text, embedding_model: 'text-embedding-ada-002', @@ -39,7 +40,6 @@ describe('ExternalKnowledgeSection', () => { embedding_available: true, app_count: 0, document_count: 5, - total_document_count: 5, word_count: 1000, provider: 'external', tags: [], @@ -56,7 +56,6 @@ describe('ExternalKnowledgeSection', () => { score_threshold_enabled: true, }, retrieval_model_dict: mockRetrievalConfig, - retrieval_model: mockRetrievalConfig, built_in_field_enabled: false, keyword_number: 10, created_by: 'user-1', diff --git a/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx b/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx index fa32f68d14ae0d..a9931f3f765164 100644 --- a/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx +++ b/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx @@ -11,6 +11,7 @@ import { } from '@/app/components/header/account-setting/model-provider-page/declarations' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD } from '@/types/app' import { IndexingType } from '../../../../create/step-two' import IndexingSection from '../indexing-section' @@ -163,6 +164,7 @@ describe('IndexingSection', () => { } const mockDataset: DataSet = { + ...createDatasetFixture(), id: 'dataset-1', name: 'Test Dataset', description: 'Test description', @@ -174,7 +176,6 @@ describe('IndexingSection', () => { icon_url: '', }, indexing_technique: IndexingType.QUALIFIED, - indexing_status: 'completed', data_source_type: DataSourceType.FILE, doc_form: ChunkingMode.text, embedding_model: 'text-embedding-ada-002', @@ -182,7 +183,6 @@ describe('IndexingSection', () => { embedding_available: true, app_count: 0, document_count: 5, - total_document_count: 5, word_count: 1000, provider: 'vendor', tags: [], @@ -199,7 +199,6 @@ describe('IndexingSection', () => { score_threshold_enabled: true, }, retrieval_model_dict: mockRetrievalConfig, - retrieval_model: mockRetrievalConfig, built_in_field_enabled: false, keyword_number: 10, created_by: 'user-1', diff --git a/web/app/components/datasets/settings/form/components/__tests__/reflow.browser.spec.tsx b/web/app/components/datasets/settings/form/components/__tests__/reflow.browser.spec.tsx index c140beafaa74c5..21dedbf42ee636 100644 --- a/web/app/components/datasets/settings/form/components/__tests__/reflow.browser.spec.tsx +++ b/web/app/components/datasets/settings/form/components/__tests__/reflow.browser.spec.tsx @@ -6,6 +6,7 @@ import { page } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' import datasetSettings from '@/i18n/locales/en-US/dataset-settings.json' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD } from '@/types/app' import { IndexingType } from '../../../../create/step-two' import ExternalKnowledgeSection from '../external-knowledge-section' @@ -55,6 +56,7 @@ const mockRetrievalConfig: RetrievalConfig = { } const mockDataset: DataSet = { + ...createDatasetFixture(), id: 'dataset-1', name: 'Test Dataset', description: 'Test description', @@ -66,7 +68,6 @@ const mockDataset: DataSet = { icon_url: '', }, indexing_technique: IndexingType.ECONOMICAL, - indexing_status: 'completed', data_source_type: DataSourceType.FILE, doc_form: ChunkingMode.text, embedding_model: 'text-embedding-ada-002', @@ -74,7 +75,6 @@ const mockDataset: DataSet = { embedding_available: true, app_count: 0, document_count: 5, - total_document_count: 5, word_count: 1000, provider: 'vendor', tags: [], @@ -91,7 +91,6 @@ const mockDataset: DataSet = { score_threshold_enabled: true, }, retrieval_model_dict: mockRetrievalConfig, - retrieval_model: mockRetrievalConfig, built_in_field_enabled: false, keyword_number: 10, created_by: 'user-1', diff --git a/web/app/components/datasets/settings/form/components/external-knowledge-section.tsx b/web/app/components/datasets/settings/form/components/external-knowledge-section.tsx index f69a1ad8408e49..27a7ae67a4d544 100644 --- a/web/app/components/datasets/settings/form/components/external-knowledge-section.tsx +++ b/web/app/components/datasets/settings/form/components/external-knowledge-section.tsx @@ -67,11 +67,11 @@ const ExternalKnowledgeSection = ({ className="i-custom-vender-solid-development-api-connection-mod size-4 shrink-0 text-text-secondary" />
- {currentDataset.external_knowledge_info.external_knowledge_api_name} + {currentDataset.external_knowledge_info?.external_knowledge_api_name}
·
- {currentDataset.external_knowledge_info.external_knowledge_api_endpoint} + {currentDataset.external_knowledge_info?.external_knowledge_api_endpoint}
@@ -87,7 +87,7 @@ const ExternalKnowledgeSection = ({
- {currentDataset.external_knowledge_info.external_knowledge_id} + {currentDataset.external_knowledge_info?.external_knowledge_id}
diff --git a/web/app/components/datasets/settings/form/components/indexing-section.tsx b/web/app/components/datasets/settings/form/components/indexing-section.tsx index 3b864bebef344e..de7bcb3f17e5af 100644 --- a/web/app/components/datasets/settings/form/components/indexing-section.tsx +++ b/web/app/components/datasets/settings/form/components/indexing-section.tsx @@ -18,6 +18,7 @@ import { useDocLink } from '@/context/i18n' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { ChunkingMode } from '@/models/datasets' import { IndexingType } from '../../../create/step-two' +import { toChunkingMode } from '../../../dataset-fields' import ChunkStructure from '../../chunk-structure' import IndexMethod from '../../index-method' import SummaryIndexSetting from '../../summary-index-setting' @@ -71,6 +72,7 @@ const IndexingSection = ({ }) const isNonCloudEdition = deploymentEdition === 'COMMUNITY' || deploymentEdition === 'ENTERPRISE' const docLink = useDocLink() + const chunkingMode = toChunkingMode(currentDataset?.doc_form) const isShowIndexMethod = currentDataset && @@ -84,13 +86,14 @@ const IndexingSection = ({ const showSummaryIndexSetting = indexMethod === IndexingType.QUALIFIED && - embeddingChunkingModes.includes(currentDataset?.doc_form as ChunkingMode) && + !!chunkingMode && + embeddingChunkingModes.includes(chunkingMode) && isNonCloudEdition return ( <> {/* Chunk Structure */} - {!!currentDataset?.doc_form && ( + {!!chunkingMode && ( <>
@@ -111,7 +114,7 @@ const IndexingSection = ({
- +
@@ -134,7 +137,7 @@ const IndexingSection = ({ value={indexMethod!} disabled={!currentDataset?.embedding_available || readonly} onChange={setIndexMethod} - currentValue={currentDataset.indexing_technique} + currentValue={currentDataset.indexing_technique ?? undefined} keywordNumber={keywordNumber} onKeywordNumberChange={setKeywordNumber} /> diff --git a/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts b/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts index 52a469e779c87e..e3a34ddd8a92c9 100644 --- a/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts +++ b/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts @@ -2,6 +2,7 @@ import type { GetWorkspacesCurrentModelsModelTypesByModelTypeData } from '@dify/ import type { OperationKey } from '@orpc/tanstack-query' import type { DataSet } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' +import { QueryClient } from '@tanstack/react-query' import { act, waitFor } from '@testing-library/react' import { ChunkingMode, @@ -10,13 +11,18 @@ import { RerankingModeEnum, WeightedScoreEnum, } from '@/models/datasets' +import { consoleQuery } from '@/service/console' import { createAccountProfileQueryWrapper } from '@/test/console/account-profile' import { renderHook as renderHookWithConsoleState } from '@/test/console/render' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD } from '@/types/app' import { DatasetACLPermission } from '@/utils/permission' import { IndexingType } from '../../../../create/step-two' import { useFormState } from '../use-form-state' +const invalidateQueries = vi.spyOn(QueryClient.prototype, 'invalidateQueries') +const datasetListKey = consoleQuery.datasets.get.key() + const { mockToastSuccess, mockToastError } = vi.hoisted(() => ({ mockToastSuccess: vi.fn(), mockToastError: vi.fn(), @@ -24,7 +30,6 @@ const { mockToastSuccess, mockToastError } = vi.hoisted(() => ({ // Mock contexts const mockMutateDatasets = vi.fn() -const mockInvalidDatasetList = vi.fn() const renderHook = (callback: () => ReturnType) => renderHookWithConsoleState(callback, { @@ -57,6 +62,7 @@ vi.mock('@/features/system-features/state', async () => { }) const createDefaultMockDataset = (): DataSet => ({ + ...createDatasetFixture(), id: 'dataset-1', name: 'Test Dataset', description: 'Test description', @@ -68,7 +74,6 @@ const createDefaultMockDataset = (): DataSet => ({ icon_url: '', }, indexing_technique: IndexingType.QUALIFIED, - indexing_status: 'completed', data_source_type: DataSourceType.FILE, doc_form: ChunkingMode.text, embedding_model: 'text-embedding-ada-002', @@ -76,7 +81,6 @@ const createDefaultMockDataset = (): DataSet => ({ embedding_available: true, app_count: 0, document_count: 5, - total_document_count: 5, word_count: 1000, provider: 'vendor', tags: [], @@ -103,17 +107,6 @@ const createDefaultMockDataset = (): DataSet => ({ score_threshold_enabled: false, score_threshold: 0.5, } as RetrievalConfig, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, - } as RetrievalConfig, built_in_field_enabled: false, keyword_number: 10, created_by: 'user-1', @@ -144,10 +137,6 @@ vi.mock('@/service/datasets', () => ({ updateDatasetSetting: vi.fn().mockResolvedValue({}), })) -vi.mock('@/service/knowledge/use-dataset', () => ({ - useInvalidDatasetList: () => mockInvalidDatasetList, -})) - vi.mock('@/app/components/datasets/common/check-rerank-model', async (importOriginal) => ({ ...(await importOriginal()), isReRankModelSelected: () => true, @@ -168,7 +157,7 @@ describe('useFormState', () => { describe('Initial State', () => { it('should initialize with dataset values', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) expect(result.current.name).toBe('Test Dataset') expect(result.current.description).toBe('Test description') @@ -178,7 +167,7 @@ describe('useFormState', () => { }) it('should initialize icon info from dataset', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) expect(result.current.iconInfo).toEqual({ icon_type: 'emoji', @@ -189,7 +178,7 @@ describe('useFormState', () => { }) it('should initialize external retrieval settings', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) expect(result.current.topK).toBe(3) expect(result.current.scoreThreshold).toBe(0.7) @@ -197,14 +186,14 @@ describe('useFormState', () => { }) it('should derive member list from API data', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) expect(result.current.memberList).toHaveLength(2) expect(result.current.memberList[0]!.name).toBe('User 1') }) it('should return currentDataset from context', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) expect(result.current.currentDataset).toBeDefined() expect(result.current.currentDataset?.id).toBe('dataset-1') @@ -215,7 +204,7 @@ describe('useFormState', () => { ...createDefaultMockDataset(), permission_keys: [DatasetACLPermission.Readonly], } - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) expect(result.current.canEditSettings).toBe(false) expect('isCurrentWorkspaceDatasetOperator' in result.current).toBe(false) @@ -224,7 +213,7 @@ describe('useFormState', () => { describe('State Setters', () => { it('should update name when setName is called', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.setName('New Name') @@ -234,7 +223,7 @@ describe('useFormState', () => { }) it('should update description when setDescription is called', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.setDescription('New Description') @@ -244,7 +233,7 @@ describe('useFormState', () => { }) it('should update permission when setPermission is called', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.setPermission(DatasetPermission.allTeamMembers) @@ -254,7 +243,7 @@ describe('useFormState', () => { }) it('should update indexMethod when setIndexMethod is called', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.setIndexMethod(IndexingType.ECONOMICAL) @@ -264,7 +253,7 @@ describe('useFormState', () => { }) it('should update keywordNumber when setKeywordNumber is called', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.setKeywordNumber(20) @@ -274,7 +263,7 @@ describe('useFormState', () => { }) it('should update selectedMemberIDs when setSelectedMemberIDs is called', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.setSelectedMemberIDs(['user-1', 'user-2']) @@ -286,7 +275,7 @@ describe('useFormState', () => { describe('Icon Handlers', () => { it('should select emoji icon without owning picker close state', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.handleSelectAppIcon({ @@ -305,7 +294,7 @@ describe('useFormState', () => { }) it('should select image icon without owning picker close state', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.handleSelectAppIcon({ @@ -326,7 +315,7 @@ describe('useFormState', () => { describe('External Retrieval Settings Handler', () => { it('should update topK when provided', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.handleSettingsChange({ top_k: 5 }) @@ -336,7 +325,7 @@ describe('useFormState', () => { }) it('should update scoreThreshold when provided', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.handleSettingsChange({ score_threshold: 0.8 }) @@ -346,7 +335,7 @@ describe('useFormState', () => { }) it('should update scoreThresholdEnabled when provided', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.handleSettingsChange({ score_threshold_enabled: false }) @@ -356,7 +345,7 @@ describe('useFormState', () => { }) it('should update multiple settings at once', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.handleSettingsChange({ @@ -374,7 +363,7 @@ describe('useFormState', () => { describe('Summary Index Setting Handler', () => { it('should update summary index setting', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.handleSummaryIndexSettingChange({ @@ -388,7 +377,7 @@ describe('useFormState', () => { }) it('should merge with existing settings', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.handleSummaryIndexSettingChange({ @@ -414,7 +403,7 @@ describe('useFormState', () => { describe('handleSave', () => { it('should show error toast when name is empty', async () => { const { toast } = await import('@/app/notifications') - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.setName('') @@ -429,7 +418,7 @@ describe('useFormState', () => { it('should show error toast when name is whitespace only', async () => { const { toast } = await import('@/app/notifications') - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.setName(' ') @@ -444,7 +433,7 @@ describe('useFormState', () => { it('should call updateDatasetSetting with correct params', async () => { const { updateDatasetSetting } = await import('@/service/datasets') - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) await act(async () => { await result.current.handleSave() @@ -466,7 +455,7 @@ describe('useFormState', () => { ...createDefaultMockDataset(), permission_keys: [DatasetACLPermission.Readonly], } - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) await act(async () => { await result.current.handleSave() @@ -477,7 +466,7 @@ describe('useFormState', () => { it('should show success toast on successful save', async () => { const { toast } = await import('@/app/notifications') - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) await act(async () => { await result.current.handleSave() @@ -489,7 +478,7 @@ describe('useFormState', () => { }) it('should call mutateDatasets after successful save', async () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) await act(async () => { await result.current.handleSave() @@ -501,19 +490,19 @@ describe('useFormState', () => { }) it('should call invalidDatasetList after successful save', async () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) await act(async () => { await result.current.handleSave() }) await waitFor(() => { - expect(mockInvalidDatasetList).toHaveBeenCalled() + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: datasetListKey }) }) }) it('should set loading to true during save', async () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) expect(result.current.loading).toBe(false) @@ -533,7 +522,7 @@ describe('useFormState', () => { () => new Promise((resolve) => setTimeout(resolve, 100)), ) - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) // Start first save act(() => { @@ -554,7 +543,7 @@ describe('useFormState', () => { const { toast } = await import('@/app/notifications') vi.mocked(updateDatasetSetting).mockRejectedValueOnce(new Error('Network error')) - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) await act(async () => { await result.current.handleSave() @@ -565,7 +554,7 @@ describe('useFormState', () => { it('should include partial_member_list when permission is partialMembers', async () => { const { updateDatasetSetting } = await import('@/service/datasets') - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.setPermission(DatasetPermission.partialMembers) @@ -590,7 +579,7 @@ describe('useFormState', () => { describe('Embedding Model', () => { it('should initialize embedding model from dataset', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) expect(result.current.embeddingModel).toEqual({ provider: 'openai', @@ -599,7 +588,7 @@ describe('useFormState', () => { }) it('should update embedding model when setEmbeddingModel is called', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.setEmbeddingModel({ @@ -617,14 +606,14 @@ describe('useFormState', () => { describe('Retrieval Config', () => { it('should initialize retrieval config from dataset', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) expect(result.current.retrievalConfig).toBeDefined() expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.semantic) }) it('should update retrieval config when setRetrievalConfig is called', () => { - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) const newConfig: RetrievalConfig = { ...result.current.retrievalConfig, @@ -640,7 +629,7 @@ describe('useFormState', () => { it('should include weights in save request when weights are set', async () => { const { updateDatasetSetting } = await import('@/service/datasets') - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) // Set retrieval config with weights const configWithWeights: RetrievalConfig = { @@ -685,7 +674,7 @@ describe('useFormState', () => { it('should send the configured score threshold when score_threshold_enabled is true', async () => { const { updateDatasetSetting } = await import('@/service/datasets') - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.setRetrievalConfig({ @@ -728,7 +717,7 @@ describe('useFormState', () => { it('should persist reranking_enable=true when a rerank model is configured but the stale flag is false', async () => { const { updateDatasetSetting } = await import('@/service/datasets') - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) // Reproduces the reported bug: Hybrid Search renders no rerank switch, so the flag keeps // the stale `false` default even though a rerank model is selected. @@ -755,7 +744,7 @@ describe('useFormState', () => { it('should leave reranking_enable untouched when Hybrid Search uses weighted score', async () => { const { updateDatasetSetting } = await import('@/service/datasets') - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.setRetrievalConfig({ @@ -780,7 +769,7 @@ describe('useFormState', () => { it('should leave reranking_enable untouched for semantic search', async () => { const { updateDatasetSetting } = await import('@/service/datasets') - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) act(() => { result.current.setRetrievalConfig({ @@ -826,7 +815,7 @@ describe('useFormState', () => { it('should include external knowledge info in save request for external provider', async () => { const { updateDatasetSetting } = await import('@/service/datasets') - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) await act(async () => { await result.current.handleSave() @@ -848,7 +837,7 @@ describe('useFormState', () => { it('should use correct external retrieval settings', async () => { const { updateDatasetSetting } = await import('@/service/datasets') - const { result } = renderHook(() => useFormState()) + const { result } = renderHook(() => useFormState(mockDataset)) // Update external retrieval settings act(() => { diff --git a/web/app/components/datasets/settings/form/hooks/use-form-state.ts b/web/app/components/datasets/settings/form/hooks/use-form-state.ts index 1ddf7d217d78c2..b0c65b101ebd26 100644 --- a/web/app/components/datasets/settings/form/hooks/use-form-state.ts +++ b/web/app/components/datasets/settings/form/hooks/use-form-state.ts @@ -2,9 +2,13 @@ import type { IconPickerValue } from '@/app/components/base/icon-picker' import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Member } from '@/models/common' -import type { IconInfo, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets' +import type { + DataSet, + IconInfo, + SummaryIndexSetting as SummaryIndexSettingType, +} from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' -import { useQuery, useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -20,8 +24,8 @@ import { userProfileQueryOptions } from '@/features/account-profile/client' import { DatasetPermission } from '@/models/datasets' import { consoleQuery } from '@/service/console' import { updateDatasetSetting } from '@/service/datasets' -import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' import { getDatasetACLCapabilities } from '@/utils/permission' +import { toIconInfo, toRetrievalConfig, toSummaryIndexSetting } from '../../../dataset-fields' import { checkShowMultiModalTip } from '../../utils' const DEFAULT_APP_ICON: IconInfo = { @@ -31,9 +35,8 @@ const DEFAULT_APP_ICON: IconInfo = { icon_url: '', } -export const useFormState = () => { +export const useFormState = (currentDataset: DataSet) => { const { t } = useTranslation(['appDebug', 'common', 'datasetSettings']) - const currentDataset = useDatasetDetailContextWithSelector((state) => state.dataset) const mutateDatasets = useDatasetDetailContextWithSelector((state) => state.mutateDatasetRes) const { data: currentUserId } = useSuspenseQuery({ ...userProfileQueryOptions(), @@ -62,33 +65,37 @@ export const useFormState = () => { const [description, setDescription] = useState(currentDataset?.description ?? '') // Icon state - const [iconInfo, setIconInfo] = useState(currentDataset?.icon_info || DEFAULT_APP_ICON) + const [iconInfo, setIconInfo] = useState( + toIconInfo(currentDataset?.icon_info) ?? DEFAULT_APP_ICON, + ) // Permission state - const [permission, setPermission] = useState(currentDataset?.permission) + const [permission, setPermission] = useState( + currentDataset.permission, + ) const [selectedMemberIDs, setSelectedMemberIDs] = useState( currentDataset?.partial_member_list || [], ) // External retrieval state - const [topK, setTopK] = useState(currentDataset?.external_retrieval_model.top_k ?? 2) + const [topK, setTopK] = useState(currentDataset?.external_retrieval_model?.top_k ?? 2) const [scoreThreshold, setScoreThreshold] = useState( - currentDataset?.external_retrieval_model.score_threshold ?? 0.5, + currentDataset?.external_retrieval_model?.score_threshold ?? 0.5, ) const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState( - currentDataset?.external_retrieval_model.score_threshold_enabled ?? false, + currentDataset?.external_retrieval_model?.score_threshold_enabled ?? false, ) // Indexing and retrieval state - const [indexMethod, setIndexMethod] = useState(currentDataset?.indexing_technique) + const [indexMethod, setIndexMethod] = useState(currentDataset?.indexing_technique ?? undefined) const [keywordNumber, setKeywordNumber] = useState(currentDataset?.keyword_number ?? 10) - const [retrievalConfig, setRetrievalConfig] = useState( - currentDataset?.retrieval_model_dict as RetrievalConfig, + const [retrievalConfig, setRetrievalConfig] = useState(() => + toRetrievalConfig(currentDataset.retrieval_model_dict), ) const [embeddingModel, setEmbeddingModel] = useState( currentDataset?.embedding_model ? { - provider: currentDataset.embedding_model_provider, + provider: currentDataset.embedding_model_provider ?? '', model: currentDataset.embedding_model, } : { @@ -98,8 +105,12 @@ export const useFormState = () => { ) // Summary index state - const [summaryIndexSetting, setSummaryIndexSetting] = useState( - currentDataset?.summary_index_setting, + const [summaryIndexSetting, setSummaryIndexSetting] = useState< + SummaryIndexSettingType | undefined + >( + () => + currentDataset.summary_index_setting && + toSummaryIndexSetting(currentDataset.summary_index_setting), ) // Model lists @@ -118,7 +129,10 @@ export const useFormState = () => { const { data: membersData } = useQuery( consoleQuery.workspaces.current.members.get.queryOptions({ input: {} }), ) - const invalidDatasetList = useInvalidDatasetList() + const queryClient = useQueryClient() + const invalidDatasetList = useCallback(() => { + queryClient.invalidateQueries({ queryKey: consoleQuery.datasets.get.key() }) + }, [queryClient]) // Derive member list from API data const memberList = useMemo(() => { @@ -197,10 +211,10 @@ export const useFormState = () => { summary_index_setting: summaryIndexSetting, } - if (currentDataset!.provider === 'external') { - body.external_knowledge_id = currentDataset!.external_knowledge_info.external_knowledge_id + if (currentDataset.provider === 'external') { + body.external_knowledge_id = currentDataset.external_knowledge_info?.external_knowledge_id body.external_knowledge_api_id = - currentDataset!.external_knowledge_info.external_knowledge_api_id + currentDataset.external_knowledge_info?.external_knowledge_api_id body.external_retrieval_model = { top_k: topK, score_threshold: scoreThreshold, @@ -217,7 +231,7 @@ export const useFormState = () => { }) } - await updateDatasetSetting({ datasetId: currentDataset!.id, body }) + await updateDatasetSetting({ datasetId: currentDataset.id, body }) toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) if (mutateDatasets) { diff --git a/web/app/components/datasets/settings/form/index.tsx b/web/app/components/datasets/settings/form/index.tsx index 543c529c0f5e36..5c1f6e2623fce8 100644 --- a/web/app/components/datasets/settings/form/index.tsx +++ b/web/app/components/datasets/settings/form/index.tsx @@ -1,17 +1,17 @@ 'use client' +import type { DataSet } from '@/models/datasets' import { Button } from '@langgenius/dify-ui/button' import { Separator } from '@langgenius/dify-ui/separator' import { useTranslation } from 'react-i18next' +import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import BasicInfoSection from './components/basic-info-section' import ExternalKnowledgeSection from './components/external-knowledge-section' import IndexingSection from './components/indexing-section' import { useFormState } from './hooks/use-form-state' -const Form = () => { +const DatasetSettingsForm = ({ currentDataset }: { currentDataset: DataSet }) => { const { t } = useTranslation(['datasetSettings']) const { - // Context values - currentDataset, canEditSettings, // Loading state @@ -60,7 +60,7 @@ const Form = () => { // Actions handleSave, - } = useFormState() + } = useFormState(currentDataset) const isExternalProvider = currentDataset?.provider === 'external' const readonly = !canEditSettings @@ -132,4 +132,10 @@ const Form = () => { ) } +const Form = () => { + const currentDataset = useDatasetDetailContextWithSelector((state) => state.dataset) + if (!currentDataset) return null + return +} + export default Form diff --git a/web/app/components/datasets/settings/index-method/index.tsx b/web/app/components/datasets/settings/index-method/index.tsx index 9ec90aea7780a7..b617495079b385 100644 --- a/web/app/components/datasets/settings/index-method/index.tsx +++ b/web/app/components/datasets/settings/index-method/index.tsx @@ -7,7 +7,8 @@ import OptionCard from '../option-card' import KeywordNumber from './keyword-number' type IndexMethodProps = { - value: IndexingType + // Unset until the knowledge base indexes its first document. + value?: IndexingType onChange: (id: IndexingType) => void disabled?: boolean currentValue?: IndexingType diff --git a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx index 8a5c201d563b07..84e50a959e8964 100644 --- a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx @@ -1,10 +1,14 @@ +import { QueryClient } from '@tanstack/react-query' import { fireEvent, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { consoleQuery } from '@/service/console' import { renderWithAccountProfile as render } from '@/test/console/account-profile' import Conversion from '../conversion' const mockConvert = vi.fn() -const mockInvalidDatasetDetail = vi.fn() +const datasetDetailKey = consoleQuery.datasets.byDatasetId.get.queryKey({ + input: { params: { dataset_id: 'ds-123' } }, +}) vi.mock('@/context/workspace-state', async () => { const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') return createWorkspaceStateModuleMock(() => ({ @@ -38,14 +42,6 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { }) }) -vi.mock('@/service/knowledge/use-dataset', () => ({ - datasetDetailQueryKeyPrefix: ['dataset-detail'], -})) - -vi.mock('@/service/use-base', () => ({ - useInvalid: () => mockInvalidDatasetDetail, -})) - const { mockToast } = vi.hoisted(() => { const mockToast = Object.assign(vi.fn(), { success: vi.fn(), @@ -78,6 +74,7 @@ vi.mock('@/context/permission-state', async () => { describe('Conversion', () => { beforeEach(() => { vi.clearAllMocks() + vi.spyOn(QueryClient.prototype, 'invalidateQueries') mockDatasetDetailState = { dataset: { permission_keys: ['dataset.acl.edit'], @@ -185,7 +182,9 @@ describe('Conversion', () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) expect(mockToast.success).toHaveBeenCalledWith('datasetPipeline.conversion.successMessage') - expect(mockInvalidDatasetDetail).toHaveBeenCalled() + expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ + queryKey: datasetDetailKey, + }) }) it('should handle failed conversion', async () => { diff --git a/web/app/components/rag-pipeline/components/conversion.tsx b/web/app/components/rag-pipeline/components/conversion.tsx index e898a742a35eba..51a6ec5fdfaeef 100644 --- a/web/app/components/rag-pipeline/components/conversion.tsx +++ b/web/app/components/rag-pipeline/components/conversion.tsx @@ -8,7 +8,7 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' -import { useMutation, useSuspenseQuery } from '@tanstack/react-query' +import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import * as React from 'react' import { useCallback, useState } from 'react' @@ -19,14 +19,12 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import { useParams } from '@/next/navigation' import { consoleQuery } from '@/service/console' -import { datasetDetailQueryKeyPrefix } from '@/service/knowledge/use-dataset' -import { useInvalid } from '@/service/use-base' import { getDatasetACLCapabilities } from '@/utils/permission' import PipelineScreenShot from './screenshot' const Conversion = () => { const { t } = useTranslation(['common', 'datasetPipeline']) - const { datasetId } = useParams() + const { datasetId } = useParams<{ datasetId: string }>() const dataset = useDatasetDetailContextWithSelector((state) => state.dataset) const { data: currentUserId } = useSuspenseQuery({ ...userProfileQueryOptions(), @@ -37,7 +35,14 @@ const Conversion = () => { const { mutateAsync: convert, isPending } = useMutation( consoleQuery.rag.pipelines.transform.datasets.byDatasetId.post.mutationOptions(), ) - const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, datasetId]) + const queryClient = useQueryClient() + const invalidDatasetDetail = React.useCallback(() => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.get.queryKey({ + input: { params: { dataset_id: datasetId } }, + }), + }) + }, [queryClient, datasetId]) const datasetACLCapabilities = React.useMemo( () => getDatasetACLCapabilities(dataset?.permission_keys, { @@ -52,7 +57,7 @@ const Conversion = () => { if (!canConvertDataset || !datasetId) return convert( - { params: { dataset_id: datasetId as string } }, + { params: { dataset_id: datasetId } }, { onSuccess: (res) => { if (res.status === 'success') { diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx index febf0fa30782c8..491564bd600bc8 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx @@ -5,11 +5,15 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { renderEditor as renderWithoutPricing } from '@/app/components/workflow/editor-resource/__tests__/render' +import { consoleQuery } from '@/service/console' import { seedAccountProfileQuery } from '@/test/console/account-profile' import { seedFeatures, seedSystemFeatures } from '@/test/console/query-data' import Publisher from '../index' import { Popup } from '../popup' +const invalidateQueries = vi.spyOn(QueryClient.prototype, 'invalidateQueries') +const datasetListKey = consoleQuery.datasets.get.key() + const onPricingUrlUpdate = vi.hoisted(() => vi.fn()) vi.mock('@/features/system-features/state', async () => { @@ -142,11 +146,6 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ const mockPublishWorkflow = vi.fn() const mockPublishAsCustomizedPipeline = vi.fn() -const mockInvalidDatasetList = vi.fn() - -vi.mock('@/service/knowledge/use-dataset', () => ({ - useInvalidDatasetList: () => mockInvalidDatasetList, -})) vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') @@ -624,7 +623,7 @@ describe('publisher', () => { await waitFor(() => { expect(mockMutateDatasetRes).toHaveBeenCalled() expectInvalidated('rag', 'pipelines', 'byPipelineId', 'workflows', 'publish', 'get') - expect(mockInvalidDatasetList).toHaveBeenCalled() + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: datasetListKey }) }) }) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx index fdb888f9185eb4..e7f29f4383a725 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx @@ -52,8 +52,6 @@ const mockHandleCheckBeforePublish = vi.fn().mockResolvedValue(true) const mockSetPublishedAt = vi.fn() const mockMutateDatasetRes = vi.fn() -const mockInvalidDatasetList = vi.fn() - let mockPublishedAt: string | undefined = '2024-01-01T00:00:00Z' let mockDraftUpdatedAt: string | undefined = '2024-06-01T00:00:00Z' let mockPipelineId: string | undefined = 'pipeline-123' @@ -156,10 +154,6 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ }), })) -vi.mock('@/service/knowledge/use-dataset', () => ({ - useInvalidDatasetList: () => mockInvalidDatasetList, -})) - vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx index fffce0655fe25b..c9ce1409aff8df 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx @@ -42,7 +42,6 @@ import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import Link from '@/next/link' import { useParams } from '@/next/navigation' import { consoleQuery } from '@/service/console' -import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' import { usePublishWorkflow } from '@/service/use-workflow' import { getDatasetACLCapabilities } from '@/utils/permission' import { RAG_PIPELINE_PUBLISH_HOTKEY } from '../hotkeys' @@ -108,7 +107,9 @@ export function Popup({ const hideConfirm = onHideConfirm ?? hideLocalConfirm const [publishing, { setFalse: hidePublishing, setTrue: showPublishing }] = useBoolean(false) const queryClient = useQueryClient() - const invalidDatasetList = useInvalidDatasetList() + const invalidDatasetList = useCallback(() => { + queryClient.invalidateQueries({ queryKey: consoleQuery.datasets.get.key() }) + }, [queryClient]) const handleHideConfirm = useCallback(() => { hideConfirm() onRequestClose?.() diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts index 7d8e724cc9b6f0..5f4dee12cc339f 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts @@ -64,7 +64,7 @@ describe('usePipelineInit', () => { dataset: { pipeline_id: 'test-pipeline-id', name: 'Test Knowledge', - icon_info: { icon: 'test-icon' }, + icon_info: { icon: 'test-icon', icon_type: 'emoji' }, }, } return selector(state) @@ -106,7 +106,12 @@ describe('usePipelineInit', () => { expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ knowledgeName: 'Test Knowledge', - knowledgeIcon: { icon: 'test-icon' }, + knowledgeIcon: { + icon: 'test-icon', + icon_type: 'emoji', + icon_background: undefined, + icon_url: undefined, + }, }) }) }) diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts index f19d928c4d5b8d..85e4af35062529 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts @@ -1,7 +1,8 @@ import type { FetchWorkflowDraftResponse } from '@/types/workflow' import { ORPCError } from '@orpc/client' import { useAtomValue } from 'jotai' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { toIconInfo } from '@/app/components/datasets/dataset-fields' import { knowledgePipelineIdentityAtom } from '@/app/components/workflow/editor-resource/state' import { useWorkflowStore } from '@/app/components/workflow/store' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' @@ -16,7 +17,8 @@ export const usePipelineInit = () => { const [isLoading, setIsLoading] = useState(true) const { pipelineId } = useAtomValue(knowledgePipelineIdentityAtom) const knowledgeName = useDatasetDetailContextWithSelector((s) => s.dataset)?.name - const knowledgeIcon = useDatasetDetailContextWithSelector((s) => s.dataset)?.icon_info + const datasetIcon = useDatasetDetailContextWithSelector((s) => s.dataset?.icon_info) + const knowledgeIcon = useMemo(() => toIconInfo(datasetIcon), [datasetIcon]) useEffect(() => { workflowStore.setState({ knowledgeName, knowledgeIcon }) diff --git a/web/app/components/workflow/datasets-detail-store/__tests__/store.spec.tsx b/web/app/components/workflow/datasets-detail-store/__tests__/store.spec.tsx index 0b8c1e90692c9d..970468c27f4065 100644 --- a/web/app/components/workflow/datasets-detail-store/__tests__/store.spec.tsx +++ b/web/app/components/workflow/datasets-detail-store/__tests__/store.spec.tsx @@ -1,16 +1,17 @@ import type { DataSet } from '@/models/datasets' import { renderHook } from '@testing-library/react' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { DatasetsDetailContext } from '../provider' import { createDatasetsDetailStore, useDatasetsDetailStore } from '../store' const createDataset = (id: string, name = `dataset-${id}`): DataSet => ({ + ...createDatasetFixture(), id, name, - indexing_status: 'completed', icon_info: { icon: 'book', - icon_type: 'emoji' as DataSet['icon_info']['icon_type'], + icon_type: 'emoji', }, description: `${name} description`, permission: DatasetPermission.onlyMe, @@ -22,14 +23,12 @@ const createDataset = (id: string, name = `dataset-${id}`): DataSet => ({ app_count: 0, doc_form: ChunkingMode.text, document_count: 0, - total_document_count: 0, word_count: 0, provider: 'provider', embedding_model: 'model', embedding_model_provider: 'provider', embedding_available: true, retrieval_model_dict: {} as DataSet['retrieval_model_dict'], - retrieval_model: {} as DataSet['retrieval_model'], tags: [], external_knowledge_info: { external_knowledge_id: '', diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx index d2a95ffd123537..17a3bf3d114de3 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx @@ -7,7 +7,7 @@ import Recipient from '../index' const mockUseTranslation = vi.hoisted(() => vi.fn()) const mockConsoleStateReader = vi.hoisted(() => vi.fn()) -const mockUseMembers = vi.hoisted(() => vi.fn()) +const mockMembersQuery = vi.hoisted(() => vi.fn()) const mockConsoleState = vi.hoisted(() => ({ userProfile: { email: 'owner@example.com' }, currentWorkspace: { name: "Dify's Lab" }, @@ -27,9 +27,12 @@ vi.mock('@/context/workspace-state', async () => { return createWorkspaceStateModuleMock(() => mockConsoleState) }) -vi.mock('@/service/use-common', () => ({ - useMembers: () => mockUseMembers(), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.members.get': () => mockMembersQuery(), + }) +}) vi.mock('../member-selector', () => ({ __esModule: true, @@ -78,7 +81,7 @@ describe('Recipient', () => { ), }) mockConsoleStateReader.mockReturnValue(mockConsoleState) - mockUseMembers.mockReturnValue({ + mockMembersQuery.mockReturnValue({ data: { accounts: [ { id: 'member-1', email: 'member-1@example.com', name: 'Member One' }, diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx index 20831bd761ff9d..dbe3df2f2660e0 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx @@ -11,6 +11,7 @@ import { } from '@/test/console/account-profile' import { QueryClientTestProvider } from '@/test/console/query-provider' import { render } from '@/test/console/render' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' import { DatasetACLPermission, getDatasetACLCapabilities } from '@/utils/permission' import { DatasetsDetailContext } from '../../../datasets-detail-store/provider' @@ -54,9 +55,9 @@ const mockHasEditPermissionForDataset = vi.fn( ) => true, ) const createDataset = (overrides: Partial = {}): DataSet => ({ + ...createDatasetFixture(), id: 'dataset-1', name: 'Dataset Name', - indexing_status: 'completed', icon_info: { icon: '📙', icon_background: '#FFF4ED', @@ -74,7 +75,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ app_count: 0, doc_form: ChunkingMode.text, document_count: 1, - total_document_count: 1, word_count: 1000, provider: 'internal', embedding_model: 'text-embedding-3', @@ -91,17 +91,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ score_threshold_enabled: false, score_threshold: 0, }, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 5, - score_threshold_enabled: false, - score_threshold: 0, - }, tags: [], external_knowledge_info: { external_knowledge_id: '', @@ -125,7 +114,6 @@ const createMetadata = (overrides: Partial = {}): MetadataInDoc = id: 'meta-1', name: 'topic', type: MetadataFilteringVariableType.string, - value: 'topic', ...overrides, }) diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/node.spec.tsx index 1d26fa990d5295..5bd9d4398a91bf 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/node.spec.tsx @@ -2,6 +2,7 @@ import type { KnowledgeRetrievalNodeType } from '../types' import type { DataSet } from '@/models/datasets' import { render, screen } from '@testing-library/react' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' import { DatasetsDetailContext } from '../../../datasets-detail-store/provider' import { createDatasetsDetailStore } from '../../../datasets-detail-store/store' @@ -10,9 +11,9 @@ import Node from '../node' import { MetadataFilteringModeEnum } from '../types' const createDataset = (overrides: Partial = {}): DataSet => ({ + ...createDatasetFixture(), id: 'dataset-1', name: 'Dataset Name', - indexing_status: 'completed', icon_info: { icon: '📙', icon_background: '#FFF4ED', @@ -29,7 +30,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ app_count: 0, doc_form: ChunkingMode.text, document_count: 1, - total_document_count: 1, word_count: 1000, provider: 'internal', embedding_model: 'text-embedding-3', @@ -46,17 +46,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ score_threshold_enabled: false, score_threshold: 0, }, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 5, - score_threshold_enabled: false, - score_threshold: 0, - }, tags: [], external_knowledge_info: { external_knowledge_id: '', diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/panel.spec.tsx index 09566126f1590c..b50ba5582cb357 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/panel.spec.tsx @@ -6,6 +6,7 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { BlockEnum, VarType } from '@/app/components/workflow/types' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' import Panel from '../panel' import { @@ -23,9 +24,9 @@ const mockAddKnowledge = vi.hoisted(() => vi.fn()) const mockMetadataFilter = vi.hoisted(() => vi.fn()) const createDataset = (overrides: Partial = {}): DataSet => ({ + ...createDatasetFixture(), id: 'dataset-1', name: 'Dataset Name', - indexing_status: 'completed', icon_info: { icon: '📙', icon_background: '#FFF4ED', @@ -42,7 +43,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ app_count: 0, doc_form: ChunkingMode.text, document_count: 1, - total_document_count: 1, word_count: 1000, provider: 'internal', embedding_model: 'text-embedding-3', @@ -59,17 +59,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ score_threshold_enabled: false, score_threshold: 0, }, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 5, - score_threshold_enabled: false, - score_threshold: 0, - }, tags: [], external_knowledge_info: { external_knowledge_id: '', @@ -93,7 +82,6 @@ const createMetadata = (overrides: Partial = {}): MetadataInDoc = id: 'meta-1', name: 'topic', type: MetadataFilteringVariableType.string, - value: 'topic', ...overrides, }) diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts index ce69b8d51075ca..16f4eaf77da89a 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts @@ -1,5 +1,5 @@ import type { KnowledgeRetrievalNodeType } from '../types' -import type { DataSet, MetadataInDoc } from '@/models/datasets' +import type { DataSet, DataSetListResponse, MetadataInDoc } from '@/models/datasets' import { act, renderHook, waitFor } from '@testing-library/react' import { isEqual } from 'es-toolkit/predicate' import { useState } from 'react' @@ -14,6 +14,7 @@ import { BlockEnum, VarType } from '@/app/components/workflow/types' import { DATASET_DEFAULT } from '@/config' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { fetchDatasets } from '@/service/datasets' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' import { useNodesReadOnly, useWorkflow } from '../../../hooks/use-workflow' import { @@ -67,6 +68,8 @@ vi.mock('@/service/datasets', () => ({ fetchDatasets: vi.fn(), })) +type DatasetListItem = DataSetListResponse['data'][number] + const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) const mockUseIsChatMode = vi.hoisted(() => vi.fn()) const mockUseWorkflow = vi.mocked(useWorkflow) @@ -79,10 +82,10 @@ const mockUseAvailableVarList = vi.mocked(useAvailableVarList) const mockUseDatasetsDetailStore = vi.mocked(useDatasetsDetailStore) const mockFetchDatasets = vi.mocked(fetchDatasets) -const createDataset = (overrides: Partial = {}): DataSet => ({ +const createDataset = (overrides: Partial = {}): DatasetListItem => ({ + ...createDatasetFixture(), id: 'dataset-1', name: 'Dataset Name', - indexing_status: 'completed', icon_info: { icon: '📙', icon_background: '#FFF4ED', @@ -99,7 +102,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ app_count: 0, doc_form: ChunkingMode.text, document_count: 1, - total_document_count: 1, word_count: 1000, provider: 'internal', embedding_model: 'text-embedding-3', @@ -116,17 +118,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ score_threshold_enabled: false, score_threshold: 0, }, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 5, - score_threshold_enabled: false, - score_threshold: 0, - }, tags: [], external_knowledge_info: { external_knowledge_id: '', @@ -150,7 +141,6 @@ const createMetadata = (overrides: Partial = {}): MetadataInDoc = id: 'meta-1', name: 'topic', type: MetadataFilteringVariableType.string, - value: 'topic', ...overrides, }) diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx index b0139f2a4873c0..a0a7e5bbce221e 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx @@ -90,8 +90,7 @@ const ConditionItem = ({ const valueAndValueMethod = useMemo(() => { if ( (currentMetadata?.type === MetadataFilteringVariableType.string || - currentMetadata?.type === MetadataFilteringVariableType.number || - currentMetadata?.type === MetadataFilteringVariableType.select) && + currentMetadata?.type === MetadataFilteringVariableType.number) && typeof condition.value === 'string' ) { const regex = isCommonVariable ? COMMON_VARIABLE_REGEX : VARIABLE_REGEX @@ -163,8 +162,7 @@ const ConditionItem = ({
{!comparisonOperatorNotRequireValue(condition.comparison_operator) && - (currentMetadata?.type === MetadataFilteringVariableType.string || - currentMetadata?.type === MetadataFilteringVariableType.select) && ( + currentMetadata?.type === MetadataFilteringVariableType.string && ( ({ fetchDatasets: vi.fn(), })) +type DatasetListItem = DataSetListResponse['data'][number] + const mockFetchDatasets = vi.mocked(fetchDatasets) -const createDataset = (overrides: Partial = {}): DataSet => ({ +const createDataset = (overrides: Partial = {}): DatasetListItem => ({ + ...createDatasetFixture(), id: 'dataset-1', name: 'Dataset Name', - indexing_status: 'completed', icon_info: { icon: '📙', icon_background: '#FFF4ED', @@ -34,7 +37,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ app_count: 0, doc_form: ChunkingMode.text, document_count: 1, - total_document_count: 1, word_count: 1000, provider: 'internal', embedding_model: 'text-embedding-3', @@ -51,17 +53,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ score_threshold_enabled: false, score_threshold: 0, }, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 5, - score_threshold_enabled: false, - score_threshold: 0, - }, tags: [], external_knowledge_info: { external_knowledge_id: '', diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-metadata-config.spec.ts b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-metadata-config.spec.ts index d4920ed834d00f..d0bc5577b4bd31 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-metadata-config.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-metadata-config.spec.ts @@ -105,13 +105,11 @@ describe('use-knowledge-metadata-config', () => { id: 'meta-1', name: 'topic', type: MetadataFilteringVariableType.string, - value: 'topic', }) result.current.handleAddCondition({ id: 'meta-2', name: 'score', type: MetadataFilteringVariableType.number, - value: '10', }) }) diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-model-config.spec.ts b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-model-config.spec.ts index abb34e81dc84e9..3e34542502509c 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-model-config.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-model-config.spec.ts @@ -5,13 +5,14 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { BlockEnum } from '@/app/components/workflow/types' import { DATASET_DEFAULT } from '@/config' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' import useKnowledgeModelConfig from '../use-knowledge-model-config' const createDataset = (overrides: Partial = {}): DataSet => ({ + ...createDatasetFixture(), id: 'dataset-1', name: 'Dataset Name', - indexing_status: 'completed', icon_info: { icon: '📙', icon_background: '#FFF4ED', @@ -28,7 +29,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ app_count: 0, doc_form: ChunkingMode.text, document_count: 1, - total_document_count: 1, word_count: 1000, provider: 'internal', embedding_model: 'text-embedding-3', @@ -45,17 +45,6 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ score_threshold_enabled: false, score_threshold: 0, }, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 5, - score_threshold_enabled: false, - score_threshold: 0, - }, tags: [], external_knowledge_info: { external_knowledge_id: '', diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts b/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts index 910dbdef8adce1..81d8f9a4fcb26e 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts @@ -123,8 +123,8 @@ export const getMultipleRetrievalConfig = ( : allHighQualityFullTextSearch ? DEFAULT_WEIGHTED_SCORE.allHighQualityFullTextSearch.semantic : DEFAULT_WEIGHTED_SCORE.other.semantic, - embedding_provider_name: selectedDatasets[0]!.embedding_model_provider, - embedding_model_name: selectedDatasets[0]!.embedding_model, + embedding_provider_name: selectedDatasets[0]!.embedding_model_provider ?? '', + embedding_model_name: selectedDatasets[0]!.embedding_model ?? '', }, keyword_setting: { keyword_weight: allHighQualityVectorSearch diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx index ea289f167b67a5..5bb6077e0303ef 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx @@ -123,7 +123,6 @@ const createDatasetFromRef = (dataset: AgentKnowledgeDatasetConfig, index: numbe return { id, name, - indexing_status: 'completed', icon_info: { icon: '📙', icon_type: 'emoji', @@ -135,12 +134,16 @@ const createDatasetFromRef = (dataset: AgentKnowledgeDatasetConfig, index: numbe data_source_type: DataSourceType.FILE, indexing_technique: IndexingType.QUALIFIED, created_by: '', + author_name: null, + created_at: 0, updated_by: '', updated_at: 0, app_count: 0, doc_form: ChunkingMode.text, + chunk_structure: null, document_count: 0, - total_document_count: 0, + total_documents: 0, + total_available_documents: 0, word_count: 0, provider: '', embedding_model: '', @@ -157,17 +160,6 @@ const createDatasetFromRef = (dataset: AgentKnowledgeDatasetConfig, index: numbe score_threshold_enabled: false, score_threshold: 0, }, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: DATASET_DEFAULT.top_k, - score_threshold_enabled: false, - score_threshold: 0, - }, tags: [], external_knowledge_info: { external_knowledge_id: '', @@ -180,8 +172,11 @@ const createDatasetFromRef = (dataset: AgentKnowledgeDatasetConfig, index: numbe score_threshold: 0, score_threshold_enabled: false, }, + doc_metadata: [], built_in_field_enabled: false, + pipeline_id: null, runtime_mode: 'general', + is_published: false, enable_api: false, is_multimodal: false, } diff --git a/web/models/datasets.ts b/web/models/datasets.ts index 29392da7643310..f03e56c349e5ed 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -1,9 +1,18 @@ -import type { AppMode } from '@dify/contracts/api/console/apps/types.gen' import type { ChildChunkListResponse, ChildChunkResponse, ConsoleSegmentListResponse, DocumentDetailResponse as ContractDocumentDetailResponse, + ErrorDocsResponse as ContractErrorDocsResponse, + ProcessRuleResponse as ContractProcessRuleResponse, + RelatedAppResponse as ContractRelatedAppResponse, + DatasetDetailWithPartialMembersResponse, + DatasetDocMetadataResponse, + DatasetListResponse, + DatasetQueryContentResponse, + DatasetQueryDetailResponse, + DocumentStatusListResponse, + DocumentStatusResponse, DocumentWithSegmentsListResponse, DocumentWithSegmentsResponse, ExternalHitTestingRecordResponse, @@ -13,13 +22,13 @@ import type { HitTestingRecord as HitTestingRecordResponse, NotionInfo as NotionInfoPayload, PreProcessingRule as PreProcessingRulePayload, + RelatedAppListResponse, SegmentResponse, } from '@dify/contracts/api/console/datasets/types.gen' import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen' import type { DataSourceNotionPage, DataSourceProvider } from './common' import type { DatasourceType } from './pipeline' import type { IndexingType } from '@/app/components/datasets/create/step-two' -import type { MetadataFilteringVariableType } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import type { AppIconType, RetrievalConfig, TransferMethod } from '@/types/app' import type { I18nKeysByPrefix } from '@/types/i18n' import { PermissionLevel } from './permission' @@ -46,12 +55,7 @@ export const ChunkingMode = { export type ChunkingMode = (typeof ChunkingMode)[keyof typeof ChunkingMode] -export type MetadataInDoc = { - value: string - id: string - type: MetadataFilteringVariableType - name: string -} +export type MetadataInDoc = DatasetDocMetadataResponse export type IconInfo = { icon: string @@ -67,57 +71,7 @@ export type SummaryIndexSetting = { summary_prompt?: string } -export type DataSet = { - id: string - name: string - indexing_status: DocumentIndexingStatus - icon_info: IconInfo - description: string - permission: DatasetPermission - data_source_type: DataSourceType - indexing_technique: IndexingType - author_name?: string - created_by: string - maintainer?: string - updated_by: string - updated_at: number - app_count: number - doc_form: ChunkingMode - document_count: number - total_document_count: number - total_available_documents?: number - word_count: number - provider: string - embedding_model: string - embedding_model_provider: string - embedding_available: boolean - retrieval_model_dict: RetrievalConfig - retrieval_model: RetrievalConfig - tags: Tag[] - partial_member_list?: string[] - external_knowledge_info: { - external_knowledge_id: string - external_knowledge_api_id: string - external_knowledge_api_name: string - external_knowledge_api_endpoint: string - } - external_retrieval_model: { - top_k: number - score_threshold: number - score_threshold_enabled: boolean - } - built_in_field_enabled: boolean - doc_metadata?: MetadataInDoc[] - keyword_number?: number - pipeline_id?: string - is_published?: boolean // Indicates if the pipeline is published - runtime_mode: 'rag_pipeline' | 'general' - enable_api: boolean // Indicates if the service API is enabled - is_multimodal: boolean // Indicates if the dataset supports multimodal - summary_index_setting?: SummaryIndexSetting - /** ACL permission keys */ - permission_keys?: string[] -} +export type DataSet = DatasetDetailWithPartialMembersResponse export type ExternalAPIItem = { id: string @@ -217,21 +171,7 @@ export type FetchDatasetsParams = { } } -export type DatasetListRequest = { - initialPage: number - tag_ids?: string[] - limit: number - include_all?: boolean - keyword?: string -} - -export type DataSetListResponse = { - data: DataSet[] - has_more: boolean - limit: number - page: number - total: number -} +export type DataSetListResponse = DatasetListResponse export type QA = { question: string @@ -251,26 +191,8 @@ export type FileIndexingEstimateResponse = { total_nodes: number } & IndexingEstimateResponse -export type IndexingStatusResponse = { - id: string - indexing_status: DocumentIndexingStatus - processing_started_at: number - parsing_completed_at: number - cleaning_completed_at: number - splitting_completed_at: number - completed_at: any - paused_at: any - error: any - error_code?: 'vector_space_estimate_exceeded' | null - estimated_vector_space_mb?: number | null - vector_space_limit_mb?: number | null - stopped_at: any - completed_segments: number - total_segments: number -} -export type IndexingStatusBatchResponse = { - data: IndexingStatusResponse[] -} +export type IndexingStatusResponse = DocumentStatusResponse +export type IndexingStatusBatchResponse = DocumentStatusListResponse export const ProcessMode = { general: 'custom', @@ -281,12 +203,7 @@ export type ProcessMode = (typeof ProcessMode)[keyof typeof ProcessMode] export type ParentMode = 'full-doc' | 'paragraph' -export type ProcessRuleResponse = { - mode: ProcessMode - rules: Rules - limits: Limits - summary_index_setting?: SummaryIndexSetting -} +export type ProcessRuleResponse = ContractProcessRuleResponse export type Rules = { pre_processing_rules: PreProcessingRule[] @@ -295,10 +212,6 @@ export type Rules = { subchunk_segmentation?: Segmentation } -type Limits = { - indexing_max_segmentation_tokens_length: number -} - export type PreProcessingRule = PreProcessingRulePayload type Segmentation = { @@ -496,44 +409,13 @@ export type SegmentDetailModel = SegmentResponse export type SegmentsResponse = ConsoleSegmentListResponse -export type Query = { - content: string - content_type: 'text_query' | 'image_query' - file_info: Attachment | null -} - -export type HitTestingRecord = { - id: string - source: 'app' | 'hit_testing' | 'plugin' - source_app_id: string - created_by_role: 'account' | 'end_user' - created_by: string - created_at: number - queries: Query[] -} +export type Query = DatasetQueryContentResponse -export type HitTestingRecordsResponse = { - data: HitTestingRecord[] - has_more: boolean - limit: number - total: number - page: number -} +export type HitTestingRecord = DatasetQueryDetailResponse -export type RelatedApp = { - id: string - name: string - mode: AppMode - icon_type: AppIconType | null - icon: string - icon_background: string - icon_url: string -} +export type RelatedApp = ContractRelatedAppResponse -export type RelatedAppResponse = { - data: Array - total: number -} +export type RelatedAppResponse = RelatedAppListResponse export type SegmentUpdater = { content: string @@ -544,10 +426,7 @@ export type SegmentUpdater = { attachment_ids?: string[] } -export type ErrorDocsResponse = { - data: IndexingStatusResponse[] - total: number -} +export type ErrorDocsResponse = ContractErrorDocsResponse export type SelectedDatasetsMode = { allHighQuality: boolean @@ -624,11 +503,6 @@ export const DOC_FORM_TEXT: Record = { // [ChunkingMode.graph]: 'graph', // todo: Graph RAG } -export type IndexingStatusBatchRequest = { - datasetId: string - batchId: string -} - export type HitTestingRequest = { query: string attachment_ids: string[] diff --git a/web/service/console/__tests__/dataset-query-policies.spec.ts b/web/service/console/__tests__/dataset-query-policies.spec.ts new file mode 100644 index 00000000000000..ce38465735dad0 --- /dev/null +++ b/web/service/console/__tests__/dataset-query-policies.spec.ts @@ -0,0 +1,33 @@ +import { ORPCError } from '@orpc/client' +import { consoleQuery } from '@/service/console' + +const detailOptions = () => + consoleQuery.datasets.byDatasetId.get.queryOptions({ + input: { params: { dataset_id: 'dataset-1' } }, + }) + +// Shared defaults are merged into the generated options at runtime. +const retryDetail = (failureCount: number, error: Error) => { + const options = detailOptions() + const retry = 'retry' in options ? options.retry : undefined + if (typeof retry !== 'function') throw new Error('expected a retry policy') + return retry(failureCount, error) +} + +describe('dataset query policies', () => { + it.each([403, 404])('does not retry a dataset detail answered with %i', (status) => { + expect(retryDetail(0, new ORPCError('ERROR', { status }))).toBe(false) + }) + + it('retries other dataset detail failures up to three times', () => { + const error = new ORPCError('INTERNAL_SERVER_ERROR', { status: 500 }) + + expect(retryDetail(2, error)).toBe(true) + expect(retryDetail(3, error)).toBe(false) + }) + + it('reads process rules once per import', () => { + const options = consoleQuery.datasets.processRule.get.queryOptions({ input: { query: {} } }) + expect('refetchOnWindowFocus' in options ? options.refetchOnWindowFocus : undefined).toBe(false) + }) +}) diff --git a/web/service/console/query-policies.ts b/web/service/console/query-policies.ts index 0d91ac50a05919..35f746e6983d9b 100644 --- a/web/service/console/query-policies.ts +++ b/web/service/console/query-policies.ts @@ -10,7 +10,7 @@ import type { RouterUtils } from '@orpc/tanstack-query' import type { InfiniteData, QueryClient, QueryKey } from '@tanstack/react-query' import type { ConsoleClient } from './index' import { createTanstackQueryUtils } from '@orpc/tanstack-query' -import { isNotFoundError } from '@/service/http-error' +import { getHttpErrorStatus, isNotFoundError } from '@/service/http-error' export function createConsoleQuery(consoleClient: ConsoleClient) { const consoleQuery: RouterUtils = createTanstackQueryUtils(consoleClient, { @@ -917,7 +917,20 @@ export function createConsoleQuery(consoleClient: ConsoleClient) { }, }, datasets: { + // Process rules are read once to describe a running import. + processRule: { get: { queryOptions: { refetchOnWindowFocus: false } } }, byDatasetId: { + get: { + queryOptions: { + // A missing or inaccessible dataset sends the detail page back to the list. + retry: (failureCount, error) => { + const status = getHttpErrorStatus(error) + if (status === 403 || status === 404) return false + + return failureCount < 3 + }, + }, + }, documents: { byDocumentId: { pipelineExecutionLog: { get: { queryOptions: { staleTime: 0 } } }, diff --git a/web/service/knowledge/use-dataset.spec.ts b/web/service/knowledge/use-dataset.spec.ts deleted file mode 100644 index f9520da3526855..00000000000000 --- a/web/service/knowledge/use-dataset.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { createHttpError } from '@/service/http-error' -import { consoleClient } from '../console' -import { useDatasetDetail, useDatasetRelatedApps } from './use-dataset' - -vi.mock('@tanstack/react-query', () => ({ - keepPreviousData: Symbol('keepPreviousData'), - useInfiniteQuery: vi.fn(), - useMutation: vi.fn(), - useQuery: vi.fn(), - useQueryClient: vi.fn(), -})) - -vi.mock('../console', () => ({ - consoleClient: { datasets: { byDatasetId: { get: vi.fn() } } }, -})) - -vi.mock('../use-base', () => ({ - useInvalid: vi.fn(), -})) - -const mockUseQuery = vi.mocked(useQuery) -const mockGetDataset = vi.mocked(consoleClient.datasets.byDatasetId.get) - -type QueryOptions = Parameters[0] -type RetryFn = (failureCount: number, error: unknown) => boolean - -const getLastQueryOptions = () => { - return mockUseQuery.mock.calls.at(-1)?.[0] as QueryOptions -} - -const getRetryFn = () => { - return getLastQueryOptions().retry as RetryFn -} - -describe('knowledge dataset hooks', () => { - beforeEach(() => { - vi.clearAllMocks() - mockUseQuery.mockReturnValue({} as ReturnType) - }) - - describe('useDatasetDetail', () => { - it('should not retry forbidden or missing dataset detail errors', () => { - // Arrange & Act - useDatasetDetail('dataset-1') - const retry = getRetryFn() - - // Assert - expect(retry(0, createHttpError(new Response(null, { status: 403 })))).toBe(false) - expect(retry(0, createHttpError(new Response(null, { status: 404 })))).toBe(false) - }) - - it('should retry other dataset detail errors fewer than three times', () => { - // Arrange & Act - useDatasetDetail('dataset-1') - const retry = getRetryFn() - - // Assert - expect(retry(2, new Error('temporary failure'))).toBe(true) - expect(retry(3, new Error('temporary failure'))).toBe(false) - }) - - it('should fetch dataset detail without silent mode', () => { - // Arrange - mockGetDataset.mockResolvedValue({ id: 'dataset-1' } as never) - - // Act - useDatasetDetail('dataset-1') - const queryFn = getLastQueryOptions().queryFn as () => unknown - queryFn() - - // Assert - expect(mockGetDataset).toHaveBeenCalledWith({ params: { dataset_id: 'dataset-1' } }) - }) - }) - - describe('useDatasetRelatedApps', () => { - it('should use explicit enabled option when provided', () => { - // Arrange & Act - useDatasetRelatedApps('dataset-1', { enabled: false }) - - // Assert - expect(getLastQueryOptions().enabled).toBe(false) - }) - - it('should enable related apps query when dataset id exists and no option is provided', () => { - // Arrange & Act - useDatasetRelatedApps('dataset-1') - - // Assert - expect(getLastQueryOptions().enabled).toBe(true) - }) - }) -}) diff --git a/web/service/knowledge/use-dataset.ts b/web/service/knowledge/use-dataset.ts deleted file mode 100644 index 50924ae9061edf..00000000000000 --- a/web/service/knowledge/use-dataset.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { MutationOptions } from '@tanstack/react-query' -import type { - DataSet, - DatasetListRequest, - DataSetListResponse, - ErrorDocsResponse, - FetchDatasetsParams, - HitTestingRecordsResponse, - IndexingStatusBatchRequest, - IndexingStatusBatchResponse, - ProcessRuleResponse, - RelatedAppResponse, -} from '@/models/datasets' -import { ORPCError } from '@orpc/client' -import { keepPreviousData, useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query' -import { consoleClient } from '../console' -import { narrowResponse } from '../console/narrow-response' -import { fetchIndexingStatusBatch } from '../datasets' -import { useInvalid } from '../use-base' - -const NAME_SPACE = 'dataset' - -const datasetListQueryKey = [NAME_SPACE, 'list'] - -const normalizeDatasetsParams = (params: Partial = {}) => { - const { page = 1, limit, ids, tag_ids, include_all, keyword } = params - - return { - page, - ...(limit ? { limit } : {}), - ...(ids?.length ? { ids } : {}), - ...(tag_ids?.length ? { tag_ids } : {}), - ...(include_all !== undefined ? { include_all } : {}), - ...(keyword ? { keyword } : {}), - } -} - -type UseInfiniteDatasetsOptions = { - enabled?: boolean - refetchOnMount?: boolean | 'always' - staleTime?: number - refetchOnReconnect?: boolean - refetchOnWindowFocus?: boolean -} - -export const useInfiniteDatasets = ( - params: Partial, - options?: UseInfiniteDatasetsOptions, -) => { - const normalizedParams = normalizeDatasetsParams(params) - - return useInfiniteQuery({ - queryKey: [...datasetListQueryKey, 'infinite', normalizedParams], - queryFn: ({ pageParam = normalizedParams.page }) => - narrowResponse( - consoleClient.datasets.get({ - query: { ...normalizedParams, page: pageParam as number | undefined }, - }), - ), - getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), - initialPageParam: normalizedParams.page, - staleTime: 0, - refetchOnMount: 'always', - ...options, - }) -} - -export const useDatasetList = (params: DatasetListRequest) => { - const { initialPage, tag_ids, limit, include_all, keyword } = params - return useInfiniteQuery({ - queryKey: [...datasetListQueryKey, initialPage, tag_ids, limit, include_all, keyword], - queryFn: ({ pageParam = 1 }) => - narrowResponse( - consoleClient.datasets.get({ - query: { tag_ids, limit, include_all, keyword, page: pageParam }, - }), - ), - getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : null), - initialPageParam: initialPage, - placeholderData: keepPreviousData, - }) -} - -export const useInvalidDatasetList = () => { - return useInvalid([...datasetListQueryKey]) -} - -export const datasetDetailQueryKeyPrefix = [NAME_SPACE, 'detail'] - -export const useDatasetDetail = (datasetId: string) => { - return useQuery({ - queryKey: [...datasetDetailQueryKeyPrefix, datasetId], - queryFn: () => - narrowResponse( - consoleClient.datasets.byDatasetId.get({ params: { dataset_id: datasetId } }), - ), - enabled: !!datasetId, - retry: (failureCount, error) => { - if (error instanceof ORPCError && [403, 404].includes(error.status)) return false - - return failureCount < 3 - }, - }) -} - -export const useDatasetRelatedApps = (datasetId: string, options?: { enabled?: boolean }) => { - return useQuery({ - queryKey: [NAME_SPACE, 'related-apps', datasetId], - queryFn: () => - narrowResponse( - consoleClient.datasets.byDatasetId.relatedApps.get({ params: { dataset_id: datasetId } }), - ), - enabled: options?.enabled ?? !!datasetId, - }) -} - -export const useIndexingStatusBatch = ( - params: IndexingStatusBatchRequest, - mutationOptions: MutationOptions = {}, -) => { - const { datasetId, batchId } = params - return useMutation({ - mutationKey: [NAME_SPACE, 'indexing-status-batch', datasetId, batchId], - mutationFn: () => fetchIndexingStatusBatch({ datasetId, batchId }), - ...mutationOptions, - }) -} - -export const useProcessRule = (documentId?: string) => { - return useQuery({ - queryKey: [NAME_SPACE, 'process-rule', documentId], - queryFn: () => - narrowResponse( - consoleClient.datasets.processRule.get({ query: { document_id: documentId } }), - ), - enabled: !!documentId, - refetchOnWindowFocus: false, - }) -} - -export const useDatasetApiBaseUrl = () => { - return useQuery<{ api_base_url: string }>({ - queryKey: [NAME_SPACE, 'api-base-info'], - queryFn: () => consoleClient.datasets.apiBaseInfo.get(), - }) -} - -export const useEnableDatasetServiceApi = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'enable-api'], - mutationFn: (datasetId: string) => - consoleClient.datasets.byDatasetId.apiKeys.byStatus.post({ - params: { dataset_id: datasetId, status: 'enable' }, - }), - }) -} - -export const useDisableDatasetServiceApi = () => { - return useMutation({ - mutationKey: [NAME_SPACE, 'disable-api'], - mutationFn: (datasetId: string) => - consoleClient.datasets.byDatasetId.apiKeys.byStatus.post({ - params: { dataset_id: datasetId, status: 'disable' }, - }), - }) -} - -export const useDatasetTestingRecords = ( - datasetId?: string, - params?: { page: number; limit: number }, - options?: { enabled?: boolean }, -) => { - return useQuery({ - queryKey: [NAME_SPACE, 'testing-records', datasetId, params], - queryFn: () => - narrowResponse( - consoleClient.datasets.byDatasetId.queries.get({ - params: { dataset_id: datasetId! }, - query: params, - }), - ), - enabled: !!datasetId && !!params && (options?.enabled ?? true), - placeholderData: keepPreviousData, - }) -} - -export const useDatasetErrorDocs = (datasetId?: string) => { - return useQuery({ - queryKey: [NAME_SPACE, 'error-docs', datasetId], - queryFn: () => - narrowResponse( - consoleClient.datasets.byDatasetId.errorDocs.get({ params: { dataset_id: datasetId! } }), - ), - enabled: !!datasetId, - }) -} diff --git a/web/test/fixtures/dataset.ts b/web/test/fixtures/dataset.ts new file mode 100644 index 00000000000000..eebd2977168fcb --- /dev/null +++ b/web/test/fixtures/dataset.ts @@ -0,0 +1,54 @@ +import type { DatasetListItemResponse } from '@dify/contracts/api/console/datasets/types.gen' + +/** A general-purpose, high-quality knowledge base as the console dataset list returns it. */ +export const createDatasetFixture = ( + overrides: Partial = {}, +): DatasetListItemResponse => ({ + id: 'dataset-1', + name: 'Test Dataset', + description: 'Test description', + provider: 'vendor', + permission: 'only_me', + data_source_type: 'upload_file', + indexing_technique: 'high_quality', + keyword_number: 10, + app_count: 0, + document_count: 0, + word_count: 0, + created_by: 'user-1', + author_name: 'Test Author', + created_at: 1700000000, + updated_by: 'user-1', + updated_at: 1700000000, + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + embedding_available: true, + retrieval_model_dict: { + search_method: 'semantic_search', + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + summary_index_setting: {}, + tags: [], + doc_form: 'text_model', + external_knowledge_info: {}, + external_retrieval_model: { top_k: 3, score_threshold: 0.5, score_threshold_enabled: false }, + doc_metadata: [], + built_in_field_enabled: false, + pipeline_id: null, + runtime_mode: 'general', + chunk_structure: null, + icon_info: { icon_type: 'emoji', icon: '📙', icon_background: '#FFF4ED', icon_url: '' }, + is_published: false, + total_documents: 0, + total_available_documents: 0, + enable_api: true, + is_multimodal: false, + permission_keys: [], + maintainer: null, + partial_member_list: [], + ...overrides, +}) diff --git a/web/types/app.ts b/web/types/app.ts index 02de4615bb5217..41aaad5fa745f2 100644 --- a/web/types/app.ts +++ b/web/types/app.ts @@ -351,7 +351,7 @@ export type RetrievalConfig = { } top_k: number score_threshold_enabled: boolean - score_threshold: number + score_threshold?: number reranking_mode?: RerankingModeEnum weights?: { weight_type: WeightedScoreEnum From 1a42625ce58290df9eeb2d86397540730a495154 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:15:13 +0800 Subject: [PATCH 54/60] chore(web): drop the test-only ErrorDocsResponse alias Co-Authored-By: Claude Opus 5.5 --- .../__tests__/index-failed.spec.tsx | 2 +- web/models/datasets.ts | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx b/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx index ccb2f02bed2648..5c9a3abb4bf34b 100644 --- a/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx +++ b/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx @@ -1,4 +1,4 @@ -import type { ErrorDocsResponse } from '@/models/datasets' +import type { ErrorDocsResponse } from '@dify/contracts/api/console/datasets/types.gen' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { retryErrorDocs } from '@/service/datasets' diff --git a/web/models/datasets.ts b/web/models/datasets.ts index 512b68df2e56b7..99c103ec28f7ea 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -3,7 +3,6 @@ import type { ChildChunkResponse, ConsoleSegmentListResponse, DocumentDetailResponse as ContractDocumentDetailResponse, - ErrorDocsResponse as ContractErrorDocsResponse, ProcessRuleResponse as ContractProcessRuleResponse, RelatedAppResponse as ContractRelatedAppResponse, DatasetDetailWithPartialMembersResponse, @@ -423,8 +422,6 @@ export type SegmentUpdater = { attachment_ids?: string[] } -export type ErrorDocsResponse = ContractErrorDocsResponse - export type SelectedDatasetsMode = { allHighQuality: boolean allHighQualityVectorSearch: boolean From 58f1605e2ad220b1064d1cd8e5bca0e2234eeb1f Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:21:06 +0800 Subject: [PATCH 55/60] refactor(web): type dataset service calls by the contract Remove narrowResponse from service/datasets.ts. The dataset, external API, document and indexing calls return the generated response types, and the forwarding hooks in service/knowledge/use-create-dataset are gone: the indexing estimate, document creation, default process rule and pipeline dataset creation use the generated mutation options at their call sites. The indexing estimate payload builders move to create/step-two/creation-params, and step two sends one estimate request for its data source. The creation result, indexing estimate and external API UI types alias the contract. External knowledge API settings are a typed model with optional endpoint and API key, so the edit form reads absent values as empty. Fields the creation response never carried (the dataset icon and retrieval model in step three) use their fallbacks directly, and the document rename spec now answers with the renamed document the endpoint returns. Co-Authored-By: Claude Opus 5.5 --- api/controllers/console/datasets/external.py | 9 +- api/openapi/markdown/console-openapi.md | 9 +- .../api/console/datasets/types.gen.ts | 9 +- .../generated/api/console/datasets/zod.gen.ts | 10 +- .../list/__tests__/create-card.spec.tsx | 11 +- .../create-from-pipeline/list/create-card.tsx | 7 +- .../__tests__/focus.browser.spec.tsx | 4 +- .../template-card/__tests__/index.spec.tsx | 7 +- .../list/template-card/index.tsx | 40 +++-- .../datasets/create/__tests__/index.spec.tsx | 8 +- .../indexing-progress-item.tsx | 6 +- .../create/embedding-process/utils.ts | 12 +- .../datasets/create/step-three/index.tsx | 7 +- .../create/step-two/__tests__/index.spec.tsx | 144 +++++----------- .../step-two/components/preview-panel.tsx | 4 +- .../create/step-two/creation-params.ts} | 124 +------------- .../__tests__/use-document-creation.spec.ts | 16 +- .../__tests__/use-indexing-estimate.spec.ts | 141 ++++++++-------- .../step-two/hooks/use-document-creation.ts | 53 +++--- .../step-two/hooks/use-indexing-estimate.ts | 120 +++++++------ .../datasets/create/step-two/index.tsx | 8 +- .../__tests__/rename-modal.spec.tsx | 13 +- .../preview/__tests__/chunk-preview.spec.tsx | 1 - .../preview/chunk-preview.tsx | 2 +- .../external-api/external-api-modal/index.tsx | 3 +- .../external-knowledge-api-card/index.tsx | 14 +- web/context/modal-context-provider.tsx | 5 +- web/models/datasets.ts | 85 +--------- web/service/datasets.ts | 159 +++++------------- 29 files changed, 375 insertions(+), 656 deletions(-) rename web/{service/knowledge/use-create-dataset.ts => app/components/datasets/create/step-two/creation-params.ts} (53%) diff --git a/api/controllers/console/datasets/external.py b/api/controllers/console/datasets/external.py index c9961b50a30ea8..3f86a1c61f70fe 100644 --- a/api/controllers/console/datasets/external.py +++ b/api/controllers/console/datasets/external.py @@ -76,12 +76,19 @@ class ExternalKnowledgeApiBindingResponse(ResponseModel): name: str +class ExternalKnowledgeApiSettingsResponse(ResponseModel): + endpoint: str | None = None + api_key: str | None = None + + class ExternalKnowledgeApiResponse(ResponseModel): id: str tenant_id: str name: str description: str - settings: dict[str, Any] | None = Field(validation_alias=AliasChoices("settings_dict", "settings")) + settings: ExternalKnowledgeApiSettingsResponse | None = Field( + validation_alias=AliasChoices("settings_dict", "settings") + ) dataset_bindings: list[ExternalKnowledgeApiBindingResponse] created_by: str created_at: str diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 14dafbb7e3f531..cf9e87075792a2 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -21736,9 +21736,16 @@ Flask blueprint initialization. | description | string | | Yes | | id | string | | Yes | | name | string | | Yes | -| settings | object | | Yes | +| settings | [ExternalKnowledgeApiSettingsResponse](#externalknowledgeapisettingsresponse) | | Yes | | tenant_id | string | | Yes | +#### ExternalKnowledgeApiSettingsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| api_key | string | | No | +| endpoint | string | | No | + #### FeatureModel Effective feature availability and limits for the current workspace. diff --git a/packages/contracts/generated/api/console/datasets/types.gen.ts b/packages/contracts/generated/api/console/datasets/types.gen.ts index ac129a37ef63db..44da2d14ceb29e 100644 --- a/packages/contracts/generated/api/console/datasets/types.gen.ts +++ b/packages/contracts/generated/api/console/datasets/types.gen.ts @@ -197,9 +197,7 @@ export type ExternalKnowledgeApiResponse = { description: string id: string name: string - settings: { - [key: string]: unknown - } | null + settings: ExternalKnowledgeApiSettingsResponse | null tenant_id: string } @@ -787,6 +785,11 @@ export type ExternalKnowledgeApiBindingResponse = { name: string } +export type ExternalKnowledgeApiSettingsResponse = { + api_key?: string | null + endpoint?: string | null +} + export type PreviewDetail = { child_chunks?: Array | null content: string diff --git a/packages/contracts/generated/api/console/datasets/zod.gen.ts b/packages/contracts/generated/api/console/datasets/zod.gen.ts index e6729446699eae..898d6dd8343e2f 100644 --- a/packages/contracts/generated/api/console/datasets/zod.gen.ts +++ b/packages/contracts/generated/api/console/datasets/zod.gen.ts @@ -614,6 +614,14 @@ export const zExternalKnowledgeApiBindingResponse = z.object({ name: z.string(), }) +/** + * ExternalKnowledgeApiSettingsResponse + */ +export const zExternalKnowledgeApiSettingsResponse = z.object({ + api_key: z.string().nullish(), + endpoint: z.string().nullish(), +}) + /** * ExternalKnowledgeApiResponse */ @@ -624,7 +632,7 @@ export const zExternalKnowledgeApiResponse = z.object({ description: z.string(), id: z.string(), name: z.string(), - settings: z.record(z.string(), z.unknown()).nullable(), + settings: zExternalKnowledgeApiSettingsResponse.nullable(), tenant_id: z.string(), }) diff --git a/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx index e1bf0a9dd3be40..2f18005ce3e0c4 100644 --- a/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx @@ -32,11 +32,12 @@ vi.mock('@/app/notifications', async (importOriginal) => { const mockCreateEmptyDataset = vi.fn() -vi.mock('@/service/knowledge/use-create-dataset', () => ({ - useCreatePipelineDataset: () => ({ - mutateAsync: mockCreateEmptyDataset, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipeline.emptyDataset.post': () => ({ mutateAsync: mockCreateEmptyDataset }), + }) +}) // CreateCard Component Tests diff --git a/web/app/components/datasets/create-from-pipeline/list/create-card.tsx b/web/app/components/datasets/create-from-pipeline/list/create-card.tsx index df2eecaf3e51ce..425a22b962ccc5 100644 --- a/web/app/components/datasets/create-from-pipeline/list/create-card.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/create-card.tsx @@ -1,5 +1,5 @@ import { RiAddCircleLine } from '@remixicon/react' -import { useQueryClient } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' @@ -7,13 +7,14 @@ import { trackEvent } from '@/app/components/base/amplitude' import { toast } from '@/app/notifications' import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/console' -import { useCreatePipelineDataset } from '@/service/knowledge/use-create-dataset' const CreateCard = () => { const { t } = useTranslation(['datasetPipeline']) const { push } = useRouter() - const { mutateAsync: createEmptyDataset } = useCreatePipelineDataset() + const { mutateAsync: createEmptyDataset } = useMutation( + consoleQuery.rag.pipeline.emptyDataset.post.mutationOptions(), + ) const queryClient = useQueryClient() const invalidDatasetList = useCallback(() => { queryClient.invalidateQueries({ queryKey: consoleQuery.datasets.get.key() }) diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/focus.browser.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/focus.browser.spec.tsx index fb3cd912083af5..af9a947e01ca5b 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/focus.browser.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/focus.browser.spec.tsx @@ -22,15 +22,13 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn() })) vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ usePluginDependencies: () => ({ handleCheckPluginDependencies: vi.fn() }), })) -vi.mock('@/service/knowledge/use-create-dataset', () => ({ - useCreatePipelineDatasetFromCustomized: () => ({ mutateAsync: vi.fn() }), -})) vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { 'rag.pipeline.templates.byTemplateId.get': function useTemplateDetailQuery() { return useQuery(templateQueryOptions) }, + 'rag.pipeline.dataset.post': () => ({ mutateAsync: vi.fn() }), 'rag.pipeline.customized.templates.byTemplateId.delete': () => ({ mutateAsync: vi.fn() }), 'rag.pipeline.customized.templates.byTemplateId.post': () => ({ mutateAsync: vi.fn(), diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx index 16ecc14b78de4f..55ea2e0882da0a 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx @@ -114,15 +114,10 @@ const mockHandleCheckPluginDependencies = vi.fn() // Configurable isPending for export let mockIsExporting = false -vi.mock('@/service/knowledge/use-create-dataset', () => ({ - useCreatePipelineDatasetFromCustomized: () => ({ - mutateAsync: mockCreateDataset, - }), -})) - vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { + 'rag.pipeline.dataset.post': () => ({ mutateAsync: mockCreateDataset }), 'rag.pipeline.templates.byTemplateId.get': () => ({ data: { name: 'Test Pipeline', diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx index 9e89682c7af645..b852f168375395 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx @@ -19,7 +19,6 @@ import { usePluginDependencies } from '@/app/components/workflow/plugin-dependen import { toast } from '@/app/notifications' import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/console' -import { useCreatePipelineDatasetFromCustomized } from '@/service/knowledge/use-create-dataset' import { downloadBlob } from '@/utils/download' import Actions from './actions' import Content from './content' @@ -46,7 +45,9 @@ const TemplateCard = ({ pipeline, showMoreOperations = true, type }: TemplateCar select: selectPipelineTemplate, }), ) - const { mutateAsync: createDataset } = useCreatePipelineDatasetFromCustomized() + const { mutateAsync: createDataset } = useMutation( + consoleQuery.rag.pipeline.dataset.post.mutationOptions(), + ) const { handleCheckPluginDependencies } = usePluginDependencies() const invalidDatasetList = useCallback(() => { queryClient.invalidateQueries({ queryKey: consoleQuery.datasets.get.key() }) @@ -61,23 +62,26 @@ const TemplateCard = ({ pipeline, showMoreOperations = true, type }: TemplateCar const request = { yaml_content: pipelineTemplateInfo.export_data, } - await createDataset(request, { - onSuccess: async (newDataset) => { - toast.success(t(($) => $['creation.successTip'], { ns: 'datasetPipeline' })) - invalidDatasetList() - if (newDataset.pipeline_id) - await handleCheckPluginDependencies(newDataset.pipeline_id, true) - trackEvent('create_datasets_with_pipeline', { - template_name: pipeline.name, - template_id: pipeline.id, - template_type: type, - }) - push(`/datasets/${newDataset.dataset_id}/pipeline`) - }, - onError: () => { - toast.error(t(($) => $['creation.errorTip'], { ns: 'datasetPipeline' })) + await createDataset( + { body: request }, + { + onSuccess: async (newDataset) => { + toast.success(t(($) => $['creation.successTip'], { ns: 'datasetPipeline' })) + invalidDatasetList() + if (newDataset.pipeline_id) + await handleCheckPluginDependencies(newDataset.pipeline_id, true) + trackEvent('create_datasets_with_pipeline', { + template_name: pipeline.name, + template_id: pipeline.id, + template_type: type, + }) + push(`/datasets/${newDataset.dataset_id}/pipeline`) + }, + onError: () => { + toast.error(t(($) => $['creation.errorTip'], { ns: 'datasetPipeline' })) + }, }, - }) + ) }, [ getPipelineTemplateInfo, createDataset, diff --git a/web/app/components/datasets/create/__tests__/index.spec.tsx b/web/app/components/datasets/create/__tests__/index.spec.tsx index a6f3769673e9b0..cc6f4464683b74 100644 --- a/web/app/components/datasets/create/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/__tests__/index.spec.tsx @@ -258,7 +258,13 @@ vi.mock('../step-two', () => ({ diff --git a/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx b/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx index 049542db6702ea..f62412e493570f 100644 --- a/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx +++ b/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx @@ -1,4 +1,4 @@ -import type { FC } from 'react' +import type { ComponentProps, FC } from 'react' import type { IndexingStatusResponse } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/infotip' @@ -15,7 +15,7 @@ type IndexingProgressItemProps = { detail: IndexingStatusResponse name?: string sourceType?: DataSourceType - notionIcon?: string + notionIcon?: ComponentProps['src'] } // Status icon component for completed/error states @@ -54,7 +54,7 @@ const StatusIcon: FC<{ status: string; error?: string }> = ({ status, error }) = const SourceTypeIcon: FC<{ sourceType?: DataSourceType name?: string - notionIcon?: string + notionIcon?: ComponentProps['src'] }> = ({ sourceType, name, notionIcon }) => { if (sourceType === DataSourceType.FILE) { return ( diff --git a/web/app/components/datasets/create/embedding-process/utils.ts b/web/app/components/datasets/create/embedding-process/utils.ts index a61e96872c8333..73ac7d5dd750a1 100644 --- a/web/app/components/datasets/create/embedding-process/utils.ts +++ b/web/app/components/datasets/create/embedding-process/utils.ts @@ -1,10 +1,11 @@ import type { DataSourceInfo, - DataSourceType, IndexingStatusResponse, InitialDocumentDetail, LegacyDataSourceInfo, } from '@/models/datasets' +import { toCreationDataSourceType } from '@/app/components/datasets/dataset-fields' +import { getNotionPageIcon } from '@/app/components/datasets/documents/document-source' const EMBEDDING_STATUSES = ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting'] as const @@ -50,13 +51,8 @@ export const createDocumentLookup = (documents: InitialDocumentDetail[]) => { getName: (id: string) => documentMap.get(id)?.name, - getSourceType: (id: string) => - documentMap.get(id)?.data_source_type as DataSourceType | undefined, + getSourceType: (id: string) => toCreationDataSourceType(documentMap.get(id)?.data_source_type), - getNotionIcon: (id: string) => { - const info = documentMap.get(id)?.data_source_info - if (info && isLegacyDataSourceInfo(info)) return info.notion_page_icon - return undefined - }, + getNotionIcon: (id: string) => getNotionPageIcon(documentMap.get(id)?.data_source_info, false), } } diff --git a/web/app/components/datasets/create/step-three/index.tsx b/web/app/components/datasets/create/step-three/index.tsx index 16ea2f23c4ca8f..158ab953738f20 100644 --- a/web/app/components/datasets/create/step-three/index.tsx +++ b/web/app/components/datasets/create/step-three/index.tsx @@ -27,7 +27,8 @@ const StepThree = ({ const { t } = useTranslation(['datasetCreation', 'datasetPipeline']) const docLink = useDocLink() - const iconInfo = creationCache?.dataset?.icon_info || { + // The creation response carries no icon; new knowledge bases start with the default one. + const iconInfo = { icon: '📙', icon_type: 'emoji', icon_background: '#FFF4ED', @@ -75,9 +76,7 @@ const StepThree = ({ batchId={creationCache?.batch || ''} documents={creationCache?.documents} indexingType={creationCache?.dataset?.indexing_technique || indexingType} - retrievalMethod={ - creationCache?.dataset?.retrieval_model_dict?.search_method || retrievalMethod - } + retrievalMethod={retrievalMethod} />
diff --git a/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx index b3e489899dcc30..2fe7173b3ec124 100644 --- a/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx @@ -1,8 +1,4 @@ -import type { - GetWorkspacesCurrentModelsModelTypesByModelTypeData, - ProviderWithModelsResponse, -} from '@dify/contracts/api/console/workspaces/types.gen' -import type { OperationKey } from '@orpc/tanstack-query' +import type { ProviderWithModelsResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { DataSourceProvider, NotionPage } from '@/models/common' import type { CrawlOptions, @@ -134,85 +130,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () const mockFetchDefaultProcessRuleMutate = vi.fn() const mockResetFileEstimate = vi.fn() -vi.mock('@/service/knowledge/use-create-dataset', () => ({ - useFetchDefaultProcessRule: ({ - onSuccess, - }: { - onSuccess: (data: { - rules: Rules - limits: { indexing_max_segmentation_tokens_length: number } - }) => void - }) => ({ - mutate: (url: string) => { - mockFetchDefaultProcessRuleMutate(url) - onSuccess({ - rules: { - segmentation: { separator: '\\n', max_tokens: 500, chunk_overlap: 50 }, - pre_processing_rules: [ - { id: 'remove_extra_spaces', enabled: true }, - { id: 'remove_urls_emails', enabled: false }, - ], - parent_mode: 'paragraph', - subchunk_segmentation: { separator: '\\n', max_tokens: 256 }, - }, - limits: { indexing_max_segmentation_tokens_length: 4000 }, - }) - }, - isPending: false, - }), - useFetchFileIndexingEstimateForFile: () => ({ - mutate: vi.fn(), - data: undefined, - isIdle: true, - isPending: false, - reset: mockResetFileEstimate, - }), - useFetchFileIndexingEstimateForNotion: () => ({ - mutate: vi.fn(), - data: undefined, - isIdle: true, - isPending: false, - reset: vi.fn(), - }), - useFetchFileIndexingEstimateForWeb: () => ({ - mutate: vi.fn(), - data: undefined, - isIdle: true, - isPending: false, - reset: vi.fn(), - }), - useCreateFirstDocument: () => ({ - mutateAsync: vi - .fn() - .mockImplementation( - async (params: unknown, options?: { onSuccess?: (data: unknown) => void }) => { - const data = { dataset: { id: 'new-dataset-id' } } - options?.onSuccess?.(data) - return data - }, - ), - isPending: false, - }), - useCreateDocument: () => ({ - mutateAsync: vi - .fn() - .mockImplementation( - async (params: unknown, options?: { onSuccess?: (data: unknown) => void }) => { - const data = { document: { id: 'new-doc-id' } } - options?.onSuccess?.(data) - return data - }, - ), - isPending: false, - }), - getNotionInfo: vi - .fn() - .mockReturnValue([{ workspace_id: 'ws-1', pages: [{ page_id: 'page-1' }] }]), - getWebsiteInfo: vi - .fn() - .mockReturnValue({ provider: 'jinaReader', job_id: 'job-123', urls: ['https://test.com'] }), -})) - // Mock amplitude tracking (external service) vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), @@ -340,7 +257,6 @@ const createMockEstimate = ( overrides?: Partial, ): FileIndexingEstimateResponse => ({ total_segments: 10, - total_nodes: 10, tokens: 5000, total_price: 0.01, currency: 'USD', @@ -2712,17 +2628,49 @@ describe('StepTwo Component', () => { }) vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQuery: (options: { - queryKey: OperationKey< - 'query', - { params: GetWorkspacesCurrentModelsModelTypesByModelTypeData['path'] } - > - }) => { - if (!options.queryKey[0].includes('modelTypes')) return actual.useQuery(options) - return { data: mockEmbeddingModelList } - }, - } + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + const createdDocuments = (data: unknown) => + vi.fn(async (_input: unknown, options?: { onSuccess?: (data: unknown) => void }) => { + options?.onSuccess?.(data) + return data + }) + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.models.modelTypes.byModelType.get': () => ({ + data: mockEmbeddingModelList, + }), + 'datasets.processRule.get': (options: { onSuccess?: (data: unknown) => void }) => ({ + mutate: (input: unknown) => { + mockFetchDefaultProcessRuleMutate(input) + options.onSuccess?.({ + mode: 'custom', + rules: { + segmentation: { separator: '\\n', max_tokens: 500, chunk_overlap: 50 }, + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ], + parent_mode: 'paragraph', + subchunk_segmentation: { separator: '\\n', max_tokens: 256 }, + }, + limits: { indexing_max_segmentation_tokens_length: 4000 }, + }) + }, + isPending: false, + }), + 'datasets.indexingEstimate.post': () => ({ + mutate: vi.fn(), + data: undefined, + isIdle: true, + isPending: false, + reset: mockResetFileEstimate, + }), + 'datasets.init.post': () => ({ + mutateAsync: createdDocuments({ dataset: { id: 'new-dataset-id' } }), + isPending: false, + }), + 'datasets.byDatasetId.documents.post': () => ({ + mutateAsync: createdDocuments({ document: { id: 'new-doc-id' } }), + isPending: false, + }), + }) }) diff --git a/web/app/components/datasets/create/step-two/components/preview-panel.tsx b/web/app/components/datasets/create/step-two/components/preview-panel.tsx index 36a95eee36c58d..3bd1e931d6cf48 100644 --- a/web/app/components/datasets/create/step-two/components/preview-panel.tsx +++ b/web/app/components/datasets/create/step-two/components/preview-panel.tsx @@ -148,8 +148,8 @@ export const PreviewPanel: FC = ({ const indexForLabel = index + 1 const childChunks = parentChildConfig.chunkForContext === 'full-doc' - ? item.child_chunks.slice(0, FULL_DOC_PREVIEW_LENGTH) - : item.child_chunks + ? (item.child_chunks ?? []).slice(0, FULL_DOC_PREVIEW_LENGTH) + : (item.child_chunks ?? []) return ( { const workspacesMap = groupBy(notionPages, 'workspace_id') @@ -86,7 +66,7 @@ type GetFileIndexingEstimateParamsOptionFile = GetFileIndexingEstimateParamsOpti files: CustomFile[] } -const getFileIndexingEstimateParamsForFile = ({ +export const getFileIndexingEstimateParamsForFile = ({ docForm, docLanguage, dataSourceType, @@ -112,25 +92,13 @@ const getFileIndexingEstimateParamsForFile = ({ } } -export const useFetchFileIndexingEstimateForFile = ( - options: GetFileIndexingEstimateParamsOptionFile, - mutationOptions: MutationOptions = {}, -) => { - return useMutation({ - mutationFn: async () => { - return fetchFileIndexingEstimate(getFileIndexingEstimateParamsForFile(options)) - }, - ...mutationOptions, - }) -} - type GetFileIndexingEstimateParamsOptionNotion = GetFileIndexingEstimateParamsOptionBase & { dataSourceType: typeof DataSourceType.NOTION notionPages: NotionPage[] credential_id: string } -const getFileIndexingEstimateParamsForNotion = ({ +export const getFileIndexingEstimateParamsForNotion = ({ docForm, docLanguage, dataSourceType, @@ -153,18 +121,6 @@ const getFileIndexingEstimateParamsForNotion = ({ } } -export const useFetchFileIndexingEstimateForNotion = ( - options: GetFileIndexingEstimateParamsOptionNotion, - mutationOptions: MutationOptions = {}, -) => { - return useMutation({ - mutationFn: async () => { - return fetchFileIndexingEstimate(getFileIndexingEstimateParamsForNotion(options)) - }, - ...mutationOptions, - }) -} - type GetFileIndexingEstimateParamsOptionWeb = GetFileIndexingEstimateParamsOptionBase & { dataSourceType: typeof DataSourceType.WEB websitePages: CrawlResultItem[] @@ -173,7 +129,7 @@ type GetFileIndexingEstimateParamsOptionWeb = GetFileIndexingEstimateParamsOptio websiteCrawlJobId: string } -const getFileIndexingEstimateParamsForWeb = ({ +export const getFileIndexingEstimateParamsForWeb = ({ docForm, docLanguage, dataSourceType, @@ -202,75 +158,3 @@ const getFileIndexingEstimateParamsForWeb = ({ dataset_id, } } - -export const useFetchFileIndexingEstimateForWeb = ( - options: GetFileIndexingEstimateParamsOptionWeb, - mutationOptions: MutationOptions = {}, -) => { - return useMutation({ - mutationFn: async () => { - return fetchFileIndexingEstimate(getFileIndexingEstimateParamsForWeb(options)) - }, - ...mutationOptions, - }) -} - -export const useCreateFirstDocument = ( - mutationOptions: MutationOptions = {}, -) => { - return useMutation({ - mutationFn: async (createDocumentReq: CreateDocumentReq) => { - return createFirstDocument({ body: createDocumentReq }) - }, - ...mutationOptions, - }) -} - -export const useCreateDocument = ( - datasetId: string, - mutationOptions: MutationOptions = {}, -) => { - return useMutation({ - mutationFn: async (req: CreateDocumentReq) => { - return createDocument({ datasetId, body: req }) - }, - ...mutationOptions, - }) -} - -export const useFetchDefaultProcessRule = ( - mutationOptions: MutationOptions = {}, -) => { - return useMutation({ - mutationFn: fetchDefaultProcessRule, - ...mutationOptions, - }) -} - -export const useCreatePipelineDataset = ( - mutationOptions: MutationOptions = {}, -) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'create-pipeline-empty-dataset'], - mutationFn: () => { - return consoleClient.rag.pipeline.emptyDataset.post() - }, - ...mutationOptions, - }) -} - -export const useCreatePipelineDatasetFromCustomized = ( - mutationOptions: MutationOptions< - PostRagPipelineDatasetResponse, - Error, - PostRagPipelineDatasetData['body'] - > = {}, -) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'create-pipeline-dataset'], - mutationFn: (req: PostRagPipelineDatasetData['body']) => { - return consoleClient.rag.pipeline.dataset.post({ body: req }) - }, - ...mutationOptions, - }) -} diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts index 4325edb962663e..8875b1950acb30 100644 --- a/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts @@ -42,12 +42,16 @@ vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({ isReRankModelSelected: mocks.isReRankModelSelected, })) -vi.mock('@/service/knowledge/use-create-dataset', () => ({ - useCreateFirstDocument: () => ({ mutateAsync: mocks.mutateAsync, isPending: false }), - useCreateDocument: () => ({ mutateAsync: mocks.mutateAsync, isPending: false }), - getNotionInfo: vi.fn(() => []), - getWebsiteInfo: vi.fn(() => ({})), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.init.post': () => ({ mutateAsync: mocks.mutateAsync, isPending: false }), + 'datasets.byDatasetId.documents.post': () => ({ + mutateAsync: mocks.mutateAsync, + isPending: false, + }), + }) +}) const { useDocumentCreation } = await import('../use-document-creation') const { IndexingType } = await import('../use-indexing-config') diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts index a59d9297c19458..b32f0ec4e891b5 100644 --- a/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts @@ -3,115 +3,110 @@ import type { NotionPage } from '@/models/common' import type { ChunkingMode, CrawlResultItem, CustomFile, ProcessRule } from '@/models/datasets' import { renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { DataSourceProvider } from '@/models/common' import { DataSourceType } from '@/models/datasets' -// Hoisted mocks const mocks = vi.hoisted(() => ({ - fileMutate: vi.fn(), - fileReset: vi.fn(), - notionMutate: vi.fn(), - notionReset: vi.fn(), - webMutate: vi.fn(), - webReset: vi.fn(), + mutate: vi.fn(), + reset: vi.fn(), })) -vi.mock('@/service/knowledge/use-create-dataset', () => ({ - useFetchFileIndexingEstimateForFile: () => ({ - mutate: mocks.fileMutate, - reset: mocks.fileReset, - data: { tokens: 100, total_segments: 5 }, - isIdle: true, - isPending: false, - }), - useFetchFileIndexingEstimateForNotion: () => ({ - mutate: mocks.notionMutate, - reset: mocks.notionReset, - data: null, - isIdle: true, - isPending: false, - }), - useFetchFileIndexingEstimateForWeb: () => ({ - mutate: mocks.webMutate, - reset: mocks.webReset, - data: null, - isIdle: true, - isPending: false, - }), -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'datasets.indexingEstimate.post': () => ({ + mutate: mocks.mutate, + reset: mocks.reset, + data: { tokens: 100, total_segments: 5 }, + isIdle: true, + isPending: false, + }), + }) +}) const { useIndexingEstimate } = await import('../use-indexing-estimate') describe('useIndexingEstimate', () => { const defaultOptions = { dataSourceType: DataSourceType.FILE, + datasetId: 'dataset-1', currentDocForm: 'text_model' as ChunkingMode, docLanguage: 'English', files: [{ id: 'f-1', name: 'test.txt' }] as unknown as CustomFile[], - previewNotionPage: {} as unknown as NotionPage, - notionCredentialId: '', - previewWebsitePage: {} as unknown as CrawlResultItem, + previewNotionPage: { + page_id: 'page-1', + page_name: 'Page', + page_icon: null, + type: 'page', + workspace_id: 'workspace-1', + } as unknown as NotionPage, + notionCredentialId: 'credential-1', + previewWebsitePage: { source_url: 'https://example.com' } as unknown as CrawlResultItem, + websiteCrawlProvider: DataSourceProvider.jinaReader, + websiteCrawlJobId: 'job-1', indexingTechnique: 'high_quality' as unknown as IndexingType, processRule: { mode: 'custom', rules: {} } as unknown as ProcessRule, } + const sentBody = () => mocks.mutate.mock.calls.at(-1)?.[0].body + beforeEach(() => { vi.clearAllMocks() }) - describe('currentMutation selection', () => { - it('should select file mutation for FILE type', () => { - const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) - expect(result.current.estimate).toEqual({ tokens: 100, total_segments: 5 }) - }) - - it('should select notion mutation for NOTION type', () => { - const { result } = renderHook(() => - useIndexingEstimate({ - ...defaultOptions, - dataSourceType: DataSourceType.NOTION, - }), - ) - expect(result.current.estimate).toBeNull() - }) - - it('should select web mutation for WEB type', () => { - const { result } = renderHook(() => - useIndexingEstimate({ - ...defaultOptions, - dataSourceType: DataSourceType.WEB, - }), - ) - expect(result.current.estimate).toBeNull() - }) + it('exposes the estimate', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + expect(result.current.estimate).toEqual({ tokens: 100, total_segments: 5 }) }) describe('fetchEstimate', () => { - it('should call file mutate for FILE type', () => { + it('estimates the uploaded files', () => { const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) result.current.fetchEstimate() - expect(mocks.fileMutate).toHaveBeenCalledOnce() + + expect(sentBody()).toMatchObject({ + info_list: { data_source_type: 'upload_file', file_info_list: { file_ids: ['f-1'] } }, + doc_form: 'text_model', + dataset_id: 'dataset-1', + }) }) - it('should call notion mutate for NOTION type', () => { + it('estimates the previewed Notion page', () => { const { result } = renderHook(() => - useIndexingEstimate({ - ...defaultOptions, - dataSourceType: DataSourceType.NOTION, - }), + useIndexingEstimate({ ...defaultOptions, dataSourceType: DataSourceType.NOTION }), ) result.current.fetchEstimate() - expect(mocks.notionMutate).toHaveBeenCalledOnce() + + expect(sentBody()).toMatchObject({ + info_list: { + data_source_type: 'notion_import', + notion_info_list: [ + { + credential_id: 'credential-1', + workspace_id: 'workspace-1', + pages: [{ page_id: 'page-1' }], + }, + ], + }, + }) }) - it('should call web mutate for WEB type', () => { + it('estimates the previewed website page', () => { const { result } = renderHook(() => - useIndexingEstimate({ - ...defaultOptions, - dataSourceType: DataSourceType.WEB, - }), + useIndexingEstimate({ ...defaultOptions, dataSourceType: DataSourceType.WEB }), ) result.current.fetchEstimate() - expect(mocks.webMutate).toHaveBeenCalledOnce() + + expect(sentBody()).toMatchObject({ + info_list: { + data_source_type: 'website_crawl', + website_info_list: { + provider: DataSourceProvider.jinaReader, + job_id: 'job-1', + urls: ['https://example.com'], + }, + }, + }) }) }) @@ -129,7 +124,7 @@ describe('useIndexingEstimate', () => { it('should expose reset function', () => { const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) result.current.reset() - expect(mocks.fileReset).toHaveBeenCalledOnce() + expect(mocks.reset).toHaveBeenCalledOnce() }) }) }) diff --git a/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts b/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts index 2d99fd9889acb4..771a2174f71cb6 100644 --- a/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts +++ b/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts @@ -13,7 +13,7 @@ import type { SummaryIndexSetting as SummaryIndexSettingType, } from '@/models/datasets' import type { RetrievalConfig, RETRIEVE_METHOD } from '@/types/app' -import { useQueryClient } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' @@ -22,12 +22,7 @@ import { toast } from '@/app/notifications' import { DataSourceProvider } from '@/models/common' import { DataSourceType } from '@/models/datasets' import { consoleQuery } from '@/service/console' -import { - getNotionInfo, - getWebsiteInfo, - useCreateDocument, - useCreateFirstDocument, -} from '@/service/knowledge/use-create-dataset' +import { getNotionInfo, getWebsiteInfo } from '../creation-params' import { IndexingType } from './use-indexing-config' import { MAXIMUM_CHUNK_TOKEN_LENGTH } from './use-segmentation-state' @@ -84,13 +79,17 @@ export const useDocumentCreation = (options: UseDocumentCreationOptions) => { onSave, mutateDatasetRes, } = options - const createFirstDocumentMutation = useCreateFirstDocument() - const createDocumentMutation = useCreateDocument(datasetId!) + const { mutateAsync: createFirstDocument, isPending: isCreatingFirstDocument } = useMutation( + consoleQuery.datasets.init.post.mutationOptions(), + ) + const { mutateAsync: createDocument, isPending: isCreatingDocument } = useMutation( + consoleQuery.datasets.byDatasetId.documents.post.mutationOptions(), + ) const queryClient = useQueryClient() const invalidDatasetList = useCallback(() => { queryClient.invalidateQueries({ queryKey: consoleQuery.datasets.get.key() }) }, [queryClient]) - const isCreating = createFirstDocumentMutation.isPending || createDocumentMutation.isPending + const isCreating = isCreatingFirstDocument || isCreatingDocument // Validate creation params const validateParams = useCallback( (params: ValidationParams): boolean => { @@ -224,21 +223,27 @@ export const useDocumentCreation = (options: UseDocumentCreationOptions) => { if (datasetId && !isSetting && !canCreateDocument) return if (!datasetId) { - await createFirstDocumentMutation.mutateAsync(params, { - onSuccess(data) { - updateIndexingTypeCache?.(indexType) - updateResultCache?.(data) - updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) + await createFirstDocument( + { body: params }, + { + onSuccess(data) { + updateIndexingTypeCache?.(indexType) + updateResultCache?.(data) + updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) + }, }, - }) + ) } else { - await createDocumentMutation.mutateAsync(params, { - onSuccess(data) { - updateIndexingTypeCache?.(indexType) - updateResultCache?.(data) - updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) + await createDocument( + { params: { dataset_id: datasetId }, body: params }, + { + onSuccess(data) { + updateIndexingTypeCache?.(indexType) + updateResultCache?.(data) + updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) + }, }, - }) + ) } mutateDatasetRes?.() invalidDatasetList() @@ -251,8 +256,8 @@ export const useDocumentCreation = (options: UseDocumentCreationOptions) => { }, [ datasetId, - createFirstDocumentMutation, - createDocumentMutation, + createFirstDocument, + createDocument, updateIndexingTypeCache, updateResultCache, updateRetrievalMethodCache, diff --git a/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts b/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts index b7a762aa504988..3038de853cf308 100644 --- a/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts +++ b/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts @@ -7,14 +7,16 @@ import type { CustomFile, ProcessRule, } from '@/models/datasets' +import { useMutation } from '@tanstack/react-query' import { useCallback } from 'react' import { DataSourceProvider } from '@/models/common' import { DataSourceType } from '@/models/datasets' +import { consoleQuery } from '@/service/console' import { - useFetchFileIndexingEstimateForFile, - useFetchFileIndexingEstimateForNotion, - useFetchFileIndexingEstimateForWeb, -} from '@/service/knowledge/use-create-dataset' + getFileIndexingEstimateParamsForFile, + getFileIndexingEstimateParamsForNotion, + getFileIndexingEstimateParamsForWeb, +} from '../creation-params' type UseIndexingEstimateOptions = { dataSourceType: DataSourceType @@ -56,65 +58,75 @@ export const useIndexingEstimate = (options: UseIndexingEstimateOptions) => { processRule, } = options - // File indexing estimate - const fileQuery = useFetchFileIndexingEstimateForFile({ - docForm: currentDocForm, - docLanguage, - dataSourceType: DataSourceType.FILE, - files: previewFileName ? [files.find((file) => file.name === previewFileName)!] : files, - indexingTechnique, - processRule, - dataset_id: datasetId!, - }) - - // Notion indexing estimate - const notionQuery = useFetchFileIndexingEstimateForNotion({ - docForm: currentDocForm, - docLanguage, - dataSourceType: DataSourceType.NOTION, - notionPages: previewNotionPage ? [previewNotionPage] : [], - indexingTechnique, - processRule, - dataset_id: datasetId || '', - credential_id: notionCredentialId, - }) + // Step two keeps one data source, so one estimate request serves it. + const estimateMutation = useMutation( + consoleQuery.datasets.indexingEstimate.post.mutationOptions(), + ) - // Website indexing estimate - const websiteQuery = useFetchFileIndexingEstimateForWeb({ - docForm: currentDocForm, + const getEstimateParams = useCallback(() => { + if (dataSourceType === DataSourceType.FILE) { + return getFileIndexingEstimateParamsForFile({ + docForm: currentDocForm, + docLanguage, + dataSourceType: DataSourceType.FILE, + files: previewFileName ? [files.find((file) => file.name === previewFileName)!] : files, + indexingTechnique, + processRule, + dataset_id: datasetId!, + }) + } + if (dataSourceType === DataSourceType.NOTION) { + return getFileIndexingEstimateParamsForNotion({ + docForm: currentDocForm, + docLanguage, + dataSourceType: DataSourceType.NOTION, + notionPages: previewNotionPage ? [previewNotionPage] : [], + indexingTechnique, + processRule, + dataset_id: datasetId || '', + credential_id: notionCredentialId, + }) + } + return getFileIndexingEstimateParamsForWeb({ + docForm: currentDocForm, + docLanguage, + dataSourceType: DataSourceType.WEB, + websitePages: previewWebsitePage ? [previewWebsitePage] : [], + crawlOptions, + websiteCrawlProvider: websiteCrawlProvider ?? DataSourceProvider.jinaReader, + websiteCrawlJobId: websiteCrawlJobId ?? '', + indexingTechnique, + processRule, + dataset_id: datasetId || '', + }) + }, [ + dataSourceType, + currentDocForm, docLanguage, - dataSourceType: DataSourceType.WEB, - websitePages: previewWebsitePage ? [previewWebsitePage] : [], + files, + previewFileName, + previewNotionPage, + notionCredentialId, + previewWebsitePage, crawlOptions, - websiteCrawlProvider: websiteCrawlProvider ?? DataSourceProvider.jinaReader, - websiteCrawlJobId: websiteCrawlJobId ?? '', + websiteCrawlProvider, + websiteCrawlJobId, indexingTechnique, processRule, - dataset_id: datasetId || '', - }) - - // Get current mutation based on data source type - const getCurrentMutation = useCallback(() => { - if (dataSourceType === DataSourceType.FILE) return fileQuery - if (dataSourceType === DataSourceType.NOTION) return notionQuery - return websiteQuery - }, [dataSourceType, fileQuery, notionQuery, websiteQuery]) - - const currentMutation = getCurrentMutation() + datasetId, + ]) - // Trigger estimate fetch + const { mutate } = estimateMutation const fetchEstimate = useCallback(() => { - if (dataSourceType === DataSourceType.FILE) fileQuery.mutate() - else if (dataSourceType === DataSourceType.NOTION) notionQuery.mutate() - else websiteQuery.mutate() - }, [dataSourceType, fileQuery, notionQuery, websiteQuery]) + mutate({ body: getEstimateParams() }) + }, [mutate, getEstimateParams]) return { - currentMutation, - estimate: currentMutation.data, - isIdle: currentMutation.isIdle, - isPending: currentMutation.isPending, + currentMutation: estimateMutation, + estimate: estimateMutation.data, + isIdle: estimateMutation.isIdle, + isPending: estimateMutation.isPending, fetchEstimate, - reset: currentMutation.reset, + reset: estimateMutation.reset, } } diff --git a/web/app/components/datasets/create/step-two/index.tsx b/web/app/components/datasets/create/step-two/index.tsx index daa631137a193f..621eb795678fe7 100644 --- a/web/app/components/datasets/create/step-two/index.tsx +++ b/web/app/components/datasets/create/step-two/index.tsx @@ -5,6 +5,7 @@ import type { StepTwoProps } from './types' import { cn } from '@langgenius/dify-ui/cn' import { RadioGroup } from '@langgenius/dify-ui/radio-group' import { Separator } from '@langgenius/dify-ui/separator' +import { useMutation } from '@tanstack/react-query' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' @@ -20,7 +21,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { LanguagesSupported } from '@/i18n/language' import { DataSourceProvider } from '@/models/common' import { ChunkingMode, ProcessMode } from '@/models/datasets' -import { useFetchDefaultProcessRule } from '@/service/knowledge/use-create-dataset' +import { consoleQuery } from '@/service/console' import { GeneralChunkingOptions, IndexingModeSection, @@ -171,7 +172,8 @@ const StepTwo: FC = ({ }) // Fetch default process rule - const fetchDefaultProcessRuleMutation = useFetchDefaultProcessRule({ + const fetchDefaultProcessRuleMutation = useMutation({ + ...consoleQuery.datasets.processRule.get.mutationOptions(), onSuccess(data) { const defaultRules = data.rules ? toSegmentationRules(data.rules) : undefined if (defaultRules) { @@ -269,7 +271,7 @@ const StepTwo: FC = ({ // Initialize rules useEffect(() => { if (!isSetting) { - fetchDefaultProcessRuleMutation.mutate() + fetchDefaultProcessRuleMutation.mutate({}) } else if (documentDetail?.dataset_process_rule) { const { mode, rules } = documentDetail.dataset_process_rule const segmentationRules = rules ? toSegmentationRules(rules) : undefined diff --git a/web/app/components/datasets/documents/components/__tests__/rename-modal.spec.tsx b/web/app/components/datasets/documents/components/__tests__/rename-modal.spec.tsx index 105857911a4ec7..2953c7172eccd2 100644 --- a/web/app/components/datasets/documents/components/__tests__/rename-modal.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/rename-modal.spec.tsx @@ -25,6 +25,9 @@ vi.mock('@/app/notifications', () => ({ const mockRenameDocumentName = vi.mocked(renameDocumentName) +// The rename endpoint answers with the renamed document. +const renamedDocument = { id: 'doc-123', name: 'New Name', data_source_detail_dict: {} } + describe('RenameModal', () => { const defaultProps = { datasetId: 'dataset-123', @@ -95,7 +98,7 @@ describe('RenameModal', () => { }) it('should call renameDocumentName with correct params when save is clicked', async () => { - mockRenameDocumentName.mockResolvedValueOnce({ result: 'success' }) + mockRenameDocumentName.mockResolvedValueOnce(renamedDocument) render() const input = screen.getByRole('textbox') @@ -114,7 +117,7 @@ describe('RenameModal', () => { }) it('should call onSaved and onClose on successful save', async () => { - mockRenameDocumentName.mockResolvedValueOnce({ result: 'success' }) + mockRenameDocumentName.mockResolvedValueOnce(renamedDocument) const handleSaved = vi.fn() const handleClose = vi.fn() @@ -133,8 +136,8 @@ describe('RenameModal', () => { describe('Loading State', () => { it('should not submit again while saving', async () => { // Create a promise that we can resolve manually - let resolvePromise: (value: { result: 'success' | 'fail' }) => void - const pendingPromise = new Promise<{ result: 'success' | 'fail' }>((resolve) => { + let resolvePromise: (value: typeof renamedDocument) => void + const pendingPromise = new Promise((resolve) => { resolvePromise = resolve }) mockRenameDocumentName.mockReturnValueOnce(pendingPromise) @@ -151,7 +154,7 @@ describe('RenameModal', () => { await user.keyboard('{Enter}') expect(mockRenameDocumentName).toHaveBeenCalledTimes(1) - resolvePromise!({ result: 'success' }) + resolvePromise!(renamedDocument) await waitFor(() => { expect(defaultProps.onClose).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/chunk-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/chunk-preview.spec.tsx index 43a2b325e59f48..b38907c0739e55 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/chunk-preview.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/chunk-preview.spec.tsx @@ -97,7 +97,6 @@ const createMockOnlineDriveFile = (overrides?: Partial): Online const createMockEstimateData = ( overrides?: Partial, ): FileIndexingEstimateResponse => ({ - total_nodes: 5, tokens: 1000, total_price: 0.01, currency: 'USD', diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx index c4a989400755f6..252857f13725d4 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.tsx @@ -198,7 +198,7 @@ const ChunkPreview = ({ characterCount={item.content.length} > - {item.child_chunks.map((child, index) => { + {(item.child_chunks ?? []).map((child, index) => { const indexForLabel = index + 1 return ( void + onSave: (api: ExternalAPIItem) => void onCancel: () => void onEdit?: (formValue: CreateExternalAPIReq) => Promise datasetBindings?: { diff --git a/web/app/components/datasets/external-api/external-knowledge-api-card/index.tsx b/web/app/components/datasets/external-api/external-knowledge-api-card/index.tsx index 78da98648f8362..05c88ed86ba0c2 100644 --- a/web/app/components/datasets/external-api/external-knowledge-api-card/index.tsx +++ b/web/app/components/datasets/external-api/external-knowledge-api-card/index.tsx @@ -55,8 +55,8 @@ const ExternalKnowledgeAPICard: React.FC = ({ const formValue: CreateExternalAPIReq = { name: response.name, settings: { - endpoint: response.settings.endpoint, - api_key: response.settings.api_key, + endpoint: response.settings?.endpoint ?? '', + api_key: response.settings?.api_key ?? '', }, } @@ -68,15 +68,7 @@ const ExternalKnowledgeAPICard: React.FC = ({ try { await updateExternalAPI({ apiTemplateId: api.id, - body: { - ...response, - name: updatedData.name, - settings: { - ...response.settings, - endpoint: updatedData.settings.endpoint, - api_key: updatedData.settings.api_key, - }, - }, + body: { name: updatedData.name, settings: updatedData.settings }, }) await queryClient.invalidateQueries({ queryKey: externalKnowledgeApiQueryKey }) } catch (error) { diff --git a/web/context/modal-context-provider.tsx b/web/context/modal-context-provider.tsx index da470ea3e1f547..6fa3599c09ff6a 100644 --- a/web/context/modal-context-provider.tsx +++ b/web/context/modal-context-provider.tsx @@ -125,9 +125,10 @@ export const ModalContextProvider = ({ children }: ModalContextProviderProps) => }, [showExternalKnowledgeAPIModal]) const handleSaveExternalApiModal = useCallback( - async (updatedFormValue: CreateExternalAPIReq) => { + // Callers refresh their API list; the created API is not passed back. + async () => { if (showExternalKnowledgeAPIModal?.onSaveCallback) - showExternalKnowledgeAPIModal.onSaveCallback(updatedFormValue) + showExternalKnowledgeAPIModal.onSaveCallback() setShowExternalKnowledgeAPIModal(null) }, [showExternalKnowledgeAPIModal], diff --git a/web/models/datasets.ts b/web/models/datasets.ts index 512b68df2e56b7..9c2320a653d17e 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -3,19 +3,22 @@ import type { ChildChunkResponse, ConsoleSegmentListResponse, DocumentDetailResponse as ContractDocumentDetailResponse, + DocumentResponse as ContractDocumentResponse, ErrorDocsResponse as ContractErrorDocsResponse, + IndexingEstimateResponse as ContractIndexingEstimateResponse, ProcessRuleResponse as ContractProcessRuleResponse, RelatedAppResponse as ContractRelatedAppResponse, + DatasetAndDocumentResponse, DatasetDetailWithPartialMembersResponse, DatasetDocMetadataResponse, DatasetListResponse, DatasetQueryContentResponse, DatasetQueryDetailResponse, - DocumentStatusListResponse, DocumentStatusResponse, DocumentWithSegmentsResponse, ExternalHitTestingRecordResponse, ExternalHitTestingResponse, + ExternalKnowledgeApiResponse, HitTestingChildChunk as GeneratedHitTestingChildChunk, HitTestingResponse as GeneratedHitTestingResponse, HitTestingRecord as HitTestingRecordResponse, @@ -24,9 +27,7 @@ import type { RelatedAppListResponse, SegmentResponse, } from '@dify/contracts/api/console/datasets/types.gen' -import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen' import type { DataSourceNotionPage, DataSourceProvider } from './common' -import type { DatasourceType } from './pipeline' import type { IndexingType } from '@/app/components/datasets/create/step-two' import type { AppIconType, RetrievalConfig, TransferMethod } from '@/types/app' import type { I18nKeysByPrefix } from '@/types/i18n' @@ -72,42 +73,7 @@ export type SummaryIndexSetting = { export type DataSet = DatasetDetailWithPartialMembersResponse -export type ExternalAPIItem = { - id: string - tenant_id: string - name: string - description: string - settings: { - endpoint: string - api_key: string - } - dataset_bindings: { id: string; name: string }[] - created_by: string - created_at: string -} - -export type ExternalKnowledgeItem = { - id: string - name: string - description: string | null - provider: 'external' - permission: DatasetPermission - data_source_type: null - indexing_technique: null - app_count: number - document_count: number - word_count: number - created_by: string - created_at: string - updated_by: string - updated_at: string - tags: Tag[] -} - -export type ExternalAPIUsage = { - is_using: boolean - count: number -} +export type ExternalAPIItem = ExternalKnowledgeApiResponse export type CustomFile = File & { id?: string @@ -177,21 +143,9 @@ export type QA = { answer: string } -type IndexingEstimateResponse = { - tokens: number - total_price: number - currency: string - total_segments: number - preview: Array<{ content: string; child_chunks: string[]; summary?: string }> - qa_preview?: QA[] -} - -export type FileIndexingEstimateResponse = { - total_nodes: number -} & IndexingEstimateResponse +export type FileIndexingEstimateResponse = ContractIndexingEstimateResponse export type IndexingStatusResponse = DocumentStatusResponse -export type IndexingStatusBatchResponse = DocumentStatusListResponse export const ProcessMode = { general: 'custom', @@ -308,26 +262,7 @@ export type DataSourceInfo = | WebsiteCrawlInfo | UploadFileIdInfo -export type InitialDocumentDetail = { - id: string - batch: string - position: number - dataset_id: string - data_source_type: DataSourceType | DatasourceType - data_source_info: DataSourceInfo - dataset_process_rule_id: string - name: string - created_from: 'rag-pipeline' | 'api' | 'web' - created_by: string - created_at: number - indexing_status: DocumentIndexingStatus - display_status: DocumentDisplayStatus - completed_segments?: number - total_segments?: number - doc_form: ChunkingMode - doc_language: string - summary_index_status?: string -} +export type InitialDocumentDetail = ContractDocumentResponse // A document in the dataset document list. export type SimpleDocumentDetail = DocumentWithSegmentsResponse @@ -373,11 +308,7 @@ export type ProcessRule = { summary_index_setting?: SummaryIndexSetting } -export type createDocumentResponse = { - dataset?: DataSet - batch: string - documents: InitialDocumentDetail[] -} +export type createDocumentResponse = DatasetAndDocumentResponse // A dataset document as the document detail query returns it. export type FullDocumentDetail = ContractDocumentDetailResponse diff --git a/web/service/datasets.ts b/web/service/datasets.ts index 94899744b58043..9df01ac8be398d 100644 --- a/web/service/datasets.ts +++ b/web/service/datasets.ts @@ -1,29 +1,11 @@ -import type { - DatasetUpdatePayload, - IndexingEstimatePayload, - KnowledgeConfig, -} from '@dify/contracts/api/console/datasets/types.gen' +import type { DatasetUpdatePayload } from '@dify/contracts/api/console/datasets/types.gen' import type { CreateExternalAPIReq } from '@/app/components/datasets/external-api/declarations' import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations' -import type { CommonResponse } from '@/models/common' -import type { - createDocumentResponse, - DataSet, - DataSetListResponse, - ExternalAPIItem, - ExternalAPIUsage, - ExternalKnowledgeItem, - FetchDatasetsParams, - FileIndexingEstimateResponse, - IndexingStatusBatchResponse, - IndexingStatusResponse, - ProcessRuleResponse, -} from '@/models/datasets' +import type { FetchDatasetsParams } from '@/models/datasets' import { DataSourceProvider } from '@/models/common' // oxlint-disable-next-line no-restricted-imports -- the zip download needs the raw Blob response import { post } from './base' import { consoleClient } from './console' -import { narrowResponse } from './console/narrow-response' // apis for documents in a dataset @@ -50,21 +32,19 @@ export const updateDatasetSetting = ({ }: { datasetId: string body: DatasetUpdatePayload -}): Promise => { - return narrowResponse( - consoleClient.datasets.byDatasetId.patch({ params: { dataset_id: datasetId }, body }), - ) +}) => { + return consoleClient.datasets.byDatasetId.patch({ params: { dataset_id: datasetId }, body }) } -export const fetchDatasets = ({ params }: FetchDatasetsParams): Promise => { - return narrowResponse(consoleClient.datasets.get({ query: params })) +export const fetchDatasets = ({ params }: FetchDatasetsParams) => { + return consoleClient.datasets.get({ query: params }) } -export const createEmptyDataset = ({ name }: { name: string }): Promise => { - return narrowResponse(consoleClient.datasets.post({ body: { name } })) +export const createEmptyDataset = ({ name }: { name: string }) => { + return consoleClient.datasets.post({ body: { name } }) } -export const checkIsUsedInApp = (id: string): Promise<{ is_using: boolean }> => { +export const checkIsUsedInApp = (id: string) => { return consoleClient.datasets.byDatasetId.useCheck.get( { params: { dataset_id: id } }, { context: { silent: true } }, @@ -75,16 +55,10 @@ export const deleteDataset = (datasetID: string) => { return consoleClient.datasets.byDatasetId.delete({ params: { dataset_id: datasetID } }) } -export const fetchExternalAPI = ({ - apiTemplateId, -}: { - apiTemplateId: string -}): Promise => { - return narrowResponse( - consoleClient.datasets.externalKnowledgeApi.byExternalKnowledgeApiId.get({ - params: { external_knowledge_api_id: apiTemplateId }, - }), - ) +export const fetchExternalAPI = ({ apiTemplateId }: { apiTemplateId: string }) => { + return consoleClient.datasets.externalKnowledgeApi.byExternalKnowledgeApiId.get({ + params: { external_knowledge_api_id: apiTemplateId }, + }) } export const updateExternalAPI = ({ @@ -92,14 +66,12 @@ export const updateExternalAPI = ({ body, }: { apiTemplateId: string - body: ExternalAPIItem -}): Promise => { - return narrowResponse( - consoleClient.datasets.externalKnowledgeApi.byExternalKnowledgeApiId.patch({ - params: { external_knowledge_api_id: apiTemplateId }, - body, - }), - ) + body: CreateExternalAPIReq +}) => { + return consoleClient.datasets.externalKnowledgeApi.byExternalKnowledgeApiId.patch({ + params: { external_knowledge_api_id: apiTemplateId }, + body, + }) } export const deleteExternalAPI = ({ apiTemplateId }: { apiTemplateId: string }) => { @@ -108,89 +80,41 @@ export const deleteExternalAPI = ({ apiTemplateId }: { apiTemplateId: string }) }) } -export const checkUsageExternalAPI = ({ - apiTemplateId, -}: { - apiTemplateId: string -}): Promise => { +export const checkUsageExternalAPI = ({ apiTemplateId }: { apiTemplateId: string }) => { return consoleClient.datasets.externalKnowledgeApi.byExternalKnowledgeApiId.useCheck.get({ params: { external_knowledge_api_id: apiTemplateId }, }) } -export const createExternalAPI = ({ - body, -}: { - body: CreateExternalAPIReq -}): Promise => { - return narrowResponse(consoleClient.datasets.externalKnowledgeApi.post({ body })) -} - -export const createExternalKnowledgeBase = ({ - body, -}: { - body: CreateKnowledgeBaseReq -}): Promise => { - return narrowResponse(consoleClient.datasets.external.post({ body })) -} - -export const fetchDefaultProcessRule = (): Promise => { - return narrowResponse(consoleClient.datasets.processRule.get({})) -} - -export const createFirstDocument = ({ - body, -}: { - body: KnowledgeConfig -}): Promise => { - return narrowResponse(consoleClient.datasets.init.post({ body })) +export const createExternalAPI = ({ body }: { body: CreateExternalAPIReq }) => { + return consoleClient.datasets.externalKnowledgeApi.post({ body }) } -export const createDocument = ({ - datasetId, - body, -}: { - datasetId: string - body: KnowledgeConfig -}): Promise => { - return narrowResponse( - consoleClient.datasets.byDatasetId.documents.post({ params: { dataset_id: datasetId }, body }), - ) +export const createExternalKnowledgeBase = ({ body }: { body: CreateKnowledgeBaseReq }) => { + return consoleClient.datasets.external.post({ body }) } -export const fetchIndexingStatus = ({ - datasetId, - documentId, -}: CommonDocReq): Promise => { - return narrowResponse( - consoleClient.datasets.byDatasetId.documents.byDocumentId.indexingStatus.get({ - params: { dataset_id: datasetId, document_id: documentId }, - }), - ) +export const fetchIndexingStatus = ({ datasetId, documentId }: CommonDocReq) => { + return consoleClient.datasets.byDatasetId.documents.byDocumentId.indexingStatus.get({ + params: { dataset_id: datasetId, document_id: documentId }, + }) } -export const fetchIndexingStatusBatch = ({ - datasetId, - batchId, -}: BatchReq): Promise => { - return narrowResponse( - consoleClient.datasets.byDatasetId.batch.byBatch.indexingStatus.get({ - params: { dataset_id: datasetId, batch: batchId }, - }), - ) +export const fetchIndexingStatusBatch = ({ datasetId, batchId }: BatchReq) => { + return consoleClient.datasets.byDatasetId.batch.byBatch.indexingStatus.get({ + params: { dataset_id: datasetId, batch: batchId }, + }) } export const renameDocumentName = ({ datasetId, documentId, name, -}: CommonDocReq & { name: string }): Promise => { - return narrowResponse( - consoleClient.datasets.byDatasetId.documents.byDocumentId.rename.post({ - params: { dataset_id: datasetId, document_id: documentId }, - body: { name }, - }), - ) +}: CommonDocReq & { name: string }) => { + return consoleClient.datasets.byDatasetId.documents.byDocumentId.rename.post({ + params: { dataset_id: datasetId, document_id: documentId }, + body: { name }, + }) } export const pauseDocIndexing = ({ datasetId, documentId }: CommonDocReq) => { @@ -218,13 +142,6 @@ export const downloadDocumentsZip = ({ }) } -// hit testing -export const fetchFileIndexingEstimate = ( - body: IndexingEstimatePayload, -): Promise => { - return narrowResponse(consoleClient.datasets.indexingEstimate.post({ body })) -} - export const fetchNotionPagePreview = ({ pageID, pageType, @@ -233,7 +150,7 @@ export const fetchNotionPagePreview = ({ pageID: string pageType: string credentialID: string -}): Promise<{ content: string }> => { +}) => { return consoleClient.notion.pages.byPageId.byPageType.preview.get({ params: { page_id: pageID, page_type: pageType }, query: { credential_id: credentialID }, From b55011a55bcea5b20b498611b2e77024dcca9612 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:27:52 +0800 Subject: [PATCH 56/60] refactor(web): map tool service responses from the contract Remove narrowResponse from service/tools.ts. Collection and tool lists and the workflow tool detail go through the existing tool selectors, and the API schema parse and custom provider detail get their own: parsed operations keep an empty label when the schema gives no id or summary, and stored credentials are read by their auth type, which a provider saved without one does not have. The custom tool editor state drops the provider id the detail never returned and stores the parsed operations it actually holds. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/test-api.spec.tsx | 1 - .../__tests__/custom-create-card.spec.tsx | 1 - web/app/components/tools/types.ts | 3 +- .../utils/__tests__/query-select.spec.ts | 60 ++++++++++++++++++- .../components/tools/utils/query-select.ts | 51 ++++++++++++++++ web/service/tools.ts | 46 +++++++------- 6 files changed, 135 insertions(+), 27 deletions(-) diff --git a/web/app/components/tools/edit-custom-collection-modal/__tests__/test-api.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/test-api.spec.tsx index 138651011af8d0..5358a24180e290 100644 --- a/web/app/components/tools/edit-custom-collection-modal/__tests__/test-api.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/test-api.spec.tsx @@ -22,7 +22,6 @@ describe('TestApi', () => { icon: { background: '', content: '' }, privacy_policy: '', custom_disclaimer: '', - id: 'test-id', labels: [], } diff --git a/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx b/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx index 92233e1e645125..22943655008ed2 100644 --- a/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx +++ b/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx @@ -73,7 +73,6 @@ vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({ schema: '{}', privacy_policy: '', custom_disclaimer: '', - id: 'test-id', labels: [], }) }} diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index df76caa78c2ed4..54b4c151d00f25 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -180,8 +180,7 @@ export type CustomCollectionBackend = { schema: string privacy_policy: string custom_disclaimer: string - tools?: ParamItem[] - id: string + tools?: CustomParamSchema[] labels: string[] } diff --git a/web/app/components/tools/utils/__tests__/query-select.spec.ts b/web/app/components/tools/utils/__tests__/query-select.spec.ts index 798bcb075e8ecd..746ecb55b7780e 100644 --- a/web/app/components/tools/utils/__tests__/query-select.spec.ts +++ b/web/app/components/tools/utils/__tests__/query-select.spec.ts @@ -1,6 +1,11 @@ -import type { ToolProviderApiEntityResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { + ApiProviderDetailResponse, + ToolProviderApiEntityResponse, +} from '@dify/contracts/api/console/workspaces/types.gen' import { describe, expect, it } from 'vite-plus/test' import { + selectApiSchemaParameters, + selectCustomCollection, selectMCPServerDetail, selectRAGRecommendedPlugins, selectToolsWithProvider, @@ -154,4 +159,57 @@ describe('tool query selectors', () => { expect(recommendations.installed_recommended_plugins[0]!.tools).toEqual([]) expect(recommendations.uninstalled_recommended_plugins).toEqual([plugin]) }) + + it('should label parsed operations without an id or summary as empty', () => { + expect( + selectApiSchemaParameters({ + schema_type: 'openapi', + credentials_schema: [], + warning: {}, + parameters_schema: [ + { author: 'dify', method: 'get', openapi: {}, server_url: 'https://api.example.com' }, + ], + }).parameters_schema, + ).toEqual([ + { + operation_id: '', + summary: '', + server_url: 'https://api.example.com', + method: 'get', + parameters: [], + }, + ]) + }) + + it('should read stored API provider credentials by their auth type', () => { + const detail: ApiProviderDetailResponse = { + schema_type: 'openapi', + schema: '{}', + tools: [], + icon: { background: '#FFF', content: '🔧' }, + } + + expect( + selectCustomCollection({ + ...detail, + credentials: { + auth_type: 'api_key_header', + api_key_header: 'Authorization', + api_key_header_prefix: 'bearer', + api_key_value: '[__HIDDEN__]', + }, + }).credentials, + ).toEqual({ + auth_type: 'api_key_header', + api_key_header: 'Authorization', + api_key_header_prefix: 'bearer', + api_key_value: '[__HIDDEN__]', + api_key_query_param: undefined, + }) + expect(selectCustomCollection(detail)).toMatchObject({ + credentials: { auth_type: 'none' }, + privacy_policy: '', + custom_disclaimer: '', + }) + }) }) diff --git a/web/app/components/tools/utils/query-select.ts b/web/app/components/tools/utils/query-select.ts index 274034abeaebbd..2fe2d7ff12e3fe 100644 --- a/web/app/components/tools/utils/query-select.ts +++ b/web/app/components/tools/utils/query-select.ts @@ -1,6 +1,9 @@ import type { AppMcpServerResponse } from '@dify/contracts/api/console/apps/types.gen' import type { RagPipelineRecommendedPluginsResponse } from '@dify/contracts/api/console/rag/types.gen' import type { + ApiProviderDetailResponse, + ApiSchemaParseResponse, + ApiToolBundle, ToolParameter as ContractToolParameter, I18nObject, ToolApiEntity, @@ -12,6 +15,8 @@ import type { import type { MarketplacePlugin } from '@dify/contracts/marketplace' import type { Collection, + Credential, + CustomParamSchema, Emoji, MCPServerDetail, Tool, @@ -20,6 +25,7 @@ import type { WorkflowToolProviderResponse, } from '../types' import type { RAGRecommendedPlugins, ToolWithProvider } from '@/app/components/workflow/types' +import { AuthHeaderPrefix, AuthType } from '../types' type LocalizedText = Collection['label'] type ProviderIcon = ToolProviderApiEntityResponse['icon'] @@ -27,6 +33,14 @@ type ProviderIcon = ToolProviderApiEntityResponse['icon'] const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) +const isOneOf = (values: Record, value: unknown): value is T => + typeof value === 'string' && Object.values(values).includes(value) + +const getString = (record: Record, key: string) => { + const value = record[key] + return typeof value === 'string' ? value : undefined +} + // Untranslated locales fall back to en_US, matching renderI18nObject. const toLocalizedText = ({ en_US, ja_JP, pt_BR, zh_Hans }: I18nObject): LocalizedText => ({ en_US, @@ -180,3 +194,40 @@ export const selectRAGRecommendedPlugins = ( installed_recommended_plugins: data.installed_recommended_plugins.map(selectToolProvider), uninstalled_recommended_plugins: data.uninstalled_recommended_plugins.filter(isMarketplacePlugin), }) + +// OpenAPI operations without an id or summary leave those labels empty in the editor. +const toCustomParamSchema = (bundle: ApiToolBundle): CustomParamSchema => ({ + operation_id: bundle.operation_id ?? '', + summary: bundle.summary ?? '', + server_url: bundle.server_url, + method: bundle.method, + parameters: (bundle.parameters ?? []).map(toToolParameter), +}) + +export const selectApiSchemaParameters = (data: ApiSchemaParseResponse) => ({ + schema_type: data.schema_type, + parameters_schema: data.parameters_schema.map(toCustomParamSchema), +}) + +// Stored API tool credentials hold the fields of their auth type. A provider saved without an +// auth type sends no credentials. +const toCredential = (credentials: Record): Credential => ({ + auth_type: isOneOf(AuthType, credentials.auth_type) ? credentials.auth_type : AuthType.none, + api_key_header: getString(credentials, 'api_key_header'), + api_key_value: getString(credentials, 'api_key_value'), + api_key_header_prefix: isOneOf(AuthHeaderPrefix, credentials.api_key_header_prefix) + ? credentials.api_key_header_prefix + : undefined, + api_key_query_param: getString(credentials, 'api_key_query_param'), +}) + +/** The custom tool editor state for a stored API provider. */ +export const selectCustomCollection = (detail: ApiProviderDetailResponse) => ({ + credentials: toCredential(detail.credentials ?? {}), + icon: detail.icon, + schema_type: detail.schema_type, + schema: detail.schema, + privacy_policy: detail.privacy_policy ?? '', + custom_disclaimer: detail.custom_disclaimer ?? '', + tools: detail.tools.map(toCustomParamSchema), +}) diff --git a/web/service/tools.ts b/web/service/tools.ts index 69eea1e0202675..31e7f5323ea20a 100644 --- a/web/service/tools.ts +++ b/web/service/tools.ts @@ -1,18 +1,20 @@ import type { - Collection, Credential, CustomCollectionBackend, - CustomParamSchema, - Tool, ToolCredential, WorkflowToolProviderParameter, WorkflowToolProviderRequest, - WorkflowToolProviderResponse, } from '@/app/components/tools/types' +import { + selectApiSchemaParameters, + selectBuiltinProviderTools, + selectCustomCollection, + selectToolCollections, + selectWorkflowToolDetail, +} from '@/app/components/tools/utils/query-select' // oxlint-disable-next-line no-restricted-imports -- legacy built-in credential flow, see below import { get, post } from './base' import { consoleClient } from './console' -import { narrowResponse } from './console/narrow-response' const toolProvider = consoleClient.workspaces.current.toolProvider @@ -32,23 +34,25 @@ const toWorkflowToolParameters = (parameters: WorkflowToolProviderParameter[]) = const toSchemaType = (schemaType: string) => schemaType as ApiToolSchemaType export const fetchCollectionList = () => { - return narrowResponse(consoleClient.workspaces.current.toolProviders.get({})) + return consoleClient.workspaces.current.toolProviders.get({}).then(selectToolCollections) } export const fetchBuiltInToolList = (collectionName: string) => { - return narrowResponse( - toolProvider.builtin.byProvider.tools.get({ params: { provider: collectionName } }), - ) + return toolProvider.builtin.byProvider.tools + .get({ params: { provider: collectionName } }) + .then(selectBuiltinProviderTools) } export const fetchCustomToolList = (collectionName: string) => { - return narrowResponse(toolProvider.api.tools.get({ query: { provider: collectionName } })) + return toolProvider.api.tools + .get({ query: { provider: collectionName } }) + .then(selectBuiltinProviderTools) } export const fetchWorkflowToolList = (appID: string) => { - return narrowResponse( - toolProvider.workflow.tools.get({ query: { workflow_tool_id: appID } }), - ) + return toolProvider.workflow.tools + .get({ query: { workflow_tool_id: appID } }) + .then(selectBuiltinProviderTools) } // Not migrated to the console contract: this legacy built-in credential flow predates @@ -86,15 +90,13 @@ export const removeBuiltInToolCredential = (collectionName: string) => { } export const parseParamsSchema = (schema: string) => { - return narrowResponse<{ parameters_schema: CustomParamSchema[]; schema_type: string }>( - toolProvider.api.schema.post({ body: { schema } }), - ) + return toolProvider.api.schema.post({ body: { schema } }).then(selectApiSchemaParameters) } export const fetchCustomCollection = (collectionName: string) => { - return narrowResponse( - toolProvider.api.get.get({ query: { provider: collectionName } }), - ) + return toolProvider.api.get + .get({ query: { provider: collectionName } }) + .then(selectCustomCollection) } export const createCustomCollection = (collection: CustomCollectionBackend) => { @@ -162,9 +164,9 @@ export const saveWorkflowToolProvider = ( } export const fetchWorkflowToolDetail = (toolID: string) => { - return narrowResponse( - toolProvider.workflow.get.get({ query: { workflow_tool_id: toolID } }), - ) + return toolProvider.workflow.get + .get({ query: { workflow_tool_id: toolID } }) + .then(selectWorkflowToolDetail) } export const deleteWorkflowTool = (toolID: string) => { From f531912b6c23bb02e5b231d7618cd461808e3d71 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:31:28 +0800 Subject: [PATCH 57/60] chore(web): drop test-only DocumentIndexingStatus and isLegacyDataSourceInfo Co-Authored-By: Claude Opus 5.5 --- .../embedding-process/__tests__/utils.spec.ts | 35 ++----------------- .../create/embedding-process/utils.ts | 14 +------- .../__tests__/index.spec.tsx | 8 ++--- web/models/datasets.ts | 17 --------- 4 files changed, 7 insertions(+), 67 deletions(-) diff --git a/web/app/components/datasets/create/embedding-process/__tests__/utils.spec.ts b/web/app/components/datasets/create/embedding-process/__tests__/utils.spec.ts index 35e2da78e6e134..a100a89fc09e4c 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/utils.spec.ts +++ b/web/app/components/datasets/create/embedding-process/__tests__/utils.spec.ts @@ -1,37 +1,6 @@ -import type { - DataSourceInfo, - IndexingStatusResponse, - InitialDocumentDetail, -} from '@/models/datasets' +import type { IndexingStatusResponse, InitialDocumentDetail } from '@/models/datasets' import { describe, expect, it } from 'vite-plus/test' -import { - createDocumentLookup, - getFileType, - getSourcePercent, - isLegacyDataSourceInfo, - isSourceEmbedding, -} from '../utils' - -describe('isLegacyDataSourceInfo', () => { - it('should return true when upload_file object exists', () => { - const info = { upload_file: { id: '1', name: 'test.pdf' } } as unknown as DataSourceInfo - expect(isLegacyDataSourceInfo(info)).toBe(true) - }) - - it('should return false when upload_file is absent', () => { - const info = { notion_page_icon: 'icon' } as unknown as DataSourceInfo - expect(isLegacyDataSourceInfo(info)).toBe(false) - }) - - it('should return false for null', () => { - expect(isLegacyDataSourceInfo(null as unknown as DataSourceInfo)).toBe(false) - }) - - it('should return false when upload_file is a string', () => { - const info = { upload_file: 'not-an-object' } as unknown as DataSourceInfo - expect(isLegacyDataSourceInfo(info)).toBe(false) - }) -}) +import { createDocumentLookup, getFileType, getSourcePercent, isSourceEmbedding } from '../utils' describe('isSourceEmbedding', () => { const embeddingStatuses = ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting'] diff --git a/web/app/components/datasets/create/embedding-process/utils.ts b/web/app/components/datasets/create/embedding-process/utils.ts index 73ac7d5dd750a1..3b7e22491740e8 100644 --- a/web/app/components/datasets/create/embedding-process/utils.ts +++ b/web/app/components/datasets/create/embedding-process/utils.ts @@ -1,21 +1,9 @@ -import type { - DataSourceInfo, - IndexingStatusResponse, - InitialDocumentDetail, - LegacyDataSourceInfo, -} from '@/models/datasets' +import type { IndexingStatusResponse, InitialDocumentDetail } from '@/models/datasets' import { toCreationDataSourceType } from '@/app/components/datasets/dataset-fields' import { getNotionPageIcon } from '@/app/components/datasets/documents/document-source' const EMBEDDING_STATUSES = ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting'] as const -/** - * Type guard for legacy data source info with upload_file property - */ -export const isLegacyDataSourceInfo = (info: DataSourceInfo): info is LegacyDataSourceInfo => { - return info != null && typeof (info as LegacyDataSourceInfo).upload_file === 'object' -} - /** * Check if a status indicates the source is being embedded */ diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx index 6a2a51f8e4dd3b..ada869aa1e8053 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen' import type { ReactElement } from 'react' import type { Mock } from 'vite-plus/test' -import type { DocumentIndexingStatus, IndexingStatusResponse } from '@/models/datasets' +import type { IndexingStatusResponse } from '@/models/datasets' import type { InitialDocumentDetail } from '@/models/pipeline' import { QueryClient } from '@tanstack/react-query' import { fireEvent, screen, waitFor } from '@testing-library/react' @@ -92,7 +92,7 @@ const createMockDocument = ( data_source_info: {}, enabled: true, error: '', - indexing_status: 'waiting' as DocumentIndexingStatus, + indexing_status: 'waiting' as IndexingStatusResponse['indexing_status'], position: 0, ...overrides, }) @@ -104,7 +104,7 @@ const createMockIndexingStatus = ( overrides: Partial = {}, ): IndexingStatusResponse => ({ id: `doc-${Math.random().toString(36).slice(2, 9)}`, - indexing_status: 'waiting' as DocumentIndexingStatus, + indexing_status: 'waiting' as IndexingStatusResponse['indexing_status'], processing_started_at: Date.now(), parsing_completed_at: 0, cleaning_completed_at: 0, @@ -897,7 +897,7 @@ describe('EmbeddingProcess', () => { mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', - indexing_status: undefined as unknown as DocumentIndexingStatus, + indexing_status: undefined as unknown as IndexingStatusResponse['indexing_status'], }), ] const props = createDefaultProps({ documents: [doc1] }) diff --git a/web/models/datasets.ts b/web/models/datasets.ts index da0a4a734c9874..6b9a6699b8fd0b 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -172,16 +172,6 @@ type Segmentation = { chunk_overlap?: number } -export type DocumentIndexingStatus = - | 'waiting' - | 'parsing' - | 'cleaning' - | 'splitting' - | 'indexing' - | 'paused' - | 'error' - | 'completed' - export const DisplayStatusList = [ 'queuing', 'indexing', @@ -254,13 +244,6 @@ export type UploadFileIdInfo = { upload_file_id: string } -export type DataSourceInfo = - | LegacyDataSourceInfo - | LocalFileInfo - | OnlineDocumentInfo - | WebsiteCrawlInfo - | UploadFileIdInfo - export type InitialDocumentDetail = ContractDocumentResponse // A document in the dataset document list. From 804b71bc242155ecef23fae56d464a6187be5781 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:41:54 +0800 Subject: [PATCH 58/60] refactor(web): type plugin service calls by the contract Remove narrowResponse from service/plugins.ts and service/use-plugins.ts. Install, upgrade, upload and task responses use the generated types: the plugin task and status UI types derive from the contract and add only the task id the task list attaches, which the task list query now attaches too instead of assuming it. Dynamic parameter options are mapped to form options, with untranslated labels falling back to en_US as renderI18nObject does. The uploaded package card comes from a mapper for the contract manifest (pluginDeclarationToCardPluginProps), and plugin card text drops untranslated locales through toPluginText. Co-Authored-By: Claude Opus 5.5 --- .../install-plugin/__tests__/utils.spec.ts | 41 +++++ .../install-plugin/base/check-task-status.ts | 5 +- .../item/__tests__/github-item.spec.tsx | 12 +- .../install-bundle/item/github-item.tsx | 5 +- .../plugins/install-plugin/utils.ts | 45 ++++++ .../plugin-tasks/__tests__/index.spec.tsx | 10 +- .../components/error-plugin-item.tsx | 3 +- ...nstallation-task-clearing.browser.spec.tsx | 2 +- web/app/components/plugins/types.ts | 49 +----- web/service/__tests__/use-plugins.spec.tsx | 2 - web/service/plugins.ts | 54 +++---- web/service/use-plugins.ts | 148 +++++++++--------- 12 files changed, 203 insertions(+), 173 deletions(-) diff --git a/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts b/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts index 9c6dc2331782b4..f27ff67e83f8af 100644 --- a/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts +++ b/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts @@ -4,8 +4,10 @@ import { PluginCategoryEnum } from '../../types' import { convertRepoToUrl, parseGitHubUrl, + pluginDeclarationToCardPluginProps, pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps, + toPluginText, } from '../utils' // Mock es-toolkit/compat @@ -199,6 +201,45 @@ describe('pluginManifestToCardPluginProps', () => { }) }) +describe('pluginDeclarationToCardPluginProps', () => { + const manifest = { + author: null, + category: 'agent-strategy', + created_at: '2026-01-01T00:00:00Z', + description: { en_US: 'Description', zh_Hans: null }, + icon: 'icon.svg', + label: { en_US: 'Agent', zh_Hans: '代理' }, + meta: { minimum_dify_version: null, version: '0.0.1' }, + name: 'agent', + plugins: {}, + resource: { memory: 0 }, + version: '1.0.0', + } satisfies Parameters[0] + + it('reads the uploaded package as a card', () => { + expect( + pluginDeclarationToCardPluginProps(manifest, 'langgenius/agent:1.0.0@hash'), + ).toMatchObject({ + plugin_id: 'langgenius/agent:1.0.0@hash', + type: 'agent_strategy', + category: 'agent-strategy', + org: '', + author: undefined, + label: { en_US: 'Agent', zh_Hans: '代理' }, + description: { en_US: 'Description' }, + verified: false, + tags: [], + }) + }) + + it('leaves untranslated locales out of card text', () => { + expect(toPluginText({ en_US: 'Name', ja_JP: null, pt_BR: 'Nome' })).toEqual({ + en_US: 'Name', + pt_BR: 'Nome', + }) + }) +}) + describe('pluginManifestInMarketToPluginProps', () => { const createMockPluginManifestInMarket = ( overrides?: Partial, diff --git a/web/app/components/plugins/install-plugin/base/check-task-status.ts b/web/app/components/plugins/install-plugin/base/check-task-status.ts index 9206aa727a11a6..e03c64eee861bd 100644 --- a/web/app/components/plugins/install-plugin/base/check-task-status.ts +++ b/web/app/components/plugins/install-plugin/base/check-task-status.ts @@ -1,4 +1,3 @@ -import type { PluginStatus } from '../../types' import { checkTaskStatus as fetchCheckTaskStatus } from '@/service/plugins' import { sleep } from '@/utils' import { TaskStatus } from '../../types' @@ -24,9 +23,7 @@ function checkTaskStatus() { } const res = await fetchCheckTaskStatus(taskId) const { plugins } = res.task - const plugin = plugins.find( - (p: PluginStatus) => p.plugin_unique_identifier === pluginUniqueIdentifier, - ) + const plugin = plugins.find((p) => p.plugin_unique_identifier === pluginUniqueIdentifier) if (!plugin) { nextStatus = TaskStatus.failed return { diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx index 4f97a93443c93f..6e8e41a8b8f93c 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import GithubItem from '../github-item' const mockUseUploadGitHub = vi.fn() -const mockPluginManifestToCardPluginProps = vi.fn() +const mockPluginDeclarationToCardPluginProps = vi.fn() const mockLoadedItem = vi.fn() vi.mock('@/service/use-plugins', () => ({ @@ -14,8 +14,8 @@ vi.mock('@/service/use-plugins', () => ({ })) vi.mock('../../../utils', () => ({ - pluginManifestToCardPluginProps: (manifest: unknown) => - mockPluginManifestToCardPluginProps(manifest), + pluginDeclarationToCardPluginProps: (manifest: unknown, uniqueIdentifier: string) => + mockPluginDeclarationToCardPluginProps(manifest, uniqueIdentifier), })) vi.mock('../../../base/loading', () => ({ @@ -88,7 +88,7 @@ describe('GithubItem', () => { }, error: null, }) - mockPluginManifestToCardPluginProps.mockReturnValue(payload) + mockPluginDeclarationToCardPluginProps.mockReturnValue(payload) render( { ) await waitFor(() => { + expect(mockPluginDeclarationToCardPluginProps).toHaveBeenCalledWith( + { name: 'manifest' }, + 'plugin-1', + ) expect(onFetchedPayload).toHaveBeenCalledWith(payload) expect(screen.getByTestId('loaded-item')).toBeInTheDocument() }) diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/github-item.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/github-item.tsx index e2cf39e537ba3a..a66a82c2f33832 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/item/github-item.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/item/github-item.tsx @@ -5,7 +5,7 @@ import * as React from 'react' import { useEffect } from 'react' import { useUploadGitHub } from '@/service/use-plugins' import Loading from '../../base/loading' -import { pluginManifestToCardPluginProps } from '../../utils' +import { pluginDeclarationToCardPluginProps } from '../../utils' import LoadedItem from './loaded-item' type Props = Readonly<{ @@ -35,8 +35,7 @@ const Item: FC = ({ useEffect(() => { if (data) { const payload = { - ...pluginManifestToCardPluginProps(data.manifest), - plugin_id: data.unique_identifier, + ...pluginDeclarationToCardPluginProps(data.manifest, data.unique_identifier), } onFetchedPayload(payload) setPayload({ ...payload, from: dependency.type }) diff --git a/web/app/components/plugins/install-plugin/utils.ts b/web/app/components/plugins/install-plugin/utils.ts index 3d0405e4246904..38a18b03a340c4 100644 --- a/web/app/components/plugins/install-plugin/utils.ts +++ b/web/app/components/plugins/install-plugin/utils.ts @@ -1,3 +1,7 @@ +import type { + I18nObject, + PluginDeclarationResponse, +} from '@dify/contracts/api/console/workspaces/types.gen' import type { GitHubUrlInfo, Plugin, PluginDeclaration, PluginManifestInMarket } from '../types' import { isEmpty } from 'es-toolkit/compat' @@ -31,6 +35,47 @@ export const pluginManifestToCardPluginProps = (pluginManifest: PluginDeclaratio } } +/** Plugin card text from a translated object; untranslated locales are left out. */ +export const toPluginText = ({ en_US, zh_Hans, ja_JP, pt_BR }: I18nObject): Plugin['label'] => ({ + en_US, + ...(zh_Hans != null && { zh_Hans }), + ...(ja_JP != null && { ja_JP }), + ...(pt_BR != null && { pt_BR }), +}) + +/** Card props for the manifest of a package the console has just uploaded. */ +export const pluginDeclarationToCardPluginProps = ( + manifest: PluginDeclarationResponse, + uniqueIdentifier: string, +): Plugin => ({ + plugin_id: uniqueIdentifier, + type: manifest.category === 'agent-strategy' ? 'agent_strategy' : manifest.category, + category: manifest.category, + name: manifest.name, + version: manifest.version, + latest_version: '', + latest_package_identifier: '', + // A manifest without an author shows no organization. + org: manifest.author ?? '', + author: manifest.author ?? undefined, + label: toPluginText(manifest.label), + brief: toPluginText(manifest.description), + description: toPluginText(manifest.description), + icon: manifest.icon, + icon_dark: manifest.icon_dark ?? undefined, + verified: manifest.verified ?? false, + introduction: '', + repository: '', + install_count: 0, + endpoint: { + settings: [], + }, + tags: (manifest.tags ?? []).map((tag) => ({ name: tag })), + badges: [], + verification: { authorized_category: 'langgenius' }, + from: 'package', +}) + export const pluginManifestInMarketToPluginProps = ( pluginManifest: PluginManifestInMarket, ): Plugin => { diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx index 62299d05c68fc5..3720a0912d072f 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx @@ -33,7 +33,7 @@ const createMockPlugin = (overrides: Partial = {}): PluginStatus = labels: { en_US: 'Test Plugin', zh_Hans: '测试插件', - } as Record, + }, taskId: 'task-1', ...overrides, }) @@ -482,7 +482,7 @@ describe('PluginTaskList Component', () => { it('should display plugin name from labels', () => { const plugin = createMockPlugin({ status: TaskStatus.running, - labels: { en_US: 'My Test Plugin' } as Record, + labels: { en_US: 'My Test Plugin' }, }) render() @@ -505,11 +505,11 @@ describe('PluginTaskList Component', () => { const runningPlugins = [ createMockPlugin({ status: TaskStatus.running, - labels: { en_US: 'Plugin A' } as Record, + labels: { en_US: 'Plugin A' }, }), createMockPlugin({ status: TaskStatus.running, - labels: { en_US: 'Plugin B' } as Record, + labels: { en_US: 'Plugin B' }, }), ] @@ -780,7 +780,7 @@ describe('PluginTasks Component', () => { it('should handle plugins with empty labels', () => { const plugin = createMockPlugin({ status: TaskStatus.running, - labels: {} as Record, + labels: { en_US: '' }, }) setupMocks([plugin]) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx index 2dd9c1565fc7a6..6ff9e169bad9fb 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next' import { PluginInstallPermissionProvider } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider' import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' +import { toPluginText } from '@/app/components/plugins/install-plugin/utils' import { PluginSource } from '@/app/components/plugins/types' import { fetchPluginInfoFromMarketPlace } from '@/service/plugins' import PluginItem from './plugin-item' @@ -46,7 +47,7 @@ const ErrorPluginItem: FC = ({ plugin, getIconUrl, languag version: info.latest_version, latest_version: info.latest_version, latest_package_identifier: info.latest_package_identifier, - label: plugin.labels, + label: toPluginText(plugin.labels), brief: {}, description: {}, icon: plugin.icon, diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/installation-task-clearing.browser.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/installation-task-clearing.browser.spec.tsx index 44c16b72074052..46c28d2324972f 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/installation-task-clearing.browser.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/installation-task-clearing.browser.spec.tsx @@ -18,7 +18,7 @@ vi.mock('@/service/use-plugins', () => ({ status: TaskStatus.success, message: '', icon: '', - labels: { en_US: 'Installed Plugin' } as Record, + labels: { en_US: 'Installed Plugin' }, } satisfies Omit, ], }, diff --git a/web/app/components/plugins/types.ts b/web/app/components/plugins/types.ts index 35528abaeac056..895a9e68adc7c6 100644 --- a/web/app/components/plugins/types.ts +++ b/web/app/components/plugins/types.ts @@ -2,6 +2,9 @@ import type { AgentStrategyProviderEntity, DatasourceProviderEntity, EndpointProviderDeclarationResponse, + PluginInstallTask, + PluginInstallTaskPluginStatus, + PluginInstallTaskStartResponse, } from '@dify/contracts/api/console/workspaces/types.gen' import type { FormTypeEnum } from '../base/form/types' import type { CredentialFormSchemaBase } from '../header/account-setting/model-provider-page/declarations' @@ -350,12 +353,7 @@ export type GitHubRepoReleaseResponse = { assets: GitHubAsset[] } -export type InstallPackageResponse = { - plugin_unique_identifier?: string - all_installed: boolean - task_id: string - task?: PluginTaskStart -} +export type InstallPackageResponse = PluginInstallTaskStartResponse export type InstallStatusResponse = { status: TaskStatus @@ -368,17 +366,6 @@ export type InstallStatus = { isFromMarketPlace?: boolean } -export type updatePackageResponse = { - all_installed: boolean - task_id: string - task?: PluginTaskStart -} - -export type uploadGitHubResponse = { - unique_identifier: string - manifest: PluginDeclaration -} - export const TaskStatus = { pending: 'pending', running: 'running', @@ -388,34 +375,14 @@ export const TaskStatus = { export type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus] -export type PluginStatus = { - plugin_unique_identifier: string - plugin_id: string - source: PluginSource - status: TaskStatus - message: string - icon: string - labels: Record +export type PluginStatus = PluginInstallTaskPluginStatus & { + // The task the status belongs to, added when the task list is read. taskId: string } -export type PluginTask = { - id: string - created_at: string - updated_at: string - status: string - total_plugins: number - completed_plugins: number - plugins: PluginStatus[] -} - -export type PluginTaskStart = Omit & { - plugins: Array & { taskId?: string }> -} +export type PluginTask = Omit & { plugins: PluginStatus[] } -export type TaskStatusResponse = { - task: PluginTask -} +export type PluginTaskStart = PluginInstallTask export type MetaData = { repo: string diff --git a/web/service/__tests__/use-plugins.spec.tsx b/web/service/__tests__/use-plugins.spec.tsx index 33d2e9973a9bd2..80f22835aeb230 100644 --- a/web/service/__tests__/use-plugins.spec.tsx +++ b/web/service/__tests__/use-plugins.spec.tsx @@ -1175,7 +1175,6 @@ describe('usePluginTaskList', () => { result.current.handleInstallTaskStart({ all_installed: false, task_id: 'task-new', - plugin_unique_identifier: 'langgenius/gitlab_datasource:0.3.11@test', task: startedTask, }) }) @@ -1233,7 +1232,6 @@ describe('usePluginTaskList', () => { result.current.handleInstallTaskStart({ all_installed: false, task_id: 'task-new', - plugin_unique_identifier: 'langgenius/gitlab_datasource:0.3.11@test', task: { id: 'task-new', created_at: '2026-06-05T03:34:59.578653Z', diff --git a/web/service/plugins.ts b/web/service/plugins.ts index bb2f0d1184ff19..d55fdde02289c0 100644 --- a/web/service/plugins.ts +++ b/web/service/plugins.ts @@ -1,16 +1,8 @@ import type { ParserMarketplaceUpgrade } from '@dify/contracts/api/console/workspaces/types.gen' import type { PluginInfoFromMarketPlace } from '@dify/contracts/marketplace' -import type { - Dependency, - InstallPackageResponse, - PluginManifestInMarket, - TaskStatusResponse, - updatePackageResponse, - uploadGitHubResponse, -} from '@/app/components/plugins/types' +import type { Dependency, PluginManifestInMarket } from '@/app/components/plugins/types' import { consoleClient } from '@/service/console' import { getMarketplace, upload } from './base' -import { narrowResponse } from './console/narrow-response' export const uploadFile = async (file: File, isBundle: boolean) => { const formData = new FormData() @@ -26,9 +18,7 @@ export const uploadFile = async (file: File, isBundle: boolean) => { } export const updateFromMarketPlace = async (body: ParserMarketplaceUpgrade) => { - return narrowResponse( - consoleClient.workspaces.current.plugin.upgrade.marketplace.post({ body }), - ) + return consoleClient.workspaces.current.plugin.upgrade.marketplace.post({ body }) } export const updateFromGitHub = async ( @@ -38,17 +28,15 @@ export const updateFromGitHub = async ( originalPlugin: string, newPlugin: string, ) => { - return narrowResponse( - consoleClient.workspaces.current.plugin.upgrade.github.post({ - body: { - repo: repoUrl, - version: selectedVersion, - package: selectedPackage, - original_plugin_unique_identifier: originalPlugin, - new_plugin_unique_identifier: newPlugin, - }, - }), - ) + return consoleClient.workspaces.current.plugin.upgrade.github.post({ + body: { + repo: repoUrl, + version: selectedVersion, + package: selectedPackage, + original_plugin_unique_identifier: originalPlugin, + new_plugin_unique_identifier: newPlugin, + }, + }) } export const uploadGitHub = async ( @@ -56,15 +44,13 @@ export const uploadGitHub = async ( selectedVersion: string, selectedPackage: string, ) => { - return narrowResponse( - consoleClient.workspaces.current.plugin.upload.github.post({ - body: { - repo: repoUrl, - version: selectedVersion, - package: selectedPackage, - }, - }), - ) + return consoleClient.workspaces.current.plugin.upload.github.post({ + body: { + repo: repoUrl, + version: selectedVersion, + package: selectedPackage, + }, + }) } export const fetchManifestFromMarketPlace = async (uniqueIdentifier: string) => { @@ -90,9 +76,7 @@ export const fetchPluginInfoFromMarketPlace = async ({ org, name }: Record { - return narrowResponse( - consoleClient.workspaces.current.plugin.tasks.byTaskId.get({ params: { task_id: taskId } }), - ) + return consoleClient.workspaces.current.plugin.tasks.byTaskId.get({ params: { task_id: taskId } }) } export const uninstallPlugin = async ( diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 4eda2c81aaa3bd..a31725a7007b2d 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -6,6 +6,7 @@ import type { PluginEntity, PluginInstallationItemResponse, PluginListResponse, + PluginParameterOption, } from '@dify/contracts/api/console/workspaces/types.gen' import type { PluginInfoFromMarketPlace, @@ -29,7 +30,6 @@ import type { PluginTask, PluginTaskStart, ReferenceSetting, - uploadGitHubResponse, VersionInfo, VersionListResponse, } from '@/app/components/plugins/types' @@ -49,7 +49,6 @@ import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' import { PluginCategoryEnum, PluginSource, TaskStatus } from '@/app/components/plugins/types' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { consoleClient, consoleQuery } from '@/service/console' -import { narrowResponse } from '@/service/console/narrow-response' import { uninstallPlugin } from '@/service/plugins' import { hasPermission } from '@/utils/permission' // oxlint-disable-next-line no-restricted-imports @@ -483,11 +482,12 @@ export const normalizeInstalledPluginDetail = (plugin: InstalledPluginResponse): const isUnfinishedPluginTask = (task: PluginTask) => task.status === TaskStatus.pending || task.status === TaskStatus.running +// Each plugin status records its task so a single status can be cleared. const normalizeStartedPluginTask = (task: PluginTaskStart): PluginTask => ({ ...task, plugins: task.plugins.map((plugin) => ({ ...plugin, - taskId: plugin.taskId || task.id, + taskId: task.id, })), }) @@ -812,11 +812,9 @@ export const useInstallPackageFromMarketPlace = ( return useMutation({ ...options, mutationFn: (uniqueIdentifier: string) => { - return narrowResponse( - consoleClient.workspaces.current.plugin.install.marketplace.post({ - body: { plugin_unique_identifiers: [uniqueIdentifier] }, - }), - ) + return consoleClient.workspaces.current.plugin.install.marketplace.post({ + body: { plugin_unique_identifiers: [uniqueIdentifier] }, + }) }, onSuccess: (data, variables, context, mutation) => { upsertStartedPluginTask(queryClient, data) @@ -832,11 +830,9 @@ export const useUpdatePackageFromMarketPlace = ( return useMutation({ ...options, mutationFn: (body: ParserMarketplaceUpgrade) => { - return narrowResponse( - consoleClient.workspaces.current.plugin.upgrade.marketplace.post({ - body, - }), - ) + return consoleClient.workspaces.current.plugin.upgrade.marketplace.post({ + body, + }) }, onSuccess: (data, variables, context, mutation) => { upsertStartedPluginTask(queryClient, data) @@ -849,11 +845,9 @@ export const usePluginDeclarationFromMarketPlace = (pluginUniqueIdentifier: stri return useQuery({ queryKey: [NAME_SPACE, 'pluginDeclaration', pluginUniqueIdentifier], queryFn: () => - narrowResponse<{ manifest: PluginDeclaration }>( - consoleClient.workspaces.current.plugin.marketplace.pkg.get({ - query: { plugin_unique_identifier: pluginUniqueIdentifier }, - }), - ), + consoleClient.workspaces.current.plugin.marketplace.pkg.get({ + query: { plugin_unique_identifier: pluginUniqueIdentifier }, + }), enabled: !!pluginUniqueIdentifier, }) } @@ -873,11 +867,9 @@ export const useInstallPackageFromLocal = () => { const queryClient = useQueryClient() return useMutation({ mutationFn: (uniqueIdentifier: string) => { - return narrowResponse( - consoleClient.workspaces.current.plugin.install.pkg.post({ - body: { plugin_unique_identifiers: [uniqueIdentifier] }, - }), - ) + return consoleClient.workspaces.current.plugin.install.pkg.post({ + body: { plugin_unique_identifiers: [uniqueIdentifier] }, + }) }, onSuccess: (data) => { upsertStartedPluginTask(queryClient, data) @@ -899,16 +891,14 @@ export const useInstallPackageFromGitHub = () => { selectedPackage: string uniqueIdentifier: string }) => { - return narrowResponse( - consoleClient.workspaces.current.plugin.install.github.post({ - body: { - repo: repoUrl, - version: selectedVersion, - package: selectedPackage, - plugin_unique_identifier: uniqueIdentifier, - }, - }), - ) + return consoleClient.workspaces.current.plugin.install.github.post({ + body: { + repo: repoUrl, + version: selectedVersion, + package: selectedPackage, + plugin_unique_identifier: uniqueIdentifier, + }, + }) }, onSuccess: (data) => { upsertStartedPluginTask(queryClient, data) @@ -920,11 +910,9 @@ export const useUploadGitHub = (payload: { repo: string; version: string; packag return useQuery({ queryKey: [NAME_SPACE, 'uploadGitHub', payload], queryFn: () => - narrowResponse( - consoleClient.workspaces.current.plugin.upload.github.post({ - body: payload, - }), - ), + consoleClient.workspaces.current.plugin.upload.github.post({ + body: payload, + }), retry: 0, }) } @@ -960,15 +948,14 @@ export const useInstallOrUpdate = ({ // From local bundle don't have data.value.github_plugin_unique_identifier uniqueIdentifier = data.value.github_plugin_unique_identifier! if (!uniqueIdentifier) { - const { unique_identifier } = await narrowResponse( - consoleClient.workspaces.current.plugin.upload.github.post({ + const { unique_identifier } = + await consoleClient.workspaces.current.plugin.upload.github.post({ body: { repo: data.value.repo!, version: data.value.release! || data.value.version!, package: data.value.packages! || data.value.package!, }, - }), - ) + }) uniqueIdentifier = data.value.github_plugin_unique_identifier! || unique_identifier // has the same version, but not installed if (uniqueIdentifier === installedPayload?.uniqueIdentifier) { @@ -980,16 +967,14 @@ export const useInstallOrUpdate = ({ } } if (!isInstalled) { - const response = await narrowResponse( - consoleClient.workspaces.current.plugin.install.github.post({ - body: { - repo: data.value.repo!, - version: data.value.release! || data.value.version!, - package: data.value.packages! || data.value.package!, - plugin_unique_identifier: uniqueIdentifier, - }, - }), - ) + const response = await consoleClient.workspaces.current.plugin.install.github.post({ + body: { + repo: data.value.repo!, + version: data.value.release! || data.value.version!, + package: data.value.packages! || data.value.package!, + plugin_unique_identifier: uniqueIdentifier, + }, + }) upsertStartedPluginTask(queryClient, response) const { task_id, all_installed } = response taskId = task_id @@ -1010,13 +995,12 @@ export const useInstallOrUpdate = ({ } } if (!isInstalled) { - const response = await narrowResponse( - consoleClient.workspaces.current.plugin.install.marketplace.post({ + const response = + await consoleClient.workspaces.current.plugin.install.marketplace.post({ body: { plugin_unique_identifiers: [uniqueIdentifier], }, - }), - ) + }) upsertStartedPluginTask(queryClient, response) const { task_id, all_installed } = response taskId = task_id @@ -1034,13 +1018,11 @@ export const useInstallOrUpdate = ({ } } if (!isInstalled) { - const response = await narrowResponse( - consoleClient.workspaces.current.plugin.install.pkg.post({ - body: { - plugin_unique_identifiers: [uniqueIdentifier], - }, - }), - ) + const response = await consoleClient.workspaces.current.plugin.install.pkg.post({ + body: { + plugin_unique_identifiers: [uniqueIdentifier], + }, + }) upsertStartedPluginTask(queryClient, response) const { task_id, all_installed } = response taskId = task_id @@ -1050,13 +1032,11 @@ export const useInstallOrUpdate = ({ if (isInstalled) { if (item.type === 'package') { await uninstallPlugin(installedPayload.installedId, { preserveCredentials: true }) - const response = await narrowResponse( - consoleClient.workspaces.current.plugin.install.pkg.post({ - body: { - plugin_unique_identifiers: [uniqueIdentifier], - }, - }), - ) + const response = await consoleClient.workspaces.current.plugin.install.pkg.post({ + body: { + plugin_unique_identifiers: [uniqueIdentifier], + }, + }) upsertStartedPluginTask(queryClient, response) const { task_id, all_installed } = response taskId = task_id @@ -1424,9 +1404,9 @@ export const usePluginTaskList = (category?: PluginCategoryEnum | string) => { enabled: canManagement, queryKey: usePluginTaskListKey, queryFn: () => - narrowResponse<{ tasks: PluginTask[] }>( - consoleClient.workspaces.current.plugin.tasks.get({ query: { page: 1, page_size: 100 } }), - ), + consoleClient.workspaces.current.plugin.tasks + .get({ query: { page: 1, page_size: 100 } }) + .then(({ tasks }) => ({ tasks: tasks.map(normalizeStartedPluginTask) })), structuralSharing: (previousData, nextData) => preserveLocalUnfinishedPluginTasks( previousData as PluginTaskListResponse | undefined, @@ -1509,6 +1489,20 @@ export const usePluginManifestInfo = (pluginUID: string) => { }) } +// Untranslated labels fall back to en_US, matching renderI18nObject. Dynamic options have no +// display conditions. +const toDynamicOption = ({ value, label, icon }: PluginParameterOption): FormOption => ({ + value, + label: { + en_US: label.en_US, + zh_Hans: label.zh_Hans ?? label.en_US, + ...(label.ja_JP != null && { ja_JP: label.ja_JP }), + ...(label.pt_BR != null && { pt_BR: label.pt_BR }), + }, + icon: icon ?? undefined, + show_on: [], +}) + export const useFetchDynamicOptions = ( plugin_id: string, provider: string, @@ -1520,8 +1514,8 @@ export const useFetchDynamicOptions = ( ) => { return useMutation({ mutationFn: () => - narrowResponse<{ options: FormOption[] }>( - consoleClient.workspaces.current.plugin.parameters.dynamicOptions.get({ + consoleClient.workspaces.current.plugin.parameters.dynamicOptions + .get({ query: { plugin_id, provider, @@ -1530,7 +1524,7 @@ export const useFetchDynamicOptions = ( provider_type, ...extra, }, - }), - ), + }) + .then(({ options }) => ({ options: options.map(toDynamicOption) })), }) } From 6ee888660d0a7af222648db4a464f9649be04327 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:11:42 +0800 Subject: [PATCH 59/60] refactor(web): drop narrowResponse Remove the last narrowResponse callers in service/debug.ts and delete the helper. The rule generator response is a typed model, and the prompt template response declares its chat and completion configs and the stop words the templates carry, which the old untyped model dropped. The instruction generator answers with a rule, code or an LLM-written object depending on the target, so its result is read through a guard. Document settings no longer cast stored source info: step two takes the file and Notion page fields it reads (StepTwoFile, StepTwoNotionPage), and the crawl options are read with type checks. Co-Authored-By: Claude Opus 5.5 --- .../console/app/advanced_prompt_template.py | 30 ++++++- api/controllers/console/app/generator.py | 12 ++- api/openapi/markdown/console-openapi.md | 49 +++++++++++- .../test_advanced_prompt_template_response.py | 32 ++++++++ .../generated/api/console/app/types.gen.ts | 32 ++++++-- .../generated/api/console/app/zod.gen.ts | 55 +++++++++++-- .../api/console/rule-generate/types.gen.ts | 9 ++- .../api/console/rule-generate/zod.gen.ts | 11 ++- .../hooks/use-advanced-prompt-config.ts | 4 + .../create/step-two/__tests__/index.spec.tsx | 9 ++- .../step-two/components/preview-panel.tsx | 2 +- .../create/step-two/creation-params.ts | 13 +-- .../step-two/hooks/use-document-creation.ts | 7 +- .../step-two/hooks/use-indexing-estimate.ts | 14 +--- .../step-two/hooks/use-preview-state.ts | 31 +++++--- .../datasets/create/step-two/index.tsx | 4 +- .../datasets/create/step-two/types.ts | 13 ++- .../detail/settings/document-settings.tsx | 79 +++++++++++-------- web/service/console/narrow-response.ts | 8 -- web/service/debug.ts | 78 ++++++++++++------ 20 files changed, 362 insertions(+), 130 deletions(-) create mode 100644 api/tests/unit_tests/controllers/console/app/test_advanced_prompt_template_response.py delete mode 100644 web/service/console/narrow-response.ts diff --git a/api/controllers/console/app/advanced_prompt_template.py b/api/controllers/console/app/advanced_prompt_template.py index 267ac25c0c944b..f7b1efaae04dc6 100644 --- a/api/controllers/console/app/advanced_prompt_template.py +++ b/api/controllers/console/app/advanced_prompt_template.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Literal from flask_restx import Resource from pydantic import BaseModel, Field @@ -23,9 +23,33 @@ class AdvancedPromptTemplateQuery(BaseModel): model_name: str = Field(..., description="Model name") +class PromptTemplateMessageResponse(ResponseModel): + role: Literal["system", "user", "assistant"] + text: str + + +class ChatPromptTemplateResponse(ResponseModel): + prompt: list[PromptTemplateMessageResponse] + + +class CompletionPromptTemplateTextResponse(ResponseModel): + text: str + + +class ConversationHistoriesRoleResponse(ResponseModel): + user_prefix: str + assistant_prefix: str + + +class CompletionPromptTemplateResponse(ResponseModel): + prompt: CompletionPromptTemplateTextResponse + conversation_histories_role: ConversationHistoriesRoleResponse | None = None + + class AdvancedPromptTemplateResponse(ResponseModel): - chat_prompt_config: dict[str, Any] | None = Field(default=None) - completion_prompt_config: dict[str, Any] | None = Field(default=None) + chat_prompt_config: ChatPromptTemplateResponse | None = Field(default=None) + completion_prompt_config: CompletionPromptTemplateResponse | None = Field(default=None) + stop: list[str] | None = Field(default=None, description="Stop words for completion prompts.") console_ns.schema_model( diff --git a/api/controllers/console/app/generator.py b/api/controllers/console/app/generator.py index 53ea0bc804772b..d52ec962ac3bfc 100644 --- a/api/controllers/console/app/generator.py +++ b/api/controllers/console/app/generator.py @@ -211,6 +211,13 @@ class GeneratorResponse(RootModel[Any]): root: Any +class RuleGenerateResponse(ResponseModel): + prompt: str + variables: list[str] + opening_statement: str + error: str + + class StructuredOutputGenerateResponse(ResponseModel): output: str = Field(description="Generated JSON Schema, empty when generation failed") error: str = Field(description="Failure reason, empty on success") @@ -231,6 +238,7 @@ class StructuredOutputGenerateResponse(ResponseModel): register_response_schema_models( console_ns, GeneratorResponse, + RuleGenerateResponse, StructuredOutputGenerateResponse, SimpleDataResponse, WorkflowGenerateResponse, @@ -257,7 +265,7 @@ class RuleGenerateApi(Resource): @console_ns.response( 200, "Rule configuration generated successfully", - console_ns.models[GeneratorResponse.__name__], + console_ns.models[RuleGenerateResponse.__name__], ) @console_ns.response(400, "Invalid request parameters") @setup_required @@ -278,7 +286,7 @@ def post(self, req_data: RuleGeneratePayload, current_tenant_id: str): except InvokeError as e: raise CompletionRequestError(e.description) - return rules + return dump_response(RuleGenerateResponse, rules) @console_ns.route("/rule-code-generate") diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index cf9e87075792a2..f2f2d4b02d88e9 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -9387,7 +9387,7 @@ Generate rule configuration using LLM | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Rule configuration generated successfully | **application/json**: [GeneratorResponse](#generatorresponse)
| +| 200 | Rule configuration generated successfully | **application/json**: [RuleGenerateResponse](#rulegenerateresponse)
| | 400 | Invalid request parameters | **application/json**: [CompletionRequestError](#completionrequesterror)
[ModelCurrentlyNotSupportError](#modelcurrentlynotsupporterror)
[ProviderNotInitializeError](#providernotinitializeerror)
[ProviderQuotaExceededError](#providerquotaexceedederror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| @@ -15266,8 +15266,9 @@ Account not link tenant. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| chat_prompt_config | object | | No | -| completion_prompt_config | object | | No | +| chat_prompt_config | [ChatPromptTemplateResponse](#chatprompttemplateresponse) | | No | +| completion_prompt_config | [CompletionPromptTemplateResponse](#completionprompttemplateresponse) | | No | +| stop | [ string ] | Stop words for completion prompts. | No | #### AgentAccessMatrix @@ -19115,6 +19116,12 @@ You cannot transfer ownership to yourself. | first_id | string | First message ID for pagination | No | | limit | integer,
**Default:** 20 | Number of messages to return (1-100) | No | +#### ChatPromptTemplateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| prompt | [ [PromptTemplateMessageResponse](#prompttemplatemessageresponse) ] | | Yes | + #### ChatRequest | Name | Type | Description | Required | @@ -19296,6 +19303,19 @@ TEAM: Team collaboration paid plan | response_mode | string,
**Available values:** "blocking", "streaming",
**Default:** blocking | Response mode
*Enum:* `"blocking"`, `"streaming"` | No | | retriever_from | string,
**Default:** dev | Retriever source | No | +#### CompletionPromptTemplateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_histories_role | [ConversationHistoriesRoleResponse](#conversationhistoriesroleresponse) | | No | +| prompt | [CompletionPromptTemplateTextResponse](#completionprompttemplatetextresponse) | | Yes | + +#### CompletionPromptTemplateTextResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| text | string | | Yes | + #### CompletionRequest | Name | Type | Description | Required | @@ -19542,6 +19562,13 @@ The conversation has no message from which to generate a name. | ---- | ---- | ----------- | -------- | | ConversationFirstMessageNotFoundError | | The conversation has no message from which to generate a name. | | +#### ConversationHistoriesRoleResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| assistant_prefix | string | | Yes | +| user_prefix | string | | Yes | + #### ConversationInfiniteScrollPagination | Name | Type | Description | Required | @@ -24930,6 +24957,13 @@ Dataset Process Rule Mode | mode | [ProcessRuleMode](#processrulemode) | | Yes | | rules | [Rule](#rule) | | No | +#### PromptTemplateMessageResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| role | string,
**Available values:** "assistant", "system", "user" | *Enum:* `"assistant"`, `"system"`, `"user"` | Yes | +| text | string | | Yes | + #### PromptType Prompt configuration type @@ -25852,6 +25886,15 @@ The Roster Agent package exceeds the size limit. | model_config | [ModelConfig](#modelconfig) | Model configuration | Yes | | no_variable | boolean | Whether to exclude variables | No | +#### RuleGenerateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| error | string | | Yes | +| opening_statement | string | | Yes | +| prompt | string | | Yes | +| variables | [ string ] | | Yes | + #### RuleStructuredOutputPayload | Name | Type | Description | Required | diff --git a/api/tests/unit_tests/controllers/console/app/test_advanced_prompt_template_response.py b/api/tests/unit_tests/controllers/console/app/test_advanced_prompt_template_response.py new file mode 100644 index 00000000000000..33d4e0d1d81a60 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_advanced_prompt_template_response.py @@ -0,0 +1,32 @@ +import pytest + +from controllers.console.app.advanced_prompt_template import AdvancedPromptTemplateResponse +from core.prompt.prompt_templates.advanced_prompt_templates import ( + CHAT_APP_CHAT_PROMPT_CONFIG, + CHAT_APP_COMPLETION_PROMPT_CONFIG, + COMPLETION_APP_CHAT_PROMPT_CONFIG, + COMPLETION_APP_COMPLETION_PROMPT_CONFIG, +) + + +@pytest.mark.parametrize( + "template", + [ + CHAT_APP_CHAT_PROMPT_CONFIG, + CHAT_APP_COMPLETION_PROMPT_CONFIG, + COMPLETION_APP_CHAT_PROMPT_CONFIG, + COMPLETION_APP_COMPLETION_PROMPT_CONFIG, + ], +) +def test_prompt_templates_keep_their_shape(template: dict) -> None: + dumped = AdvancedPromptTemplateResponse.model_validate(template).model_dump(mode="json", exclude_unset=True) + + assert dumped == template + + +def test_completion_templates_keep_their_stop_words() -> None: + dumped = AdvancedPromptTemplateResponse.model_validate(CHAT_APP_COMPLETION_PROMPT_CONFIG).model_dump( + mode="json", exclude_unset=True + ) + + assert dumped["stop"] == ["Human:"] diff --git a/packages/contracts/generated/api/console/app/types.gen.ts b/packages/contracts/generated/api/console/app/types.gen.ts index bc4980b7e13b3d..e3669c3e4d2700 100644 --- a/packages/contracts/generated/api/console/app/types.gen.ts +++ b/packages/contracts/generated/api/console/app/types.gen.ts @@ -5,12 +5,9 @@ export type ClientOptions = { } export type AdvancedPromptTemplateResponse = { - chat_prompt_config?: { - [key: string]: unknown - } | null - completion_prompt_config?: { - [key: string]: unknown - } | null + chat_prompt_config?: ChatPromptTemplateResponse | null + completion_prompt_config?: CompletionPromptTemplateResponse | null + stop?: Array | null } export type ErrorResponse = { @@ -23,6 +20,29 @@ export type ErrorResponse = { status: number } +export type ChatPromptTemplateResponse = { + prompt: Array +} + +export type CompletionPromptTemplateResponse = { + conversation_histories_role?: ConversationHistoriesRoleResponse | null + prompt: CompletionPromptTemplateTextResponse +} + +export type PromptTemplateMessageResponse = { + role: 'assistant' | 'system' | 'user' + text: string +} + +export type ConversationHistoriesRoleResponse = { + assistant_prefix: string + user_prefix: string +} + +export type CompletionPromptTemplateTextResponse = { + text: string +} + export type GetAppPromptTemplatesData = { body?: never path?: never diff --git a/packages/contracts/generated/api/console/app/zod.gen.ts b/packages/contracts/generated/api/console/app/zod.gen.ts index be4ed9303451b5..4141461c80f111 100644 --- a/packages/contracts/generated/api/console/app/zod.gen.ts +++ b/packages/contracts/generated/api/console/app/zod.gen.ts @@ -2,14 +2,6 @@ import * as z from 'zod' -/** - * AdvancedPromptTemplateResponse - */ -export const zAdvancedPromptTemplateResponse = z.object({ - chat_prompt_config: z.record(z.string(), z.unknown()).nullish(), - completion_prompt_config: z.record(z.string(), z.unknown()).nullish(), -}) - /** * ErrorResponse * @@ -26,6 +18,53 @@ export const zErrorResponse = z.object({ status: z.int(), }) +/** + * PromptTemplateMessageResponse + */ +export const zPromptTemplateMessageResponse = z.object({ + role: z.enum(['assistant', 'system', 'user']), + text: z.string(), +}) + +/** + * ChatPromptTemplateResponse + */ +export const zChatPromptTemplateResponse = z.object({ + prompt: z.array(zPromptTemplateMessageResponse), +}) + +/** + * ConversationHistoriesRoleResponse + */ +export const zConversationHistoriesRoleResponse = z.object({ + assistant_prefix: z.string(), + user_prefix: z.string(), +}) + +/** + * CompletionPromptTemplateTextResponse + */ +export const zCompletionPromptTemplateTextResponse = z.object({ + text: z.string(), +}) + +/** + * CompletionPromptTemplateResponse + */ +export const zCompletionPromptTemplateResponse = z.object({ + conversation_histories_role: zConversationHistoriesRoleResponse.nullish(), + prompt: zCompletionPromptTemplateTextResponse, +}) + +/** + * AdvancedPromptTemplateResponse + */ +export const zAdvancedPromptTemplateResponse = z.object({ + chat_prompt_config: zChatPromptTemplateResponse.nullish(), + completion_prompt_config: zCompletionPromptTemplateResponse.nullish(), + stop: z.array(z.string()).nullish(), +}) + export const zGetAppPromptTemplatesQuery = z.object({ app_mode: z.string(), has_context: z.string().optional().default('true'), diff --git a/packages/contracts/generated/api/console/rule-generate/types.gen.ts b/packages/contracts/generated/api/console/rule-generate/types.gen.ts index 0102cc6299562f..c620715302ecf6 100644 --- a/packages/contracts/generated/api/console/rule-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/rule-generate/types.gen.ts @@ -10,7 +10,12 @@ export type RuleGeneratePayload = { no_variable?: boolean } -export type GeneratorResponse = unknown +export type RuleGenerateResponse = { + error: string + opening_statement: string + prompt: string + variables: Array +} export type CompletionRequestError = ErrorResponse & { code: 'completion_request_error' @@ -73,7 +78,7 @@ export type PostRuleGenerateErrors = { export type PostRuleGenerateError = PostRuleGenerateErrors[keyof PostRuleGenerateErrors] export type PostRuleGenerateResponses = { - 200: GeneratorResponse + 200: RuleGenerateResponse } export type PostRuleGenerateResponse = PostRuleGenerateResponses[keyof PostRuleGenerateResponses] diff --git a/packages/contracts/generated/api/console/rule-generate/zod.gen.ts b/packages/contracts/generated/api/console/rule-generate/zod.gen.ts index ccdb81084d4511..e480b37ca96ace 100644 --- a/packages/contracts/generated/api/console/rule-generate/zod.gen.ts +++ b/packages/contracts/generated/api/console/rule-generate/zod.gen.ts @@ -3,9 +3,14 @@ import * as z from 'zod' /** - * GeneratorResponse + * RuleGenerateResponse */ -export const zGeneratorResponse = z.unknown() +export const zRuleGenerateResponse = z.object({ + error: z.string(), + opening_statement: z.string(), + prompt: z.string(), + variables: z.array(z.string()), +}) /** * ErrorResponse @@ -102,4 +107,4 @@ export const zPostRuleGenerateBody = zRuleGeneratePayload /** * Rule configuration generated successfully */ -export const zPostRuleGenerateResponse = zGeneratorResponse +export const zPostRuleGenerateResponse = zRuleGenerateResponse diff --git a/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts b/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts index 7f820369fa286d..3bbdaf733ef0ab 100644 --- a/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts +++ b/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts @@ -129,6 +129,7 @@ const useAdvancedPromptConfig = ({ hasSetDataSet, }) if (modelModeType === ModelModeType.chat) { + if (!chat_prompt_config) return const newPromptConfig = produce(chat_prompt_config, (draft) => { draft.prompt = draft.prompt.map((p) => { return { @@ -139,6 +140,7 @@ const useAdvancedPromptConfig = ({ }) setChatPromptConfig(newPromptConfig) } else { + if (!completion_prompt_config) return const newPromptConfig = produce(completion_prompt_config, (draft) => { draft.prompt.text = draft.prompt.text.replace( PRE_PROMPT_PLACEHOLDER_TEXT, @@ -163,6 +165,7 @@ const useAdvancedPromptConfig = ({ }) if (toModelModeType === ModelModeType.completion) { + if (!completion_prompt_config) return const newPromptConfig = produce(completion_prompt_config, (draft) => { if (!completionPromptConfig.prompt?.text) draft.prompt.text = draft.prompt.text.replace( @@ -191,6 +194,7 @@ const useAdvancedPromptConfig = ({ } setStop(stop) // switch mode's params is async. It may override the stop value. } else { + if (!chat_prompt_config) return const newPromptConfig = produce(chat_prompt_config, (draft) => { draft.prompt = draft.prompt.map((p) => { return { diff --git a/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx index 2fe7173b3ec124..4001b92de1d67c 100644 --- a/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx @@ -949,7 +949,9 @@ describe('usePreviewState', () => { const { result } = renderHookWithConsoleQuery(() => usePreviewState(defaultOptions)) const items = result.current.getPreviewPickerItems() - expect(items).toEqual(defaultOptions.files) + expect(items).toEqual( + defaultOptions.files.map(({ id, name, extension }) => ({ id, name, extension })), + ) }) it('should return mapped notion pages for NOTION data source', () => { @@ -994,7 +996,8 @@ describe('usePreviewState', () => { const { result } = renderHookWithConsoleQuery(() => usePreviewState(defaultOptions)) const value = result.current.getPreviewPickerValue() - expect(value).toEqual(defaultOptions.files[0]) + const [file] = defaultOptions.files + expect(value).toEqual({ id: file!.id, name: file!.name, extension: file!.extension }) }) it('should return mapped notion page value for NOTION data source', () => { @@ -2192,7 +2195,7 @@ describe('Integration Scenarios', () => { ) // Initial state - expect(result.current.getPreviewPickerValue().name).toBe('first.pdf') + expect(result.current.getPreviewPickerValue()?.name).toBe('first.pdf') // Change preview act(() => { diff --git a/web/app/components/datasets/create/step-two/components/preview-panel.tsx b/web/app/components/datasets/create/step-two/components/preview-panel.tsx index 3bd1e931d6cf48..1c68ce6d3d0dc3 100644 --- a/web/app/components/datasets/create/step-two/components/preview-panel.tsx +++ b/web/app/components/datasets/create/step-two/components/preview-panel.tsx @@ -36,7 +36,7 @@ type PreviewPanelProps = { isSetting?: boolean // Picker pickerFiles: Array<{ id: string; name: string; extension: string }> - pickerValue: { id: string; name: string; extension: string } + pickerValue?: { id: string; name: string; extension: string } // Mutation state isIdle: boolean isPending: boolean diff --git a/web/app/components/datasets/create/step-two/creation-params.ts b/web/app/components/datasets/create/step-two/creation-params.ts index c55f3bce75b0c4..ca8d0356bafd08 100644 --- a/web/app/components/datasets/create/step-two/creation-params.ts +++ b/web/app/components/datasets/create/step-two/creation-params.ts @@ -1,18 +1,21 @@ import type { IndexingEstimatePayload } from '@dify/contracts/api/console/datasets/types.gen' import type { IndexingType } from './hooks/use-indexing-config' -import type { DataSourceProvider, NotionPage } from '@/models/common' +import type { StepTwoFile, StepTwoNotionPage } from './types' +import type { DataSourceProvider } from '@/models/common' import type { ChunkingMode, CrawlOptions, CrawlResultItem, - CustomFile, DataSourceType, NotionInfo, ProcessRule, } from '@/models/datasets' import { groupBy } from 'es-toolkit/compat' -export const getNotionInfo = (notionPages: NotionPage[], credentialId: string): NotionInfo[] => { +export const getNotionInfo = ( + notionPages: StepTwoNotionPage[], + credentialId: string, +): NotionInfo[] => { const workspacesMap = groupBy(notionPages, 'workspace_id') const workspaces = Object.keys(workspacesMap).map((workspaceId) => { return { @@ -63,7 +66,7 @@ type GetFileIndexingEstimateParamsOptionBase = { type GetFileIndexingEstimateParamsOptionFile = GetFileIndexingEstimateParamsOptionBase & { dataSourceType: typeof DataSourceType.FILE - files: CustomFile[] + files: StepTwoFile[] } export const getFileIndexingEstimateParamsForFile = ({ @@ -94,7 +97,7 @@ export const getFileIndexingEstimateParamsForFile = ({ type GetFileIndexingEstimateParamsOptionNotion = GetFileIndexingEstimateParamsOptionBase & { dataSourceType: typeof DataSourceType.NOTION - notionPages: NotionPage[] + notionPages: StepTwoNotionPage[] credential_id: string } diff --git a/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts b/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts index 771a2174f71cb6..4fc150fbaefd42 100644 --- a/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts +++ b/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts @@ -1,13 +1,12 @@ import type { ProviderWithModelsResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { StepTwoFile, StepTwoNotionPage } from '../types' import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { NotionPage } from '@/models/common' import type { ChunkingMode, CrawlOptions, CrawlResultItem, CreateDocumentReq, createDocumentResponse, - CustomFile, FullDocumentDetail, ProcessRule, SummaryIndexSetting as SummaryIndexSettingType, @@ -31,8 +30,8 @@ type UseDocumentCreationOptions = { isSetting?: boolean documentDetail?: FullDocumentDetail dataSourceType: DataSourceType - files: CustomFile[] - notionPages: NotionPage[] + files: StepTwoFile[] + notionPages: StepTwoNotionPage[] notionCredentialId: string websitePages: CrawlResultItem[] crawlOptions?: CrawlOptions diff --git a/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts b/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts index 3038de853cf308..013079e24c8458 100644 --- a/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts +++ b/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts @@ -1,12 +1,6 @@ +import type { StepTwoFile, StepTwoNotionPage } from '../types' import type { IndexingType } from './use-indexing-config' -import type { NotionPage } from '@/models/common' -import type { - ChunkingMode, - CrawlOptions, - CrawlResultItem, - CustomFile, - ProcessRule, -} from '@/models/datasets' +import type { ChunkingMode, CrawlOptions, CrawlResultItem, ProcessRule } from '@/models/datasets' import { useMutation } from '@tanstack/react-query' import { useCallback } from 'react' import { DataSourceProvider } from '@/models/common' @@ -25,10 +19,10 @@ type UseIndexingEstimateOptions = { currentDocForm: ChunkingMode docLanguage: string // File data source - files: CustomFile[] + files: StepTwoFile[] previewFileName?: string // Notion data source - previewNotionPage?: NotionPage + previewNotionPage?: StepTwoNotionPage notionCredentialId: string // Website data source previewWebsitePage?: CrawlResultItem diff --git a/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts b/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts index 64144f8f85c75c..41cefaf60c7420 100644 --- a/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts +++ b/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts @@ -1,17 +1,12 @@ -import type { NotionPage } from '@/models/common' -import type { - CrawlResultItem, - CustomFile, - DocumentItem, - FullDocumentDetail, -} from '@/models/datasets' +import type { StepTwoFile, StepTwoNotionPage } from '../types' +import type { CrawlResultItem, DocumentItem, FullDocumentDetail } from '@/models/datasets' import { useCallback, useState } from 'react' import { DataSourceType } from '@/models/datasets' type UsePreviewStateOptions = { dataSourceType: DataSourceType - files: CustomFile[] - notionPages: NotionPage[] + files: StepTwoFile[] + notionPages: StepTwoNotionPage[] websitePages: CrawlResultItem[] documentDetail?: FullDocumentDetail datasetId?: string @@ -23,12 +18,12 @@ export const usePreviewState = (options: UsePreviewStateOptions) => { // File preview state // Editing an existing document has no preview item until one is picked. const isEditing = !!datasetId && !!documentDetail - const [previewFile, setPreviewFile] = useState( + const [previewFile, setPreviewFile] = useState( isEditing ? undefined : files[0], ) // Notion page preview state - const [previewNotionPage, setPreviewNotionPage] = useState( + const [previewNotionPage, setPreviewNotionPage] = useState( isEditing ? undefined : notionPages[0], ) @@ -40,7 +35,11 @@ export const usePreviewState = (options: UsePreviewStateOptions) => { // Get preview items for document picker based on data source type const getPreviewPickerItems = useCallback(() => { if (dataSourceType === DataSourceType.FILE) { - return files as Array> + return files.map((file) => ({ + id: file.id ?? '', + name: file.name, + extension: file.extension ?? '', + })) } if (dataSourceType === DataSourceType.NOTION) { return notionPages.map((page) => ({ @@ -62,7 +61,13 @@ export const usePreviewState = (options: UsePreviewStateOptions) => { // Get current preview value for picker const getPreviewPickerValue = useCallback(() => { if (dataSourceType === DataSourceType.FILE) { - return previewFile as Required + return ( + previewFile && { + id: previewFile.id ?? '', + name: previewFile.name, + extension: previewFile.extension ?? '', + } + ) } if (dataSourceType === DataSourceType.NOTION) { return { diff --git a/web/app/components/datasets/create/step-two/index.tsx b/web/app/components/datasets/create/step-two/index.tsx index 621eb795678fe7..8419f7e84227aa 100644 --- a/web/app/components/datasets/create/step-two/index.tsx +++ b/web/app/components/datasets/create/step-two/index.tsx @@ -397,9 +397,7 @@ const StepTwo: FC = ({ estimate={estimateHook.estimate} parentChildConfig={segmentation.parentChildConfig} isSetting={isSetting} - pickerFiles={ - preview.getPreviewPickerItems() as Array<{ id: string; name: string; extension: string }> - } + pickerFiles={preview.getPreviewPickerItems()} pickerValue={preview.getPreviewPickerValue()} isIdle={estimateHook.isIdle} isPending={estimateHook.isPending} diff --git a/web/app/components/datasets/create/step-two/types.ts b/web/app/components/datasets/create/step-two/types.ts index ab5542901d900c..591b114ac4306f 100644 --- a/web/app/components/datasets/create/step-two/types.ts +++ b/web/app/components/datasets/create/step-two/types.ts @@ -10,6 +10,15 @@ import type { } from '@/models/datasets' import type { RETRIEVE_METHOD } from '@/types/app' +/** The fields step two reads from a file to index; uploads and stored documents both provide them. */ +export type StepTwoFile = Pick + +/** The fields step two reads from a Notion page to index. */ +export type StepTwoNotionPage = Pick< + NotionPage, + 'workspace_id' | 'page_id' | 'page_name' | 'page_icon' | 'type' +> + export type StepTwoProps = { isSetting?: boolean documentDetail?: FullDocumentDetail @@ -19,8 +28,8 @@ export type StepTwoProps = { indexingType?: IndexingType retrievalMethod?: string dataSourceType: DataSourceType - files: CustomFile[] - notionPages?: NotionPage[] + files: StepTwoFile[] + notionPages?: StepTwoNotionPage[] notionCredentialId: string websitePages?: CrawlResultItem[] crawlOptions?: CrawlOptions diff --git a/web/app/components/datasets/documents/detail/settings/document-settings.tsx b/web/app/components/datasets/documents/detail/settings/document-settings.tsx index c5c424310d0ad3..d3686ca6084bb1 100644 --- a/web/app/components/datasets/documents/detail/settings/document-settings.tsx +++ b/web/app/components/datasets/documents/detail/settings/document-settings.tsx @@ -1,7 +1,10 @@ -import type { DataSourceProvider, NotionPage } from '@/models/common' +import type { + StepTwoFile, + StepTwoNotionPage, +} from '@/app/components/datasets/create/step-two/types' +import type { DataSourceProvider } from '@/models/common' import type { CrawlOptions, - CustomFile, DataSourceType, LegacyDataSourceInfo, LocalFileInfo, @@ -17,6 +20,7 @@ import { useContext } from 'use-context-selector' import AppUnavailable from '@/app/components/base/app-unavailable' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import StepTwo from '@/app/components/datasets/create/step-two' +import { getNotionPageIcon } from '@/app/components/datasets/documents/document-source' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { @@ -31,6 +35,27 @@ import { consoleQuery } from '@/service/console' // data_source_info is written by the data source that imported the document. type DocumentSourceInfo = Record | null | undefined +const isCount = (value: unknown): value is number | string => + typeof value === 'number' || typeof value === 'string' + +// Website imports store the crawl options they ran with next to the crawled page. +const toCrawlOptions = (info: DocumentSourceInfo): CrawlOptions | undefined => { + if (!info) return undefined + const { includes, excludes, crawl_sub_pages, only_main_content, use_sitemap, limit, max_depth } = + info + if ( + typeof includes !== 'string' || + typeof excludes !== 'string' || + typeof crawl_sub_pages !== 'boolean' || + typeof only_main_content !== 'boolean' || + typeof use_sitemap !== 'boolean' || + !isCount(limit) || + !isCount(max_depth) + ) + return undefined + return { includes, excludes, crawl_sub_pages, only_main_content, use_sitemap, limit, max_depth } +} + type DocumentSettingsProps = { datasetId: string documentId: string @@ -104,14 +129,16 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => { const localFileInfo = isLocalFileInfo(dataSourceInfo) ? dataSourceInfo : undefined const uploadFileIdInfo = isUploadFileIdInfo(dataSourceInfo) ? dataSourceInfo : undefined - const currentPage = useMemo(() => { + const currentPage = useMemo(() => { if (legacyInfo) { + // Only icon objects carry the icon type the import payload needs. + const icon = getNotionPageIcon(dataSourceInfo, false) return { workspace_id: legacyInfo.notion_workspace_id ?? '', page_id: legacyInfo.notion_page_id ?? '', - page_name: documentDetail?.name, - page_icon: legacyInfo.notion_page_icon, - type: documentDetail?.data_source_type, + page_name: documentDetail?.name ?? '', + page_icon: typeof icon === 'object' ? icon : null, + type: documentDetail?.data_source_type ?? '', } } if (onlineDocumentInfo) { @@ -124,34 +151,30 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => { } } return undefined - }, [documentDetail?.data_source_type, documentDetail?.name, legacyInfo, onlineDocumentInfo]) - - const files = useMemo(() => { + }, [ + dataSourceInfo, + documentDetail?.data_source_type, + documentDetail?.name, + legacyInfo, + onlineDocumentInfo, + ]) + + const files = useMemo(() => { // Handle upload_file_id format if (uploadFileIdInfo) { - return [ - { - id: uploadFileIdInfo.upload_file_id, - name: documentDetail?.name || '', - } as unknown as CustomFile, - ] + return [{ id: uploadFileIdInfo.upload_file_id, name: documentDetail?.name || '' }] } // Handle legacy upload_file format if (legacyInfo?.upload_file) { - return [legacyInfo.upload_file as unknown as CustomFile] + const { id, name, extension } = legacyInfo.upload_file + return [{ id, name, extension }] } // Handle local file info format if (localFileInfo) { const { related_id, name, extension } = localFileInfo - return [ - { - id: related_id, - name, - extension, - } as unknown as CustomFile, - ] + return [{ id: related_id, name, extension }] } return [] @@ -169,13 +192,7 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => { ] }, [websiteInfo]) - const crawlOptions = - dataSourceInfo && - typeof dataSourceInfo === 'object' && - 'includes' in dataSourceInfo && - 'excludes' in dataSourceInfo - ? (dataSourceInfo as unknown as CrawlOptions) - : undefined + const crawlOptions = toCrawlOptions(dataSourceInfo) const websiteCrawlProvider = (websiteInfo?.provider ?? legacyInfo?.provider) as | DataSourceProvider @@ -200,7 +217,7 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => { onSetting={handleOpenAccountSetting} datasetId={datasetId} dataSourceType={documentDetail.data_source_type as DataSourceType} - notionPages={currentPage ? [currentPage as unknown as NotionPage] : []} + notionPages={currentPage ? [currentPage] : []} notionCredentialId={ legacyInfo?.credential_id || onlineDocumentInfo?.credential_id || '' } diff --git a/web/service/console/narrow-response.ts b/web/service/console/narrow-response.ts deleted file mode 100644 index d76098014a5eb9..00000000000000 --- a/web/service/console/narrow-response.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Some UI types narrow fields that backend response models declare as plain strings or - * loose records (enum-like statuses, `doc_form`, plugin `source`, locale label maps, ...). - * Until those response models declare literal types, contract responses are narrowed to the - * UI types at the service boundary with this helper instead of re-typing every consumer. - * Prefer aliasing the generated type whenever the UI type can follow it. - */ -export const narrowResponse = (response: Promise) => response as Promise diff --git a/web/service/debug.ts b/web/service/debug.ts index 667a6baca22d42..375d2db0b01511 100644 --- a/web/service/debug.ts +++ b/web/service/debug.ts @@ -1,19 +1,11 @@ +import type { AdvancedPromptTemplateResponse } from '@dify/contracts/api/console/app/types.gen' import type { AppMode } from '@dify/contracts/api/console/apps/types.gen' import type { PostInstructionGenerateData } from '@dify/contracts/api/console/instruction-generate/types.gen' import type { PostRuleGenerateData } from '@dify/contracts/api/console/rule-generate/types.gen' import type { IOnCompleted, IOnData, IOnError, IOnMessageReplace } from './base' -import type { ChatPromptConfig, CompletionPromptConfig } from '@/models/debug' import type { Model, ModelModeType } from '@/types/app' import { ssePost } from './base' import { consoleClient } from './console' -import { narrowResponse } from './console/narrow-response' - -type BasicAppFirstRes = { - prompt: string - variables: string[] - opening_statement: string - error?: string -} export type GenRes = { modified: string @@ -23,6 +15,33 @@ export type GenRes = { error?: string } +const getString = (record: Record, key: string) => { + const value = record[key] + return typeof value === 'string' ? value : undefined +} + +/** + * The instruction generator answers with the generated rule, generated code or an LLM-written + * JSON object, depending on what is being edited; read the fields the editors understand. + */ +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const toGenRes = (value: unknown): GenRes & { code?: string } => { + const fields = isRecord(value) ? value : {} + const variables = fields.variables + return { + modified: getString(fields, 'modified') ?? getString(fields, 'prompt') ?? '', + message: getString(fields, 'message'), + variables: Array.isArray(variables) + ? variables.filter((item): item is string => typeof item === 'string') + : undefined, + opening_statement: getString(fields, 'opening_statement'), + code: getString(fields, 'code'), + error: getString(fields, 'error') || undefined, + } +} + export const stopChatMessageResponding = async (appId: string, taskId: string) => { return consoleClient.apps.byAppId.chatMessages.byTaskId.stop.post({ params: { app_id: appId, task_id: taskId }, @@ -95,17 +114,34 @@ type InstructionGenerateRequest = Omit< } export const generateBasicAppFirstTimeRule = (body: RuleGenerateRequest) => { - return narrowResponse( - consoleClient.ruleGenerate.post({ body: body as PostRuleGenerateData['body'] }), - ) + return consoleClient.ruleGenerate.post({ body: body as PostRuleGenerateData['body'] }) } export const generateRule = (body: InstructionGenerateRequest) => { - return narrowResponse( - consoleClient.instructionGenerate.post({ body: body as PostInstructionGenerateData['body'] }), - ) + return consoleClient.instructionGenerate + .post({ body: body as PostInstructionGenerateData['body'] }) + .then(toGenRes) } +// A completion template without history prefixes keeps them empty, as the console's default does. +const toPromptTemplates = ({ + chat_prompt_config, + completion_prompt_config, + stop, +}: AdvancedPromptTemplateResponse) => ({ + chat_prompt_config: chat_prompt_config ?? undefined, + completion_prompt_config: completion_prompt_config + ? { + prompt: completion_prompt_config.prompt, + conversation_histories_role: completion_prompt_config.conversation_histories_role ?? { + user_prefix: '', + assistant_prefix: '', + }, + } + : undefined, + stop: stop ?? [], +}) + export const fetchPromptTemplate = ({ appMode, mode, @@ -117,20 +153,16 @@ export const fetchPromptTemplate = ({ modelName: string hasSetDataSet: boolean }) => { - return narrowResponse<{ - chat_prompt_config: ChatPromptConfig - completion_prompt_config: CompletionPromptConfig - stop: [] - }>( - consoleClient.app.promptTemplates.get({ + return consoleClient.app.promptTemplates + .get({ query: { app_mode: appMode, model_mode: mode, model_name: modelName, has_context: String(hasSetDataSet), }, - }), - ) + }) + .then(toPromptTemplates) } export const fetchTextGenerationMessage = ({ From c08553fc3a2346ecaf8b3451235fe35ff209d9a6 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:21:59 +0800 Subject: [PATCH 60/60] test: follow the typed generator and preview picker responses - rule-generate spec stubs a rule config matching RuleGenerateResponse - prompt template spec types its parameter for pyrefly - preview picker spec expects the id/name/extension picker value Co-Authored-By: Claude Opus 5.5 --- .../app/test_advanced_prompt_template_response.py | 2 +- .../controllers/console/app/test_generator_api.py | 10 ++++++++-- .../step-two/hooks/__tests__/use-preview-state.spec.ts | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/api/tests/unit_tests/controllers/console/app/test_advanced_prompt_template_response.py b/api/tests/unit_tests/controllers/console/app/test_advanced_prompt_template_response.py index 33d4e0d1d81a60..fa70d6eb6c7a96 100644 --- a/api/tests/unit_tests/controllers/console/app/test_advanced_prompt_template_response.py +++ b/api/tests/unit_tests/controllers/console/app/test_advanced_prompt_template_response.py @@ -18,7 +18,7 @@ COMPLETION_APP_COMPLETION_PROMPT_CONFIG, ], ) -def test_prompt_templates_keep_their_shape(template: dict) -> None: +def test_prompt_templates_keep_their_shape(template: dict[str, object]) -> None: dumped = AdvancedPromptTemplateResponse.model_validate(template).model_dump(mode="json", exclude_unset=True) assert dumped == template diff --git a/api/tests/unit_tests/controllers/console/app/test_generator_api.py b/api/tests/unit_tests/controllers/console/app/test_generator_api.py index 94371295601385..f268f9a38693e3 100644 --- a/api/tests/unit_tests/controllers/console/app/test_generator_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_generator_api.py @@ -67,7 +67,13 @@ def test_rule_generate_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> N api = generator_module.RuleGenerateApi() method = unwrap(api.post) - monkeypatch.setattr(generator_module.LLMGenerator, "generate_rule_config", lambda **_kwargs: {"rules": []}) + rule_config = { + "prompt": "You are a helpful assistant.", + "variables": ["topic"], + "opening_statement": "Hi", + "error": "", + } + monkeypatch.setattr(generator_module.LLMGenerator, "generate_rule_config", lambda **_kwargs: rule_config) with app.test_request_context( "/console/api/rule-generate", @@ -76,7 +82,7 @@ def test_rule_generate_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> N ): response = method(api, RuleGeneratePayload.model_validate(request.get_json()), "t1") - assert response == {"rules": []} + assert response == rule_config def test_rule_code_generate_maps_token_error(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts index ba322945ac29a0..a05b167a271353 100644 --- a/web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts @@ -143,7 +143,7 @@ describe('usePreviewState', () => { }), ) const value = result.current.getPreviewPickerValue() - expect(value).toBe(files[0]) + expect(value).toEqual({ id: 'f-1', name: 'file1.txt', extension: 'txt' }) }) it('should return mapped notion page value for NOTION type', () => {