Compare commits

...
Author SHA1 Message Date
twwu 6e75e7c5b0 Merge branch 'main' into feat/app-multi-env-deployments 2026-07-28 18:10:30 +08:00
twwu 8c91bce282 feat(workflow): update workflow tool publish action 2026-07-28 17:58:30 +08:00
yyhGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
798e5ed7a7 refactor(web): derive workspace billing from current workspace (#39674)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 09:32:03 +00:00
Escape0707andGitHub eef709e475 ci: enforce strict checks for unit tests (#39682) 2026-07-28 09:10:02 +00:00
Escape0707andGitHub 04158ac8ea ci: replace custom noqa markers with guard ignores (#39679) 2026-07-28 08:52:12 +00:00
JoelandGitHub ae0b66311d feat: add missing plugin installation to agent DSL imports (#39680) 2026-07-28 08:23:42 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>yunlu.wen
b97abe5328 chore: bump nltk from 3.9.4 to 3.10.0 in /api (#39503)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
2026-07-28 08:19:15 +00:00
twwu 24a66293fc feat: refine app publishing and dirty tracking 2026-07-28 16:17:57 +08:00
JyongandGitHub d94314627f ci: wait for KnowledgeFS before deployment (#39677) 2026-07-28 07:12:29 +00:00
ShaktiandGitHub 7ec6a57ddf fix: populate completion_params from model schema defaults in Agent node (#39590) 2026-07-28 07:06:49 +00:00
JoelandGitHub 35b539e35b fix: improve unconfigured agent guidance (#39676) 2026-07-28 07:02:49 +00:00
Asuka MinatoGitHubByron.wangautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
3b040e6bed test: use sqlite3 session in test_web_login (#38725)
Co-authored-by: Byron.wang <byron@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 06:37:45 +00:00
Asuka MinatoandGitHub 9300be03f0 test: use sqlite3 session in test_agent_app_feature_service (#38724) 2026-07-28 06:32:07 +00:00
twwu be88e6adda feat(workflow): redesign publisher action menu 2026-07-27 16:47:33 +08:00
twwu 3b832da46a feat(workflow): show deployment details in version history
Preview workflow IDs in the copy action, add mock environment badges, and fix the action menu trigger's native button semantics.
2026-07-27 13:54:37 +08:00
twwu 8812fd649c feat: Add app detail routes for Access Point and Deploy 2026-07-27 11:04:00 +08:00
171 changed files with 5818 additions and 1244 deletions
+43
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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):
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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]):
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -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/*
+2 -2
View File
@@ -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
+430
View File
@@ -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) == {}
+961
View File
@@ -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
View File
@@ -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]]
+14
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
-8
View File
@@ -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
+3 -3
View File
@@ -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:
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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')
+7 -1
View File
@@ -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) => {
+106 -87
View File
@@ -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"
+286 -185
View File
@@ -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])
@@ -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', () => {
@@ -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', () => {
+13 -2
View File
@@ -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],
)
+13 -3
View File
@@ -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')
})
})
})
@@ -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')
@@ -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
@@ -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,
@@ -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
@@ -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,
@@ -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)
@@ -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}
/>
))
)}
@@ -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>
@@ -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')
})
})
@@ -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} />
</>
)
}
@@ -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>
)
}
+1 -1
View File
@@ -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