Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e75e7c5b0 | ||
|
|
8c91bce282 | ||
|
|
798e5ed7a7 | ||
|
|
eef709e475 | ||
|
|
04158ac8ea | ||
|
|
ae0b66311d | ||
|
|
b97abe5328 | ||
|
|
24a66293fc | ||
|
|
d94314627f | ||
|
|
7ec6a57ddf | ||
|
|
35b539e35b | ||
|
|
3b040e6bed | ||
|
|
9300be03f0 | ||
|
|
be88e6adda | ||
|
|
3b832da46a | ||
|
|
8812fd649c |
@@ -1,6 +1,7 @@
|
||||
name: Deploy Knowledge
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
on:
|
||||
@@ -18,6 +19,48 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'deploy/konwledge'
|
||||
steps:
|
||||
- name: Wait for KnowledgeFS CI
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
timeout-minutes: 35
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const workflowId = "knowledge-fs-ci.yml";
|
||||
const headBranch = context.payload.workflow_run.head_branch;
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const deadline = Date.now() + 30 * 60 * 1000;
|
||||
const pollIntervalMs = 15 * 1000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
branch: headBranch,
|
||||
event: "push",
|
||||
head_sha: headSha,
|
||||
per_page: 10,
|
||||
});
|
||||
const run = data.workflow_runs[0];
|
||||
|
||||
if (!run) {
|
||||
core.info(`Waiting for ${workflowId} to start for ${headSha}.`);
|
||||
} else if (run.status !== "completed") {
|
||||
core.info(`Waiting for ${run.html_url}; current status is ${run.status}.`);
|
||||
} else if (run.conclusion !== "success") {
|
||||
throw new Error(
|
||||
`${workflowId} did not succeed for ${headSha}: ${run.conclusion} (${run.html_url})`,
|
||||
);
|
||||
} else {
|
||||
core.info(`KnowledgeFS CI succeeded for ${headSha}: ${run.html_url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${workflowId} to succeed for ${headSha}.`);
|
||||
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
|
||||
+2
-2
@@ -99,9 +99,9 @@ ENV VIRTUAL_ENV=/app/api/.venv
|
||||
COPY --from=packages --chown=dify:dify ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
|
||||
|
||||
# Download nltk data
|
||||
RUN mkdir -p /usr/local/share/nltk_data \
|
||||
&& NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.download('punkt'); nltk.download('averaged_perceptron_tagger'); nltk.download('stopwords')" \
|
||||
&& NLTK_DATA=/usr/local/share/nltk_data python -m nltk.downloader punkt_tab averaged_perceptron_tagger_eng stopwords \
|
||||
&& NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.data.find('tokenizers/punkt_tab'); nltk.data.find('taggers/averaged_perceptron_tagger_eng'); nltk.data.find('corpora/stopwords')" \
|
||||
&& chmod -R 755 /usr/local/share/nltk_data
|
||||
|
||||
ENV TIKTOKEN_CACHE_DIR=/app/api/.tiktoken_cache
|
||||
|
||||
@@ -52,7 +52,7 @@ def with_session[T, **P, R](
|
||||
session.commit()
|
||||
return result
|
||||
except Exception:
|
||||
session.rollback() # noqa: no-new-controller-sqlalchemy decorator owns transaction rollback
|
||||
session.rollback() # guard-ignore: no-new-controller-sqlalchemy -- decorator owns rollback
|
||||
raise
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
|
||||
@@ -317,7 +317,7 @@ class _WorkflowResponseSource:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> object:
|
||||
return getattr(self._workflow, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self._workflow, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
@property
|
||||
def created_by_account(self) -> Account | None:
|
||||
|
||||
@@ -218,7 +218,7 @@ class _DatasetQueryResponseSource:
|
||||
return self.query.get_queries(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.query, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.query, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
class DatasetQueryListResponse(ResponseModel):
|
||||
@@ -257,7 +257,7 @@ class _RelatedAppResponseSource:
|
||||
return self.app.mode_compatible_with_agent_with_session(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.app, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.app, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
class RelatedAppListResponse(ResponseModel):
|
||||
|
||||
@@ -106,7 +106,7 @@ class ExternalKnowledgeApiResponseSource:
|
||||
return self.external_knowledge_api.get_dataset_bindings(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.external_knowledge_api, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.external_knowledge_api, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
def external_knowledge_api_response(
|
||||
|
||||
@@ -404,7 +404,7 @@ class TrialWorkflowResponseSource:
|
||||
return self.workflow.get_tool_published(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.workflow, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.workflow, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
register_schema_models(
|
||||
|
||||
@@ -198,8 +198,23 @@ class AgentRuntimeSupport:
|
||||
if model_schema:
|
||||
model_schema = self._remove_unsupported_model_features_for_old_version(model_schema)
|
||||
value["entity"] = model_schema.model_dump(mode="json")
|
||||
# The model selector value from the workflow frontend only
|
||||
# carries provider/model/mode — it does NOT include
|
||||
# completion_params. AgentStrategy plugins (cot_agent,
|
||||
# function_calling) read completion_params to build the
|
||||
# LLMModelConfig that is backwards-invoked, and some model
|
||||
# providers raise KeyError('required') when
|
||||
# completion_params is empty because their parameter_rules
|
||||
# declare required fields with no default. Populate
|
||||
# completion_params with the defaults declared in the model
|
||||
# schema so the plugin daemon always receives a valid set
|
||||
# of model parameters.
|
||||
if "completion_params" not in value:
|
||||
value["completion_params"] = self._extract_default_completion_params(model_schema)
|
||||
else:
|
||||
value["entity"] = None
|
||||
if "completion_params" not in value:
|
||||
value["completion_params"] = {}
|
||||
result[parameter_name] = value
|
||||
|
||||
return result
|
||||
@@ -275,6 +290,24 @@ class AgentRuntimeSupport:
|
||||
model_schema.features.remove(feature)
|
||||
return model_schema
|
||||
|
||||
@staticmethod
|
||||
def _extract_default_completion_params(model_schema: AIModelEntity) -> dict[str, Any]:
|
||||
"""Build a completion_params dict from the model schema's parameter_rules.
|
||||
|
||||
The workflow Agent node's model-selector parameter only stores
|
||||
provider/model/mode — it never carries completion_params. When the
|
||||
value is forwarded to the plugin daemon, AgentModelConfig defaults
|
||||
completion_params to ``{}``, which causes some model providers to fail
|
||||
because their parameter_rules declare required fields. This helper
|
||||
collects the ``default`` value of every parameter_rule that has one so
|
||||
the plugin daemon receives a valid, non-empty set of model parameters.
|
||||
"""
|
||||
completion_params: dict[str, Any] = {}
|
||||
for rule in model_schema.parameter_rules:
|
||||
if rule.default is not None:
|
||||
completion_params[rule.name] = rule.default
|
||||
return completion_params
|
||||
|
||||
@staticmethod
|
||||
def _filter_mcp_type_tool(
|
||||
strategy: ResolvedAgentStrategy,
|
||||
|
||||
@@ -34,7 +34,7 @@ class _SessionResponseSource[SourceT]:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> object:
|
||||
return getattr(self._source, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self._source, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
class _FeedbackResponseSource(_SessionResponseSource[MessageFeedback]):
|
||||
|
||||
@@ -227,7 +227,7 @@ class DatasetDetailResponseSource:
|
||||
return self.dataset.get_total_available_documents(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.dataset, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.dataset, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
def dataset_detail_response_source(dataset: Any, *, session: Session) -> DatasetDetailResponseSource:
|
||||
|
||||
@@ -90,7 +90,7 @@ class DocumentWithSession:
|
||||
return self.document.get_doc_metadata_details(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.document, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self.document, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
def document_response(document: Document, *, session: Session) -> DocumentResponse:
|
||||
|
||||
@@ -316,10 +316,10 @@ class OracleVector(BaseVector):
|
||||
entities.append(current_entity)
|
||||
else:
|
||||
try:
|
||||
nltk.data.find("tokenizers/punkt")
|
||||
nltk.data.find("tokenizers/punkt_tab")
|
||||
nltk.data.find("corpora/stopwords")
|
||||
except LookupError:
|
||||
raise LookupError("Unable to find the required NLTK data package: punkt and stopwords")
|
||||
raise LookupError("Unable to find the required NLTK data package: punkt_tab and stopwords")
|
||||
e_str = re.sub(r"[^\w ]", "", query)
|
||||
all_tokens = nltk.word_tokenize(e_str)
|
||||
stop_words = stopwords.words("english")
|
||||
|
||||
+1
-1
@@ -206,7 +206,7 @@ storage = [
|
||||
############################################################
|
||||
# [ Tools ] dependency group
|
||||
############################################################
|
||||
tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.9.1,<4.0.0"]
|
||||
tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.10.0,<4.0.0"]
|
||||
|
||||
############################################################
|
||||
# [ VDB ] workspace plugins — hollow packages under providers/vdb/*
|
||||
|
||||
@@ -114,7 +114,7 @@ class AppModelConfigResponseView:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._app_model_config, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self._app_model_config, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
@property
|
||||
def annotation_reply_dict(self) -> Any:
|
||||
@@ -129,7 +129,7 @@ class AppResponseView:
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._app, name) # noqa: no-new-getattr response adapter delegates model fields
|
||||
return getattr(self._app, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
@property
|
||||
def desc_or_prompt(self) -> str:
|
||||
|
||||
@@ -2,114 +2,94 @@ extend = "../../.ruff.toml"
|
||||
src = ["../.."]
|
||||
|
||||
[lint]
|
||||
extend-select = ["ANN401", "ARG", "TID251"]
|
||||
extend-select = ["ANN401", "ARG"]
|
||||
|
||||
# Existing strict-mode debt. Remove a file entry when bringing it under strict checking.
|
||||
[lint.per-file-ignores]
|
||||
"core/rag/pipeline/test_queue_integration.py" = ["ANN401", "TID251", "ARG"]
|
||||
"controllers/console/test_apikey.py" = ["ARG002"]
|
||||
"controllers/openapi/test_app_dsl.py" = ["ARG002"]
|
||||
"controllers/service_api/dataset/test_dataset.py" = ["ARG002"]
|
||||
"controllers/web/test_conversation.py" = ["ARG002"]
|
||||
"controllers/web/test_human_input_form.py" = ["ARG001"]
|
||||
"controllers/web/test_wraps.py" = ["ARG002"]
|
||||
"core/app/layers/test_pause_state_persist_layer.py" = ["ARG002"]
|
||||
"core/rag/pipeline/test_queue_integration.py" = ["ARG002", "TID251"]
|
||||
"core/rag/retrieval/test_dataset_retrieval_integration.py" = ["ARG002"]
|
||||
"models/test_conversation_message_inputs.py" = ["ARG001"]
|
||||
"models/test_types_enum_text.py" = ["ANN401", "TID251"]
|
||||
"services/test_app_dsl_service.py" = ["ANN401", "TID251", "ARG"]
|
||||
"services/test_file_service_zip_and_lookup.py" = ["ANN401", "TID251", "ARG"]
|
||||
"trigger/conftest.py" = ["ANN401", "TID251"]
|
||||
"trigger/test_trigger_e2e.py" = ["ANN401", "TID251", "ARG"]
|
||||
"controllers/console/app/test_app_apis.py" = ["ARG"]
|
||||
"controllers/console/app/test_app_import_api.py" = ["ARG"]
|
||||
"controllers/console/auth/test_oauth.py" = ["ARG"]
|
||||
"controllers/console/auth/test_password_reset.py" = ["ARG"]
|
||||
"controllers/console/datasets/test_data_source.py" = ["ARG"]
|
||||
"controllers/console/test_apikey.py" = ["ARG"]
|
||||
"controllers/console/workspace/test_tool_provider.py" = ["ARG"]
|
||||
"controllers/mcp/test_mcp.py" = ["ARG"]
|
||||
"controllers/openapi/test_app_dsl.py" = ["ARG"]
|
||||
"controllers/openapi/test_workspaces.py" = ["ARG"]
|
||||
"controllers/service_api/dataset/test_dataset.py" = ["ARG"]
|
||||
"controllers/web/test_conversation.py" = ["ARG"]
|
||||
"controllers/web/test_human_input_form.py" = ["ARG"]
|
||||
"controllers/web/test_wraps.py" = ["ARG"]
|
||||
"core/app/layers/test_pause_state_persist_layer.py" = ["ARG"]
|
||||
"core/rag/retrieval/test_dataset_retrieval_integration.py" = ["ARG"]
|
||||
"models/test_conversation_message_inputs.py" = ["ARG"]
|
||||
"models/test_conversation_status_count.py" = ["ARG"]
|
||||
"repositories/test_sqlalchemy_api_workflow_run_repository.py" = ["ARG"]
|
||||
"repositories/test_workflow_run_repository.py" = ["ARG"]
|
||||
"services/auth/test_api_key_auth_service.py" = ["ARG"]
|
||||
"services/auth/test_auth_integration.py" = ["ARG"]
|
||||
"services/dataset_collection_binding.py" = ["ARG"]
|
||||
"services/dataset_service_update_delete.py" = ["ARG"]
|
||||
"services/document_service_status.py" = ["ARG"]
|
||||
"services/enterprise/test_account_deletion_sync.py" = ["ARG"]
|
||||
"services/plugin/test_plugin_parameter_service.py" = ["ARG"]
|
||||
"services/plugin/test_plugin_service.py" = ["ARG"]
|
||||
"services/rag_pipeline/test_rag_pipeline_service_db.py" = ["ARG"]
|
||||
"services/recommend_app/test_database_retrieval.py" = ["ARG"]
|
||||
"services/test_account_service.py" = ["ARG"]
|
||||
"services/test_advanced_prompt_template_service.py" = ["ARG"]
|
||||
"services/test_annotation_service.py" = ["ARG"]
|
||||
"services/test_api_based_extension_service.py" = ["ARG"]
|
||||
"services/test_api_token_service.py" = ["ARG"]
|
||||
"services/test_app_generate_service.py" = ["ARG"]
|
||||
"services/test_app_service.py" = ["ARG"]
|
||||
"services/test_attachment_service.py" = ["ARG"]
|
||||
"services/test_conversation_variable_updater.py" = ["ARG"]
|
||||
"services/test_dataset_permission_service.py" = ["ARG"]
|
||||
"services/test_dataset_service_batch_update_document_status.py" = ["ARG"]
|
||||
"services/test_dataset_service_retrieval.py" = ["ARG"]
|
||||
"services/test_delete_archived_workflow_run.py" = ["ARG"]
|
||||
"services/test_document_service_rename_document.py" = ["ARG"]
|
||||
"services/test_end_user_service.py" = ["ARG"]
|
||||
"services/test_feature_service.py" = ["ARG"]
|
||||
"services/test_feedback_service.py" = ["ARG"]
|
||||
"services/test_file_service.py" = ["ARG"]
|
||||
"services/test_human_input_delivery_test_service.py" = ["ARG"]
|
||||
"services/test_message_service.py" = ["ARG"]
|
||||
"services/test_messages_clean_service.py" = ["ARG", "S110"]
|
||||
"services/test_metadata_partial_update.py" = ["ARG"]
|
||||
"services/test_metadata_service.py" = ["ARG"]
|
||||
"services/test_model_load_balancing_service.py" = ["ARG"]
|
||||
"services/test_model_provider_service.py" = ["ARG"]
|
||||
"services/test_oauth_server_service.py" = ["ARG"]
|
||||
"services/test_ops_service.py" = ["ARG"]
|
||||
"services/test_saved_message_service.py" = ["ARG"]
|
||||
"services/test_web_conversation_service.py" = ["ARG"]
|
||||
"services/test_webapp_auth_service.py" = ["ARG"]
|
||||
"services/test_webhook_service.py" = ["ARG"]
|
||||
"services/test_workflow_app_service.py" = ["ARG"]
|
||||
"services/test_workflow_draft_variable_service.py" = ["ARG"]
|
||||
"services/test_workflow_run_service.py" = ["ARG"]
|
||||
"services/test_workflow_service.py" = ["ARG"]
|
||||
"services/test_workspace_service.py" = ["ARG"]
|
||||
"services/tools/test_api_tools_manage_service.py" = ["ARG"]
|
||||
"services/tools/test_mcp_tools_manage_service.py" = ["ARG"]
|
||||
"services/tools/test_tools_transform_service.py" = ["ARG"]
|
||||
"services/workflow/test_workflow_converter.py" = ["ARG"]
|
||||
"tasks/test_add_document_to_index_task.py" = ["ARG"]
|
||||
"tasks/test_batch_clean_document_task.py" = ["ARG"]
|
||||
"tasks/test_batch_create_segment_to_index_task.py" = ["ARG"]
|
||||
"repositories/test_sqlalchemy_api_workflow_run_repository.py" = ["ARG002", "ARG005"]
|
||||
"repositories/test_workflow_run_repository.py" = ["ARG002"]
|
||||
"services/auth/test_auth_integration.py" = ["ARG002"]
|
||||
"services/dataset_collection_binding.py" = ["ARG002"]
|
||||
"services/document_service_status.py" = ["ARG002"]
|
||||
"services/rag_pipeline/test_rag_pipeline_service_db.py" = ["ARG002"]
|
||||
"services/recommend_app/test_database_retrieval.py" = ["ARG002"]
|
||||
"services/test_account_service.py" = ["ARG002"]
|
||||
"services/test_advanced_prompt_template_service.py" = ["ARG002"]
|
||||
"services/test_app_dsl_service.py" = ["ANN401", "ARG001", "ARG002", "ARG005", "TID251"]
|
||||
"services/test_app_service.py" = ["ARG002"]
|
||||
"services/test_attachment_service.py" = ["ARG002"]
|
||||
"services/test_conversation_variable_updater.py" = ["ARG002"]
|
||||
"services/test_dataset_service_batch_update_document_status.py" = ["ARG002"]
|
||||
"services/test_delete_archived_workflow_run.py" = ["ARG002"]
|
||||
"services/test_document_service_rename_document.py" = ["ARG001"]
|
||||
"services/test_end_user_service.py" = ["ARG002"]
|
||||
"services/test_feature_service.py" = ["ARG002"]
|
||||
"services/test_file_service.py" = ["ARG002"]
|
||||
"services/test_file_service_zip_and_lookup.py" = ["TID251"]
|
||||
"services/test_messages_clean_service.py" = ["ARG002", "S110"]
|
||||
"services/test_metadata_partial_update.py" = ["ARG002"]
|
||||
"services/test_metadata_service.py" = ["ARG002"]
|
||||
"services/test_model_load_balancing_service.py" = ["ARG002"]
|
||||
"services/test_model_provider_service.py" = ["ARG002"]
|
||||
"services/test_ops_service.py" = ["ARG002"]
|
||||
"services/test_webapp_auth_service.py" = ["ARG002"]
|
||||
"services/test_webhook_service.py" = ["ARG002"]
|
||||
"services/test_workflow_draft_variable_service.py" = ["ARG002"]
|
||||
"services/test_workflow_run_service.py" = ["ARG002"]
|
||||
"services/test_workflow_service.py" = ["ARG002"]
|
||||
"services/test_workspace_service.py" = ["ARG002"]
|
||||
"services/tools/test_api_tools_manage_service.py" = ["ARG002"]
|
||||
"services/tools/test_mcp_tools_manage_service.py" = ["ARG002", "ARG005"]
|
||||
"services/tools/test_tools_transform_service.py" = ["ARG002"]
|
||||
"services/workflow/test_workflow_converter.py" = ["ARG002"]
|
||||
"tasks/test_add_document_to_index_task.py" = ["ARG002"]
|
||||
"tasks/test_batch_clean_document_task.py" = ["ARG002"]
|
||||
"tasks/test_batch_create_segment_to_index_task.py" = ["ARG001", "ARG002"]
|
||||
"tasks/test_clean_dataset_task.py" = ["T201"]
|
||||
"tasks/test_clean_notion_document_task.py" = ["ARG"]
|
||||
"tasks/test_create_segment_to_index_task.py" = ["ARG"]
|
||||
"tasks/test_dataset_indexing_task.py" = ["ARG"]
|
||||
"tasks/test_deal_dataset_vector_index_task.py" = ["ARG"]
|
||||
"tasks/test_delete_segment_from_index_task.py" = ["ARG"]
|
||||
"tasks/test_disable_segment_from_index_task.py" = ["ARG"]
|
||||
"tasks/test_disable_segments_from_index_task.py" = ["ARG"]
|
||||
"tasks/test_document_indexing_sync_task.py" = ["ARG"]
|
||||
"tasks/test_document_indexing_task.py" = ["ARG"]
|
||||
"tasks/test_document_indexing_update_task.py" = ["ARG"]
|
||||
"tasks/test_duplicate_document_indexing_task.py" = ["ARG"]
|
||||
"tasks/test_enable_segments_to_index_task.py" = ["ARG"]
|
||||
"tasks/test_mail_change_mail_task.py" = ["ARG"]
|
||||
"tasks/test_mail_email_code_login_task.py" = ["ARG"]
|
||||
"tasks/test_mail_human_input_delivery_task.py" = ["ARG"]
|
||||
"tasks/test_mail_inner_task.py" = ["ARG"]
|
||||
"tasks/test_mail_invite_member_task.py" = ["ARG"]
|
||||
"tasks/test_mail_owner_transfer_task.py" = ["ARG"]
|
||||
"tasks/test_mail_register_task.py" = ["ARG"]
|
||||
"tasks/test_rag_pipeline_run_tasks.py" = ["ARG"]
|
||||
"tasks/test_clean_notion_document_task.py" = ["ARG002"]
|
||||
"tasks/test_create_segment_to_index_task.py" = ["ARG002"]
|
||||
"tasks/test_dataset_indexing_task.py" = ["ARG002"]
|
||||
"tasks/test_deal_dataset_vector_index_task.py" = ["ARG002"]
|
||||
"tasks/test_delete_segment_from_index_task.py" = ["ARG002"]
|
||||
"tasks/test_disable_segment_from_index_task.py" = ["ARG002"]
|
||||
"tasks/test_disable_segments_from_index_task.py" = ["ARG002"]
|
||||
"tasks/test_document_indexing_sync_task.py" = ["ARG002"]
|
||||
"tasks/test_document_indexing_task.py" = ["ARG002"]
|
||||
"tasks/test_document_indexing_update_task.py" = ["ARG002"]
|
||||
"tasks/test_duplicate_document_indexing_task.py" = ["ARG002"]
|
||||
"tasks/test_enable_segments_to_index_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_change_mail_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_email_code_login_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_human_input_delivery_task.py" = ["ARG001"]
|
||||
"tasks/test_mail_inner_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_invite_member_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_owner_transfer_task.py" = ["ARG002"]
|
||||
"tasks/test_mail_register_task.py" = ["ARG002"]
|
||||
"tasks/test_rag_pipeline_run_tasks.py" = ["ARG002"]
|
||||
"test_workflow_pause_integration.py" = ["T201"]
|
||||
"workflow/nodes/code_executor/test_code_javascript.py" = ["ARG"]
|
||||
"workflow/nodes/code_executor/test_code_jinja2.py" = ["ARG"]
|
||||
"workflow/nodes/code_executor/test_code_python3.py" = ["ARG"]
|
||||
"trigger/conftest.py" = ["ANN401", "TID251"]
|
||||
"trigger/test_trigger_e2e.py" = ["ANN401", "ARG001", "TID251"]
|
||||
"workflow/nodes/code_executor/test_code_javascript.py" = ["ARG002"]
|
||||
"workflow/nodes/code_executor/test_code_jinja2.py" = ["ARG002"]
|
||||
"workflow/nodes/code_executor/test_code_python3.py" = ["ARG002"]
|
||||
"workflow/nodes/code_executor/test_utils.py" = ["T201"]
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse"]
|
||||
msg = "Use Pydantic payload/query models instead of reqparse."
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"]
|
||||
msg = "Use Pydantic payload/query models instead of reqparse."
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."typing.Any"]
|
||||
msg = "Use object, Protocol, TypedDict, TypeVar, ParamSpec, or a localized cast instead."
|
||||
|
||||
@@ -1,42 +1,24 @@
|
||||
preset = "strict"
|
||||
strict-callable-subtyping = true
|
||||
project-includes = ["."]
|
||||
search-path = ["../.."]
|
||||
python-platform = "linux"
|
||||
python-version = "3.12.0"
|
||||
infer-with-first-use = true
|
||||
min-severity = "warn"
|
||||
|
||||
# Verify project-excludes from the repo root:
|
||||
# tmp_config=$(mktemp --tmpdir=api/tests/test_containers_integration_tests pyrefly-no-excludes.XXXXXX.toml)
|
||||
# awk 'BEGIN {skip=0} /^project-excludes = \[/ {skip=1; next} skip && /^\]/ {skip=0; next} !skip {print}' api/tests/test_containers_integration_tests/pyrefly.toml > "$tmp_config"
|
||||
# tmp_name=$(basename "$tmp_config")
|
||||
# comm -3 <(sed -n 's/^ "\(.*\)",$/\1/p' api/tests/test_containers_integration_tests/pyrefly.toml | sort) <(uv --directory api run pyrefly check --config "tests/test_containers_integration_tests/$tmp_name" --summary=none --output-format=min-text 2>/dev/null | rg '^ERROR ' | sed -E 's#^ERROR (tests/test_containers_integration_tests/[^:]+):.*#\1#' | sed 's#^tests/test_containers_integration_tests/##' | sort -u)
|
||||
# rm --force "$tmp_config"
|
||||
# Existing strict-mode debt. Remove a file when bringing it under strict checking.
|
||||
project-excludes = [
|
||||
"commands/test_legacy_model_type_migration.py",
|
||||
"controllers/console/app/test_app_apis.py",
|
||||
"controllers/console/app/test_app_import_api.py",
|
||||
"controllers/console/app/test_chat_conversation_status_count_api.py",
|
||||
"controllers/console/app/test_conversation_read_timestamp.py",
|
||||
"controllers/console/app/test_workflow_draft_variable.py",
|
||||
"controllers/console/auth/test_email_register.py",
|
||||
"controllers/console/auth/test_forgot_password.py",
|
||||
"controllers/console/auth/test_oauth.py",
|
||||
"controllers/console/auth/test_password_reset.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_datasets.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_import.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py",
|
||||
"controllers/console/datasets/test_data_source.py",
|
||||
"controllers/console/explore/test_conversation.py",
|
||||
"controllers/console/test_api_based_extension.py",
|
||||
"controllers/console/test_apikey.py",
|
||||
"controllers/console/workspace/test_tool_provider.py",
|
||||
"controllers/console/workspace/test_trigger_providers.py",
|
||||
"controllers/console/workspace/test_workspace_wraps.py",
|
||||
"controllers/mcp/test_mcp.py",
|
||||
"controllers/service_api/dataset/test_dataset.py",
|
||||
"controllers/service_api/test_site.py",
|
||||
"controllers/web/test_conversation.py",
|
||||
"controllers/web/test_site.py",
|
||||
"controllers/web/test_web_forgot_password.py",
|
||||
"controllers/web/test_wraps.py",
|
||||
"core/app/layers/test_pause_state_persist_layer.py",
|
||||
"core/rag/pipeline/test_queue_integration.py",
|
||||
@@ -57,22 +39,16 @@ project-excludes = [
|
||||
"repositories/test_sqlalchemy_execution_extra_content_repository.py",
|
||||
"repositories/test_sqlalchemy_workflow_node_execution_repository.py",
|
||||
"repositories/test_workflow_run_repository.py",
|
||||
"services/auth/test_api_key_auth_service.py",
|
||||
"services/auth/test_auth_integration.py",
|
||||
"services/dataset_collection_binding.py",
|
||||
"services/dataset_service_update_delete.py",
|
||||
"services/document_service_status.py",
|
||||
"services/enterprise/test_account_deletion_sync.py",
|
||||
"services/plugin/test_plugin_parameter_service.py",
|
||||
"services/plugin/test_plugin_service.py",
|
||||
"services/rag_pipeline/test_rag_pipeline_service_db.py",
|
||||
"services/recommend_app/test_database_retrieval.py",
|
||||
"services/test_account_service.py",
|
||||
"services/test_advanced_prompt_template_service.py",
|
||||
"services/test_agent_service.py",
|
||||
"services/test_annotation_service.py",
|
||||
"services/test_api_based_extension_service.py",
|
||||
"services/test_api_token_service.py",
|
||||
"services/test_app_dsl_service.py",
|
||||
"services/test_app_generate_service.py",
|
||||
"services/test_app_service.py",
|
||||
@@ -97,20 +73,15 @@ project-excludes = [
|
||||
"services/test_document_service_rename_document.py",
|
||||
"services/test_end_user_service.py",
|
||||
"services/test_feature_service.py",
|
||||
"services/test_feedback_service.py",
|
||||
"services/test_file_service.py",
|
||||
"services/test_human_input_delivery_test.py",
|
||||
"services/test_human_input_delivery_test_service.py",
|
||||
"services/test_message_export_service.py",
|
||||
"services/test_message_service.py",
|
||||
"services/test_message_service_execution_extra_content.py",
|
||||
"services/test_message_service_extra_contents.py",
|
||||
"services/test_messages_clean_service.py",
|
||||
"services/test_metadata_partial_update.py",
|
||||
"services/test_metadata_service.py",
|
||||
"services/test_model_load_balancing_service.py",
|
||||
"services/test_model_provider_service.py",
|
||||
"services/test_oauth_server_service.py",
|
||||
"services/test_ops_service.py",
|
||||
"services/test_restore_archived_workflow_run.py",
|
||||
"services/test_saved_message_service.py",
|
||||
@@ -124,7 +95,6 @@ project-excludes = [
|
||||
"services/test_workflow_run_service.py",
|
||||
"services/test_workflow_service.py",
|
||||
"services/test_workspace_service.py",
|
||||
"services/tools/test_api_tools_manage_service.py",
|
||||
"services/tools/test_mcp_tools_manage_service.py",
|
||||
"services/tools/test_tools_transform_service.py",
|
||||
"services/tools/test_workflow_tools_manage_service.py",
|
||||
@@ -161,7 +131,6 @@ project-excludes = [
|
||||
"test_workflow_pause_integration.py",
|
||||
"trigger/conftest.py",
|
||||
"trigger/test_trigger_e2e.py",
|
||||
"workflow/nodes/code_executor/test_code_executor.py",
|
||||
"workflow/nodes/code_executor/test_code_javascript.py",
|
||||
"workflow/nodes/code_executor/test_code_jinja2.py",
|
||||
"workflow/nodes/code_executor/test_code_python3.py",
|
||||
@@ -169,6 +138,7 @@ project-excludes = [
|
||||
]
|
||||
|
||||
[errors]
|
||||
missing-override-decorator = "error"
|
||||
redundant-cast = true
|
||||
unannotated-return = true
|
||||
unnecessary-type-conversion = true
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
extend = "../../.ruff.toml"
|
||||
src = ["../.."]
|
||||
|
||||
[lint]
|
||||
extend-select = ["ANN401", "ARG"]
|
||||
|
||||
# Existing strict-mode debt. Remove a file entry when bringing it under strict checking.
|
||||
[lint.per-file-ignores]
|
||||
"clients/agent_backend/test_request_builder.py" = ["TID251"]
|
||||
"commands/test_archive_workflow_runs.py" = ["ARG005"]
|
||||
"commands/test_data_migration_wizard.py" = ["ARG005"]
|
||||
"commands/test_legacy_model_type_migration.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"controllers/common/test_agent_app_parameters.py" = ["ARG005", "TID251"]
|
||||
"controllers/common/test_app_access.py" = ["ARG005"]
|
||||
"controllers/console/agent/test_agent_controllers.py" = ["ARG001", "ARG002", "ARG003", "ARG005", "TID251"]
|
||||
"controllers/console/app/test_agent_app_sandbox.py" = ["ARG002", "ARG005"]
|
||||
"controllers/console/app/test_agent_config_inspector.py" = ["ARG005"]
|
||||
"controllers/console/app/test_agent_drive_inspector.py" = ["ARG005"]
|
||||
"controllers/console/app/test_agent_manage_guard.py" = ["ARG001"]
|
||||
"controllers/console/app/test_agent_skills.py" = ["ARG005"]
|
||||
"controllers/console/app/test_annotation_security.py" = ["ARG002"]
|
||||
"controllers/console/app/test_app_apis.py" = ["ARG001", "ARG002"]
|
||||
"controllers/console/app/test_app_import_api.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"controllers/console/app/test_app_response_models.py" = ["ARG002", "ARG004", "ARG005"]
|
||||
"controllers/console/app/test_conversation_api.py" = ["ARG001"]
|
||||
"controllers/console/app/test_generator_api.py" = ["ARG001"]
|
||||
"controllers/console/app/test_mcp_server_response.py" = ["ARG002"]
|
||||
"controllers/console/app/test_message_api.py" = ["ARG001"]
|
||||
"controllers/console/app/test_statistic_api.py" = ["ANN401", "ARG001", "TID251"]
|
||||
"controllers/console/app/test_workflow.py" = ["ARG001", "ARG005"]
|
||||
"controllers/console/app/test_workflow_convert_api.py" = ["ARG005"]
|
||||
"controllers/console/app/test_workflow_node_output_inspector.py" = ["ANN401", "ARG001", "TID251"]
|
||||
"controllers/console/app/test_workflow_run_api.py" = ["ANN401", "TID251"]
|
||||
"controllers/console/app/workflow_draft_variables_test.py" = ["TID251"]
|
||||
"controllers/console/auth/test_account_activation.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_authentication_security.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_email_verification.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_login_logout.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_oauth.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_oauth_timezone.py" = ["ARG001"]
|
||||
"controllers/console/auth/test_password_reset.py" = ["ARG002"]
|
||||
"controllers/console/auth/test_token_refresh.py" = ["ARG002"]
|
||||
"controllers/console/billing/test_billing.py" = ["ARG002"]
|
||||
"controllers/console/datasets/test_datasets.py" = ["ARG005"]
|
||||
"controllers/console/datasets/test_datasets_document.py" = ["ARG002", "ARG005"]
|
||||
"controllers/console/datasets/test_datasets_document_download.py" = ["ARG005"]
|
||||
"controllers/console/datasets/test_datasets_segments.py" = ["TID251"]
|
||||
"controllers/console/datasets/test_external.py" = ["TID251"]
|
||||
"controllers/console/datasets/test_wraps.py" = ["ARG001"]
|
||||
"controllers/console/explore/test_trial.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"controllers/console/explore/test_wraps.py" = ["ARG001"]
|
||||
"controllers/console/snippets/test_snippet_workflow.py" = ["ARG001"]
|
||||
"controllers/console/tag/test_tags.py" = ["ARG002"]
|
||||
"controllers/console/test_files.py" = ["ARG001", "ARG002"]
|
||||
"controllers/console/test_human_input_form.py" = ["ARG001", "ARG005"]
|
||||
"controllers/console/test_init_validate.py" = ["ARG005"]
|
||||
"controllers/console/test_workspace_account.py" = ["ARG002"]
|
||||
"controllers/console/test_workspace_members.py" = ["ARG002", "ARG005"]
|
||||
"controllers/console/test_wraps.py" = ["ARG001", "ARG002"]
|
||||
"controllers/console/workspace/test_load_balancing_config.py" = ["ARG001"]
|
||||
"controllers/console/workspace/test_plugin.py" = ["ARG002", "TID251"]
|
||||
"controllers/console/workspace/test_snippets.py" = ["ARG002"]
|
||||
"controllers/console/workspace/test_tool_providers.py" = ["ARG001", "ARG005"]
|
||||
"controllers/console/workspace/test_trigger_providers.py" = ["ARG001"]
|
||||
"controllers/console/workspace/test_workspace.py" = ["ARG005"]
|
||||
"controllers/files/test_image_preview.py" = ["ARG005"]
|
||||
"controllers/files/test_tool_files.py" = ["ARG002", "ARG005"]
|
||||
"controllers/files/test_upload.py" = ["ARG002", "ARG005"]
|
||||
"controllers/inner_api/plugin/test_plugin.py" = ["ARG002"]
|
||||
"controllers/inner_api/plugin/test_plugin_wraps.py" = ["ARG001", "ARG002", "ARG003", "TID251"]
|
||||
"controllers/inner_api/test_runtime_credentials.py" = ["ARG001"]
|
||||
"controllers/mcp/test_mcp.py" = ["ARG002"]
|
||||
"controllers/openapi/auth/test_conditions.py" = ["ARG005"]
|
||||
"controllers/openapi/auth/test_flow.py" = ["ARG005"]
|
||||
"controllers/openapi/auth/test_pipeline.py" = ["ARG001"]
|
||||
"controllers/openapi/conftest.py" = ["ARG001"]
|
||||
"controllers/openapi/test_account.py" = ["ARG005"]
|
||||
"controllers/openapi/test_app_describe_builder.py" = ["ARG001"]
|
||||
"controllers/openapi/test_app_run_streaming.py" = ["ARG001"]
|
||||
"controllers/openapi/test_contract.py" = ["ARG001", "TID251"]
|
||||
"controllers/openapi/test_error_contract.py" = ["ARG002"]
|
||||
"controllers/openapi/test_human_input_form.py" = ["ARG002"]
|
||||
"controllers/openapi/test_oauth_sso_claims.py" = ["ARG002"]
|
||||
"controllers/openapi/test_workflow_events_openapi.py" = ["ARG002", "ARG005"]
|
||||
"controllers/openapi/test_workspaces_members.py" = ["ARG001"]
|
||||
"controllers/service_api/app/test_app.py" = ["ARG002"]
|
||||
"controllers/service_api/app/test_completion.py" = ["ARG002"]
|
||||
"controllers/service_api/app/test_file.py" = ["ARG002"]
|
||||
"controllers/service_api/app/test_hitl_service_api.py" = ["ARG002", "ARG005"]
|
||||
"controllers/service_api/app/test_workflow_events.py" = ["ARG005"]
|
||||
"controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py" = ["ARG002"]
|
||||
"controllers/service_api/dataset/test_dataset_segment.py" = ["ARG002"]
|
||||
"controllers/service_api/dataset/test_document.py" = ["ARG001", "ARG002"]
|
||||
"controllers/service_api/dataset/test_metadata.py" = ["ARG002"]
|
||||
"controllers/service_api/test_trace_session_id_parsing.py" = ["ARG001"]
|
||||
"controllers/service_api/test_wraps.py" = ["ARG001", "ARG002"]
|
||||
"controllers/trigger/test_trigger.py" = ["ARG002"]
|
||||
"controllers/trigger/test_webhook.py" = ["ARG002"]
|
||||
"controllers/web/conftest.py" = ["ANN401", "TID251"]
|
||||
"controllers/web/test_app.py" = ["ARG002", "ARG005"]
|
||||
"controllers/web/test_audio.py" = ["ARG002"]
|
||||
"controllers/web/test_completion.py" = ["ARG002"]
|
||||
"controllers/web/test_feature.py" = ["ARG002"]
|
||||
"controllers/web/test_human_input_form.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"controllers/web/test_message_endpoints.py" = ["ARG002"]
|
||||
"controllers/web/test_remote_files.py" = ["ARG002"]
|
||||
"controllers/web/test_saved_message.py" = ["ARG002"]
|
||||
"controllers/web/test_web_login.py" = ["ARG002"]
|
||||
"controllers/web/test_web_passport.py" = ["ARG002"]
|
||||
"controllers/web/test_workflow.py" = ["ARG002"]
|
||||
"core/agent/test_base_agent_runner.py" = ["ARG002"]
|
||||
"core/agent/test_cot_agent_runner.py" = ["ARG001"]
|
||||
"core/agent/test_cot_chat_agent_runner.py" = ["ARG002"]
|
||||
"core/agent/test_fc_agent_runner.py" = ["TID251"]
|
||||
"core/app/app_config/common/test_parameters_mapping.py" = ["ARG002"]
|
||||
"core/app/app_config/easy_ui_based_app/test_dataset_manager.py" = ["ARG001", "ARG002"]
|
||||
"core/app/app_config/easy_ui_based_app/test_model_config_converter.py" = ["ARG002"]
|
||||
"core/app/app_config/easy_ui_based_app/test_variables_manager.py" = ["ARG002"]
|
||||
"core/app/apps/advanced_chat/test_app_generator.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/advanced_chat/test_app_runner_input_moderation.py" = ["ARG001", "ARG005"]
|
||||
"core/app/apps/advanced_chat/test_generate_task_pipeline.py" = ["ARG005"]
|
||||
"core/app/apps/advanced_chat/test_generate_task_pipeline_core.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/agent_app/test_app_generator.py" = ["ARG001", "ARG005"]
|
||||
"core/app/apps/agent_app/test_app_runner.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"core/app/apps/agent_app/test_input_guards.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"core/app/apps/agent_app/test_resolve_agent.py" = ["ANN401", "TID251"]
|
||||
"core/app/apps/agent_app/test_runtime_request_builder.py" = ["ARG002", "TID251"]
|
||||
"core/app/apps/agent_chat/test_agent_chat_app_config_manager.py" = ["ARG005"]
|
||||
"core/app/apps/agent_chat/test_agent_chat_app_generator.py" = ["ARG001"]
|
||||
"core/app/apps/chat/test_app_config_manager.py" = ["ARG001"]
|
||||
"core/app/apps/chat/test_app_generator_and_runner.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/common/test_workflow_response_converter_truncation.py" = ["TID251"]
|
||||
"core/app/apps/completion/test_app_runner.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/pipeline/test_pipeline_generator.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/pipeline/test_pipeline_runner.py" = ["ARG001", "ARG002"]
|
||||
"core/app/apps/test_advanced_chat_app_generator.py" = ["ARG001"]
|
||||
"core/app/apps/test_base_app_generator.py" = ["ARG005"]
|
||||
"core/app/apps/test_base_app_runner.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/apps/test_pause_resume.py" = ["ANN401", "TID251"]
|
||||
"core/app/apps/test_streaming_utils.py" = ["ARG001"]
|
||||
"core/app/apps/test_workflow_app_generator.py" = ["ARG005"]
|
||||
"core/app/apps/test_workflow_app_runner_core.py" = ["ARG001", "ARG002", "ARG004", "ARG005"]
|
||||
"core/app/apps/test_workflow_app_runner_single_node.py" = ["ANN401", "TID251"]
|
||||
"core/app/apps/test_workflow_pause_events.py" = ["ARG005"]
|
||||
"core/app/apps/workflow/test_app_generator_extra.py" = ["ARG005"]
|
||||
"core/app/apps/workflow/test_generate_task_pipeline_core.py" = ["ARG002", "ARG005"]
|
||||
"core/app/features/rate_limiting/test_rate_limit.py" = ["ARG001"]
|
||||
"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py" = ["ARG002"]
|
||||
"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/app/task_pipeline/test_message_cycle_manager_optimization.py" = ["ARG002"]
|
||||
"core/app/test_easy_ui_model_config_manager.py" = ["ARG005"]
|
||||
"core/app/workflow/layers/test_persistence_inspector_publish.py" = ["ANN401", "ARG005", "TID251"]
|
||||
"core/app/workflow/test_file_runtime.py" = ["ARG001", "ARG005"]
|
||||
"core/app/workflow/test_observability_layer_extra.py" = ["ARG005"]
|
||||
"core/app/workflow/test_persistence_layer.py" = ["ARG001"]
|
||||
"core/base/test_app_generator_tts_publisher.py" = ["ARG002"]
|
||||
"core/callback_handler/test_agent_tool_callback_handler.py" = ["ARG002"]
|
||||
"core/callback_handler/test_workflow_tool_callback_handler.py" = ["ARG002"]
|
||||
"core/datasource/__base/test_datasource_provider.py" = ["ARG002"]
|
||||
"core/datasource/test_datasource_file_manager.py" = ["ARG001", "ARG002"]
|
||||
"core/datasource/test_notion_provider.py" = ["ARG002", "TID251"]
|
||||
"core/datasource/test_website_crawl.py" = ["ARG002"]
|
||||
"core/datasource/utils/test_message_transformer.py" = ["ARG002"]
|
||||
"core/entities/test_entities_mcp_provider.py" = ["ARG001"]
|
||||
"core/entities/test_entities_provider_configuration.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"core/extension/test_extensible.py" = ["ARG002", "ARG005"]
|
||||
"core/external_data_tool/api/test_api.py" = ["ARG001"]
|
||||
"core/external_data_tool/test_base.py" = ["TID251"]
|
||||
"core/external_data_tool/test_external_data_fetch.py" = ["ARG001"]
|
||||
"core/helper/code_executor/test_code_executor.py" = ["TID251"]
|
||||
"core/helper/code_executor/test_template_transformer.py" = ["ANN401", "TID251"]
|
||||
"core/llm_generator/test_llm_generator.py" = ["ARG002"]
|
||||
"core/mcp/auth/test_auth_flow.py" = ["ARG002"]
|
||||
"core/mcp/client/test_session.py" = ["ARG001", "TID251"]
|
||||
"core/mcp/client/test_sse.py" = ["ARG001", "TID251"]
|
||||
"core/mcp/client/test_streamable_http.py" = ["ARG001", "ARG005", "S110", "TID251"]
|
||||
"core/mcp/session/test_base_session.py" = ["S110"]
|
||||
"core/mcp/session/test_client_session.py" = ["ARG005"]
|
||||
"core/mcp/test_mcp_client.py" = ["ARG002"]
|
||||
"core/memory/test_token_buffer_memory.py" = ["ARG002"]
|
||||
"core/moderation/test_content_moderation.py" = ["TID251"]
|
||||
"core/moderation/test_output_moderation.py" = ["ARG001", "ARG002"]
|
||||
"core/ops/test_base_trace_instance.py" = ["ARG001"]
|
||||
"core/ops/test_lookup_helpers.py" = ["ARG002"]
|
||||
"core/ops/test_ops_trace_manager.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/ops/test_trace_queue_manager.py" = ["ARG004"]
|
||||
"core/ops/test_trace_session_metadata.py" = ["ARG001", "ARG005"]
|
||||
"core/plugin/impl/test_agent_client.py" = ["ARG001"]
|
||||
"core/plugin/impl/test_datasource_manager.py" = ["ARG001"]
|
||||
"core/plugin/impl/test_oauth_handler.py" = ["ARG001"]
|
||||
"core/plugin/impl/test_tool_manager.py" = ["ARG001"]
|
||||
"core/plugin/impl/test_trigger_client.py" = ["ARG001"]
|
||||
"core/plugin/test_endpoint_client.py" = ["ARG002"]
|
||||
"core/plugin/test_model_runtime_adapter.py" = ["ARG002"]
|
||||
"core/plugin/test_plugin_runtime.py" = ["ARG001", "ARG002", "TID251"]
|
||||
"core/prompt/test_advanced_prompt_transform.py" = ["ARG005"]
|
||||
"core/prompt/test_prompt_transform.py" = ["ARG005"]
|
||||
"core/rag/datasource/keyword/jieba/test_jieba.py" = ["ARG001", "TID251"]
|
||||
"core/rag/datasource/keyword/jieba/test_jieba_keyword_table_handler.py" = ["ARG004"]
|
||||
"core/rag/datasource/keyword/test_keyword_factory.py" = ["ARG005"]
|
||||
"core/rag/datasource/test_datasource_retrieval.py" = ["ARG001", "ARG002", "ARG005", "TID251"]
|
||||
"core/rag/datasource/test_retrieval_attachment_access.py" = ["ARG005"]
|
||||
"core/rag/datasource/vdb/test_vector_factory.py" = ["ARG002"]
|
||||
"core/rag/embedding/test_embedding_base.py" = ["TID251"]
|
||||
"core/rag/embedding/test_embedding_service.py" = ["ARG001"]
|
||||
"core/rag/extractor/firecrawl/test_firecrawl.py" = ["TID251"]
|
||||
"core/rag/extractor/test_csv_extractor.py" = ["ARG001", "ARG005"]
|
||||
"core/rag/extractor/test_excel_extractor.py" = ["ARG002", "ARG005"]
|
||||
"core/rag/extractor/test_extract_processor.py" = ["ARG001", "ARG005"]
|
||||
"core/rag/extractor/test_helpers.py" = ["ARG002"]
|
||||
"core/rag/extractor/test_markdown_extractor.py" = ["ARG001"]
|
||||
"core/rag/extractor/test_notion_extractor.py" = ["ARG002", "ARG005"]
|
||||
"core/rag/extractor/test_pdf_extractor.py" = ["ARG001", "ARG005"]
|
||||
"core/rag/extractor/test_text_extractor.py" = ["ARG001"]
|
||||
"core/rag/extractor/test_word_extractor.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/rag/extractor/unstructured/test_unstructured_extractors.py" = ["ARG001", "ARG005"]
|
||||
"core/rag/extractor/watercrawl/test_watercrawl.py" = ["ARG001", "ARG005", "TID251"]
|
||||
"core/rag/indexing/processor/conftest.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"core/rag/indexing/processor/test_paragraph_index_processor.py" = ["ARG002", "TID251"]
|
||||
"core/rag/indexing/processor/test_qa_index_processor.py" = ["ARG001", "TID251"]
|
||||
"core/rag/indexing/test_index_processor.py" = ["ARG005"]
|
||||
"core/rag/indexing/test_indexing_runner.py" = ["ARG005", "TID251"]
|
||||
"core/rag/pipeline/test_queue.py" = ["ARG002"]
|
||||
"core/rag/retrieval/test_dataset_retrieval.py" = ["ARG001", "ARG002", "ARG005", "TID251"]
|
||||
"core/rag/splitter/test_text_splitter.py" = ["ARG002", "ARG005"]
|
||||
"core/repositories/test_celery_workflow_execution_repository.py" = ["ARG002"]
|
||||
"core/repositories/test_celery_workflow_node_execution_repository.py" = ["ARG002"]
|
||||
"core/repositories/test_human_input_form_repository_impl.py" = ["ARG001", "ARG005"]
|
||||
"core/repositories/test_human_input_repository.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"core/repositories/test_sqlalchemy_workflow_node_execution_repository.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"core/repositories/test_workflow_node_execution_truncation.py" = ["TID251"]
|
||||
"core/schemas/test_resolver.py" = ["ARG005", "T201"]
|
||||
"core/telemetry/test_facade.py" = ["ARG002", "ARG004"]
|
||||
"core/telemetry/test_gateway_integration.py" = ["ARG002"]
|
||||
"core/test_model_manager.py" = ["ARG001"]
|
||||
"core/test_trigger_debug_event_selectors.py" = ["ARG002"]
|
||||
"core/tools/test_base_tool.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"core/tools/test_builtin_tool_base.py" = ["ARG001", "ARG002", "TID251"]
|
||||
"core/tools/test_builtin_tool_provider.py" = ["ARG001", "ARG005", "TID251"]
|
||||
"core/tools/test_builtin_tools_extra.py" = ["ARG005"]
|
||||
"core/tools/test_custom_tool.py" = ["ARG001", "ARG005", "TID251"]
|
||||
"core/tools/test_dataset_retriever_tool.py" = ["ARG005"]
|
||||
"core/tools/test_mcp_tool.py" = ["S110"]
|
||||
"core/tools/test_tool_engine.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"core/tools/test_tool_file_manager.py" = ["ARG001"]
|
||||
"core/tools/test_tool_label_manager.py" = ["TID251"]
|
||||
"core/tools/test_tool_manager.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"core/tools/test_tool_provider_controller.py" = ["TID251"]
|
||||
"core/tools/utils/test_configuration.py" = ["ARG001", "ARG002", "TID251"]
|
||||
"core/tools/utils/test_encryption.py" = ["ANN401", "TID251"]
|
||||
"core/tools/utils/test_message_transformer.py" = ["TID251"]
|
||||
"core/tools/utils/test_misc_utils_extra.py" = ["ARG002"]
|
||||
"core/tools/utils/test_model_invocation_utils.py" = ["ARG005", "TID251"]
|
||||
"core/tools/utils/test_parser.py" = ["TID251"]
|
||||
"core/tools/utils/test_web_reader_tool.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/tools/workflow_as_tool/test_provider.py" = ["TID251"]
|
||||
"core/tools/workflow_as_tool/test_tool.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"core/trigger/conftest.py" = ["ANN401", "TID251"]
|
||||
"core/trigger/debug/test_debug_event_selectors.py" = ["ARG002", "TID251"]
|
||||
"core/variables/test_segment_type_validation.py" = ["TID251"]
|
||||
"core/workflow/context/test_execution_context.py" = ["ANN401", "ARG002", "S110", "TID251"]
|
||||
"core/workflow/context/test_flask_app_context.py" = ["ARG002"]
|
||||
"core/workflow/generator/test_runner.py" = ["ARG001", "ARG002", "TID251"]
|
||||
"core/workflow/generator/test_runner_missing.py" = ["ARG003"]
|
||||
"core/workflow/generator/test_tool_catalogue.py" = ["ARG002"]
|
||||
"core/workflow/graph_engine/layers/test_observability.py" = ["ARG002"]
|
||||
"core/workflow/graph_engine/test_mock_config.py" = ["TID251"]
|
||||
"core/workflow/graph_engine/test_mock_factory.py" = ["TID251"]
|
||||
"core/workflow/graph_engine/test_mock_nodes.py" = ["ANN401", "S110", "TID251"]
|
||||
"core/workflow/graph_engine/test_parallel_human_input_join_resume.py" = ["ARG002", "TID251"]
|
||||
"core/workflow/graph_engine/test_table_runner.py" = ["ARG001", "TID251"]
|
||||
"core/workflow/nodes/agent_v2/test_agent_node.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
"core/workflow/nodes/agent_v2/test_ask_human_hitl.py" = ["ANN401", "TID251"]
|
||||
"core/workflow/nodes/agent_v2/test_dify_tools_builder.py" = ["ANN401", "ARG001", "ARG002", "ARG005", "TID251"]
|
||||
"core/workflow/nodes/agent_v2/test_output_adapter.py" = ["ARG005"]
|
||||
"core/workflow/nodes/agent_v2/test_runtime_request_builder.py" = ["ARG002"]
|
||||
"core/workflow/nodes/agent_v2/test_validators.py" = ["ARG001"]
|
||||
"core/workflow/nodes/http_request/test_http_request_node.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"core/workflow/nodes/human_input/test_entities.py" = ["TID251"]
|
||||
"core/workflow/nodes/human_input/test_human_input_form_filled_event.py" = ["TID251"]
|
||||
"core/workflow/nodes/iteration/test_iteration_child_engine_errors.py" = ["ARG002", "TID251"]
|
||||
"core/workflow/nodes/knowledge_index/test_knowledge_index_node.py" = ["ARG002"]
|
||||
"core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py" = ["ARG002"]
|
||||
"core/workflow/nodes/llm/test_node.py" = ["ARG002"]
|
||||
"core/workflow/nodes/parameter_extractor/test_parameter_extractor_node.py" = ["TID251"]
|
||||
"core/workflow/nodes/test_document_extractor_node.py" = ["ARG001"]
|
||||
"core/workflow/nodes/tool/test_tool_node.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"core/workflow/nodes/webhook/test_webhook_file_conversion.py" = ["TID251"]
|
||||
"core/workflow/nodes/webhook/test_webhook_node.py" = ["TID251"]
|
||||
"core/workflow/test_form_input_serialization_compat.py" = ["ANN401", "TID251"]
|
||||
"core/workflow/test_human_input_adapter.py" = ["ARG005"]
|
||||
"core/workflow/test_node_factory.py" = ["ARG002"]
|
||||
"core/workflow/test_workflow_entry.py" = ["ARG001"]
|
||||
"enterprise/telemetry/test_enterprise_trace.py" = ["ARG002", "TID251"]
|
||||
"enterprise/telemetry/test_exporter.py" = ["ARG001"]
|
||||
"enterprise/telemetry/test_gateway.py" = ["ARG002"]
|
||||
"events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py" = ["ARG005"]
|
||||
"extensions/logstore/test_sql_escape.py" = ["ARG001", "ARG002"]
|
||||
"extensions/otel/decorators/handlers/test_generate_handler.py" = ["ARG001", "ARG002"]
|
||||
"extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py" = ["ARG001"]
|
||||
"extensions/otel/decorators/test_base.py" = ["ARG002"]
|
||||
"extensions/otel/decorators/test_handler.py" = ["ARG002"]
|
||||
"extensions/otel/test_retrieval_tracing.py" = ["ARG001"]
|
||||
"extensions/test_ext_request_logging.py" = ["ARG002"]
|
||||
"extensions/test_redis.py" = ["ARG001"]
|
||||
"factories/test_build_from_mapping.py" = ["ARG001"]
|
||||
"factories/test_file_factory.py" = ["ARG001"]
|
||||
"factories/test_variable_factory.py" = ["TID251"]
|
||||
"fields/test_file_fields.py" = ["ARG005"]
|
||||
"libs/_human_input/support.py" = ["TID251"]
|
||||
"libs/broadcast_channel/redis/test_channel_unit_tests.py" = ["ARG002", "ARG005"]
|
||||
"libs/broadcast_channel/redis/test_streams_channel_unit_tests.py" = ["ARG001", "ARG002", "TID251"]
|
||||
"libs/test_cron_compatibility.py" = ["S110"]
|
||||
"libs/test_email_i18n.py" = ["ANN401", "TID251"]
|
||||
"libs/test_oauth_bearer_rate_limit_ordering.py" = ["ARG001"]
|
||||
"libs/test_pyrefly_type_coverage.py" = ["TID251"]
|
||||
"libs/test_schedule_utils_enhanced.py" = ["S110"]
|
||||
"libs/test_sendgrid_client.py" = ["ARG001", "TID251"]
|
||||
"libs/test_smtp_client.py" = ["TID251"]
|
||||
"models/test_dataset_models.py" = ["ARG005"]
|
||||
"models/test_plugin_entities.py" = ["TID251"]
|
||||
"models/test_snippet.py" = ["ARG001"]
|
||||
"oss/__mock/aliyun_oss.py" = ["ARG002"]
|
||||
"oss/__mock/baidu_obs.py" = ["ARG002"]
|
||||
"oss/__mock/base.py" = ["ARG002"]
|
||||
"oss/__mock/tencent_cos.py" = ["ARG002"]
|
||||
"oss/__mock/volcengine_tos.py" = ["ARG002"]
|
||||
"oss/aliyun_oss/aliyun_oss/test_aliyun_oss.py" = ["ARG002"]
|
||||
"oss/baidu_obs/test_baidu_obs.py" = ["ARG002"]
|
||||
"oss/opendal/test_opendal.py" = ["ARG002"]
|
||||
"oss/tencent_cos/test_tencent_cos.py" = ["ARG002"]
|
||||
"oss/volcengine_tos/test_volcengine_tos.py" = ["ARG002"]
|
||||
"services/agent/test_agent_observability_service.py" = ["ARG002", "ARG005"]
|
||||
"services/agent/test_agent_services.py" = ["ARG001", "ARG002", "ARG003", "ARG005"]
|
||||
"services/agent/test_composer_candidates.py" = ["ARG005"]
|
||||
"services/agent/test_prompt_mentions.py" = ["ARG005"]
|
||||
"services/agent/test_skill_tool_inference_service.py" = ["ARG001", "ARG005"]
|
||||
"services/auth/test_jina_auth_standalone_module.py" = ["TID251"]
|
||||
"services/controller_api.py" = ["ARG002"]
|
||||
"services/data_migration/test_import_service.py" = ["ARG002", "ARG005"]
|
||||
"services/dataset_service_test_helpers.py" = ["TID251"]
|
||||
"services/enterprise/test_account_deletion_sync.py" = ["ARG001"]
|
||||
"services/enterprise/test_rbac_service.py" = ["ARG002"]
|
||||
"services/enterprise/test_traceparent_propagation.py" = ["ARG002"]
|
||||
"services/hit_service.py" = ["TID251"]
|
||||
"services/plugin/test_plugin_parameter_service.py" = ["ARG002"]
|
||||
"services/rag_pipeline/pipeline_template/test_built_in_retrieval.py" = ["ARG001"]
|
||||
"services/rag_pipeline/test_rag_pipeline_dsl_service.py" = ["ARG001", "ARG005", "T201", "TID251"]
|
||||
"services/rag_pipeline/test_rag_pipeline_service.py" = ["ARG001", "ARG005"]
|
||||
"services/rag_pipeline/test_rag_pipeline_task_proxy.py" = ["ARG001", "ARG005"]
|
||||
"services/rag_pipeline/test_rag_pipeline_transform_service.py" = ["ARG001"]
|
||||
"services/recommend_app/test_remote_retrieval.py" = ["ARG002"]
|
||||
"services/retention/workflow_run/test_archive_download_preparation.py" = ["ARG002"]
|
||||
"services/retention/workflow_run/test_archive_log_service.py" = ["ARG001", "ARG002"]
|
||||
"services/retention/workflow_run/test_bundle_archive_maintenance.py" = ["TID251"]
|
||||
"services/retention/workflow_run/test_restore_archived_workflow_run.py" = ["ARG002"]
|
||||
"services/test_account_service.py" = ["ARG001", "ARG002"]
|
||||
"services/test_annotation_service.py" = ["ANN401", "TID251"]
|
||||
"services/test_api_token_service.py" = ["ARG002"]
|
||||
"services/test_app_generate_service.py" = ["ARG001", "ARG002", "ARG004"]
|
||||
"services/test_app_generate_service_streaming_integration.py" = ["ARG002", "TID251"]
|
||||
"services/test_archive_workflow_run_logs.py" = ["ARG002"]
|
||||
"services/test_audio_service.py" = ["ARG002", "TID251"]
|
||||
"services/test_batch_indexing_base.py" = ["ANN401", "TID251"]
|
||||
"services/test_billing_service.py" = ["ARG001", "ARG002"]
|
||||
"services/test_clear_free_plan_expired_workflow_run_logs.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"services/test_clear_free_plan_tenant_expired_logs.py" = ["ARG002", "ARG003"]
|
||||
"services/test_dataset_service_document.py" = ["ARG002"]
|
||||
"services/test_dataset_service_lock_not_owned.py" = ["ARG001", "ARG005"]
|
||||
"services/test_dataset_service_segment.py" = ["ARG002"]
|
||||
"services/test_datasource_provider_service.py" = ["ARG002"]
|
||||
"services/test_external_dataset_service.py" = ["ARG002", "TID251"]
|
||||
"services/test_feature_service_human_input_email_delivery.py" = ["ARG005"]
|
||||
"services/test_feedback_service.py" = ["ARG002"]
|
||||
"services/test_human_input_delivery_test_service.py" = ["ARG005"]
|
||||
"services/test_knowledge_service.py" = ["TID251"]
|
||||
"services/test_message_service.py" = ["ARG002"]
|
||||
"services/test_messages_clean_service.py" = ["TID251"]
|
||||
"services/test_model_load_balancing_service.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"services/test_model_provider_service.py" = ["ANN401", "TID251"]
|
||||
"services/test_model_provider_service_sanitization.py" = ["ARG002", "ARG005"]
|
||||
"services/test_oauth_server_service.py" = ["ARG002"]
|
||||
"services/test_operation_service.py" = ["TID251"]
|
||||
"services/test_rag_pipeline_task_proxy.py" = ["ARG002"]
|
||||
"services/test_recommended_app_service.py" = ["ARG001"]
|
||||
"services/test_schedule_service.py" = ["ANN401", "TID251"]
|
||||
"services/test_snippet_service.py" = ["ARG001", "ARG002"]
|
||||
"services/test_summary_index_service.py" = ["ARG001"]
|
||||
"services/test_telemetry_service.py" = ["ARG001", "ARG005"]
|
||||
"services/test_variable_truncator.py" = ["ARG002", "TID251"]
|
||||
"services/test_variable_truncator_additional.py" = ["ANN401", "TID251"]
|
||||
"services/test_vector_service.py" = ["ARG001", "TID251"]
|
||||
"services/test_webhook_service_additional.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"services/test_website_service.py" = ["TID251"]
|
||||
"services/test_workflow_comment_service.py" = ["ARG001", "ARG002"]
|
||||
"services/test_workflow_run_service.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"services/test_workflow_service.py" = ["ANN401", "ARG002", "TID251"]
|
||||
"services/tools/test_builtin_tools_manage_service.py" = ["ARG001", "ARG002"]
|
||||
"services/tools/test_tools_manage_service.py" = ["ARG002"]
|
||||
"services/workflow/test_inspector_events.py" = ["ANN401", "TID251"]
|
||||
"services/workflow/test_node_output_inspector_service.py" = ["TID251"]
|
||||
"services/workflow/test_workflow_converter_additional.py" = ["ANN401", "ARG001", "ARG005", "TID251"]
|
||||
"services/workflow/test_workflow_event_snapshot_service.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"services/workflow/test_workflow_event_snapshot_service_additional.py" = ["ANN401", "ARG002", "ARG005", "TID251"]
|
||||
"tasks/test_agent_backend_session_cleanup_task.py" = ["ARG005"]
|
||||
"tasks/test_clean_dataset_task.py" = ["ARG002"]
|
||||
"tasks/test_clean_document_task.py" = ["ARG002"]
|
||||
"tasks/test_dataset_indexing_task.py" = ["ARG001", "ARG002"]
|
||||
"tasks/test_document_indexing_sync_task.py" = ["ARG002"]
|
||||
"tasks/test_duplicate_document_indexing_task.py" = ["ARG002"]
|
||||
"tasks/test_human_input_timeout_tasks.py" = ["ARG001", "ARG002", "ARG005", "TID251"]
|
||||
"tasks/test_initialize_created_app_rbac_access_task.py" = ["ARG005"]
|
||||
"tasks/test_mail_send_task.py" = ["ARG002"]
|
||||
"tasks/test_ops_trace_task.py" = ["ARG004"]
|
||||
"tasks/test_process_tenant_plugin_autoupgrade_check_task.py" = ["ARG001"]
|
||||
"tasks/test_remove_app_and_related_data_task.py" = ["ARG002"]
|
||||
"tasks/test_trigger_processing_tasks.py" = ["ARG002"]
|
||||
"tasks/test_workflow_execute_task.py" = ["ARG005"]
|
||||
"test_app_factory.py" = ["ARG001"]
|
||||
"test_pytest_dify.py" = ["ARG001"]
|
||||
"tools/test_mcp_tool.py" = ["TID251"]
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse"]
|
||||
msg = "Use Pydantic payload/query models instead of reqparse."
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"]
|
||||
msg = "Use Pydantic payload/query models instead of reqparse."
|
||||
|
||||
[lint.flake8-tidy-imports.banned-api."typing.Any"]
|
||||
msg = "Use object, Protocol, TypedDict, TypeVar, ParamSpec, or a localized cast instead."
|
||||
@@ -85,6 +85,42 @@ def main_branch_rev(repo: Path) -> str:
|
||||
return git(repo, "rev-parse", "main")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source_line", "rule_id"),
|
||||
[
|
||||
(
|
||||
"value = getattr(module, name) # guard-ignore: no-new-getattr -- lazy export proxy",
|
||||
"no-new-getattr",
|
||||
),
|
||||
(
|
||||
"session.rollback() # guard-ignore: no-new-controller-sqlalchemy -- decorator owns rollback",
|
||||
"no-new-controller-sqlalchemy",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_has_reasoned_guard_ignore_accepts_custom_rules(source_line: str, rule_id: str) -> None:
|
||||
module = load_guard_module()
|
||||
|
||||
assert module.has_reasoned_guard_ignore(source_line, rule_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source_line", "rule_id"),
|
||||
[
|
||||
("value = getattr(module, name) # noqa: no-new-getattr legacy marker", "no-new-getattr"),
|
||||
("value = getattr(module, name) # guard-ignore: no-new-getattr", "no-new-getattr"),
|
||||
(
|
||||
"value = getattr(module, name) # guard-ignore: another-rule -- wrong rule",
|
||||
"no-new-getattr",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_has_reasoned_guard_ignore_rejects_invalid_markers(source_line: str, rule_id: str) -> None:
|
||||
module = load_guard_module()
|
||||
|
||||
assert not module.has_reasoned_guard_ignore(source_line, rule_id)
|
||||
|
||||
|
||||
def test_resolve_ast_grep_command_prefers_ast_grep(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
module = load_guard_module()
|
||||
monkeypatch.setattr(
|
||||
@@ -776,7 +812,7 @@ def test_modified_hunk_with_increased_getattr_count_fails(tmp_path: Path) -> Non
|
||||
assert "net-new getattr" in result.stderr
|
||||
|
||||
|
||||
def test_inline_noqa_suppression_with_explanatory_text_skips_added_getattr(tmp_path: Path) -> None:
|
||||
def test_inline_guard_ignore_with_explanatory_text_skips_added_getattr(tmp_path: Path) -> None:
|
||||
init_repo(tmp_path)
|
||||
write_repo_file(
|
||||
tmp_path,
|
||||
@@ -795,20 +831,20 @@ def test_inline_noqa_suppression_with_explanatory_text_skips_added_getattr(tmp_p
|
||||
"pkg/existing.py",
|
||||
"""
|
||||
def read_value(obj):
|
||||
return getattr(obj, "dynamic_name", None) # noqa: no-new-getattr needed for plugin-defined attributes
|
||||
return getattr(obj, "dynamic_name", None) # guard-ignore: no-new-getattr -- plugin-defined attributes
|
||||
""",
|
||||
)
|
||||
commit_all(tmp_path, "add suppressed getattr")
|
||||
|
||||
result = run_script(tmp_path, "--base-rev", base_rev)
|
||||
|
||||
assert "no-new-getattr needed for plugin-defined attributes" in (tmp_path / "pkg/existing.py").read_text(
|
||||
assert "guard-ignore: no-new-getattr -- plugin-defined attributes" in (tmp_path / "pkg/existing.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert result.returncode == 0, stderr_lines(result)
|
||||
|
||||
|
||||
def test_inline_noqa_without_explanatory_text_is_not_sufficient(tmp_path: Path) -> None:
|
||||
def test_inline_guard_ignore_without_explanatory_text_is_not_sufficient(tmp_path: Path) -> None:
|
||||
init_repo(tmp_path)
|
||||
write_repo_file(
|
||||
tmp_path,
|
||||
@@ -827,10 +863,10 @@ def test_inline_noqa_without_explanatory_text_is_not_sufficient(tmp_path: Path)
|
||||
"pkg/existing.py",
|
||||
"""
|
||||
def read_value(obj):
|
||||
return getattr(obj, "dynamic_name", None) # noqa: no-new-getattr
|
||||
return getattr(obj, "dynamic_name", None) # guard-ignore: no-new-getattr
|
||||
""",
|
||||
)
|
||||
commit_all(tmp_path, "add bare noqa getattr")
|
||||
commit_all(tmp_path, "add bare guard ignore getattr")
|
||||
|
||||
result = run_script(tmp_path, "--base-rev", base_rev)
|
||||
|
||||
|
||||
@@ -6,13 +6,19 @@ from unittest.mock import ANY, MagicMock, patch
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from jwt import InvalidTokenError
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
import services.errors.account
|
||||
from controllers.console import wraps as console_wraps
|
||||
from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi, LoginApi, LoginStatusApi, LogoutApi
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from models.model import DifySetup
|
||||
from services.entities.auth_entities import LoginFailureReason
|
||||
|
||||
pytestmark = pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
|
||||
|
||||
def encode_code(code: str) -> str:
|
||||
return base64.b64encode(code.encode("utf-8")).decode()
|
||||
@@ -33,17 +39,27 @@ def app():
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_wraps():
|
||||
def _patch_wraps(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
wraps_features = SimpleNamespace(enable_email_password_login=True)
|
||||
console_dify = SimpleNamespace(ENTERPRISE_ENABLED=True, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
|
||||
web_dify = SimpleNamespace(ENTERPRISE_ENABLED=True)
|
||||
sqlite_session.add(DifySetup(version="test"))
|
||||
sqlite_session.commit()
|
||||
console_wraps._is_setup_completed.reset_success()
|
||||
session_registry = scoped_session(sessionmaker(bind=sqlite_engine, expire_on_commit=False))
|
||||
monkeypatch.setattr(console_wraps.db, "session", session_registry)
|
||||
with (
|
||||
patch("controllers.console.wraps.db") as mock_db,
|
||||
patch("controllers.console.wraps.dify_config", console_dify),
|
||||
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features),
|
||||
patch("controllers.web.login.dify_config", web_dify),
|
||||
):
|
||||
yield
|
||||
session_registry.remove()
|
||||
console_wraps._is_setup_completed.reset_success()
|
||||
|
||||
|
||||
class TestEmailCodeLoginSendEmailApi:
|
||||
|
||||
@@ -2,7 +2,16 @@ from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from core.workflow.nodes.agent.runtime_support import AgentRuntimeSupport
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.model_entities import (
|
||||
AIModelEntity,
|
||||
FetchFrom,
|
||||
ModelFeature,
|
||||
ModelPropertyKey,
|
||||
ModelType,
|
||||
ParameterRule,
|
||||
ParameterType,
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_model_reuses_single_model_assembly():
|
||||
@@ -47,3 +56,98 @@ def test_fetch_model_reuses_single_model_assembly():
|
||||
model_type=ModelType.LLM,
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
|
||||
def _make_model_schema_with_defaults() -> AIModelEntity:
|
||||
"""Return a minimal AIModelEntity whose parameter_rules carry defaults."""
|
||||
return AIModelEntity(
|
||||
model="qwen-max",
|
||||
label=I18nObject(en_US="Qwen Max"),
|
||||
model_type=ModelType.LLM,
|
||||
features=[ModelFeature.AGENT_THOUGHT, ModelFeature.MULTI_TOOL_CALL],
|
||||
fetch_from=FetchFrom.PREDEFINED_MODEL,
|
||||
model_properties={
|
||||
ModelPropertyKey.MODE: "chat",
|
||||
ModelPropertyKey.CONTEXT_SIZE: 32768,
|
||||
},
|
||||
parameter_rules=[
|
||||
ParameterRule(
|
||||
name="temperature",
|
||||
use_template="temperature",
|
||||
label=I18nObject(en_US="Temperature"),
|
||||
type=ParameterType.FLOAT,
|
||||
required=False,
|
||||
default=0.7,
|
||||
min=0.0,
|
||||
max=2.0,
|
||||
precision=2,
|
||||
),
|
||||
ParameterRule(
|
||||
name="max_tokens",
|
||||
use_template="max_tokens",
|
||||
label=I18nObject(en_US="Max Tokens"),
|
||||
type=ParameterType.INT,
|
||||
required=False,
|
||||
default=2048,
|
||||
min=1,
|
||||
max=32768,
|
||||
),
|
||||
ParameterRule(
|
||||
name="top_p",
|
||||
use_template="top_p",
|
||||
label=I18nObject(en_US="Top P"),
|
||||
type=ParameterType.FLOAT,
|
||||
required=False,
|
||||
default=1.0,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_extract_default_completion_params_collects_rule_defaults():
|
||||
"""_extract_default_completion_params should gather every rule.default."""
|
||||
schema = _make_model_schema_with_defaults()
|
||||
params = AgentRuntimeSupport._extract_default_completion_params(schema)
|
||||
assert params == {"temperature": 0.7, "max_tokens": 2048, "top_p": 1.0}
|
||||
|
||||
|
||||
def test_extract_default_completion_params_skips_rules_without_default():
|
||||
"""Rules whose default is None must not appear in the result."""
|
||||
schema = AIModelEntity(
|
||||
model="test-model",
|
||||
label=I18nObject(en_US="Test"),
|
||||
model_type=ModelType.LLM,
|
||||
fetch_from=FetchFrom.PREDEFINED_MODEL,
|
||||
model_properties={ModelPropertyKey.MODE: "chat"},
|
||||
parameter_rules=[
|
||||
ParameterRule(
|
||||
name="seed",
|
||||
label=I18nObject(en_US="Seed"),
|
||||
type=ParameterType.INT,
|
||||
required=False,
|
||||
default=None,
|
||||
),
|
||||
ParameterRule(
|
||||
name="temperature",
|
||||
label=I18nObject(en_US="Temperature"),
|
||||
type=ParameterType.FLOAT,
|
||||
required=False,
|
||||
default=0.5,
|
||||
),
|
||||
],
|
||||
)
|
||||
params = AgentRuntimeSupport._extract_default_completion_params(schema)
|
||||
assert params == {"temperature": 0.5}
|
||||
|
||||
|
||||
def test_extract_default_completion_params_empty_when_no_defaults():
|
||||
"""An empty parameter_rules list yields an empty dict."""
|
||||
schema = AIModelEntity(
|
||||
model="test-model",
|
||||
label=I18nObject(en_US="Test"),
|
||||
model_type=ModelType.LLM,
|
||||
fetch_from=FetchFrom.PREDEFINED_MODEL,
|
||||
model_properties={ModelPropertyKey.MODE: "chat"},
|
||||
parameter_rules=[],
|
||||
)
|
||||
assert AgentRuntimeSupport._extract_default_completion_params(schema) == {}
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
preset = "strict"
|
||||
project-includes = ["."]
|
||||
search-path = ["../.."]
|
||||
python-platform = "linux"
|
||||
python-version = "3.12.0"
|
||||
infer-with-first-use = true
|
||||
min-severity = "warn"
|
||||
|
||||
# Existing strict-mode debt. Remove a file when bringing it under strict checking.
|
||||
project-excludes = [
|
||||
"clients/agent_backend/test_cleanup_composition_compositor_integration.py",
|
||||
"clients/agent_backend/test_client.py",
|
||||
"clients/agent_backend/test_event_adapter.py",
|
||||
"clients/agent_backend/test_fake_client.py",
|
||||
"clients/agent_backend/test_request_builder.py",
|
||||
"clients/agent_backend/test_session_cleanup.py",
|
||||
"commands/test_archive_workflow_runs.py",
|
||||
"commands/test_clean_expired_messages.py",
|
||||
"commands/test_data_migration_commands.py",
|
||||
"commands/test_data_migration_wizard.py",
|
||||
"commands/test_fix_app_site_missing.py",
|
||||
"commands/test_generate_swagger_markdown_docs.py",
|
||||
"commands/test_generate_swagger_specs.py",
|
||||
"commands/test_legacy_model_type_migration.py",
|
||||
"commands/test_lint_response_contracts.py",
|
||||
"commands/test_reset_encrypt_key_pair.py",
|
||||
"commands/test_upgrade_db.py",
|
||||
"configs/test_dify_config.py",
|
||||
"configs/test_env_consistency.py",
|
||||
"configs/test_nacos_http_client.py",
|
||||
"conftest.py",
|
||||
"controllers/common/test_agent_app_parameters.py",
|
||||
"controllers/common/test_app_access.py",
|
||||
"controllers/common/test_errors.py",
|
||||
"controllers/common/test_fields.py",
|
||||
"controllers/common/test_file_response.py",
|
||||
"controllers/common/test_helpers.py",
|
||||
"controllers/common/test_schema.py",
|
||||
"controllers/common/test_session.py",
|
||||
"controllers/console/agent/test_agent_controllers.py",
|
||||
"controllers/console/app/test_agent_app_sandbox.py",
|
||||
"controllers/console/app/test_agent_config_inspector.py",
|
||||
"controllers/console/app/test_agent_drive_inspector.py",
|
||||
"controllers/console/app/test_agent_manage_guard.py",
|
||||
"controllers/console/app/test_agent_skills.py",
|
||||
"controllers/console/app/test_annotation_api.py",
|
||||
"controllers/console/app/test_annotation_security.py",
|
||||
"controllers/console/app/test_app_apis.py",
|
||||
"controllers/console/app/test_app_import_api.py",
|
||||
"controllers/console/app/test_app_response_models.py",
|
||||
"controllers/console/app/test_audio.py",
|
||||
"controllers/console/app/test_conversation_api.py",
|
||||
"controllers/console/app/test_conversation_variables_api.py",
|
||||
"controllers/console/app/test_create_app_payload.py",
|
||||
"controllers/console/app/test_description_validation.py",
|
||||
"controllers/console/app/test_generator_api.py",
|
||||
"controllers/console/app/test_generator_api_missing.py",
|
||||
"controllers/console/app/test_mcp_server_response.py",
|
||||
"controllers/console/app/test_message_api.py",
|
||||
"controllers/console/app/test_model_config_api.py",
|
||||
"controllers/console/app/test_ops_trace_api.py",
|
||||
"controllers/console/app/test_statistic_api.py",
|
||||
"controllers/console/app/test_workflow.py",
|
||||
"controllers/console/app/test_workflow_app_log_api.py",
|
||||
"controllers/console/app/test_workflow_comment_api.py",
|
||||
"controllers/console/app/test_workflow_convert_api.py",
|
||||
"controllers/console/app/test_workflow_node_output_inspector.py",
|
||||
"controllers/console/app/test_workflow_pause_details_api.py",
|
||||
"controllers/console/app/test_workflow_run_api.py",
|
||||
"controllers/console/app/test_workflow_trigger_api.py",
|
||||
"controllers/console/app/test_wraps.py",
|
||||
"controllers/console/app/workflow_draft_variables_test.py",
|
||||
"controllers/console/auth/test_account_activation.py",
|
||||
"controllers/console/auth/test_authentication_security.py",
|
||||
"controllers/console/auth/test_data_source_bearer_auth.py",
|
||||
"controllers/console/auth/test_email_register.py",
|
||||
"controllers/console/auth/test_email_register_language.py",
|
||||
"controllers/console/auth/test_email_verification.py",
|
||||
"controllers/console/auth/test_forgot_password.py",
|
||||
"controllers/console/auth/test_login_logout.py",
|
||||
"controllers/console/auth/test_oauth.py",
|
||||
"controllers/console/auth/test_oauth_timezone.py",
|
||||
"controllers/console/auth/test_password_reset.py",
|
||||
"controllers/console/auth/test_token_refresh.py",
|
||||
"controllers/console/billing/test_billing.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_datasource_auth.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_datasource_content_preview.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py",
|
||||
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py",
|
||||
"controllers/console/datasets/test_data_source.py",
|
||||
"controllers/console/datasets/test_datasets.py",
|
||||
"controllers/console/datasets/test_datasets_document.py",
|
||||
"controllers/console/datasets/test_datasets_document_download.py",
|
||||
"controllers/console/datasets/test_datasets_segments.py",
|
||||
"controllers/console/datasets/test_external.py",
|
||||
"controllers/console/datasets/test_hit_testing.py",
|
||||
"controllers/console/datasets/test_hit_testing_base.py",
|
||||
"controllers/console/datasets/test_metadata.py",
|
||||
"controllers/console/datasets/test_website.py",
|
||||
"controllers/console/datasets/test_wraps.py",
|
||||
"controllers/console/explore/test_audio.py",
|
||||
"controllers/console/explore/test_banner.py",
|
||||
"controllers/console/explore/test_completion.py",
|
||||
"controllers/console/explore/test_installed_app.py",
|
||||
"controllers/console/explore/test_message.py",
|
||||
"controllers/console/explore/test_parameter.py",
|
||||
"controllers/console/explore/test_recommended_app.py",
|
||||
"controllers/console/explore/test_saved_message.py",
|
||||
"controllers/console/explore/test_trial.py",
|
||||
"controllers/console/explore/test_workflow.py",
|
||||
"controllers/console/explore/test_wraps.py",
|
||||
"controllers/console/snippets/test_snippet_workflow.py",
|
||||
"controllers/console/snippets/test_snippet_workflow_draft_variable.py",
|
||||
"controllers/console/tag/test_tags.py",
|
||||
"controllers/console/test_document_detail_api_data_source_info.py",
|
||||
"controllers/console/test_extension.py",
|
||||
"controllers/console/test_fastopenapi_ping.py",
|
||||
"controllers/console/test_fastopenapi_setup.py",
|
||||
"controllers/console/test_fastopenapi_version.py",
|
||||
"controllers/console/test_feature.py",
|
||||
"controllers/console/test_files.py",
|
||||
"controllers/console/test_files_security.py",
|
||||
"controllers/console/test_human_input_form.py",
|
||||
"controllers/console/test_knowledge_fs_proxy.py",
|
||||
"controllers/console/test_remote_files.py",
|
||||
"controllers/console/test_spec.py",
|
||||
"controllers/console/test_version.py",
|
||||
"controllers/console/test_workflow_run_archive.py",
|
||||
"controllers/console/test_workspace_account.py",
|
||||
"controllers/console/test_workspace_members.py",
|
||||
"controllers/console/test_wraps.py",
|
||||
"controllers/console/workspace/test_accounts.py",
|
||||
"controllers/console/workspace/test_agent_providers.py",
|
||||
"controllers/console/workspace/test_endpoint.py",
|
||||
"controllers/console/workspace/test_load_balancing_config.py",
|
||||
"controllers/console/workspace/test_members.py",
|
||||
"controllers/console/workspace/test_model_providers.py",
|
||||
"controllers/console/workspace/test_models.py",
|
||||
"controllers/console/workspace/test_plugin.py",
|
||||
"controllers/console/workspace/test_rbac.py",
|
||||
"controllers/console/workspace/test_snippets.py",
|
||||
"controllers/console/workspace/test_tool_providers.py",
|
||||
"controllers/console/workspace/test_workspace.py",
|
||||
"controllers/files/test_image_preview.py",
|
||||
"controllers/files/test_tool_files.py",
|
||||
"controllers/files/test_upload.py",
|
||||
"controllers/inner_api/app/test_dsl.py",
|
||||
"controllers/inner_api/plugin/test_agent_config.py",
|
||||
"controllers/inner_api/plugin/test_agent_drive.py",
|
||||
"controllers/inner_api/plugin/test_plugin.py",
|
||||
"controllers/inner_api/plugin/test_plugin_wraps.py",
|
||||
"controllers/inner_api/test_auth_wraps.py",
|
||||
"controllers/inner_api/test_knowledge_retrieval.py",
|
||||
"controllers/inner_api/test_mail.py",
|
||||
"controllers/inner_api/test_runtime_credentials.py",
|
||||
"controllers/inner_api/workspace/test_workspace.py",
|
||||
"controllers/mcp/test_mcp.py",
|
||||
"controllers/openapi/auth/test_composition.py",
|
||||
"controllers/openapi/auth/test_conditions.py",
|
||||
"controllers/openapi/auth/test_data.py",
|
||||
"controllers/openapi/auth/test_flow.py",
|
||||
"controllers/openapi/auth/test_pipeline.py",
|
||||
"controllers/openapi/auth/test_prepare.py",
|
||||
"controllers/openapi/auth/test_verify.py",
|
||||
"controllers/openapi/conftest.py",
|
||||
"controllers/openapi/test_account.py",
|
||||
"controllers/openapi/test_app_describe_builder.py",
|
||||
"controllers/openapi/test_app_list_query.py",
|
||||
"controllers/openapi/test_app_payloads.py",
|
||||
"controllers/openapi/test_app_run_dispatch.py",
|
||||
"controllers/openapi/test_app_run_rate_limit.py",
|
||||
"controllers/openapi/test_app_run_streaming.py",
|
||||
"controllers/openapi/test_apps_permitted_external_query.py",
|
||||
"controllers/openapi/test_audit_app_run.py",
|
||||
"controllers/openapi/test_contract.py",
|
||||
"controllers/openapi/test_cors.py",
|
||||
"controllers/openapi/test_device_approve_deny.py",
|
||||
"controllers/openapi/test_device_code.py",
|
||||
"controllers/openapi/test_device_lookup.py",
|
||||
"controllers/openapi/test_device_sso.py",
|
||||
"controllers/openapi/test_device_token.py",
|
||||
"controllers/openapi/test_error_contract.py",
|
||||
"controllers/openapi/test_health.py",
|
||||
"controllers/openapi/test_human_input_form.py",
|
||||
"controllers/openapi/test_input_schema.py",
|
||||
"controllers/openapi/test_meta_version.py",
|
||||
"controllers/openapi/test_models.py",
|
||||
"controllers/openapi/test_oauth_sso_claims.py",
|
||||
"controllers/openapi/test_oauth_sso_csrf.py",
|
||||
"controllers/openapi/test_oauth_sso_host_header.py",
|
||||
"controllers/openapi/test_pagination_envelope.py",
|
||||
"controllers/openapi/test_supported_app_type.py",
|
||||
"controllers/openapi/test_version_gate.py",
|
||||
"controllers/openapi/test_workflow_events_openapi.py",
|
||||
"controllers/openapi/test_workspaces.py",
|
||||
"controllers/openapi/test_workspaces_members.py",
|
||||
"controllers/service_api/app/test_annotation.py",
|
||||
"controllers/service_api/app/test_app.py",
|
||||
"controllers/service_api/app/test_audio.py",
|
||||
"controllers/service_api/app/test_chat_request_payload.py",
|
||||
"controllers/service_api/app/test_completion.py",
|
||||
"controllers/service_api/app/test_conversation.py",
|
||||
"controllers/service_api/app/test_file.py",
|
||||
"controllers/service_api/app/test_file_preview.py",
|
||||
"controllers/service_api/app/test_hitl_service_api.py",
|
||||
"controllers/service_api/app/test_human_input_form.py",
|
||||
"controllers/service_api/app/test_message.py",
|
||||
"controllers/service_api/app/test_workflow.py",
|
||||
"controllers/service_api/app/test_workflow_events.py",
|
||||
"controllers/service_api/conftest.py",
|
||||
"controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py",
|
||||
"controllers/service_api/dataset/test_dataset_segment.py",
|
||||
"controllers/service_api/dataset/test_document.py",
|
||||
"controllers/service_api/dataset/test_hit_testing.py",
|
||||
"controllers/service_api/dataset/test_metadata.py",
|
||||
"controllers/service_api/dataset/test_rag_pipeline_file_upload_serialization.py",
|
||||
"controllers/service_api/dataset/test_rag_pipeline_route_registration.py",
|
||||
"controllers/service_api/test_index.py",
|
||||
"controllers/service_api/test_trace_session_id_parsing.py",
|
||||
"controllers/service_api/test_wraps.py",
|
||||
"controllers/test_compare_versions.py",
|
||||
"controllers/test_conversation_rename_payload.py",
|
||||
"controllers/test_swagger.py",
|
||||
"controllers/trigger/test_trigger.py",
|
||||
"controllers/trigger/test_webhook.py",
|
||||
"controllers/web/conftest.py",
|
||||
"controllers/web/test_app.py",
|
||||
"controllers/web/test_completion.py",
|
||||
"controllers/web/test_human_input_file_upload.py",
|
||||
"controllers/web/test_human_input_form.py",
|
||||
"controllers/web/test_message_endpoints.py",
|
||||
"controllers/web/test_message_list.py",
|
||||
"controllers/web/test_pydantic_models.py",
|
||||
"controllers/web/test_web_forgot_password.py",
|
||||
"controllers/web/test_web_login.py",
|
||||
"controllers/web/test_web_passport.py",
|
||||
"controllers/web/test_workflow.py",
|
||||
"controllers/web/test_wraps.py",
|
||||
"core/agent/conftest.py",
|
||||
"core/agent/output_parser/test_cot_output_parser.py",
|
||||
"core/agent/strategy/test_base.py",
|
||||
"core/agent/strategy/test_plugin.py",
|
||||
"core/agent/test_base_agent_runner.py",
|
||||
"core/agent/test_cot_agent_runner.py",
|
||||
"core/agent/test_cot_chat_agent_runner.py",
|
||||
"core/agent/test_cot_completion_agent_runner.py",
|
||||
"core/agent/test_fc_agent_runner.py",
|
||||
"core/agent/test_plugin_entities.py",
|
||||
"core/app/app_config/common/test_parameters_mapping.py",
|
||||
"core/app/app_config/common/test_sensitive_word_avoidance_manager.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_agent_manager.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_dataset_manager.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_model_config_converter.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_model_config_manager.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_prompt_template_manager.py",
|
||||
"core/app/app_config/easy_ui_based_app/test_variables_manager.py",
|
||||
"core/app/app_config/features/file_upload/test_manager.py",
|
||||
"core/app/app_config/features/test_additional_feature_managers.py",
|
||||
"core/app/app_config/test_base_app_config_manager.py",
|
||||
"core/app/app_config/test_entities.py",
|
||||
"core/app/app_config/workflow_ui_based_app/test_workflow_ui_based_app_manager.py",
|
||||
"core/app/apps/advanced_chat/test_app_config_manager.py",
|
||||
"core/app/apps/advanced_chat/test_app_generator.py",
|
||||
"core/app/apps/advanced_chat/test_app_runner_conversation_variables.py",
|
||||
"core/app/apps/advanced_chat/test_app_runner_input_moderation.py",
|
||||
"core/app/apps/advanced_chat/test_generate_response_converter.py",
|
||||
"core/app/apps/advanced_chat/test_generate_task_pipeline.py",
|
||||
"core/app/apps/advanced_chat/test_generate_task_pipeline_core.py",
|
||||
"core/app/apps/agent_app/test_app_config_manager.py",
|
||||
"core/app/apps/agent_app/test_app_generator.py",
|
||||
"core/app/apps/agent_app/test_app_runner.py",
|
||||
"core/app/apps/agent_app/test_input_guards.py",
|
||||
"core/app/apps/agent_app/test_resolve_agent.py",
|
||||
"core/app/apps/agent_app/test_runtime_request_builder.py",
|
||||
"core/app/apps/agent_app/test_session_store.py",
|
||||
"core/app/apps/agent_chat/test_agent_chat_app_config_manager.py",
|
||||
"core/app/apps/agent_chat/test_agent_chat_app_generator.py",
|
||||
"core/app/apps/agent_chat/test_agent_chat_app_runner.py",
|
||||
"core/app/apps/agent_chat/test_agent_chat_generate_response_converter.py",
|
||||
"core/app/apps/chat/test_app_config_manager.py",
|
||||
"core/app/apps/chat/test_app_generator_and_runner.py",
|
||||
"core/app/apps/chat/test_base_app_runner_multimodal.py",
|
||||
"core/app/apps/chat/test_generate_response_converter.py",
|
||||
"core/app/apps/common/test_graph_runtime_state_support.py",
|
||||
"core/app/apps/common/test_workflow_response_converter.py",
|
||||
"core/app/apps/common/test_workflow_response_converter_human_input.py",
|
||||
"core/app/apps/common/test_workflow_response_converter_resumption.py",
|
||||
"core/app/apps/common/test_workflow_response_converter_truncation.py",
|
||||
"core/app/apps/completion/test_app_runner.py",
|
||||
"core/app/apps/completion/test_completion_app_config_manager.py",
|
||||
"core/app/apps/completion/test_completion_completion_app_generator.py",
|
||||
"core/app/apps/completion/test_completion_generate_response_converter.py",
|
||||
"core/app/apps/pipeline/test_pipeline_config_manager.py",
|
||||
"core/app/apps/pipeline/test_pipeline_generate_response_converter.py",
|
||||
"core/app/apps/pipeline/test_pipeline_generator.py",
|
||||
"core/app/apps/pipeline/test_pipeline_queue_manager.py",
|
||||
"core/app/apps/pipeline/test_pipeline_runner.py",
|
||||
"core/app/apps/test_advanced_chat_app_generator.py",
|
||||
"core/app/apps/test_base_app_generate_response_converter.py",
|
||||
"core/app/apps/test_base_app_generator.py",
|
||||
"core/app/apps/test_base_app_queue_manager.py",
|
||||
"core/app/apps/test_base_app_runner.py",
|
||||
"core/app/apps/test_exc.py",
|
||||
"core/app/apps/test_message_based_app_generator.py",
|
||||
"core/app/apps/test_message_based_app_queue_manager.py",
|
||||
"core/app/apps/test_message_generator.py",
|
||||
"core/app/apps/test_pause_resume.py",
|
||||
"core/app/apps/test_streaming_utils.py",
|
||||
"core/app/apps/test_trace_session_id_generate_extras.py",
|
||||
"core/app/apps/test_workflow_app_generator.py",
|
||||
"core/app/apps/test_workflow_app_runner_core.py",
|
||||
"core/app/apps/test_workflow_app_runner_notifications.py",
|
||||
"core/app/apps/test_workflow_app_runner_single_node.py",
|
||||
"core/app/apps/test_workflow_pause_events.py",
|
||||
"core/app/apps/workflow/test_active_workflow_tasks.py",
|
||||
"core/app/apps/workflow/test_app_config_manager.py",
|
||||
"core/app/apps/workflow/test_app_generator_extra.py",
|
||||
"core/app/apps/workflow/test_app_queue_manager.py",
|
||||
"core/app/apps/workflow/test_command_channels.py",
|
||||
"core/app/apps/workflow/test_errors.py",
|
||||
"core/app/apps/workflow/test_generate_response_converter.py",
|
||||
"core/app/apps/workflow/test_generate_task_pipeline.py",
|
||||
"core/app/apps/workflow/test_generate_task_pipeline_core.py",
|
||||
"core/app/entities/test_app_invoke_entities.py",
|
||||
"core/app/entities/test_queue_entities.py",
|
||||
"core/app/entities/test_rag_pipeline_invoke_entities.py",
|
||||
"core/app/entities/test_task_entities.py",
|
||||
"core/app/features/rate_limiting/conftest.py",
|
||||
"core/app/features/rate_limiting/test_rate_limit.py",
|
||||
"core/app/features/test_annotation_reply.py",
|
||||
"core/app/features/test_hosting_moderation.py",
|
||||
"core/app/layers/test_conversation_variable_persist_layer.py",
|
||||
"core/app/layers/test_pause_state_persist_layer.py",
|
||||
"core/app/layers/test_suspend_layer.py",
|
||||
"core/app/layers/test_timeslice_layer.py",
|
||||
"core/app/layers/test_trigger_post_layer.py",
|
||||
"core/app/task_pipeline/test_based_generate_task_pipeline.py",
|
||||
"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py",
|
||||
"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py",
|
||||
"core/app/task_pipeline/test_exc.py",
|
||||
"core/app/task_pipeline/test_message_cycle_manager_optimization.py",
|
||||
"core/app/test_easy_ui_model_config_manager.py",
|
||||
"core/app/test_invoke_from.py",
|
||||
"core/app/test_llm_quota.py",
|
||||
"core/app/workflow/layers/test_persistence.py",
|
||||
"core/app/workflow/layers/test_persistence_inspector_publish.py",
|
||||
"core/app/workflow/test_file_runtime.py",
|
||||
"core/app/workflow/test_node_factory.py",
|
||||
"core/app/workflow/test_observability_layer_extra.py",
|
||||
"core/app/workflow/test_persistence_layer.py",
|
||||
"core/base/test_app_generator_tts_publisher.py",
|
||||
"core/callback_handler/test_agent_tool_callback_handler.py",
|
||||
"core/callback_handler/test_index_tool_callback_handler.py",
|
||||
"core/callback_handler/test_workflow_tool_callback_handler.py",
|
||||
"core/datasource/__base/test_datasource_plugin.py",
|
||||
"core/datasource/__base/test_datasource_provider.py",
|
||||
"core/datasource/__base/test_datasource_runtime.py",
|
||||
"core/datasource/entities/test_api_entities.py",
|
||||
"core/datasource/entities/test_common_entities.py",
|
||||
"core/datasource/entities/test_datasource_entities.py",
|
||||
"core/datasource/local_file/test_local_file_plugin.py",
|
||||
"core/datasource/local_file/test_local_file_provider.py",
|
||||
"core/datasource/online_document/test_online_document_plugin.py",
|
||||
"core/datasource/online_document/test_online_document_provider.py",
|
||||
"core/datasource/online_drive/test_online_drive_plugin.py",
|
||||
"core/datasource/online_drive/test_online_drive_provider.py",
|
||||
"core/datasource/test_datasource_file_manager.py",
|
||||
"core/datasource/test_datasource_manager.py",
|
||||
"core/datasource/test_errors.py",
|
||||
"core/datasource/test_file_upload.py",
|
||||
"core/datasource/test_notion_provider.py",
|
||||
"core/datasource/test_website_crawl.py",
|
||||
"core/datasource/utils/test_message_transformer.py",
|
||||
"core/datasource/website_crawl/test_website_crawl_plugin.py",
|
||||
"core/datasource/website_crawl/test_website_crawl_provider.py",
|
||||
"core/entities/test_entities_mcp_provider.py",
|
||||
"core/entities/test_entities_provider_configuration.py",
|
||||
"core/extension/test_api_based_extension_requestor.py",
|
||||
"core/extension/test_extensible.py",
|
||||
"core/extension/test_extension.py",
|
||||
"core/external_data_tool/api/test_api.py",
|
||||
"core/external_data_tool/test_base.py",
|
||||
"core/external_data_tool/test_external_data_fetch.py",
|
||||
"core/external_data_tool/test_factory.py",
|
||||
"core/file/test_models.py",
|
||||
"core/file/test_remote_fetcher.py",
|
||||
"core/helper/code_executor/javascript/test_javascript_transformer.py",
|
||||
"core/helper/code_executor/jinja2/test_jinja2_sandbox.py",
|
||||
"core/helper/code_executor/python3/test_python3_transformer.py",
|
||||
"core/helper/code_executor/test_code_executor.py",
|
||||
"core/helper/code_executor/test_code_node_provider.py",
|
||||
"core/helper/code_executor/test_template_transformer.py",
|
||||
"core/helper/test_creators.py",
|
||||
"core/helper/test_credential_utils.py",
|
||||
"core/helper/test_csv_sanitizer.py",
|
||||
"core/helper/test_encrypter.py",
|
||||
"core/helper/test_ssrf_proxy.py",
|
||||
"core/helper/test_trace_id_helper.py",
|
||||
"core/llm_generator/output_parser/test_rule_config_generator.py",
|
||||
"core/llm_generator/output_parser/test_structured_output.py",
|
||||
"core/llm_generator/test_llm_generator.py",
|
||||
"core/llm_generator/test_llm_generator_missing.py",
|
||||
"core/logging/test_context.py",
|
||||
"core/logging/test_filters.py",
|
||||
"core/logging/test_structured_formatter.py",
|
||||
"core/logging/test_trace_helpers.py",
|
||||
"core/mcp/auth/test_auth_flow.py",
|
||||
"core/mcp/client/test_session.py",
|
||||
"core/mcp/client/test_sse.py",
|
||||
"core/mcp/client/test_streamable_http.py",
|
||||
"core/mcp/server/test_streamable_http.py",
|
||||
"core/mcp/session/test_base_session.py",
|
||||
"core/mcp/session/test_client_session.py",
|
||||
"core/mcp/test_auth_client_inheritance.py",
|
||||
"core/mcp/test_entities.py",
|
||||
"core/mcp/test_error.py",
|
||||
"core/mcp/test_mcp_client.py",
|
||||
"core/mcp/test_types.py",
|
||||
"core/mcp/test_utils.py",
|
||||
"core/memory/test_token_buffer_memory.py",
|
||||
"core/model_runtime/test_model_provider_factory.py",
|
||||
"core/moderation/api/test_api.py",
|
||||
"core/moderation/test_content_moderation.py",
|
||||
"core/moderation/test_input_moderation.py",
|
||||
"core/moderation/test_output_moderation.py",
|
||||
"core/moderation/test_sensitive_word_filter.py",
|
||||
"core/ops/test_base_trace_instance.py",
|
||||
"core/ops/test_config_entity.py",
|
||||
"core/ops/test_lookup_helpers.py",
|
||||
"core/ops/test_ops_trace_manager.py",
|
||||
"core/ops/test_trace_queue_manager.py",
|
||||
"core/ops/test_trace_session_metadata.py",
|
||||
"core/ops/test_utils.py",
|
||||
"core/plugin/impl/test_agent_client.py",
|
||||
"core/plugin/impl/test_asset_manager.py",
|
||||
"core/plugin/impl/test_base_client_impl.py",
|
||||
"core/plugin/impl/test_datasource_manager.py",
|
||||
"core/plugin/impl/test_debugging_client.py",
|
||||
"core/plugin/impl/test_endpoint_client_impl.py",
|
||||
"core/plugin/impl/test_exc_impl.py",
|
||||
"core/plugin/impl/test_model_client.py",
|
||||
"core/plugin/impl/test_model_runtime_factory.py",
|
||||
"core/plugin/impl/test_oauth_handler.py",
|
||||
"core/plugin/impl/test_tool_manager.py",
|
||||
"core/plugin/impl/test_trigger_client.py",
|
||||
"core/plugin/test_backwards_invocation_app.py",
|
||||
"core/plugin/test_backwards_invocation_model.py",
|
||||
"core/plugin/test_endpoint_client.py",
|
||||
"core/plugin/test_model_runtime_adapter.py",
|
||||
"core/plugin/test_plugin_entities.py",
|
||||
"core/plugin/test_plugin_manager.py",
|
||||
"core/plugin/test_plugin_runtime.py",
|
||||
"core/plugin/utils/test_chunk_merger.py",
|
||||
"core/plugin/utils/test_http_parser.py",
|
||||
"core/prompt/test_advanced_prompt_transform.py",
|
||||
"core/prompt/test_agent_history_prompt_transform.py",
|
||||
"core/prompt/test_extract_thread_messages.py",
|
||||
"core/prompt/test_prompt_message.py",
|
||||
"core/prompt/test_prompt_transform.py",
|
||||
"core/prompt/test_simple_prompt_transform.py",
|
||||
"core/rag/cleaner/test_clean_processor.py",
|
||||
"core/rag/data_post_processor/test_data_post_processor.py",
|
||||
"core/rag/datasource/keyword/jieba/test_jieba.py",
|
||||
"core/rag/datasource/keyword/jieba/test_jieba_keyword_table_handler.py",
|
||||
"core/rag/datasource/keyword/jieba/test_stopwords.py",
|
||||
"core/rag/datasource/keyword/test_keyword_base.py",
|
||||
"core/rag/datasource/keyword/test_keyword_factory.py",
|
||||
"core/rag/datasource/test_datasource_retrieval.py",
|
||||
"core/rag/datasource/vdb/test_field.py",
|
||||
"core/rag/datasource/vdb/test_vector_base.py",
|
||||
"core/rag/datasource/vdb/test_vector_factory.py",
|
||||
"core/rag/docstore/test_dataset_docstore.py",
|
||||
"core/rag/embedding/test_cached_embedding.py",
|
||||
"core/rag/embedding/test_embedding_base.py",
|
||||
"core/rag/embedding/test_embedding_service.py",
|
||||
"core/rag/extractor/blob/test_blob.py",
|
||||
"core/rag/extractor/firecrawl/test_firecrawl.py",
|
||||
"core/rag/extractor/test_csv_extractor.py",
|
||||
"core/rag/extractor/test_excel_extractor.py",
|
||||
"core/rag/extractor/test_extract_processor.py",
|
||||
"core/rag/extractor/test_extractor_base.py",
|
||||
"core/rag/extractor/test_helpers.py",
|
||||
"core/rag/extractor/test_html_extractor.py",
|
||||
"core/rag/extractor/test_jina_reader_extractor.py",
|
||||
"core/rag/extractor/test_markdown_extractor.py",
|
||||
"core/rag/extractor/test_notion_extractor.py",
|
||||
"core/rag/extractor/test_pdf_extractor.py",
|
||||
"core/rag/extractor/test_text_extractor.py",
|
||||
"core/rag/extractor/test_word_extractor.py",
|
||||
"core/rag/extractor/unstructured/test_unstructured_extractors.py",
|
||||
"core/rag/extractor/watercrawl/test_watercrawl.py",
|
||||
"core/rag/indexing/processor/test_paragraph_index_processor.py",
|
||||
"core/rag/indexing/processor/test_qa_index_processor.py",
|
||||
"core/rag/indexing/test_index_processor.py",
|
||||
"core/rag/indexing/test_index_processor_base.py",
|
||||
"core/rag/indexing/test_indexing_runner.py",
|
||||
"core/rag/pipeline/test_queue.py",
|
||||
"core/rag/rerank/test_reranker.py",
|
||||
"core/rag/retrieval/test_dataset_retrieval.py",
|
||||
"core/rag/retrieval/test_dataset_retrieval_methods.py",
|
||||
"core/rag/retrieval/test_multi_dataset_function_call_router.py",
|
||||
"core/rag/retrieval/test_multi_dataset_react_route.py",
|
||||
"core/rag/splitter/test_text_splitter.py",
|
||||
"core/repositories/test_celery_workflow_execution_repository.py",
|
||||
"core/repositories/test_celery_workflow_node_execution_repository.py",
|
||||
"core/repositories/test_factory.py",
|
||||
"core/repositories/test_human_input_form_repository_impl.py",
|
||||
"core/repositories/test_human_input_repository.py",
|
||||
"core/repositories/test_sqlalchemy_workflow_execution_repository.py",
|
||||
"core/repositories/test_sqlalchemy_workflow_node_execution_repository.py",
|
||||
"core/repositories/test_workflow_node_execution_conflict_handling.py",
|
||||
"core/repositories/test_workflow_node_execution_truncation.py",
|
||||
"core/schemas/test_registry.py",
|
||||
"core/schemas/test_resolver.py",
|
||||
"core/schemas/test_schema_manager.py",
|
||||
"core/telemetry/test_facade.py",
|
||||
"core/telemetry/test_gateway_integration.py",
|
||||
"core/test_file.py",
|
||||
"core/test_model_manager.py",
|
||||
"core/test_provider_configuration.py",
|
||||
"core/test_provider_manager.py",
|
||||
"core/test_trigger_debug_event_selectors.py",
|
||||
"core/tools/entities/test_api_entities.py",
|
||||
"core/tools/test_base_tool.py",
|
||||
"core/tools/test_builtin_tool_base.py",
|
||||
"core/tools/test_builtin_tool_provider.py",
|
||||
"core/tools/test_builtin_tools_extra.py",
|
||||
"core/tools/test_custom_tool.py",
|
||||
"core/tools/test_custom_tool_provider.py",
|
||||
"core/tools/test_dataset_retriever_tool.py",
|
||||
"core/tools/test_mcp_tool.py",
|
||||
"core/tools/test_mcp_tool_provider.py",
|
||||
"core/tools/test_plugin_tool.py",
|
||||
"core/tools/test_plugin_tool_provider.py",
|
||||
"core/tools/test_tool_engine.py",
|
||||
"core/tools/test_tool_entities.py",
|
||||
"core/tools/test_tool_file_manager.py",
|
||||
"core/tools/test_tool_label_manager.py",
|
||||
"core/tools/test_tool_manager.py",
|
||||
"core/tools/test_tool_parameter_type.py",
|
||||
"core/tools/test_tool_provider_controller.py",
|
||||
"core/tools/utils/test_configuration.py",
|
||||
"core/tools/utils/test_encryption.py",
|
||||
"core/tools/utils/test_message_transformer.py",
|
||||
"core/tools/utils/test_misc_utils_extra.py",
|
||||
"core/tools/utils/test_model_invocation_utils.py",
|
||||
"core/tools/utils/test_parser.py",
|
||||
"core/tools/utils/test_system_oauth_encryption.py",
|
||||
"core/tools/utils/test_tool_engine_serialization.py",
|
||||
"core/tools/utils/test_web_reader_tool.py",
|
||||
"core/tools/utils/test_workflow_configuration_sync.py",
|
||||
"core/tools/workflow_as_tool/test_provider.py",
|
||||
"core/tools/workflow_as_tool/test_tool.py",
|
||||
"core/trigger/conftest.py",
|
||||
"core/trigger/debug/test_debug_event_bus.py",
|
||||
"core/trigger/debug/test_debug_event_selectors.py",
|
||||
"core/trigger/test_provider.py",
|
||||
"core/trigger/test_trigger_manager.py",
|
||||
"core/trigger/utils/test_utils_encryption.py",
|
||||
"core/trigger/utils/test_utils_endpoint.py",
|
||||
"core/trigger/utils/test_utils_locks.py",
|
||||
"core/variables/test_segment.py",
|
||||
"core/variables/test_segment_type.py",
|
||||
"core/variables/test_segment_type_validation.py",
|
||||
"core/variables/test_variables.py",
|
||||
"core/workflow/context/test_execution_context.py",
|
||||
"core/workflow/context/test_flask_app_context.py",
|
||||
"core/workflow/entities/test_private_workflow_pause.py",
|
||||
"core/workflow/generator/test_prompts.py",
|
||||
"core/workflow/generator/test_runner.py",
|
||||
"core/workflow/generator/test_runner_missing.py",
|
||||
"core/workflow/generator/test_tool_catalogue.py",
|
||||
"core/workflow/graph_engine/layers/conftest.py",
|
||||
"core/workflow/graph_engine/layers/test_llm_quota.py",
|
||||
"core/workflow/graph_engine/layers/test_observability.py",
|
||||
"core/workflow/graph_engine/test_mock_config.py",
|
||||
"core/workflow/graph_engine/test_mock_factory.py",
|
||||
"core/workflow/graph_engine/test_mock_nodes.py",
|
||||
"core/workflow/graph_engine/test_parallel_human_input_join_resume.py",
|
||||
"core/workflow/graph_engine/test_table_runner.py",
|
||||
"core/workflow/graph_engine/test_tool_in_chatflow.py",
|
||||
"core/workflow/nodes/agent/test_message_transformer.py",
|
||||
"core/workflow/nodes/agent/test_runtime_support.py",
|
||||
"core/workflow/nodes/agent_v2/test_agent_node.py",
|
||||
"core/workflow/nodes/agent_v2/test_binding_resolver.py",
|
||||
"core/workflow/nodes/agent_v2/test_dify_tools_builder.py",
|
||||
"core/workflow/nodes/agent_v2/test_file_tenant_validator.py",
|
||||
"core/workflow/nodes/agent_v2/test_output_adapter.py",
|
||||
"core/workflow/nodes/agent_v2/test_output_failure_orchestrator.py",
|
||||
"core/workflow/nodes/agent_v2/test_output_file_rebacker.py",
|
||||
"core/workflow/nodes/agent_v2/test_output_type_checker.py",
|
||||
"core/workflow/nodes/agent_v2/test_runtime_request_builder.py",
|
||||
"core/workflow/nodes/agent_v2/test_session_cleanup_layer.py",
|
||||
"core/workflow/nodes/agent_v2/test_session_store.py",
|
||||
"core/workflow/nodes/agent_v2/test_validators.py",
|
||||
"core/workflow/nodes/answer/test_answer.py",
|
||||
"core/workflow/nodes/base/test_base_node.py",
|
||||
"core/workflow/nodes/base/test_get_node_type_classes_mapping.py",
|
||||
"core/workflow/nodes/code/code_node_spec.py",
|
||||
"core/workflow/nodes/datasource/test_datasource_node.py",
|
||||
"core/workflow/nodes/http_request/test_http_request_executor.py",
|
||||
"core/workflow/nodes/http_request/test_http_request_node.py",
|
||||
"core/workflow/nodes/human_input/test_dify_owned_contracts.py",
|
||||
"core/workflow/nodes/human_input/test_email_delivery_config.py",
|
||||
"core/workflow/nodes/human_input/test_entities.py",
|
||||
"core/workflow/nodes/human_input/test_human_input_form_filled_event.py",
|
||||
"core/workflow/nodes/iteration/test_iteration_child_engine_errors.py",
|
||||
"core/workflow/nodes/knowledge_index/test_knowledge_index_node.py",
|
||||
"core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py",
|
||||
"core/workflow/nodes/list_operator/node_spec.py",
|
||||
"core/workflow/nodes/llm/test_llm_utils.py",
|
||||
"core/workflow/nodes/llm/test_node.py",
|
||||
"core/workflow/nodes/parameter_extractor/test_parameter_extractor_node.py",
|
||||
"core/workflow/nodes/template_transform/template_transform_node_spec.py",
|
||||
"core/workflow/nodes/template_transform/test_template_transform_node.py",
|
||||
"core/workflow/nodes/test_base_node.py",
|
||||
"core/workflow/nodes/test_document_extractor_node.py",
|
||||
"core/workflow/nodes/test_if_else.py",
|
||||
"core/workflow/nodes/test_list_operator.py",
|
||||
"core/workflow/nodes/test_start_node_json_object.py",
|
||||
"core/workflow/nodes/tool/test_tool_node.py",
|
||||
"core/workflow/nodes/tool/test_tool_node_runtime.py",
|
||||
"core/workflow/nodes/webhook/test_entities.py",
|
||||
"core/workflow/nodes/webhook/test_exceptions.py",
|
||||
"core/workflow/nodes/webhook/test_webhook_file_conversion.py",
|
||||
"core/workflow/nodes/webhook/test_webhook_node.py",
|
||||
"core/workflow/test_enrich_pause_reasons.py",
|
||||
"core/workflow/test_form_input_serialization_compat.py",
|
||||
"core/workflow/test_graph_topology.py",
|
||||
"core/workflow/test_human_input_adapter.py",
|
||||
"core/workflow/test_human_input_callback.py",
|
||||
"core/workflow/test_human_input_forms.py",
|
||||
"core/workflow/test_node_factory.py",
|
||||
"core/workflow/test_node_mapping_bootstrap.py",
|
||||
"core/workflow/test_node_runtime.py",
|
||||
"core/workflow/test_system_variable.py",
|
||||
"core/workflow/test_variable_pool.py",
|
||||
"core/workflow/test_workflow_entry.py",
|
||||
"core/workflow/test_workflow_entry_helpers.py",
|
||||
"core/workflow/test_workflow_entry_redis_channel.py",
|
||||
"dev/test_generate_knowledge_fs_contract.py",
|
||||
"enterprise/telemetry/test_contracts.py",
|
||||
"enterprise/telemetry/test_draft_trace.py",
|
||||
"enterprise/telemetry/test_enterprise_trace.py",
|
||||
"enterprise/telemetry/test_event_handlers.py",
|
||||
"enterprise/telemetry/test_exporter.py",
|
||||
"enterprise/telemetry/test_gateway.py",
|
||||
"enterprise/telemetry/test_metric_handler.py",
|
||||
"enums/test_quota_type.py",
|
||||
"events/event_handlers/test_clean_when_document_deleted.py",
|
||||
"events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py",
|
||||
"events/test_app_event_signals.py",
|
||||
"events/test_events_package_compat.py",
|
||||
"events/test_update_provider_when_message_created.py",
|
||||
"extensions/logstore/repositories/test_logstore_api_workflow_node_execution_repository.py",
|
||||
"extensions/logstore/test_sql_escape.py",
|
||||
"extensions/otel/conftest.py",
|
||||
"extensions/otel/decorators/handlers/test_generate_handler.py",
|
||||
"extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py",
|
||||
"extensions/otel/decorators/test_base.py",
|
||||
"extensions/otel/decorators/test_handler.py",
|
||||
"extensions/otel/test_celery_sqlcommenter.py",
|
||||
"extensions/otel/test_context.py",
|
||||
"extensions/otel/test_retrieval_tracing.py",
|
||||
"extensions/otel/test_runtime.py",
|
||||
"extensions/storage/test_supabase_storage.py",
|
||||
"extensions/test_celery_ssl.py",
|
||||
"extensions/test_ext_blueprints_openapi.py",
|
||||
"extensions/test_ext_login.py",
|
||||
"extensions/test_ext_request_logging.py",
|
||||
"extensions/test_ext_socketio.py",
|
||||
"extensions/test_pubsub_channel.py",
|
||||
"extensions/test_redis.py",
|
||||
"extensions/test_set_secretkey.py",
|
||||
"extensions/test_workflow_warm_shutdown.py",
|
||||
"factories/test_build_from_mapping.py",
|
||||
"factories/test_file_factory.py",
|
||||
"factories/test_file_validation.py",
|
||||
"factories/test_variable_factory.py",
|
||||
"fields/test_dataset_fields.py",
|
||||
"fields/test_file_fields.py",
|
||||
"fields/test_message_fields.py",
|
||||
"libs/_human_input/support.py",
|
||||
"libs/_human_input/test_form_service.py",
|
||||
"libs/_human_input/test_models.py",
|
||||
"libs/broadcast_channel/redis/test_channel_unit_tests.py",
|
||||
"libs/broadcast_channel/redis/test_streams_channel_unit_tests.py",
|
||||
"libs/test_api_token_cache.py",
|
||||
"libs/test_archive_storage.py",
|
||||
"libs/test_cron_compatibility.py",
|
||||
"libs/test_custom_inputs.py",
|
||||
"libs/test_datetime_utils.py",
|
||||
"libs/test_email.py",
|
||||
"libs/test_email_i18n.py",
|
||||
"libs/test_encryption.py",
|
||||
"libs/test_external_api.py",
|
||||
"libs/test_file_utils.py",
|
||||
"libs/test_flask_utils.py",
|
||||
"libs/test_helper.py",
|
||||
"libs/test_json_in_md_parser.py",
|
||||
"libs/test_jwt_imports.py",
|
||||
"libs/test_login.py",
|
||||
"libs/test_oauth_base.py",
|
||||
"libs/test_oauth_bearer.py",
|
||||
"libs/test_oauth_bearer_layer0_cache.py",
|
||||
"libs/test_oauth_bearer_rate_limit_ordering.py",
|
||||
"libs/test_oauth_bearer_require_scope.py",
|
||||
"libs/test_oauth_clients.py",
|
||||
"libs/test_orjson.py",
|
||||
"libs/test_pagination.py",
|
||||
"libs/test_pandas.py",
|
||||
"libs/test_passport.py",
|
||||
"libs/test_password.py",
|
||||
"libs/test_rate_limit_bearer.py",
|
||||
"libs/test_rate_limiter.py",
|
||||
"libs/test_rsa.py",
|
||||
"libs/test_schedule_utils_enhanced.py",
|
||||
"libs/test_sendgrid_client.py",
|
||||
"libs/test_smtp_client.py",
|
||||
"libs/test_time_parser.py",
|
||||
"libs/test_token.py",
|
||||
"libs/test_token_manager.py",
|
||||
"libs/test_uuid_utils.py",
|
||||
"libs/test_workspace_member_helper.py",
|
||||
"libs/test_workspace_permission.py",
|
||||
"libs/test_yarl.py",
|
||||
"migrations/test_agent_drive_skill_metadata_refactor.py",
|
||||
"migrations/test_uuidv7_pg18_migration.py",
|
||||
"models/test_account_models.py",
|
||||
"models/test_agent.py",
|
||||
"models/test_app_models.py",
|
||||
"models/test_base.py",
|
||||
"models/test_conversation_variable.py",
|
||||
"models/test_dataset_models.py",
|
||||
"models/test_end_user_type.py",
|
||||
"models/test_enums_creator_user_role.py",
|
||||
"models/test_model.py",
|
||||
"models/test_plugin_entities.py",
|
||||
"models/test_provider_models.py",
|
||||
"models/test_tool_models.py",
|
||||
"models/test_types.py",
|
||||
"models/test_workflow.py",
|
||||
"models/test_workflow_models.py",
|
||||
"models/test_workflow_node_execution_offload.py",
|
||||
"oss/__mock/aliyun_oss.py",
|
||||
"oss/__mock/baidu_obs.py",
|
||||
"oss/__mock/base.py",
|
||||
"oss/__mock/local.py",
|
||||
"oss/__mock/tencent_cos.py",
|
||||
"oss/__mock/volcengine_tos.py",
|
||||
"oss/aliyun_oss/aliyun_oss/test_aliyun_oss.py",
|
||||
"oss/baidu_obs/test_baidu_obs.py",
|
||||
"oss/opendal/test_opendal.py",
|
||||
"oss/tencent_cos/test_tencent_cos.py",
|
||||
"oss/volcengine_tos/test_volcengine_tos.py",
|
||||
"repositories/test_sqlalchemy_api_workflow_run_repository.py",
|
||||
"services/agent/test_agent_composer_entities.py",
|
||||
"services/agent/test_agent_dsl_service.py",
|
||||
"services/agent/test_agent_observability_service.py",
|
||||
"services/agent/test_agent_services.py",
|
||||
"services/agent/test_composer_candidates.py",
|
||||
"services/agent/test_composer_mention_validation.py",
|
||||
"services/agent/test_prompt_mentions.py",
|
||||
"services/agent/test_skill_package_service.py",
|
||||
"services/agent/test_skill_standardize_service.py",
|
||||
"services/agent/test_skill_tool_inference_service.py",
|
||||
"services/agent/test_workflow_publish_service.py",
|
||||
"services/auth/test_api_key_auth_base.py",
|
||||
"services/auth/test_api_key_auth_factory.py",
|
||||
"services/auth/test_api_key_auth_service.py",
|
||||
"services/auth/test_auth_type.py",
|
||||
"services/auth/test_firecrawl_auth.py",
|
||||
"services/auth/test_jina_auth.py",
|
||||
"services/auth/test_jina_auth_standalone_module.py",
|
||||
"services/auth/test_watercrawl_auth.py",
|
||||
"services/controller_api.py",
|
||||
"services/data_migration/test_dependency_discovery_service.py",
|
||||
"services/data_migration/test_entities.py",
|
||||
"services/data_migration/test_export_service.py",
|
||||
"services/data_migration/test_import_service.py",
|
||||
"services/data_migration/test_package_service.py",
|
||||
"services/data_migration/test_report_service.py",
|
||||
"services/dataset_service_test_helpers.py",
|
||||
"services/document_service_validation.py",
|
||||
"services/enterprise/test_account_deletion_sync.py",
|
||||
"services/enterprise/test_app_permitted_service.py",
|
||||
"services/enterprise/test_enterprise_service.py",
|
||||
"services/enterprise/test_plugin_manager_service.py",
|
||||
"services/enterprise/test_rbac_service.py",
|
||||
"services/enterprise/test_traceparent_propagation.py",
|
||||
"services/hit_service.py",
|
||||
"services/openapi/test_mint_policy.py",
|
||||
"services/plugin/conftest.py",
|
||||
"services/plugin/test_dependencies_analysis.py",
|
||||
"services/plugin/test_endpoint_service.py",
|
||||
"services/plugin/test_oauth_service.py",
|
||||
"services/plugin/test_plugin_migration.py",
|
||||
"services/plugin/test_plugin_parameter_service.py",
|
||||
"services/plugin/test_plugin_service.py",
|
||||
"services/plugin/test_plugin_service_installation.py",
|
||||
"services/rag_pipeline/pipeline_template/test_built_in_retrieval.py",
|
||||
"services/rag_pipeline/pipeline_template/test_pipeline_template_base.py",
|
||||
"services/rag_pipeline/test_pipeline_generate_service.py",
|
||||
"services/rag_pipeline/test_rag_pipeline_dsl_service.py",
|
||||
"services/rag_pipeline/test_rag_pipeline_service.py",
|
||||
"services/rag_pipeline/test_rag_pipeline_task_proxy.py",
|
||||
"services/rag_pipeline/test_rag_pipeline_transform_service.py",
|
||||
"services/recommend_app/test_buildin_retrieval.py",
|
||||
"services/recommend_app/test_category_order.py",
|
||||
"services/recommend_app/test_recommend_app_factory.py",
|
||||
"services/recommend_app/test_recommend_app_type.py",
|
||||
"services/recommend_app/test_remote_retrieval.py",
|
||||
"services/retention/test_messages_clean_policy.py",
|
||||
"services/retention/workflow_run/test_archive_download_preparation.py",
|
||||
"services/retention/workflow_run/test_archive_download_task_cache.py",
|
||||
"services/retention/workflow_run/test_archive_log_service.py",
|
||||
"services/retention/workflow_run/test_bundle_archive_maintenance.py",
|
||||
"services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py",
|
||||
"services/retention/workflow_run/test_restore_archived_workflow_run.py",
|
||||
"services/test_account_service.py",
|
||||
"services/test_agent_app_feature_service.py",
|
||||
"services/test_agent_app_sandbox_service.py",
|
||||
"services/test_agent_config_service.py",
|
||||
"services/test_agent_drive_service.py",
|
||||
"services/test_agent_file_request_service.py",
|
||||
"services/test_annotation_service.py",
|
||||
"services/test_api_token_service.py",
|
||||
"services/test_app_dsl_service.py",
|
||||
"services/test_app_generate_service.py",
|
||||
"services/test_app_generate_service_streaming_integration.py",
|
||||
"services/test_app_model_config_service.py",
|
||||
"services/test_app_service.py",
|
||||
"services/test_app_task_service.py",
|
||||
"services/test_archive_workflow_run_logs.py",
|
||||
"services/test_async_workflow_service.py",
|
||||
"services/test_audio_service.py",
|
||||
"services/test_billing_service.py",
|
||||
"services/test_clear_free_plan_expired_workflow_run_logs.py",
|
||||
"services/test_clear_free_plan_tenant_expired_logs.py",
|
||||
"services/test_code_based_extension_service.py",
|
||||
"services/test_conversation_service.py",
|
||||
"services/test_credential_permission_service.py",
|
||||
"services/test_credit_pool_service.py",
|
||||
"services/test_dataset_service_dataset.py",
|
||||
"services/test_dataset_service_document.py",
|
||||
"services/test_dataset_service_lock_not_owned.py",
|
||||
"services/test_dataset_service_segment.py",
|
||||
"services/test_datasource_provider_service.py",
|
||||
"services/test_document_indexing_task_proxy.py",
|
||||
"services/test_duplicate_document_indexing_task_proxy.py",
|
||||
"services/test_export_app_messages.py",
|
||||
"services/test_external_dataset_service.py",
|
||||
"services/test_feature_service_app_dsl_version.py",
|
||||
"services/test_feature_service_enable_app_deploy.py",
|
||||
"services/test_feature_service_human_input_email_delivery.py",
|
||||
"services/test_feature_service_learn_app.py",
|
||||
"services/test_feature_service_licensed_seats.py",
|
||||
"services/test_feature_service_trial_models.py",
|
||||
"services/test_feature_service_vector_space.py",
|
||||
"services/test_feature_service_webapp_public_access.py",
|
||||
"services/test_feedback_service.py",
|
||||
"services/test_file_service.py",
|
||||
"services/test_human_input_delivery_test_service.py",
|
||||
"services/test_human_input_file_upload_service.py",
|
||||
"services/test_human_input_service.py",
|
||||
"services/test_knowledge_retrieval_inner_service.py",
|
||||
"services/test_knowledge_service.py",
|
||||
"services/test_message_service.py",
|
||||
"services/test_messages_clean_service.py",
|
||||
"services/test_metadata_nullable_bug.py",
|
||||
"services/test_metadata_service_session_boundary.py",
|
||||
"services/test_model_load_balancing_service.py",
|
||||
"services/test_model_provider_service.py",
|
||||
"services/test_model_provider_service_sanitization.py",
|
||||
"services/test_oauth_device_flow.py",
|
||||
"services/test_oauth_server_service.py",
|
||||
"services/test_operation_service.py",
|
||||
"services/test_rag_pipeline_task_proxy.py",
|
||||
"services/test_schedule_service.py",
|
||||
"services/test_snippet_dsl_service.py",
|
||||
"services/test_snippet_generate_service.py",
|
||||
"services/test_snippet_service.py",
|
||||
"services/test_step_by_step_tour_service.py",
|
||||
"services/test_summary_index_service.py",
|
||||
"services/test_telemetry_service.py",
|
||||
"services/test_trigger_provider_service.py",
|
||||
"services/test_variable_truncator.py",
|
||||
"services/test_vector_service.py",
|
||||
"services/test_webhook_service.py",
|
||||
"services/test_webhook_service_additional.py",
|
||||
"services/test_website_service.py",
|
||||
"services/test_workflow_app_service_metadata.py",
|
||||
"services/test_workflow_collaboration_service.py",
|
||||
"services/test_workflow_comment_service.py",
|
||||
"services/test_workflow_generator_service.py",
|
||||
"services/test_workflow_node_execution_trace_service.py",
|
||||
"services/test_workflow_run_service.py",
|
||||
"services/test_workflow_run_service_pause.py",
|
||||
"services/test_workflow_service.py",
|
||||
"services/tools/test_api_tools_manage_service.py",
|
||||
"services/tools/test_builtin_tools_manage_service.py",
|
||||
"services/tools/test_mcp_tools_transform.py",
|
||||
"services/tools/test_tool_labels_service.py",
|
||||
"services/tools/test_tools_manage_service.py",
|
||||
"services/tools/test_tools_transform_service.py",
|
||||
"services/workflow/test_draft_var_loader_simple.py",
|
||||
"services/workflow/test_inspector_events.py",
|
||||
"services/workflow/test_node_output_inspector_service.py",
|
||||
"services/workflow/test_queue_dispatcher.py",
|
||||
"services/workflow/test_scheduler.py",
|
||||
"services/workflow/test_workflow_converter_additional.py",
|
||||
"services/workflow/test_workflow_draft_variable_service.py",
|
||||
"services/workflow/test_workflow_event_snapshot_service.py",
|
||||
"services/workflow/test_workflow_event_snapshot_service_additional.py",
|
||||
"services/workflow/test_workflow_human_input_delivery.py",
|
||||
"services/workflow/test_workflow_restore.py",
|
||||
"tasks/test_agent_backend_session_cleanup_task.py",
|
||||
"tasks/test_async_workflow_tasks.py",
|
||||
"tasks/test_batch_clean_document_task.py",
|
||||
"tasks/test_clean_dataset_task.py",
|
||||
"tasks/test_clean_document_task.py",
|
||||
"tasks/test_community_telemetry_task.py",
|
||||
"tasks/test_dataset_indexing_task.py",
|
||||
"tasks/test_document_indexing_sync_task.py",
|
||||
"tasks/test_document_indexing_update_task.py",
|
||||
"tasks/test_duplicate_document_indexing_task.py",
|
||||
"tasks/test_enable_segment_index_tasks.py",
|
||||
"tasks/test_enterprise_telemetry_task.py",
|
||||
"tasks/test_human_input_timeout_tasks.py",
|
||||
"tasks/test_initialize_created_app_rbac_access_task.py",
|
||||
"tasks/test_install_default_plugins_task.py",
|
||||
"tasks/test_mail_human_input_delivery_task.py",
|
||||
"tasks/test_mail_send_task.py",
|
||||
"tasks/test_ops_trace_task.py",
|
||||
"tasks/test_process_tenant_plugin_autoupgrade_check_task.py",
|
||||
"tasks/test_refresh_billing_vector_space_task.py",
|
||||
"tasks/test_remove_app_and_related_data_task.py",
|
||||
"tasks/test_resume_agent_app_task.py",
|
||||
"tasks/test_summary_queue_isolation.py",
|
||||
"tasks/test_trigger_processing_tasks.py",
|
||||
"tasks/test_workflow_execute_task.py",
|
||||
"test_app_factory.py",
|
||||
"test_makefile_backend_tests.py",
|
||||
"test_pytest_dify.py",
|
||||
"tools/test_api_tool.py",
|
||||
"tools/test_mcp_tool.py",
|
||||
"utils/encryption/test_system_encryption.py",
|
||||
"utils/http_parser/test_oauth_convert_request_to_raw_data.py",
|
||||
"utils/position_helper/test_position_helper.py",
|
||||
"utils/structured_output_parser/test_structured_output_parser.py",
|
||||
"utils/test_text_processing.py",
|
||||
"utils/yaml/test_yaml_utils.py",
|
||||
]
|
||||
|
||||
[errors]
|
||||
missing-override-decorator = "error"
|
||||
redundant-cast = true
|
||||
unannotated-return = true
|
||||
unnecessary-type-conversion = true
|
||||
unused-ignore = true
|
||||
@@ -7,14 +7,16 @@ update_features persists those flags as a new app_model_config version without
|
||||
touching model / prompt / agent_mode.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.account import Account
|
||||
from models.model import App, AppMode, AppModelConfig
|
||||
from services.agent_app_feature_service import AgentAppFeatureConfigService
|
||||
|
||||
TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
APP_ID = "22222222-2222-2222-2222-222222222222"
|
||||
ACCOUNT_ID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
|
||||
class TestValidateFeatures:
|
||||
@@ -71,45 +73,47 @@ class TestValidateFeatures:
|
||||
AgentAppFeatureConfigService.validate_features(TENANT_ID, {"suggested_questions": "nope"})
|
||||
|
||||
|
||||
class _FakeWriteSession:
|
||||
def __init__(self) -> None:
|
||||
self.added: list[Any] = []
|
||||
self.flushed = 0
|
||||
self.committed = 0
|
||||
|
||||
def add(self, obj: Any) -> None:
|
||||
self.added.append(obj)
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flushed += 1
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed += 1
|
||||
|
||||
|
||||
class TestUpdateFeatures:
|
||||
def test_persists_new_app_model_config_version(self):
|
||||
session = _FakeWriteSession()
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id=TENANT_ID, id="app-1", app_model_config_id=None, updated_by=None, updated_at=None
|
||||
@pytest.mark.parametrize("sqlite_session", [(Account, App, AppModelConfig)], indirect=True)
|
||||
def test_persists_new_app_model_config_version(self, sqlite_session: Session):
|
||||
app_model = App(
|
||||
id=APP_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
name="Agent App",
|
||||
description="",
|
||||
mode=AppMode.AGENT,
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=0,
|
||||
)
|
||||
account = SimpleNamespace(id="acct-1")
|
||||
account = Account(name="Test User", email="test@example.com")
|
||||
account.id = ACCOUNT_ID
|
||||
sqlite_session.add_all([account, app_model])
|
||||
sqlite_session.commit()
|
||||
|
||||
new_config = AgentAppFeatureConfigService.update_features(
|
||||
app_model=app_model, # type: ignore[arg-type]
|
||||
account=account, # type: ignore[arg-type]
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
config={"opening_statement": "Hi!", "suggested_questions_after_answer": {"enabled": True}},
|
||||
session=session,
|
||||
session=sqlite_session,
|
||||
)
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
# New row carries the features but no Soul-owned model/prompt/agent_mode.
|
||||
assert new_config.app_id == "app-1"
|
||||
assert new_config.app_id == APP_ID
|
||||
assert new_config.opening_statement == "Hi!"
|
||||
assert new_config.model is None
|
||||
assert new_config.agent_mode is None
|
||||
# App is repointed at the new version and the write is committed.
|
||||
assert app_model.app_model_config_id == new_config.id
|
||||
assert app_model.updated_by == "acct-1"
|
||||
assert new_config in session.added
|
||||
assert session.flushed == 1
|
||||
assert session.committed == 1
|
||||
assert app_model.updated_by == ACCOUNT_ID
|
||||
sqlite_session.expunge_all()
|
||||
persisted_config = sqlite_session.get(AppModelConfig, new_config.id)
|
||||
persisted_app = sqlite_session.get(App, APP_ID)
|
||||
assert persisted_config is not None
|
||||
assert persisted_config.opening_statement == "Hi!"
|
||||
assert persisted_config.model is None
|
||||
assert persisted_config.agent_mode is None
|
||||
assert persisted_app is not None
|
||||
assert persisted_app.app_model_config_id == new_config.id
|
||||
assert persisted_app.updated_by == ACCOUNT_ID
|
||||
|
||||
Generated
+5
-4
@@ -1738,7 +1738,7 @@ storage = [
|
||||
]
|
||||
tools = [
|
||||
{ name = "cloudscraper", specifier = ">=1.2.71,<2.0.0" },
|
||||
{ name = "nltk", specifier = ">=3.9.1,<4.0.0" },
|
||||
{ name = "nltk", specifier = ">=3.10.0,<4.0.0" },
|
||||
]
|
||||
trace-aliyun = [{ name = "dify-trace-aliyun", editable = "providers/trace/trace-aliyun" }]
|
||||
trace-all = [
|
||||
@@ -4113,17 +4113,18 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nltk"
|
||||
version = "3.9.4"
|
||||
version = "3.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "defusedxml" },
|
||||
{ name = "joblib" },
|
||||
{ name = "regex" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864, upload-time = "2026-03-24T06:13:40.641Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/02/df4f105b28a7c16b0e41423bc09cf0f1b8a305df4ef0b10ca74a2e4c648c/nltk-3.10.0.tar.gz", hash = "sha256:4fbac1d98203cbcd1b5d94a2877fb822300072d80604a5e7fae49d2c5f84e8c1", size = 3089244, upload-time = "2026-07-08T02:39:13.562Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/89/a0b0f35e2820d6a99d75ea1c11977ee6d5c9e6658eceb45b0c7620881faa/nltk-3.10.0-py3-none-any.whl", hash = "sha256:54ff84d4916d3ef127e8953bee0023f6a6b320b75d634a19e06ef056d3d244bf", size = 1716144, upload-time = "2026-07-08T02:39:09.753Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -7,6 +7,7 @@ REPO_ROOT="$SCRIPT_DIR/.."
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
EXCLUDES_FILE="api/pyrefly-local-excludes.txt"
|
||||
UNIT_TESTS_CONFIG="tests/unit_tests/pyrefly.toml"
|
||||
TEST_CONTAINERS_DIR="tests/test_containers_integration_tests"
|
||||
TEST_CONTAINERS_CONFIG="$TEST_CONTAINERS_DIR/pyrefly.toml"
|
||||
|
||||
@@ -74,6 +75,19 @@ fi
|
||||
run_pyrefly "${pyrefly_command[@]}" || status=$?
|
||||
|
||||
if (( ${#target_paths[@]} == 0 )); then
|
||||
unit_tests_args=(
|
||||
"--summary=none"
|
||||
"--use-ignore-files=false"
|
||||
"--config=$UNIT_TESTS_CONFIG"
|
||||
)
|
||||
if [[ "${PYREFLY_OUTPUT_FORMAT:-}" == "github" ]]; then
|
||||
unit_tests_args+=("--output-format=github")
|
||||
fi
|
||||
run_pyrefly \
|
||||
uv run --directory api --dev pyrefly check \
|
||||
"${unit_tests_args[@]}" \
|
||||
|| status=$?
|
||||
|
||||
test_containers_args=(
|
||||
"--summary=none"
|
||||
"--use-ignore-files=false"
|
||||
|
||||
@@ -115,7 +115,7 @@ def __getattr__(name: str) -> Any:
|
||||
if name not in _EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module = import_module(_EXPORTS[name])
|
||||
value = getattr(module, name) # noqa: no-new-getattr lazy export proxy
|
||||
value = getattr(module, name) # guard-ignore: no-new-getattr -- lazy export proxy
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ def __getattr__(name: str) -> Any:
|
||||
if name not in _EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module = import_module(_EXPORTS[name])
|
||||
value = getattr(module, name) # noqa: no-new-getattr lazy export proxy
|
||||
value = getattr(module, name) # guard-ignore: no-new-getattr -- lazy export proxy
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
@@ -363,14 +363,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/app-publisher/sections.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
|
||||
@@ -245,13 +245,13 @@ def extract_meta_variables(raw_match: dict[str, Any]) -> dict[str, str]:
|
||||
return result
|
||||
|
||||
|
||||
def has_reasoned_noqa(source_line: str, rule_id: str) -> bool:
|
||||
pattern = re.compile(rf"# noqa: {re.escape(rule_id)}(?:\s+(?P<reason>\S.*))?\s*$")
|
||||
def has_reasoned_guard_ignore(source_line: str, rule_id: str) -> bool:
|
||||
pattern = re.compile(rf"# guard-ignore: {re.escape(rule_id)} -- (?P<reason>\S.*)\s*$")
|
||||
match = pattern.search(source_line)
|
||||
if not match:
|
||||
return False
|
||||
reason = match.group("reason")
|
||||
return reason is not None and bool(reason.strip())
|
||||
return bool(reason.strip())
|
||||
|
||||
|
||||
def collect_hunk_violations(
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from ast_grep_guard import Match, has_reasoned_noqa, rule_path, run_guard
|
||||
from ast_grep_guard import Match, has_reasoned_guard_ignore, rule_path, run_guard
|
||||
|
||||
|
||||
RULE_ID = "no-new-controller-sqlalchemy"
|
||||
@@ -40,7 +40,7 @@ def is_flask_session_get(match: Match) -> bool:
|
||||
|
||||
|
||||
def is_suppressed(match: Match) -> bool:
|
||||
return has_reasoned_noqa(match.source_line, RULE_ID)
|
||||
return has_reasoned_guard_ignore(match.source_line, RULE_ID)
|
||||
|
||||
|
||||
def is_reportable_match(match: Match) -> bool:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ast_grep_guard import Match, has_reasoned_noqa, is_python_source_path, rule_path, run_guard
|
||||
from ast_grep_guard import Match, has_reasoned_guard_ignore, is_python_source_path, rule_path, run_guard
|
||||
|
||||
|
||||
RULE_ID = "no-new-getattr"
|
||||
@@ -12,7 +12,7 @@ VIOLATION_MESSAGE = "no-new-getattr net-new getattr() in added code"
|
||||
|
||||
|
||||
def is_reportable_match(match: Match) -> bool:
|
||||
return not has_reasoned_noqa(match.source_line, RULE_ID)
|
||||
return not has_reasoned_guard_ignore(match.source_line, RULE_ID)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -44,7 +44,7 @@ def parse_args() -> argparse.Namespace:
|
||||
help="Files or directories to scan. Defaults to api/controllers.",
|
||||
)
|
||||
parser.add_argument("--include-allowed", action="store_true", help="Print allowed flush()/commit() findings.")
|
||||
parser.add_argument("--include-suppressed", action="store_true", help="Print reasoned noqa suppressions.")
|
||||
parser.add_argument("--include-suppressed", action="store_true", help="Print reasoned guard suppressions.")
|
||||
parser.add_argument("--summary-only", action="store_true", help="Print counts without per-finding details.")
|
||||
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
|
||||
parser.add_argument(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AppAccessPointDetailSidebarSlot() {
|
||||
return <AppDetailSidebarSlot />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AppDeployDetailSidebarSlot() {
|
||||
return <AppDetailSidebarSlot />
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Dependency } from '@/app/components/plugins/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useStore as usePluginDependencyStore } from '@/app/components/workflow/plugin-dependency/store'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
guardAgentV2Route: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-bundle', () => ({
|
||||
default: ({ fromDSLPayload }: { fromDSLPayload: Dependency[] }) => (
|
||||
<div role="dialog" aria-label="Install missing plugins">
|
||||
{`bundle-size:${fromDSLPayload.length}`}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../feature-guard', () => ({
|
||||
guardAgentV2Route: () => mocks.guardAgentV2Route(),
|
||||
}))
|
||||
@@ -18,6 +28,7 @@ vi.mock('../agents-access-guard', () => ({
|
||||
describe('RosterLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
usePluginDependencyStore.setState({ dependencies: [] })
|
||||
})
|
||||
|
||||
it('should render children when Agent v2 is enabled', async () => {
|
||||
@@ -33,6 +44,34 @@ describe('RosterLayout', () => {
|
||||
expect(screen.getByText('Roster content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the missing-plugin installer across Agent routes', async () => {
|
||||
usePluginDependencyStore.setState({
|
||||
dependencies: [
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
organization: 'langgenius',
|
||||
plugin: 'sample-plugin',
|
||||
version: '1.0.0',
|
||||
plugin_unique_identifier: 'langgenius/sample-plugin:1.0.0',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const { default: RosterLayout } = await import('../layout')
|
||||
|
||||
render(
|
||||
<RosterLayout>
|
||||
<div>Agent route content</div>
|
||||
</RosterLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Install missing plugins' })).toHaveTextContent(
|
||||
'bundle-size:1',
|
||||
)
|
||||
expect(screen.getByText('Agent route content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should block rendering when the roster guard throws notFound', async () => {
|
||||
mocks.guardAgentV2Route.mockImplementation(() => {
|
||||
throw new Error('NEXT_NOT_FOUND')
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import PluginDependency from '@/app/components/workflow/plugin-dependency'
|
||||
import { AgentsAccessGuard } from './agents-access-guard'
|
||||
import { guardAgentV2Route } from './feature-guard'
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
guardAgentV2Route()
|
||||
|
||||
return <AgentsAccessGuard>{children}</AgentsAccessGuard>
|
||||
return (
|
||||
<AgentsAccessGuard>
|
||||
<PluginDependency />
|
||||
{children}
|
||||
</AgentsAccessGuard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function AppAccessPointPage() {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function AppDeployPage() {
|
||||
return null
|
||||
}
|
||||
@@ -7,10 +7,12 @@ let mockAppMode = 'chat'
|
||||
let mockPathname = '/app/app-1/logs'
|
||||
let mockAppPermissionKeys: string[] = []
|
||||
let mockIsRbacEnabled = true
|
||||
let mockEnableAppDeploy = false
|
||||
const mockConsoleState = vi.hoisted(() => ({
|
||||
current: {
|
||||
userProfile: { id: 'user-1' },
|
||||
workspacePermissionKeys: [] as string[],
|
||||
isCurrentWorkspaceEditor: true,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -18,6 +20,7 @@ const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
|
||||
renderWithConsoleQuery(ui, {
|
||||
systemFeatures: {
|
||||
rbac_enabled: mockIsRbacEnabled,
|
||||
enable_app_deploy: mockEnableAppDeploy,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -44,6 +47,10 @@ vi.mock('@/context/permission-state', async () => {
|
||||
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createPermissionStateModuleMock(() => mockConsoleState.current)
|
||||
})
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createWorkspaceStateModuleMock(() => mockConsoleState.current)
|
||||
})
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => mockPathname,
|
||||
@@ -72,6 +79,8 @@ describe('AppDetailSection', () => {
|
||||
mockPathname = '/app/app-1/logs'
|
||||
mockAppPermissionKeys = [AppACLPermission.Monitor]
|
||||
mockIsRbacEnabled = true
|
||||
mockEnableAppDeploy = false
|
||||
mockConsoleState.current.isCurrentWorkspaceEditor = true
|
||||
})
|
||||
|
||||
// Rendering behavior for app detail navigation entries.
|
||||
@@ -183,6 +192,72 @@ describe('AppDetailSection', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render access point navigation using its app route', () => {
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.accessPoint' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/app-1/access-point',
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'common.appMenus.apiAccess' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render deploy navigation for workflow apps when app deploy is available', () => {
|
||||
// Arrange
|
||||
mockAppMode = 'workflow'
|
||||
mockEnableAppDeploy = true
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('link', { name: 'common.appMenus.deploy' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/app-1/deploy',
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'the app is not a workflow app',
|
||||
mode: 'chat',
|
||||
enableAppDeploy: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
},
|
||||
{
|
||||
label: 'app deploy is disabled',
|
||||
mode: 'workflow',
|
||||
enableAppDeploy: false,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
},
|
||||
{
|
||||
label: 'the current workspace role cannot use app deploy',
|
||||
mode: 'workflow',
|
||||
enableAppDeploy: true,
|
||||
isCurrentWorkspaceEditor: false,
|
||||
},
|
||||
])(
|
||||
'should hide deploy navigation when $label',
|
||||
({ mode, enableAppDeploy, isCurrentWorkspaceEditor }) => {
|
||||
// Arrange
|
||||
mockAppMode = mode
|
||||
mockEnableAppDeploy = enableAppDeploy
|
||||
mockConsoleState.current.isCurrentWorkspaceEditor = isCurrentWorkspaceEditor
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'common.appMenus.deploy' }),
|
||||
).not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('should render resource access navigation when app access config permission is granted', () => {
|
||||
// Arrange
|
||||
mockAppPermissionKeys = [AppACLPermission.AccessConfig]
|
||||
|
||||
@@ -3,18 +3,6 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { NavIcon } from './nav-link'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiDashboard2Fill,
|
||||
RiDashboard2Line,
|
||||
RiFileList3Fill,
|
||||
RiFileList3Line,
|
||||
RiLock2Fill,
|
||||
RiLock2Line,
|
||||
RiTerminalBoxFill,
|
||||
RiTerminalBoxLine,
|
||||
RiTerminalWindowFill,
|
||||
RiTerminalWindowLine,
|
||||
} from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
@@ -24,6 +12,7 @@ import Divider from '@/app/components/base/divider'
|
||||
import Annotations from '@/app/components/base/icons/src/vender/Annotations'
|
||||
import { userProfileIdAtom } from '@/context/account-state'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { isCurrentWorkspaceEditorAtom } from '@/context/workspace-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@@ -45,6 +34,28 @@ const AnnotationNavIcon = ({ className, ...props }: ComponentProps<typeof Annota
|
||||
|
||||
AnnotationNavIcon.displayName = 'Annotations'
|
||||
|
||||
const createClassNameNavIcon = (iconClassName: string) => {
|
||||
const ClassNameNavIcon = ({ className }: ComponentProps<'svg'>) => (
|
||||
<span aria-hidden className={cn(iconClassName, className)} />
|
||||
)
|
||||
|
||||
ClassNameNavIcon.displayName = 'ClassNameNavIcon'
|
||||
|
||||
return ClassNameNavIcon
|
||||
}
|
||||
|
||||
const accessPointNavIcon = createClassNameNavIcon('i-custom-vender-agent-v2-access-point')
|
||||
const terminalWindowLineNavIcon = createClassNameNavIcon('i-ri-terminal-window-line')
|
||||
const terminalWindowFillNavIcon = createClassNameNavIcon('i-ri-terminal-window-fill')
|
||||
const instanceLineNavIcon = createClassNameNavIcon('i-ri-instance-line')
|
||||
const instanceFillNavIcon = createClassNameNavIcon('i-ri-instance-fill')
|
||||
const fileListLineNavIcon = createClassNameNavIcon('i-ri-file-list-3-line')
|
||||
const fileListFillNavIcon = createClassNameNavIcon('i-ri-file-list-3-fill')
|
||||
const dashboardLineNavIcon = createClassNameNavIcon('i-ri-dashboard-2-line')
|
||||
const dashboardFillNavIcon = createClassNameNavIcon('i-ri-dashboard-2-fill')
|
||||
const lockLineNavIcon = createClassNameNavIcon('i-ri-lock-2-line')
|
||||
const lockFillNavIcon = createClassNameNavIcon('i-ri-lock-2-fill')
|
||||
|
||||
const isLogsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/logs')
|
||||
const isAnnotationsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/annotations')
|
||||
|
||||
@@ -73,6 +84,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const currentUserId = useAtomValue(userProfileIdAtom)
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom)
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const appDetail = useStore((state) => state.appDetail)
|
||||
const appInfoActions = useAppInfoActions({
|
||||
@@ -85,6 +97,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
|
||||
const appId = appDetail.id
|
||||
const isWorkflowApp =
|
||||
appDetail.mode === AppModeEnum.WORKFLOW || appDetail.mode === AppModeEnum.ADVANCED_CHAT
|
||||
const supportsAppDeploy = appDetail.mode === AppModeEnum.WORKFLOW
|
||||
const supportsAnnotations =
|
||||
appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.COMPLETION
|
||||
const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, {
|
||||
@@ -100,24 +113,34 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
|
||||
{
|
||||
name: t(($) => $['appMenus.promptEng'], { ns: 'common' }),
|
||||
href: `/app/${appId}/${isWorkflowApp ? 'workflow' : 'configuration'}`,
|
||||
icon: RiTerminalWindowLine,
|
||||
selectedIcon: RiTerminalWindowFill,
|
||||
icon: terminalWindowLineNavIcon,
|
||||
selectedIcon: terminalWindowFillNavIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: t(($) => $['appMenus.apiAccess'], { ns: 'common' }),
|
||||
href: `/app/${appId}/develop`,
|
||||
icon: RiTerminalBoxLine,
|
||||
selectedIcon: RiTerminalBoxFill,
|
||||
name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }),
|
||||
href: `/app/${appId}/access-point`,
|
||||
icon: accessPointNavIcon,
|
||||
selectedIcon: accessPointNavIcon,
|
||||
},
|
||||
...(supportsAppDeploy && isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy
|
||||
? [
|
||||
{
|
||||
name: t(($) => $['appMenus.deploy'], { ns: 'common' }),
|
||||
href: `/app/${appId}/deploy`,
|
||||
icon: instanceLineNavIcon,
|
||||
selectedIcon: instanceFillNavIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(appACLCapabilities.canAccessLogAndAnnotation
|
||||
? [
|
||||
{
|
||||
name: t(($) => $['appMenus.logs'], { ns: 'common' }),
|
||||
href: `/app/${appId}/logs`,
|
||||
icon: RiFileList3Line,
|
||||
selectedIcon: RiFileList3Fill,
|
||||
icon: fileListLineNavIcon,
|
||||
selectedIcon: fileListFillNavIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -136,8 +159,8 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
|
||||
{
|
||||
name: t(($) => $['appMenus.overview'], { ns: 'common' }),
|
||||
href: `/app/${appId}/overview`,
|
||||
icon: RiDashboard2Line,
|
||||
selectedIcon: RiDashboard2Fill,
|
||||
icon: dashboardLineNavIcon,
|
||||
selectedIcon: dashboardFillNavIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -146,13 +169,21 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
|
||||
{
|
||||
name: t(($) => $['settings.resourceAccess'], { ns: 'common' }),
|
||||
href: `/app/${appId}/access-config`,
|
||||
icon: RiLock2Line,
|
||||
selectedIcon: RiLock2Fill,
|
||||
icon: lockLineNavIcon,
|
||||
selectedIcon: lockFillNavIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
}, [appDetail, t, currentUserId, workspacePermissionKeys, isRbacEnabled])
|
||||
}, [
|
||||
appDetail,
|
||||
t,
|
||||
currentUserId,
|
||||
workspacePermissionKeys,
|
||||
isCurrentWorkspaceEditor,
|
||||
isRbacEnabled,
|
||||
systemFeatures.enable_app_deploy,
|
||||
])
|
||||
|
||||
if (!appDetail) return null
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
}))
|
||||
|
||||
describe('FeaturesWrappedAppPublisher', () => {
|
||||
const resetAppConfig = vi.fn()
|
||||
const publishedConfig = {
|
||||
modelConfig: {
|
||||
more_like_this: { enabled: true },
|
||||
@@ -81,7 +82,6 @@ describe('FeaturesWrappedAppPublisher', () => {
|
||||
allowed_file_upload_methods: ['remote_url'],
|
||||
number_limits: 5,
|
||||
},
|
||||
resetAppConfig: vi.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -106,13 +106,18 @@ describe('FeaturesWrappedAppPublisher', () => {
|
||||
})
|
||||
|
||||
it('should restore published features after confirmation', async () => {
|
||||
render(<FeaturesWrappedAppPublisher publishedConfig={publishedConfig as any} />)
|
||||
render(
|
||||
<FeaturesWrappedAppPublisher
|
||||
publishedConfig={publishedConfig as any}
|
||||
resetAppConfig={resetAppConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('restore-through-wrapper'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.confirm(?=$|:)/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(publishedConfig.modelConfig.resetAppConfig).toHaveBeenCalledTimes(1)
|
||||
expect(resetAppConfig).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetFeatures).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
moreLikeThis: { enabled: true },
|
||||
@@ -128,12 +133,18 @@ describe('FeaturesWrappedAppPublisher', () => {
|
||||
citation: { enabled: true },
|
||||
annotationReply: { enabled: true },
|
||||
}),
|
||||
{ silent: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should close restore confirmation without restoring when cancelled', async () => {
|
||||
render(<FeaturesWrappedAppPublisher publishedConfig={publishedConfig as any} />)
|
||||
render(
|
||||
<FeaturesWrappedAppPublisher
|
||||
publishedConfig={publishedConfig as any}
|
||||
resetAppConfig={resetAppConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('restore-through-wrapper'))
|
||||
const dialog = screen.getByRole('alertdialog')
|
||||
@@ -145,7 +156,7 @@ describe('FeaturesWrappedAppPublisher', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(publishedConfig.modelConfig.resetAppConfig).not.toHaveBeenCalled()
|
||||
expect(resetAppConfig).not.toHaveBeenCalled()
|
||||
expect(mockSetFeatures).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@@ -18,12 +20,14 @@ const mockSetAppDetail = vi.fn()
|
||||
const mockTrackEvent = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUseGetUserCanAccessApp = vi.fn()
|
||||
const mockOpenAsyncWindow = vi.fn()
|
||||
const mockFetchInstalledAppList = vi.fn()
|
||||
const mockFetchAppDetail = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockWindowOpen = vi.fn()
|
||||
const mockInvalidateAppWorkflow = vi.fn()
|
||||
const mockUpdateWorkflow = vi.fn()
|
||||
const mockFetchPublishedWorkflow = vi.fn()
|
||||
let mockPublishedWorkflow: Record<string, any> | null = null
|
||||
|
||||
const sectionProps = vi.hoisted(() => ({
|
||||
summary: null as null | Record<string, any>,
|
||||
@@ -34,9 +38,20 @@ const hotkeyMocks = vi.hoisted(() => ({
|
||||
hotkeys: [] as string[],
|
||||
handlers: [] as Array<(event: { preventDefault: () => void }) => void>,
|
||||
}))
|
||||
const collaborationMocks = vi.hoisted(() => ({
|
||||
handler: undefined as
|
||||
| ((update: {
|
||||
type: 'app_publish_update'
|
||||
userId: string
|
||||
data: Record<string, unknown>
|
||||
timestamp: number
|
||||
}) => void)
|
||||
| undefined,
|
||||
}))
|
||||
|
||||
let mockAppDetail: Record<string, any> | null = null
|
||||
let mockWorkspacePermissionKeys: string[] = ['tool.manage']
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', () => ({
|
||||
useHotkey: (hotkey: string, handler: (event: { preventDefault: () => void }) => void) => {
|
||||
@@ -64,10 +79,6 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => mockOpenAsyncWindow,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
useGetUserCanAccessApp: (params: unknown) => {
|
||||
mockUseGetUserCanAccessApp(params)
|
||||
@@ -83,10 +94,6 @@ vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/explore', () => ({
|
||||
fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args),
|
||||
}))
|
||||
|
||||
const mockPublishToCreatorsPlatform = vi.fn()
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
@@ -95,7 +102,22 @@ vi.mock('@/service/apps', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
appWorkflowQueryOptions: (appId: string) => ({
|
||||
queryKey: ['workflow', 'publish', appId],
|
||||
queryFn: () => mockFetchPublishedWorkflow(appId),
|
||||
}),
|
||||
useAppWorkflow: () => ({ data: mockPublishedWorkflow, isSuccess: true }),
|
||||
useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow,
|
||||
useUpdateWorkflow: () => ({ mutate: mockUpdateWorkflow }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
|
||||
collaborationManager: {
|
||||
onAppPublishUpdate: (handler: NonNullable<(typeof collaborationMocks)['handler']>) => {
|
||||
collaborationMocks.handler = handler
|
||||
return vi.fn()
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
@@ -110,6 +132,7 @@ vi.mock('@/service/use-tools', () => ({
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createWorkspaceStateModuleMock(() => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor,
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: mockWorkspacePermissionKeys,
|
||||
}))
|
||||
@@ -125,6 +148,7 @@ vi.mock('@/context/permission-state', async () => {
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -132,16 +156,6 @@ vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/overview/embedded', () => ({
|
||||
default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) =>
|
||||
isShow ? (
|
||||
<div data-testid="embedded-modal">
|
||||
embedded modal
|
||||
<button onClick={onClose}>close-embedded-modal</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
vi.mock('../../app-access-control', () => {
|
||||
const MockAccessControl = ({
|
||||
onConfirm,
|
||||
@@ -180,6 +194,7 @@ vi.mock('../sections', () => ({
|
||||
<div>
|
||||
<button onClick={() => void props.handlePublish()}>publisher-summary-publish</button>
|
||||
<button onClick={() => void props.handleRestore()}>publisher-summary-restore</button>
|
||||
<button onClick={props.onEditVersion}>publisher-summary-edit-version</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -190,18 +205,18 @@ vi.mock('../sections', () => ({
|
||||
PublisherActionsSection: (props: Record<string, any>) => {
|
||||
sectionProps.actions = props
|
||||
return (
|
||||
<div>
|
||||
<button onClick={props.handleEmbed}>publisher-embed</button>
|
||||
<button onClick={() => void props.handleOpenInExplore()}>publisher-open-in-explore</button>
|
||||
{props.handleOpenRunConfig && (
|
||||
<>
|
||||
<button onClick={() => props.handleOpenRunConfig(props.appURL)}>
|
||||
publisher-run-config
|
||||
</button>
|
||||
<button onClick={() => props.handleOpenRunConfig(`${props.appURL}?mode=batch`)}>
|
||||
publisher-batch-run-config
|
||||
</button>
|
||||
</>
|
||||
<div data-testid="publisher-actions">
|
||||
{props.showRunConfig && props.handleOpenRunConfig && (
|
||||
<button onClick={() => props.handleOpenRunConfig(props.appURL)}>
|
||||
publisher-run-config
|
||||
</button>
|
||||
)}
|
||||
{props.showMarketplaceAction && (
|
||||
<button disabled={props.marketplaceActionDisabled} onClick={props.onPublishToMarketplace}>
|
||||
{props.publishingToMarketplace
|
||||
? 'workflow.common.publishingToMarketplace'
|
||||
: 'workflow.common.publishToMarketplace'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={props.onConfigureWorkflowTool}>publisher-workflow-tool</button>
|
||||
</div>
|
||||
@@ -214,10 +229,14 @@ describe('AppPublisher', () => {
|
||||
vi.clearAllMocks()
|
||||
hotkeyMocks.hotkeys.length = 0
|
||||
hotkeyMocks.handlers.length = 0
|
||||
collaborationMocks.handler = undefined
|
||||
sectionProps.summary = null
|
||||
sectionProps.access = null
|
||||
sectionProps.actions = null
|
||||
mockPublishedWorkflow = null
|
||||
mockFetchPublishedWorkflow.mockResolvedValue(null)
|
||||
mockWorkspacePermissionKeys = ['tool.manage']
|
||||
mockIsCurrentWorkspaceEditor = true
|
||||
mockAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Demo App',
|
||||
@@ -228,17 +247,12 @@ describe('AppPublisher', () => {
|
||||
access_token: 'token-1',
|
||||
},
|
||||
}
|
||||
mockFetchInstalledAppList.mockResolvedValue({
|
||||
installed_apps: [{ id: 'installed-1' }],
|
||||
})
|
||||
mockFetchAppDetail.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
access_mode: AccessMode.PUBLIC,
|
||||
})
|
||||
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
|
||||
return resolver()
|
||||
})
|
||||
Object.defineProperty(window, 'open', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: mockWindowOpen,
|
||||
})
|
||||
@@ -282,16 +296,74 @@ describe('AppPublisher', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the embedded modal from the actions section', () => {
|
||||
it('should edit the current workflow version from the publish summary', () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
mockPublishedWorkflow = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
id: 'workflow-version-5',
|
||||
marked_name: 'Release 5',
|
||||
marked_comment: 'Initial notes',
|
||||
}
|
||||
|
||||
render(<AppPublisher publishedAt={Date.now()} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-embed'))
|
||||
expect(sectionProps.summary).toEqual(
|
||||
expect.objectContaining({
|
||||
isWorkflowApp: true,
|
||||
versionInfo: mockPublishedWorkflow,
|
||||
}),
|
||||
)
|
||||
fireEvent.click(screen.getByText('publisher-summary-edit-version'))
|
||||
|
||||
expect(screen.getByTestId('embedded-modal'))!.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
const [titleInput, notesInput] = screen.getAllByRole('textbox')
|
||||
fireEvent.change(titleInput!, { target: { value: 'Release 6' } })
|
||||
fireEvent.change(notesInput!, { target: { value: 'Updated notes' } })
|
||||
const publishButtons = screen.getAllByRole('button', {
|
||||
name: /(?:^|\.)common\.publish(?=$|:)/,
|
||||
})
|
||||
fireEvent.click(publishButtons.at(-1)!)
|
||||
|
||||
expect(mockUpdateWorkflow).toHaveBeenCalledWith(
|
||||
{
|
||||
url: '/apps/app-1/workflows/workflow-version-5',
|
||||
title: 'Release 6',
|
||||
releaseNotes: 'Updated notes',
|
||||
},
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
onSettled: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
|
||||
const mutationCallbacks = mockUpdateWorkflow.mock.calls[0]![1]
|
||||
mutationCallbacks.onSuccess()
|
||||
expect(mockInvalidateAppWorkflow).toHaveBeenCalledWith('app-1')
|
||||
expect(mockToastSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should collect hidden inputs before opening published run links from config actions', async () => {
|
||||
it('should expose the Deploy quick link for editable workflow apps when enabled', () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
|
||||
renderWithConsoleQuery(<AppPublisher publishedAt={Date.now()} />, {
|
||||
systemFeatures: { webapp_auth: { enabled: true }, enable_app_deploy: true },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
|
||||
expect(sectionProps.actions?.showDeployAction).toBe(true)
|
||||
expect(sectionProps.actions?.appURL).toContain('/workflow/token-1')
|
||||
})
|
||||
|
||||
it('should collect hidden inputs before opening the web app from its config action', async () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
@@ -309,6 +381,8 @@ describe('AppPublisher', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
|
||||
expect(sectionProps.actions?.showRunConfig).toBe(true)
|
||||
fireEvent.click(screen.getByText('publisher-run-config'))
|
||||
|
||||
expect(
|
||||
@@ -330,55 +404,21 @@ describe('AppPublisher', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should open batch run config links with the configured hidden inputs', async () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
inputs={[
|
||||
{
|
||||
variable: 'batch_secret',
|
||||
label: 'Batch Secret',
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
hide: true,
|
||||
default: '',
|
||||
} as any,
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-batch-run-config'))
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Batch Secret'), {
|
||||
target: { value: 'batch-value' },
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.launch(?=$|:)/ }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
`https://example.com${basePath}/workflow/token-1?mode=batch&batch_secret=${encodeURIComponent('batch-value')}`,
|
||||
'_blank',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep workflow tool drawer mounted after closing the publish popover', () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
|
||||
render(<AppPublisher publishedAt={Date.now()} />)
|
||||
render(<AppPublisher publishedAt={Date.now()} toolPublished />)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
expect(sectionProps.actions).toEqual(
|
||||
expect.objectContaining({
|
||||
toolPublished: true,
|
||||
workflowToolOutdated: false,
|
||||
}),
|
||||
)
|
||||
fireEvent.click(screen.getByText('publisher-workflow-tool'))
|
||||
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
@@ -401,14 +441,9 @@ describe('AppPublisher', () => {
|
||||
expect(sectionProps.actions?.workflowToolAvailable).toBe(false)
|
||||
})
|
||||
|
||||
it('should close embedded and access control panels through child callbacks', async () => {
|
||||
it('should close access control through its child callback', async () => {
|
||||
render(<AppPublisher publishedAt={Date.now()} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-embed'))
|
||||
fireEvent.click(screen.getByText('close-embedded-modal'))
|
||||
expect(screen.queryByTestId('embedded-modal')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-access-control'))
|
||||
expect(screen.getByTestId('access-control'))!.toBeInTheDocument()
|
||||
@@ -443,25 +478,6 @@ describe('AppPublisher', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should open the installed explore page through the async window helper', async () => {
|
||||
let openedUrl = ''
|
||||
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
|
||||
openedUrl = await resolver()
|
||||
})
|
||||
|
||||
render(<AppPublisher publishedAt={Date.now()} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-open-in-explore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenAsyncWindow).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1')
|
||||
expect(openedUrl).toBe('/installed/installed-1')
|
||||
expect(sectionProps.actions?.appURL).toBe(`https://example.com${basePath}/chat/token-1`)
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore the trigger when the publish button is disabled', () => {
|
||||
render(<AppPublisher disabled publishedAt={Date.now()} onToggle={mockOnToggle} />)
|
||||
|
||||
@@ -479,8 +495,13 @@ describe('AppPublisher', () => {
|
||||
const onRestore = vi.fn().mockResolvedValue(undefined)
|
||||
mockOnPublish.mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} onRestore={onRestore} />,
|
||||
const { rerender } = render(
|
||||
<AppPublisher
|
||||
hasUnpublishedChanges
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
onRestore={onRestore}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(hotkeyMocks.hotkeys).toContain('Mod+Shift+P')
|
||||
@@ -491,6 +512,30 @@ describe('AppPublisher', () => {
|
||||
expect(mockOnPublish).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
rerender(
|
||||
<AppPublisher
|
||||
hasUnpublishedChanges={false}
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
onRestore={onRestore}
|
||||
/>,
|
||||
)
|
||||
hotkeyMocks.handlers.at(-1)!({ preventDefault })
|
||||
expect(mockOnPublish).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(
|
||||
<AppPublisher
|
||||
hasUnpublishedChanges
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
onRestore={onRestore}
|
||||
/>,
|
||||
)
|
||||
hotkeyMocks.handlers.at(-1)!({ preventDefault })
|
||||
await waitFor(() => {
|
||||
expect(mockOnPublish).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-summary-restore'))
|
||||
|
||||
@@ -500,13 +545,44 @@ describe('AppPublisher', () => {
|
||||
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the popover open when restore fails and reset published state after publish failures', async () => {
|
||||
it('should require an explicit model selection when publishing in multiple model mode', () => {
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
debugWithMultipleModel
|
||||
hasUnpublishedChanges
|
||||
multipleModelConfigs={[
|
||||
{
|
||||
id: 'model-1',
|
||||
model: 'gpt-4o',
|
||||
provider: 'openai',
|
||||
parameters: {},
|
||||
},
|
||||
]}
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
/>,
|
||||
)
|
||||
|
||||
hotkeyMocks.handlers[0]!({ preventDefault })
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockOnPublish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep the popover open when restore and publish fail', async () => {
|
||||
const preventDefault = vi.fn()
|
||||
const onRestore = vi.fn().mockRejectedValue(new Error('restore failed'))
|
||||
mockOnPublish.mockRejectedValueOnce(new Error('publish failed'))
|
||||
|
||||
render(
|
||||
<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} onRestore={onRestore} />,
|
||||
<AppPublisher
|
||||
hasUnpublishedChanges
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
onRestore={onRestore}
|
||||
/>,
|
||||
)
|
||||
|
||||
hotkeyMocks.handlers[0]!({ preventDefault })
|
||||
@@ -526,57 +602,6 @@ describe('AppPublisher', () => {
|
||||
expect(screen.getByText('publisher-summary-publish'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should report missing explore installations', async () => {
|
||||
mockFetchInstalledAppList.mockResolvedValueOnce({
|
||||
installed_apps: [],
|
||||
})
|
||||
mockOpenAsyncWindow.mockImplementation(
|
||||
async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => {
|
||||
try {
|
||||
await resolver()
|
||||
} catch (error) {
|
||||
options.onError(error as Error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
render(<AppPublisher publishedAt={Date.now()} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-open-in-explore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/(?:^|\.)notPublishedYet(?=$|:)/),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should report explore errors when the app cannot be opened', async () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
id: undefined,
|
||||
}
|
||||
mockOpenAsyncWindow.mockImplementation(
|
||||
async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => {
|
||||
try {
|
||||
await resolver()
|
||||
} catch (error) {
|
||||
options.onError(error as Error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
render(<AppPublisher publishedAt={Date.now()} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
fireEvent.click(screen.getByText('publisher-open-in-explore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('App not found')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show marketplace button and open redirect URL on success', async () => {
|
||||
mockPublishToCreatorsPlatform.mockResolvedValue({
|
||||
redirect_url: 'https://marketplace.example.com/publish?code=abc',
|
||||
@@ -588,6 +613,12 @@ describe('AppPublisher', () => {
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
expect(sectionProps.actions).toEqual(
|
||||
expect.objectContaining({
|
||||
marketplaceActionDisabled: false,
|
||||
showMarketplaceAction: true,
|
||||
}),
|
||||
)
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -624,6 +655,12 @@ describe('AppPublisher', () => {
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
expect(sectionProps.actions).toEqual(
|
||||
expect.objectContaining({
|
||||
marketplaceActionDisabled: true,
|
||||
showMarketplaceAction: true,
|
||||
}),
|
||||
)
|
||||
const marketplaceButton = screen
|
||||
.getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/)
|
||||
.closest('a, button, div[role="button"]') as HTMLElement
|
||||
@@ -637,6 +674,7 @@ describe('AppPublisher', () => {
|
||||
render(<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
expect(sectionProps.actions?.showMarketplaceAction).toBe(false)
|
||||
expect(
|
||||
screen.queryByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/),
|
||||
).not.toBeInTheDocument()
|
||||
@@ -656,4 +694,116 @@ describe('AppPublisher', () => {
|
||||
})
|
||||
expect(screen.getByTestId('access-control'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not infer an app mode when app detail is unavailable', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAppDetail = null
|
||||
|
||||
render(<AppPublisher publishedAt={Date.now()} />)
|
||||
|
||||
await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
|
||||
expect(sectionProps.summary).toEqual(
|
||||
expect.objectContaining({
|
||||
isChatApp: false,
|
||||
isWorkflowApp: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should derive workflow changes from draft and published hashes', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
mockPublishedWorkflow = {
|
||||
created_at: 1_710_000_100,
|
||||
hash: 'published-hash',
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<AppPublisher
|
||||
draftHash="published-hash"
|
||||
draftUpdatedAt={1_710_000_200_000}
|
||||
publishedAt={1_710_000_100_000}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
expect(sectionProps.summary?.hasUnpublishedChanges).toBe(false)
|
||||
|
||||
rerender(
|
||||
<AppPublisher
|
||||
draftHash="changed-draft-hash"
|
||||
draftUpdatedAt={1_710_000_100_000}
|
||||
publishedAt={1_710_000_200_000}
|
||||
/>,
|
||||
)
|
||||
expect(sectionProps.summary?.hasUnpublishedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('should keep workflow publishing available when the published hash is unavailable', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
mockPublishedWorkflow = {
|
||||
created_at: 1_710_000_100,
|
||||
hash: '',
|
||||
}
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
draftHash="draft-hash"
|
||||
draftUpdatedAt={1_710_000_200_000}
|
||||
publishedAt={1_710_000_100_000}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
|
||||
|
||||
expect(sectionProps.summary).toEqual(
|
||||
expect.objectContaining({
|
||||
hasUnpublishedChanges: true,
|
||||
publishedAt: 1_710_000_100_000,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should refresh the shared workflow query and store after a collaborator publishes', async () => {
|
||||
const setPublishedAt = vi.fn()
|
||||
const workflowStore = {
|
||||
getState: () => ({ setPublishedAt }),
|
||||
}
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
mockFetchPublishedWorkflow.mockResolvedValue({
|
||||
created_at: 1_710_000_300,
|
||||
hash: 'published-hash',
|
||||
})
|
||||
|
||||
render(
|
||||
<WorkflowContext value={workflowStore as any}>
|
||||
<AppPublisher draftHash="draft-hash" publishedAt={1_710_000_100_000} />
|
||||
</WorkflowContext>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
collaborationMocks.handler?.({
|
||||
type: 'app_publish_update',
|
||||
userId: 'collaborator-1',
|
||||
data: { action: 'published' },
|
||||
timestamp: 1_710_000_300_000,
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchPublishedWorkflow).toHaveBeenCalledWith('app-1')
|
||||
expect(setPublishedAt).toHaveBeenCalledWith(1_710_000_300)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -143,6 +143,27 @@ describe('PublishWithMultipleModel', () => {
|
||||
expect(screen.queryByText(/(?:^|\.)publishAs(?=$|:)/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable the trigger when publishing is unavailable', () => {
|
||||
render(
|
||||
<PublishWithMultipleModel
|
||||
disabled
|
||||
multipleModelConfigs={[
|
||||
{
|
||||
id: 'config-1',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
parameters: {},
|
||||
},
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /(?:^|\.)operation\.applyConfig(?=$|:)/ }),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should open matching model options and call onSelect', () => {
|
||||
const handleSelect = vi.fn()
|
||||
const modelConfig = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import type { ReactNode } from 'react'
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@@ -12,45 +13,19 @@ import {
|
||||
} from '../sections'
|
||||
|
||||
vi.mock('../publish-with-multiple-model', () => ({
|
||||
default: ({ onSelect }: { onSelect: (item: Record<string, unknown>) => void }) => (
|
||||
<button type="button" onClick={() => onSelect({ model: 'gpt-4o' })}>
|
||||
default: ({
|
||||
disabled,
|
||||
onSelect,
|
||||
}: {
|
||||
disabled?: boolean
|
||||
onSelect: (item: Record<string, unknown>) => void
|
||||
}) => (
|
||||
<button type="button" disabled={disabled} onClick={() => onSelect({ model: 'gpt-4o' })}>
|
||||
publish-multiple-model
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../suggested-action', () => ({
|
||||
default: ({
|
||||
children,
|
||||
onClick,
|
||||
link,
|
||||
disabled,
|
||||
actionButton,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
link?: string
|
||||
disabled?: boolean
|
||||
actionButton?: { ariaLabel: string; onClick: () => void }
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" data-link={link} disabled={disabled} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
{actionButton && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={actionButton.ariaLabel}
|
||||
disabled={disabled}
|
||||
onClick={actionButton.onClick}
|
||||
>
|
||||
{actionButton.ariaLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({
|
||||
default: (props: Record<string, unknown>) => (
|
||||
<div>
|
||||
@@ -60,6 +35,32 @@ vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
const createVersionInfo = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
|
||||
id: 'workflow-version-1',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
created_at: 1_710_000_000,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
hash: 'hash-1',
|
||||
updated_at: 1_710_000_000,
|
||||
updated_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
tool_published: false,
|
||||
version: '2024-03-09T16:00:00Z',
|
||||
marked_name: '',
|
||||
marked_comment: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('app-publisher sections', () => {
|
||||
it('should render restore controls for published chat apps', () => {
|
||||
const handleRestore = vi.fn()
|
||||
@@ -71,10 +72,10 @@ describe('app-publisher sections', () => {
|
||||
formatTimeFromNow={() => '3 minutes ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={handleRestore}
|
||||
hasUnpublishedChanges
|
||||
isChatApp
|
||||
multipleModelConfigs={[]}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={Date.now()}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
@@ -85,6 +86,35 @@ describe('app-publisher sections', () => {
|
||||
expect(handleRestore).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable restore for published chat apps without unpublished changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleRestore = vi.fn()
|
||||
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
draftUpdatedAt={Date.now()}
|
||||
formatTimeFromNow={() => '3 minutes ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={handleRestore}
|
||||
hasUnpublishedChanges={false}
|
||||
isChatApp
|
||||
multipleModelConfigs={[]}
|
||||
publishDisabled={false}
|
||||
publishedAt={Date.now()}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
const restoreButton = screen.getByRole('button', {
|
||||
name: /(?:^|\.)common\.restore(?=$|:)/,
|
||||
})
|
||||
expect(restoreButton).toBeDisabled()
|
||||
await user.click(restoreButton)
|
||||
expect(handleRestore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should expose the access control warning when subjects are missing', () => {
|
||||
render(
|
||||
<PublisherAccessSection
|
||||
@@ -100,7 +130,7 @@ describe('app-publisher sections', () => {
|
||||
expect(screen.getByText(/(?:^|\.)publishApp\.notSetDesc(?=$|:)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the publish update action when the draft has not been published yet', () => {
|
||||
it('should render the initial publish action when the draft has not been published yet', () => {
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
@@ -111,17 +141,118 @@ describe('app-publisher sections', () => {
|
||||
isChatApp={false}
|
||||
multipleModelConfigs={[]}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/(?:^|\.)common\.publishUpdate(?=$|:)/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/(?:^|\.)common\.notPublishedYet(?=$|:)/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)).toBeInTheDocument()
|
||||
expect(screen.getByText('P')).toBeInTheDocument()
|
||||
expect(screen.getByText(/(?:^|\.)common\.unpublishedChanges(?=$|:)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple-model publishing', () => {
|
||||
it('should expose naming for an unnamed published workflow with no draft changes', () => {
|
||||
const onEditVersion = vi.fn()
|
||||
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
draftUpdatedAt={1_710_000_000_000}
|
||||
formatTimeFromNow={() => '17 days ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={vi.fn()}
|
||||
hasUnpublishedChanges={false}
|
||||
isChatApp={false}
|
||||
isWorkflowApp
|
||||
multipleModelConfigs={[]}
|
||||
onEditVersion={onEditVersion}
|
||||
publishDisabled={false}
|
||||
publishedAt={1_710_000_100_000}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
versionInfo={createVersionInfo()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('2024-03-09T16:00:00Z')).toBeInTheDocument()
|
||||
const nameButton = screen.getByRole('button', {
|
||||
name: /versionHistory\.nameIt\b/,
|
||||
})
|
||||
fireEvent.click(nameButton)
|
||||
expect(onEditVersion).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('button', { name: /common\.published\b/ })).toBeDisabled()
|
||||
expect(screen.queryByText('P')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/common\.noChanges\b/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show named workflow metadata and publish update when its draft changed', () => {
|
||||
const onEditVersion = vi.fn()
|
||||
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
draftUpdatedAt={1_710_000_200_000}
|
||||
formatTimeFromNow={() => '2 minutes ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={vi.fn()}
|
||||
hasUnpublishedChanges
|
||||
isChatApp={false}
|
||||
isWorkflowApp
|
||||
multipleModelConfigs={[]}
|
||||
onEditVersion={onEditVersion}
|
||||
publishDisabled={false}
|
||||
publishedAt={1_710_000_100_000}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
versionInfo={createVersionInfo({
|
||||
marked_name: 'Sprint-42',
|
||||
marked_comment: 'Fixed data synchronization and page loading.',
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Sprint-42')).toBeInTheDocument()
|
||||
expect(screen.getByText('Fixed data synchronization and page loading.')).toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: /versionHistory\.editVersionInfo\b/,
|
||||
}),
|
||||
)
|
||||
expect(onEditVersion).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('button', { name: /common\.publishUpdate\b/ })).toBeEnabled()
|
||||
expect(screen.getByText(/common\.unpublishedChanges\b/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/common\.savedAt\b/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep non-workflow apps free of workflow version details and saved time', () => {
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
draftUpdatedAt={1_710_000_200_000}
|
||||
formatTimeFromNow={() => '2 minutes ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={vi.fn()}
|
||||
hasUnpublishedChanges
|
||||
isChatApp
|
||||
isWorkflowApp={false}
|
||||
multipleModelConfigs={[]}
|
||||
publishDisabled={false}
|
||||
publishedAt={1_710_000_100_000}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/common\.latestPublished\b/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('#5')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/versionHistory\.nameIt\b/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/common\.unpublishedChanges\b/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/common\.savedAt\b/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep multiple-model publishing available without publish config changes', () => {
|
||||
const handlePublish = vi.fn()
|
||||
|
||||
render(
|
||||
@@ -131,11 +262,11 @@ describe('app-publisher sections', () => {
|
||||
formatTimeFromNow={() => '1 minute ago'}
|
||||
handlePublish={handlePublish}
|
||||
handleRestore={vi.fn()}
|
||||
hasUnpublishedChanges={false}
|
||||
isChatApp={false}
|
||||
multipleModelConfigs={[{ id: '1' } as any]}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
publishedAt={Date.now()}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
@@ -146,6 +277,27 @@ describe('app-publisher sections', () => {
|
||||
expect(handlePublish).toHaveBeenCalledWith({ model: 'gpt-4o' })
|
||||
})
|
||||
|
||||
it('should disable multiple-model publishing when publishing is unavailable', () => {
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel
|
||||
draftUpdatedAt={Date.now()}
|
||||
formatTimeFromNow={() => '1 minute ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={vi.fn()}
|
||||
hasUnpublishedChanges={false}
|
||||
isChatApp={false}
|
||||
multipleModelConfigs={[{ id: '1' } as any]}
|
||||
publishDisabled
|
||||
publishedAt={Date.now()}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'publish-multiple-model' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should render the upgrade hint when the start node limit is exceeded', () => {
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
@@ -157,7 +309,6 @@ describe('app-publisher sections', () => {
|
||||
isChatApp={false}
|
||||
multipleModelConfigs={[]}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
startNodeLimitExceeded
|
||||
upgradeHighlightStyle={{}}
|
||||
@@ -213,12 +364,13 @@ describe('app-publisher sections', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workflow actions, batch run links, and workflow tool configuration', () => {
|
||||
const handleOpenInExplore = vi.fn()
|
||||
const handleEmbed = vi.fn()
|
||||
it('should render the published workflow actions with Workflow as Tool after Marketplace', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleOpenRunConfig = vi.fn()
|
||||
const onConfigureWorkflowTool = vi.fn()
|
||||
const onPublishToMarketplace = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{
|
||||
id: 'workflow-app',
|
||||
@@ -232,92 +384,280 @@ describe('app-publisher sections', () => {
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton={false}
|
||||
disabledFunctionTooltip="disabled"
|
||||
handleEmbed={handleEmbed}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handleOpenRunConfig={handleOpenRunConfig}
|
||||
handlePublish={vi.fn()}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
missingStartNode={false}
|
||||
published={false}
|
||||
publishedAt={Date.now()}
|
||||
showBatchRunConfig
|
||||
showDeployAction
|
||||
showMarketplaceAction
|
||||
showRunConfig
|
||||
toolPublished
|
||||
workflowToolAvailable={false}
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
workflowToolOutdated={false}
|
||||
workflowToolMessage="workflow-disabled"
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
onPublishToMarketplace={onPublishToMarketplace}
|
||||
onConfigureWorkflowTool={onConfigureWorkflowTool}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/(?:^|\.)common\.batchRunApp(?=$|:)/)).toHaveAttribute(
|
||||
'data-link',
|
||||
'https://example.com/app?mode=batch',
|
||||
expect(screen.getByRole('link', { name: /common\.openWebApp\b/ })).toHaveAttribute(
|
||||
'href',
|
||||
'https://example.com/app',
|
||||
)
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })[0]!)
|
||||
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ }))
|
||||
expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app')
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })[1]!)
|
||||
expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app?mode=batch')
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.openInExplore(?=$|:)/))
|
||||
expect(handleOpenInExplore).toHaveBeenCalled()
|
||||
expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow-disabled')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /appMenus\.accessPoint\b/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/workflow-app/access-point',
|
||||
)
|
||||
expect(screen.getByRole('link', { name: /appMenus\.deploy\b/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/workflow-app/deploy',
|
||||
)
|
||||
|
||||
rerender(
|
||||
const marketplaceAction = screen.getByRole('button', {
|
||||
name: /common\.publishToMarketplace\b/,
|
||||
})
|
||||
const workflowToolAction = screen.getByRole('button', {
|
||||
name: /common\.workflowAsTool\b/,
|
||||
})
|
||||
expect(
|
||||
marketplaceAction.compareDocumentPosition(workflowToolAction) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
expect(screen.getByRole('status', { name: /common\.configureRequired\b/ })).toBeInTheDocument()
|
||||
|
||||
await user.click(marketplaceAction)
|
||||
expect(onPublishToMarketplace).toHaveBeenCalledTimes(1)
|
||||
|
||||
await user.click(workflowToolAction)
|
||||
expect(onConfigureWorkflowTool).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should expose Configure and Manage in Tools actions for a ready workflow tool', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfigureWorkflowTool = vi.fn()
|
||||
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{
|
||||
id: 'chat-app',
|
||||
mode: AppModeEnum.CHAT,
|
||||
name: 'Chat App',
|
||||
id: 'workflow-app',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
name: 'Workflow App',
|
||||
}}
|
||||
appURL="https://example.com/app?foo=bar"
|
||||
disabledFunctionButton
|
||||
disabledFunctionTooltip="disabled"
|
||||
handleEmbed={handleEmbed}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handleOpenRunConfig={handleOpenRunConfig}
|
||||
handlePublish={vi.fn()}
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton={false}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
missingStartNode
|
||||
published={false}
|
||||
publishedAt={Date.now()}
|
||||
toolPublished={false}
|
||||
showDeployAction
|
||||
toolPublished
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
workflowToolOutdated={false}
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
onConfigureWorkflowTool={onConfigureWorkflowTool}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)common\.embedIntoSite(?=$|:)/))
|
||||
expect(handleEmbed).toHaveBeenCalled()
|
||||
expect(screen.getByText(/(?:^|\.)common\.accessAPIReference(?=$|:)/)).toBeDisabled()
|
||||
expect(
|
||||
screen.getByRole('status', { name: /common\.workflowAsToolReady\b/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common\.manageInTools\b/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/integrations/tools/workflow',
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /common\.configure\b/ }))
|
||||
expect(onConfigureWorkflowTool).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should show the disabled reason below setup and configured workflow tool actions', () => {
|
||||
const commonProps = {
|
||||
appDetail: {
|
||||
id: 'workflow-app',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
appURL: 'https://example.com/app',
|
||||
disabledFunctionButton: false,
|
||||
hasHumanInputNode: false,
|
||||
hasTriggerNode: false,
|
||||
onConfigureWorkflowTool: vi.fn(),
|
||||
publishedAt: Date.now(),
|
||||
workflowToolAvailable: false,
|
||||
workflowToolIsLoading: false,
|
||||
workflowToolMessage: 'Workflow tool unavailable',
|
||||
}
|
||||
const { rerender } = render(<PublisherActionsSection {...commonProps} toolPublished={false} />)
|
||||
|
||||
const setupAction = screen.getByRole('button', { name: /common\.workflowAsTool\b/ })
|
||||
const setupReason = screen.getByText('Workflow tool unavailable')
|
||||
expect(setupAction).toBeDisabled()
|
||||
expect(setupReason).toBeVisible()
|
||||
expect(
|
||||
setupAction.compareDocumentPosition(setupReason) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
|
||||
rerender(<PublisherActionsSection {...commonProps} toolPublished />)
|
||||
|
||||
const configureAction = screen.getByRole('button', { name: /common\.configure\b/ })
|
||||
const manageAction = screen.getByRole('button', { name: /common\.manageInTools\b/ })
|
||||
const configuredReason = screen.getByText('Workflow tool unavailable')
|
||||
expect(configureAction).toBeDisabled()
|
||||
expect(manageAction).toBeDisabled()
|
||||
expect(configuredReason).toBeVisible()
|
||||
expect(
|
||||
manageAction.compareDocumentPosition(configuredReason) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should surface update-needed and loading states for a configured workflow tool', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfigureWorkflowTool = vi.fn()
|
||||
const commonProps = {
|
||||
appDetail: {
|
||||
id: 'workflow-app',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
appURL: 'https://example.com/app',
|
||||
disabledFunctionButton: false,
|
||||
hasHumanInputNode: false,
|
||||
hasTriggerNode: false,
|
||||
onConfigureWorkflowTool,
|
||||
publishedAt: Date.now(),
|
||||
toolPublished: true,
|
||||
workflowToolAvailable: true,
|
||||
}
|
||||
const { rerender } = render(
|
||||
<PublisherActionsSection
|
||||
{...commonProps}
|
||||
workflowToolIsLoading={false}
|
||||
workflowToolOutdated
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole('status', { name: /common\.workflowAsToolUpdateNeeded\b/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(/common\.workflowAsToolTip\b/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /common\.workflowAsToolReconfigure\b/ }))
|
||||
expect(onConfigureWorkflowTool).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'trigger-app', mode: AppModeEnum.WORKFLOW }}
|
||||
{...commonProps}
|
||||
workflowToolIsLoading
|
||||
workflowToolOutdated={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /common\.workflowAsTool\b/ })).toBeDisabled()
|
||||
expect(screen.getByRole('status', { name: /loading\b/ })).toBeInTheDocument()
|
||||
expect(screen.queryByText(/common\.workflowAsToolTip\b/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep Access Point and Deploy available for trigger workflows', () => {
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{
|
||||
id: 'trigger-app',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}}
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton={false}
|
||||
handleEmbed={handleEmbed}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handleOpenRunConfig={handleOpenRunConfig}
|
||||
handlePublish={vi.fn()}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode
|
||||
missingStartNode={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
toolPublished={false}
|
||||
publishedAt={Date.now()}
|
||||
showDeployAction
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
workflowToolOutdated={false}
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/(?:^|\.)common\.runApp(?=$|:)/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/(?:^|\.)common\.openWebApp(?=$|:)/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/(?:^|\.)common\.workflowAsTool(?=$|:)/)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /appMenus\.accessPoint\b/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/trigger-app/access-point',
|
||||
)
|
||||
expect(screen.getByRole('link', { name: /appMenus\.deploy\b/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/trigger-app/deploy',
|
||||
)
|
||||
})
|
||||
|
||||
it('should expose unavailable quick links as disabled buttons before the first publish', () => {
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
publishedAt={undefined}
|
||||
showDeployAction
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/(?:^|\.)common\.openWebApp(?=$|:)/).closest('button')).toBeDisabled()
|
||||
expect(
|
||||
screen.getByText(/(?:^|\.)appMenus\.accessPoint(?=$|:)/).closest('button'),
|
||||
).toBeDisabled()
|
||||
expect(screen.getByText(/(?:^|\.)appMenus\.deploy(?=$|:)/).closest('button')).toBeDisabled()
|
||||
expect(
|
||||
screen.getByText(/(?:^|\.)common\.workflowAsTool(?=$|:)/).closest('button'),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should show the disabled reason when hovering an unavailable action', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton
|
||||
disabledFunctionTooltip="Open web app unavailable"
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
publishedAt={undefined}
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.hover(screen.getByRole('button', { name: /common\.openWebApp\b/ }))
|
||||
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Open web app unavailable')
|
||||
})
|
||||
|
||||
it('should keep an unavailable action with a tooltip keyboard focusable', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton
|
||||
disabledFunctionTooltip="Open web app unavailable"
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
publishedAt={undefined}
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.tab()
|
||||
|
||||
const action = screen.getByRole('button', { name: /common\.openWebApp\b/ })
|
||||
expect(action).toHaveFocus()
|
||||
expect(action).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(action).toHaveAccessibleDescription('Open web app unavailable')
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Open web app unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,79 +1,133 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SuggestedAction from '../suggested-action'
|
||||
|
||||
describe('SuggestedAction', () => {
|
||||
it('should render an enabled external link', () => {
|
||||
render(<SuggestedAction link="https://example.com/docs">Open docs</SuggestedAction>)
|
||||
it('should render an enabled external link with supporting copy', () => {
|
||||
render(
|
||||
<SuggestedAction
|
||||
link="https://example.com/docs"
|
||||
external
|
||||
description="Read the documentation"
|
||||
>
|
||||
Open docs
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Open docs' })
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/docs')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAccessibleDescription('Read the documentation')
|
||||
expect(screen.getByText('Read the documentation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should block clicks when disabled', () => {
|
||||
it('should render internal destinations without opening a new tab', () => {
|
||||
render(
|
||||
<SuggestedAction link="/app/app-1/deploy" description="Push versions to environments">
|
||||
Deploy
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Deploy' })
|
||||
expect(link).toHaveAttribute('href', '/app/app-1/deploy')
|
||||
expect(link).not.toHaveAttribute('target')
|
||||
expect(link).toHaveAccessibleDescription('Push versions to environments')
|
||||
})
|
||||
|
||||
it('should use native disabled button semantics for unavailable links', () => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction link="https://example.com/docs" disabled onClick={handleClick}>
|
||||
Disabled action
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
const link = screen.getByText('Disabled action').closest('a') as HTMLAnchorElement
|
||||
fireEvent.click(link)
|
||||
|
||||
expect(link).not.toHaveAttribute('href')
|
||||
expect(handleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should forward click events when enabled', () => {
|
||||
const handleClick = vi.fn((event: ReactMouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
render(
|
||||
<SuggestedAction link="https://example.com/docs" onClick={handleClick}>
|
||||
Enabled action
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Enabled action' }))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render and trigger the trailing action button when configured', () => {
|
||||
const handleActionClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction
|
||||
link="https://example.com/docs"
|
||||
disabled
|
||||
description="Unavailable until published"
|
||||
onClick={handleClick}
|
||||
>
|
||||
Disabled action
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
const action = screen.getByRole('button', { name: 'Disabled action' })
|
||||
fireEvent.click(action)
|
||||
|
||||
expect(action).toBeDisabled()
|
||||
expect(screen.queryByRole('link', { name: /Disabled action/ })).not.toBeInTheDocument()
|
||||
expect(handleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep an explained disabled action in the keyboard tab order', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction disabled focusableWhenDisabled onClick={handleClick}>
|
||||
Disabled action with explanation
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
await user.tab()
|
||||
|
||||
const action = screen.getByRole('button', { name: 'Disabled action with explanation' })
|
||||
expect(action).toHaveFocus()
|
||||
expect(action).toHaveAttribute('aria-disabled', 'true')
|
||||
|
||||
await user.keyboard('{Enter}')
|
||||
expect(handleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render and trigger an enabled button action', () => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction
|
||||
description="Use as a tool in other apps"
|
||||
endIcon={<span data-testid="configure-icon" />}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Workflow as Tool
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workflow as Tool' }))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('configure-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the main link separate from a trailing action button', () => {
|
||||
const handleActionClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction
|
||||
link="https://example.com/app"
|
||||
external
|
||||
actionButton={{
|
||||
ariaLabel: 'Configure action',
|
||||
icon: <span>config</span>,
|
||||
onClick: handleActionClick,
|
||||
}}
|
||||
>
|
||||
Configurable action
|
||||
Open web app
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Configurable action' })).toHaveAttribute(
|
||||
expect(screen.getByRole('link', { name: 'Open web app' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://example.com/docs',
|
||||
'https://example.com/app',
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
|
||||
expect(handleActionClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should block action button clicks when disabled', () => {
|
||||
it('should disable both controls when an action with a trailing button is unavailable', () => {
|
||||
const handleActionClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction
|
||||
link="https://example.com/docs"
|
||||
link="https://example.com/app"
|
||||
external
|
||||
disabled
|
||||
actionButton={{
|
||||
ariaLabel: 'Configure action',
|
||||
@@ -81,11 +135,15 @@ describe('SuggestedAction', () => {
|
||||
onClick: handleActionClick,
|
||||
}}
|
||||
>
|
||||
Disabled with action
|
||||
Open web app
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
|
||||
expect(screen.getByRole('button', { name: 'Open web app' })).toBeDisabled()
|
||||
|
||||
const actionButton = screen.getByRole('button', { name: 'Configure action' })
|
||||
fireEvent.click(actionButton)
|
||||
expect(actionButton).toBeDisabled()
|
||||
expect(handleActionClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
|
||||
type ActionTooltipProps = {
|
||||
children: ReactNode
|
||||
disabled: boolean
|
||||
tooltip?: ReactNode
|
||||
}
|
||||
|
||||
const ActionTooltip = ({ children, disabled, tooltip }: ActionTooltipProps) => {
|
||||
if (!tooltip) return <>{children}</>
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div
|
||||
className={cn('flex w-full', disabled && 'cursor-not-allowed *:pointer-events-none')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent role="tooltip">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionTooltip
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_PUBLISH_DRAFT_CHANGED = 'APP_PUBLISH_DRAFT_CHANGED'
|
||||
@@ -2,8 +2,8 @@ import type {
|
||||
AppPublisherProps,
|
||||
AppPublisherPublishParams,
|
||||
} from '@/app/components/app/app-publisher'
|
||||
import type { ConfigurationPublishConfig } from '@/app/components/app/configuration/hooks/use-configuration-utils'
|
||||
import type { Features, FileUpload } from '@/app/components/base/features/types'
|
||||
import type { ModelConfig } from '@/models/debug'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AppPublisher } from '@/app/components/app/app-publisher'
|
||||
@@ -23,18 +22,12 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { Resolution } from '@/types/app'
|
||||
|
||||
type PublishedModelConfig = ModelConfig & {
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
|
||||
type Props = Omit<AppPublisherProps, 'onPublish'> & {
|
||||
onPublish?: (
|
||||
params?: AppPublisherPublishParams,
|
||||
features?: Features,
|
||||
) => Promise<unknown> | unknown
|
||||
publishedConfig: {
|
||||
modelConfig: PublishedModelConfig
|
||||
}
|
||||
publishedConfig: ConfigurationPublishConfig
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
|
||||
@@ -43,6 +36,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
|
||||
const features = useFeatures((s) => s.features)
|
||||
const featuresStore = useFeaturesStore()
|
||||
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false)
|
||||
const { resetAppConfig } = props
|
||||
const {
|
||||
more_like_this,
|
||||
opening_statement,
|
||||
@@ -54,10 +48,9 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
|
||||
retriever_resource,
|
||||
annotation_reply,
|
||||
file_upload,
|
||||
resetAppConfig,
|
||||
} = props.publishedConfig.modelConfig
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
const handleConfirm = () => {
|
||||
resetAppConfig?.()
|
||||
const { features, setFeatures } = featuresStore!.getState()
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
@@ -90,9 +83,9 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
|
||||
number_limits: file_upload?.number_limits || file_upload?.image?.number_limits || 3,
|
||||
} as FileUpload
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
setFeatures(newFeatures, { silent: true })
|
||||
setRestoreConfirmOpen(false)
|
||||
}, [featuresStore, props])
|
||||
}
|
||||
|
||||
const handlePublish = useCallback(
|
||||
(params?: AppPublisherPublishParams) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { use, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WorkflowLaunchDialog } from '@/app/components/app/overview/app-card-sections'
|
||||
@@ -20,10 +21,8 @@ import {
|
||||
createWorkflowLaunchInitialValues,
|
||||
isWorkflowLaunchInputSupported,
|
||||
} from '@/app/components/app/overview/app-card-utils'
|
||||
import EmbeddedModal from '@/app/components/app/overview/embedded'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes'
|
||||
import { useCanManageTools } from '@/app/components/tools/hooks/use-tool-permissions'
|
||||
import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool'
|
||||
import { useConfigureButton } from '@/app/components/tools/workflow-tool/hooks/use-configure-button'
|
||||
@@ -31,18 +30,20 @@ import { collaborationManager } from '@/app/components/workflow/collaboration/co
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import { isCurrentWorkspaceEditorAtom } from '@/context/workspace-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { fetchAppDetail, publishToCreatorsPlatform } from '@/service/apps'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { appDetailQueryKeyPrefix } from '@/service/use-apps'
|
||||
import { useInvalidateAppWorkflow } from '@/service/use-workflow'
|
||||
import { fetchPublishedWorkflow } from '@/service/workflow'
|
||||
import {
|
||||
appWorkflowQueryOptions,
|
||||
useAppWorkflow,
|
||||
useInvalidateAppWorkflow,
|
||||
useUpdateWorkflow,
|
||||
} from '@/service/use-workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import AccessControl from '../app-access-control'
|
||||
import { APP_PUBLISH_HOTKEY } from './hotkeys'
|
||||
import {
|
||||
@@ -50,12 +51,12 @@ import {
|
||||
PublisherActionsSection,
|
||||
PublisherSummarySection,
|
||||
} from './sections'
|
||||
import SuggestedAction from './suggested-action'
|
||||
import {
|
||||
getDisabledFunctionTooltip,
|
||||
getPublisherAppUrl,
|
||||
isPublisherAccessConfigured,
|
||||
} from './utils'
|
||||
import VersionInfoModal from './version-info-modal'
|
||||
|
||||
export type AppPublisherProps = {
|
||||
disabled?: boolean
|
||||
@@ -63,6 +64,10 @@ export type AppPublisherProps = {
|
||||
publishedAt?: number
|
||||
/** only needed in workflow / chatflow mode */
|
||||
draftUpdatedAt?: number
|
||||
/** Current persisted workflow draft hash, used to compare with the published workflow. */
|
||||
draftHash?: string
|
||||
/** Non-workflow editors should pass their local dirty state. */
|
||||
hasUnpublishedChanges?: boolean
|
||||
debugWithMultipleModel?: boolean
|
||||
multipleModelConfigs?: ModelAndParameter[]
|
||||
/** modelAndParameter is passed when debugWithMultipleModel is true */
|
||||
@@ -94,6 +99,8 @@ export function AppPublisher({
|
||||
publishDisabled = false,
|
||||
publishedAt,
|
||||
draftUpdatedAt,
|
||||
draftHash,
|
||||
hasUnpublishedChanges,
|
||||
debugWithMultipleModel = false,
|
||||
multipleModelConfigs = [],
|
||||
onPublish,
|
||||
@@ -112,32 +119,44 @@ export function AppPublisher({
|
||||
}: AppPublisherProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [published, setPublished] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
|
||||
const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false)
|
||||
|
||||
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
|
||||
const [workflowLaunchDialogOpen, setWorkflowLaunchDialogOpen] = useState(false)
|
||||
const [workflowLaunchTargetUrl, setWorkflowLaunchTargetUrl] = useState('')
|
||||
const [workflowLaunchValues, setWorkflowLaunchValues] = useState<
|
||||
Record<string, WorkflowLaunchInputValue>
|
||||
>({})
|
||||
const [publishingToMarketplace, setPublishingToMarketplace] = useState(false)
|
||||
const [editVersionInfoOpen, setEditVersionInfoOpen] = useState(false)
|
||||
|
||||
const workflowStore = use(WorkflowContext)
|
||||
const appDetail = useAppStore((state) => state.appDetail)
|
||||
const setAppDetail = useAppStore((state) => state.setAppDetail)
|
||||
const canManageTools = useCanManageTools()
|
||||
const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom)
|
||||
const queryClient = useQueryClient()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
|
||||
|
||||
const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode })
|
||||
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(
|
||||
appDetail?.mode || AppModeEnum.CHAT,
|
||||
const appMode = appDetail?.mode
|
||||
const isWorkflowApp = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
|
||||
const isChatApp =
|
||||
appMode === AppModeEnum.CHAT ||
|
||||
appMode === AppModeEnum.AGENT_CHAT ||
|
||||
appMode === AppModeEnum.COMPLETION
|
||||
const { data: publishedWorkflow, isSuccess: isPublishedWorkflowSuccess } = useAppWorkflow(
|
||||
isWorkflowApp ? (appDetail?.id ?? '') : '',
|
||||
)
|
||||
const currentPublishedAt =
|
||||
isWorkflowApp && isPublishedWorkflowSuccess
|
||||
? publishedWorkflow?.created_at
|
||||
? publishedWorkflow.created_at * 1000
|
||||
: undefined
|
||||
: publishedAt
|
||||
const { mutate: updateWorkflow } = useUpdateWorkflow()
|
||||
const hiddenLaunchVariables: WorkflowHiddenStartVariable[] = (inputs ?? []).filter(
|
||||
(input) => input.hide === true,
|
||||
)
|
||||
@@ -166,7 +185,6 @@ export function AppPublisher({
|
||||
appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
)
|
||||
const invalidateAppWorkflow = useInvalidateAppWorkflow()
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const isAppAccessSet = isPublisherAccessConfigured(appDetail, appAccessSubjects)
|
||||
|
||||
@@ -176,10 +194,10 @@ export function AppPublisher({
|
||||
appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS &&
|
||||
!userCanAccessApp?.result,
|
||||
)
|
||||
const disabledFunctionButton = !publishedAt || missingStartNode || noAccessPermission
|
||||
const disabledFunctionButton = !currentPublishedAt || missingStartNode || noAccessPermission
|
||||
const disabledFunctionTooltip = getDisabledFunctionTooltip({
|
||||
t,
|
||||
publishedAt,
|
||||
publishedAt: currentPublishedAt,
|
||||
missingStartNode,
|
||||
noAccessPermission,
|
||||
})
|
||||
@@ -187,7 +205,6 @@ export function AppPublisher({
|
||||
async function handlePublish(params?: ModelAndParameter | PublishWorkflowParams) {
|
||||
try {
|
||||
await onPublish?.(params)
|
||||
setPublished(true)
|
||||
|
||||
const appId = appDetail?.id
|
||||
const socket = appId ? webSocketClient.getSocket(appId) : null
|
||||
@@ -214,7 +231,6 @@ export function AppPublisher({
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[app-publisher] publish failed', error)
|
||||
setPublished(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,25 +249,6 @@ export function AppPublisher({
|
||||
|
||||
onToggle?.(nextOpen)
|
||||
setOpen(nextOpen)
|
||||
|
||||
if (nextOpen) setPublished(false)
|
||||
}
|
||||
|
||||
async function handleOpenInExplore() {
|
||||
await openAsyncWindow(
|
||||
async () => {
|
||||
if (!appDetail?.id) throw new Error('App not found')
|
||||
const { installed_apps } = await fetchInstalledAppList(appDetail.id)
|
||||
if (installed_apps?.length > 0)
|
||||
return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}`
|
||||
throw new Error(t(($) => $.notPublishedYet, { ns: 'app' }))
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error(`${err.message || err}`)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function handleAccessControlUpdate() {
|
||||
@@ -304,9 +301,50 @@ export function AppPublisher({
|
||||
}
|
||||
}
|
||||
|
||||
const hasPublishedVersion = Boolean(currentPublishedAt)
|
||||
const workflowHasUnpublishedChanges =
|
||||
!currentPublishedAt ||
|
||||
!draftHash ||
|
||||
!publishedWorkflow?.hash ||
|
||||
draftHash !== publishedWorkflow.hash
|
||||
const resolvedHasUnpublishedChanges =
|
||||
hasUnpublishedChanges ?? (isWorkflowApp ? workflowHasUnpublishedChanges : !currentPublishedAt)
|
||||
|
||||
function handleOpenVersionInfo() {
|
||||
if (!publishedWorkflow) return
|
||||
|
||||
handleOpenChange(false)
|
||||
setEditVersionInfoOpen(true)
|
||||
}
|
||||
|
||||
function handleUpdateVersionInfo(params: { id?: string; title: string; releaseNotes: string }) {
|
||||
if (!appDetail?.id || !params.id) return
|
||||
|
||||
updateWorkflow(
|
||||
{
|
||||
url: `/apps/${appDetail.id}/workflows/${params.id}`,
|
||||
title: params.title,
|
||||
releaseNotes: params.releaseNotes,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t(($) => $['versionHistory.action.updateSuccess'], { ns: 'workflow' }))
|
||||
invalidateAppWorkflow(appDetail.id)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['versionHistory.action.updateFailure'], { ns: 'workflow' }))
|
||||
},
|
||||
onSettled: () => {
|
||||
setEditVersionInfoOpen(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
useHotkey(APP_PUBLISH_HOTKEY, (e) => {
|
||||
if (debugWithMultipleModel) return
|
||||
e.preventDefault()
|
||||
if (publishDisabled || published) return
|
||||
if (publishDisabled || (hasPublishedVersion && !resolvedHasUnpublishedChanges)) return
|
||||
handlePublish()
|
||||
})
|
||||
|
||||
@@ -317,11 +355,10 @@ export function AppPublisher({
|
||||
const unsubscribe = collaborationManager.onAppPublishUpdate((update: CollaborationUpdate) => {
|
||||
const action = typeof update.data.action === 'string' ? update.data.action : undefined
|
||||
if (action === 'published') {
|
||||
invalidateAppWorkflow(appId)
|
||||
fetchPublishedWorkflow(`/apps/${appId}/workflows/publish`)
|
||||
void queryClient
|
||||
.fetchQuery(appWorkflowQueryOptions(appId))
|
||||
.then((publishedWorkflow) => {
|
||||
if (publishedWorkflow?.created_at)
|
||||
workflowStore?.getState().setPublishedAt(publishedWorkflow.created_at)
|
||||
workflowStore?.getState().setPublishedAt(publishedWorkflow?.created_at ?? 0)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[app-publisher] refresh published workflow failed', error)
|
||||
@@ -330,9 +367,8 @@ export function AppPublisher({
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appDetail?.id, invalidateAppWorkflow, workflowStore])
|
||||
}, [appDetail?.id, queryClient, workflowStore])
|
||||
|
||||
const hasPublishedVersion = !!publishedAt
|
||||
const workflowToolVisible =
|
||||
appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode
|
||||
const workflowToolAvailableForUser = workflowToolAvailable && canManageTools
|
||||
@@ -353,7 +389,7 @@ export function AppPublisher({
|
||||
const workflowTool = useConfigureButton({
|
||||
enabled: workflowToolVisible && canManageTools,
|
||||
published: workflowToolPublished,
|
||||
detailNeedUpdate: workflowToolPublished && published,
|
||||
detailNeedUpdate: workflowToolPublished && !resolvedHasUnpublishedChanges,
|
||||
workflowAppId: appDetail?.id ?? '',
|
||||
icon: workflowToolIcon,
|
||||
name: appDetail?.name ?? '',
|
||||
@@ -395,20 +431,23 @@ export function AppPublisher({
|
||||
alignOffset={crossAxisOffset}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
|
||||
<div className="w-98 rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={debugWithMultipleModel}
|
||||
draftUpdatedAt={draftUpdatedAt}
|
||||
formatTimeFromNow={formatTimeFromNow}
|
||||
handlePublish={handlePublish}
|
||||
handleRestore={handleRestore}
|
||||
hasUnpublishedChanges={resolvedHasUnpublishedChanges}
|
||||
isChatApp={isChatApp}
|
||||
isWorkflowApp={isWorkflowApp}
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onEditVersion={handleOpenVersionInfo}
|
||||
publishDisabled={publishDisabled}
|
||||
published={published}
|
||||
publishedAt={publishedAt}
|
||||
publishedAt={currentPublishedAt}
|
||||
startNodeLimitExceeded={startNodeLimitExceeded}
|
||||
upgradeHighlightStyle={upgradeHighlightStyle}
|
||||
versionInfo={publishedWorkflow}
|
||||
/>
|
||||
<PublisherAccessSection
|
||||
enabled={systemFeatures.webapp_auth.enabled}
|
||||
@@ -428,57 +467,29 @@ export function AppPublisher({
|
||||
appURL={appURL}
|
||||
disabledFunctionButton={disabledFunctionButton}
|
||||
disabledFunctionTooltip={disabledFunctionTooltip}
|
||||
handleEmbed={() => {
|
||||
setEmbeddingModalOpen(true)
|
||||
handleOpenChange(false)
|
||||
}}
|
||||
handleOpenInExplore={() => {
|
||||
handleOpenChange(false)
|
||||
handleOpenInExplore()
|
||||
}}
|
||||
handleOpenRunConfig={handleOpenWorkflowLaunchDialog}
|
||||
handlePublish={handlePublish}
|
||||
hasHumanInputNode={hasHumanInputNode}
|
||||
hasTriggerNode={hasTriggerNode}
|
||||
missingStartNode={missingStartNode}
|
||||
published={published}
|
||||
publishedAt={publishedAt}
|
||||
showBatchRunConfig={
|
||||
hiddenLaunchVariables.length > 0 &&
|
||||
(appDetail?.mode === AppModeEnum.WORKFLOW ||
|
||||
appDetail?.mode === AppModeEnum.COMPLETION)
|
||||
marketplaceActionDisabled={!currentPublishedAt}
|
||||
publishedAt={currentPublishedAt}
|
||||
publishingToMarketplace={publishingToMarketplace}
|
||||
showDeployAction={
|
||||
appDetail?.mode === AppModeEnum.WORKFLOW &&
|
||||
isCurrentWorkspaceEditor &&
|
||||
systemFeatures.enable_app_deploy
|
||||
}
|
||||
showMarketplaceAction={systemFeatures.enable_creators_platform}
|
||||
showRunConfig={hiddenLaunchVariables.length > 0}
|
||||
toolPublished={toolPublished}
|
||||
toolPublished={workflowToolPublished}
|
||||
workflowToolAvailable={workflowToolAvailableForUser}
|
||||
workflowToolIsLoading={workflowTool.isLoading}
|
||||
workflowToolOutdated={workflowTool.outdated}
|
||||
workflowToolMessage={workflowToolMessage}
|
||||
workflowToolOutdated={workflowTool.outdated}
|
||||
onConfigureWorkflowTool={openWorkflowToolDrawer}
|
||||
onPublishToMarketplace={handlePublishToMarketplace}
|
||||
/>
|
||||
{systemFeatures.enable_creators_platform && (
|
||||
<div className="border-t border-divider-subtle p-4">
|
||||
<SuggestedAction
|
||||
icon={<span className="i-ri-store-line size-4" />}
|
||||
disabled={!publishedAt || publishingToMarketplace}
|
||||
onClick={handlePublishToMarketplace}
|
||||
>
|
||||
{publishingToMarketplace
|
||||
? t(($) => $['common.publishingToMarketplace'], { ns: 'workflow' })
|
||||
: t(($) => $['common.publishToMarketplace'], { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
<EmbeddedModal
|
||||
siteInfo={appDetail?.site}
|
||||
isShow={embeddingModalOpen}
|
||||
onClose={() => setEmbeddingModalOpen(false)}
|
||||
appBaseUrl={appBaseURL}
|
||||
accessToken={accessToken}
|
||||
hiddenInputs={hiddenLaunchVariables}
|
||||
/>
|
||||
{showAppAccessControl && (
|
||||
<AccessControl
|
||||
app={appDetail!}
|
||||
@@ -499,6 +510,14 @@ export function AppPublisher({
|
||||
onSubmit={handleWorkflowLaunchConfirm}
|
||||
/>
|
||||
</Popover>
|
||||
{editVersionInfoOpen && (
|
||||
<VersionInfoModal
|
||||
isOpen={editVersionInfoOpen}
|
||||
versionInfo={publishedWorkflow ?? undefined}
|
||||
onClose={() => setEditVersionInfoOpen(false)}
|
||||
onPublish={handleUpdateVersionInfo}
|
||||
/>
|
||||
)}
|
||||
{workflowToolDrawerOpen && canManageTools && (
|
||||
<WorkflowToolDrawer
|
||||
isAdd={!workflowToolPublished}
|
||||
|
||||
@@ -19,11 +19,13 @@ import { useProviderContext } from '@/context/provider-context'
|
||||
import ModelIcon from '../../header/account-setting/model-provider-page/model-icon'
|
||||
|
||||
type PublishWithMultipleModelProps = {
|
||||
disabled?: boolean
|
||||
multipleModelConfigs: ModelAndParameter[]
|
||||
// textGenerationModelList?: Model[]
|
||||
onSelect: (v: ModelAndParameter) => void
|
||||
}
|
||||
const PublishWithMultipleModel: FC<PublishWithMultipleModelProps> = ({
|
||||
disabled = false,
|
||||
multipleModelConfigs,
|
||||
// textGenerationModelList = [],
|
||||
onSelect,
|
||||
@@ -55,13 +57,13 @@ const PublishWithMultipleModel: FC<PublishWithMultipleModelProps> = ({
|
||||
}
|
||||
})
|
||||
|
||||
const triggerDisabled = disabled || !validModelConfigs.length
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={!validModelConfigs.length}
|
||||
render={
|
||||
<Button variant="primary" disabled={!validModelConfigs.length} className="mt-3 w-full" />
|
||||
}
|
||||
disabled={triggerDisabled}
|
||||
render={<Button variant="primary" disabled={triggerDisabled} className="w-full" />}
|
||||
>
|
||||
<>
|
||||
{t(($) => $['operation.applyConfig'], { ns: 'appDebug' })}
|
||||
@@ -69,12 +71,12 @@ const PublishWithMultipleModel: FC<PublishWithMultipleModelProps> = ({
|
||||
</>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[288px] p-1">
|
||||
<div className="flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary">
|
||||
<div className="flex h-5.5 items-center px-3 text-xs font-medium text-text-tertiary">
|
||||
{t(($) => $.publishAs, { ns: 'appDebug' })}
|
||||
</div>
|
||||
{validModelConfigs.map((item, index) => (
|
||||
<DropdownMenuItem key={item.id} className="gap-0 px-3" onClick={() => onSelect(item)}>
|
||||
<span className="min-w-[18px] italic">#{index + 1}</span>
|
||||
<span className="min-w-4.5 italic">#{index + 1}</span>
|
||||
<ModelIcon modelName={item.model} provider={item.providerItem} className="ml-2" />
|
||||
<div
|
||||
className="ml-1 truncate text-text-secondary"
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import type { AppPublisherProps } from './index'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import type { PublishWorkflowParams, VersionHistory } from '@/types/workflow'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
import { APP_PUBLISH_HOTKEY } from './hotkeys'
|
||||
import PublishWithMultipleModel from './publish-with-multiple-model'
|
||||
import SuggestedAction from './suggested-action'
|
||||
import { ACCESS_MODE_MAP } from './utils'
|
||||
import WorkflowToolAction from './workflow-tool-action'
|
||||
|
||||
type SummarySectionProps = Pick<
|
||||
AppPublisherProps,
|
||||
@@ -29,9 +30,12 @@ type SummarySectionProps = Pick<
|
||||
formatTimeFromNow: (value: number) => string
|
||||
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
|
||||
handleRestore: () => Promise<void>
|
||||
hasUnpublishedChanges?: boolean
|
||||
isChatApp: boolean
|
||||
published: boolean
|
||||
isWorkflowApp?: boolean
|
||||
onEditVersion?: () => void
|
||||
upgradeHighlightStyle: CSSProperties
|
||||
versionInfo?: VersionHistory | null
|
||||
}
|
||||
|
||||
type AccessSectionProps = {
|
||||
@@ -44,12 +48,7 @@ type AccessSectionProps = {
|
||||
|
||||
type ActionsSectionProps = Pick<
|
||||
AppPublisherProps,
|
||||
| 'hasHumanInputNode'
|
||||
| 'hasTriggerNode'
|
||||
| 'missingStartNode'
|
||||
| 'toolPublished'
|
||||
| 'publishedAt'
|
||||
| 'workflowToolAvailable'
|
||||
'hasHumanInputNode' | 'hasTriggerNode' | 'publishedAt' | 'toolPublished' | 'workflowToolAvailable'
|
||||
> & {
|
||||
appDetail:
|
||||
| {
|
||||
@@ -66,17 +65,17 @@ type ActionsSectionProps = Pick<
|
||||
appURL: string
|
||||
disabledFunctionButton: boolean
|
||||
disabledFunctionTooltip?: string
|
||||
handleEmbed: () => void
|
||||
handleOpenInExplore: () => void
|
||||
handleOpenRunConfig?: (url: string) => void
|
||||
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
|
||||
published: boolean
|
||||
showBatchRunConfig?: boolean
|
||||
marketplaceActionDisabled?: boolean
|
||||
publishingToMarketplace?: boolean
|
||||
showDeployAction?: boolean
|
||||
showMarketplaceAction?: boolean
|
||||
showRunConfig?: boolean
|
||||
workflowToolIsLoading: boolean
|
||||
workflowToolOutdated: boolean
|
||||
workflowToolMessage?: string
|
||||
workflowToolOutdated?: boolean
|
||||
onConfigureWorkflowTool: () => void
|
||||
onPublishToMarketplace?: () => void
|
||||
}
|
||||
|
||||
export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => {
|
||||
@@ -98,97 +97,227 @@ export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MA
|
||||
)
|
||||
}
|
||||
|
||||
const PublisherTimelineMarker = ({ position }: { position: 'top' | 'bottom' }) => (
|
||||
<span
|
||||
className={cn(
|
||||
'relative flex w-4 shrink-0 items-start p-1',
|
||||
position === 'top' ? 'self-stretch' : 'h-4',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="relative z-1 size-2 rounded-full border-2 border-text-quaternary bg-components-panel-bg"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute left-1/2 w-0.5 -translate-x-1/2 bg-divider-subtle',
|
||||
position === 'top' ? 'top-3.5 -bottom-3.5' : '-top-3.5 h-4',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
|
||||
export const PublisherSummarySection = ({
|
||||
debugWithMultipleModel = false,
|
||||
draftUpdatedAt,
|
||||
formatTimeFromNow,
|
||||
handlePublish,
|
||||
handleRestore,
|
||||
hasUnpublishedChanges,
|
||||
isChatApp,
|
||||
isWorkflowApp = false,
|
||||
multipleModelConfigs = [],
|
||||
onEditVersion,
|
||||
publishDisabled = false,
|
||||
published,
|
||||
publishedAt,
|
||||
startNodeLimitExceeded = false,
|
||||
upgradeHighlightStyle,
|
||||
versionInfo,
|
||||
}: SummarySectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const hasPublishedVersion = Boolean(publishedAt)
|
||||
const resolvedHasUnpublishedChanges = hasUnpublishedChanges ?? !hasPublishedVersion
|
||||
const publishedTimestamp =
|
||||
publishedAt || (versionInfo?.created_at ? versionInfo.created_at * 1000 : undefined)
|
||||
const publisherName = versionInfo?.created_by.name
|
||||
const markedName = versionInfo?.marked_name
|
||||
const markedComment = versionInfo?.marked_comment
|
||||
const publishButtonDisabled =
|
||||
publishDisabled || (hasPublishedVersion && !resolvedHasUnpublishedChanges)
|
||||
const publishButtonLabel = !hasPublishedVersion
|
||||
? t(($) => $['common.publish'], { ns: 'workflow' })
|
||||
: resolvedHasUnpublishedChanges
|
||||
? t(($) => $['common.publishUpdate'], { ns: 'workflow' })
|
||||
: t(($) => $['common.published'], { ns: 'workflow' })
|
||||
|
||||
return (
|
||||
<div className="p-4 pt-3">
|
||||
<div className="flex h-6 items-center system-xs-medium-uppercase text-text-tertiary">
|
||||
{publishedAt
|
||||
? t(($) => $['common.latestPublished'], { ns: 'workflow' })
|
||||
: t(($) => $['common.currentDraftUnpublished'], { ns: 'workflow' })}
|
||||
</div>
|
||||
{publishedAt ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center system-sm-medium text-text-secondary">
|
||||
{t(($) => $['common.publishedAt'], { ns: 'workflow' })} {formatTimeFromNow(publishedAt)}
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-start gap-1 px-1 py-0.5">
|
||||
<PublisherTimelineMarker position="top" />
|
||||
{!hasPublishedVersion ? (
|
||||
<p className="min-w-0 flex-1 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['common.notPublishedYet'], { ns: 'workflow' })}
|
||||
</p>
|
||||
) : isWorkflowApp ? (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex min-h-4 min-w-0 items-center gap-1">
|
||||
<span className="truncate system-sm-semibold text-text-secondary">
|
||||
{markedName || versionInfo?.version}
|
||||
</span>
|
||||
<span aria-hidden className="system-xs-regular text-text-tertiary">
|
||||
·
|
||||
</span>
|
||||
{markedName ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded text-text-tertiary outline-hidden hover:text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
aria-label={t(($) => $['versionHistory.editVersionInfo'], { ns: 'workflow' })}
|
||||
disabled={!versionInfo || !onEditVersion}
|
||||
onClick={onEditVersion}
|
||||
>
|
||||
<span aria-hidden className="i-ri-edit-line size-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-1 rounded text-text-accent outline-hidden hover:text-text-accent-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-wait"
|
||||
disabled={!versionInfo || !onEditVersion}
|
||||
onClick={onEditVersion}
|
||||
>
|
||||
<span aria-hidden className="i-ri-edit-line size-3.5 shrink-0" />
|
||||
<span className="truncate system-xs-medium">
|
||||
{t(($) => $['versionHistory.nameIt'], { ns: 'workflow' })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{markedComment && (
|
||||
<>
|
||||
<p className="line-clamp-3 system-xs-regular wrap-break-word text-text-tertiary">
|
||||
{markedComment}
|
||||
</p>
|
||||
<span aria-hidden className="my-1 h-px w-4 bg-divider-regular" />
|
||||
</>
|
||||
)}
|
||||
{publishedTimestamp && (
|
||||
<p className="system-xs-regular text-text-tertiary">
|
||||
{publisherName
|
||||
? t(($) => $['common.publishedBy'], {
|
||||
ns: 'workflow',
|
||||
time: formatTimeFromNow(publishedTimestamp),
|
||||
author: publisherName,
|
||||
})
|
||||
: `${t(($) => $['common.publishedAt'], { ns: 'workflow' })} ${formatTimeFromNow(publishedTimestamp)}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isChatApp && (
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<p className="truncate system-sm-semibold text-text-secondary">
|
||||
{t(($) => $['common.latestPublished'], { ns: 'workflow' })}
|
||||
</p>
|
||||
{publishedTimestamp && (
|
||||
<p className="truncate system-xs-regular text-text-tertiary">
|
||||
{publisherName
|
||||
? t(($) => $['common.publishedBy'], {
|
||||
ns: 'workflow',
|
||||
time: formatTimeFromNow(publishedTimestamp),
|
||||
author: publisherName,
|
||||
})
|
||||
: `${t(($) => $['common.publishedAt'], { ns: 'workflow' })} ${formatTimeFromNow(publishedTimestamp)}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isChatApp && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="h-6 shrink-0 gap-1"
|
||||
onClick={handleRestore}
|
||||
disabled={!resolvedHasUnpublishedChanges}
|
||||
>
|
||||
<span aria-hidden className="i-ri-reset-left-line size-3.5" />
|
||||
{t(($) => $['common.restore'], { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
{debugWithMultipleModel ? (
|
||||
<PublishWithMultipleModel
|
||||
disabled={publishDisabled}
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onSelect={(item) => handlePublish(item)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
onClick={handleRestore}
|
||||
disabled={published}
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => handlePublish()}
|
||||
disabled={publishButtonDisabled}
|
||||
>
|
||||
{t(($) => $['common.restore'], { ns: 'workflow' })}
|
||||
{publishButtonDisabled ? (
|
||||
publishButtonLabel
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{publishButtonLabel}</span>
|
||||
<KbdGroup aria-hidden>
|
||||
{APP_PUBLISH_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key} color="white">
|
||||
{formatForDisplay(key)}
|
||||
</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center system-sm-medium text-text-secondary">
|
||||
{t(($) => $['common.autoSaved'], { ns: 'workflow' })} ·
|
||||
{Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
|
||||
</div>
|
||||
)}
|
||||
{debugWithMultipleModel ? (
|
||||
<PublishWithMultipleModel
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onSelect={(item) => handlePublish(item)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-3 w-full"
|
||||
onClick={() => handlePublish()}
|
||||
disabled={publishDisabled || published}
|
||||
>
|
||||
{published ? (
|
||||
t(($) => $['common.published'], { ns: 'workflow' })
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
<span>{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}</span>
|
||||
<KbdGroup>
|
||||
{APP_PUBLISH_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key} color="white">
|
||||
{formatForDisplay(key)}
|
||||
</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
{startNodeLimitExceeded && (
|
||||
<div className="mt-3 flex flex-col items-stretch">
|
||||
<p
|
||||
className="text-sm/5 font-semibold text-transparent"
|
||||
style={upgradeHighlightStyle}
|
||||
>
|
||||
<span className="block">
|
||||
{t(($) => $['publishLimit.startNodeTitlePrefix'], { ns: 'workflow' })}
|
||||
</span>
|
||||
<span className="block">
|
||||
{t(($) => $['publishLimit.startNodeTitleSuffix'], { ns: 'workflow' })}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs/4 text-text-secondary">
|
||||
{t(($) => $['publishLimit.startNodeDesc'], { ns: 'workflow' })}
|
||||
</p>
|
||||
<UpgradeBtn isShort className="mt-2.25 mb-3 h-8 w-23.25 self-start" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
{startNodeLimitExceeded && (
|
||||
<div className="mt-3 flex flex-col items-stretch">
|
||||
<p className="text-sm/5 font-semibold text-transparent" style={upgradeHighlightStyle}>
|
||||
<span className="block">
|
||||
{t(($) => $['publishLimit.startNodeTitlePrefix'], { ns: 'workflow' })}
|
||||
</span>
|
||||
<span className="block">
|
||||
{t(($) => $['publishLimit.startNodeTitleSuffix'], { ns: 'workflow' })}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs/4 text-text-secondary">
|
||||
{t(($) => $['publishLimit.startNodeDesc'], { ns: 'workflow' })}
|
||||
</p>
|
||||
<UpgradeBtn isShort className="mt-[9px] mb-[12px] h-[32px] w-[93px] self-start" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 py-0.5 pr-0.5 pl-1">
|
||||
<PublisherTimelineMarker position="bottom" />
|
||||
<p className="min-w-0 flex-1 truncate system-xs-regular text-text-tertiary">
|
||||
{resolvedHasUnpublishedChanges ? (
|
||||
<>
|
||||
{t(($) => $['common.unpublishedChanges'], { ns: 'workflow' })}
|
||||
{isWorkflowApp && Boolean(draftUpdatedAt) && (
|
||||
<>
|
||||
{' · '}
|
||||
{t(($) => $['common.savedAt'], {
|
||||
ns: 'workflow',
|
||||
time: formatTimeFromNow(draftUpdatedAt!),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
t(($) => $['common.noChanges'], { ns: 'workflow' })
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -219,8 +348,9 @@ export const PublisherAccessSection = ({
|
||||
{t(($) => $['publishApp.title'], { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pr-2 pl-2.5 hover:bg-primary-50 hover:text-text-accent"
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center gap-x-0.5 rounded-lg border-0 bg-components-input-bg-normal py-1 pr-2 pl-2.5 text-left outline-hidden hover:bg-primary-50 hover:text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
|
||||
@@ -234,7 +364,7 @@ export const PublisherAccessSection = ({
|
||||
<div className="flex size-4 shrink-0 items-center justify-center">
|
||||
<span className="i-ri-arrow-right-s-line size-4 text-text-quaternary" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{!isAppAccessSet && (
|
||||
<p className="mt-1 system-xs-regular text-text-warning">
|
||||
{t(($) => $['publishApp.notSetDesc'], { ns: 'app' })}
|
||||
@@ -246,143 +376,114 @@ export const PublisherAccessSection = ({
|
||||
)
|
||||
}
|
||||
|
||||
const ActionTooltip = ({
|
||||
disabled,
|
||||
tooltip,
|
||||
children,
|
||||
}: {
|
||||
disabled: boolean
|
||||
tooltip?: ReactNode
|
||||
children: ReactNode
|
||||
}) => {
|
||||
if (!disabled || !tooltip) return <>{children}</>
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className="flex">{children}</div>} />
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export const PublisherActionsSection = ({
|
||||
appDetail,
|
||||
appURL,
|
||||
disabledFunctionButton,
|
||||
disabledFunctionTooltip,
|
||||
handleEmbed,
|
||||
handleOpenInExplore,
|
||||
handleOpenRunConfig,
|
||||
hasHumanInputNode = false,
|
||||
hasTriggerNode = false,
|
||||
missingStartNode = false,
|
||||
marketplaceActionDisabled = false,
|
||||
publishedAt,
|
||||
showBatchRunConfig = false,
|
||||
publishingToMarketplace = false,
|
||||
showDeployAction = false,
|
||||
showMarketplaceAction = false,
|
||||
showRunConfig = false,
|
||||
toolPublished,
|
||||
toolPublished = false,
|
||||
workflowToolAvailable = true,
|
||||
workflowToolIsLoading,
|
||||
workflowToolOutdated,
|
||||
workflowToolMessage,
|
||||
workflowToolOutdated = false,
|
||||
onConfigureWorkflowTool,
|
||||
onPublishToMarketplace,
|
||||
}: ActionsSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (hasTriggerNode) return null
|
||||
|
||||
const workflowToolDisabled = !publishedAt || !workflowToolAvailable
|
||||
const appId = appDetail?.id
|
||||
const hasPublishedVersion = Boolean(publishedAt)
|
||||
const showOpenWebApp = !hasTriggerNode
|
||||
const showDeploy = Boolean(showDeployAction && appId)
|
||||
const showWorkflowTool =
|
||||
appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode
|
||||
const navigationDisabled = !hasPublishedVersion || !appId
|
||||
const workflowToolDisabled =
|
||||
!hasPublishedVersion || !workflowToolAvailable || (toolPublished && workflowToolIsLoading)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3">
|
||||
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={disabledFunctionButton}
|
||||
link={appURL}
|
||||
icon={<span className="i-ri-play-circle-line size-4" />}
|
||||
actionButton={
|
||||
showRunConfig
|
||||
? {
|
||||
ariaLabel: t(($) => $['operation.config'], { ns: 'common' }),
|
||||
icon: <span className="i-ri-settings-2-line size-4" />,
|
||||
onClick: () => handleOpenRunConfig?.(appURL),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t(($) => $['common.runApp'], { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION ? (
|
||||
<div className="flex flex-col border-t-[0.5px] border-t-divider-regular p-3">
|
||||
{showOpenWebApp && (
|
||||
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={disabledFunctionButton}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<span className="i-ri-play-list-2-line size-4" />}
|
||||
description={
|
||||
disabledFunctionButton && disabledFunctionTooltip
|
||||
? disabledFunctionTooltip
|
||||
: t(($) => $['common.openWebAppDescription'], { ns: 'workflow' })
|
||||
}
|
||||
external
|
||||
focusableWhenDisabled={Boolean(disabledFunctionTooltip)}
|
||||
link={appURL}
|
||||
icon={<span className="i-ri-planet-line size-4" />}
|
||||
actionButton={
|
||||
showBatchRunConfig
|
||||
showRunConfig && handleOpenRunConfig
|
||||
? {
|
||||
ariaLabel: t(($) => $['operation.config'], { ns: 'common' }),
|
||||
icon: <span className="i-ri-settings-2-line size-4" />,
|
||||
onClick: () =>
|
||||
handleOpenRunConfig?.(
|
||||
`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`,
|
||||
),
|
||||
onClick: () => handleOpenRunConfig(appURL),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t(($) => $['common.batchRunApp'], { ns: 'workflow' })}
|
||||
{t(($) => $['common.openWebApp'], { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
) : (
|
||||
)}
|
||||
<SuggestedAction
|
||||
disabled={navigationDisabled}
|
||||
description={t(($) => $['common.accessPointDescription'], { ns: 'workflow' })}
|
||||
link={appId ? `/app/${appId}/access-point` : undefined}
|
||||
icon={<span className="i-custom-vender-agent-v2-access-point size-4" />}
|
||||
>
|
||||
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
|
||||
</SuggestedAction>
|
||||
{showDeploy && (
|
||||
<SuggestedAction
|
||||
onClick={handleEmbed}
|
||||
disabled={!publishedAt}
|
||||
icon={<span className="i-custom-vender-line-development-code-browser size-4" />}
|
||||
disabled={navigationDisabled}
|
||||
description={t(($) => $['common.deployDescription'], { ns: 'workflow' })}
|
||||
link={`/app/${appId}/deploy`}
|
||||
icon={<span className="i-ri-instance-line size-4" />}
|
||||
>
|
||||
{t(($) => $['common.embedIntoSite'], { ns: 'workflow' })}
|
||||
{t(($) => $['appMenus.deploy'], { ns: 'common' })}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
|
||||
{showMarketplaceAction && (
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
if (publishedAt) handleOpenInExplore()
|
||||
}}
|
||||
disabled={disabledFunctionButton}
|
||||
icon={<span className="i-ri-planet-line size-4" />}
|
||||
disabled={marketplaceActionDisabled || publishingToMarketplace || !onPublishToMarketplace}
|
||||
description={t(($) => $['common.publishToMarketplaceDescription'], {
|
||||
ns: 'workflow',
|
||||
})}
|
||||
icon={<span className="i-ri-store-2-line size-4" />}
|
||||
onClick={onPublishToMarketplace}
|
||||
>
|
||||
{t(($) => $['common.openInExplore'], { ns: 'workflow' })}
|
||||
{publishingToMarketplace
|
||||
? t(($) => $['common.publishingToMarketplace'], { ns: 'workflow' })
|
||||
: t(($) => $['common.publishToMarketplace'], { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
<ActionTooltip
|
||||
disabled={!publishedAt || missingStartNode}
|
||||
tooltip={
|
||||
!publishedAt
|
||||
? t(($) => $.notPublishedYet, { ns: 'app' })
|
||||
: t(($) => $.noUserInputNode, { ns: 'app' })
|
||||
}
|
||||
>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={!publishedAt || missingStartNode}
|
||||
link="./develop"
|
||||
icon={<span className="i-ri-terminal-box-line size-4" />}
|
||||
>
|
||||
{t(($) => $['common.accessAPIReference'], { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={workflowToolDisabled}
|
||||
published={!!toolPublished}
|
||||
isLoading={workflowToolIsLoading}
|
||||
outdated={workflowToolOutdated}
|
||||
onConfigure={onConfigureWorkflowTool}
|
||||
disabledReason={workflowToolMessage}
|
||||
/>
|
||||
)}
|
||||
{showWorkflowTool && (
|
||||
<>
|
||||
<div aria-hidden className="m-1 h-px bg-divider-subtle" />
|
||||
<WorkflowToolAction
|
||||
disabled={workflowToolDisabled}
|
||||
isLoading={workflowToolIsLoading}
|
||||
message={workflowToolMessage}
|
||||
outdated={workflowToolOutdated}
|
||||
published={toolPublished}
|
||||
onConfigure={onConfigureWorkflowTool}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,80 +1,166 @@
|
||||
import type { HTMLProps, PropsWithChildren, MouseEvent as ReactMouseEvent } from 'react'
|
||||
import type { PropsWithChildren, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiArrowRightUpLine } from '@remixicon/react'
|
||||
import { useId } from 'react'
|
||||
import Link from '@/next/link'
|
||||
|
||||
type SuggestedActionButton = {
|
||||
ariaLabel: string
|
||||
icon: React.ReactNode
|
||||
icon: ReactNode
|
||||
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
type SuggestedActionProps = PropsWithChildren<
|
||||
HTMLProps<HTMLAnchorElement> & {
|
||||
icon?: React.ReactNode
|
||||
link?: string
|
||||
disabled?: boolean
|
||||
actionButton?: SuggestedActionButton
|
||||
}
|
||||
>
|
||||
type SuggestedActionProps = PropsWithChildren<{
|
||||
icon?: ReactNode
|
||||
link?: string
|
||||
external?: boolean
|
||||
disabled?: boolean
|
||||
focusableWhenDisabled?: boolean
|
||||
description?: ReactNode
|
||||
endIcon?: ReactNode
|
||||
actionButton?: SuggestedActionButton
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}>
|
||||
|
||||
const SuggestedAction = ({
|
||||
icon,
|
||||
link,
|
||||
disabled,
|
||||
external = false,
|
||||
disabled = false,
|
||||
focusableWhenDisabled = false,
|
||||
description,
|
||||
endIcon,
|
||||
actionButton,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
actionButton,
|
||||
...props
|
||||
}: SuggestedActionProps) => {
|
||||
const handleClick = (event: ReactMouseEvent<HTMLAnchorElement>) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
onClick?.(event)
|
||||
}
|
||||
|
||||
const handleActionClick = (event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
actionButton?.onClick(event)
|
||||
}
|
||||
|
||||
const mainAction = (
|
||||
<a
|
||||
href={disabled ? undefined : link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
'flex min-w-0 items-center justify-start gap-2 px-2.5 py-2 text-text-secondary transition-colors',
|
||||
actionButton
|
||||
? 'flex-1 rounded-l-lg'
|
||||
: 'rounded-lg bg-background-section-burn not-first:mt-1',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-30 shadow-xs'
|
||||
: 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative size-4 shrink-0">{icon}</div>
|
||||
<div className="shrink grow basis-0 system-sm-medium">{children}</div>
|
||||
<RiArrowRightUpLine className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
const id = useId()
|
||||
const labelId = `${id}-label`
|
||||
const descriptionId = `${id}-description`
|
||||
const interactiveClassName = cn(
|
||||
'group flex h-10 min-w-0 items-center gap-2 border-0 bg-transparent p-1 text-left outline-hidden transition-colors',
|
||||
actionButton ? 'flex-1 rounded-l-lg' : 'w-full rounded-lg',
|
||||
disabled
|
||||
? cn(
|
||||
'cursor-not-allowed',
|
||||
!actionButton && 'opacity-30',
|
||||
focusableWhenDisabled && 'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
)
|
||||
: 'cursor-pointer hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
!actionButton && className,
|
||||
)
|
||||
const content = (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-regular bg-components-panel-on-panel-item-bg text-text-secondary shadow-xs transition-colors',
|
||||
!disabled && 'group-hover:text-text-accent group-focus-visible:text-text-accent',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
id={labelId}
|
||||
className={cn(
|
||||
'block truncate system-sm-medium text-text-secondary transition-colors',
|
||||
!disabled && 'group-hover:text-text-primary group-focus-visible:text-text-primary',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{description && (
|
||||
<span
|
||||
id={descriptionId}
|
||||
className={cn(
|
||||
'hidden truncate system-xs-regular text-text-tertiary',
|
||||
!disabled && 'group-hover:block group-focus-visible:block',
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex min-h-4 min-w-4 shrink-0 items-center justify-center text-text-quaternary">
|
||||
{endIcon ?? <span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
const accessibilityProps = {
|
||||
'aria-labelledby': labelId,
|
||||
'aria-describedby': description ? descriptionId : undefined,
|
||||
}
|
||||
|
||||
let mainAction: ReactNode
|
||||
|
||||
if (disabled) {
|
||||
mainAction = (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!focusableWhenDisabled}
|
||||
aria-disabled={focusableWhenDisabled || undefined}
|
||||
className={interactiveClassName}
|
||||
onClick={
|
||||
focusableWhenDisabled
|
||||
? (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={
|
||||
focusableWhenDisabled
|
||||
? (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') event.preventDefault()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
{...accessibilityProps}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
} else if (!link) {
|
||||
mainAction = (
|
||||
<button
|
||||
type="button"
|
||||
className={interactiveClassName}
|
||||
onClick={onClick}
|
||||
{...accessibilityProps}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
} else if (external) {
|
||||
mainAction = (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={interactiveClassName}
|
||||
onClick={onClick}
|
||||
{...accessibilityProps}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
} else {
|
||||
mainAction = (
|
||||
<Link href={link} className={interactiveClassName} onClick={onClick} {...accessibilityProps}>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
if (!actionButton) return mainAction
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-stretch rounded-lg bg-background-section-burn not-first:mt-1',
|
||||
disabled ? 'opacity-30 shadow-xs' : '',
|
||||
'flex w-full min-w-0 items-stretch rounded-lg',
|
||||
disabled && 'opacity-30',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -84,14 +170,16 @@ const SuggestedAction = ({
|
||||
aria-label={actionButton.ariaLabel}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex w-9 shrink-0 items-center justify-center rounded-r-lg border-l-[0.5px] border-divider-subtle text-text-tertiary transition-colors',
|
||||
'flex w-9 shrink-0 items-center justify-center rounded-r-lg border-l-[0.5px] border-divider-subtle text-text-tertiary outline-hidden transition-colors',
|
||||
disabled
|
||||
? 'cursor-not-allowed'
|
||||
: 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent',
|
||||
: 'cursor-pointer hover:bg-state-base-hover hover:text-text-accent focus-visible:bg-state-base-hover focus-visible:text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
)}
|
||||
onClick={handleActionClick}
|
||||
onClick={actionButton.onClick}
|
||||
>
|
||||
{actionButton.icon}
|
||||
<span aria-hidden className="flex size-4 items-center justify-center">
|
||||
{actionButton.icon}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
type WorkflowToolDisabledReasonProps = {
|
||||
message: string
|
||||
}
|
||||
|
||||
const WorkflowToolDisabledReason = ({ message }: WorkflowToolDisabledReasonProps) => {
|
||||
return <p className="mt-1 px-2.5 pb-2 system-xs-regular text-text-tertiary opacity-30">{message}</p>
|
||||
}
|
||||
|
||||
export default WorkflowToolDisabledReason
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { buildIntegrationPath } from '@/app/components/integrations/routes'
|
||||
import Link from '@/next/link'
|
||||
import SuggestedAction from '../suggested-action'
|
||||
import WorkflowToolDisabledReason from './disabled-reason'
|
||||
import WorkflowToolLoadingStatus from './loading-status'
|
||||
import WorkflowToolSetupStatus from './setup-status'
|
||||
import WorkflowToolStateLabel from './state-label'
|
||||
|
||||
type WorkflowToolActionProps = {
|
||||
disabled: boolean
|
||||
isLoading: boolean
|
||||
message?: string
|
||||
outdated: boolean
|
||||
published: boolean
|
||||
onConfigure: () => void
|
||||
}
|
||||
|
||||
const WorkflowToolAction = ({
|
||||
disabled,
|
||||
isLoading,
|
||||
message,
|
||||
outdated,
|
||||
published,
|
||||
onConfigure,
|
||||
}: WorkflowToolActionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const disabledReason = disabled ? message : undefined
|
||||
const workflowToolLabel = t(($) => $['common.workflowAsTool'], { ns: 'workflow' })
|
||||
|
||||
if (!published || isLoading)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full rounded-lg'
|
||||
)}
|
||||
>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
description={t(($) => $['common.workflowAsToolDescription'], { ns: 'workflow' })}
|
||||
endIcon={
|
||||
isLoading ? (
|
||||
<WorkflowToolLoadingStatus label={t(($) => $.loading, { ns: 'appApi' })} />
|
||||
) : (
|
||||
<WorkflowToolSetupStatus
|
||||
label={t(($) => $['common.configureRequired'], { ns: 'workflow' })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
icon={<span className="i-ri-hammer-line size-4" />}
|
||||
onClick={onConfigure}
|
||||
>
|
||||
{workflowToolLabel}
|
||||
</SuggestedAction>
|
||||
{disabledReason && <WorkflowToolDisabledReason message={disabledReason} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
const configureLabel = outdated
|
||||
? t(($) => $['common.workflowAsToolReconfigure'], { ns: 'workflow' })
|
||||
: t(($) => $['common.configure'], { ns: 'workflow' })
|
||||
const manageInToolsLabel = t(($) => $['common.manageInTools'], { ns: 'workflow' })
|
||||
const stateLabel = outdated
|
||||
? t(($) => $['common.workflowAsToolUpdateNeeded'], { ns: 'workflow' })
|
||||
: t(($) => $['common.workflowAsToolReady'], { ns: 'workflow' })
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full flex-col rounded-lg',
|
||||
disabled && 'opacity-30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2 p-1">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-regular bg-components-panel-on-panel-item-bg text-text-secondary shadow-xs"
|
||||
>
|
||||
<span className="i-ri-hammer-line size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-h-7 items-center gap-2 pt-1 pr-1 pb-1">
|
||||
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary">
|
||||
{workflowToolLabel}
|
||||
</span>
|
||||
<WorkflowToolStateLabel label={stateLabel} outdated={outdated} />
|
||||
</div>
|
||||
{outdated && (
|
||||
<p className="pr-4 pb-1 system-xs-regular text-text-warning">
|
||||
{t(($) => $['common.workflowAsToolTip'], { ns: 'workflow' })}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1 py-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
className="gap-1 px-1.5"
|
||||
onClick={onConfigure}
|
||||
>
|
||||
<span aria-hidden className="i-ri-equalizer-2-line size-3.5" />
|
||||
{configureLabel}
|
||||
</Button>
|
||||
{disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="flex h-6 cursor-not-allowed items-center gap-1 rounded-md px-2 system-xs-medium text-components-button-tertiary-text-disabled"
|
||||
>
|
||||
{manageInToolsLabel}
|
||||
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={buildIntegrationPath('workflow-tool')}
|
||||
className="flex h-6 items-center gap-1 rounded-md px-2 system-xs-medium text-text-tertiary outline-hidden hover:bg-components-button-tertiary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
{manageInToolsLabel}
|
||||
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{disabledReason && <WorkflowToolDisabledReason message={disabledReason} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowToolAction
|
||||
@@ -0,0 +1,15 @@
|
||||
type WorkflowToolLoadingStatusProps = {
|
||||
label: string
|
||||
}
|
||||
|
||||
const WorkflowToolLoadingStatus = ({ label }: WorkflowToolLoadingStatusProps) => {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className="i-ri-loader-2-line size-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowToolLoadingStatus
|
||||
@@ -0,0 +1,17 @@
|
||||
type WorkflowToolSetupStatusProps = {
|
||||
label: string
|
||||
}
|
||||
|
||||
const WorkflowToolSetupStatus = ({ label }: WorkflowToolSetupStatusProps) => {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className="rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowToolSetupStatus
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { StatusDot } from '@langgenius/dify-ui/status-dot'
|
||||
|
||||
type WorkflowToolStateLabelProps = {
|
||||
label: string
|
||||
outdated: boolean
|
||||
}
|
||||
|
||||
const WorkflowToolStateLabel = ({ label, outdated }: WorkflowToolStateLabelProps) => {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-1 system-xs-semibold-uppercase',
|
||||
outdated ? 'text-util-colors-warning-warning-600' : 'text-util-colors-green-green-600',
|
||||
)}
|
||||
>
|
||||
<StatusDot size="small" status={outdated ? 'warning' : 'success'} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowToolStateLabel
|
||||
@@ -214,6 +214,11 @@ const createViewModel = (
|
||||
publishedConfig: {
|
||||
modelConfig: createContextValue().modelConfig,
|
||||
completionParams: {},
|
||||
promptMode: createContextValue().promptMode,
|
||||
chatPromptConfig: createContextValue().chatPromptConfig,
|
||||
completionPromptConfig: createContextValue().completionPromptConfig,
|
||||
datasetConfigs: createContextValue().datasetConfigs,
|
||||
externalDataToolsConfig: createContextValue().externalDataToolsConfig,
|
||||
},
|
||||
resetAppConfig: vi.fn(),
|
||||
} as ComponentProps<typeof AppPublisher>,
|
||||
@@ -248,6 +253,7 @@ const createViewModel = (
|
||||
onCompletionParamsChange: vi.fn(),
|
||||
onConfirmUseGPT4: vi.fn(),
|
||||
onEnableMultipleModelDebug: vi.fn(),
|
||||
onFeatureStoreChange: vi.fn(),
|
||||
onFeaturesChange: vi.fn(),
|
||||
onHideDebugPanel: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
|
||||
@@ -99,6 +99,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
|
||||
onCompletionParamsChange,
|
||||
onConfirmUseGPT4,
|
||||
onEnableMultipleModelDebug,
|
||||
onFeatureStoreChange,
|
||||
onFeaturesChange,
|
||||
onHideDebugPanel,
|
||||
onModelChange,
|
||||
@@ -128,7 +129,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
|
||||
|
||||
return (
|
||||
<ConfigContext.Provider value={contextValue}>
|
||||
<FeaturesProvider features={featuresData}>
|
||||
<FeaturesProvider features={featuresData} onFeaturesChange={onFeatureStoreChange}>
|
||||
<MittProvider>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="relative flex h-[200px] grow pt-14">
|
||||
|
||||
@@ -1143,6 +1143,7 @@ describe('Debug', () => {
|
||||
enabled: true,
|
||||
}),
|
||||
}),
|
||||
{ silent: true },
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -369,7 +369,7 @@ const Debug: FC<IDebug> = ({
|
||||
enabled: supportedVision,
|
||||
}
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
setFeatures(newFeatures, { silent: true })
|
||||
}
|
||||
}, [debugWithMultipleModel, featuresStore, mode, multipleModelConfigs, textGenerationModelList])
|
||||
|
||||
|
||||
+3
@@ -24,6 +24,7 @@ describe('useAdvancedPromptConfig', () => {
|
||||
|
||||
it('should update the advanced chat prompt and mark user changes', () => {
|
||||
const handleUserChangedPrompt = vi.fn()
|
||||
const handlePublishConfigChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useAdvancedPromptConfig({
|
||||
appMode: AppModeEnum.CHAT,
|
||||
@@ -36,6 +37,7 @@ describe('useAdvancedPromptConfig', () => {
|
||||
completionParams: {},
|
||||
setCompletionParams: vi.fn(),
|
||||
setStop: vi.fn(),
|
||||
onPublishConfigChange: handlePublishConfigChange,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -51,6 +53,7 @@ describe('useAdvancedPromptConfig', () => {
|
||||
])
|
||||
expect(result.current.hasSetBlockStatus.query).toBe(true)
|
||||
expect(handleUserChangedPrompt).toHaveBeenCalledTimes(1)
|
||||
expect(handlePublishConfigChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should derive simple prompt block status from the pre-prompt', () => {
|
||||
|
||||
+123
-30
@@ -1,5 +1,6 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import type { VisionSettings } from '@/types/app'
|
||||
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||
import { withSelectorKey } from '@/test/i18n-mock'
|
||||
import {
|
||||
AgentStrategy,
|
||||
@@ -153,12 +154,20 @@ describe('useConfiguration utils', () => {
|
||||
is_team_authorization: false,
|
||||
},
|
||||
] as any,
|
||||
datasetConfigs: {
|
||||
datasets: { datasets: [] },
|
||||
retrieval_model: RETRIEVE_TYPE.multiWay,
|
||||
top_k: 4,
|
||||
} as any,
|
||||
deletedTools: [{ provider_id: 'tool-1', tool_name: 'search' }],
|
||||
mode: AppModeEnum.AGENT_CHAT,
|
||||
nextDataSets: [{ id: 'dataset-1' }] as any,
|
||||
})
|
||||
|
||||
expect(publishedConfig.completionParams).toEqual({ temperature: 0.7 })
|
||||
expect(publishedConfig.datasetConfigs.top_k).toBe(4)
|
||||
expect(publishedConfig.promptMode).toBe('simple')
|
||||
expect(publishedConfig.externalDataToolsConfig).toHaveLength(1)
|
||||
expect(publishedConfig.modelConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
dataSets: [{ id: 'dataset-1' }],
|
||||
@@ -180,6 +189,76 @@ describe('useConfiguration utils', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should normalize an empty chat prompt config for completion apps', () => {
|
||||
const publishedConfig = buildPublishedConfig({
|
||||
backendModelConfig: {
|
||||
chat_prompt_config: {},
|
||||
completion_prompt_config: {
|
||||
prompt: { text: 'completion' },
|
||||
conversation_histories_role: {
|
||||
assistant_prefix: '',
|
||||
user_prefix: '',
|
||||
},
|
||||
},
|
||||
dataset_configs: {
|
||||
datasets: { datasets: [] },
|
||||
},
|
||||
external_data_tools: [],
|
||||
model: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
name: 'gpt-4o',
|
||||
mode: ModelModeType.completion,
|
||||
completion_params: {},
|
||||
},
|
||||
user_input_form: [],
|
||||
} as any,
|
||||
collectionList: [],
|
||||
datasetConfigs: {
|
||||
datasets: { datasets: [] },
|
||||
retrieval_model: RETRIEVE_TYPE.multiWay,
|
||||
} as any,
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
nextDataSets: [],
|
||||
})
|
||||
|
||||
expect(publishedConfig.chatPromptConfig).toEqual(DEFAULT_CHAT_PROMPT_CONFIG)
|
||||
expect(publishedConfig.modelConfig.chat_prompt_config).toEqual(DEFAULT_CHAT_PROMPT_CONFIG)
|
||||
})
|
||||
|
||||
it('should normalize an empty completion prompt config for chat apps', () => {
|
||||
const publishedConfig = buildPublishedConfig({
|
||||
backendModelConfig: {
|
||||
chat_prompt_config: {
|
||||
prompt: [{ role: 'system', text: 'chat' }],
|
||||
},
|
||||
completion_prompt_config: {},
|
||||
dataset_configs: {
|
||||
datasets: { datasets: [] },
|
||||
},
|
||||
external_data_tools: [],
|
||||
model: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
name: 'gpt-4o',
|
||||
mode: ModelModeType.chat,
|
||||
completion_params: {},
|
||||
},
|
||||
user_input_form: [],
|
||||
} as any,
|
||||
collectionList: [],
|
||||
datasetConfigs: {
|
||||
datasets: { datasets: [] },
|
||||
retrieval_model: RETRIEVE_TYPE.multiWay,
|
||||
} as any,
|
||||
mode: AppModeEnum.CHAT,
|
||||
nextDataSets: [],
|
||||
})
|
||||
|
||||
expect(publishedConfig.completionPromptConfig).toEqual(DEFAULT_COMPLETION_PROMPT_CONFIG)
|
||||
expect(publishedConfig.modelConfig.completion_prompt_config).toEqual(
|
||||
DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||
)
|
||||
})
|
||||
|
||||
it('should build dataset configs with reranking defaults', () => {
|
||||
const datasetConfigs = buildConfigurationDatasetConfigs({
|
||||
backendModelConfig: {
|
||||
@@ -665,7 +744,6 @@ describe('useConfiguration utils', () => {
|
||||
const onPublish = createPublishHandler({
|
||||
appId: 'app-1',
|
||||
chatPromptConfig: { prompt: [{ role: 'system', text: 'hi' }] } as any,
|
||||
citationConfig: { enabled: true } as any,
|
||||
completionParamsState: { temperature: 0.7 },
|
||||
completionPromptConfig: {
|
||||
prompt: { text: 'completion' },
|
||||
@@ -680,13 +758,13 @@ describe('useConfiguration utils', () => {
|
||||
datasetConfigs: {
|
||||
datasets: { datasets: [] },
|
||||
retrieval_model: RETRIEVE_TYPE.multiWay,
|
||||
top_k: 7,
|
||||
} as any,
|
||||
externalDataToolsConfig: [],
|
||||
externalDataToolsConfig: [{ enabled: true, variable: 'external' }] as any,
|
||||
hasSetBlockStatus: {
|
||||
history: true,
|
||||
query: true,
|
||||
},
|
||||
introduction: 'hello',
|
||||
isAdvancedMode: true,
|
||||
isFunctionCall: true,
|
||||
mode: AppModeEnum.CHAT,
|
||||
@@ -711,36 +789,40 @@ describe('useConfiguration utils', () => {
|
||||
workflow_file_upload_limit: 1,
|
||||
},
|
||||
} as any,
|
||||
moreLikeThisConfig: { enabled: true },
|
||||
promptEmpty: false,
|
||||
promptMode: 'advanced' as any,
|
||||
resolvedModelModeType: ModelModeType.chat,
|
||||
setCanReturnToSimpleMode,
|
||||
setPublishedConfig,
|
||||
speechToTextConfig: { enabled: false } as any,
|
||||
suggestedQuestionsAfterAnswerConfig: { enabled: false } as any,
|
||||
t,
|
||||
textToSpeechConfig: { enabled: false, voice: '', language: '' } as any,
|
||||
})
|
||||
|
||||
const result = await onPublish(mockUpdateAppModelConfig, undefined, {
|
||||
moreLikeThis: { enabled: true },
|
||||
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
|
||||
moderation: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
text2speech: { enabled: false, voice: '', language: '' },
|
||||
file: {
|
||||
enabled: false,
|
||||
image: {
|
||||
const result = await onPublish(
|
||||
mockUpdateAppModelConfig,
|
||||
{
|
||||
model: 'published-model',
|
||||
provider: 'published-provider',
|
||||
parameters: { temperature: 0.2 },
|
||||
},
|
||||
{
|
||||
moreLikeThis: { enabled: true },
|
||||
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
|
||||
moderation: { enabled: true },
|
||||
speech2text: { enabled: false },
|
||||
text2speech: { enabled: false, voice: '', language: '' },
|
||||
file: {
|
||||
enabled: false,
|
||||
detail: 'low',
|
||||
number_limits: 1,
|
||||
transfer_methods: ['local_file'],
|
||||
},
|
||||
image: {
|
||||
enabled: false,
|
||||
detail: 'low',
|
||||
number_limits: 1,
|
||||
transfer_methods: ['local_file'],
|
||||
},
|
||||
} as any,
|
||||
suggested: { enabled: false },
|
||||
citation: { enabled: true },
|
||||
} as any,
|
||||
suggested: { enabled: false },
|
||||
citation: { enabled: true },
|
||||
} as any)
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockUpdateAppModelConfig).toHaveBeenCalledWith(
|
||||
@@ -753,7 +835,24 @@ describe('useConfiguration utils', () => {
|
||||
url: '/apps/app-1/model-config',
|
||||
}),
|
||||
)
|
||||
expect(setPublishedConfig).toHaveBeenCalledTimes(1)
|
||||
expect(setPublishedConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatPromptConfig: { prompt: [{ role: 'system', text: 'hi' }] },
|
||||
completionParams: { temperature: 0.2 },
|
||||
datasetConfigs: expect.objectContaining({ top_k: 7 }),
|
||||
externalDataToolsConfig: [{ enabled: true, variable: 'external' }],
|
||||
modelConfig: expect.objectContaining({
|
||||
file_upload: expect.objectContaining({
|
||||
image: expect.objectContaining({ detail: 'low' }),
|
||||
}),
|
||||
model_id: 'published-model',
|
||||
opening_statement: '',
|
||||
provider: 'published-provider',
|
||||
sensitive_word_avoidance: { enabled: true },
|
||||
}),
|
||||
promptMode: 'advanced',
|
||||
}),
|
||||
)
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('api.success')
|
||||
expect(setCanReturnToSimpleMode).toHaveBeenCalledWith(false)
|
||||
})
|
||||
@@ -764,7 +863,6 @@ describe('useConfiguration utils', () => {
|
||||
createPublishHandler({
|
||||
appId: 'app-1',
|
||||
chatPromptConfig: { prompt: [{ role: 'system', text: 'hi' }] } as any,
|
||||
citationConfig: { enabled: false } as any,
|
||||
completionParamsState: { temperature: 0.7 },
|
||||
completionPromptConfig: {
|
||||
prompt: { text: 'completion' },
|
||||
@@ -782,7 +880,6 @@ describe('useConfiguration utils', () => {
|
||||
history: true,
|
||||
query: true,
|
||||
},
|
||||
introduction: 'hello',
|
||||
isAdvancedMode: true,
|
||||
isFunctionCall: false,
|
||||
mode: AppModeEnum.CHAT,
|
||||
@@ -801,16 +898,12 @@ describe('useConfiguration utils', () => {
|
||||
workflow_file_upload_limit: 1,
|
||||
},
|
||||
} as any,
|
||||
moreLikeThisConfig: { enabled: false },
|
||||
promptEmpty: false,
|
||||
promptMode: 'advanced' as any,
|
||||
resolvedModelModeType: ModelModeType.completion,
|
||||
setCanReturnToSimpleMode: vi.fn(),
|
||||
setPublishedConfig: vi.fn(),
|
||||
speechToTextConfig: { enabled: false } as any,
|
||||
suggestedQuestionsAfterAnswerConfig: { enabled: false } as any,
|
||||
t,
|
||||
textToSpeechConfig: { enabled: false, voice: '', language: '' } as any,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { APP_PUBLISH_DRAFT_CHANGED } from '@/app/components/app/app-publisher/events'
|
||||
import { updateAppModelConfig } from '@/service/apps'
|
||||
import { renderHook } from '@/test/console/render'
|
||||
import { AppModeEnum, ModelModeType } from '@/types/app'
|
||||
@@ -20,7 +21,14 @@ const mockSetConversationHistoriesRole = vi.fn()
|
||||
const mockSetChatPromptConfig = vi.fn()
|
||||
const mockSetCompletionPromptConfig = vi.fn()
|
||||
const mockSetCurrentAdvancedPrompt = vi.fn()
|
||||
type MockEvent = {
|
||||
type: string
|
||||
instanceId?: string
|
||||
}
|
||||
let mockEventSubscription: ((event: MockEvent) => void) | undefined
|
||||
const mockEventEmit = vi.fn((event: MockEvent) => mockEventSubscription?.(event))
|
||||
type AdvancedPromptConfigOptions = {
|
||||
onPublishConfigChange?: () => void
|
||||
onUserChangedPrompt: () => void
|
||||
setStop: (stop: string[]) => void
|
||||
}
|
||||
@@ -85,6 +93,17 @@ vi.mock('@/context/provider-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
emit: mockEventEmit,
|
||||
useSubscription: (callback: (event: MockEvent) => void) => {
|
||||
mockEventSubscription = callback
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
@@ -195,6 +214,7 @@ vi.mock('@/utils/completion-params', () => ({
|
||||
describe('useConfiguration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEventSubscription = undefined
|
||||
latestAdvancedPromptConfigOptions = undefined
|
||||
mockTempStopState = []
|
||||
mockCurrentModelFeatures = ['vision']
|
||||
@@ -304,6 +324,236 @@ describe('useConfiguration', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should track configuration events and clear them after publishing', async () => {
|
||||
const { result } = renderHook(() => useConfiguration())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.showLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.contextValue.setDatasetConfigs({
|
||||
...result.current.contextValue.datasetConfigs,
|
||||
top_k: 8,
|
||||
})
|
||||
})
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(true)
|
||||
expect(mockEventEmit).toHaveBeenCalledWith({
|
||||
type: APP_PUBLISH_DRAFT_CHANGED,
|
||||
instanceId: 'app-1',
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData)
|
||||
})
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
|
||||
expect(result.current.appPublisherProps.publishedConfig.datasetConfigs.top_k).toBe(8)
|
||||
|
||||
act(() => {
|
||||
result.current.contextValue.setDatasetConfigs({
|
||||
...result.current.contextValue.datasetConfigs,
|
||||
top_k: 10,
|
||||
})
|
||||
})
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(true)
|
||||
|
||||
mockEventEmit.mockClear()
|
||||
mockSetChatPromptConfig.mockClear()
|
||||
mockSetCompletionPromptConfig.mockClear()
|
||||
act(() => {
|
||||
result.current.appPublisherProps.resetAppConfig?.()
|
||||
})
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
|
||||
expect(result.current.contextValue.datasetConfigs.top_k).toBe(8)
|
||||
expect(mockSetChatPromptConfig).toHaveBeenCalledWith({
|
||||
prompt: [{ role: 'system', text: 'hi' }],
|
||||
})
|
||||
expect(mockSetCompletionPromptConfig).toHaveBeenCalledWith({
|
||||
prompt: { text: 'completion' },
|
||||
conversation_histories_role: {
|
||||
assistant_prefix: 'assistant',
|
||||
user_prefix: 'user',
|
||||
},
|
||||
})
|
||||
expect(mockEventEmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore debug formatting events and track feature-store changes', async () => {
|
||||
const { result } = renderHook(() => useConfiguration())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.showLoading).toBe(false)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
mockEventSubscription?.({ type: 'ORCHESTRATE_CHANGED' })
|
||||
})
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.onFeatureStoreChange()
|
||||
})
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.onFeatureStoreChange({ moreLikeThis: { enabled: true } })
|
||||
})
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('should enable multiple-model mode without marking publish config dirty', async () => {
|
||||
const { result } = renderHook(() => useConfiguration())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.showLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
|
||||
mockEventEmit.mockClear()
|
||||
|
||||
act(() => {
|
||||
result.current.onEnableMultipleModelDebug()
|
||||
})
|
||||
|
||||
expect(mockHandleMultipleModelConfigsChange).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
model: 'gpt-4o',
|
||||
provider: 'langgenius/openai/openai',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(mockEventEmit).not.toHaveBeenCalled()
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('should update multiple-model debug configs without marking publish config dirty', async () => {
|
||||
const { result } = renderHook(() => useConfiguration())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.showLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
|
||||
mockEventEmit.mockClear()
|
||||
|
||||
const modelConfigs = [
|
||||
{
|
||||
id: 'model-1',
|
||||
model: 'gpt-4o',
|
||||
provider: 'langgenius/openai/openai',
|
||||
parameters: { temperature: 0.7 },
|
||||
},
|
||||
{
|
||||
id: 'model-2',
|
||||
model: 'gpt-4.1',
|
||||
provider: 'langgenius/openai/openai',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'model-3',
|
||||
model: '',
|
||||
provider: '',
|
||||
parameters: {},
|
||||
},
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.onMultipleModelConfigsChange(true, modelConfigs)
|
||||
})
|
||||
|
||||
expect(mockHandleMultipleModelConfigsChange).toHaveBeenCalledWith(true, modelConfigs)
|
||||
expect(mockEventEmit).not.toHaveBeenCalled()
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep multiple-model debug state when restoring published config', async () => {
|
||||
const { result } = renderHook(() => useConfiguration())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.showLoading).toBe(false)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.onEnableMultipleModelDebug()
|
||||
})
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.contextValue.setDatasetConfigs({
|
||||
...result.current.contextValue.datasetConfigs,
|
||||
top_k: 8,
|
||||
})
|
||||
})
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(true)
|
||||
|
||||
mockEventEmit.mockClear()
|
||||
mockHandleMultipleModelConfigsChange.mockClear()
|
||||
act(() => {
|
||||
result.current.appPublisherProps.resetAppConfig?.()
|
||||
})
|
||||
|
||||
expect(mockHandleMultipleModelConfigsChange).not.toHaveBeenCalled()
|
||||
expect(mockEventEmit).not.toHaveBeenCalled()
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('should silently sync the selected multiple-model config after publishing', async () => {
|
||||
const { result } = renderHook(() => useConfiguration())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.showLoading).toBe(false)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.contextValue.setDatasetConfigs({
|
||||
...result.current.contextValue.datasetConfigs,
|
||||
top_k: 8,
|
||||
})
|
||||
})
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(true)
|
||||
|
||||
mockEventEmit.mockClear()
|
||||
await act(async () => {
|
||||
await result.current.appPublisherProps.onPublish!(
|
||||
{
|
||||
id: 'model-2',
|
||||
model: 'gpt-4.1',
|
||||
provider: 'langgenius/openai/openai',
|
||||
parameters: { temperature: 0.2 },
|
||||
},
|
||||
result.current.featuresData,
|
||||
)
|
||||
})
|
||||
|
||||
expect(result.current.contextValue.modelConfig.model_id).toBe('gpt-4.1')
|
||||
expect(result.current.contextValue.modelConfig.provider).toBe('langgenius/openai/openai')
|
||||
expect(result.current.contextValue.completionParams).toEqual({ temperature: 0.2 })
|
||||
expect(result.current.appPublisherProps.publishedConfig.modelConfig.model_id).toBe('gpt-4.1')
|
||||
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
|
||||
expect(mockEventEmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should refresh the latest published time after publishing a non-workflow app', async () => {
|
||||
const { result } = renderHook(() => useConfiguration())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.showLoading).toBe(false)
|
||||
})
|
||||
|
||||
const previousPublishedAt = result.current.appPublisherProps.publishedAt ?? 0
|
||||
expect(previousPublishedAt).toBe(1710000000000)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData)
|
||||
})
|
||||
|
||||
expect(result.current.appPublisherProps.publishedAt).toBeGreaterThan(previousPublishedAt)
|
||||
})
|
||||
|
||||
it('should block publishing when app release permission is missing', async () => {
|
||||
mockAppPermissionKeys = [AppACLPermission.ViewLayout]
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
} from '@/models/debug'
|
||||
import { clone } from 'es-toolkit/object'
|
||||
import { produce } from 'immer'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
checkHasContextBlock,
|
||||
checkHasHistoryBlock,
|
||||
@@ -30,6 +30,7 @@ type Param = {
|
||||
completionParams: FormValue
|
||||
setCompletionParams: (params: FormValue) => void
|
||||
setStop: (stop: string[]) => void
|
||||
onPublishConfigChange?: () => void
|
||||
}
|
||||
|
||||
const useAdvancedPromptConfig = ({
|
||||
@@ -43,13 +44,28 @@ const useAdvancedPromptConfig = ({
|
||||
completionParams,
|
||||
setCompletionParams,
|
||||
setStop,
|
||||
onPublishConfigChange,
|
||||
}: Param) => {
|
||||
const isAdvancedPrompt = promptMode === PromptMode.advanced
|
||||
const [chatPromptConfig, setChatPromptConfig] = useState<ChatPromptConfig>(() =>
|
||||
const [chatPromptConfig, doSetChatPromptConfig] = useState<ChatPromptConfig>(() =>
|
||||
clone(DEFAULT_CHAT_PROMPT_CONFIG),
|
||||
)
|
||||
const [completionPromptConfig, setCompletionPromptConfig] = useState<CompletionPromptConfig>(() =>
|
||||
clone(DEFAULT_COMPLETION_PROMPT_CONFIG),
|
||||
const [completionPromptConfig, doSetCompletionPromptConfig] = useState<CompletionPromptConfig>(
|
||||
() => clone(DEFAULT_COMPLETION_PROMPT_CONFIG),
|
||||
)
|
||||
const setChatPromptConfig = useCallback(
|
||||
(config: ChatPromptConfig) => {
|
||||
doSetChatPromptConfig(config)
|
||||
onPublishConfigChange?.()
|
||||
},
|
||||
[onPublishConfigChange],
|
||||
)
|
||||
const setCompletionPromptConfig = useCallback(
|
||||
(config: CompletionPromptConfig) => {
|
||||
doSetCompletionPromptConfig(config)
|
||||
onPublishConfigChange?.()
|
||||
},
|
||||
[onPublishConfigChange],
|
||||
)
|
||||
|
||||
const currentAdvancedPrompt = (() => {
|
||||
|
||||
@@ -56,6 +56,28 @@ type DeletedTool = {
|
||||
tool_name: string
|
||||
}
|
||||
|
||||
const normalizeChatPromptConfig = (
|
||||
chatPromptConfig: BackendModelConfig['chat_prompt_config'],
|
||||
): NonNullable<BackendModelConfig['chat_prompt_config']> =>
|
||||
chatPromptConfig?.prompt?.length ? chatPromptConfig : clone(DEFAULT_CHAT_PROMPT_CONFIG)
|
||||
|
||||
const normalizeCompletionPromptConfig = (
|
||||
completionPromptConfig: BackendModelConfig['completion_prompt_config'],
|
||||
): NonNullable<BackendModelConfig['completion_prompt_config']> =>
|
||||
completionPromptConfig?.prompt && completionPromptConfig.conversation_histories_role
|
||||
? completionPromptConfig
|
||||
: clone(DEFAULT_COMPLETION_PROMPT_CONFIG)
|
||||
|
||||
export type ConfigurationPublishConfig = {
|
||||
modelConfig: ModelConfig
|
||||
completionParams: FormValue
|
||||
promptMode: PromptMode
|
||||
chatPromptConfig: NonNullable<BackendModelConfig['chat_prompt_config']>
|
||||
completionPromptConfig: NonNullable<BackendModelConfig['completion_prompt_config']>
|
||||
datasetConfigs: DatasetConfigs
|
||||
externalDataToolsConfig: NonNullable<BackendModelConfig['external_data_tools']>
|
||||
}
|
||||
|
||||
const buildPublishedModelConfig = ({
|
||||
backendModelConfig,
|
||||
collectionList,
|
||||
@@ -99,6 +121,11 @@ const buildPublishedModelConfig = ({
|
||||
backendModelConfig.dataset_query_variable,
|
||||
),
|
||||
},
|
||||
prompt_type: backendModelConfig.prompt_type,
|
||||
chat_prompt_config: normalizeChatPromptConfig(backendModelConfig.chat_prompt_config),
|
||||
completion_prompt_config: normalizeCompletionPromptConfig(
|
||||
backendModelConfig.completion_prompt_config,
|
||||
),
|
||||
more_like_this: backendModelConfig.more_like_this ?? { enabled: false },
|
||||
opening_statement: backendModelConfig.opening_statement,
|
||||
suggested_questions: backendModelConfig.suggested_questions ?? [],
|
||||
@@ -158,16 +185,18 @@ const buildPublishedModelConfig = ({
|
||||
export const buildPublishedConfig = ({
|
||||
backendModelConfig,
|
||||
collectionList,
|
||||
datasetConfigs,
|
||||
deletedTools,
|
||||
mode,
|
||||
nextDataSets,
|
||||
}: {
|
||||
backendModelConfig: BackendModelConfig
|
||||
collectionList: Collection[]
|
||||
datasetConfigs: DatasetConfigs
|
||||
deletedTools?: DeletedTool[]
|
||||
mode: AppModeEnum
|
||||
nextDataSets: DataSet[]
|
||||
}) => ({
|
||||
}): ConfigurationPublishConfig => ({
|
||||
modelConfig: buildPublishedModelConfig({
|
||||
backendModelConfig,
|
||||
collectionList,
|
||||
@@ -176,6 +205,16 @@ export const buildPublishedConfig = ({
|
||||
nextDataSets,
|
||||
}),
|
||||
completionParams: backendModelConfig.model.completion_params,
|
||||
promptMode:
|
||||
backendModelConfig.prompt_type === PromptMode.advanced
|
||||
? PromptMode.advanced
|
||||
: PromptMode.simple,
|
||||
chatPromptConfig: normalizeChatPromptConfig(backendModelConfig.chat_prompt_config),
|
||||
completionPromptConfig: normalizeCompletionPromptConfig(
|
||||
backendModelConfig.completion_prompt_config,
|
||||
),
|
||||
datasetConfigs,
|
||||
externalDataToolsConfig: backendModelConfig.external_data_tools ?? [],
|
||||
})
|
||||
|
||||
export const buildConfigurationDatasetConfigs = ({
|
||||
@@ -369,19 +408,22 @@ export const loadConfigurationState = async ({
|
||||
nextDataSets = data
|
||||
}
|
||||
|
||||
const datasetConfigs = buildConfigurationDatasetConfigs({
|
||||
backendModelConfig,
|
||||
currentRerankModel,
|
||||
currentRerankProvider,
|
||||
nextDataSets,
|
||||
})
|
||||
|
||||
return {
|
||||
annotationConfig: normalizeAnnotationConfig(backendModelConfig.annotation_reply),
|
||||
backendModelConfig,
|
||||
canReturnToSimpleMode: nextPromptMode !== PromptMode.advanced,
|
||||
collectionList,
|
||||
completionPromptConfig:
|
||||
backendModelConfig.completion_prompt_config || clone(DEFAULT_COMPLETION_PROMPT_CONFIG),
|
||||
datasetConfigs: buildConfigurationDatasetConfigs({
|
||||
backendModelConfig,
|
||||
currentRerankModel,
|
||||
currentRerankProvider,
|
||||
nextDataSets,
|
||||
}),
|
||||
completionPromptConfig: normalizeCompletionPromptConfig(
|
||||
backendModelConfig.completion_prompt_config,
|
||||
),
|
||||
datasetConfigs,
|
||||
externalDataToolsConfig: backendModelConfig.external_data_tools ?? [],
|
||||
mode: response.mode as AppModeEnum,
|
||||
moreLikeThisConfig: backendModelConfig.more_like_this || { enabled: false },
|
||||
@@ -390,6 +432,7 @@ export const loadConfigurationState = async ({
|
||||
publishedConfig: buildPublishedConfig({
|
||||
backendModelConfig,
|
||||
collectionList,
|
||||
datasetConfigs,
|
||||
deletedTools: response.deleted_tools,
|
||||
mode: response.mode as AppModeEnum,
|
||||
nextDataSets,
|
||||
@@ -407,11 +450,7 @@ export const loadConfigurationState = async ({
|
||||
},
|
||||
visionConfig: backendModelConfig.file_upload?.image,
|
||||
citationConfig: backendModelConfig.retriever_resource || { enabled: false },
|
||||
chatPromptConfig:
|
||||
backendModelConfig.chat_prompt_config &&
|
||||
backendModelConfig.chat_prompt_config.prompt?.length > 0
|
||||
? backendModelConfig.chat_prompt_config
|
||||
: clone(DEFAULT_CHAT_PROMPT_CONFIG),
|
||||
chatPromptConfig: normalizeChatPromptConfig(backendModelConfig.chat_prompt_config),
|
||||
introduction: backendModelConfig.opening_statement,
|
||||
moderationConfig: backendModelConfig.sensitive_word_avoidance,
|
||||
}
|
||||
@@ -530,7 +569,6 @@ export const createPublishHandler =
|
||||
({
|
||||
appId,
|
||||
chatPromptConfig,
|
||||
citationConfig,
|
||||
completionParamsState,
|
||||
completionPromptConfig,
|
||||
contextVar,
|
||||
@@ -539,25 +577,19 @@ export const createPublishHandler =
|
||||
datasetConfigs,
|
||||
externalDataToolsConfig,
|
||||
hasSetBlockStatus,
|
||||
introduction,
|
||||
isAdvancedMode,
|
||||
isFunctionCall,
|
||||
mode,
|
||||
modelConfig,
|
||||
moreLikeThisConfig,
|
||||
promptEmpty,
|
||||
promptMode,
|
||||
resolvedModelModeType,
|
||||
setCanReturnToSimpleMode,
|
||||
setPublishedConfig,
|
||||
speechToTextConfig,
|
||||
suggestedQuestionsAfterAnswerConfig,
|
||||
t: rawTranslate,
|
||||
textToSpeechConfig,
|
||||
}: {
|
||||
appId: string
|
||||
chatPromptConfig: BackendModelConfig['chat_prompt_config']
|
||||
citationConfig: ModelConfig['retriever_resource']
|
||||
completionParamsState: FormValue
|
||||
completionPromptConfig: BackendModelConfig['completion_prompt_config']
|
||||
contextVar?: string
|
||||
@@ -566,21 +598,16 @@ export const createPublishHandler =
|
||||
datasetConfigs: DatasetConfigs
|
||||
externalDataToolsConfig: BackendModelConfig['external_data_tools']
|
||||
hasSetBlockStatus: { history: boolean; query: boolean }
|
||||
introduction: string
|
||||
isAdvancedMode: boolean
|
||||
isFunctionCall: boolean
|
||||
mode: AppModeEnum
|
||||
modelConfig: ModelConfig
|
||||
moreLikeThisConfig: ModelConfig['more_like_this']
|
||||
promptEmpty: boolean
|
||||
promptMode: BackendModelConfig['prompt_type']
|
||||
resolvedModelModeType: ModelModeType
|
||||
setCanReturnToSimpleMode: (value: boolean) => void
|
||||
setPublishedConfig: (config: { modelConfig: ModelConfig; completionParams: FormValue }) => void
|
||||
speechToTextConfig: ModelConfig['speech_to_text']
|
||||
suggestedQuestionsAfterAnswerConfig: ModelConfig['suggested_questions_after_answer']
|
||||
setPublishedConfig: (config: ConfigurationPublishConfig) => void
|
||||
t: SelectorTranslate<'appDebug' | 'common'>
|
||||
textToSpeechConfig: ModelConfig['text_to_speech']
|
||||
}) =>
|
||||
async (
|
||||
updateAppModelConfig: (params: { url: string; body: BackendModelConfig }) => Promise<unknown>,
|
||||
@@ -643,18 +670,47 @@ export const createPublishHandler =
|
||||
await updateAppModelConfig({ url: `/apps/${appId}/model-config`, body })
|
||||
|
||||
const nextModelConfig = produce(modelConfig, (draft: ModelConfig) => {
|
||||
draft.opening_statement = introduction
|
||||
draft.more_like_this = moreLikeThisConfig
|
||||
draft.suggested_questions_after_answer = suggestedQuestionsAfterAnswerConfig
|
||||
draft.speech_to_text = speechToTextConfig
|
||||
draft.text_to_speech = textToSpeechConfig
|
||||
draft.retriever_resource = citationConfig
|
||||
draft.provider = body.model.provider
|
||||
draft.model_id = body.model.name
|
||||
draft.mode = body.model.mode
|
||||
draft.configs.prompt_template = body.pre_prompt
|
||||
draft.prompt_type = body.prompt_type
|
||||
draft.chat_prompt_config = normalizeChatPromptConfig(body.chat_prompt_config)
|
||||
draft.completion_prompt_config = normalizeCompletionPromptConfig(
|
||||
body.completion_prompt_config,
|
||||
)
|
||||
draft.opening_statement = body.opening_statement
|
||||
draft.more_like_this = body.more_like_this
|
||||
draft.suggested_questions = body.suggested_questions ?? []
|
||||
draft.suggested_questions_after_answer = body.suggested_questions_after_answer
|
||||
draft.speech_to_text = body.speech_to_text
|
||||
draft.text_to_speech = body.text_to_speech
|
||||
draft.file_upload = body.file_upload ?? null
|
||||
draft.retriever_resource = body.retriever_resource
|
||||
draft.sensitive_word_avoidance = body.sensitive_word_avoidance
|
||||
draft.external_data_tools = body.external_data_tools
|
||||
draft.system_parameters = body.system_parameters
|
||||
const publishedAgentConfig = body.agent_mode as ModelConfig['agentConfig']
|
||||
draft.agentConfig = {
|
||||
...draft.agentConfig,
|
||||
...publishedAgentConfig,
|
||||
max_iteration: publishedAgentConfig.max_iteration || draft.agentConfig.max_iteration,
|
||||
}
|
||||
draft.dataSets = dataSets
|
||||
})
|
||||
|
||||
setPublishedConfig({
|
||||
modelConfig: nextModelConfig,
|
||||
completionParams: completionParamsState,
|
||||
completionParams: body.model.completion_params,
|
||||
promptMode:
|
||||
body.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple,
|
||||
chatPromptConfig: normalizeChatPromptConfig(body.chat_prompt_config),
|
||||
completionPromptConfig: normalizeCompletionPromptConfig(body.completion_prompt_config),
|
||||
datasetConfigs: {
|
||||
...datasetConfigs,
|
||||
datasets: body.dataset_configs.datasets,
|
||||
},
|
||||
externalDataToolsConfig: body.external_data_tools ?? [],
|
||||
})
|
||||
toast.success(t(($) => $['api.success'], { ns: 'common' }))
|
||||
setCanReturnToSimpleMode(false)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { ConfigurationPublishConfig } from './use-configuration-utils'
|
||||
import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher'
|
||||
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
|
||||
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
|
||||
@@ -32,6 +33,7 @@ import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { APP_PUBLISH_DRAFT_CHANGED } from '@/app/components/app/app-publisher/events'
|
||||
import {
|
||||
useDebugWithSingleOrMultipleModel,
|
||||
useFormattingChangedDispatcher,
|
||||
@@ -57,6 +59,7 @@ import {
|
||||
DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||
} from '@/config'
|
||||
import { userProfileIdAtom } from '@/context/account-state'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state'
|
||||
@@ -77,11 +80,6 @@ import {
|
||||
loadConfigurationState,
|
||||
} from './use-configuration-utils'
|
||||
|
||||
type PublishConfig = {
|
||||
modelConfig: ModelConfig
|
||||
completionParams: FormValue
|
||||
}
|
||||
|
||||
type DebugConfigurationValue = ComponentProps<typeof ConfigContext.Provider>['value']
|
||||
|
||||
export type ConfigurationViewModel = {
|
||||
@@ -104,6 +102,7 @@ export type ConfigurationViewModel = {
|
||||
onCompletionParamsChange: (params: FormValue) => void
|
||||
onConfirmUseGPT4: () => void
|
||||
onEnableMultipleModelDebug: () => void
|
||||
onFeatureStoreChange: OnFeaturesChange
|
||||
onFeaturesChange: OnFeaturesChange
|
||||
onHideDebugPanel: () => void
|
||||
onModelChange: ComponentProps<typeof ModelParameterModal>['setModel']
|
||||
@@ -141,7 +140,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
const setDetailSidebarMode = useSetDetailSidebarMode()
|
||||
|
||||
const { data: fileUploadConfigResponse } = useFileUploadConfig()
|
||||
const latestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail])
|
||||
const serverLatestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail])
|
||||
const appACLCapabilities = useMemo(
|
||||
() =>
|
||||
getAppACLCapabilities(appDetail?.permission_keys, {
|
||||
@@ -157,36 +156,117 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
const pathname = usePathname()
|
||||
const matched = /\/app\/([^/]+)/.exec(pathname)
|
||||
const appId = matched?.[1] || ''
|
||||
const [publishedAtOverride, setPublishedAtOverride] = useState({
|
||||
appId,
|
||||
value: 0,
|
||||
})
|
||||
const latestPublishedAt =
|
||||
publishedAtOverride.appId === appId
|
||||
? Math.max(serverLatestPublishedAt || 0, publishedAtOverride.value)
|
||||
: serverLatestPublishedAt
|
||||
const [mode, setMode] = useState<AppModeEnum>(AppModeEnum.CHAT)
|
||||
const [publishedConfig, setPublishedConfig] = useState<PublishConfig | null>(null)
|
||||
const [publishedConfig, setPublishedConfig] = useState<ConfigurationPublishConfig | null>(null)
|
||||
const [unpublishedChangesState, setUnpublishedChangesState] = useState({
|
||||
appId,
|
||||
value: false,
|
||||
})
|
||||
const hasUnpublishedChanges =
|
||||
unpublishedChangesState.appId === appId && unpublishedChangesState.value
|
||||
const [conversationId, setConversationId] = useState<string | null>('')
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const publishChangeTrackingAppIdRef = useRef('')
|
||||
const dispatchPublishDraftChanged = useCallback(() => {
|
||||
if (publishChangeTrackingAppIdRef.current !== appId) return
|
||||
eventEmitter?.emit({
|
||||
type: APP_PUBLISH_DRAFT_CHANGED,
|
||||
instanceId: appId,
|
||||
})
|
||||
}, [appId, eventEmitter])
|
||||
|
||||
eventEmitter?.useSubscription((event) => {
|
||||
if (
|
||||
typeof event !== 'string' &&
|
||||
event.type === APP_PUBLISH_DRAFT_CHANGED &&
|
||||
event.instanceId === appId
|
||||
)
|
||||
setUnpublishedChangesState({ appId, value: true })
|
||||
})
|
||||
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const [isShowDebugPanel, { setTrue: showDebugPanel, setFalse: hideDebugPanel }] =
|
||||
useBoolean(false)
|
||||
|
||||
const [introduction, setIntroduction] = useState('')
|
||||
const [suggestedQuestions, setSuggestedQuestions] = useState<string[]>([])
|
||||
const [introduction, doSetIntroduction] = useState('')
|
||||
const setIntroduction = useCallback(
|
||||
(value: string) => {
|
||||
doSetIntroduction(value)
|
||||
dispatchPublishDraftChanged()
|
||||
},
|
||||
[dispatchPublishDraftChanged],
|
||||
)
|
||||
const [suggestedQuestions, doSetSuggestedQuestions] = useState<string[]>([])
|
||||
const setSuggestedQuestions = useCallback(
|
||||
(value: string[]) => {
|
||||
doSetSuggestedQuestions(value)
|
||||
dispatchPublishDraftChanged()
|
||||
},
|
||||
[dispatchPublishDraftChanged],
|
||||
)
|
||||
const [controlClearChatMessage, setControlClearChatMessage] = useState(0)
|
||||
const [prevPromptConfig, setPrevPromptConfig] = useState<PromptConfig>({
|
||||
prompt_template: '',
|
||||
prompt_variables: [],
|
||||
})
|
||||
const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig>({
|
||||
const [moreLikeThisConfig, doSetMoreLikeThisConfig] = useState<MoreLikeThisConfig>({
|
||||
enabled: false,
|
||||
})
|
||||
const [suggestedQuestionsAfterAnswerConfig, setSuggestedQuestionsAfterAnswerConfig] =
|
||||
const setMoreLikeThisConfig = useCallback(
|
||||
(value: MoreLikeThisConfig) => {
|
||||
doSetMoreLikeThisConfig(value)
|
||||
dispatchPublishDraftChanged()
|
||||
},
|
||||
[dispatchPublishDraftChanged],
|
||||
)
|
||||
const [suggestedQuestionsAfterAnswerConfig, doSetSuggestedQuestionsAfterAnswerConfig] =
|
||||
useState<MoreLikeThisConfig>({ enabled: false })
|
||||
const [speechToTextConfig, setSpeechToTextConfig] = useState<MoreLikeThisConfig>({
|
||||
const setSuggestedQuestionsAfterAnswerConfig = useCallback(
|
||||
(value: MoreLikeThisConfig) => {
|
||||
doSetSuggestedQuestionsAfterAnswerConfig(value)
|
||||
dispatchPublishDraftChanged()
|
||||
},
|
||||
[dispatchPublishDraftChanged],
|
||||
)
|
||||
const [speechToTextConfig, doSetSpeechToTextConfig] = useState<MoreLikeThisConfig>({
|
||||
enabled: false,
|
||||
})
|
||||
const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig>({
|
||||
const setSpeechToTextConfig = useCallback(
|
||||
(value: MoreLikeThisConfig) => {
|
||||
doSetSpeechToTextConfig(value)
|
||||
dispatchPublishDraftChanged()
|
||||
},
|
||||
[dispatchPublishDraftChanged],
|
||||
)
|
||||
const [textToSpeechConfig, doSetTextToSpeechConfig] = useState<TextToSpeechConfig>({
|
||||
enabled: false,
|
||||
voice: '',
|
||||
language: '',
|
||||
})
|
||||
const [citationConfig, setCitationConfig] = useState<MoreLikeThisConfig>({ enabled: false })
|
||||
const setTextToSpeechConfig = useCallback(
|
||||
(value: TextToSpeechConfig) => {
|
||||
doSetTextToSpeechConfig(value)
|
||||
dispatchPublishDraftChanged()
|
||||
},
|
||||
[dispatchPublishDraftChanged],
|
||||
)
|
||||
const [citationConfig, doSetCitationConfig] = useState<MoreLikeThisConfig>({ enabled: false })
|
||||
const setCitationConfig = useCallback(
|
||||
(value: MoreLikeThisConfig) => {
|
||||
doSetCitationConfig(value)
|
||||
dispatchPublishDraftChanged()
|
||||
},
|
||||
[dispatchPublishDraftChanged],
|
||||
)
|
||||
const [annotationConfig, doSetAnnotationConfig] = useState<AnnotationReplyConfig>({
|
||||
id: '',
|
||||
enabled: false,
|
||||
@@ -205,8 +285,22 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
[formattingChangedDispatcher],
|
||||
)
|
||||
|
||||
const [moderationConfig, setModerationConfig] = useState<ModerationConfig>({ enabled: false })
|
||||
const [externalDataToolsConfig, setExternalDataToolsConfig] = useState<ExternalDataTool[]>([])
|
||||
const [moderationConfig, doSetModerationConfig] = useState<ModerationConfig>({ enabled: false })
|
||||
const setModerationConfig = useCallback(
|
||||
(value: ModerationConfig) => {
|
||||
doSetModerationConfig(value)
|
||||
dispatchPublishDraftChanged()
|
||||
},
|
||||
[dispatchPublishDraftChanged],
|
||||
)
|
||||
const [externalDataToolsConfig, doSetExternalDataToolsConfig] = useState<ExternalDataTool[]>([])
|
||||
const setExternalDataToolsConfig = useCallback(
|
||||
(value: ExternalDataTool[]) => {
|
||||
doSetExternalDataToolsConfig(value)
|
||||
dispatchPublishDraftChanged()
|
||||
},
|
||||
[dispatchPublishDraftChanged],
|
||||
)
|
||||
const [inputs, setInputs] = useState<Inputs>({})
|
||||
const [query, setQuery] = useState('')
|
||||
const [completionParamsState, doSetCompletionParams] = useState<FormValue>({})
|
||||
@@ -257,13 +351,18 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
setTempStop([])
|
||||
}
|
||||
doSetCompletionParams(params)
|
||||
dispatchPublishDraftChanged()
|
||||
},
|
||||
[getTempStop, setTempStop],
|
||||
[dispatchPublishDraftChanged, getTempStop, setTempStop],
|
||||
)
|
||||
|
||||
const setModelConfig = useCallback((newModelConfig: ModelConfig) => {
|
||||
doSetModelConfig(newModelConfig)
|
||||
}, [])
|
||||
const setModelConfig = useCallback(
|
||||
(newModelConfig: ModelConfig) => {
|
||||
doSetModelConfig(newModelConfig)
|
||||
dispatchPublishDraftChanged()
|
||||
},
|
||||
[dispatchPublishDraftChanged],
|
||||
)
|
||||
|
||||
const isAgent = mode === AppModeEnum.AGENT_CHAT
|
||||
|
||||
@@ -282,12 +381,23 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
},
|
||||
})
|
||||
const datasetConfigsRef = useRef(datasetConfigs)
|
||||
const setDatasetConfigs = useCallback((newDatasetConfigs: DatasetConfigs) => {
|
||||
doSetDatasetConfigs(newDatasetConfigs)
|
||||
datasetConfigsRef.current = newDatasetConfigs
|
||||
}, [])
|
||||
const setDatasetConfigs = useCallback(
|
||||
(newDatasetConfigs: DatasetConfigs) => {
|
||||
doSetDatasetConfigs(newDatasetConfigs)
|
||||
datasetConfigsRef.current = newDatasetConfigs
|
||||
dispatchPublishDraftChanged()
|
||||
},
|
||||
[dispatchPublishDraftChanged],
|
||||
)
|
||||
|
||||
const [dataSets, setDataSets] = useState<DataSet[]>([])
|
||||
const [dataSets, doSetDataSets] = useState<DataSet[]>([])
|
||||
const setDataSets = useCallback(
|
||||
(value: DataSet[]) => {
|
||||
doSetDataSets(value)
|
||||
dispatchPublishDraftChanged()
|
||||
},
|
||||
[dispatchPublishDraftChanged],
|
||||
)
|
||||
const contextVar = modelConfig.configs.prompt_variables.find((item) => item.is_context_var)?.key
|
||||
const hasSetContextVar = !!contextVar
|
||||
const [isShowSelectDataSet, { setTrue: showSelectDataSet, setFalse: hideSelectDataSet }] =
|
||||
@@ -301,30 +411,6 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
const { currentModel: currentRerankModel, currentProvider: currentRerankProvider } =
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank)
|
||||
|
||||
const syncToPublishedConfig = useCallback(
|
||||
(_publishedConfig: PublishConfig) => {
|
||||
const publishedModelConfig = _publishedConfig.modelConfig
|
||||
setModelConfig(publishedModelConfig)
|
||||
setCompletionParams(_publishedConfig.completionParams)
|
||||
setDataSets(publishedModelConfig.dataSets || [])
|
||||
setIntroduction(publishedModelConfig.opening_statement || '')
|
||||
setMoreLikeThisConfig(publishedModelConfig.more_like_this || { enabled: false })
|
||||
setSuggestedQuestionsAfterAnswerConfig(
|
||||
publishedModelConfig.suggested_questions_after_answer || { enabled: false },
|
||||
)
|
||||
setSpeechToTextConfig(publishedModelConfig.speech_to_text || { enabled: false })
|
||||
setTextToSpeechConfig(
|
||||
publishedModelConfig.text_to_speech || {
|
||||
enabled: false,
|
||||
voice: '',
|
||||
language: '',
|
||||
},
|
||||
)
|
||||
setCitationConfig(publishedModelConfig.retriever_resource || { enabled: false })
|
||||
},
|
||||
[setCompletionParams, setModelConfig],
|
||||
)
|
||||
|
||||
const { isAPIKeySet } = useProviderContext()
|
||||
const { currentModel: currModel } = useTextGenerationCurrentProviderAndModelAndModelList({
|
||||
provider: modelConfig.provider,
|
||||
@@ -361,9 +447,10 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
detail: config.detail || Resolution.low,
|
||||
transfer_methods: config.transfer_methods || [TransferMethod.local_file],
|
||||
})
|
||||
dispatchPublishDraftChanged()
|
||||
if (!notNoticeFormattingChanged) formattingChangedDispatcher()
|
||||
},
|
||||
[formattingChangedDispatcher],
|
||||
[dispatchPublishDraftChanged, formattingChangedDispatcher],
|
||||
)
|
||||
|
||||
const {
|
||||
@@ -389,8 +476,77 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
completionParams: completionParamsState,
|
||||
setCompletionParams,
|
||||
setStop: setTempStop,
|
||||
onPublishConfigChange: dispatchPublishDraftChanged,
|
||||
})
|
||||
|
||||
const syncToPublishedConfig = useCallback(
|
||||
(_publishedConfig: ConfigurationPublishConfig) => {
|
||||
const trackedAppId = publishChangeTrackingAppIdRef.current
|
||||
publishChangeTrackingAppIdRef.current = ''
|
||||
|
||||
try {
|
||||
const publishedModelConfig = _publishedConfig.modelConfig
|
||||
setModelConfig(publishedModelConfig)
|
||||
setCompletionParams(_publishedConfig.completionParams)
|
||||
doSetPromptMode(_publishedConfig.promptMode)
|
||||
setCanReturnToSimpleMode(_publishedConfig.promptMode !== PromptMode.advanced)
|
||||
setChatPromptConfig(_publishedConfig.chatPromptConfig)
|
||||
setCompletionPromptConfig(_publishedConfig.completionPromptConfig)
|
||||
setDataSets(publishedModelConfig.dataSets || [])
|
||||
setDatasetConfigs(_publishedConfig.datasetConfigs)
|
||||
setExternalDataToolsConfig(_publishedConfig.externalDataToolsConfig)
|
||||
setIntroduction(publishedModelConfig.opening_statement || '')
|
||||
setSuggestedQuestions(publishedModelConfig.suggested_questions || [])
|
||||
setMoreLikeThisConfig(publishedModelConfig.more_like_this || { enabled: false })
|
||||
setSuggestedQuestionsAfterAnswerConfig(
|
||||
publishedModelConfig.suggested_questions_after_answer || { enabled: false },
|
||||
)
|
||||
setSpeechToTextConfig(publishedModelConfig.speech_to_text || { enabled: false })
|
||||
setTextToSpeechConfig(
|
||||
publishedModelConfig.text_to_speech || {
|
||||
enabled: false,
|
||||
voice: '',
|
||||
language: '',
|
||||
},
|
||||
)
|
||||
setCitationConfig(publishedModelConfig.retriever_resource || { enabled: false })
|
||||
setModerationConfig(publishedModelConfig.sensitive_word_avoidance || { enabled: false })
|
||||
const publishedVisionConfig = publishedModelConfig.file_upload?.image
|
||||
handleSetVisionConfig(
|
||||
{
|
||||
enabled: publishedVisionConfig?.enabled || false,
|
||||
number_limits: publishedVisionConfig?.number_limits || 2,
|
||||
detail: publishedVisionConfig?.detail || Resolution.low,
|
||||
transfer_methods: publishedVisionConfig?.transfer_methods || [
|
||||
TransferMethod.local_file,
|
||||
],
|
||||
},
|
||||
true,
|
||||
)
|
||||
} finally {
|
||||
publishChangeTrackingAppIdRef.current = trackedAppId
|
||||
}
|
||||
},
|
||||
[
|
||||
handleSetVisionConfig,
|
||||
setChatPromptConfig,
|
||||
setCitationConfig,
|
||||
setCompletionParams,
|
||||
setCompletionPromptConfig,
|
||||
setDataSets,
|
||||
setDatasetConfigs,
|
||||
setExternalDataToolsConfig,
|
||||
setIntroduction,
|
||||
setModelConfig,
|
||||
setModerationConfig,
|
||||
setMoreLikeThisConfig,
|
||||
setSpeechToTextConfig,
|
||||
setSuggestedQuestions,
|
||||
setSuggestedQuestionsAfterAnswerConfig,
|
||||
setTextToSpeechConfig,
|
||||
],
|
||||
)
|
||||
|
||||
const setPromptMode = useCallback(
|
||||
async (nextMode: PromptMode) => {
|
||||
if (nextMode === PromptMode.advanced) {
|
||||
@@ -398,8 +554,9 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
setCanReturnToSimpleMode(true)
|
||||
}
|
||||
doSetPromptMode(nextMode)
|
||||
dispatchPublishDraftChanged()
|
||||
},
|
||||
[migrateToDefaultPrompt],
|
||||
[dispatchPublishDraftChanged, migrateToDefaultPrompt],
|
||||
)
|
||||
|
||||
const handleSelect = useCallback(
|
||||
@@ -479,6 +636,12 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
},
|
||||
[formattingChangedDispatcher, setShowAppConfigureFeaturesModal],
|
||||
)
|
||||
const handleFeatureStoreChange = useCallback<OnFeaturesChange>(
|
||||
(features) => {
|
||||
if (features) dispatchPublishDraftChanged()
|
||||
},
|
||||
[dispatchPublishDraftChanged],
|
||||
)
|
||||
|
||||
const handleAddPromptVariable = useCallback(
|
||||
(variables: PromptVariable[]) => {
|
||||
@@ -492,6 +655,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
publishChangeTrackingAppIdRef.current = ''
|
||||
void (async () => {
|
||||
const configurationState = await loadConfigurationState({
|
||||
appId,
|
||||
@@ -502,35 +666,14 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
|
||||
setCollectionList(configurationState.collectionList)
|
||||
setMode(configurationState.mode)
|
||||
doSetPromptMode(configurationState.promptMode)
|
||||
setDataSets(configurationState.nextDataSets)
|
||||
setIntroduction(configurationState.introduction)
|
||||
setSuggestedQuestions(configurationState.suggestedQuestions)
|
||||
setMoreLikeThisConfig(configurationState.moreLikeThisConfig)
|
||||
setSuggestedQuestionsAfterAnswerConfig(configurationState.suggestedQuestionsAfterAnswerConfig)
|
||||
setSpeechToTextConfig(configurationState.speechToTextConfig)
|
||||
setTextToSpeechConfig(configurationState.textToSpeechConfig)
|
||||
setCitationConfig(configurationState.citationConfig)
|
||||
setModerationConfig(configurationState.moderationConfig || { enabled: false })
|
||||
setExternalDataToolsConfig(configurationState.externalDataToolsConfig)
|
||||
setDatasetConfigs(configurationState.datasetConfigs)
|
||||
|
||||
if (configurationState.promptMode === PromptMode.advanced) {
|
||||
setChatPromptConfig(configurationState.chatPromptConfig)
|
||||
setCompletionPromptConfig(configurationState.completionPromptConfig as never)
|
||||
setCanReturnToSimpleMode(false)
|
||||
} else {
|
||||
setCanReturnToSimpleMode(configurationState.canReturnToSimpleMode)
|
||||
}
|
||||
syncToPublishedConfig(configurationState.publishedConfig)
|
||||
|
||||
if (configurationState.annotationConfig)
|
||||
setAnnotationConfig(configurationState.annotationConfig, true)
|
||||
|
||||
if (configurationState.visionConfig)
|
||||
handleSetVisionConfig(configurationState.visionConfig, true)
|
||||
|
||||
syncToPublishedConfig(configurationState.publishedConfig as PublishConfig)
|
||||
setPublishedConfig(configurationState.publishedConfig as PublishConfig)
|
||||
setPublishedConfig(configurationState.publishedConfig)
|
||||
setUnpublishedChangesState({ appId, value: false })
|
||||
publishChangeTrackingAppIdRef.current = appId
|
||||
setHasFetchedDetail(true)
|
||||
})()
|
||||
}, [appId])
|
||||
@@ -569,11 +712,14 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
params && 'model' in params && 'provider' in params && 'parameters' in params
|
||||
? params
|
||||
: undefined
|
||||
const handlePublishedConfigChange = (config: ConfigurationPublishConfig) => {
|
||||
setPublishedConfig(config)
|
||||
if (modelAndParameter) syncToPublishedConfig(config)
|
||||
}
|
||||
|
||||
return createPublishHandler({
|
||||
const result = await createPublishHandler({
|
||||
appId,
|
||||
chatPromptConfig,
|
||||
citationConfig,
|
||||
completionParamsState,
|
||||
completionPromptConfig,
|
||||
contextVar,
|
||||
@@ -582,28 +728,30 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
datasetConfigs,
|
||||
externalDataToolsConfig,
|
||||
hasSetBlockStatus,
|
||||
introduction,
|
||||
isAdvancedMode,
|
||||
isFunctionCall,
|
||||
mode,
|
||||
modelConfig,
|
||||
moreLikeThisConfig,
|
||||
promptEmpty,
|
||||
promptMode,
|
||||
resolvedModelModeType,
|
||||
setCanReturnToSimpleMode,
|
||||
setPublishedConfig,
|
||||
speechToTextConfig,
|
||||
suggestedQuestionsAfterAnswerConfig,
|
||||
setPublishedConfig: handlePublishedConfigChange,
|
||||
t,
|
||||
textToSpeechConfig,
|
||||
})(updateAppModelConfig, modelAndParameter, features)
|
||||
|
||||
if (result) {
|
||||
setUnpublishedChangesState({ appId, value: false })
|
||||
// The publish API currently returns only a result flag, so keep the summary current
|
||||
// locally until app detail is refreshed with the server-side updated_at value.
|
||||
setPublishedAtOverride({ appId, value: Math.floor(Date.now() / 1000) })
|
||||
}
|
||||
return result
|
||||
},
|
||||
[
|
||||
appACLCapabilities.canReleaseAndVersion,
|
||||
appId,
|
||||
chatPromptConfig,
|
||||
citationConfig,
|
||||
completionParamsState,
|
||||
completionPromptConfig,
|
||||
contextVar,
|
||||
@@ -612,21 +760,16 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
datasetConfigs,
|
||||
externalDataToolsConfig,
|
||||
hasSetBlockStatus,
|
||||
introduction,
|
||||
isAdvancedMode,
|
||||
isFunctionCall,
|
||||
mode,
|
||||
modelConfig,
|
||||
moreLikeThisConfig,
|
||||
promptEmpty,
|
||||
promptMode,
|
||||
resolvedModelModeType,
|
||||
setCanReturnToSimpleMode,
|
||||
setPublishedConfig,
|
||||
speechToTextConfig,
|
||||
suggestedQuestionsAfterAnswerConfig,
|
||||
syncToPublishedConfig,
|
||||
t,
|
||||
textToSpeechConfig,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -746,11 +889,16 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
disabled: !appACLCapabilities.canReleaseAndVersion,
|
||||
publishDisabled: cannotPublish || !appACLCapabilities.canReleaseAndVersion,
|
||||
publishedAt: (latestPublishedAt || 0) * 1000,
|
||||
hasUnpublishedChanges: !latestPublishedAt || hasUnpublishedChanges,
|
||||
debugWithMultipleModel,
|
||||
multipleModelConfigs,
|
||||
onPublish,
|
||||
publishedConfig: publishedConfig as PublishConfig,
|
||||
resetAppConfig: () => publishedConfig && syncToPublishedConfig(publishedConfig),
|
||||
publishedConfig: publishedConfig as ConfigurationPublishConfig,
|
||||
resetAppConfig: () => {
|
||||
if (!publishedConfig) return
|
||||
syncToPublishedConfig(publishedConfig)
|
||||
setUnpublishedChangesState({ appId, value: false })
|
||||
},
|
||||
},
|
||||
contextValue,
|
||||
featuresData,
|
||||
@@ -773,6 +921,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
setShowUseGPT4Confirm(false)
|
||||
},
|
||||
onEnableMultipleModelDebug: handleDebugWithMultipleModelChange,
|
||||
onFeatureStoreChange: handleFeatureStoreChange,
|
||||
onFeaturesChange: handleFeaturesChange,
|
||||
onHideDebugPanel: hideDebugPanel,
|
||||
onModelChange: setModel,
|
||||
|
||||
@@ -151,6 +151,20 @@ describe('createFeaturesStore', () => {
|
||||
expect(store.getState().features.moreLikeThis?.enabled).toBe(true)
|
||||
expect(store.getState().features.opening?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should notify publish tracking unless the update is silent', () => {
|
||||
const onFeaturesChange = vi.fn()
|
||||
const store = createFeaturesStore(undefined, onFeaturesChange)
|
||||
const nextFeatures = {
|
||||
moreLikeThis: { enabled: true },
|
||||
}
|
||||
|
||||
store.getState().setFeatures(nextFeatures)
|
||||
store.getState().setFeatures(nextFeatures, { silent: true })
|
||||
|
||||
expect(onFeaturesChange).toHaveBeenCalledOnce()
|
||||
expect(onFeaturesChange).toHaveBeenCalledWith(nextFeatures)
|
||||
})
|
||||
})
|
||||
|
||||
describe('showFeaturesModal', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FeaturesState, FeaturesStore } from './store'
|
||||
import type { Features } from './types'
|
||||
import { createContext, useRef } from 'react'
|
||||
import { createFeaturesStore } from './store'
|
||||
|
||||
@@ -6,11 +7,21 @@ export const FeaturesContext = createContext<FeaturesStore | null>(null)
|
||||
|
||||
type FeaturesProviderProps = {
|
||||
children: React.ReactNode
|
||||
onFeaturesChange?: (features: Features) => void
|
||||
} & Partial<FeaturesState>
|
||||
export const FeaturesProvider = ({ children, ...props }: FeaturesProviderProps) => {
|
||||
export const FeaturesProvider = ({
|
||||
children,
|
||||
onFeaturesChange,
|
||||
...props
|
||||
}: FeaturesProviderProps) => {
|
||||
const storeRef = useRef<FeaturesStore | undefined>(undefined)
|
||||
const onFeaturesChangeRef = useRef(onFeaturesChange)
|
||||
onFeaturesChangeRef.current = onFeaturesChange
|
||||
|
||||
if (!storeRef.current) storeRef.current = createFeaturesStore(props)
|
||||
if (!storeRef.current)
|
||||
storeRef.current = createFeaturesStore(props, (features) =>
|
||||
onFeaturesChangeRef.current?.(features),
|
||||
)
|
||||
|
||||
return <FeaturesContext.Provider value={storeRef.current}>{children}</FeaturesContext.Provider>
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ const AnnotationReply = ({ disabled, onChange }: Props) => {
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft.annotationReply = newConfig
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
if (onChange) onChange(newFeatures)
|
||||
setFeatures(newFeatures, { silent: true })
|
||||
onChange?.()
|
||||
},
|
||||
[featuresStore, onChange],
|
||||
)
|
||||
|
||||
@@ -12,14 +12,21 @@ export type FeaturesState = {
|
||||
}
|
||||
|
||||
type FeaturesAction = {
|
||||
setFeatures: (features: Features) => void
|
||||
setFeatures: (features: Features, options?: SetFeaturesOptions) => void
|
||||
}
|
||||
|
||||
export type FeatureStoreState = FeaturesState & FeaturesAction & FeaturesModal
|
||||
|
||||
export type FeaturesStore = ReturnType<typeof createFeaturesStore>
|
||||
|
||||
export const createFeaturesStore = (initProps?: Partial<FeaturesState>) => {
|
||||
export type SetFeaturesOptions = {
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
export const createFeaturesStore = (
|
||||
initProps?: Partial<FeaturesState>,
|
||||
onFeaturesChange?: (features: Features) => void,
|
||||
) => {
|
||||
const DEFAULT_PROPS: FeaturesState = {
|
||||
features: {
|
||||
moreLikeThis: {
|
||||
@@ -59,7 +66,10 @@ export const createFeaturesStore = (initProps?: Partial<FeaturesState>) => {
|
||||
return createStore<FeatureStoreState>()((set) => ({
|
||||
...DEFAULT_PROPS,
|
||||
...initProps,
|
||||
setFeatures: (features) => set(() => ({ features })),
|
||||
setFeatures: (features, options) => {
|
||||
set(() => ({ features }))
|
||||
if (!options?.silent) onFeaturesChange?.(features)
|
||||
},
|
||||
showFeaturesModal: false,
|
||||
setShowFeaturesModal: (showFeaturesModal) => set(() => ({ showFeaturesModal })),
|
||||
}))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ModalContextState } from '@/context/modal-context'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import type { ICurrentWorkspace, IWorkspace } from '@/models/common'
|
||||
import type { IWorkspace } from '@/models/common'
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
@@ -95,14 +96,13 @@ vi.mock('@/service/client', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const currentWorkspaceValue: ICurrentWorkspace = {
|
||||
const currentWorkspaceValue: PostWorkspacesCurrentResponse = {
|
||||
id: 'workspace-1',
|
||||
name: 'Solar Studio',
|
||||
plan: Plan.sandbox,
|
||||
status: 'normal',
|
||||
created_at: 0,
|
||||
role: 'owner',
|
||||
providers: [],
|
||||
trial_credits: 10000,
|
||||
trial_credits_used: 2500,
|
||||
trial_credits_exhausted_at: 0,
|
||||
@@ -111,11 +111,11 @@ const currentWorkspaceValue: ICurrentWorkspace = {
|
||||
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
let mockCurrentWorkspace: ICurrentWorkspace | undefined = currentWorkspaceValue
|
||||
let mockCurrentWorkspace: PostWorkspacesCurrentResponse | undefined = currentWorkspaceValue
|
||||
let mockWorkspaces: IWorkspace[] = []
|
||||
|
||||
const mockCurrentWorkspaceQuery = (
|
||||
data: ICurrentWorkspace | undefined = currentWorkspaceValue,
|
||||
data: PostWorkspacesCurrentResponse | undefined = currentWorkspaceValue,
|
||||
isPending = false,
|
||||
) => {
|
||||
mockCurrentWorkspace = isPending ? undefined : data
|
||||
@@ -284,13 +284,12 @@ describe('WorkspaceCard', () => {
|
||||
plan: Plan.team,
|
||||
})
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
enableBilling: true,
|
||||
enableBilling: false,
|
||||
isEducationAccount: false,
|
||||
isEducationWorkspace: false,
|
||||
isFetchedPlan: true,
|
||||
plan: { type: Plan.team },
|
||||
plan: { type: Plan.sandbox },
|
||||
} as ProviderContextState)
|
||||
|
||||
renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } })
|
||||
|
||||
expect(screen.getByText(Plan.team)).toBeInTheDocument()
|
||||
@@ -304,14 +303,6 @@ describe('WorkspaceCard', () => {
|
||||
...currentWorkspaceValue,
|
||||
plan: Plan.team,
|
||||
})
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
enableBilling: true,
|
||||
isEducationAccount: false,
|
||||
isEducationWorkspace: false,
|
||||
isFetchedPlan: true,
|
||||
plan: { type: Plan.team },
|
||||
} as ProviderContextState)
|
||||
|
||||
renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } })
|
||||
|
||||
expect(screen.getByText(Plan.team)).toBeInTheDocument()
|
||||
@@ -322,14 +313,6 @@ describe('WorkspaceCard', () => {
|
||||
...currentWorkspaceValue,
|
||||
plan: '',
|
||||
})
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
enableBilling: true,
|
||||
isEducationAccount: false,
|
||||
isEducationWorkspace: false,
|
||||
isFetchedPlan: false,
|
||||
plan: { type: Plan.sandbox },
|
||||
} as ProviderContextState)
|
||||
|
||||
renderWorkspaceCard({
|
||||
systemFeatures: {
|
||||
deployment_edition: 'ENTERPRISE',
|
||||
@@ -471,13 +454,10 @@ describe('WorkspaceCard', () => {
|
||||
})
|
||||
|
||||
it('opens members settings from workspace menu when billing is disabled', async () => {
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
enableBilling: false,
|
||||
isEducationAccount: false,
|
||||
isEducationWorkspace: false,
|
||||
isFetchedPlan: false,
|
||||
plan: { type: Plan.sandbox },
|
||||
} as ProviderContextState)
|
||||
mockCurrentWorkspaceQuery({
|
||||
...currentWorkspaceValue,
|
||||
plan: null,
|
||||
})
|
||||
|
||||
renderWorkspaceCard()
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import LicenseNav from '@/app/components/header/license-env'
|
||||
import { buildIntegrationPath } from '@/app/components/integrations/routes'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@@ -259,26 +258,24 @@ export function WorkspaceCard() {
|
||||
const switchWorkspaceMutation = useMutation(consoleQuery.workspaces.switch.post.mutationOptions())
|
||||
const currentWorkspace = currentWorkspaceQuery.data
|
||||
const workspaces = workspacesQuery.data?.workspaces
|
||||
const { enableBilling } = useProviderContext()
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
|
||||
const showCloudBilling = deploymentEdition === 'CLOUD' && enableBilling
|
||||
const isCloudEdition = deploymentEdition === 'CLOUD'
|
||||
const prefetchWorkspaces = () => {
|
||||
void queryClient.prefetchQuery(workspacesQueryOptions)
|
||||
}
|
||||
|
||||
if (currentWorkspaceQuery.isPending || !currentWorkspace?.name) {
|
||||
return (
|
||||
<WorkspaceCardSkeleton
|
||||
showCloudBilling={showCloudBilling}
|
||||
showPlanAction={showCloudBilling}
|
||||
/>
|
||||
<WorkspaceCardSkeleton showCloudBilling={isCloudEdition} showPlanAction={isCloudEdition} />
|
||||
)
|
||||
}
|
||||
|
||||
const workspacePlan = isWorkspacePlan(currentWorkspace.plan) ? currentWorkspace.plan : null
|
||||
const isFreePlan = workspacePlan === Plan.sandbox
|
||||
const hasBillingPlan = typeof currentWorkspace.plan === 'string'
|
||||
const showCloudBilling = isCloudEdition && hasBillingPlan
|
||||
const showPlanAction = showCloudBilling && workspacePlan !== null
|
||||
const isFreePlan = workspacePlan === Plan.sandbox
|
||||
const planActionLabel = t(
|
||||
($) => $[isFreePlan ? 'upgradeBtn.encourageShort' : 'upgradeBtn.plain'],
|
||||
{ ns: 'billing' },
|
||||
@@ -286,7 +283,7 @@ export function WorkspaceCard() {
|
||||
const showInviteMembers = hasPermission(workspacePermissionKeys, 'workspace.member.manage')
|
||||
const renderWorkspaceStatus = () => {
|
||||
if (deploymentEdition === 'CLOUD')
|
||||
return enableBilling && workspacePlan ? <WorkspacePlanBadge plan={workspacePlan} /> : null
|
||||
return workspacePlan ? <WorkspacePlanBadge plan={workspacePlan} /> : null
|
||||
if (deploymentEdition === 'ENTERPRISE') return <LicenseNav />
|
||||
return null
|
||||
}
|
||||
@@ -333,7 +330,7 @@ export function WorkspaceCard() {
|
||||
onOpenSettings={() => {
|
||||
setOpen(false)
|
||||
setShowAccountSettingModal({
|
||||
payload: enableBilling ? ACCOUNT_SETTING_TAB.BILLING : ACCOUNT_SETTING_TAB.MEMBERS,
|
||||
payload: hasBillingPlan ? ACCOUNT_SETTING_TAB.BILLING : ACCOUNT_SETTING_TAB.MEMBERS,
|
||||
})
|
||||
}}
|
||||
onInviteMembers={() => {
|
||||
|
||||
@@ -51,6 +51,7 @@ const FeaturesTrigger = () => {
|
||||
const { plan, isFetchedPlan } = useProviderContext()
|
||||
const publishedAt = useStore((s) => s.publishedAt)
|
||||
const draftUpdatedAt = useStore((s) => s.draftUpdatedAt)
|
||||
const draftHash = useStore((s) => s.syncWorkflowDraftHash)
|
||||
const toolPublished = useStore((s) => s.toolPublished)
|
||||
const lastPublishedHasUserInput = useStore((s) => s.lastPublishedHasUserInput)
|
||||
|
||||
@@ -253,6 +254,7 @@ const FeaturesTrigger = () => {
|
||||
{...{
|
||||
publishedAt,
|
||||
draftUpdatedAt,
|
||||
draftHash,
|
||||
disabled: nodesReadOnly || !hasWorkflowNodes || !canReleaseAndVersion,
|
||||
toolPublished,
|
||||
inputs: variables,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { renderHook } from '@/test/console/render'
|
||||
import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data'
|
||||
import { AppACLPermission } from '@/utils/permission'
|
||||
import { useWorkflowInit } from '../use-workflow-init'
|
||||
|
||||
@@ -72,6 +72,10 @@ vi.mock('../use-workflow-template', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
appWorkflowQueryOptions: (appId: string) => ({
|
||||
queryKey: ['workflow', 'publish', appId],
|
||||
queryFn: () => mockFetchPublishedWorkflow(`/apps/${appId}/workflows/publish`),
|
||||
}),
|
||||
useWorkflowConfig: (_url: string, onSuccess: (config: Record<string, unknown>) => void) => {
|
||||
if (workflowConfigState.data) onSuccess(workflowConfigState.data)
|
||||
return workflowConfigState
|
||||
@@ -84,7 +88,6 @@ vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
|
||||
syncWorkflowDraft: (...args: unknown[]) => mockSyncWorkflowDraft(...args),
|
||||
fetchNodesDefaultConfigs: (...args: unknown[]) => mockFetchNodesDefaultConfigs(...args),
|
||||
fetchPublishedWorkflow: (...args: unknown[]) => mockFetchPublishedWorkflow(...args),
|
||||
}))
|
||||
|
||||
const notExistError = () => ({
|
||||
@@ -352,14 +355,45 @@ describe('useWorkflowInit', () => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should fall back to no published user input when preload requests fail', async () => {
|
||||
it('should keep published metadata when loading node defaults fails', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
mockFetchWorkflowDraft.mockReset().mockResolvedValue(draftResponse)
|
||||
mockFetchNodesDefaultConfigs.mockRejectedValue(new Error('preload failed'))
|
||||
mockFetchPublishedWorkflow.mockResolvedValue({
|
||||
created_at: 99,
|
||||
graph: {
|
||||
nodes: [{ id: 'start', data: { type: BlockEnum.Start } }],
|
||||
edges: [{ source: 'start', target: 'end' }],
|
||||
},
|
||||
})
|
||||
|
||||
renderHook(() => useWorkflowInit())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetPublishedAt).toHaveBeenCalledWith(99)
|
||||
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should keep node defaults when loading published metadata fails', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
mockFetchWorkflowDraft.mockReset().mockResolvedValue(draftResponse)
|
||||
mockFetchNodesDefaultConfigs.mockResolvedValue([
|
||||
{ type: 'start', config: { title: 'Start Config' } },
|
||||
])
|
||||
mockFetchPublishedWorkflow.mockRejectedValue(new Error('published workflow failed'))
|
||||
|
||||
renderHook(() => useWorkflowInit())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
|
||||
nodesDefaultConfigs: {
|
||||
start: { title: 'Start Config' },
|
||||
},
|
||||
})
|
||||
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Edge, Node } from '@/app/components/workflow/types'
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
@@ -8,13 +9,8 @@ import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { userProfileIdAtom } from '@/context/account-state'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { useWorkflowConfig } from '@/service/use-workflow'
|
||||
import {
|
||||
fetchNodesDefaultConfigs,
|
||||
fetchPublishedWorkflow,
|
||||
fetchWorkflowDraft,
|
||||
syncWorkflowDraft,
|
||||
} from '@/service/workflow'
|
||||
import { appWorkflowQueryOptions, useWorkflowConfig } from '@/service/use-workflow'
|
||||
import { fetchNodesDefaultConfigs, fetchWorkflowDraft, syncWorkflowDraft } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getAppACLCapabilities } from '@/utils/permission'
|
||||
import { useWorkflowDraftGraphForCanvas } from './use-workflow-draft-graph-for-canvas'
|
||||
@@ -58,6 +54,7 @@ const hasConnectedUserInput = (nodes: Node[] = [], edges: Edge[] = []): boolean
|
||||
}
|
||||
|
||||
export const useWorkflowInit = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { nodes: nodesTemplate, edges: edgesTemplate } = useWorkflowTemplate()
|
||||
const appDetail = useAppStore((state) => state.appDetail)!
|
||||
@@ -190,13 +187,13 @@ export const useWorkflowInit = () => {
|
||||
}, [])
|
||||
|
||||
const handleFetchPreloadData = useCallback(async () => {
|
||||
try {
|
||||
const nodesDefaultConfigsData = await fetchNodesDefaultConfigs(
|
||||
`/apps/${appDetail?.id}/workflows/default-workflow-block-configs`,
|
||||
)
|
||||
const publishedWorkflow = await fetchPublishedWorkflow(
|
||||
`/apps/${appDetail?.id}/workflows/publish`,
|
||||
)
|
||||
const [nodesDefaultConfigsResult, publishedWorkflowResult] = await Promise.allSettled([
|
||||
fetchNodesDefaultConfigs(`/apps/${appDetail.id}/workflows/default-workflow-block-configs`),
|
||||
queryClient.fetchQuery(appWorkflowQueryOptions(appDetail.id)),
|
||||
])
|
||||
|
||||
if (nodesDefaultConfigsResult.status === 'fulfilled') {
|
||||
const nodesDefaultConfigsData = nodesDefaultConfigsResult.value
|
||||
workflowStore.setState({
|
||||
nodesDefaultConfigs: nodesDefaultConfigsData.reduce(
|
||||
(acc, block) => {
|
||||
@@ -206,16 +203,22 @@ export const useWorkflowInit = () => {
|
||||
{} as Record<string, unknown>,
|
||||
),
|
||||
})
|
||||
} else {
|
||||
console.error(nodesDefaultConfigsResult.reason)
|
||||
}
|
||||
|
||||
if (publishedWorkflowResult.status === 'fulfilled') {
|
||||
const publishedWorkflow = publishedWorkflowResult.value
|
||||
workflowStore.getState().setPublishedAt(publishedWorkflow?.created_at ?? 0)
|
||||
const graph = publishedWorkflow?.graph
|
||||
workflowStore
|
||||
.getState()
|
||||
.setLastPublishedHasUserInput(hasConnectedUserInput(graph?.nodes, graph?.edges))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} else {
|
||||
console.error(publishedWorkflowResult.reason)
|
||||
workflowStore.getState().setLastPublishedHasUserInput(false)
|
||||
}
|
||||
}, [workflowStore, appDetail])
|
||||
}, [workflowStore, appDetail, queryClient])
|
||||
|
||||
useEffect(() => {
|
||||
handleFetchPreloadData()
|
||||
|
||||
@@ -11,6 +11,8 @@ const mockHandleLoadBackupDraft = vi.fn()
|
||||
const mockHandleRefreshWorkflowDraft = vi.fn()
|
||||
const mockHandleExportDSL = vi.fn()
|
||||
const mockRestoreWorkflow = vi.fn()
|
||||
const mockUpdateWorkflow = vi.fn()
|
||||
const mockInvalidateAppWorkflow = vi.fn()
|
||||
const mockSetCurrentVersion = vi.fn()
|
||||
const mockSetShowWorkflowVersionHistoryPanel = vi.fn()
|
||||
const mockWorkflowStoreSetState = vi.fn()
|
||||
@@ -83,10 +85,11 @@ vi.mock('@/context/provider-context', () => ({
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useDeleteWorkflow: () => ({ mutateAsync: vi.fn() }),
|
||||
useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow,
|
||||
useInvalidAllLastRun: () => vi.fn(),
|
||||
useResetWorkflowVersionHistory: () => vi.fn(),
|
||||
useRestoreWorkflow: () => ({ mutateAsync: mockRestoreWorkflow }),
|
||||
useUpdateWorkflow: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateWorkflow: () => ({ mutateAsync: mockUpdateWorkflow }),
|
||||
useWorkflowVersionHistory: () => ({
|
||||
data: {
|
||||
pages: [
|
||||
@@ -128,10 +131,19 @@ vi.mock('../../../hooks/use-workflow-run', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks-store', () => ({
|
||||
useHooksStore: () => ({
|
||||
flowId: 'test-flow-id',
|
||||
flowType: 'workflow',
|
||||
}),
|
||||
useHooksStore: (
|
||||
selector: (state: {
|
||||
accessControl: { canImportExportDSL: boolean }
|
||||
configsMap: { flowId: string; flowType: string }
|
||||
}) => unknown,
|
||||
) =>
|
||||
selector({
|
||||
accessControl: { canImportExportDSL: true },
|
||||
configsMap: {
|
||||
flowId: 'app-1',
|
||||
flowType: 'appFlow',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../collaboration/core/collaboration-manager', () => ({
|
||||
@@ -180,7 +192,25 @@ vi.mock('../restore-confirm-modal', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({
|
||||
default: () => null,
|
||||
default: ({
|
||||
versionInfo,
|
||||
onPublish,
|
||||
}: {
|
||||
versionInfo: VersionHistory
|
||||
onPublish: (params: { id?: string; title: string; releaseNotes: string }) => Promise<void>
|
||||
}) => (
|
||||
<button
|
||||
onClick={() =>
|
||||
onPublish({
|
||||
id: versionInfo.id,
|
||||
title: 'Updated release',
|
||||
releaseNotes: 'Updated notes',
|
||||
})
|
||||
}
|
||||
>
|
||||
submit version info
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../version-history-item', () => ({
|
||||
@@ -213,6 +243,11 @@ vi.mock('../version-history-item', () => ({
|
||||
>
|
||||
{`export-${item.id}`}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.edit)}
|
||||
>
|
||||
{`edit-${item.id}`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -227,6 +262,7 @@ describe('VersionHistoryPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRestoreWorkflow.mockResolvedValue(undefined)
|
||||
mockUpdateWorkflow.mockResolvedValue(undefined)
|
||||
mockCurrentVersion = null
|
||||
mockPlanType = Plan.professional
|
||||
mockEnableBilling = true
|
||||
@@ -369,4 +405,35 @@ describe('VersionHistoryPanel', () => {
|
||||
expect(mockSetCurrentVersion).not.toHaveBeenCalled()
|
||||
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should refresh the published workflow after editing the latest app version', async () => {
|
||||
mockUpdateWorkflow.mockImplementation(
|
||||
async (
|
||||
_params,
|
||||
options?: {
|
||||
onSuccess?: () => void
|
||||
onSettled?: () => void
|
||||
},
|
||||
) => {
|
||||
options?.onSuccess?.()
|
||||
options?.onSettled?.()
|
||||
},
|
||||
)
|
||||
const { VersionHistoryPanel } = await import('../index')
|
||||
|
||||
render(
|
||||
<VersionHistoryPanel
|
||||
latestVersionId="published-version-id"
|
||||
restoreVersionUrl={(versionId) => `/apps/app-1/workflows/${versionId}/restore`}
|
||||
updateVersionUrl={(versionId) => `/apps/app-1/workflows/${versionId}`}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('edit-published-version-id'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit version info' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidateAppWorkflow).toHaveBeenCalledWith('app-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+18
@@ -83,6 +83,23 @@ describe('VersionHistoryItem', () => {
|
||||
|
||||
// Published items should expose metadata and the hover context menu.
|
||||
describe('Published Items', () => {
|
||||
it('should show the mocked deployed environments', () => {
|
||||
render(
|
||||
<VersionHistoryItem
|
||||
item={createVersionHistory()}
|
||||
currentVersion={null}
|
||||
latestVersionId="version-1"
|
||||
onClick={vi.fn()}
|
||||
handleClickActionMenuItem={vi.fn()}
|
||||
canImportExportDSL
|
||||
isLast={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Pre-release')).toBeInTheDocument()
|
||||
expect(screen.getByText('QA')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the context menu for a latest named version and forward restore', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleClickActionMenuItem = vi.fn()
|
||||
@@ -116,6 +133,7 @@ describe('VersionHistoryItem', () => {
|
||||
expect(screen.getByText('workflow.versionHistory.editVersionInfo')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.export')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.versionHistory.copyId')).toBeInTheDocument()
|
||||
expect(screen.getByText('version-1')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
|
||||
const restoreItem = screen.getByText('workflow.common.restore').closest('.cursor-pointer')
|
||||
|
||||
+6
-1
@@ -43,6 +43,7 @@ describe('ActionMenu', () => {
|
||||
|
||||
renderActionMenu(
|
||||
<ActionMenu
|
||||
workflowId="version-1"
|
||||
isNamedVersion
|
||||
isShowDelete
|
||||
canImportExportDSL
|
||||
@@ -52,7 +53,10 @@ describe('ActionMenu', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
const trigger = screen.getByRole('button', { name: 'common.operation.more' })
|
||||
expect(trigger).not.toHaveAttribute('role')
|
||||
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByText('workflow.common.restore'))
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
@@ -74,6 +78,7 @@ describe('ActionMenu', () => {
|
||||
|
||||
renderActionMenu(
|
||||
<ActionMenu
|
||||
workflowId="version-1"
|
||||
isNamedVersion
|
||||
isShowDelete
|
||||
canImportExportDSL
|
||||
|
||||
+4
@@ -6,6 +6,7 @@ describe('useActionMenu', () => {
|
||||
it('returns restore, edit, export, copy and delete operations for app workflows', () => {
|
||||
const { result } = renderWorkflowHook(() =>
|
||||
useActionMenu({
|
||||
workflowId: 'version-1',
|
||||
isNamedVersion: true,
|
||||
canImportExportDSL: true,
|
||||
isShowDelete: false,
|
||||
@@ -31,6 +32,7 @@ describe('useActionMenu', () => {
|
||||
const { result } = renderWorkflowHook(
|
||||
() =>
|
||||
useActionMenu({
|
||||
workflowId: 'version-1',
|
||||
isNamedVersion: false,
|
||||
canImportExportDSL: true,
|
||||
isShowDelete: true,
|
||||
@@ -57,6 +59,7 @@ describe('useActionMenu', () => {
|
||||
{
|
||||
key: VersionHistoryContextMenuOptions.copyId,
|
||||
name: 'workflow.versionHistory.copyId',
|
||||
description: 'version-1',
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -64,6 +67,7 @@ describe('useActionMenu', () => {
|
||||
it('omits export when import/export DSL permission is missing', () => {
|
||||
const { result } = renderWorkflowHook(() =>
|
||||
useActionMenu({
|
||||
workflowId: 'version-1',
|
||||
isNamedVersion: true,
|
||||
canImportExportDSL: false,
|
||||
isShowDelete: false,
|
||||
|
||||
+13
-2
@@ -9,6 +9,7 @@ type ActionMenuItemProps = {
|
||||
item: {
|
||||
key: VersionHistoryContextMenuOptions
|
||||
name: string
|
||||
description?: string
|
||||
showUpgrade?: boolean
|
||||
}
|
||||
onClick: (operation: VersionHistoryContextMenuOptions) => void
|
||||
@@ -21,6 +22,7 @@ const ActionMenuItem: FC<ActionMenuItemProps> = ({ item, onClick, isDestructive
|
||||
variant={isDestructive ? 'destructive' : 'default'}
|
||||
className={cn(
|
||||
'justify-between gap-x-3 px-2 py-1.5 whitespace-nowrap',
|
||||
item.description && 'h-auto py-1',
|
||||
isDestructive && 'data-highlighted:bg-state-destructive-hover',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
@@ -33,11 +35,20 @@ const ActionMenuItem: FC<ActionMenuItemProps> = ({ item, onClick, isDestructive
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 system-md-regular whitespace-nowrap text-text-primary',
|
||||
'min-w-0 flex-1 system-md-regular whitespace-nowrap text-text-primary',
|
||||
item.description && 'flex flex-col gap-y-0.5 px-1 py-0.5 text-text-secondary',
|
||||
isDestructive && 'text-inherit',
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
<div className="w-full truncate">{item.name}</div>
|
||||
{item.description && (
|
||||
<div
|
||||
className="w-full max-w-[152px] truncate system-2xs-regular text-text-tertiary"
|
||||
title={item.description}
|
||||
>
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.showUpgrade && (
|
||||
<div data-upgrade-action className="shrink-0">
|
||||
|
||||
@@ -6,13 +6,14 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { VersionHistoryContextMenuOptions } from '../../../types'
|
||||
import ActionMenuItem from './action-menu-item'
|
||||
import useActionMenu from './use-action-menu'
|
||||
|
||||
export type ActionMenuProps = {
|
||||
workflowId: string
|
||||
isShowDelete: boolean
|
||||
isNamedVersion: boolean
|
||||
canImportExportDSL: boolean
|
||||
@@ -24,21 +25,21 @@ export type ActionMenuProps = {
|
||||
const ActionMenu: FC<ActionMenuProps> = (props: ActionMenuProps) => {
|
||||
const { isShowDelete, handleClickActionMenuItem, open, setOpen } = props
|
||||
const { deleteOperation, options } = useActionMenu(props)
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Button
|
||||
nativeButton={false}
|
||||
size="small"
|
||||
className="px-1"
|
||||
aria-label={t(($) => $['operation.more'], { ns: 'common' })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<RiMoreFill className="size-4" />
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
@@ -54,7 +55,7 @@ const ActionMenu: FC<ActionMenuProps> = (props: ActionMenuProps) => {
|
||||
))}
|
||||
{isShowDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
<DropdownMenuSeparator className="my-1" />
|
||||
<ActionMenuItem
|
||||
item={deleteOperation}
|
||||
isDestructive
|
||||
|
||||
+3
-2
@@ -7,7 +7,7 @@ import { useProviderContext } from '@/context/provider-context'
|
||||
import { VersionHistoryContextMenuOptions } from '../../../types'
|
||||
|
||||
const useActionMenu = (props: ActionMenuProps) => {
|
||||
const { isNamedVersion, canImportExportDSL } = props
|
||||
const { workflowId, isNamedVersion, canImportExportDSL } = props
|
||||
const { t } = useTranslation()
|
||||
const pipelineId = useStore((s) => s.pipelineId)
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
@@ -47,9 +47,10 @@ const useActionMenu = (props: ActionMenuProps) => {
|
||||
{
|
||||
key: VersionHistoryContextMenuOptions.copyId,
|
||||
name: t(($) => $['versionHistory.copyId'], { ns: 'workflow' }),
|
||||
description: workflowId,
|
||||
},
|
||||
]
|
||||
}, [canImportExportDSL, isNamedVersion, pipelineId, shouldShowUpgrade, shouldShowUpgrade, t])
|
||||
}, [canImportExportDSL, isNamedVersion, pipelineId, shouldShowUpgrade, t, workflowId])
|
||||
|
||||
return {
|
||||
deleteOperation,
|
||||
|
||||
@@ -16,11 +16,13 @@ import { useProviderContext } from '@/context/provider-context'
|
||||
import {
|
||||
useDeleteWorkflow,
|
||||
useInvalidAllLastRun,
|
||||
useInvalidateAppWorkflow,
|
||||
useResetWorkflowVersionHistory,
|
||||
useRestoreWorkflow,
|
||||
useUpdateWorkflow,
|
||||
useWorkflowVersionHistory,
|
||||
} from '@/service/use-workflow'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { useHooksStore } from '../../hooks-store'
|
||||
import { useDSL } from '../../hooks/use-DSL'
|
||||
import { useWorkflowRefreshDraft } from '../../hooks/use-workflow-refresh-draft'
|
||||
@@ -76,6 +78,7 @@ export const VersionHistoryPanel = ({
|
||||
const configsMap = useHooksStore((s) => s.configsMap)
|
||||
const canImportExportDSL = useHooksStore((s) => s.accessControl.canImportExportDSL)
|
||||
const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId)
|
||||
const invalidateAppWorkflow = useInvalidateAppWorkflow()
|
||||
const { deleteAllInspectVars } = workflowStore.getState()
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -314,7 +317,12 @@ export const VersionHistoryPanel = ({
|
||||
onSuccess: () => {
|
||||
setEditModalOpen(false)
|
||||
toast.success(t(($) => $['versionHistory.action.updateSuccess'], { ns: 'workflow' }))
|
||||
resetWorkflowVersionHistory()
|
||||
if (
|
||||
id === latestVersionId &&
|
||||
configsMap?.flowType === FlowType.appFlow &&
|
||||
configsMap.flowId
|
||||
)
|
||||
invalidateAppWorkflow(configsMap.flowId)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['versionHistory.action.updateFailure'], { ns: 'workflow' }))
|
||||
@@ -325,7 +333,15 @@ export const VersionHistoryPanel = ({
|
||||
},
|
||||
)
|
||||
},
|
||||
[t, updateWorkflow, resetWorkflowVersionHistory, updateVersionUrl],
|
||||
[
|
||||
configsMap?.flowId,
|
||||
configsMap?.flowType,
|
||||
invalidateAppWorkflow,
|
||||
latestVersionId,
|
||||
t,
|
||||
updateWorkflow,
|
||||
updateVersionUrl,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import dayjs from 'dayjs'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge/index'
|
||||
import { WorkflowVersion } from '../../types'
|
||||
import ActionMenu from './action-menu'
|
||||
|
||||
@@ -19,6 +20,22 @@ type VersionHistoryItemProps = {
|
||||
hideActionMenu?: boolean
|
||||
}
|
||||
|
||||
type VersionEnvironment = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const MOCK_ENVIRONMENTS: VersionEnvironment[] = [
|
||||
{
|
||||
id: 'mock-pre-release',
|
||||
name: 'Pre-release',
|
||||
},
|
||||
{
|
||||
id: 'mock-qa',
|
||||
name: 'QA',
|
||||
},
|
||||
]
|
||||
|
||||
const formatVersion = (versionHistory: VersionHistory, latestVersionId: string): string => {
|
||||
const { version, id } = versionHistory
|
||||
if (version === WorkflowVersion.Draft) return WorkflowVersion.Draft
|
||||
@@ -53,6 +70,8 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
|
||||
const isSelected = item.version === currentVersion?.version
|
||||
const isDraft = formattedVersion === WorkflowVersion.Draft
|
||||
const isLatest = formattedVersion === WorkflowVersion.Latest
|
||||
// TODO: Replace with item.environments when the version history API exposes deployment data.
|
||||
const deployedEnvironments = MOCK_ENVIRONMENTS
|
||||
|
||||
useEffect(() => {
|
||||
if (isDraft) onClick(item)
|
||||
@@ -87,7 +106,7 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
|
||||
{!isLast && (
|
||||
<div className="absolute top-6 left-4 h-[calc(100%-0.75rem)] w-0.5 bg-divider-subtle" />
|
||||
)}
|
||||
<div className="flex h-5 w-[18px] shrink-0 items-center justify-center">
|
||||
<div className="flex h-5 w-4.5 shrink-0 items-center justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
'size-2 rounded-lg border-2',
|
||||
@@ -123,11 +142,25 @@ const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({
|
||||
{`${formatTime(item.created_at)} · ${item.created_by.name}`}
|
||||
</div>
|
||||
)}
|
||||
{!isDraft && deployedEnvironments.length > 0 && (
|
||||
<div className="flex w-full flex-wrap content-start items-start gap-x-1 gap-y-2 pt-0.5">
|
||||
{deployedEnvironments.map((environment) => (
|
||||
<Badge
|
||||
key={environment.id}
|
||||
size="s"
|
||||
className="h-4.5 shrink-0 bg-components-badge-bg-dimm py-0!"
|
||||
>
|
||||
{environment.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Action Menu */}
|
||||
{!hideActionMenu && !isDraft && isHovering && (
|
||||
<div className="absolute top-1 right-1">
|
||||
<ActionMenu
|
||||
workflowId={item.id}
|
||||
isShowDelete={!isLatest}
|
||||
isNamedVersion={!!item.marked_name}
|
||||
canImportExportDSL={canImportExportDSL}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import InstallBundle from '@/app/components/plugins/install-plugin/install-bundle'
|
||||
import { useStore } from './store'
|
||||
|
||||
@@ -381,6 +381,40 @@ describe('agent composer store conversions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve a plugin tool identity when hydrating and publishing imported config', () => {
|
||||
const baseConfig = {
|
||||
tools: {
|
||||
dify_tools: [
|
||||
{
|
||||
plugin_id: 'langgenius/google',
|
||||
provider_id: 'langgenius/google/google',
|
||||
provider_type: 'plugin',
|
||||
tool_name: 'search',
|
||||
credential_type: 'unauthorized',
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies AgentSoulConfig
|
||||
|
||||
const formState = agentSoulConfigToFormState(baseConfig)
|
||||
const publishConfig = formStateToAgentSoulConfig({ baseConfig, formState })
|
||||
|
||||
expect(formState.tools).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'langgenius/google/google',
|
||||
name: 'google',
|
||||
pluginId: 'langgenius/google',
|
||||
}),
|
||||
])
|
||||
expect(publishConfig.tools?.dify_tools).toEqual([
|
||||
expect.objectContaining({
|
||||
plugin_id: 'langgenius/google',
|
||||
provider: 'google',
|
||||
provider_id: 'langgenius/google/google',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should hydrate legacy secret refs from ref when value is absent', () => {
|
||||
const formState = agentSoulConfigToFormState({
|
||||
env: {
|
||||
|
||||
@@ -197,8 +197,21 @@ const toToolRuntimeParameters = (settings: Record<string, unknown> | undefined)
|
||||
return runtimeParameters
|
||||
}
|
||||
|
||||
const getDifyToolProviderId = (tool: AgentSoulDifyToolConfig) =>
|
||||
tool.provider_id ??
|
||||
(tool.plugin_id && tool.provider
|
||||
? `${tool.plugin_id}/${tool.provider}`
|
||||
: (tool.provider ?? tool.plugin_id ?? ''))
|
||||
|
||||
const getDifyToolProviderName = (tool: AgentSoulDifyToolConfig) => {
|
||||
if (tool.provider) return tool.provider
|
||||
|
||||
const providerIdSegments = getDifyToolProviderId(tool).split('/').filter(Boolean)
|
||||
return providerIdSegments.at(-1) ?? ''
|
||||
}
|
||||
|
||||
const getDifyToolActionId = (tool: AgentSoulDifyToolConfig) =>
|
||||
`${tool.provider_id ?? tool.provider ?? tool.plugin_id ?? 'provider'}:${tool.tool_name ?? tool.name ?? 'tool'}`
|
||||
`${getDifyToolProviderId(tool) || 'provider'}:${tool.tool_name ?? tool.name ?? 'tool'}`
|
||||
|
||||
const toCredentialVariant = (tool: AgentSoulDifyToolConfig) => {
|
||||
const credentialType = tool.credential_type
|
||||
@@ -227,7 +240,7 @@ const toProviderToolFormState = (
|
||||
const toolSettings: AgentSoulConfigFormState['toolSettings'] = {}
|
||||
|
||||
for (const tool of config?.tools?.dify_tools ?? []) {
|
||||
const providerId = tool.provider_id ?? tool.provider ?? tool.plugin_id ?? ''
|
||||
const providerId = getDifyToolProviderId(tool)
|
||||
const toolName = tool.tool_name ?? tool.name ?? ''
|
||||
if (!providerId || !toolName) continue
|
||||
|
||||
@@ -249,8 +262,9 @@ const toProviderToolFormState = (
|
||||
|
||||
toolByProviderId.set(providerId, {
|
||||
id: providerId,
|
||||
name: tool.provider ?? providerId,
|
||||
name: getDifyToolProviderName(tool),
|
||||
kind: 'provider',
|
||||
pluginId: tool.plugin_id ?? undefined,
|
||||
iconClassName: 'i-custom-public-other-default-tool-icon text-text-tertiary',
|
||||
providerType: tool.provider_type,
|
||||
allowDelete:
|
||||
@@ -287,6 +301,7 @@ const toDifyToolConfigs = (
|
||||
enabled: true,
|
||||
provider: tool.name,
|
||||
provider_id: tool.id,
|
||||
plugin_id: tool.pluginId,
|
||||
provider_type: tool.providerType,
|
||||
tool_name: action.toolName,
|
||||
runtime_parameters: toToolRuntimeParameters(toolSettings[action.id]),
|
||||
|
||||
@@ -93,6 +93,8 @@ type AgentProviderToolCredentialType = 'api-key' | 'oauth2' | 'unauthorized'
|
||||
export type AgentProviderTool = AgentToolBase & {
|
||||
kind: 'provider'
|
||||
displayName?: string
|
||||
pluginId?: string
|
||||
pluginUniqueIdentifier?: string
|
||||
iconClassName: string
|
||||
icon?: ToolDefaultValue['provider_icon']
|
||||
iconDark?: ToolDefaultValue['provider_icon_dark']
|
||||
|
||||
@@ -88,6 +88,9 @@ export const addProviderTools = (
|
||||
nextTools[existingToolIndex] = {
|
||||
...existingTool,
|
||||
displayName: existingTool.displayName ?? selectedTool.provider_show_name,
|
||||
pluginId: existingTool.pluginId ?? selectedTool.plugin_id,
|
||||
pluginUniqueIdentifier:
|
||||
existingTool.pluginUniqueIdentifier ?? selectedTool.plugin_unique_identifier,
|
||||
icon: existingTool.icon ?? selectedTool.provider_icon,
|
||||
iconDark: existingTool.iconDark ?? selectedTool.provider_icon_dark,
|
||||
allowDelete: existingTool.allowDelete ?? selectedTool.allowDelete,
|
||||
@@ -101,6 +104,8 @@ export const addProviderTools = (
|
||||
name: selectedTool.provider_name,
|
||||
kind: 'provider',
|
||||
displayName: selectedTool.provider_show_name,
|
||||
pluginId: selectedTool.plugin_id,
|
||||
pluginUniqueIdentifier: selectedTool.plugin_unique_identifier,
|
||||
iconClassName: 'i-custom-public-other-default-tool-icon text-text-tertiary',
|
||||
icon: selectedTool.provider_icon,
|
||||
iconDark: selectedTool.provider_icon_dark,
|
||||
|
||||
+165
-2
@@ -2,7 +2,7 @@ import type { AddOAuthButtonProps, Credential } from '@/app/components/plugins/p
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -19,7 +19,7 @@ import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
|
||||
import { AgentTools } from '../index'
|
||||
|
||||
const toolProviderState = vi.hoisted(() => ({
|
||||
builtInTools: [] as ToolWithProvider[],
|
||||
builtInTools: [] as ToolWithProvider[] | undefined,
|
||||
}))
|
||||
const pluginAuthState = vi.hoisted(() => ({
|
||||
canOAuth: true as boolean | undefined,
|
||||
@@ -28,6 +28,21 @@ const pluginAuthState = vi.hoisted(() => ({
|
||||
notAllowCustomCredential: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
}))
|
||||
const pluginInstallState = vi.hoisted(() => ({
|
||||
manifest: undefined as
|
||||
| {
|
||||
label: Record<string, string>
|
||||
latest_package_identifier: string
|
||||
}
|
||||
| undefined,
|
||||
fallbackManifest: undefined as
|
||||
| {
|
||||
latest_package_identifier: string
|
||||
}
|
||||
| undefined,
|
||||
invalidateBuiltInTools: vi.fn(),
|
||||
invalidateInstalledPluginList: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/tool-picker', () => ({
|
||||
ToolPickerContent: () => <div>Mock tool picker</div>,
|
||||
@@ -48,6 +63,54 @@ vi.mock('@/app/components/workflow/block-icon', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
|
||||
InstallPluginButton: ({
|
||||
uniqueIdentifier,
|
||||
onSuccess,
|
||||
}: {
|
||||
uniqueIdentifier: string
|
||||
onSuccess?: () => void
|
||||
}) => (
|
||||
<button type="button" data-unique-identifier={uniqueIdentifier} onClick={onSuccess}>
|
||||
workflow.nodes.agent.pluginInstaller.install
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInvalidateInstalledPluginList: () => pluginInstallState.invalidateInstalledPluginList,
|
||||
useFetchPluginsInMarketPlaceByInfo: (infos: Array<{ organization: string; plugin: string }>) => ({
|
||||
data:
|
||||
infos.length > 0 && pluginInstallState.manifest
|
||||
? {
|
||||
data: {
|
||||
list: infos.map(({ organization, plugin }) => ({
|
||||
plugin: {
|
||||
...pluginInstallState.manifest,
|
||||
name: plugin,
|
||||
plugin_id: `${organization}/${plugin}`,
|
||||
},
|
||||
})),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
usePluginManifestInfo: (pluginId: string) => ({
|
||||
data:
|
||||
pluginId && pluginInstallState.fallbackManifest
|
||||
? {
|
||||
data: {
|
||||
plugin: pluginInstallState.fallbackManifest,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/get-icon', () => ({
|
||||
getIconFromMarketPlace: (pluginId: string) => `https://marketplace.example.com/${pluginId}/icon`,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-auth/authorize/add-oauth-button', () => ({
|
||||
default: ({ buttonText, onUpdate, renderTrigger }: AddOAuthButtonProps) => {
|
||||
if (renderTrigger) {
|
||||
@@ -99,6 +162,7 @@ vi.mock('@/service/use-tools', () => ({
|
||||
useAllCustomTools: () => ({ data: [] }),
|
||||
useAllWorkflowTools: () => ({ data: [] }),
|
||||
useAllMCPTools: () => ({ data: [] }),
|
||||
useInvalidateAllBuiltInTools: () => pluginInstallState.invalidateBuiltInTools,
|
||||
useInvalidToolsByType: () => vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -181,6 +245,30 @@ const reflectedUnauthorizedNoCredentialDraft = {
|
||||
],
|
||||
} satisfies AgentSoulConfigFormState
|
||||
|
||||
const reflectedUninstalledPluginDraft = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
tools: [
|
||||
{
|
||||
id: 'langgenius/google/google',
|
||||
kind: 'provider',
|
||||
name: 'langgenius/google/google',
|
||||
pluginId: 'langgenius/google',
|
||||
iconClassName: 'i-custom-public-other-default-tool-icon',
|
||||
providerType: 'plugin',
|
||||
credentialType: 'unauthorized',
|
||||
credentialVariant: 'unauthorized',
|
||||
actions: [
|
||||
{
|
||||
id: 'langgenius/google/google:search',
|
||||
name: 'search',
|
||||
toolName: 'search',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies AgentSoulConfigFormState
|
||||
|
||||
const reflectedUnauthorizedOAuthCredentialTypeDraft = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
tools: [
|
||||
@@ -369,6 +457,10 @@ describe('AgentTools', () => {
|
||||
pluginAuthState.canApiKey = false
|
||||
pluginAuthState.credentials = []
|
||||
pluginAuthState.notAllowCustomCredential = false
|
||||
pluginInstallState.manifest = undefined
|
||||
pluginInstallState.fallbackManifest = undefined
|
||||
pluginInstallState.invalidateBuiltInTools.mockResolvedValue(undefined)
|
||||
pluginInstallState.invalidateInstalledPluginList.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
@@ -528,6 +620,77 @@ describe('AgentTools', () => {
|
||||
expect(screen.getByText('Google Search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should let users install a missing provider and show its marketplace icon', async () => {
|
||||
const user = userEvent.setup()
|
||||
pluginInstallState.manifest = {
|
||||
label: {
|
||||
en_US: 'Google Tools',
|
||||
},
|
||||
latest_package_identifier: 'langgenius/google:1.0.0@checksum',
|
||||
}
|
||||
renderAgentTools(reflectedUninstalledPluginDraft)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Google Tools' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('https://marketplace.example.com/langgenius/google/icon'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'tools.notAuthorized',
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
const installButton = screen.getByRole('button', {
|
||||
name: 'workflow.nodes.agent.pluginInstaller.install',
|
||||
})
|
||||
expect(installButton).toHaveAttribute(
|
||||
'data-unique-identifier',
|
||||
'langgenius/google:1.0.0@checksum',
|
||||
)
|
||||
|
||||
await user.click(installButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pluginInstallState.invalidateBuiltInTools).toHaveBeenCalledTimes(1)
|
||||
expect(pluginInstallState.invalidateInstalledPluginList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep install actionable when batch marketplace metadata is unavailable', () => {
|
||||
pluginInstallState.fallbackManifest = {
|
||||
latest_package_identifier: 'langgenius/google:0.0.1@fallback',
|
||||
}
|
||||
renderAgentTools(reflectedUninstalledPluginDraft)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'google',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('https://marketplace.example.com/langgenius/google/icon'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'workflow.nodes.agent.pluginInstaller.install',
|
||||
}),
|
||||
).toHaveAttribute('data-unique-identifier', 'langgenius/google:0.0.1@fallback')
|
||||
})
|
||||
|
||||
it('should wait for the provider catalog before showing an uninstalled status', () => {
|
||||
toolProviderState.builtInTools = undefined
|
||||
renderAgentTools(reflectedUnauthorizedNoCredentialDraft)
|
||||
|
||||
expect(
|
||||
screen.queryByText('plugin.detailPanel.toolSelector.uninstalledTitle'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'tools.notAuthorized',
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide unauthorized status when reflected provider tools do not require credentials', () => {
|
||||
toolProviderState.builtInTools = [duckDuckGoProvider]
|
||||
renderAgentTools(reflectedUnauthorizedNoCredentialDraft)
|
||||
|
||||
+138
-11
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { MarketplacePlugin } from '@dify/contracts/marketplace'
|
||||
import type { AgentOrchestrateAddActionOptions } from '../add-actions-context'
|
||||
import type { ToolSettingTarget } from './types'
|
||||
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||
@@ -25,12 +26,18 @@ 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 {
|
||||
useFetchPluginsInMarketPlaceByInfo,
|
||||
useInvalidateInstalledPluginList,
|
||||
} from '@/service/use-plugins'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
useInvalidateAllBuiltInTools,
|
||||
} from '@/service/use-tools'
|
||||
import { getIconFromMarketPlace } from '@/utils/get-icon'
|
||||
import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context'
|
||||
import { ConfigureSectionAddButton } from '../common/add-button'
|
||||
import { ConfigureSectionEmpty } from '../common/empty'
|
||||
@@ -47,6 +54,12 @@ import {
|
||||
import { ProviderToolSettingsDialog } from './provider-tool/dialog'
|
||||
import { AgentProviderToolItem } from './provider-tool/item'
|
||||
|
||||
type DisplayAgentProviderTool = AgentProviderTool & {
|
||||
isInstalled?: boolean
|
||||
}
|
||||
|
||||
type DisplayAgentTool = AgentCliTool | DisplayAgentProviderTool
|
||||
|
||||
const AgentToolItem = memo(
|
||||
({
|
||||
tool,
|
||||
@@ -56,8 +69,9 @@ const AgentToolItem = memo(
|
||||
onDeleteProviderToolAction,
|
||||
onEditCliTool,
|
||||
onCredentialChange,
|
||||
onPluginInstalled,
|
||||
}: {
|
||||
tool: AgentTool
|
||||
tool: DisplayAgentTool
|
||||
onConfigureAction: (target: ToolSettingTarget) => void
|
||||
onDeleteCliTool: (toolId: string) => void
|
||||
onDeleteProviderTool: (toolId: string) => void
|
||||
@@ -68,6 +82,7 @@ const AgentToolItem = memo(
|
||||
credentialId?: string,
|
||||
credentialType?: AgentProviderTool['credentialType'],
|
||||
) => void
|
||||
onPluginInstalled: () => void
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
@@ -101,12 +116,14 @@ const AgentToolItem = memo(
|
||||
return (
|
||||
<AgentProviderToolItem
|
||||
tool={tool}
|
||||
isInstalled={tool.isInstalled}
|
||||
isExpanded={isExpanded}
|
||||
onOpenChange={setIsExpanded}
|
||||
onConfigureAction={onConfigureAction}
|
||||
onRemoveAction={handleRemoveProviderAction}
|
||||
onRemoveProvider={handleRemoveProvider}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
onInstall={onPluginInstalled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -125,6 +142,7 @@ function useAgentToolProviderMap() {
|
||||
|
||||
return useMemo(() => {
|
||||
const providers = new Map<string, ToolWithProvider>()
|
||||
const resolvedProviderTypes = new Set<AgentProviderTool['providerType']>()
|
||||
const buildInToolList = Array.isArray(buildInTools) ? buildInTools : []
|
||||
const customToolList = Array.isArray(customTools) ? customTools : []
|
||||
const workflowToolList = Array.isArray(workflowTools) ? workflowTools : []
|
||||
@@ -136,6 +154,14 @@ function useAgentToolProviderMap() {
|
||||
...mcpToolList,
|
||||
]
|
||||
|
||||
if (Array.isArray(buildInTools)) {
|
||||
resolvedProviderTypes.add(CollectionType.builtIn)
|
||||
resolvedProviderTypes.add('plugin')
|
||||
}
|
||||
if (Array.isArray(customTools)) resolvedProviderTypes.add(CollectionType.custom)
|
||||
if (Array.isArray(workflowTools)) resolvedProviderTypes.add(CollectionType.workflow)
|
||||
if (Array.isArray(mcpTools)) resolvedProviderTypes.add(CollectionType.mcp)
|
||||
|
||||
allProviders.forEach((provider) => {
|
||||
providers.set(provider.id, provider)
|
||||
providers.set(provider.name, provider)
|
||||
@@ -145,14 +171,43 @@ function useAgentToolProviderMap() {
|
||||
}
|
||||
})
|
||||
|
||||
return providers
|
||||
return {
|
||||
providerById: providers,
|
||||
resolvedProviderTypes,
|
||||
}
|
||||
}, [buildInTools, customTools, workflowTools, mcpTools])
|
||||
}
|
||||
|
||||
function getLocalizedText(text: Record<string, string> | undefined, language: string) {
|
||||
function getLocalizedText(text: Partial<Record<string, string>> | undefined, language: string) {
|
||||
return text?.[language] ?? text?.en_US ?? text?.zh_Hans
|
||||
}
|
||||
|
||||
function getProviderPluginId(tool: AgentProviderTool) {
|
||||
if (tool.pluginId) return tool.pluginId
|
||||
|
||||
if (tool.providerType !== 'plugin' && tool.providerType !== CollectionType.builtIn) return ''
|
||||
|
||||
const providerIdSegments = tool.id.split('/')
|
||||
if (providerIdSegments.length !== 3) return ''
|
||||
|
||||
return providerIdSegments.slice(0, 2).join('/')
|
||||
}
|
||||
|
||||
function getProviderDisplayName(tool: AgentProviderTool) {
|
||||
const providerIdSegments = tool.name.split('/').filter(Boolean)
|
||||
return providerIdSegments.at(-1) ?? tool.name
|
||||
}
|
||||
|
||||
function getMarketplacePluginInfo(pluginId: string) {
|
||||
const [organization, plugin, ...remainingSegments] = pluginId.split('/')
|
||||
if (!organization || !plugin || remainingSegments.length > 0) return undefined
|
||||
|
||||
return {
|
||||
organization,
|
||||
plugin,
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderCredentialType(
|
||||
provider?: ToolWithProvider,
|
||||
): AgentProviderTool['credentialType'] {
|
||||
@@ -191,16 +246,42 @@ function getProviderCredentialVariant(
|
||||
: ('unauthorized' as const)
|
||||
}
|
||||
|
||||
function useDisplayTools(tools: AgentTool[], providerById: Map<string, ToolWithProvider>) {
|
||||
function useDisplayTools(
|
||||
tools: AgentTool[],
|
||||
providerById: Map<string, ToolWithProvider>,
|
||||
resolvedProviderTypes: Set<AgentProviderTool['providerType']>,
|
||||
marketplacePluginById: Map<string, MarketplacePlugin>,
|
||||
) {
|
||||
const language = useGetLanguage()
|
||||
|
||||
return useMemo(() => {
|
||||
return tools.map((tool) => {
|
||||
return tools.map((tool): DisplayAgentTool => {
|
||||
if (tool.kind !== 'provider') return tool
|
||||
|
||||
const provider = providerById.get(tool.id) ?? providerById.get(tool.name)
|
||||
|
||||
if (!provider) return tool
|
||||
if (!provider) {
|
||||
const providerPluginId = getProviderPluginId(tool)
|
||||
const marketplacePlugin = marketplacePluginById.get(providerPluginId)
|
||||
|
||||
return {
|
||||
...tool,
|
||||
isInstalled: resolvedProviderTypes.has(tool.providerType) ? false : undefined,
|
||||
pluginId: tool.pluginId ?? providerPluginId,
|
||||
pluginUniqueIdentifier:
|
||||
tool.pluginUniqueIdentifier ?? marketplacePlugin?.latest_package_identifier,
|
||||
displayName:
|
||||
tool.displayName ??
|
||||
getLocalizedText(marketplacePlugin?.label ?? marketplacePlugin?.labels, language) ??
|
||||
marketplacePlugin?.name ??
|
||||
getProviderDisplayName(tool),
|
||||
icon:
|
||||
tool.icon ??
|
||||
(marketplacePlugin && providerPluginId
|
||||
? getIconFromMarketPlace(providerPluginId)
|
||||
: undefined),
|
||||
}
|
||||
}
|
||||
|
||||
const providerToolByName = new Map(
|
||||
provider.tools.map((providerTool) => [providerTool.name, providerTool]),
|
||||
@@ -209,6 +290,7 @@ function useDisplayTools(tools: AgentTool[], providerById: Map<string, ToolWithP
|
||||
|
||||
return {
|
||||
...tool,
|
||||
isInstalled: true,
|
||||
displayName: tool.displayName ?? getLocalizedText(provider.label, language) ?? tool.name,
|
||||
icon: tool.icon ?? provider.icon,
|
||||
iconDark: tool.iconDark ?? provider.icon_dark,
|
||||
@@ -234,9 +316,9 @@ function useDisplayTools(tools: AgentTool[], providerById: Map<string, ToolWithP
|
||||
action.description || getLocalizedText(providerTool.description, language) || '',
|
||||
}
|
||||
}),
|
||||
} satisfies AgentProviderTool
|
||||
} satisfies DisplayAgentProviderTool
|
||||
})
|
||||
}, [language, providerById, tools])
|
||||
}, [language, marketplacePluginById, providerById, resolvedProviderTypes, tools])
|
||||
}
|
||||
|
||||
function AddToolMenuItem({
|
||||
@@ -295,7 +377,7 @@ function AddToolMenu({
|
||||
const { t } = useTranslation('agentV2')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState<AddToolMenuView>(addToolDefaultView)
|
||||
const providerById = useAgentToolProviderMap()
|
||||
const { providerById } = useAgentToolProviderMap()
|
||||
|
||||
const openToolPicker = useCallback(() => {
|
||||
setView('tool-picker')
|
||||
@@ -402,7 +484,9 @@ export function AgentTools() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const setProviderToolCredential = useSetAtom(setProviderToolCredentialAtom)
|
||||
const providerById = useAgentToolProviderMap()
|
||||
const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools()
|
||||
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
|
||||
const { providerById, resolvedProviderTypes } = useAgentToolProviderMap()
|
||||
const tools = useAtomValue(agentComposerToolsAtom)
|
||||
const selectedTools = useSelectedProviderTools()
|
||||
const addTools = useSetAtom(addProviderToolsAtom)
|
||||
@@ -432,11 +516,53 @@ export function AgentTools() {
|
||||
},
|
||||
[setProviderToolCredential],
|
||||
)
|
||||
const handlePluginInstalled = useCallback(() => {
|
||||
void Promise.allSettled([invalidateAllBuiltInTools(), invalidateInstalledPluginList()])
|
||||
}, [invalidateAllBuiltInTools, invalidateInstalledPluginList])
|
||||
const visibleTools = useMemo(
|
||||
() => (ENABLE_AGENT_CLI_TOOLS ? tools : tools.filter((tool) => tool.kind !== 'cli')),
|
||||
[tools],
|
||||
)
|
||||
const displayTools = useDisplayTools(visibleTools, providerById)
|
||||
const missingMarketplacePluginInfos = useMemo(() => {
|
||||
const pluginIds = new Set<string>()
|
||||
|
||||
visibleTools.forEach((tool) => {
|
||||
if (
|
||||
tool.kind !== 'provider' ||
|
||||
!resolvedProviderTypes.has(tool.providerType) ||
|
||||
providerById.has(tool.id) ||
|
||||
providerById.has(tool.name)
|
||||
)
|
||||
return
|
||||
|
||||
const pluginId = getProviderPluginId(tool)
|
||||
if (pluginId) pluginIds.add(pluginId)
|
||||
})
|
||||
|
||||
return Array.from(pluginIds).flatMap((pluginId) => {
|
||||
const info = getMarketplacePluginInfo(pluginId)
|
||||
return info ? [info] : []
|
||||
})
|
||||
}, [providerById, resolvedProviderTypes, visibleTools])
|
||||
const { data: missingMarketplacePluginsData } = useFetchPluginsInMarketPlaceByInfo(
|
||||
missingMarketplacePluginInfos,
|
||||
)
|
||||
const marketplacePluginById = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(missingMarketplacePluginsData?.data.list ?? []).map(({ plugin }) => [
|
||||
plugin.plugin_id,
|
||||
plugin,
|
||||
]),
|
||||
),
|
||||
[missingMarketplacePluginsData],
|
||||
)
|
||||
const displayTools = useDisplayTools(
|
||||
visibleTools,
|
||||
providerById,
|
||||
resolvedProviderTypes,
|
||||
marketplacePluginById,
|
||||
)
|
||||
/*
|
||||
* knip-ignore-start
|
||||
* Keep this disabled sync logic while backend credential snapshots are being investigated.
|
||||
@@ -558,6 +684,7 @@ export function AgentTools() {
|
||||
onDeleteProviderToolAction={deleteProviderToolAction}
|
||||
onEditCliTool={editCliTool}
|
||||
onCredentialChange={handleProviderCredentialChange}
|
||||
onPluginInstalled={handlePluginInstalled}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
+85
-26
@@ -21,9 +21,12 @@ import { AuthCategory, Authorized, usePluginAuth } from '@/app/components/plugin
|
||||
import AuthorizedInNode from '@/app/components/plugins/plugin-auth/authorized-in-node'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { usePluginManifestInfo } from '@/service/use-plugins'
|
||||
import { Theme } from '@/types/app'
|
||||
import { getIconFromMarketPlace } from '@/utils/get-icon'
|
||||
import { useAgentOrchestrateReadOnly } from '../../read-only-context'
|
||||
|
||||
function ProviderIcon({
|
||||
@@ -119,6 +122,36 @@ function UnauthorizedCredentialStatus({
|
||||
)
|
||||
}
|
||||
|
||||
function UninstalledPluginStatus({
|
||||
installInfo,
|
||||
extraIdentifiers,
|
||||
onInstall,
|
||||
}: {
|
||||
installInfo?: string
|
||||
extraIdentifiers: string[]
|
||||
onInstall: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (installInfo) {
|
||||
return (
|
||||
<InstallPluginButton
|
||||
size="small"
|
||||
uniqueIdentifier={installInfo}
|
||||
extraIdentifiers={extraIdentifiers}
|
||||
onSuccess={onInstall}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex h-6 shrink-0 items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 system-xs-medium text-components-button-secondary-text shadow-xs backdrop-blur-[5px]">
|
||||
{t(($) => $['detailPanel.toolSelector.uninstalledTitle'], { ns: 'plugin' })}
|
||||
<StatusDot className="ml-2" status="warning" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CredentialStatus({
|
||||
tool,
|
||||
onCredentialChange,
|
||||
@@ -231,19 +264,23 @@ const ProviderToolActionItem = memo(
|
||||
export const AgentProviderToolItem = memo(
|
||||
({
|
||||
tool,
|
||||
isInstalled,
|
||||
isExpanded,
|
||||
onOpenChange,
|
||||
onConfigureAction,
|
||||
onRemoveAction,
|
||||
onRemoveProvider,
|
||||
onCredentialChange,
|
||||
onInstall,
|
||||
}: {
|
||||
tool: AgentProviderTool
|
||||
isInstalled?: boolean
|
||||
isExpanded: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfigureAction: (target: ToolSettingTarget) => void
|
||||
onRemoveAction: (actionId: string) => void
|
||||
onRemoveProvider: () => void
|
||||
onInstall: () => void
|
||||
onCredentialChange: (
|
||||
credentialId?: string,
|
||||
credentialType?: AgentProviderTool['credentialType'],
|
||||
@@ -252,7 +289,20 @@ export const AgentProviderToolItem = memo(
|
||||
const { t } = useTranslation('agentV2')
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const { theme } = useTheme()
|
||||
const icon = theme === Theme.dark && tool.iconDark ? tool.iconDark : tool.icon
|
||||
const shouldFetchPluginManifest =
|
||||
isInstalled === false && !!tool.pluginId && !tool.pluginUniqueIdentifier
|
||||
const { data: pluginManifestData } = usePluginManifestInfo(
|
||||
shouldFetchPluginManifest ? tool.pluginId! : '',
|
||||
)
|
||||
const pluginManifest = pluginManifestData?.data.plugin
|
||||
const configuredIcon = theme === Theme.dark && tool.iconDark ? tool.iconDark : tool.icon
|
||||
const icon =
|
||||
configuredIcon ??
|
||||
(pluginManifest && tool.pluginId ? getIconFromMarketPlace(tool.pluginId) : undefined)
|
||||
const installInfo = tool.pluginUniqueIdentifier ?? pluginManifest?.latest_package_identifier
|
||||
const installIdentifiers = [tool.pluginId, tool.id].filter((identifier): identifier is string =>
|
||||
Boolean(identifier),
|
||||
)
|
||||
const displayName = tool.displayName ?? tool.name
|
||||
|
||||
return (
|
||||
@@ -277,32 +327,41 @@ export const AgentProviderToolItem = memo(
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
{!readOnly && (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t(($) => $['agentDetail.configure.tools.moreActions'], {
|
||||
name: tool.name,
|
||||
})}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover"
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t(($) => $['agentDetail.configure.tools.moreActions'], {
|
||||
name: tool.name,
|
||||
})}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover"
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t(($) => $['agentDetail.configure.tools.moreActions'], { name: tool.name })}
|
||||
</span>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-44">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="gap-2"
|
||||
onClick={onRemoveProvider}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t(($) => $['agentDetail.configure.tools.moreActions'], { name: tool.name })}
|
||||
</span>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-44">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="gap-2"
|
||||
onClick={onRemoveProvider}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span>{t(($) => $['agentDetail.configure.tools.removeProvider'])}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<CredentialStatus tool={tool} onCredentialChange={onCredentialChange} />
|
||||
</>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span>{t(($) => $['agentDetail.configure.tools.removeProvider'])}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{isInstalled === false && (
|
||||
<div className="shrink-0">
|
||||
<UninstalledPluginStatus
|
||||
installInfo={installInfo}
|
||||
extraIdentifiers={installIdentifiers}
|
||||
onInstall={onInstall}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!readOnly && isInstalled === true && (
|
||||
<CredentialStatus tool={tool} onCredentialChange={onCredentialChange} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
+62
-3
@@ -9,6 +9,10 @@ import { sendPreviewChatMessage } from '../preview-chat-request'
|
||||
|
||||
const runtimePropsMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../../community-edition-tip', () => ({
|
||||
CommunityEditionTip: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../chat-runtime', () => ({
|
||||
AgentChatRuntime: (
|
||||
props: Pick<AgentChatRuntimeProps, 'draftType'> & { sendMessage: AgentChatMessageSender },
|
||||
@@ -51,18 +55,73 @@ describe('Agent chat mode request routing', () => {
|
||||
expect(runtimePropsMock.mock.calls.at(-1)?.[0]).not.toHaveProperty('draftType')
|
||||
})
|
||||
|
||||
it('should show only the agent name in the Preview empty-state title', () => {
|
||||
it('should show the unconfigured notice below the Preview description', () => {
|
||||
render(<AgentPreviewChat {...commonProps} agentName="Research Agent" />)
|
||||
|
||||
const renderEmptyState = runtimePropsMock.mock.calls.at(-1)?.[0].renderEmptyState
|
||||
render(
|
||||
const emptyStateView = render(
|
||||
renderEmptyState({
|
||||
agentName: 'Research Agent',
|
||||
hasInstructions: true,
|
||||
showUnconfiguredNotice: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const description = screen.getByText('agentV2.agentDetail.configure.preview.empty.description')
|
||||
const unconfiguredNotice = screen.getByText(
|
||||
'agentV2.agentDetail.configure.preview.unconfiguredNotice',
|
||||
)
|
||||
|
||||
expect(screen.getByText('Research Agent')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Preview Research Agent')).not.toBeInTheDocument()
|
||||
expect(
|
||||
description.compareDocumentPosition(unconfiguredNotice) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
|
||||
emptyStateView.rerender(
|
||||
renderEmptyState({
|
||||
agentName: 'Research Agent',
|
||||
showUnconfiguredNotice: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText('agentV2.agentDetail.configure.preview.unconfiguredNotice'),
|
||||
).not.toBeVisible()
|
||||
expect(
|
||||
screen.getByText('agentV2.agentDetail.configure.preview.unconfiguredNotice').closest('p'),
|
||||
).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('should show the unconfigured notice below the Build description', () => {
|
||||
render(<AgentBuildChat {...commonProps} />)
|
||||
|
||||
const renderEmptyState = runtimePropsMock.mock.calls.at(-1)?.[0].renderEmptyState
|
||||
const emptyStateView = render(
|
||||
renderEmptyState({
|
||||
showUnconfiguredNotice: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const description = screen.getByText('agentV2.agentDetail.configure.build.empty.description')
|
||||
const unconfiguredNotice = screen.getByText(
|
||||
'agentV2.agentDetail.configure.preview.unconfiguredNotice',
|
||||
)
|
||||
|
||||
expect(
|
||||
description.compareDocumentPosition(unconfiguredNotice) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
|
||||
emptyStateView.rerender(
|
||||
renderEmptyState({
|
||||
showUnconfiguredNotice: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText('agentV2.agentDetail.configure.preview.unconfiguredNotice'),
|
||||
).not.toBeVisible()
|
||||
expect(
|
||||
screen.getByText('agentV2.agentDetail.configure.preview.unconfiguredNotice').closest('p'),
|
||||
).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
+74
-1
@@ -1,12 +1,15 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import type { AgentPreviewChatController } from '../chat-conversation'
|
||||
import type { AgentChatRuntimeEmptyStateProps } from '../chat-runtime'
|
||||
import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types'
|
||||
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { createRef, useState } from 'react'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model'
|
||||
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@@ -244,7 +247,16 @@ vi.mock('@/service/client', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
function renderPreviewChat(props?: Partial<ComponentProps<typeof AgentChatRuntime>>) {
|
||||
function renderUnconfiguredEmptyState({ showUnconfiguredNotice }: AgentChatRuntimeEmptyStateProps) {
|
||||
return showUnconfiguredNotice ? (
|
||||
<span>agentV2.agentDetail.configure.preview.unconfiguredNotice</span>
|
||||
) : null
|
||||
}
|
||||
|
||||
function renderPreviewChat(
|
||||
props?: Partial<ComponentProps<typeof AgentChatRuntime>>,
|
||||
draftOverrides?: Partial<AgentSoulConfigFormState>,
|
||||
) {
|
||||
const store = createStore()
|
||||
seedRegisteredConsoleStateFixture(store)
|
||||
const queryClient = new QueryClient({
|
||||
@@ -259,6 +271,12 @@ function renderPreviewChat(props?: Partial<ComponentProps<typeof AgentChatRuntim
|
||||
model: 'gpt-4',
|
||||
})
|
||||
store.set(agentComposerPromptAtom, 'You are helpful.')
|
||||
if (draftOverrides) {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...store.get(agentComposerDraftAtom),
|
||||
...draftOverrides,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
queryClient,
|
||||
@@ -1215,6 +1233,61 @@ describe('AgentPreviewChat', () => {
|
||||
expect(screen.queryByRole('button', { name: 'sandbox notice info' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Preview', undefined],
|
||||
['Build', 'debug_build' as const],
|
||||
])('should show the unconfigured warning in %s mode', (_mode, draftType) => {
|
||||
renderPreviewChat(
|
||||
{
|
||||
draftType,
|
||||
renderEmptyState: renderUnconfiguredEmptyState,
|
||||
},
|
||||
{
|
||||
prompt: '',
|
||||
},
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText('agentV2.agentDetail.configure.preview.unconfiguredNotice'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['prompt', { prompt: 'You are helpful.' }],
|
||||
['build note (config_note)', { configNote: 'Use the latest build context.' }],
|
||||
['skill', { skills: [{ id: 'skill-1', name: 'Research' }] }],
|
||||
['knowledge base', { knowledgeRetrievals: [{ id: 'retrieval-1', name: 'Docs' }] }],
|
||||
['file', { files: [{ id: 'brief.md', icon: 'markdown' as const, name: 'brief.md' }] }],
|
||||
['tool', { tools: [{ id: 'cli-1', kind: 'cli' as const, name: 'CLI' }] }],
|
||||
[
|
||||
'environment variable',
|
||||
{
|
||||
envVariables: [
|
||||
{
|
||||
id: 'env-1',
|
||||
key: 'API_KEY',
|
||||
scope: 'secret' as const,
|
||||
value: 'secret',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
])('should hide the unconfigured warning when the agent has a %s', (_config, draft) => {
|
||||
renderPreviewChat(
|
||||
{
|
||||
renderEmptyState: renderUnconfiguredEmptyState,
|
||||
},
|
||||
{
|
||||
prompt: '',
|
||||
...draft,
|
||||
},
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.queryByText('agentV2.agentDetail.configure.preview.unconfiguredNotice'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should send build chat inputs from the prepared build draft snapshot', async () => {
|
||||
const saveDraftBeforeRun = vi.fn().mockResolvedValue({
|
||||
app_variables: [
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentChatRuntimeProps } from './chat-runtime'
|
||||
import type { AgentChatRuntimeEmptyStateProps, AgentChatRuntimeProps } from './chat-runtime'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CommunityEditionTip } from '../community-edition-tip'
|
||||
import { sendBuildChatMessage } from './build-chat-request'
|
||||
import { AgentChatRuntime } from './chat-runtime'
|
||||
import { AgentUnconfiguredNotice } from './unconfigured-notice'
|
||||
|
||||
const buildIconGridCellOpacities = [
|
||||
'0 0 0.093 0.166 0 0 0.155 0',
|
||||
@@ -27,7 +28,9 @@ type AgentBuildChatProps = Omit<
|
||||
'draftType' | 'inputPlaceholder' | 'renderEmptyState' | 'sendButtonLabel' | 'sendMessage'
|
||||
>
|
||||
|
||||
function AgentBuildChatEmptyState() {
|
||||
function AgentBuildChatEmptyState({
|
||||
showUnconfiguredNotice,
|
||||
}: Pick<AgentChatRuntimeEmptyStateProps, 'showUnconfiguredNotice'>) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const communityEditionBuildModeTip = t(
|
||||
($) => $['agentDetail.configure.build.empty.communityEditionTip'],
|
||||
@@ -63,6 +66,7 @@ function AgentBuildChatEmptyState() {
|
||||
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
|
||||
{t(($) => $['agentDetail.configure.build.empty.description'])}
|
||||
</p>
|
||||
<AgentUnconfiguredNotice visible={showUnconfiguredNotice} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -78,7 +82,7 @@ export function AgentBuildChat(props: AgentBuildChatProps) {
|
||||
inputAutoFocus={false}
|
||||
sendButtonLabel={t(($) => $['agentDetail.configure.build.startBuild'])}
|
||||
sendMessage={sendBuildChatMessage}
|
||||
renderEmptyState={() => <AgentBuildChatEmptyState />}
|
||||
renderEmptyState={(emptyStateProps) => <AgentBuildChatEmptyState {...emptyStateProps} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export type AgentChatRuntimeEmptyStateProps = {
|
||||
agentIconBackground?: string | null
|
||||
agentIconType?: AgentIconType | null
|
||||
agentName?: string
|
||||
hasInstructions: boolean
|
||||
showUnconfiguredNotice: boolean
|
||||
}
|
||||
|
||||
export type AgentChatRuntimeProps = {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'rea
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ChatInputArea from '@/app/components/base/chat/chat/chat-input-area'
|
||||
import { deploymentEditionAtom } from '@/context/system-features-state'
|
||||
import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model'
|
||||
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
|
||||
import { buildChatConfig, getAgentSoulInputs, getAgentSoulInputsForm } from './chat-config'
|
||||
@@ -82,6 +83,7 @@ export function AgentPreviewChatSession({
|
||||
const { t } = useTranslation('agentV2')
|
||||
const prompt = useAtomValue(agentComposerPromptAtom)
|
||||
const currentModel = useAtomValue(agentComposerModelAtom)
|
||||
const composerDraft = useAtomValue(agentComposerDraftAtom)
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildChatConfig({
|
||||
@@ -132,12 +134,21 @@ export function AgentPreviewChatSession({
|
||||
[handleInputSend],
|
||||
)
|
||||
const { isEmptyChat, isResponding, isSendPending } = runtimeState
|
||||
const hasInstructions = !!config.pre_prompt.trim()
|
||||
const hasAgentConfiguration = !!(
|
||||
composerDraft.prompt.trim() ||
|
||||
composerDraft.skills.length ||
|
||||
composerDraft.files.length ||
|
||||
composerDraft.tools.length ||
|
||||
composerDraft.knowledgeRetrievals.length ||
|
||||
composerDraft.envVariables.length
|
||||
)
|
||||
const hasBuildNote = !!composerDraft.configNote.trim()
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const sendButtonLoading = isEmptyChat && !!sendButtonLabel && (isSendPending || isResponding)
|
||||
const sandboxNotice = t(($) => $['agentDetail.configure.preview.sandboxNotice'])
|
||||
const sandboxNoticeTooltip = t(($) => $['agentDetail.configure.preview.sandboxNoticeTooltip'])
|
||||
const showSandboxNotice = isEmptyChat && !isSendPending && !isResponding
|
||||
const showUnconfiguredNotice = showSandboxNotice && !hasAgentConfiguration && !hasBuildNote
|
||||
const speechToTextTarget: SpeechToTextTarget = {
|
||||
type: 'agent',
|
||||
agentId,
|
||||
@@ -219,7 +230,7 @@ export function AgentPreviewChatSession({
|
||||
agentIconBackground,
|
||||
agentIconType,
|
||||
agentName,
|
||||
hasInstructions,
|
||||
showUnconfiguredNotice,
|
||||
})}
|
||||
<div className={cn(isEmptyChat && 'pointer-events-auto mt-5 w-full')}>
|
||||
{chatInputNode}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { AgentChatRuntime } from './chat-runtime'
|
||||
import { sendPreviewChatMessage } from './preview-chat-request'
|
||||
import { AgentUnconfiguredNotice } from './unconfigured-notice'
|
||||
|
||||
type AgentPreviewChatProps = Omit<
|
||||
AgentChatRuntimeProps,
|
||||
@@ -16,7 +17,7 @@ function AgentPreviewChatEmptyState({
|
||||
agentIconBackground,
|
||||
agentIconType,
|
||||
agentName,
|
||||
hasInstructions,
|
||||
showUnconfiguredNotice,
|
||||
}: AgentChatRuntimeEmptyStateProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const imageUrl = agentIconType === 'image' || agentIconType === 'link' ? agentIcon : undefined
|
||||
@@ -39,11 +40,7 @@ function AgentPreviewChatEmptyState({
|
||||
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
|
||||
{t(($) => $['agentDetail.configure.preview.empty.description'])}
|
||||
</p>
|
||||
{!hasInstructions && (
|
||||
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
|
||||
{t(($) => $['agentDetail.configure.preview.empty.noInstructionsDescription'])}
|
||||
</p>
|
||||
)}
|
||||
<AgentUnconfiguredNotice visible={showUnconfiguredNotice} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function AgentUnconfiguredNotice({ visible }: { visible: boolean }) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
|
||||
return (
|
||||
<p
|
||||
aria-hidden={!visible}
|
||||
className="mt-1 flex max-w-full items-start gap-1 body-md-regular text-text-tertiary"
|
||||
style={{ visibility: visible ? 'visible' : 'hidden' }}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-0.5 i-ri-alert-fill size-4 shrink-0 text-text-warning-secondary"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
{t(($) => $['agentDetail.configure.preview.unconfiguredNotice'])}
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -154,7 +154,6 @@
|
||||
"agentDetail.configure.preview.chatFeatures": "ميزات الدردشة",
|
||||
"agentDetail.configure.preview.empty.defaultAgentName": "وكيل",
|
||||
"agentDetail.configure.preview.empty.description": "شغّل الوكيل كمحادثة مكتملة، تمامًا كما سيختبرها المستخدمون بعد النشر.",
|
||||
"agentDetail.configure.preview.empty.noInstructionsDescription": "لا توجد تعليمات بعد، لذلك تأتي الردود من النموذج البسيط.",
|
||||
"agentDetail.configure.preview.empty.title": "معاينة {{name}}",
|
||||
"agentDetail.configure.preview.endUserAuth": "مصادقة المستخدم النهائي",
|
||||
"agentDetail.configure.preview.inputPlaceholder": "إرسال رسالة إلى {{name}}",
|
||||
@@ -162,6 +161,7 @@
|
||||
"agentDetail.configure.preview.sandboxNotice": "يعمل Agent داخل صندوق رمل Linux.",
|
||||
"agentDetail.configure.preview.sandboxNoticeTooltip": "بالنسبة إلى إصدار Dify Community Edition، يعمل كل Agent من Agents الخاصة بك في بيئة صندوق رمل Linux 7.0.0-10060-aws داخل Docker لديك. تعديلاتك على البيئة عبر Build Chats مستمرة.",
|
||||
"agentDetail.configure.preview.title": "معاينة",
|
||||
"agentDetail.configure.preview.unconfiguredNotice": "لم يتم إعداد هذا الوكيل بعد، لذا تأتي الردود مباشرةً من النموذج.",
|
||||
"agentDetail.configure.prompt.copied": "تم نسخ المطالبة",
|
||||
"agentDetail.configure.prompt.copy": "نسخ المطالبة",
|
||||
"agentDetail.configure.prompt.copyFailed": "فشل نسخ المطالبة.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user