Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0736ac1baf | ||
|
|
0c8dec3315 | ||
|
|
bd1a0b8908 | ||
|
|
38e831c1b3 | ||
|
|
1c5d62d98a | ||
|
|
6b4736bf78 | ||
|
|
c9503fd818 | ||
|
|
91a1df96cb | ||
|
|
5b2c5da945 | ||
|
|
b59ecea346 | ||
|
|
61c0948136 | ||
|
|
f746c7bdf2 | ||
|
|
2a3deee385 | ||
|
|
4b6803ba06 | ||
|
|
4c908c8f39 | ||
|
|
afec528f51 | ||
|
|
491061b8f4 | ||
|
|
8b1533438f | ||
|
|
ba924fc97b | ||
|
|
712e522220 | ||
|
|
33eebe8cfc | ||
|
|
2e1b11bdb2 | ||
|
|
d65a6b4810 | ||
|
|
44a91e344c | ||
|
|
0fec9af6a6 | ||
|
|
5e5113e08e | ||
|
|
73f9a9e7d6 | ||
|
|
48d23cd744 | ||
|
|
0b60bf6ef0 | ||
|
|
3b24d8d2d1 | ||
|
|
051ba99cd2 | ||
|
|
dc83e8aa09 | ||
|
|
77f8f2babb | ||
|
|
77d6c108e7 | ||
|
|
c2a5962023 | ||
|
|
d583b1b835 | ||
|
|
da00de6688 |
@@ -367,7 +367,7 @@ For each extraction:
|
||||
┌────────────────────────────────────────┐
|
||||
│ 1. Extract code │
|
||||
│ 2. Run: pnpm lint:fix │
|
||||
│ 3. Run: pnpm type-check:tsgo │
|
||||
│ 3. Run: pnpm type-check │
|
||||
│ 4. Run: pnpm test │
|
||||
│ 5. Test functionality manually │
|
||||
│ 6. PASS? → Next extraction │
|
||||
|
||||
@@ -127,7 +127,7 @@ For the current file being tested:
|
||||
- [ ] Run full directory test: `pnpm test path/to/directory/`
|
||||
- [ ] Check coverage report: `pnpm test:coverage`
|
||||
- [ ] Run `pnpm lint:fix` on all test files
|
||||
- [ ] Run `pnpm type-check:tsgo`
|
||||
- [ ] Run `pnpm type-check`
|
||||
|
||||
## Common Issues to Watch
|
||||
|
||||
|
||||
@@ -237,6 +237,10 @@ scripts/stress-test/reports/
|
||||
.playwright-mcp/
|
||||
.serena/
|
||||
|
||||
# vitest browser mode attachments (failure screenshots, traces, etc.)
|
||||
.vitest-attachments/
|
||||
**/__screenshots__/
|
||||
|
||||
# settings
|
||||
*.local.json
|
||||
*.local.md
|
||||
|
||||
@@ -30,7 +30,7 @@ The codebase is split into:
|
||||
## Language Style
|
||||
|
||||
- **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation.
|
||||
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check:tsgo`, and avoid `any` types.
|
||||
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check`, and avoid `any` types.
|
||||
|
||||
## General Practices
|
||||
|
||||
|
||||
@@ -139,19 +139,6 @@ Star Dify on GitHub and be instantly notified of new releases.
|
||||
|
||||
If you need to customize the configuration, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker compose up -d`. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
|
||||
|
||||
#### Customizing Suggested Questions
|
||||
|
||||
You can now customize the "Suggested Questions After Answer" feature to better fit your use case. For example, to generate longer, more technical questions:
|
||||
|
||||
```bash
|
||||
# In your .env file
|
||||
SUGGESTED_QUESTIONS_PROMPT='Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: ["question1","question2","question3","question4","question5"]'
|
||||
SUGGESTED_QUESTIONS_MAX_TOKENS=512
|
||||
SUGGESTED_QUESTIONS_TEMPERATURE=0.3
|
||||
```
|
||||
|
||||
See the [Suggested Questions Configuration Guide](docs/suggested-questions-configuration.md) for detailed examples and usage instructions.
|
||||
|
||||
### Metrics Monitoring with Grafana
|
||||
|
||||
Import the dashboard to Grafana, using Dify's PostgreSQL database as data source, to monitor metrics in granularity of apps, tenants, messages, and more.
|
||||
|
||||
@@ -709,22 +709,6 @@ SWAGGER_UI_PATH=/swagger-ui.html
|
||||
# Set to false to export dataset IDs as plain text for easier cross-environment import
|
||||
DSL_EXPORT_ENCRYPT_DATASET_ID=true
|
||||
|
||||
# Suggested Questions After Answer Configuration
|
||||
# These environment variables allow customization of the suggested questions feature
|
||||
#
|
||||
# Custom prompt for generating suggested questions (optional)
|
||||
# If not set, uses the default prompt that generates 3 questions under 20 characters each
|
||||
# Example: "Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: [\"question1\",\"question2\",\"question3\",\"question4\",\"question5\"]"
|
||||
# SUGGESTED_QUESTIONS_PROMPT=
|
||||
|
||||
# Maximum number of tokens for suggested questions generation (default: 256)
|
||||
# Adjust this value for longer questions or more questions
|
||||
# SUGGESTED_QUESTIONS_MAX_TOKENS=256
|
||||
|
||||
# Temperature for suggested questions generation (default: 0.0)
|
||||
# Higher values (0.5-1.0) produce more creative questions, lower values (0.0-0.3) produce more focused questions
|
||||
# SUGGESTED_QUESTIONS_TEMPERATURE=0
|
||||
|
||||
# Tenant isolated task queue configuration
|
||||
TENANT_ISOLATED_TASK_CONCURRENCY=1
|
||||
|
||||
|
||||
@@ -101,3 +101,11 @@ The scripts resolve paths relative to their location, so you can run them from a
|
||||
uv run ruff format ./ # Format code
|
||||
uv run basedpyright . # Type checking
|
||||
```
|
||||
|
||||
## Generate TS stub
|
||||
|
||||
```
|
||||
uv run dev/generate_swagger_specs.py --output-dir openapi
|
||||
```
|
||||
|
||||
use https://jsontotable.org/openapi-to-typescript to convert to typescript
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
"""Console workspace endpoint controllers.
|
||||
|
||||
This module exposes workspace-scoped plugin endpoint management APIs. The
|
||||
canonical write routes follow resource-oriented paths, while the historical
|
||||
verb-based aliases stay available as deprecated resources so OpenAPI metadata
|
||||
marks only the legacy paths as deprecated.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import request
|
||||
@@ -25,7 +33,12 @@ class EndpointIdPayload(BaseModel):
|
||||
endpoint_id: str
|
||||
|
||||
|
||||
class EndpointUpdatePayload(EndpointIdPayload):
|
||||
class EndpointUpdatePayload(BaseModel):
|
||||
settings: dict[str, Any]
|
||||
name: str = Field(min_length=1)
|
||||
|
||||
|
||||
class LegacyEndpointUpdatePayload(EndpointIdPayload):
|
||||
settings: dict[str, Any]
|
||||
name: str = Field(min_length=1)
|
||||
|
||||
@@ -76,6 +89,7 @@ register_schema_models(
|
||||
EndpointCreatePayload,
|
||||
EndpointIdPayload,
|
||||
EndpointUpdatePayload,
|
||||
LegacyEndpointUpdatePayload,
|
||||
EndpointListQuery,
|
||||
EndpointListForPluginQuery,
|
||||
EndpointCreateResponse,
|
||||
@@ -88,8 +102,60 @@ register_schema_models(
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/endpoints/create")
|
||||
class EndpointCreateApi(Resource):
|
||||
def _create_endpoint() -> dict[str, bool]:
|
||||
"""Create a plugin endpoint for the current workspace."""
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
|
||||
args = EndpointCreatePayload.model_validate(console_ns.payload)
|
||||
|
||||
try:
|
||||
return {
|
||||
"success": EndpointService.create_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user.id,
|
||||
plugin_unique_identifier=args.plugin_unique_identifier,
|
||||
name=args.name,
|
||||
settings=args.settings,
|
||||
)
|
||||
}
|
||||
except PluginPermissionDeniedError as e:
|
||||
raise ValueError(e.description) from e
|
||||
|
||||
|
||||
def _update_endpoint(endpoint_id: str) -> dict[str, bool]:
|
||||
"""Update a plugin endpoint identified by the canonical path parameter."""
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
|
||||
args = EndpointUpdatePayload.model_validate(console_ns.payload)
|
||||
|
||||
return {
|
||||
"success": EndpointService.update_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user.id,
|
||||
endpoint_id=endpoint_id,
|
||||
name=args.name,
|
||||
settings=args.settings,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _delete_endpoint(endpoint_id: str) -> dict[str, bool]:
|
||||
"""Delete a plugin endpoint identified by the canonical path parameter."""
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
|
||||
return {
|
||||
"success": EndpointService.delete_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user.id,
|
||||
endpoint_id=endpoint_id,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/endpoints")
|
||||
class EndpointCollectionApi(Resource):
|
||||
"""Canonical collection resource for endpoint creation."""
|
||||
|
||||
@console_ns.doc("create_endpoint")
|
||||
@console_ns.doc(description="Create a new plugin endpoint")
|
||||
@console_ns.expect(console_ns.models[EndpointCreatePayload.__name__])
|
||||
@@ -104,22 +170,33 @@ class EndpointCreateApi(Resource):
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
return _create_endpoint()
|
||||
|
||||
args = EndpointCreatePayload.model_validate(console_ns.payload)
|
||||
|
||||
try:
|
||||
return {
|
||||
"success": EndpointService.create_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user.id,
|
||||
plugin_unique_identifier=args.plugin_unique_identifier,
|
||||
name=args.name,
|
||||
settings=args.settings,
|
||||
)
|
||||
}
|
||||
except PluginPermissionDeniedError as e:
|
||||
raise ValueError(e.description) from e
|
||||
@console_ns.route("/workspaces/current/endpoints/create")
|
||||
class DeprecatedEndpointCreateApi(Resource):
|
||||
"""Deprecated verb-based alias for endpoint creation."""
|
||||
|
||||
@console_ns.doc("create_endpoint_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/current/endpoints instead."
|
||||
)
|
||||
)
|
||||
@console_ns.expect(console_ns.models[EndpointCreatePayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Endpoint created successfully",
|
||||
console_ns.models[EndpointCreateResponse.__name__],
|
||||
)
|
||||
@console_ns.response(403, "Admin privileges required")
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
return _create_endpoint()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/endpoints/list")
|
||||
@@ -190,10 +267,56 @@ class EndpointListForSinglePluginApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/endpoints/delete")
|
||||
class EndpointDeleteApi(Resource):
|
||||
@console_ns.route("/workspaces/current/endpoints/<string:id>")
|
||||
class EndpointItemApi(Resource):
|
||||
"""Canonical item resource for endpoint updates and deletion."""
|
||||
|
||||
@console_ns.doc("delete_endpoint")
|
||||
@console_ns.doc(description="Delete a plugin endpoint")
|
||||
@console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Endpoint deleted successfully",
|
||||
console_ns.models[EndpointDeleteResponse.__name__],
|
||||
)
|
||||
@console_ns.response(403, "Admin privileges required")
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
def delete(self, id: str):
|
||||
return _delete_endpoint(endpoint_id=id)
|
||||
|
||||
@console_ns.doc("update_endpoint")
|
||||
@console_ns.doc(description="Update a plugin endpoint")
|
||||
@console_ns.expect(console_ns.models[EndpointUpdatePayload.__name__])
|
||||
@console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Endpoint updated successfully",
|
||||
console_ns.models[EndpointUpdateResponse.__name__],
|
||||
)
|
||||
@console_ns.response(403, "Admin privileges required")
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
def patch(self, id: str):
|
||||
return _update_endpoint(endpoint_id=id)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/endpoints/delete")
|
||||
class DeprecatedEndpointDeleteApi(Resource):
|
||||
"""Deprecated verb-based alias for endpoint deletion."""
|
||||
|
||||
@console_ns.doc("delete_endpoint_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Deprecated legacy alias for deleting a plugin endpoint. "
|
||||
"Use DELETE /workspaces/current/endpoints/{id} instead."
|
||||
)
|
||||
)
|
||||
@console_ns.expect(console_ns.models[EndpointIdPayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
@@ -206,22 +329,23 @@ class EndpointDeleteApi(Resource):
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
|
||||
args = EndpointIdPayload.model_validate(console_ns.payload)
|
||||
|
||||
return {
|
||||
"success": EndpointService.delete_endpoint(
|
||||
tenant_id=tenant_id, user_id=user.id, endpoint_id=args.endpoint_id
|
||||
)
|
||||
}
|
||||
return _delete_endpoint(endpoint_id=args.endpoint_id)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/endpoints/update")
|
||||
class EndpointUpdateApi(Resource):
|
||||
@console_ns.doc("update_endpoint")
|
||||
@console_ns.doc(description="Update a plugin endpoint")
|
||||
@console_ns.expect(console_ns.models[EndpointUpdatePayload.__name__])
|
||||
class DeprecatedEndpointUpdateApi(Resource):
|
||||
"""Deprecated verb-based alias for endpoint updates."""
|
||||
|
||||
@console_ns.doc("update_endpoint_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Deprecated legacy alias for updating a plugin endpoint. "
|
||||
"Use PATCH /workspaces/current/endpoints/{id} instead."
|
||||
)
|
||||
)
|
||||
@console_ns.expect(console_ns.models[LegacyEndpointUpdatePayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Endpoint updated successfully",
|
||||
@@ -233,19 +357,8 @@ class EndpointUpdateApi(Resource):
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
|
||||
args = EndpointUpdatePayload.model_validate(console_ns.payload)
|
||||
|
||||
return {
|
||||
"success": EndpointService.update_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user.id,
|
||||
endpoint_id=args.endpoint_id,
|
||||
name=args.name,
|
||||
settings=args.settings,
|
||||
)
|
||||
}
|
||||
args = LegacyEndpointUpdatePayload.model_validate(console_ns.payload)
|
||||
return _update_endpoint(endpoint_id=args.endpoint_id)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/endpoints/enable")
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
"""Service API endpoints for dataset document management.
|
||||
|
||||
The canonical Service API paths use hyphenated route segments. Legacy underscore
|
||||
aliases remain registered for backward compatibility, but they must stay marked
|
||||
deprecated in generated API docs so clients migrate toward the canonical paths.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from contextlib import ExitStack
|
||||
from typing import Self
|
||||
from uuid import UUID
|
||||
@@ -117,12 +125,137 @@ register_schema_models(
|
||||
)
|
||||
|
||||
|
||||
@service_api_ns.route(
|
||||
"/datasets/<uuid:dataset_id>/document/create_by_text",
|
||||
"/datasets/<uuid:dataset_id>/document/create-by-text",
|
||||
)
|
||||
def _create_document_by_text(tenant_id: str, dataset_id: UUID) -> tuple[Mapping[str, object], int]:
|
||||
"""Create a document from text for both canonical and legacy routes."""
|
||||
payload = DocumentTextCreatePayload.model_validate(service_api_ns.payload or {})
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
|
||||
dataset_id_str = str(dataset_id)
|
||||
tenant_id_str = str(tenant_id)
|
||||
dataset = db.session.scalar(
|
||||
select(Dataset).where(Dataset.tenant_id == tenant_id_str, Dataset.id == dataset_id_str).limit(1)
|
||||
)
|
||||
|
||||
if not dataset:
|
||||
raise ValueError("Dataset does not exist.")
|
||||
|
||||
if not dataset.indexing_technique and not args["indexing_technique"]:
|
||||
raise ValueError("indexing_technique is required.")
|
||||
|
||||
embedding_model_provider = payload.embedding_model_provider
|
||||
embedding_model = payload.embedding_model
|
||||
if embedding_model_provider and embedding_model:
|
||||
DatasetService.check_embedding_model_setting(tenant_id_str, embedding_model_provider, embedding_model)
|
||||
|
||||
retrieval_model = payload.retrieval_model
|
||||
if (
|
||||
retrieval_model
|
||||
and retrieval_model.reranking_model
|
||||
and retrieval_model.reranking_model.reranking_provider_name
|
||||
and retrieval_model.reranking_model.reranking_model_name
|
||||
):
|
||||
DatasetService.check_reranking_model_setting(
|
||||
tenant_id_str,
|
||||
retrieval_model.reranking_model.reranking_provider_name,
|
||||
retrieval_model.reranking_model.reranking_model_name,
|
||||
)
|
||||
|
||||
if not current_user:
|
||||
raise ValueError("current_user is required")
|
||||
|
||||
upload_file = FileService(db.engine).upload_text(
|
||||
text=payload.text, text_name=payload.name, user_id=current_user.id, tenant_id=tenant_id_str
|
||||
)
|
||||
data_source = {
|
||||
"type": "upload_file",
|
||||
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
|
||||
}
|
||||
args["data_source"] = data_source
|
||||
knowledge_config = KnowledgeConfig.model_validate(args)
|
||||
DocumentService.document_create_args_validate(knowledge_config)
|
||||
|
||||
if not current_user:
|
||||
raise ValueError("current_user is required")
|
||||
|
||||
try:
|
||||
documents, batch = DocumentService.save_document_with_dataset_id(
|
||||
dataset=dataset,
|
||||
knowledge_config=knowledge_config,
|
||||
account=current_user,
|
||||
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
|
||||
created_from="api",
|
||||
)
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
document = documents[0]
|
||||
|
||||
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch}
|
||||
return documents_and_batch_fields, 200
|
||||
|
||||
|
||||
def _update_document_by_text(tenant_id: str, dataset_id: UUID, document_id: UUID) -> tuple[Mapping[str, object], int]:
|
||||
"""Update a document from text for both canonical and legacy routes."""
|
||||
payload = DocumentTextUpdate.model_validate(service_api_ns.payload or {})
|
||||
dataset = db.session.scalar(
|
||||
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == str(dataset_id)).limit(1)
|
||||
)
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
if not dataset:
|
||||
raise ValueError("Dataset does not exist.")
|
||||
|
||||
retrieval_model = payload.retrieval_model
|
||||
if (
|
||||
retrieval_model
|
||||
and retrieval_model.reranking_model
|
||||
and retrieval_model.reranking_model.reranking_provider_name
|
||||
and retrieval_model.reranking_model.reranking_model_name
|
||||
):
|
||||
DatasetService.check_reranking_model_setting(
|
||||
tenant_id,
|
||||
retrieval_model.reranking_model.reranking_provider_name,
|
||||
retrieval_model.reranking_model.reranking_model_name,
|
||||
)
|
||||
|
||||
# indexing_technique is already set in dataset since this is an update
|
||||
args["indexing_technique"] = dataset.indexing_technique
|
||||
|
||||
if args.get("text"):
|
||||
text = args.get("text")
|
||||
name = args.get("name")
|
||||
if not current_user:
|
||||
raise ValueError("current_user is required")
|
||||
upload_file = FileService(db.engine).upload_text(
|
||||
text=str(text), text_name=str(name), user_id=current_user.id, tenant_id=tenant_id
|
||||
)
|
||||
data_source = {
|
||||
"type": "upload_file",
|
||||
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
|
||||
}
|
||||
args["data_source"] = data_source
|
||||
|
||||
args["original_document_id"] = str(document_id)
|
||||
knowledge_config = KnowledgeConfig.model_validate(args)
|
||||
DocumentService.document_create_args_validate(knowledge_config)
|
||||
|
||||
try:
|
||||
documents, batch = DocumentService.save_document_with_dataset_id(
|
||||
dataset=dataset,
|
||||
knowledge_config=knowledge_config,
|
||||
account=current_user,
|
||||
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
|
||||
created_from="api",
|
||||
)
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
document = documents[0]
|
||||
|
||||
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch}
|
||||
return documents_and_batch_fields, 200
|
||||
|
||||
|
||||
@service_api_ns.route("/datasets/<uuid:dataset_id>/document/create-by-text")
|
||||
class DocumentAddByTextApi(DatasetApiResource):
|
||||
"""Resource for documents."""
|
||||
"""Resource for the canonical text document creation route."""
|
||||
|
||||
@service_api_ns.expect(service_api_ns.models[DocumentTextCreatePayload.__name__])
|
||||
@service_api_ns.doc("create_document_by_text")
|
||||
@@ -138,81 +271,43 @@ class DocumentAddByTextApi(DatasetApiResource):
|
||||
@cloud_edition_billing_resource_check("vector_space", "dataset")
|
||||
@cloud_edition_billing_resource_check("documents", "dataset")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
def post(self, tenant_id, dataset_id):
|
||||
def post(self, tenant_id: str, dataset_id: UUID):
|
||||
"""Create document by text."""
|
||||
payload = DocumentTextCreatePayload.model_validate(service_api_ns.payload or {})
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
return _create_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
|
||||
dataset_id = str(dataset_id)
|
||||
tenant_id = str(tenant_id)
|
||||
dataset = db.session.scalar(
|
||||
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
|
||||
|
||||
@service_api_ns.route("/datasets/<uuid:dataset_id>/document/create_by_text")
|
||||
class DeprecatedDocumentAddByTextApi(DatasetApiResource):
|
||||
"""Deprecated resource alias for text document creation."""
|
||||
|
||||
@service_api_ns.expect(service_api_ns.models[DocumentTextCreatePayload.__name__])
|
||||
@service_api_ns.doc("create_document_by_text_deprecated")
|
||||
@service_api_ns.doc(deprecated=True)
|
||||
@service_api_ns.doc(
|
||||
description=(
|
||||
"Deprecated legacy alias for creating a new document by providing text content. "
|
||||
"Use /datasets/{dataset_id}/document/create-by-text instead."
|
||||
)
|
||||
|
||||
if not dataset:
|
||||
raise ValueError("Dataset does not exist.")
|
||||
|
||||
if not dataset.indexing_technique and not args["indexing_technique"]:
|
||||
raise ValueError("indexing_technique is required.")
|
||||
|
||||
embedding_model_provider = payload.embedding_model_provider
|
||||
embedding_model = payload.embedding_model
|
||||
if embedding_model_provider and embedding_model:
|
||||
DatasetService.check_embedding_model_setting(tenant_id, embedding_model_provider, embedding_model)
|
||||
|
||||
retrieval_model = payload.retrieval_model
|
||||
if (
|
||||
retrieval_model
|
||||
and retrieval_model.reranking_model
|
||||
and retrieval_model.reranking_model.reranking_provider_name
|
||||
and retrieval_model.reranking_model.reranking_model_name
|
||||
):
|
||||
DatasetService.check_reranking_model_setting(
|
||||
tenant_id,
|
||||
retrieval_model.reranking_model.reranking_provider_name,
|
||||
retrieval_model.reranking_model.reranking_model_name,
|
||||
)
|
||||
|
||||
if not current_user:
|
||||
raise ValueError("current_user is required")
|
||||
|
||||
upload_file = FileService(db.engine).upload_text(
|
||||
text=payload.text, text_name=payload.name, user_id=current_user.id, tenant_id=tenant_id
|
||||
)
|
||||
data_source = {
|
||||
"type": "upload_file",
|
||||
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
|
||||
)
|
||||
@service_api_ns.doc(params={"dataset_id": "Dataset ID"})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Document created successfully",
|
||||
401: "Unauthorized - invalid API token",
|
||||
400: "Bad request - invalid parameters",
|
||||
}
|
||||
args["data_source"] = data_source
|
||||
knowledge_config = KnowledgeConfig.model_validate(args)
|
||||
# validate args
|
||||
DocumentService.document_create_args_validate(knowledge_config)
|
||||
|
||||
if not current_user:
|
||||
raise ValueError("current_user is required")
|
||||
|
||||
try:
|
||||
documents, batch = DocumentService.save_document_with_dataset_id(
|
||||
dataset=dataset,
|
||||
knowledge_config=knowledge_config,
|
||||
account=current_user,
|
||||
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
|
||||
created_from="api",
|
||||
)
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
document = documents[0]
|
||||
|
||||
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch}
|
||||
return documents_and_batch_fields, 200
|
||||
)
|
||||
@cloud_edition_billing_resource_check("vector_space", "dataset")
|
||||
@cloud_edition_billing_resource_check("documents", "dataset")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
def post(self, tenant_id: str, dataset_id: UUID):
|
||||
"""Create document by text through the deprecated underscore alias."""
|
||||
return _create_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id)
|
||||
|
||||
|
||||
@service_api_ns.route(
|
||||
"/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update_by_text",
|
||||
"/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update-by-text",
|
||||
)
|
||||
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update-by-text")
|
||||
class DocumentUpdateByTextApi(DatasetApiResource):
|
||||
"""Resource for update documents."""
|
||||
"""Resource for the canonical text document update route."""
|
||||
|
||||
@service_api_ns.expect(service_api_ns.models[DocumentTextUpdate.__name__])
|
||||
@service_api_ns.doc("update_document_by_text")
|
||||
@@ -229,62 +324,35 @@ class DocumentUpdateByTextApi(DatasetApiResource):
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
|
||||
"""Update document by text."""
|
||||
payload = DocumentTextUpdate.model_validate(service_api_ns.payload or {})
|
||||
dataset = db.session.scalar(
|
||||
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == str(dataset_id)).limit(1)
|
||||
return _update_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
|
||||
|
||||
|
||||
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update_by_text")
|
||||
class DeprecatedDocumentUpdateByTextApi(DatasetApiResource):
|
||||
"""Deprecated resource alias for text document updates."""
|
||||
|
||||
@service_api_ns.expect(service_api_ns.models[DocumentTextUpdate.__name__])
|
||||
@service_api_ns.doc("update_document_by_text_deprecated")
|
||||
@service_api_ns.doc(deprecated=True)
|
||||
@service_api_ns.doc(
|
||||
description=(
|
||||
"Deprecated legacy alias for updating an existing document by providing text content. "
|
||||
"Use /datasets/{dataset_id}/documents/{document_id}/update-by-text instead."
|
||||
)
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
if not dataset:
|
||||
raise ValueError("Dataset does not exist.")
|
||||
|
||||
retrieval_model = payload.retrieval_model
|
||||
if (
|
||||
retrieval_model
|
||||
and retrieval_model.reranking_model
|
||||
and retrieval_model.reranking_model.reranking_provider_name
|
||||
and retrieval_model.reranking_model.reranking_model_name
|
||||
):
|
||||
DatasetService.check_reranking_model_setting(
|
||||
tenant_id,
|
||||
retrieval_model.reranking_model.reranking_provider_name,
|
||||
retrieval_model.reranking_model.reranking_model_name,
|
||||
)
|
||||
|
||||
# indexing_technique is already set in dataset since this is an update
|
||||
args["indexing_technique"] = dataset.indexing_technique
|
||||
|
||||
if args.get("text"):
|
||||
text = args.get("text")
|
||||
name = args.get("name")
|
||||
if not current_user:
|
||||
raise ValueError("current_user is required")
|
||||
upload_file = FileService(db.engine).upload_text(
|
||||
text=str(text), text_name=str(name), user_id=current_user.id, tenant_id=tenant_id
|
||||
)
|
||||
data_source = {
|
||||
"type": "upload_file",
|
||||
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
|
||||
}
|
||||
args["data_source"] = data_source
|
||||
# validate args
|
||||
args["original_document_id"] = str(document_id)
|
||||
knowledge_config = KnowledgeConfig.model_validate(args)
|
||||
DocumentService.document_create_args_validate(knowledge_config)
|
||||
|
||||
try:
|
||||
documents, batch = DocumentService.save_document_with_dataset_id(
|
||||
dataset=dataset,
|
||||
knowledge_config=knowledge_config,
|
||||
account=current_user,
|
||||
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
|
||||
created_from="api",
|
||||
)
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
document = documents[0]
|
||||
|
||||
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch}
|
||||
return documents_and_batch_fields, 200
|
||||
)
|
||||
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Document updated successfully",
|
||||
401: "Unauthorized - invalid API token",
|
||||
404: "Document not found",
|
||||
}
|
||||
)
|
||||
@cloud_edition_billing_resource_check("vector_space", "dataset")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
|
||||
"""Update document by text through the deprecated underscore alias."""
|
||||
return _update_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
|
||||
|
||||
|
||||
@service_api_ns.route(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH = 1000
|
||||
|
||||
|
||||
class SuggestedQuestionsAfterAnswerConfigManager:
|
||||
@classmethod
|
||||
@@ -20,7 +22,11 @@ class SuggestedQuestionsAfterAnswerConfigManager:
|
||||
@classmethod
|
||||
def validate_and_set_defaults(cls, config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||
"""
|
||||
Validate and set defaults for suggested questions feature
|
||||
Validate and set defaults for suggested questions feature.
|
||||
|
||||
Optional fields:
|
||||
- prompt: custom instruction prompt.
|
||||
- model: provider/model configuration for suggested question generation.
|
||||
|
||||
:param config: app model config args
|
||||
"""
|
||||
@@ -39,4 +45,27 @@ class SuggestedQuestionsAfterAnswerConfigManager:
|
||||
if not isinstance(config["suggested_questions_after_answer"]["enabled"], bool):
|
||||
raise ValueError("enabled in suggested_questions_after_answer must be of boolean type")
|
||||
|
||||
prompt = config["suggested_questions_after_answer"].get("prompt")
|
||||
if prompt is not None and not isinstance(prompt, str):
|
||||
raise ValueError("prompt in suggested_questions_after_answer must be of string type")
|
||||
if isinstance(prompt, str) and len(prompt) > CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH:
|
||||
raise ValueError(
|
||||
f"prompt in suggested_questions_after_answer must be less than or equal to "
|
||||
f"{CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH} characters"
|
||||
)
|
||||
|
||||
if "model" in config["suggested_questions_after_answer"]:
|
||||
model_config = config["suggested_questions_after_answer"]["model"]
|
||||
if not isinstance(model_config, dict):
|
||||
raise ValueError("model in suggested_questions_after_answer must be of object type")
|
||||
|
||||
if "provider" not in model_config or not isinstance(model_config["provider"], str):
|
||||
raise ValueError("provider in suggested_questions_after_answer.model must be of string type")
|
||||
|
||||
if "name" not in model_config or not isinstance(model_config["name"], str):
|
||||
raise ValueError("name in suggested_questions_after_answer.model must be of string type")
|
||||
|
||||
if "completion_params" in model_config and not isinstance(model_config["completion_params"], dict):
|
||||
raise ValueError("completion_params in suggested_questions_after_answer.model must be of object type")
|
||||
|
||||
return config, ["suggested_questions_after_answer"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Generator # Changed from Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
@@ -32,7 +32,7 @@ def get_current_file_access_scope() -> FileAccessScope | None:
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_file_access_scope(scope: FileAccessScope) -> Iterator[None]:
|
||||
def bind_file_access_scope(scope: FileAccessScope) -> Generator[None, None, None]: # Changed from Iterator[None]
|
||||
token = _current_file_access_scope.set(scope)
|
||||
try:
|
||||
yield
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Protocol, TypedDict, cast
|
||||
from typing import Any, NotRequired, Protocol, TypedDict, cast
|
||||
|
||||
import json_repair
|
||||
from sqlalchemy import select
|
||||
@@ -13,13 +13,13 @@ from core.llm_generator.output_parser.rule_config_generator import RuleConfigGen
|
||||
from core.llm_generator.output_parser.suggested_questions_after_answer import SuggestedQuestionsAfterAnswerOutputParser
|
||||
from core.llm_generator.prompts import (
|
||||
CONVERSATION_TITLE_PROMPT,
|
||||
DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
|
||||
GENERATOR_QA_PROMPT,
|
||||
JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE,
|
||||
LLM_MODIFY_CODE_SYSTEM,
|
||||
LLM_MODIFY_PROMPT_SYSTEM,
|
||||
PYTHON_CODE_GENERATOR_PROMPT_TEMPLATE,
|
||||
SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
SUGGESTED_QUESTIONS_TEMPERATURE,
|
||||
SYSTEM_STRUCTURED_OUTPUT_GENERATE,
|
||||
WORKFLOW_RULE_CONFIG_PROMPT_GENERATE_TEMPLATE,
|
||||
)
|
||||
@@ -41,6 +41,36 @@ from models.workflow import Workflow
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SuggestedQuestionsModelConfig(TypedDict):
|
||||
provider: str
|
||||
name: str
|
||||
completion_params: NotRequired[dict[str, object]]
|
||||
|
||||
|
||||
def _normalize_completion_params(completion_params: dict[str, object]) -> tuple[dict[str, object], list[str]]:
|
||||
"""
|
||||
Normalize raw completion params into invocation parameters and stop sequences.
|
||||
|
||||
This mirrors the app-model access path by separating ``stop`` from provider
|
||||
parameters before invocation, then drops non-positive token limits because
|
||||
some plugin-backed models reject ``0`` after mapping ``max_tokens`` to their
|
||||
provider-specific output-token field.
|
||||
"""
|
||||
normalized_parameters = dict(completion_params)
|
||||
stop_value = normalized_parameters.pop("stop", [])
|
||||
if isinstance(stop_value, list) and all(isinstance(item, str) for item in stop_value):
|
||||
stop = stop_value
|
||||
else:
|
||||
stop = []
|
||||
|
||||
for token_limit_key in ("max_tokens", "max_output_tokens"):
|
||||
token_limit = normalized_parameters.get(token_limit_key)
|
||||
if isinstance(token_limit, int | float) and token_limit <= 0:
|
||||
normalized_parameters.pop(token_limit_key, None)
|
||||
|
||||
return normalized_parameters, stop
|
||||
|
||||
|
||||
class WorkflowServiceInterface(Protocol):
|
||||
def get_draft_workflow(self, app_model: App, workflow_id: str | None = None) -> Workflow | None:
|
||||
pass
|
||||
@@ -123,8 +153,15 @@ class LLMGenerator:
|
||||
return name
|
||||
|
||||
@classmethod
|
||||
def generate_suggested_questions_after_answer(cls, tenant_id: str, histories: str) -> Sequence[str]:
|
||||
output_parser = SuggestedQuestionsAfterAnswerOutputParser()
|
||||
def generate_suggested_questions_after_answer(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
histories: str,
|
||||
*,
|
||||
instruction_prompt: str | None = None,
|
||||
model_config: object | None = None,
|
||||
) -> Sequence[str]:
|
||||
output_parser = SuggestedQuestionsAfterAnswerOutputParser(instruction_prompt=instruction_prompt)
|
||||
format_instructions = output_parser.get_format_instructions()
|
||||
|
||||
prompt_template = PromptTemplateParser(template="{{histories}}\n{{format_instructions}}\nquestions:\n")
|
||||
@@ -133,10 +170,36 @@ class LLMGenerator:
|
||||
|
||||
try:
|
||||
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
|
||||
model_instance = model_manager.get_default_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
configured_model = cast(dict[str, object], model_config) if isinstance(model_config, dict) else {}
|
||||
provider = configured_model.get("provider")
|
||||
model_name = configured_model.get("name")
|
||||
use_configured_model = False
|
||||
|
||||
if isinstance(provider, str) and provider and isinstance(model_name, str) and model_name:
|
||||
try:
|
||||
model_instance = model_manager.get_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
provider=provider,
|
||||
model=model_name,
|
||||
)
|
||||
use_configured_model = True
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to use configured suggested-questions model %s/%s, fallback to default model",
|
||||
provider,
|
||||
model_name,
|
||||
exc_info=True,
|
||||
)
|
||||
model_instance = model_manager.get_default_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
else:
|
||||
model_instance = model_manager.get_default_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
except InvokeAuthorizationError:
|
||||
return []
|
||||
|
||||
@@ -145,19 +208,29 @@ class LLMGenerator:
|
||||
questions: Sequence[str] = []
|
||||
|
||||
try:
|
||||
configured_completion_params = configured_model.get("completion_params")
|
||||
if use_configured_model and isinstance(configured_completion_params, dict):
|
||||
model_parameters, stop = _normalize_completion_params(configured_completion_params)
|
||||
elif use_configured_model:
|
||||
model_parameters = {}
|
||||
stop = []
|
||||
else:
|
||||
# Default-model generation keeps the built-in suggested-questions tuning.
|
||||
model_parameters = {
|
||||
"max_tokens": DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
"temperature": DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
|
||||
}
|
||||
stop = []
|
||||
|
||||
response: LLMResult = model_instance.invoke_llm(
|
||||
prompt_messages=list(prompt_messages),
|
||||
model_parameters={
|
||||
"max_tokens": SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
"temperature": SUGGESTED_QUESTIONS_TEMPERATURE,
|
||||
},
|
||||
model_parameters=model_parameters,
|
||||
stop=stop,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
text_content = response.message.get_text_content()
|
||||
questions = output_parser.parse(text_content) if text_content else []
|
||||
except InvokeError:
|
||||
questions = []
|
||||
except Exception:
|
||||
logger.exception("Failed to generate suggested questions after answer")
|
||||
questions = []
|
||||
|
||||
@@ -3,17 +3,21 @@ import logging
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
|
||||
from core.llm_generator.prompts import SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
|
||||
from core.llm_generator.prompts import DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SuggestedQuestionsAfterAnswerOutputParser:
|
||||
def __init__(self, instruction_prompt: str | None = None) -> None:
|
||||
self._instruction_prompt = instruction_prompt or DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
|
||||
|
||||
def get_format_instructions(self) -> str:
|
||||
return SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
|
||||
return self._instruction_prompt
|
||||
|
||||
def parse(self, text: str) -> Sequence[str]:
|
||||
action_match = re.search(r"\[.*?\]", text.strip(), re.DOTALL)
|
||||
stripped_text = text.strip()
|
||||
action_match = re.search(r"\[.*?\]", stripped_text, re.DOTALL)
|
||||
questions: list[str] = []
|
||||
if action_match is not None:
|
||||
try:
|
||||
@@ -23,4 +27,6 @@ class SuggestedQuestionsAfterAnswerOutputParser:
|
||||
else:
|
||||
if isinstance(json_obj, list):
|
||||
questions = [question for question in json_obj if isinstance(question, str)]
|
||||
elif stripped_text:
|
||||
logger.warning("Failed to find suggested questions payload array in text: %r", stripped_text[:200])
|
||||
return questions
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# Written by YORKI MINAKO🤡, Edited by Xiaoyi, Edited by yasu-oh
|
||||
import os
|
||||
|
||||
CONVERSATION_TITLE_PROMPT = """You are asked to generate a concise chat title by decomposing the user’s input into two parts: “Intention” and “Subject”.
|
||||
|
||||
@@ -96,8 +95,8 @@ JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE = (
|
||||
)
|
||||
|
||||
|
||||
# Default prompt for suggested questions (can be overridden by environment variable)
|
||||
_DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_PROMPT = (
|
||||
# Default prompt and model parameters for suggested questions.
|
||||
DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT = (
|
||||
"Please help me predict the three most likely questions that human would ask, "
|
||||
"and keep each question under 20 characters.\n"
|
||||
"MAKE SURE your output is the SAME language as the Assistant's latest response. "
|
||||
@@ -105,14 +104,8 @@ _DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_PROMPT = (
|
||||
'["question1","question2","question3"]\n'
|
||||
)
|
||||
|
||||
# Environment variable override for suggested questions prompt
|
||||
SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT = os.getenv(
|
||||
"SUGGESTED_QUESTIONS_PROMPT", _DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_PROMPT
|
||||
)
|
||||
|
||||
# Configurable LLM parameters for suggested questions (can be overridden by environment variables)
|
||||
SUGGESTED_QUESTIONS_MAX_TOKENS = int(os.getenv("SUGGESTED_QUESTIONS_MAX_TOKENS", "256"))
|
||||
SUGGESTED_QUESTIONS_TEMPERATURE = float(os.getenv("SUGGESTED_QUESTIONS_TEMPERATURE", "0"))
|
||||
DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS = 256
|
||||
DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE = 0.0
|
||||
|
||||
GENERATOR_QA_PROMPT = (
|
||||
"<Task> The user will send a long text. Generate a Question and Answer pairs only using the knowledge"
|
||||
|
||||
@@ -70,12 +70,32 @@ class ProviderManager:
|
||||
Request-bound managers may carry caller identity in that runtime, and the
|
||||
resulting ``ProviderConfiguration`` objects must reuse it for downstream
|
||||
model-type and schema lookups.
|
||||
|
||||
Configuration assembly is cached per manager instance so call chains that
|
||||
share one request-scoped manager can reuse the same provider graph instead
|
||||
of rebuilding it for every lookup. Call ``clear_configurations_cache()``
|
||||
when a long-lived manager needs to observe writes performed within the same
|
||||
instance scope.
|
||||
"""
|
||||
|
||||
decoding_rsa_key: Any | None
|
||||
decoding_cipher_rsa: Any | None
|
||||
_model_runtime: ModelRuntime
|
||||
_configurations_cache: dict[str, ProviderConfigurations]
|
||||
|
||||
def __init__(self, model_runtime: ModelRuntime):
|
||||
self.decoding_rsa_key = None
|
||||
self.decoding_cipher_rsa = None
|
||||
self._model_runtime = model_runtime
|
||||
self._configurations_cache = {}
|
||||
|
||||
def clear_configurations_cache(self, tenant_id: str | None = None) -> None:
|
||||
"""Drop assembled provider configurations cached on this manager instance."""
|
||||
if tenant_id is None:
|
||||
self._configurations_cache.clear()
|
||||
return
|
||||
|
||||
self._configurations_cache.pop(tenant_id, None)
|
||||
|
||||
def get_configurations(self, tenant_id: str) -> ProviderConfigurations:
|
||||
"""
|
||||
@@ -114,6 +134,10 @@ class ProviderManager:
|
||||
:param tenant_id:
|
||||
:return:
|
||||
"""
|
||||
cached_configurations = self._configurations_cache.get(tenant_id)
|
||||
if cached_configurations is not None:
|
||||
return cached_configurations
|
||||
|
||||
# Get all provider records of the workspace
|
||||
provider_name_to_provider_records_dict = self._get_all_providers(tenant_id)
|
||||
|
||||
@@ -273,6 +297,8 @@ class ProviderManager:
|
||||
|
||||
provider_configurations[str(provider_id_entity)] = provider_configuration
|
||||
|
||||
self._configurations_cache[tenant_id] = provider_configurations
|
||||
|
||||
# Return the encapsulated object
|
||||
return provider_configurations
|
||||
|
||||
|
||||
@@ -139,8 +139,10 @@ class Jieba(BaseKeyword):
|
||||
"__data__": {"index_id": self.dataset.id, "summary": None, "table": keyword_table},
|
||||
}
|
||||
dataset_keyword_table = self.dataset.dataset_keyword_table
|
||||
keyword_data_source_type = dataset_keyword_table.data_source_type
|
||||
keyword_data_source_type = dataset_keyword_table.data_source_type if dataset_keyword_table else "file"
|
||||
if keyword_data_source_type == "database":
|
||||
if dataset_keyword_table is None:
|
||||
return
|
||||
dataset_keyword_table.keyword_table = dumps_with_sets(keyword_table_dict)
|
||||
db.session.commit()
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from operator import itemgetter
|
||||
from typing import cast
|
||||
|
||||
@@ -80,12 +81,14 @@ class JiebaKeywordTableHandler:
|
||||
|
||||
def extract_tags(self, sentence: str, top_k: int | None = 20, **kwargs):
|
||||
# Basic frequency-based keyword extraction as a fallback when TF-IDF is unavailable.
|
||||
top_k = kwargs.pop("topK", top_k)
|
||||
top_k = cast(int | None, kwargs.pop("topK", top_k))
|
||||
if top_k is None:
|
||||
top_k = 20
|
||||
cut = getattr(jieba, "cut", None)
|
||||
if self._lcut:
|
||||
tokens = self._lcut(sentence)
|
||||
elif callable(cut):
|
||||
tokens = list(cut(sentence))
|
||||
tokens = list(cast(Callable[[str], list[str]], cut)(sentence))
|
||||
else:
|
||||
tokens = re.findall(r"\w+", sentence)
|
||||
|
||||
@@ -108,7 +111,7 @@ class JiebaKeywordTableHandler:
|
||||
sentence=text,
|
||||
topK=max_keywords_per_chunk,
|
||||
)
|
||||
# jieba.analyse.extract_tags returns list[Any] when withFlag is False by default.
|
||||
# jieba.analyse.extract_tags returns an untyped list when withFlag is False by default.
|
||||
keywords = cast(list[str], keywords)
|
||||
|
||||
return set(self._expand_tokens_with_subtokens(set(keywords)))
|
||||
|
||||
@@ -158,7 +158,7 @@ class RetrievalService:
|
||||
)
|
||||
|
||||
if futures:
|
||||
for future in concurrent.futures.as_completed(futures, timeout=3600):
|
||||
for _ in concurrent.futures.as_completed(futures, timeout=3600):
|
||||
if exceptions:
|
||||
for f in futures:
|
||||
f.cancel()
|
||||
|
||||
@@ -94,6 +94,7 @@ class ExtractProcessor:
|
||||
cls, extract_setting: ExtractSetting, is_automatic: bool = False, file_path: str | None = None
|
||||
) -> list[Document]:
|
||||
if extract_setting.datasource_type == DatasourceType.FILE:
|
||||
upload_file = extract_setting.upload_file
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
upload_file = extract_setting.upload_file
|
||||
if not file_path:
|
||||
@@ -104,6 +105,7 @@ class ExtractProcessor:
|
||||
storage.download(upload_file.key, file_path)
|
||||
input_file = Path(file_path)
|
||||
file_extension = input_file.suffix.lower()
|
||||
assert upload_file is not None, "upload_file is required"
|
||||
etl_type = dify_config.ETL_TYPE
|
||||
extractor: BaseExtractor | None = None
|
||||
if etl_type == "Unstructured":
|
||||
|
||||
@@ -28,7 +28,7 @@ class FunctionCallMultiDatasetRouter:
|
||||
SystemPromptMessage(content="You are a helpful AI assistant."),
|
||||
UserPromptMessage(content=query),
|
||||
]
|
||||
result: LLMResult = model_instance.invoke_llm(
|
||||
result: LLMResult = model_instance.invoke_llm( # pyright: ignore[reportCallIssue, reportArgumentType]
|
||||
prompt_messages=prompt_messages,
|
||||
tools=dataset_tools,
|
||||
stream=False,
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import re
|
||||
from collections.abc import Collection
|
||||
from collections.abc import Set as AbstractSet
|
||||
from typing import Any, Literal
|
||||
|
||||
from core.model_manager import ModelInstance
|
||||
@@ -21,8 +21,8 @@ class EnhanceRecursiveCharacterTextSplitter(RecursiveCharacterTextSplitter):
|
||||
def from_encoder[T: EnhanceRecursiveCharacterTextSplitter](
|
||||
cls: type[T],
|
||||
embedding_model_instance: ModelInstance | None,
|
||||
allowed_special: Literal["all"] | set[str] = set(),
|
||||
disallowed_special: Literal["all"] | Collection[str] = "all",
|
||||
allowed_special: Literal["all"] | AbstractSet[str] = frozenset(),
|
||||
disallowed_special: Literal["all"] | AbstractSet[str] = "all",
|
||||
**kwargs: Any,
|
||||
) -> T:
|
||||
def _token_encoder(texts: list[str]) -> list[int]:
|
||||
@@ -40,6 +40,7 @@ class EnhanceRecursiveCharacterTextSplitter(RecursiveCharacterTextSplitter):
|
||||
|
||||
return [len(text) for text in texts]
|
||||
|
||||
_ = _token_encoder # kept for future token-length wiring
|
||||
return cls(length_function=_character_encoder, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import copy
|
||||
import logging
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable, Collection, Iterable, Sequence, Set
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -187,8 +188,8 @@ class TokenTextSplitter(TextSplitter):
|
||||
self,
|
||||
encoding_name: str = "gpt2",
|
||||
model_name: str | None = None,
|
||||
allowed_special: Literal["all"] | Set[str] = set(),
|
||||
disallowed_special: Literal["all"] | Collection[str] = "all",
|
||||
allowed_special: Literal["all"] | AbstractSet[str] = frozenset(),
|
||||
disallowed_special: Literal["all"] | AbstractSet[str] = "all",
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""Create a new TextSplitter."""
|
||||
@@ -207,8 +208,8 @@ class TokenTextSplitter(TextSplitter):
|
||||
else:
|
||||
enc = tiktoken.get_encoding(encoding_name)
|
||||
self._tokenizer = enc
|
||||
self._allowed_special = allowed_special
|
||||
self._disallowed_special = disallowed_special
|
||||
self._allowed_special: Literal["all"] | AbstractSet[str] = allowed_special
|
||||
self._disallowed_special: Literal["all"] | AbstractSet[str] = disallowed_special
|
||||
|
||||
def split_text(self, text: str) -> list[str]:
|
||||
def _encode(_text: str) -> list[int]:
|
||||
|
||||
@@ -105,7 +105,7 @@ class Article:
|
||||
|
||||
|
||||
def extract_using_readabilipy(html: str):
|
||||
json_article: dict[str, Any] = simple_json_from_html_string(html, use_readability=True)
|
||||
json_article: dict[str, Any] = simple_json_from_html_string(html, use_readability=False)
|
||||
article = Article(
|
||||
title=json_article.get("title") or "",
|
||||
author=json_article.get("byline") or "",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Generate Flask-RESTX Swagger 2.0 specs without booting the full backend.
|
||||
|
||||
This helper intentionally avoids `app_factory.create_app()`. The normal backend
|
||||
startup eagerly initializes database, Redis, Celery, and storage extensions,
|
||||
which is unnecessary when the goal is only to serialize the Flask-RESTX
|
||||
`/swagger.json` documents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask
|
||||
from flask_restx.swagger import Swagger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(API_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(API_ROOT))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecTarget:
|
||||
route: str
|
||||
filename: str
|
||||
|
||||
|
||||
SPEC_TARGETS: tuple[SpecTarget, ...] = (
|
||||
SpecTarget(route="/console/api/swagger.json", filename="console-swagger.json"),
|
||||
SpecTarget(route="/api/swagger.json", filename="web-swagger.json"),
|
||||
SpecTarget(route="/v1/swagger.json", filename="service-swagger.json"),
|
||||
)
|
||||
|
||||
_ORIGINAL_REGISTER_MODEL = Swagger.register_model
|
||||
_ORIGINAL_REGISTER_FIELD = Swagger.register_field
|
||||
|
||||
|
||||
def _apply_runtime_defaults() -> None:
|
||||
"""Force the small config surface required for Swagger generation."""
|
||||
|
||||
os.environ.setdefault("SECRET_KEY", "spec-export")
|
||||
os.environ.setdefault("STORAGE_TYPE", "local")
|
||||
os.environ.setdefault("STORAGE_LOCAL_PATH", "/tmp/dify-storage")
|
||||
os.environ.setdefault("SWAGGER_UI_ENABLED", "true")
|
||||
|
||||
from configs import dify_config
|
||||
|
||||
dify_config.SECRET_KEY = os.environ["SECRET_KEY"]
|
||||
dify_config.STORAGE_TYPE = "local"
|
||||
dify_config.STORAGE_LOCAL_PATH = os.environ["STORAGE_LOCAL_PATH"]
|
||||
dify_config.SWAGGER_UI_ENABLED = os.environ["SWAGGER_UI_ENABLED"].lower() == "true"
|
||||
|
||||
|
||||
def _patch_swagger_for_inline_nested_dicts() -> None:
|
||||
"""Teach Flask-RESTX Swagger generation to tolerate inline nested field maps.
|
||||
|
||||
Some existing controllers use `fields.Nested({...})` with a raw field mapping
|
||||
instead of a named `api.model(...)`. Flask-RESTX crashes on those anonymous
|
||||
dicts during schema registration, so this helper upgrades them into temporary
|
||||
named models at export time.
|
||||
"""
|
||||
|
||||
if getattr(Swagger, "_dify_inline_nested_dict_patch", False):
|
||||
return
|
||||
|
||||
def get_or_create_inline_model(self: Swagger, nested_fields: dict[object, object]) -> object:
|
||||
anonymous_models = getattr(self, "_anonymous_inline_models", None)
|
||||
if anonymous_models is None:
|
||||
anonymous_models = {}
|
||||
self._anonymous_inline_models = anonymous_models
|
||||
|
||||
anonymous_name = anonymous_models.get(id(nested_fields))
|
||||
if anonymous_name is None:
|
||||
anonymous_name = f"_AnonymousInlineModel{len(anonymous_models) + 1}"
|
||||
anonymous_models[id(nested_fields)] = anonymous_name
|
||||
self.api.model(anonymous_name, nested_fields)
|
||||
|
||||
return self.api.models[anonymous_name]
|
||||
|
||||
def register_model_with_inline_dict_support(self: Swagger, model: object) -> dict[str, str]:
|
||||
if isinstance(model, dict):
|
||||
model = get_or_create_inline_model(self, model)
|
||||
|
||||
return _ORIGINAL_REGISTER_MODEL(self, model)
|
||||
|
||||
def register_field_with_inline_dict_support(self: Swagger, field: object) -> None:
|
||||
nested = getattr(field, "nested", None)
|
||||
if isinstance(nested, dict):
|
||||
field.model = get_or_create_inline_model(self, nested) # type: ignore
|
||||
|
||||
_ORIGINAL_REGISTER_FIELD(self, field)
|
||||
|
||||
Swagger.register_model = register_model_with_inline_dict_support
|
||||
Swagger.register_field = register_field_with_inline_dict_support
|
||||
Swagger._dify_inline_nested_dict_patch = True
|
||||
|
||||
|
||||
def create_spec_app() -> Flask:
|
||||
"""Build a minimal Flask app that only mounts the Swagger-producing blueprints."""
|
||||
|
||||
_apply_runtime_defaults()
|
||||
_patch_swagger_for_inline_nested_dicts()
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
from controllers.console import bp as console_bp
|
||||
from controllers.service_api import bp as service_api_bp
|
||||
from controllers.web import bp as web_bp
|
||||
|
||||
app.register_blueprint(console_bp)
|
||||
app.register_blueprint(web_bp)
|
||||
app.register_blueprint(service_api_bp)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def generate_specs(output_dir: Path) -> list[Path]:
|
||||
"""Write all Swagger specs to `output_dir` and return the written paths."""
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app = create_spec_app()
|
||||
client = app.test_client()
|
||||
|
||||
written_paths: list[Path] = []
|
||||
for target in SPEC_TARGETS:
|
||||
response = client.get(target.route)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f"failed to fetch {target.route}: {response.status_code}")
|
||||
|
||||
payload = response.get_json()
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError(f"unexpected response payload for {target.route}")
|
||||
|
||||
output_path = output_dir / target.filename
|
||||
output_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
written_paths.append(output_path)
|
||||
|
||||
return written_paths
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output-dir",
|
||||
type=Path,
|
||||
default=Path("openapi"),
|
||||
help="Directory where the Swagger JSON files will be written.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
written_paths = generate_specs(args.output_dir)
|
||||
|
||||
for path in written_paths:
|
||||
logger.debug(path)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,5 +1,5 @@
|
||||
import contextvars
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Generator # Changed from Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
||||
def preserve_flask_contexts(
|
||||
flask_app: Flask,
|
||||
context_vars: contextvars.Context,
|
||||
) -> Iterator[None]:
|
||||
) -> Generator[None, None, None]: # Changed from Iterator[None]
|
||||
"""
|
||||
A context manager that handles:
|
||||
1. flask-login's UserProxy copy
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy import Index, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .account import Account
|
||||
from .base import Base
|
||||
from .base import Base, gen_uuidv7_string
|
||||
from .engine import db
|
||||
from .types import StringUUID
|
||||
|
||||
@@ -42,7 +42,7 @@ class WorkflowComment(Base):
|
||||
Index("workflow_comments_created_at_idx", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuidv7()"))
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string)
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
position_x: Mapped[float] = mapped_column(sa.Float)
|
||||
@@ -149,7 +149,7 @@ class WorkflowCommentReply(Base):
|
||||
Index("comment_replies_created_at_idx", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuidv7()"))
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string)
|
||||
comment_id: Mapped[str] = mapped_column(
|
||||
StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
@@ -194,7 +194,7 @@ class WorkflowCommentMention(Base):
|
||||
Index("comment_mentions_user_idx", "mentioned_user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuidv7()"))
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string)
|
||||
comment_id: Mapped[str] = mapped_column(
|
||||
StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
+21
-3
@@ -91,6 +91,19 @@ class EnabledConfig(TypedDict):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class SuggestedQuestionsAfterAnswerModelConfig(TypedDict):
|
||||
provider: str
|
||||
name: str
|
||||
mode: NotRequired[str]
|
||||
completion_params: NotRequired[dict[str, Any]]
|
||||
|
||||
|
||||
class SuggestedQuestionsAfterAnswerConfig(TypedDict):
|
||||
enabled: bool
|
||||
model: NotRequired[SuggestedQuestionsAfterAnswerModelConfig]
|
||||
prompt: NotRequired[str]
|
||||
|
||||
|
||||
class EmbeddingModelInfo(TypedDict):
|
||||
embedding_provider_name: str
|
||||
embedding_model_name: str
|
||||
@@ -220,7 +233,7 @@ class ModelConfig(TypedDict):
|
||||
class AppModelConfigDict(TypedDict):
|
||||
opening_statement: str | None
|
||||
suggested_questions: list[str]
|
||||
suggested_questions_after_answer: EnabledConfig
|
||||
suggested_questions_after_answer: SuggestedQuestionsAfterAnswerConfig
|
||||
speech_to_text: EnabledConfig
|
||||
text_to_speech: EnabledConfig
|
||||
retriever_resource: EnabledConfig
|
||||
@@ -680,8 +693,13 @@ class AppModelConfig(TypeBase):
|
||||
return cast(EnabledConfig, json.loads(value) if value else {"enabled": default_enabled})
|
||||
|
||||
@property
|
||||
def suggested_questions_after_answer_dict(self) -> EnabledConfig:
|
||||
return self._get_enabled_config(self.suggested_questions_after_answer)
|
||||
def suggested_questions_after_answer_dict(self) -> SuggestedQuestionsAfterAnswerConfig:
|
||||
return cast(
|
||||
SuggestedQuestionsAfterAnswerConfig,
|
||||
json.loads(self.suggested_questions_after_answer)
|
||||
if self.suggested_questions_after_answer
|
||||
else {"enabled": False},
|
||||
)
|
||||
|
||||
@property
|
||||
def speech_to_text_dict(self) -> EnabledConfig:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Generator # Added Generator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
@@ -75,7 +75,7 @@ class AnalyticdbVectorBySql:
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _get_cursor(self) -> Iterator[Any]:
|
||||
def _get_cursor(self) -> Generator[Any, None, None]: # Changed from Iterator[Any]
|
||||
assert self.pool is not None, "Connection pool is not initialized"
|
||||
conn = self.pool.getconn()
|
||||
cur = conn.cursor()
|
||||
|
||||
+7
-7
@@ -114,10 +114,10 @@ override-dependencies = [
|
||||
dev = [
|
||||
"coverage>=7.13.4",
|
||||
"dotenv-linter>=0.7.0",
|
||||
"faker>=20.1.0",
|
||||
"faker>=40.15.0",
|
||||
"lxml-stubs>=0.5.1",
|
||||
"basedpyright>=1.39.0",
|
||||
"ruff>=0.15.10",
|
||||
"basedpyright>=1.39.3",
|
||||
"ruff>=0.15.11",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-benchmark>=5.2.3",
|
||||
"pytest-cov>=7.1.0",
|
||||
@@ -157,14 +157,14 @@ dev = [
|
||||
"types-tensorflow>=2.18.0.20260408",
|
||||
"types-tqdm>=4.67.3.20260408",
|
||||
"types-ujson>=5.10.0",
|
||||
"boto3-stubs>=1.42.88",
|
||||
"boto3-stubs>=1.42.92",
|
||||
"types-jmespath>=1.1.0.20260408",
|
||||
"hypothesis>=6.151.12",
|
||||
"hypothesis>=6.152.1",
|
||||
"types_pyOpenSSL>=24.1.0",
|
||||
"types_cffi>=2.0.0.20260408",
|
||||
"types_setuptools>=82.0.0.20260408",
|
||||
"pandas-stubs>=3.0.0",
|
||||
"scipy-stubs>=1.15.3.0",
|
||||
"scipy-stubs>=1.17.1.4",
|
||||
"types-python-http-client>=3.3.7.20260408",
|
||||
"import-linter>=2.3",
|
||||
"types-redis>=4.6.0.20241004",
|
||||
@@ -173,7 +173,7 @@ dev = [
|
||||
# "locust>=2.40.4", # Temporarily removed due to compatibility issues. Uncomment when resolved.
|
||||
"pytest-timeout>=2.4.0",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"pyrefly>=0.61.1",
|
||||
"pyrefly>=0.62.0",
|
||||
"xinference-client>=2.5.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import base64
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Iterator, Sequence
|
||||
from collections.abc import Generator, Sequence # Changed Iterator to Generator
|
||||
from contextlib import contextmanager, suppress
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Literal
|
||||
@@ -324,7 +324,7 @@ class FileService:
|
||||
def build_upload_files_zip_tempfile(
|
||||
*,
|
||||
upload_files: Sequence[UploadFile],
|
||||
) -> Iterator[str]:
|
||||
) -> Generator[str, None, None]: # Changed from Iterator[str]
|
||||
"""
|
||||
Build a ZIP from `UploadFile`s and yield a tempfile path.
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, TypedDict
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
from core.rag.datasource.retrieval_service import RetrievalService
|
||||
from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService
|
||||
from core.rag.index_processor.constant.query_type import QueryType
|
||||
from core.rag.models.document import Document
|
||||
from core.rag.retrieval.dataset_retrieval import DatasetRetrieval
|
||||
@@ -36,6 +36,10 @@ default_retrieval_model = {
|
||||
}
|
||||
|
||||
|
||||
class HitTestingRetrievalModelDict(DefaultRetrievalModelDict, total=False):
|
||||
metadata_filtering_conditions: dict[str, Any]
|
||||
|
||||
|
||||
class HitTestingService:
|
||||
@classmethod
|
||||
def retrieve(
|
||||
@@ -51,17 +55,18 @@ class HitTestingService:
|
||||
start = time.perf_counter()
|
||||
|
||||
# get retrieval model , if the model is not setting , using default
|
||||
if not retrieval_model:
|
||||
retrieval_model = dataset.retrieval_model or default_retrieval_model
|
||||
assert isinstance(retrieval_model, dict)
|
||||
resolved_retrieval_model = cast(
|
||||
HitTestingRetrievalModelDict,
|
||||
retrieval_model or dataset.retrieval_model or default_retrieval_model,
|
||||
)
|
||||
document_ids_filter = None
|
||||
metadata_filtering_conditions = retrieval_model.get("metadata_filtering_conditions", {})
|
||||
if metadata_filtering_conditions and query:
|
||||
metadata_filtering_conditions_raw = resolved_retrieval_model.get("metadata_filtering_conditions", {})
|
||||
if metadata_filtering_conditions_raw and query:
|
||||
dataset_retrieval = DatasetRetrieval()
|
||||
|
||||
from core.rag.entities import MetadataFilteringCondition
|
||||
|
||||
metadata_filtering_conditions = MetadataFilteringCondition.model_validate(metadata_filtering_conditions)
|
||||
metadata_filtering_conditions = MetadataFilteringCondition.model_validate(metadata_filtering_conditions_raw)
|
||||
|
||||
metadata_filter_document_ids, metadata_condition = dataset_retrieval.get_metadata_filter_condition(
|
||||
dataset_ids=[dataset.id],
|
||||
@@ -78,19 +83,21 @@ class HitTestingService:
|
||||
if metadata_condition and not document_ids_filter:
|
||||
return cls.compact_retrieve_response(query, [])
|
||||
all_documents = RetrievalService.retrieve(
|
||||
retrieval_method=RetrievalMethod(retrieval_model.get("search_method", RetrievalMethod.SEMANTIC_SEARCH)),
|
||||
retrieval_method=RetrievalMethod(
|
||||
resolved_retrieval_model.get("search_method", RetrievalMethod.SEMANTIC_SEARCH)
|
||||
),
|
||||
dataset_id=dataset.id,
|
||||
query=query,
|
||||
attachment_ids=attachment_ids,
|
||||
top_k=retrieval_model.get("top_k", 4),
|
||||
score_threshold=retrieval_model.get("score_threshold", 0.0)
|
||||
if retrieval_model["score_threshold_enabled"]
|
||||
top_k=resolved_retrieval_model.get("top_k", 4),
|
||||
score_threshold=resolved_retrieval_model.get("score_threshold", 0.0)
|
||||
if resolved_retrieval_model["score_threshold_enabled"]
|
||||
else 0.0,
|
||||
reranking_model=retrieval_model.get("reranking_model", None)
|
||||
if retrieval_model["reranking_enable"]
|
||||
reranking_model=resolved_retrieval_model.get("reranking_model", None)
|
||||
if resolved_retrieval_model["reranking_enable"]
|
||||
else None,
|
||||
reranking_mode=retrieval_model.get("reranking_mode") or "reranking_model",
|
||||
weights=retrieval_model.get("weights", None),
|
||||
reranking_mode=resolved_retrieval_model.get("reranking_mode") or "reranking_model",
|
||||
weights=resolved_retrieval_model.get("weights", None),
|
||||
document_ids_filter=document_ids_filter,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from typing import cast
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
@@ -17,7 +19,16 @@ from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||
from models import Account
|
||||
from models.enums import FeedbackFromSource, FeedbackRating
|
||||
from models.model import App, AppMode, AppModelConfig, AppModelConfigDict, EndUser, Message, MessageFeedback
|
||||
from models.model import (
|
||||
App,
|
||||
AppMode,
|
||||
AppModelConfig,
|
||||
AppModelConfigDict,
|
||||
EndUser,
|
||||
Message,
|
||||
MessageFeedback,
|
||||
SuggestedQuestionsAfterAnswerConfig,
|
||||
)
|
||||
from repositories.execution_extra_content_repository import ExecutionExtraContentRepository
|
||||
from repositories.sqlalchemy_execution_extra_content_repository import (
|
||||
SQLAlchemyExecutionExtraContentRepository,
|
||||
@@ -32,6 +43,7 @@ from services.errors.message import (
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
_app_model_config_adapter: TypeAdapter[AppModelConfigDict] = TypeAdapter(AppModelConfigDict)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _create_execution_extra_content_repository() -> ExecutionExtraContentRepository:
|
||||
@@ -252,6 +264,7 @@ class MessageService:
|
||||
)
|
||||
|
||||
model_manager = ModelManager.for_tenant(tenant_id=app_model.tenant_id)
|
||||
suggested_questions_after_answer_config: SuggestedQuestionsAfterAnswerConfig = {"enabled": False}
|
||||
|
||||
if app_model.mode == AppMode.ADVANCED_CHAT:
|
||||
workflow_service = WorkflowService()
|
||||
@@ -271,9 +284,11 @@ class MessageService:
|
||||
if not app_config.additional_features.suggested_questions_after_answer:
|
||||
raise SuggestedQuestionsAfterAnswerDisabledError()
|
||||
|
||||
model_instance = model_manager.get_default_model_instance(
|
||||
tenant_id=app_model.tenant_id, model_type=ModelType.LLM
|
||||
)
|
||||
suggested_questions_after_answer = workflow.features_dict.get("suggested_questions_after_answer")
|
||||
if isinstance(suggested_questions_after_answer, dict):
|
||||
suggested_questions_after_answer_config = cast(
|
||||
SuggestedQuestionsAfterAnswerConfig, suggested_questions_after_answer
|
||||
)
|
||||
else:
|
||||
if not conversation.override_model_configs:
|
||||
app_model_config = db.session.scalar(
|
||||
@@ -293,16 +308,14 @@ class MessageService:
|
||||
if not app_model_config:
|
||||
raise ValueError("did not find app model config")
|
||||
|
||||
suggested_questions_after_answer = app_model_config.suggested_questions_after_answer_dict
|
||||
if suggested_questions_after_answer.get("enabled", False) is False:
|
||||
suggested_questions_after_answer_config = app_model_config.suggested_questions_after_answer_dict
|
||||
if suggested_questions_after_answer_config.get("enabled", False) is False:
|
||||
raise SuggestedQuestionsAfterAnswerDisabledError()
|
||||
|
||||
model_instance = model_manager.get_model_instance(
|
||||
tenant_id=app_model.tenant_id,
|
||||
provider=app_model_config.model_dict["provider"],
|
||||
model_type=ModelType.LLM,
|
||||
model=app_model_config.model_dict["name"],
|
||||
)
|
||||
model_instance = model_manager.get_default_model_instance(
|
||||
tenant_id=app_model.tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
|
||||
# get memory of conversation (read-only)
|
||||
memory = TokenBufferMemory(conversation=conversation, model_instance=model_instance)
|
||||
@@ -312,9 +325,17 @@ class MessageService:
|
||||
message_limit=3,
|
||||
)
|
||||
|
||||
instruction_prompt = suggested_questions_after_answer_config.get("prompt")
|
||||
if not isinstance(instruction_prompt, str) or not instruction_prompt.strip():
|
||||
instruction_prompt = None
|
||||
|
||||
configured_model = suggested_questions_after_answer_config.get("model")
|
||||
with measure_time() as timer:
|
||||
questions_sequence = LLMGenerator.generate_suggested_questions_after_answer(
|
||||
tenant_id=app_model.tenant_id, histories=histories
|
||||
tenant_id=app_model.tenant_id,
|
||||
histories=histories,
|
||||
instruction_prompt=instruction_prompt,
|
||||
model_config=configured_model,
|
||||
)
|
||||
questions: list[str] = list(questions_sequence)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class PluginAutoUpgradeService:
|
||||
exclude_plugins: list[str],
|
||||
include_plugins: list[str],
|
||||
) -> bool:
|
||||
with session_factory.create_session() as session:
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
exist_strategy = session.scalar(
|
||||
select(TenantPluginAutoUpgradeStrategy)
|
||||
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
|
||||
@@ -50,7 +50,7 @@ class PluginAutoUpgradeService:
|
||||
|
||||
@staticmethod
|
||||
def exclude_plugin(tenant_id: str, plugin_id: str) -> bool:
|
||||
with session_factory.create_session() as session:
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
exist_strategy = session.scalar(
|
||||
select(TenantPluginAutoUpgradeStrategy)
|
||||
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Unit tests for the standalone Swagger export helper."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_generate_swagger_specs_module():
|
||||
api_dir = Path(__file__).resolve().parents[3]
|
||||
script_path = api_dir / "dev" / "generate_swagger_specs.py"
|
||||
|
||||
spec = importlib.util.spec_from_file_location("generate_swagger_specs", script_path)
|
||||
assert spec
|
||||
assert spec.loader
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module) # type: ignore[attr-defined]
|
||||
return module
|
||||
|
||||
|
||||
def test_generate_specs_writes_console_web_and_service_swagger_files(tmp_path):
|
||||
module = _load_generate_swagger_specs_module()
|
||||
|
||||
written_paths = module.generate_specs(tmp_path)
|
||||
|
||||
assert [path.name for path in written_paths] == [
|
||||
"console-swagger.json",
|
||||
"web-swagger.json",
|
||||
"service-swagger.json",
|
||||
]
|
||||
|
||||
for path in written_paths:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert payload["swagger"] == "2.0"
|
||||
assert "paths" in payload
|
||||
@@ -2,14 +2,17 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.workspace.endpoint import (
|
||||
EndpointCreateApi,
|
||||
EndpointDeleteApi,
|
||||
DeprecatedEndpointCreateApi,
|
||||
DeprecatedEndpointDeleteApi,
|
||||
DeprecatedEndpointUpdateApi,
|
||||
EndpointCollectionApi,
|
||||
EndpointDisableApi,
|
||||
EndpointEnableApi,
|
||||
EndpointItemApi,
|
||||
EndpointListApi,
|
||||
EndpointListForSinglePluginApi,
|
||||
EndpointUpdateApi,
|
||||
)
|
||||
from core.plugin.impl.exc import PluginPermissionDeniedError
|
||||
|
||||
@@ -35,9 +38,9 @@ def patch_current_account(user_and_tenant):
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("patch_current_account")
|
||||
class TestEndpointCreateApi:
|
||||
class TestEndpointCollectionApi:
|
||||
def test_create_success(self, app):
|
||||
api = EndpointCreateApi()
|
||||
api = EndpointCollectionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {
|
||||
@@ -55,7 +58,7 @@ class TestEndpointCreateApi:
|
||||
assert result["success"] is True
|
||||
|
||||
def test_create_permission_denied(self, app):
|
||||
api = EndpointCreateApi()
|
||||
api = EndpointCollectionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {
|
||||
@@ -75,7 +78,7 @@ class TestEndpointCreateApi:
|
||||
method(api)
|
||||
|
||||
def test_create_validation_error(self, app):
|
||||
api = EndpointCreateApi()
|
||||
api = EndpointCollectionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {
|
||||
@@ -91,6 +94,27 @@ class TestEndpointCreateApi:
|
||||
method(api)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("patch_current_account")
|
||||
class TestDeprecatedEndpointCreateApi:
|
||||
def test_create_success(self, app):
|
||||
api = DeprecatedEndpointCreateApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {
|
||||
"plugin_unique_identifier": "plugin-1",
|
||||
"name": "endpoint",
|
||||
"settings": {"a": 1},
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", json=payload),
|
||||
patch("controllers.console.workspace.endpoint.EndpointService.create_endpoint", return_value=True),
|
||||
):
|
||||
result = method(api)
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("patch_current_account")
|
||||
class TestEndpointListApi:
|
||||
def test_list_success(self, app):
|
||||
@@ -146,9 +170,96 @@ class TestEndpointListForSinglePluginApi:
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("patch_current_account")
|
||||
class TestEndpointDeleteApi:
|
||||
class TestEndpointItemApi:
|
||||
def test_delete_success(self, app):
|
||||
api = EndpointDeleteApi()
|
||||
api = EndpointItemApi()
|
||||
method = unwrap(api.delete)
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="DELETE"),
|
||||
patch(
|
||||
"controllers.console.workspace.endpoint.EndpointService.delete_endpoint",
|
||||
return_value=True,
|
||||
) as mock_delete,
|
||||
):
|
||||
result = method(api, "e1")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_delete.assert_called_once_with(tenant_id="t1", user_id="u1", endpoint_id="e1")
|
||||
|
||||
def test_delete_service_failure(self, app):
|
||||
api = EndpointItemApi()
|
||||
method = unwrap(api.delete)
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="DELETE"),
|
||||
patch("controllers.console.workspace.endpoint.EndpointService.delete_endpoint", return_value=False),
|
||||
):
|
||||
result = method(api, "e1")
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
def test_update_success(self, app):
|
||||
api = EndpointItemApi()
|
||||
method = unwrap(api.patch)
|
||||
|
||||
payload = {
|
||||
"name": "new-name",
|
||||
"settings": {"x": 1},
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="PATCH", json=payload),
|
||||
patch(
|
||||
"controllers.console.workspace.endpoint.EndpointService.update_endpoint",
|
||||
return_value=True,
|
||||
) as mock_update,
|
||||
):
|
||||
result = method(api, "e1")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_update.assert_called_once_with(
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
endpoint_id="e1",
|
||||
name="new-name",
|
||||
settings={"x": 1},
|
||||
)
|
||||
|
||||
def test_update_validation_error(self, app):
|
||||
api = EndpointItemApi()
|
||||
method = unwrap(api.patch)
|
||||
|
||||
payload = {"settings": {}}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="PATCH", json=payload),
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
method(api, "e1")
|
||||
|
||||
def test_update_service_failure(self, app):
|
||||
api = EndpointItemApi()
|
||||
method = unwrap(api.patch)
|
||||
|
||||
payload = {
|
||||
"name": "n",
|
||||
"settings": {},
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="PATCH", json=payload),
|
||||
patch("controllers.console.workspace.endpoint.EndpointService.update_endpoint", return_value=False),
|
||||
):
|
||||
result = method(api, "e1")
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("patch_current_account")
|
||||
class TestDeprecatedEndpointDeleteApi:
|
||||
def test_delete_success(self, app):
|
||||
api = DeprecatedEndpointDeleteApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {"endpoint_id": "e1"}
|
||||
@@ -162,7 +273,7 @@ class TestEndpointDeleteApi:
|
||||
assert result["success"] is True
|
||||
|
||||
def test_delete_invalid_payload(self, app):
|
||||
api = EndpointDeleteApi()
|
||||
api = DeprecatedEndpointDeleteApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
with (
|
||||
@@ -172,7 +283,7 @@ class TestEndpointDeleteApi:
|
||||
method(api)
|
||||
|
||||
def test_delete_service_failure(self, app):
|
||||
api = EndpointDeleteApi()
|
||||
api = DeprecatedEndpointDeleteApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {"endpoint_id": "e1"}
|
||||
@@ -187,9 +298,9 @@ class TestEndpointDeleteApi:
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("patch_current_account")
|
||||
class TestEndpointUpdateApi:
|
||||
class TestDeprecatedEndpointUpdateApi:
|
||||
def test_update_success(self, app):
|
||||
api = EndpointUpdateApi()
|
||||
api = DeprecatedEndpointUpdateApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {
|
||||
@@ -207,7 +318,7 @@ class TestEndpointUpdateApi:
|
||||
assert result["success"] is True
|
||||
|
||||
def test_update_validation_error(self, app):
|
||||
api = EndpointUpdateApi()
|
||||
api = DeprecatedEndpointUpdateApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {"endpoint_id": "e1", "settings": {}}
|
||||
@@ -219,7 +330,7 @@ class TestEndpointUpdateApi:
|
||||
method(api)
|
||||
|
||||
def test_update_service_failure(self, app):
|
||||
api = EndpointUpdateApi()
|
||||
api = DeprecatedEndpointUpdateApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {
|
||||
@@ -237,6 +348,36 @@ class TestEndpointUpdateApi:
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
class TestEndpointRouteMetadata:
|
||||
def test_legacy_write_routes_are_marked_deprecated(self):
|
||||
assert DeprecatedEndpointCreateApi.post.__apidoc__["deprecated"] is True
|
||||
assert DeprecatedEndpointDeleteApi.post.__apidoc__["deprecated"] is True
|
||||
assert DeprecatedEndpointUpdateApi.post.__apidoc__["deprecated"] is True
|
||||
assert EndpointCollectionApi.post.__apidoc__.get("deprecated") is not True
|
||||
assert EndpointItemApi.delete.__apidoc__.get("deprecated") is not True
|
||||
assert EndpointItemApi.patch.__apidoc__.get("deprecated") is not True
|
||||
|
||||
def test_canonical_and_legacy_write_routes_are_registered(self):
|
||||
route_map = {
|
||||
resource.__name__: urls
|
||||
for resource, urls, _route_doc, _kwargs in console_ns.resources
|
||||
if resource.__name__
|
||||
in {
|
||||
"EndpointCollectionApi",
|
||||
"EndpointItemApi",
|
||||
"DeprecatedEndpointCreateApi",
|
||||
"DeprecatedEndpointDeleteApi",
|
||||
"DeprecatedEndpointUpdateApi",
|
||||
}
|
||||
}
|
||||
|
||||
assert route_map["EndpointCollectionApi"] == ("/workspaces/current/endpoints",)
|
||||
assert route_map["EndpointItemApi"] == ("/workspaces/current/endpoints/<string:id>",)
|
||||
assert route_map["DeprecatedEndpointCreateApi"] == ("/workspaces/current/endpoints/create",)
|
||||
assert route_map["DeprecatedEndpointDeleteApi"] == ("/workspaces/current/endpoints/delete",)
|
||||
assert route_map["DeprecatedEndpointUpdateApi"] == ("/workspaces/current/endpoints/update",)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("patch_current_account")
|
||||
class TestEndpointEnableApi:
|
||||
def test_enable_success(self, app):
|
||||
|
||||
@@ -22,6 +22,8 @@ import pytest
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from controllers.service_api.dataset.document import (
|
||||
DeprecatedDocumentAddByTextApi,
|
||||
DeprecatedDocumentUpdateByTextApi,
|
||||
DocumentAddByFileApi,
|
||||
DocumentAddByTextApi,
|
||||
DocumentApi,
|
||||
@@ -1005,7 +1007,7 @@ class TestDocumentAddByTextApi:
|
||||
|
||||
# Act
|
||||
with app.test_request_context(
|
||||
f"/datasets/{mock_dataset.id}/document/create_by_text",
|
||||
f"/datasets/{mock_dataset.id}/document/create-by-text",
|
||||
method="POST",
|
||||
json={
|
||||
"name": "Test Document",
|
||||
@@ -1037,7 +1039,7 @@ class TestDocumentAddByTextApi:
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
f"/datasets/{mock_dataset.id}/document/create_by_text",
|
||||
f"/datasets/{mock_dataset.id}/document/create-by-text",
|
||||
method="POST",
|
||||
json={"name": "Test Document", "text": "Content"},
|
||||
headers={"Authorization": "Bearer test_token"},
|
||||
@@ -1066,7 +1068,7 @@ class TestDocumentAddByTextApi:
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
f"/datasets/{mock_dataset.id}/document/create_by_text",
|
||||
f"/datasets/{mock_dataset.id}/document/create-by-text",
|
||||
method="POST",
|
||||
json={"name": "Test Document", "text": "Content"},
|
||||
headers={"Authorization": "Bearer test_token"},
|
||||
@@ -1093,6 +1095,20 @@ class TestArchivedDocumentImmutableError:
|
||||
assert error.code == 403
|
||||
|
||||
|
||||
class TestDocumentTextRouteDeprecation:
|
||||
"""Test that legacy underscore text routes stay marked deprecated."""
|
||||
|
||||
def test_create_by_text_legacy_alias_is_deprecated(self):
|
||||
"""Ensure only the legacy create-by-text alias is marked deprecated."""
|
||||
assert DeprecatedDocumentAddByTextApi.post.__apidoc__["deprecated"] is True
|
||||
assert DocumentAddByTextApi.post.__apidoc__.get("deprecated") is not True
|
||||
|
||||
def test_update_by_text_legacy_alias_is_deprecated(self):
|
||||
"""Ensure only the legacy update-by-text alias is marked deprecated."""
|
||||
assert DeprecatedDocumentUpdateByTextApi.post.__apidoc__["deprecated"] is True
|
||||
assert DocumentUpdateByTextApi.post.__apidoc__.get("deprecated") is not True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoint tests for DocumentUpdateByTextApi, DocumentAddByFileApi,
|
||||
# DocumentUpdateByFileApi.
|
||||
@@ -1162,7 +1178,7 @@ class TestDocumentUpdateByTextApiPost:
|
||||
|
||||
doc_id = str(uuid.uuid4())
|
||||
with app.test_request_context(
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_text",
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update-by-text",
|
||||
method="POST",
|
||||
json={"name": "Updated Doc", "text": "New content"},
|
||||
headers={"Authorization": "Bearer test_token"},
|
||||
@@ -1195,7 +1211,7 @@ class TestDocumentUpdateByTextApiPost:
|
||||
|
||||
doc_id = str(uuid.uuid4())
|
||||
with app.test_request_context(
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_text",
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update-by-text",
|
||||
method="POST",
|
||||
json={"name": "Doc", "text": "Content"},
|
||||
headers={"Authorization": "Bearer test_token"},
|
||||
|
||||
@@ -77,6 +77,38 @@ class TestAdditionalFeatureManagers:
|
||||
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
|
||||
{"suggested_questions_after_answer": {"enabled": "yes"}}
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
|
||||
{"suggested_questions_after_answer": {"enabled": True, "prompt": 123}}
|
||||
)
|
||||
with pytest.raises(ValueError, match="must be less than or equal to 1000 characters"):
|
||||
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
|
||||
{"suggested_questions_after_answer": {"enabled": True, "prompt": "a" * 1001}}
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
|
||||
{"suggested_questions_after_answer": {"enabled": True, "model": "bad"}}
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
|
||||
{"suggested_questions_after_answer": {"enabled": True, "model": {"provider": "openai"}}}
|
||||
)
|
||||
|
||||
validated_config, _ = SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
|
||||
{
|
||||
"suggested_questions_after_answer": {
|
||||
"enabled": True,
|
||||
"prompt": "custom prompt",
|
||||
"model": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"completion_params": {"max_tokens": 1024},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
assert validated_config["suggested_questions_after_answer"]["prompt"] == "custom prompt"
|
||||
assert validated_config["suggested_questions_after_answer"]["model"]["name"] == "gpt-4o-mini"
|
||||
|
||||
assert (
|
||||
SuggestedQuestionsAfterAnswerConfigManager.convert({"suggested_questions_after_answer": {"enabled": True}})
|
||||
|
||||
@@ -6,7 +6,12 @@ import pytest
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
|
||||
from core.llm_generator.llm_generator import LLMGenerator
|
||||
from core.llm_generator.prompts import (
|
||||
DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
|
||||
)
|
||||
from graphon.model_runtime.entities.llm_entities import LLMMode, LLMResult
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeError
|
||||
|
||||
|
||||
@@ -96,6 +101,10 @@ class TestLLMGenerator:
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
|
||||
assert len(questions) == 2
|
||||
assert questions[0] == "Question 1?"
|
||||
assert mock_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
|
||||
"max_tokens": DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
"temperature": DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
|
||||
}
|
||||
|
||||
def test_generate_suggested_questions_after_answer_auth_error(self, mock_model_instance):
|
||||
with patch("core.llm_generator.llm_generator.ModelManager.for_tenant") as mock_manager:
|
||||
@@ -113,6 +122,97 @@ class TestLLMGenerator:
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
|
||||
assert questions == []
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
|
||||
def test_generate_suggested_questions_after_answer_with_custom_model_and_prompt(self, mock_for_tenant):
|
||||
custom_model_instance = MagicMock()
|
||||
custom_response = MagicMock()
|
||||
custom_response.message.get_text_content.return_value = '["Question 1?"]'
|
||||
custom_model_instance.invoke_llm.return_value = custom_response
|
||||
|
||||
mock_for_tenant.return_value.get_model_instance.return_value = custom_model_instance
|
||||
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer(
|
||||
"tenant_id",
|
||||
"histories",
|
||||
instruction_prompt="custom prompt",
|
||||
model_config={
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o",
|
||||
"completion_params": {"temperature": 0.2},
|
||||
},
|
||||
)
|
||||
|
||||
assert questions == ["Question 1?"]
|
||||
mock_for_tenant.return_value.get_model_instance.assert_called_once_with(
|
||||
tenant_id="tenant_id",
|
||||
model_type=ModelType.LLM,
|
||||
provider="openai",
|
||||
model="gpt-4o",
|
||||
)
|
||||
|
||||
invoke_kwargs = custom_model_instance.invoke_llm.call_args.kwargs
|
||||
assert invoke_kwargs["model_parameters"] == {"temperature": 0.2}
|
||||
assert invoke_kwargs["stop"] == []
|
||||
assert "custom prompt" in invoke_kwargs["prompt_messages"][0].content
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
|
||||
def test_generate_suggested_questions_after_answer_fallback_to_default_model(self, mock_for_tenant):
|
||||
default_model_instance = MagicMock()
|
||||
default_response = MagicMock()
|
||||
default_response.message.get_text_content.return_value = '["Question 1?"]'
|
||||
default_model_instance.invoke_llm.return_value = default_response
|
||||
|
||||
mock_for_tenant.return_value.get_model_instance.side_effect = ValueError("invalid configured model")
|
||||
mock_for_tenant.return_value.get_default_model_instance.return_value = default_model_instance
|
||||
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer(
|
||||
"tenant_id",
|
||||
"histories",
|
||||
model_config={
|
||||
"provider": "openai",
|
||||
"name": "not-found-model",
|
||||
"completion_params": {"temperature": 0.2},
|
||||
},
|
||||
)
|
||||
|
||||
assert questions == ["Question 1?"]
|
||||
mock_for_tenant.return_value.get_default_model_instance.assert_called_once_with(
|
||||
tenant_id="tenant_id",
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
assert default_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
|
||||
"max_tokens": DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
"temperature": DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
|
||||
}
|
||||
assert default_model_instance.invoke_llm.call_args.kwargs["stop"] == []
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
|
||||
def test_generate_suggested_questions_after_answer_drops_non_positive_max_tokens(self, mock_for_tenant):
|
||||
custom_model_instance = MagicMock()
|
||||
custom_response = MagicMock()
|
||||
custom_response.message.get_text_content.return_value = '["Question 1?"]'
|
||||
custom_model_instance.invoke_llm.return_value = custom_response
|
||||
mock_for_tenant.return_value.get_model_instance.return_value = custom_model_instance
|
||||
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer(
|
||||
"tenant_id",
|
||||
"histories",
|
||||
model_config={
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o",
|
||||
"completion_params": {
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 0,
|
||||
"stop": ["END"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert questions == ["Question 1?"]
|
||||
invoke_kwargs = custom_model_instance.invoke_llm.call_args.kwargs
|
||||
assert invoke_kwargs["model_parameters"] == {"temperature": 0.2}
|
||||
assert invoke_kwargs["stop"] == ["END"]
|
||||
|
||||
def test_generate_rule_config_no_variable_success(self, mock_model_instance, model_config_entity):
|
||||
payload = RuleGeneratePayload(
|
||||
instruction="test instruction", model_config=model_config_entity, no_variable=True
|
||||
|
||||
@@ -372,6 +372,78 @@ def test_get_configurations_binds_manager_runtime_to_provider_configuration(
|
||||
provider_configuration.bind_model_runtime.assert_called_once_with(manager._model_runtime)
|
||||
|
||||
|
||||
def test_get_configurations_reuses_cached_result_for_same_tenant(mocker: MockerFixture, mock_provider_entity):
|
||||
manager = _build_provider_manager(mocker)
|
||||
provider_configuration = Mock()
|
||||
provider_factory = Mock()
|
||||
provider_factory.get_providers.return_value = [mock_provider_entity]
|
||||
custom_configuration = SimpleNamespace(provider=None, models=[])
|
||||
system_configuration = SimpleNamespace(enabled=False, quota_configurations=[], current_quota_type=None)
|
||||
|
||||
with (
|
||||
patch.object(manager, "_get_all_providers", return_value={"openai": []}) as mock_get_all_providers,
|
||||
patch.object(manager, "_init_trial_provider_records", return_value={"openai": []}),
|
||||
patch.object(manager, "_get_all_provider_models", return_value={"openai": []}),
|
||||
patch.object(manager, "_get_all_preferred_model_providers", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
|
||||
patch.object(manager, "_to_custom_configuration", return_value=custom_configuration),
|
||||
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
|
||||
patch.object(manager, "_to_model_settings", return_value=[]),
|
||||
patch("core.provider_manager.ModelProviderFactory", return_value=provider_factory) as mock_factory_cls,
|
||||
patch(
|
||||
"core.provider_manager.ProviderConfiguration",
|
||||
return_value=provider_configuration,
|
||||
) as mock_provider_configuration,
|
||||
):
|
||||
first = manager.get_configurations("tenant-id")
|
||||
second = manager.get_configurations("tenant-id")
|
||||
|
||||
assert first is second
|
||||
mock_get_all_providers.assert_called_once_with("tenant-id")
|
||||
mock_factory_cls.assert_called_once_with(model_runtime=manager._model_runtime)
|
||||
mock_provider_configuration.assert_called_once()
|
||||
provider_configuration.bind_model_runtime.assert_called_once_with(manager._model_runtime)
|
||||
|
||||
|
||||
def test_clear_configurations_cache_rebuilds_requested_tenant(mocker: MockerFixture, mock_provider_entity):
|
||||
manager = _build_provider_manager(mocker)
|
||||
provider_factory = Mock()
|
||||
provider_factory.get_providers.return_value = [mock_provider_entity]
|
||||
custom_configuration = SimpleNamespace(provider=None, models=[])
|
||||
system_configuration = SimpleNamespace(enabled=False, quota_configurations=[], current_quota_type=None)
|
||||
provider_configuration_first = Mock()
|
||||
provider_configuration_second = Mock()
|
||||
|
||||
with (
|
||||
patch.object(manager, "_get_all_providers", return_value={"openai": []}) as mock_get_all_providers,
|
||||
patch.object(manager, "_init_trial_provider_records", return_value={"openai": []}),
|
||||
patch.object(manager, "_get_all_provider_models", return_value={"openai": []}),
|
||||
patch.object(manager, "_get_all_preferred_model_providers", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
|
||||
patch.object(manager, "_to_custom_configuration", return_value=custom_configuration),
|
||||
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
|
||||
patch.object(manager, "_to_model_settings", return_value=[]),
|
||||
patch("core.provider_manager.ModelProviderFactory", return_value=provider_factory),
|
||||
patch(
|
||||
"core.provider_manager.ProviderConfiguration",
|
||||
side_effect=[provider_configuration_first, provider_configuration_second],
|
||||
) as mock_provider_configuration,
|
||||
):
|
||||
first = manager.get_configurations("tenant-id")
|
||||
manager.clear_configurations_cache("tenant-id")
|
||||
second = manager.get_configurations("tenant-id")
|
||||
|
||||
assert first is not second
|
||||
assert mock_get_all_providers.call_count == 2
|
||||
assert mock_provider_configuration.call_count == 2
|
||||
provider_configuration_first.bind_model_runtime.assert_called_once_with(manager._model_runtime)
|
||||
provider_configuration_second.bind_model_runtime.assert_called_once_with(manager._model_runtime)
|
||||
|
||||
|
||||
def test_get_provider_model_bundle_returns_selected_model_type_instance(mocker: MockerFixture):
|
||||
manager = _build_provider_manager(mocker)
|
||||
provider_configuration = Mock()
|
||||
|
||||
@@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||
from models.enums import FeedbackFromSource, FeedbackRating
|
||||
from models.model import App, AppMode, EndUser, Message
|
||||
@@ -931,6 +932,130 @@ class TestMessageServiceSuggestedQuestions:
|
||||
assert result == ["Q1?"]
|
||||
mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once()
|
||||
|
||||
@patch("services.message_service.db")
|
||||
@patch("services.message_service.ModelManager.for_tenant")
|
||||
@patch("services.message_service.TokenBufferMemory")
|
||||
@patch("services.message_service.LLMGenerator")
|
||||
@patch("services.message_service.TraceQueueManager")
|
||||
@patch.object(MessageService, "get_message")
|
||||
@patch("services.message_service.ConversationService")
|
||||
def test_get_suggested_questions_chat_app_uses_frontend_model_and_prompt(
|
||||
self,
|
||||
mock_conversation_service,
|
||||
mock_get_message,
|
||||
mock_trace_manager,
|
||||
mock_llm_gen,
|
||||
mock_memory,
|
||||
mock_model_manager,
|
||||
mock_db,
|
||||
factory,
|
||||
):
|
||||
"""Test suggested question generation uses frontend configured model and prompt."""
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
|
||||
app = factory.create_app_mock(mode=AppMode.CHAT.value)
|
||||
app.tenant_id = "tenant-123"
|
||||
user = factory.create_end_user_mock()
|
||||
message = factory.create_message_mock()
|
||||
mock_get_message.return_value = message
|
||||
|
||||
conversation = MagicMock()
|
||||
conversation.override_model_configs = None
|
||||
mock_conversation_service.get_conversation.return_value = conversation
|
||||
|
||||
app_model_config = MagicMock()
|
||||
app_model_config.suggested_questions_after_answer_dict = {
|
||||
"enabled": True,
|
||||
"prompt": "custom prompt",
|
||||
"model": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"completion_params": {"max_tokens": 2048, "temperature": 0.1},
|
||||
},
|
||||
}
|
||||
mock_db.session.scalar.return_value = app_model_config
|
||||
|
||||
mock_memory.return_value.get_history_prompt_text.return_value = "histories"
|
||||
mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"]
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app,
|
||||
user=user,
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
|
||||
assert result == ["Q1?"]
|
||||
mock_model_manager.return_value.get_default_model_instance.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
mock_memory.assert_called_once_with(
|
||||
conversation=conversation,
|
||||
model_instance=mock_model_manager.return_value.get_default_model_instance.return_value,
|
||||
)
|
||||
mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
histories="histories",
|
||||
instruction_prompt="custom prompt",
|
||||
model_config={
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"completion_params": {"max_tokens": 2048, "temperature": 0.1},
|
||||
},
|
||||
)
|
||||
|
||||
@patch("services.message_service.db")
|
||||
@patch("services.message_service.ModelManager.for_tenant")
|
||||
@patch("services.message_service.TokenBufferMemory")
|
||||
@patch("services.message_service.LLMGenerator")
|
||||
@patch("services.message_service.TraceQueueManager")
|
||||
@patch.object(MessageService, "get_message")
|
||||
@patch("services.message_service.ConversationService")
|
||||
def test_get_suggested_questions_chat_app_invalid_frontend_model_fallback_to_default(
|
||||
self,
|
||||
mock_conversation_service,
|
||||
mock_get_message,
|
||||
mock_trace_manager,
|
||||
mock_llm_gen,
|
||||
mock_memory,
|
||||
mock_model_manager,
|
||||
mock_db,
|
||||
factory,
|
||||
):
|
||||
"""Test invalid frontend configured model falls back to tenant default model."""
|
||||
app = factory.create_app_mock(mode=AppMode.CHAT.value)
|
||||
app.tenant_id = "tenant-123"
|
||||
user = factory.create_end_user_mock()
|
||||
message = factory.create_message_mock()
|
||||
mock_get_message.return_value = message
|
||||
|
||||
conversation = MagicMock()
|
||||
conversation.override_model_configs = None
|
||||
mock_conversation_service.get_conversation.return_value = conversation
|
||||
|
||||
app_model_config = MagicMock()
|
||||
app_model_config.suggested_questions_after_answer_dict = {
|
||||
"enabled": True,
|
||||
"model": {"provider": "openai", "name": "invalid-model"},
|
||||
}
|
||||
mock_db.session.scalar.return_value = app_model_config
|
||||
|
||||
mock_model_manager.return_value.get_model_instance.side_effect = ValueError("invalid model")
|
||||
mock_memory.return_value.get_history_prompt_text.return_value = "histories"
|
||||
mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"]
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app, user=user, message_id="msg-123", invoke_from=MagicMock()
|
||||
)
|
||||
|
||||
assert result == ["Q1?"]
|
||||
mock_model_manager.return_value.get_default_model_instance.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
mock_model_manager.return_value.get_model_instance.assert_not_called()
|
||||
|
||||
# Test 30: get_suggested_questions_after_answer - Disabled Error
|
||||
@patch("services.message_service.WorkflowService")
|
||||
@patch("services.message_service.AdvancedChatAppConfigManager")
|
||||
|
||||
Generated
+72
-72
@@ -469,14 +469,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "basedpyright"
|
||||
version = "1.39.0"
|
||||
version = "1.39.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nodejs-wheel-binaries" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/f4/4a77cc1ffb3dab7391642cde30163961d8ee973e9e6b6740c7d15aa3d3ba/basedpyright-1.39.0.tar.gz", hash = "sha256:6666f51c378c7ac45877c4c1c7041ee0b5b83d755ebc82f898f47b6fafe0cc4f", size = 25357403, upload-time = "2026-04-01T12:27:41.92Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/19/5a5b9b9197973da732638957be3a65cf514d2f5a4964eeedbf33b6c65bbd/basedpyright-1.39.3.tar.gz", hash = "sha256:2f794e6b5f4260fb89f614ca6cd23c6f305373bb6b50c4ed7794ff2ae647fb14", size = 25503187, upload-time = "2026-04-20T22:14:47.424Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/47/08145d1bcc3083ed20059bdecbde404bd767f91b91e2764ec01cffec9f4b/basedpyright-1.39.0-py3-none-any.whl", hash = "sha256:91b8ad50bc85ee4a985b928f9368c35c99eee5a56c44e99b2442fa12ecc3d670", size = 12353868, upload-time = "2026-04-01T12:27:38.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/5c/f950c1239ad26f3bb453e665428a2cf1893995de725a5eb0b64a2520b366/basedpyright-1.39.3-py3-none-any.whl", hash = "sha256:aba760dc83307727554f936d6b4381caa14482f30dbc2173167710e217c1f7ab", size = 12419181, upload-time = "2026-04-20T22:14:51.975Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -618,15 +618,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3-stubs"
|
||||
version = "1.42.88"
|
||||
version = "1.42.92"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore-stubs" },
|
||||
{ name = "types-s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/c7/d4dfbb4757cd72fd350ba666902ec3ac19e04d6be639e96cdad4543d4726/boto3_stubs-1.42.88.tar.gz", hash = "sha256:85215fb4938a94d1cf83cd8632f46ae7728b5ec88187d83468f393bbe64236d6", size = 102495, upload-time = "2026-04-10T19:55:57.526Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/b4/7f472d64a89f6aa6b8e8eeadc876667b7e4edfb526c6118efe2b2c98ba17/boto3_stubs-1.42.92.tar.gz", hash = "sha256:4bc934069c5e8c7b3cdd2442569dae14e8272fe207d445bd38aa578b8463638f", size = 102696, upload-time = "2026-04-20T19:55:19.858Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/6f/3befd72080aedbb4ad26b353a6e364645668664930ce49668fd0bab8f2b5/boto3_stubs-1.42.88-py3-none-any.whl", hash = "sha256:9e74350715ca8ccd63fc250f8eca9fa3161b3d1704339554344d72e4e21c5ed1", size = 70603, upload-time = "2026-04-10T19:55:49.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/ce/2fe2c6456f8dc0b8bb8d80e05e154c7975ec058991bedf54f3aeed634b79/boto3_stubs-1.42.92-py3-none-any.whl", hash = "sha256:b3994e60f0133b2dd3d9a88ceaeef48fa6367d9a9429426e919575768a1ad9c6", size = 70666, upload-time = "2026-04-20T19:55:16.398Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1616,18 +1616,18 @@ requires-dist = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "basedpyright", specifier = ">=1.39.0" },
|
||||
{ name = "boto3-stubs", specifier = ">=1.42.88" },
|
||||
{ name = "basedpyright", specifier = ">=1.39.3" },
|
||||
{ name = "boto3-stubs", specifier = ">=1.42.92" },
|
||||
{ name = "celery-types", specifier = ">=0.23.0" },
|
||||
{ name = "coverage", specifier = ">=7.13.4" },
|
||||
{ name = "dotenv-linter", specifier = ">=0.7.0" },
|
||||
{ name = "faker", specifier = ">=20.1.0" },
|
||||
{ name = "hypothesis", specifier = ">=6.151.12" },
|
||||
{ name = "faker", specifier = ">=40.15.0" },
|
||||
{ name = "hypothesis", specifier = ">=6.152.1" },
|
||||
{ name = "import-linter", specifier = ">=2.3" },
|
||||
{ name = "lxml-stubs", specifier = ">=0.5.1" },
|
||||
{ name = "mypy", specifier = ">=1.20.1" },
|
||||
{ name = "pandas-stubs", specifier = ">=3.0.0" },
|
||||
{ name = "pyrefly", specifier = ">=0.61.1" },
|
||||
{ name = "pyrefly", specifier = ">=0.62.0" },
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-benchmark", specifier = ">=5.2.3" },
|
||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||
@@ -1635,8 +1635,8 @@ dev = [
|
||||
{ name = "pytest-mock", specifier = ">=3.15.1" },
|
||||
{ name = "pytest-timeout", specifier = ">=2.4.0" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.8.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.10" },
|
||||
{ name = "scipy-stubs", specifier = ">=1.15.3.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.11" },
|
||||
{ name = "scipy-stubs", specifier = ">=1.17.1.4" },
|
||||
{ name = "testcontainers", specifier = ">=4.14.2" },
|
||||
{ name = "types-aiofiles", specifier = ">=25.1.0" },
|
||||
{ name = "types-beautifulsoup4", specifier = ">=4.12.0" },
|
||||
@@ -2351,14 +2351,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "faker"
|
||||
version = "40.13.0"
|
||||
version = "40.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/95/4822ffe94723553789aef783104f4f18fc20d7c4c68e1bbd633e11d09758/faker-40.13.0.tar.gz", hash = "sha256:a0751c84c3abac17327d7bb4c98e8afe70ebf7821e01dd7d0b15cd8856415525", size = 1962043, upload-time = "2026-04-06T16:44:55.68Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7f/13/6741787bd91c4109c7bed047d68273965cd52ce8a5f773c471b949334b6d/faker-40.15.0.tar.gz", hash = "sha256:20f3a6ec8c266b74d4c554e34118b21c3c2056c0b4a519d15c8decb3a4e6e795", size = 1967447, upload-time = "2026-04-17T20:05:27.555Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/8a/708103325edff16a0b0e004de0d37db8ba216a32713948c64d71f6d4a4c2/faker-40.13.0-py3-none-any.whl", hash = "sha256:c1298fd0d819b3688fb5fd358c4ba8f56c7c8c740b411fd3dbd8e30bf2c05019", size = 1994597, upload-time = "2026-04-06T16:44:53.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/a7/a600f8f30d4505e89166de51dd121bd540ab8e560e8cf0901de00a81de8c/faker-40.15.0-py3-none-any.whl", hash = "sha256:71ab3c3370da9d2205ab74ffb0fd51273063ad562b3a3bb69d0026a20923e318", size = 2004447, upload-time = "2026-04-17T20:05:25.437Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3317,14 +3317,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hypothesis"
|
||||
version = "6.151.12"
|
||||
version = "6.152.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "sortedcontainers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/ab/67ca321d1ab96fd3828b12142f1c258e2d4a668a025d06cd50ab3409787f/hypothesis-6.151.12.tar.gz", hash = "sha256:be485f503979af4c3dfa19e3fc2b967d0458e7f8c4e28128d7e215e0a55102e0", size = 463900, upload-time = "2026-04-08T19:40:06.205Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/64/b1/c32bcddb9aab9e3abc700f1f56faf14e7655c64a16ca47701a57362276ea/hypothesis-6.152.1.tar.gz", hash = "sha256:4f4ed934eee295dd84ee97592477d23e8dc03e9f12ae0ee30a4e7c9ef3fca3b0", size = 465029, upload-time = "2026-04-14T22:29:24.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/5a/6cecf134b631050a1f8605096adbe812483b60790d951470989d39b56860/hypothesis-6.151.12-py3-none-any.whl", hash = "sha256:37d4f3a768365c30571b11dfd7a6857a12173d933010b2c4ab65619f1b5952c5", size = 529656, upload-time = "2026-04-08T19:40:03.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/83/860fb3075e00b0fc19a22a2301bc3c96f00437558c3911bdd0a3573a4a53/hypothesis-6.152.1-py3-none-any.whl", hash = "sha256:40a3619d9e0cb97b018857c7986f75cf5de2e5ec0fa8a0b172d00747758f749e", size = 530752, upload-time = "2026-04-14T22:29:20.893Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3687,28 +3687,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.0.2"
|
||||
version = "6.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5357,19 +5357,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyrefly"
|
||||
version = "0.61.1"
|
||||
version = "0.62.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/c8/52fce3f0e3718d9ff71d16af41cef925e58613741328004d3aa3fe585057/pyrefly-0.61.1.tar.gz", hash = "sha256:2a871320b7d2b28b8635064b620097d7091e84c49e4808d915ad31dad685d0f5", size = 5535788, upload-time = "2026-04-17T18:47:33.958Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/8874ed25781e7dd561c6d75fb4a7becf10a18d75b074f25b845cc334f781/pyrefly-0.62.0.tar.gz", hash = "sha256:da1fbe1075dc1e6c8e3134e9370b0a0e7a296061d782cca5bf83dbb8e4c10d7c", size = 5537672, upload-time = "2026-04-20T17:12:15.718Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/38/e94ff401405a05fbf81c9bbfa993a34ffd03be84812b545063c8efb56b44/pyrefly-0.61.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6e3ed857b99291fc4aa3b54ce22deb086c0174cf3a3775eccea7439efd16d925", size = 12969301, upload-time = "2026-04-17T18:47:06.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/be/53c7f9400696e46633c8cee8b6fd32ce7ab4a965ddf9ac4f4ea9e2034647/pyrefly-0.61.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cf6335c1baf9470ca8113f7ea8bdbd0b96081c82a911157c576cdfc8a67a9a87", size = 12475413, upload-time = "2026-04-17T18:47:08.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/68/83cc3267620b14f81fa596a84efc7ebcf5c49f79b521499e85d1a4fca6d8/pyrefly-0.61.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844b5baddc2a631f69648a4756c54c97d86e4b9c07e335b216668e24390b77b6", size = 36074785, upload-time = "2026-04-17T18:47:11.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/00/e8d437995b8dcea022f5310bc873f5de1dcc71da4876d5be917ee9a93fef/pyrefly-0.61.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eaa294f90622c5b3743af8e9de4263447f22bb0e8b60c80cf83292adb4f2d14b", size = 38802979, upload-time = "2026-04-17T18:47:16.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/3f/f1cbc58e8875608ae740d9575de95c8bc6d4dce202f82b4fe90005727618/pyrefly-0.61.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a8d8c3fe08b9593dce23ad4bc7c393891a379c2d580aa1f263182567721bd6f", size = 37029339, upload-time = "2026-04-17T18:47:19.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/8c/0ff67041c88c28f48b10ce15758831d1e4e60f11db5bfc09dcffd5edb6ba/pyrefly-0.61.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:305f2086f4d7d796244b337884d96cf0d32435420336a77840ca369cf6fa06fd", size = 41595667, upload-time = "2026-04-17T18:47:23.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/9e/62b8139b140931593a6b29334802ea6b86d033c0bfd9794950279732253b/pyrefly-0.61.1-py3-none-win32.whl", hash = "sha256:3271a019885a72c8dd064e928bb445af807771506842f5f2faaac17d8e6e73a5", size = 11963660, upload-time = "2026-04-17T18:47:25.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/6e/73280243d12bec28f55b6edd4e70c5cf11e3d7de2395ecb4eb36cca7dab4/pyrefly-0.61.1-py3-none-win_amd64.whl", hash = "sha256:3e3763d5d76f505c5b8897db1446bde8e138d50a67751f2aa76d6c6034254836", size = 12804056, upload-time = "2026-04-17T18:47:28.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/32/38ac5af84d96167412024abf5e2f49f15b777987a1942e7a442e8e5fef82/pyrefly-0.61.1-py3-none-win_arm64.whl", hash = "sha256:cef5631e2ab09702274ec2eaaafee28a114891cf85f2d31568b329727e3ff735", size = 12302467, upload-time = "2026-04-17T18:47:31.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/ea/09bd9da7d5df294db800312fb415be2fefbaa5594178e9e49f44fa071aea/pyrefly-0.62.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d78ec4f126dee1fa76215b193b964490ce10e62a32d2787a72c51623658b803", size = 13020414, upload-time = "2026-04-20T17:11:43.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f0/f84afac4f220c4c8c801b779ee2ff28ad3f7731f4283c2e1b6ee9012e8c2/pyrefly-0.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2a41a34902d20756264486f9e309f22633d100261bd960feea6e858a098d985d", size = 12515659, upload-time = "2026-04-20T17:11:46.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/0b/620c39cefa9ae1b25ee7a2da9d8d3c278b095649cb8435c5e01ea64f7c17/pyrefly-0.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4666c6b65aea662e5f77b64dc91c091b7ea5cede6aa66c0f4cbae26480403583", size = 36228332, upload-time = "2026-04-20T17:11:50.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/fb/47b8b76438c12761e509a3666cd5a99d4af7f21976ba8385feb475cbfe30/pyrefly-0.62.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1aefab798f47d37c13ded791192fee9b39a6d2b12e31f38ae06a1f80c4b26e22", size = 38995741, upload-time = "2026-04-20T17:11:54.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/d2/03bd17673f61147cd5609cd7d6a1455eeccc17a07a7e141ed9931b0c42c0/pyrefly-0.62.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8fa986b50d56740da1d7ae7c660a505143cb9d286fa98cc7e5f4a759cc6eaa5d", size = 37205321, upload-time = "2026-04-20T17:11:58.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/14/20ba7b7f2d182f9b7c1e24a3041dac9b5730ae28cfe1614a2c98706650f2/pyrefly-0.62.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32e9b175805c82ffb967e4708f4910bace7e1a12736907380cc9afdbaabb0efb", size = 41786834, upload-time = "2026-04-20T17:12:03.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c8/5a7ba88c4fa1b5090d877f70fa1b742b921b9e7d8d3f4b6b9b1ba1820850/pyrefly-0.62.0-py3-none-win32.whl", hash = "sha256:1cd98edc20cab5bac8016c9220ee66080e39bd22e7f0e9bb3e2c4e2be1555eed", size = 12010170, upload-time = "2026-04-20T17:12:06.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/78/d8f810de010ff2ed594c630c724fd817ef430963249e9eb396ce8f785e9d/pyrefly-0.62.0-py3-none-win_amd64.whl", hash = "sha256:6994f8ee7d6720325ee52207fbdaca98a799a1efe462bb5ba90c47160f7f3e6e", size = 12861816, upload-time = "2026-04-20T17:12:09.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/a9/ac824ef6a3f50b7c0ec5974471f8f2cb205cd1edd53a5abbcf7ba37feb5d/pyrefly-0.62.0-py3-none-win_arm64.whl", hash = "sha256:362a5d47a5ac5aaa5258091e878a1759ff8b687d8cf462af1c516144f7b0108a", size = 12352977, upload-time = "2026-04-20T17:12:12.736Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5887,27 +5887,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.10"
|
||||
version = "0.15.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5946,14 +5946,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "scipy-stubs"
|
||||
version = "1.17.1.3"
|
||||
version = "1.17.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "optype", extra = ["numpy"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/59/59c6cc3f9970154b9ed6b1aff42a0185cdd60cef54adc0404b9e77972221/scipy_stubs-1.17.1.3.tar.gz", hash = "sha256:5eb87a8d23d726706259b012ebe76a4a96a9ae9e141fc59bf55fc8eac2ed9e0f", size = 392185, upload-time = "2026-03-22T22:11:58.34Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/75/d944a11fca64aa84fbb4bfcf613b758319c6103cb30a304a0e9727009d62/scipy_stubs-1.17.1.4.tar.gz", hash = "sha256:cae00c5207aa62ceb4bcadea202d9fbbf002e958f9e4de981720436b8d5c1802", size = 396980, upload-time = "2026-04-13T11:46:54.528Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/d4/94304532c0a75a55526119043dd44a9bd1541a21e14483cbb54261c527d2/scipy_stubs-1.17.1.3-py3-none-any.whl", hash = "sha256:7b91d3f05aa47da06fbca14eb6c5bb4c28994e9245fd250cc847e375bab31297", size = 597933, upload-time = "2026-03-22T22:11:56.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/f8/334aa5a7a482ea89cb14d92f6a4d9ffa1e193e733144d4d14c7ffcb33583/scipy_stubs-1.17.1.4-py3-none-any.whl", hash = "sha256:e6e5c390fb864745bc3d5f591de81f5cb4f84403857d4f660acb5b6339956f5b", size = 604752, upload-time = "2026-04-13T11:46:53.135Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -273,7 +273,7 @@ SQLALCHEMY_POOL_TIMEOUT=30
|
||||
# Default is 100
|
||||
#
|
||||
# Reference: https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS
|
||||
POSTGRES_MAX_CONNECTIONS=100
|
||||
POSTGRES_MAX_CONNECTIONS=200
|
||||
|
||||
# Sets the amount of shared memory used for postgres's shared buffers.
|
||||
# Default is 128MB
|
||||
|
||||
@@ -70,7 +70,7 @@ x-shared-env: &shared-api-worker-env
|
||||
SQLALCHEMY_POOL_PRE_PING: ${SQLALCHEMY_POOL_PRE_PING:-false}
|
||||
SQLALCHEMY_POOL_USE_LIFO: ${SQLALCHEMY_POOL_USE_LIFO:-false}
|
||||
SQLALCHEMY_POOL_TIMEOUT: ${SQLALCHEMY_POOL_TIMEOUT:-30}
|
||||
POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-100}
|
||||
POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-200}
|
||||
POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS:-128MB}
|
||||
POSTGRES_WORK_MEM: ${POSTGRES_WORK_MEM:-4MB}
|
||||
POSTGRES_MAINTENANCE_WORK_MEM: ${POSTGRES_MAINTENANCE_WORK_MEM:-64MB}
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
# Configurable Suggested Questions After Answer
|
||||
|
||||
This document explains how to configure the "Suggested Questions After Answer" feature in Dify using environment variables.
|
||||
|
||||
## Overview
|
||||
|
||||
The suggested questions feature generates follow-up questions after each AI response to help users continue the conversation. By default, Dify generates 3 short questions (under 20 characters each), but you can customize this behavior to better fit your specific use case.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### `SUGGESTED_QUESTIONS_PROMPT`
|
||||
|
||||
**Description**: Custom prompt template for generating suggested questions.
|
||||
|
||||
**Default**:
|
||||
|
||||
```
|
||||
Please help me predict the three most likely questions that human would ask, and keep each question under 20 characters.
|
||||
MAKE SURE your output is the SAME language as the Assistant's latest response.
|
||||
The output must be an array in JSON format following the specified schema:
|
||||
["question1","question2","question3"]
|
||||
```
|
||||
|
||||
**Usage Examples**:
|
||||
|
||||
1. **Technical/Developer Questions (Your Use Case)**:
|
||||
|
||||
```bash
|
||||
export SUGGESTED_QUESTIONS_PROMPT='Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: ["question1","question2","question3","question4","question5"]'
|
||||
```
|
||||
|
||||
1. **Customer Support**:
|
||||
|
||||
```bash
|
||||
export SUGGESTED_QUESTIONS_PROMPT='Generate 3 helpful follow-up questions that guide customers toward solving their own problems. Focus on troubleshooting steps and common issues. Keep questions under 30 characters. JSON format: ["q1","q2","q3"]'
|
||||
```
|
||||
|
||||
1. **Educational Content**:
|
||||
|
||||
```bash
|
||||
export SUGGESTED_QUESTIONS_PROMPT='Create 4 thought-provoking questions that help students deeper understand the topic. Focus on concepts, relationships, and applications. Questions should be 25-40 characters. JSON: ["question1","question2","question3","question4"]'
|
||||
```
|
||||
|
||||
1. **Multilingual Support**:
|
||||
|
||||
```bash
|
||||
export SUGGESTED_QUESTIONS_PROMPT='Generate exactly 3 follow-up questions in the same language as the conversation. Adapt question length appropriately for the language (Chinese: 10-15 chars, English: 20-30 chars, Arabic: 25-35 chars). Always output valid JSON array.'
|
||||
```
|
||||
|
||||
**Important Notes**:
|
||||
|
||||
- The prompt must request JSON array output format
|
||||
- Include language matching instructions for multilingual support
|
||||
- Specify clear character limits or question count requirements
|
||||
- Focus on your specific domain or use case
|
||||
|
||||
### `SUGGESTED_QUESTIONS_MAX_TOKENS`
|
||||
|
||||
**Description**: Maximum number of tokens for the LLM response.
|
||||
|
||||
**Default**: `256`
|
||||
|
||||
**Usage**:
|
||||
|
||||
```bash
|
||||
export SUGGESTED_QUESTIONS_MAX_TOKENS=512 # For longer questions or more questions
|
||||
```
|
||||
|
||||
**Recommended Values**:
|
||||
|
||||
- `256`: Default, good for 3-4 short questions
|
||||
- `384`: Medium, good for 4-5 medium-length questions
|
||||
- `512`: High, good for 5+ longer questions or complex prompts
|
||||
- `1024`: Maximum, for very complex question generation
|
||||
|
||||
### `SUGGESTED_QUESTIONS_TEMPERATURE`
|
||||
|
||||
**Description**: Temperature parameter for LLM creativity.
|
||||
|
||||
**Default**: `0.0`
|
||||
|
||||
**Usage**:
|
||||
|
||||
```bash
|
||||
export SUGGESTED_QUESTIONS_TEMPERATURE=0.3 # Balanced creativity
|
||||
```
|
||||
|
||||
**Recommended Values**:
|
||||
|
||||
- `0.0-0.2`: Very focused, predictable questions (good for technical support)
|
||||
- `0.3-0.5`: Balanced creativity and relevance (good for general use)
|
||||
- `0.6-0.8`: More creative, diverse questions (good for brainstorming)
|
||||
- `0.9-1.0`: Maximum creativity (good for educational exploration)
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Example 1: Developer Documentation Chatbot
|
||||
|
||||
```bash
|
||||
# .env file
|
||||
SUGGESTED_QUESTIONS_PROMPT='Generate exactly 5 technical follow-up questions that developers would ask after reading code documentation. Focus on implementation details, edge cases, performance considerations, and best practices. Each question should be 40-60 characters long. Output as JSON array: ["question1","question2","question3","question4","question5"]'
|
||||
SUGGESTED_QUESTIONS_MAX_TOKENS=512
|
||||
SUGGESTED_QUESTIONS_TEMPERATURE=0.3
|
||||
```
|
||||
|
||||
### Example 2: Customer Service Bot
|
||||
|
||||
```bash
|
||||
# .env file
|
||||
SUGGESTED_QUESTIONS_PROMPT='Create 3 actionable follow-up questions that help customers resolve their own issues. Focus on common problems, troubleshooting steps, and product features. Keep questions simple and under 25 characters. JSON: ["q1","q2","q3"]'
|
||||
SUGGESTED_QUESTIONS_MAX_TOKENS=256
|
||||
SUGGESTED_QUESTIONS_TEMPERATURE=0.1
|
||||
```
|
||||
|
||||
### Example 3: Educational Tutor
|
||||
|
||||
```bash
|
||||
# .env file
|
||||
SUGGESTED_QUESTIONS_PROMPT='Generate 4 thought-provoking questions that help students deepen their understanding of the topic. Focus on relationships between concepts, practical applications, and critical thinking. Questions should be 30-45 characters. Output: ["question1","question2","question3","question4"]'
|
||||
SUGGESTED_QUESTIONS_MAX_TOKENS=384
|
||||
SUGGESTED_QUESTIONS_TEMPERATURE=0.6
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Environment Variable Loading**: The system checks for environment variables at startup
|
||||
1. **Fallback to Defaults**: If no environment variables are set, original behavior is preserved
|
||||
1. **Prompt Template**: The custom prompt is used as-is, allowing full control over question generation
|
||||
1. **LLM Parameters**: Custom max_tokens and temperature are passed to the LLM API
|
||||
1. **JSON Parsing**: The system expects JSON array output and parses it accordingly
|
||||
|
||||
### File Changes
|
||||
|
||||
The implementation modifies these files:
|
||||
|
||||
- `api/core/llm_generator/prompts.py`: Environment variable support
|
||||
- `api/core/llm_generator/llm_generator.py`: Custom LLM parameters
|
||||
- `api/.env.example`: Documentation of new variables
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
- ✅ **Zero Breaking Changes**: Works exactly as before if no environment variables are set
|
||||
- ✅ **Default Behavior Preserved**: Original prompt and parameters used as fallbacks
|
||||
- ✅ **No Database Changes**: Pure environment variable configuration
|
||||
- ✅ **No UI Changes Required**: Configuration happens at deployment level
|
||||
|
||||
## Testing Your Configuration
|
||||
|
||||
### Local Testing
|
||||
|
||||
1. Set environment variables:
|
||||
|
||||
```bash
|
||||
export SUGGESTED_QUESTIONS_PROMPT='Your test prompt...'
|
||||
export SUGGESTED_QUESTIONS_MAX_TOKENS=300
|
||||
export SUGGESTED_QUESTIONS_TEMPERATURE=0.4
|
||||
```
|
||||
|
||||
1. Start Dify API:
|
||||
|
||||
```bash
|
||||
cd api
|
||||
python -m flask run --host 0.0.0.0 --port=5001 --debug
|
||||
```
|
||||
|
||||
1. Test the feature in your chat application and verify the questions match your expectations.
|
||||
|
||||
### Monitoring
|
||||
|
||||
Monitor the following when testing:
|
||||
|
||||
- **Question Quality**: Are questions relevant and helpful?
|
||||
- **Language Matching**: Do questions match the conversation language?
|
||||
- **JSON Format**: Is output properly formatted as JSON array?
|
||||
- **Length Constraints**: Do questions follow your length requirements?
|
||||
- **Response Time**: Are the custom parameters affecting performance?
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Invalid JSON Output**:
|
||||
|
||||
- **Problem**: LLM doesn't return valid JSON
|
||||
- **Solution**: Make sure your prompt explicitly requests JSON array format
|
||||
|
||||
1. **Questions Too Long/Short**:
|
||||
|
||||
- **Problem**: Questions don't follow length constraints
|
||||
- **Solution**: Be more specific about character limits in your prompt
|
||||
|
||||
1. **Too Few/Many Questions**:
|
||||
|
||||
- **Problem**: Wrong number of questions generated
|
||||
- **Solution**: Clearly specify the exact number in your prompt
|
||||
|
||||
1. **Language Mismatch**:
|
||||
|
||||
- **Problem**: Questions in wrong language
|
||||
- **Solution**: Include explicit language matching instructions in prompt
|
||||
|
||||
1. **Performance Issues**:
|
||||
|
||||
- **Problem**: Slow response times
|
||||
- **Solution**: Reduce `SUGGESTED_QUESTIONS_MAX_TOKENS` or simplify prompt
|
||||
|
||||
### Debug Logging
|
||||
|
||||
To debug your configuration, you can temporarily add logging to see the actual prompt and parameters being used:
|
||||
|
||||
```python
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# In llm_generator.py
|
||||
logger.info(f"Suggested questions prompt: {prompt}")
|
||||
logger.info(f"Max tokens: {SUGGESTED_QUESTIONS_MAX_TOKENS}")
|
||||
logger.info(f"Temperature: {SUGGESTED_QUESTIONS_TEMPERATURE}")
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Default Configuration
|
||||
|
||||
If you're currently using the default configuration and want to customize:
|
||||
|
||||
1. **Assess Your Needs**: Determine what aspects need customization (question count, length, domain focus)
|
||||
1. **Design Your Prompt**: Write a custom prompt that addresses your specific use case
|
||||
1. **Choose Parameters**: Select appropriate max_tokens and temperature values
|
||||
1. **Test Incrementally**: Start with small changes and test thoroughly
|
||||
1. **Deploy Gradually**: Roll out to production after successful testing
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Start Simple**: Begin with minimal changes to the default prompt
|
||||
1. **Test Thoroughly**: Test with various conversation types and languages
|
||||
1. **Monitor Performance**: Watch for impact on response times and costs
|
||||
1. **Get User Feedback**: Collect feedback on question quality and relevance
|
||||
1. **Iterate**: Refine your configuration based on real-world usage
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
This environment variable approach provides immediate customization while maintaining backward compatibility. Future enhancements could include:
|
||||
|
||||
1. **App-Level Configuration**: Different apps with different suggested question settings
|
||||
1. **Dynamic Prompts**: Context-aware prompts based on conversation content
|
||||
1. **Multi-Model Support**: Different models for different types of questions
|
||||
1. **Analytics Dashboard**: Insights into question effectiveness and usage patterns
|
||||
1. **A/B Testing**: Built-in testing of different prompt configurations
|
||||
|
||||
For now, the environment variable approach offers a simple, reliable way to customize the suggested questions feature for your specific needs.
|
||||
@@ -0,0 +1,26 @@
|
||||
@apps @authenticated @core
|
||||
Feature: App detail navigation
|
||||
|
||||
Scenario: Opening a workflow app navigates to the workflow editor
|
||||
Given I am signed in as the default E2E admin
|
||||
And a "workflow" app has been created via API
|
||||
When I open the app from the app list
|
||||
Then I should land on the workflow editor
|
||||
|
||||
Scenario: Opening a chatbot app navigates to the configuration page
|
||||
Given I am signed in as the default E2E admin
|
||||
And a "chat" app has been created via API
|
||||
When I open the app from the app list
|
||||
Then I should land on the app configuration page
|
||||
|
||||
Scenario: The develop tab is accessible from a workflow app
|
||||
Given I am signed in as the default E2E admin
|
||||
And a "workflow" app has been created via API
|
||||
When I navigate to the app develop page
|
||||
Then I should be on the app develop page
|
||||
|
||||
Scenario: The overview tab is accessible from a workflow app
|
||||
Given I am signed in as the default E2E admin
|
||||
And a "workflow" app has been created via API
|
||||
When I navigate to the app overview page
|
||||
Then I should be on the app overview page
|
||||
@@ -1,4 +1,4 @@
|
||||
@apps @authenticated
|
||||
@apps @authenticated @core
|
||||
Feature: Create app
|
||||
Scenario: Create a new blank app and redirect to the editor
|
||||
Given I am signed in as the default E2E admin
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@apps @authenticated
|
||||
@apps @authenticated @core @mode-matrix
|
||||
Feature: Create Chatbot app
|
||||
Scenario: Create a new Chatbot app and redirect to the configuration page
|
||||
Given I am signed in as the default E2E admin
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@apps @authenticated
|
||||
@apps @authenticated @core @mode-matrix
|
||||
Feature: Create Workflow app
|
||||
Scenario: Create a new Workflow app and redirect to the workflow editor
|
||||
Given I am signed in as the default E2E admin
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@apps @authenticated @core
|
||||
Feature: Publish app
|
||||
|
||||
Scenario: Publish a workflow app for the first time
|
||||
Given I am signed in as the default E2E admin
|
||||
And a "workflow" app has been created via API
|
||||
And a minimal workflow draft has been synced
|
||||
When I open the app from the app list
|
||||
And I open the publish panel
|
||||
And I publish the app
|
||||
Then the app should be marked as published
|
||||
@@ -0,0 +1,8 @@
|
||||
@auth @smoke @core @unauthenticated
|
||||
Feature: Sign in
|
||||
|
||||
Scenario: Sign in with valid credentials and reach the apps console
|
||||
Given I am not signed in
|
||||
When I open the sign-in page
|
||||
And I sign in as the default E2E admin
|
||||
Then I should be on the apps console
|
||||
@@ -1,4 +1,4 @@
|
||||
@auth @authenticated
|
||||
@auth @authenticated @core
|
||||
Feature: Sign out
|
||||
Scenario: Sign out from the apps console
|
||||
Given I am signed in as the default E2E admin
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
When('I navigate to the app develop page', async function (this: DifyWorld) {
|
||||
const appId = this.createdAppIds.at(-1)
|
||||
await this.getPage().goto(`/app/${appId}/develop`)
|
||||
})
|
||||
|
||||
When('I navigate to the app overview page', async function (this: DifyWorld) {
|
||||
const appId = this.createdAppIds.at(-1)
|
||||
await this.getPage().goto(`/app/${appId}/overview`)
|
||||
})
|
||||
|
||||
Then('I should be on the app develop page', async function (this: DifyWorld) {
|
||||
await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/develop(?:\?.*)?$/, { timeout: 30_000 })
|
||||
})
|
||||
|
||||
Then('I should be on the app overview page', async function (this: DifyWorld) {
|
||||
await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/overview(?:\?.*)?$/, { timeout: 30_000 })
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
When('I open the publish panel', async function (this: DifyWorld) {
|
||||
await this.getPage().getByRole('button', { name: 'Publish' }).first().click()
|
||||
})
|
||||
|
||||
When('I publish the app', async function (this: DifyWorld) {
|
||||
await this.getPage().getByRole('button', { name: /Publish Update/ }).click()
|
||||
})
|
||||
|
||||
Then('the app should be marked as published', async function (this: DifyWorld) {
|
||||
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { adminCredentials } from '../../../fixtures/auth'
|
||||
|
||||
When('I open the sign-in page', async function (this: DifyWorld) {
|
||||
await this.getPage().goto('/signin')
|
||||
})
|
||||
|
||||
When('I sign in as the default E2E admin', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
|
||||
await page.getByLabel('Email address').fill(adminCredentials.email)
|
||||
await page.getByLabel('Password').fill(adminCredentials.password)
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
})
|
||||
|
||||
Then('I should be on the apps console', async function (this: DifyWorld) {
|
||||
await expect(this.getPage()).toHaveURL(/\/apps(?:\?.*)?$/, { timeout: 30_000 })
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { createTestApp, syncMinimalWorkflowDraft } from '../../../support/api'
|
||||
|
||||
Given('a {string} app has been created via API', async function (this: DifyWorld, mode: string) {
|
||||
const app = await createTestApp(`E2E ${Date.now()}`, mode)
|
||||
this.createdAppIds.push(app.id)
|
||||
this.lastCreatedAppName = app.name
|
||||
})
|
||||
|
||||
Given('a minimal workflow draft has been synced', async function (this: DifyWorld) {
|
||||
const appId = this.createdAppIds.at(-1)!
|
||||
await syncMinimalWorkflowDraft(appId)
|
||||
})
|
||||
|
||||
When('I open the app from the app list', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
await page.goto('/apps')
|
||||
await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible()
|
||||
await page.getByText(this.lastCreatedAppName!).click()
|
||||
})
|
||||
+2
-1
@@ -12,13 +12,14 @@
|
||||
"e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down",
|
||||
"e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up",
|
||||
"e2e:reset": "tsx ./scripts/setup.ts reset",
|
||||
"type-check": "tsc"
|
||||
"type-check": "tsgo"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cucumber/cucumber": "catalog:",
|
||||
"@dify/tsconfig": "workspace:*",
|
||||
"@playwright/test": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
|
||||
@@ -43,6 +43,34 @@ export async function createTestApp(name: string, mode = 'workflow'): Promise<Ap
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncMinimalWorkflowDraft(appId: string): Promise<void> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
await ctx.post(`/console/api/apps/${appId}/workflows/draft`, {
|
||||
data: {
|
||||
graph: {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'custom',
|
||||
position: { x: 80, y: 282 },
|
||||
data: { id: '1', type: 'start', title: 'Start', variables: [] },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
features: {},
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTestApp(id: string): Promise<void> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
|
||||
+8
-505
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,27 @@ Shared design tokens, the `cn()` utility, a Tailwind CSS preset, and headless pr
|
||||
- Props pattern: `Omit<BaseXxx.Root.Props, 'className' | ...> & VariantProps<typeof xxxVariants> & { /* custom */ }`.
|
||||
- When a component accepts a prop typed from a shared internal module, `export type` it from that component so consumers import it from the component subpath.
|
||||
|
||||
## Overlay Primitive Selection: Tooltip vs PreviewCard vs Popover
|
||||
|
||||
Pick by the **trigger's purpose** and **a11y reach**, not visual richness.
|
||||
|
||||
| Primitive | Opens on | Trigger's purpose | Content | Reachable on touch / SR? |
|
||||
| ------------- | --------------------- | -------------------------- | ------------------------- | ------------------------ |
|
||||
| `Tooltip` | hover / focus | has its own action | short plain-text label | ❌ (label only) |
|
||||
| `PreviewCard` | hover / focus | has a primary click target | supplementary preview | ❌ (via click target) |
|
||||
| `Popover` | click / tap (+ hover) | **to open the popup** | anything, incl. long text | ✅ |
|
||||
|
||||
Base UI decision rule ([docs]):
|
||||
|
||||
> _"If the trigger's purpose is to open the popup itself, it's a popover.
|
||||
> If the trigger's purpose is unrelated to opening the popup, it's a tooltip."_
|
||||
|
||||
Apply this first, then narrow:
|
||||
|
||||
- `Tooltip` — ephemeral visual label. Trigger must already carry its own `aria-label` / visible text; tooltip mirrors it for sighted mouse/keyboard users. No interactive UI, no multi-line prose. Not dwell-able.
|
||||
- `PreviewCard` — hover-revealed rich supplementary preview anchored to a trigger whose click goes somewhere (link, selectable row, jumpable chip). **Hard contract:** the popup MUST NOT contain information or actions unreachable from the trigger's click destination — touch and SR users can't open it. If the info is unique to the popup, switch to `Popover` (click or `openOnHover`) or move it to the click destination. Do not hand-roll "hover to open" on top of `Popover` to evade this split.
|
||||
- `Popover` — any popup with its own interactions, or any "infotip" (`?` / `(i)` glyph whose sole purpose is to reveal help text). Pass `openOnHover` on `PopoverTrigger` for the infotip case — unlike `Tooltip` / `PreviewCard`, this stays accessible to touch and SR users because the popover still opens on tap and focus.
|
||||
|
||||
## Border Radius: Figma Token → Tailwind Class Mapping
|
||||
|
||||
The Figma design system uses `--radius/*` tokens whose scale is **offset by one step** from Tailwind CSS v4 defaults. When translating Figma specs to code, always use this mapping — never use `radius-*` as a CSS class, and never extend `borderRadius` in the preset.
|
||||
@@ -34,3 +55,5 @@ The Figma design system uses `--radius/*` tokens whose scale is **offset by one
|
||||
- **Do not** use `radius-*` as CSS class names. The old `@utility radius-*` definitions have been removed.
|
||||
- When the Figma MCP returns `rounded-[var(--radius/sm, 6px)]`, convert it to the standard Tailwind class from the table above (e.g. `rounded-md`).
|
||||
- For values without a standard Tailwind equivalent (10px, 20px, 28px), use arbitrary values like `rounded-[10px]`.
|
||||
|
||||
[docs]: https://base-ui.com/react/components/tooltip#infotips
|
||||
|
||||
@@ -88,7 +88,7 @@ See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for t
|
||||
|
||||
- `pnpm -C packages/dify-ui test` — Vitest unit tests for primitives.
|
||||
- `pnpm -C packages/dify-ui storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`.
|
||||
- `pnpm -C packages/dify-ui type-check` — `tsc --noEmit` for this package only.
|
||||
- `pnpm -C packages/dify-ui type-check` — `tsgo --noEmit` for this package only.
|
||||
|
||||
See `[AGENTS.md](./AGENTS.md)` for:
|
||||
|
||||
|
||||
@@ -49,6 +49,10 @@
|
||||
"types": "./src/popover/index.tsx",
|
||||
"import": "./src/popover/index.tsx"
|
||||
},
|
||||
"./preview-card": {
|
||||
"types": "./src/preview-card/index.tsx",
|
||||
"import": "./src/preview-card/index.tsx"
|
||||
},
|
||||
"./scroll-area": {
|
||||
"types": "./src/scroll-area/index.tsx",
|
||||
"import": "./src/scroll-area/index.tsx"
|
||||
@@ -79,7 +83,7 @@
|
||||
"storybook:build": "storybook build",
|
||||
"test": "vp test",
|
||||
"test:watch": "vp test --watch",
|
||||
"type-check": "tsc"
|
||||
"type-check": "tsgo"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@base-ui/react": "catalog:",
|
||||
@@ -105,6 +109,7 @@
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
|
||||
@@ -20,7 +20,7 @@ const meta = {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compound popover built on Base UI Popover. Use it for contextual affordances, overflow menus, filters, and forms that anchor to a trigger. Control placement via the `placement` prop on `PopoverContent` and compose arbitrary children inside the popup.',
|
||||
component: 'Compound popover built on Base UI Popover. Use it for contextual affordances, overflow menus, filters, and forms that anchor to a trigger. Control placement via the `placement` prop on `PopoverContent` and compose arbitrary children inside the popup.\n\nPass `openOnHover` on `PopoverTrigger` when the popup should also reveal on hover (see the **Infotip** story). Unlike `Tooltip` and `PreviewCard`, hover on `Popover` still falls back to tap/focus, so touch and screen-reader users can reach the content.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -101,6 +101,48 @@ export const WithActions: Story = {
|
||||
),
|
||||
}
|
||||
|
||||
export const Infotip: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: [
|
||||
'The **infotip** pattern from [Base UI](https://base-ui.com/react/components/tooltip#infotips): an info glyph (`?`, `(i)`) whose sole purpose is to reveal explanatory text. Use `Popover` with `openOnHover` on the trigger — never `Tooltip`.',
|
||||
'',
|
||||
'Why not `Tooltip`? Tooltips are disabled on touch devices and not announced to screen readers; descriptive help text hidden in them is unreachable for those users. Why not `PreviewCard`? PreviewCard\'s a11y contract requires the trigger to already own a primary click destination, but an info glyph has no other purpose.',
|
||||
'',
|
||||
'Base UI rule of thumb: *"If the trigger\'s purpose is to open the popup itself, it\'s a popover. If the trigger\'s purpose is unrelated to opening the popup, it\'s a tooltip."*',
|
||||
'',
|
||||
'Hover, tap, or focus the `?` icon to open. In the Dify app, reach for `@/app/components/base/infotip` (`<Infotip aria-label={...}>{helpText}</Infotip>`) which wraps this pattern with consistent delays (300/200), typography, and `aria-label` plumbing.',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<span>Usage priority</span>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={300}
|
||||
closeDelay={200}
|
||||
aria-label="Set which resource to use first when running models."
|
||||
render={(
|
||||
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top"
|
||||
popupClassName="max-w-[300px] px-3 py-2 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
Set which resource to use first when running models. The Trial quota will be used after the paid quota is exhausted.
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
const PLACEMENTS: Placement[] = [
|
||||
'top-start',
|
||||
'top',
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
PreviewCard,
|
||||
PreviewCardContent,
|
||||
PreviewCardTrigger,
|
||||
} from '..'
|
||||
|
||||
const renderWithSafeViewport = (ui: import('react').ReactNode) => render(
|
||||
<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>
|
||||
{ui}
|
||||
</div>,
|
||||
)
|
||||
|
||||
describe('PreviewCardContent', () => {
|
||||
describe('Placement', () => {
|
||||
it('should use bottom placement and default offsets when placement props are not provided', async () => {
|
||||
const screen = await renderWithSafeViewport(
|
||||
<PreviewCard open>
|
||||
<PreviewCardTrigger
|
||||
render={<button type="button" aria-label="preview trigger">Open</button>}
|
||||
/>
|
||||
<PreviewCardContent
|
||||
positionerProps={{ 'role': 'group', 'aria-label': 'default positioner' }}
|
||||
popupProps={{ 'role': 'dialog', 'aria-label': 'default popup' }}
|
||||
>
|
||||
<span>Default content</span>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-side', 'bottom')
|
||||
await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-align', 'center')
|
||||
await expect.element(screen.getByRole('dialog', { name: 'default popup' })).toHaveTextContent('Default content')
|
||||
})
|
||||
|
||||
it('should apply parsed custom placement and custom offsets when placement props are provided', async () => {
|
||||
const screen = await renderWithSafeViewport(
|
||||
<PreviewCard open>
|
||||
<PreviewCardTrigger
|
||||
render={<button type="button" aria-label="preview trigger">Open</button>}
|
||||
/>
|
||||
<PreviewCardContent
|
||||
placement="top-end"
|
||||
sideOffset={14}
|
||||
alignOffset={6}
|
||||
positionerProps={{ 'role': 'group', 'aria-label': 'custom positioner' }}
|
||||
popupProps={{ 'role': 'dialog', 'aria-label': 'custom popup' }}
|
||||
>
|
||||
<span>Custom placement content</span>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-side', 'top')
|
||||
await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-align', 'end')
|
||||
await expect.element(screen.getByRole('dialog', { name: 'custom popup' })).toHaveTextContent('Custom placement content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Passthrough props', () => {
|
||||
it('should forward positionerProps and popupProps when passthrough props are provided', async () => {
|
||||
const onPopupClick = vi.fn()
|
||||
|
||||
const screen = await render(
|
||||
<PreviewCard open>
|
||||
<PreviewCardTrigger
|
||||
render={<button type="button" aria-label="preview trigger">Open</button>}
|
||||
/>
|
||||
<PreviewCardContent
|
||||
positionerProps={{
|
||||
'role': 'group',
|
||||
'aria-label': 'preview positioner',
|
||||
'id': 'preview-positioner-id',
|
||||
}}
|
||||
popupProps={{
|
||||
'id': 'preview-popup-id',
|
||||
'role': 'dialog',
|
||||
'aria-label': 'preview content',
|
||||
'onClick': onPopupClick,
|
||||
}}
|
||||
>
|
||||
<span>Preview body</span>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>,
|
||||
)
|
||||
|
||||
const popup = screen.getByRole('dialog', { name: 'preview content' })
|
||||
await popup.click()
|
||||
|
||||
await expect.element(screen.getByRole('group', { name: 'preview positioner' })).toHaveAttribute('id', 'preview-positioner-id')
|
||||
await expect.element(popup).toHaveAttribute('id', 'preview-popup-id')
|
||||
expect(onPopupClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Trigger click behavior', () => {
|
||||
it('should forward the trigger click to the consumer handler so the primary action runs', async () => {
|
||||
const onPrimaryClick = vi.fn()
|
||||
|
||||
const screen = await renderWithSafeViewport(
|
||||
<PreviewCard>
|
||||
<PreviewCardTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label="preview trigger"
|
||||
onClick={onPrimaryClick}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PreviewCardContent
|
||||
popupProps={{ 'role': 'dialog', 'aria-label': 'preview content' }}
|
||||
>
|
||||
<span>Preview body</span>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'preview trigger' })
|
||||
await trigger.click()
|
||||
|
||||
expect(onPrimaryClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { Placement } from '.'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
createPreviewCardHandle,
|
||||
PreviewCard,
|
||||
PreviewCardContent,
|
||||
PreviewCardTrigger,
|
||||
} from '.'
|
||||
|
||||
const rowButtonClassName
|
||||
= 'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover'
|
||||
|
||||
const triggerButtonClassName
|
||||
= 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
|
||||
|
||||
const inlineLinkClassName
|
||||
= 'text-text-accent underline decoration-text-accent/60 decoration-1 underline-offset-2 outline-hidden hover:decoration-text-accent focus-visible:rounded-xs focus-visible:no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-text-accent data-[popup-open]:decoration-text-accent'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/PreviewCard',
|
||||
component: PreviewCard,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Hover- and focus-activated rich preview for triggers whose primary click has its own destination (following a link, selecting a row, jumping to a definition). Built on Base UI PreviewCard.\n\n**A11y contract:** touch and screen-reader users cannot open the preview. Never place information or actions in the popup that are not also reachable from the trigger\'s primary click destination. If that is unavoidable, add a separate click affordance (Popover) or move the unique content onto the destination.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PreviewCard>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// --- Canonical: inline link preview ---------------------------------------
|
||||
// Mirrors Base UI's own PreviewCard docs demo: an inline `<a href>` in a
|
||||
// paragraph, hovering reveals a rich preview (image + summary) of the link's
|
||||
// destination. The Wikipedia URL and Unsplash image are the exact assets used
|
||||
// in base-ui.com's public docs so the story renders a real preview.
|
||||
// https://base-ui.com/react/components/preview-card
|
||||
const typographyPreview = createPreviewCardHandle()
|
||||
|
||||
export const LinkPreview: Story = {
|
||||
name: 'Link preview (canonical)',
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'The prototypical PreviewCard use case: an inline hyperlink with a rich hover preview of the destination. Uses a detached trigger + `createPreviewCardHandle()` so the trigger can sit inline in prose while the popup content is defined elsewhere. The trigger renders a real `<a href>` — click still follows the link; the preview is strictly supplementary.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="max-w-md p-6 text-sm leading-6 text-text-secondary">
|
||||
<p>
|
||||
The principles of good
|
||||
{' '}
|
||||
<PreviewCardTrigger
|
||||
handle={typographyPreview}
|
||||
href="https://en.wikipedia.org/wiki/Typography"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={inlineLinkClassName}
|
||||
>
|
||||
typography
|
||||
</PreviewCardTrigger>
|
||||
{' '}
|
||||
remain in the digital age.
|
||||
</p>
|
||||
|
||||
<PreviewCard handle={typographyPreview}>
|
||||
<PreviewCardContent popupClassName="w-[240px] p-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<img
|
||||
width="224"
|
||||
height="150"
|
||||
className="block max-w-none rounded-md"
|
||||
src="https://images.unsplash.com/photo-1619615391095-dfa29e1672ef?q=80&w=448&h=300"
|
||||
alt="Station Hofplein signage in Rotterdam, Netherlands"
|
||||
/>
|
||||
<p className="m-0 text-xs leading-5 text-text-secondary">
|
||||
<strong className="text-text-primary">Typography</strong>
|
||||
{' '}
|
||||
is the art and science of arranging type to make written language legible, readable, and visually appealing.
|
||||
</p>
|
||||
</div>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Supplementary: Story = {
|
||||
name: 'Supplementary preview on a button trigger',
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Application-level adaptation of the same semantic: the trigger is a `<button>` that owns a primary action (selecting a model row) rather than an `<a>`. The preview still only shows supplementary info reachable from the selection destination, so the a11y contract holds.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<PreviewCard>
|
||||
<PreviewCardTrigger
|
||||
render={(
|
||||
<button type="button" className={rowButtonClassName}>
|
||||
<span className="i-ri-sparkling-fill h-4 w-4 text-text-accent" />
|
||||
<span>gpt-4o</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PreviewCardContent
|
||||
placement="right"
|
||||
popupClassName="w-[220px] p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm font-medium text-text-primary">gpt-4o</div>
|
||||
<div className="text-xs text-text-tertiary">
|
||||
Multimodal flagship model. Vision, audio and 128k context.
|
||||
</div>
|
||||
</div>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>
|
||||
),
|
||||
}
|
||||
|
||||
const PLACEMENTS: Placement[] = [
|
||||
'top-start',
|
||||
'top',
|
||||
'top-end',
|
||||
'right-start',
|
||||
'right',
|
||||
'right-end',
|
||||
'bottom-start',
|
||||
'bottom',
|
||||
'bottom-end',
|
||||
'left-start',
|
||||
'left',
|
||||
'left-end',
|
||||
]
|
||||
|
||||
const PlacementsDemo = () => {
|
||||
const [placement, setPlacement] = useState<Placement>('bottom')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 p-20">
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
{PLACEMENTS.map(value => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setPlacement(value)}
|
||||
className={`rounded-md border border-divider-subtle px-2 py-1 text-text-secondary ${
|
||||
placement === value ? 'bg-state-base-hover' : 'bg-components-button-secondary-bg'
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<PreviewCard open>
|
||||
<PreviewCardTrigger
|
||||
render={<button type="button" className={triggerButtonClassName}>Hover me</button>}
|
||||
/>
|
||||
<PreviewCardContent placement={placement} popupClassName="w-56 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm font-semibold text-text-primary">
|
||||
placement="
|
||||
{placement}
|
||||
"
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
Preview positions itself relative to the trigger.
|
||||
</div>
|
||||
</div>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Placements: Story = {
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
render: () => <PlacementsDemo />,
|
||||
}
|
||||
|
||||
const CustomDelayDemo = () => (
|
||||
<PreviewCard>
|
||||
<PreviewCardTrigger
|
||||
delay={100}
|
||||
closeDelay={100}
|
||||
render={<button type="button" className={triggerButtonClassName}>Snappy trigger</button>}
|
||||
/>
|
||||
<PreviewCardContent popupClassName="w-64 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm font-semibold text-text-primary">Fast hover</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
Base UI defaults (600ms / 300ms) are tuned for link previews. Override per trigger for denser UIs.
|
||||
</div>
|
||||
</div>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>
|
||||
)
|
||||
|
||||
export const CustomDelays: Story = {
|
||||
render: () => <CustomDelayDemo />,
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Placement } from '../placement'
|
||||
import { PreviewCard as BasePreviewCard } from '@base-ui/react/preview-card'
|
||||
import { cn } from '../cn'
|
||||
import { parsePlacement } from '../placement'
|
||||
|
||||
export type { Placement }
|
||||
|
||||
/**
|
||||
* PreviewCard is a hover/focus-triggered rich preview intended to supplement a
|
||||
* trigger whose primary action is its own click destination (e.g. a link, a
|
||||
* selectable row, a chip that jumps to a definition).
|
||||
*
|
||||
* A11y contract — match Base UI's guidance:
|
||||
* - The popup MUST NOT contain information or actions that are not also
|
||||
* reachable from the trigger's primary click destination. Touch and screen
|
||||
* reader users cannot open the card and must be able to get the same
|
||||
* information/actions without it.
|
||||
* - If content is unique to the popup, either (a) add a separate click-triggered
|
||||
* affordance (Popover) next to the trigger, or (b) move the unique content
|
||||
* onto the click destination.
|
||||
*/
|
||||
export const PreviewCard = BasePreviewCard.Root
|
||||
export const PreviewCardTrigger = BasePreviewCard.Trigger
|
||||
export const createPreviewCardHandle = BasePreviewCard.createHandle
|
||||
|
||||
type PreviewCardContentProps = {
|
||||
children: ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: Omit<
|
||||
BasePreviewCard.Positioner.Props,
|
||||
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
|
||||
>
|
||||
popupProps?: Omit<
|
||||
BasePreviewCard.Popup.Props,
|
||||
'children' | 'className'
|
||||
>
|
||||
}
|
||||
|
||||
export function PreviewCardContent({
|
||||
children,
|
||||
placement = 'bottom',
|
||||
sideOffset = 8,
|
||||
alignOffset = 0,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
}: PreviewCardContentProps) {
|
||||
const { side, align } = parsePlacement(placement)
|
||||
|
||||
return (
|
||||
<BasePreviewCard.Portal>
|
||||
<BasePreviewCard.Positioner
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className={cn('z-1002 outline-hidden', className)}
|
||||
{...positionerProps}
|
||||
>
|
||||
<BasePreviewCard.Popup
|
||||
className={cn(
|
||||
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
|
||||
'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',
|
||||
popupClassName,
|
||||
)}
|
||||
{...popupProps}
|
||||
>
|
||||
{children}
|
||||
</BasePreviewCard.Popup>
|
||||
</BasePreviewCard.Positioner>
|
||||
</BasePreviewCard.Portal>
|
||||
)
|
||||
}
|
||||
@@ -46,20 +46,7 @@ describe('TooltipContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Variant and popup props', () => {
|
||||
it('should render popup content when variant is plain', async () => {
|
||||
const screen = await render(
|
||||
<Tooltip open>
|
||||
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
|
||||
<TooltipContent variant="plain" role="tooltip" aria-label="plain tooltip">
|
||||
Plain tooltip body
|
||||
</TooltipContent>
|
||||
</Tooltip>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('tooltip', { name: 'plain tooltip' })).toHaveTextContent('Plain tooltip body')
|
||||
})
|
||||
|
||||
describe('Popup props', () => {
|
||||
it('should forward popup props and handlers when popup props are provided', async () => {
|
||||
const onMouseEnter = vi.fn()
|
||||
|
||||
@@ -83,7 +70,11 @@ describe('TooltipContent', () => {
|
||||
|
||||
await expect.element(popup).toHaveAttribute('id', 'tooltip-popup-id')
|
||||
await expect.element(popup).toHaveAttribute('data-track-id', 'tooltip-track')
|
||||
expect(onMouseEnter).toHaveBeenCalledTimes(1)
|
||||
// Intent of the assertion is "handler is wired up". The exact call count
|
||||
// depends on vitest-browser's pointer simulation and Base UI's internal
|
||||
// pointer tracking (both of which may fire more than one enter event for
|
||||
// a single `.hover()` action), so assert presence, not count.
|
||||
expect(onMouseEnter).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply className to the popup and positionerClassName to the positioner', async () => {
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '.'
|
||||
|
||||
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
|
||||
const iconButtonClassName = 'inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-subtle bg-components-button-secondary-bg text-text-secondary shadow-xs hover:bg-state-base-hover'
|
||||
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Tooltip',
|
||||
@@ -25,7 +25,7 @@ const meta = {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compound tooltip built on Base UI Tooltip. Wrap the app in `TooltipProvider` (done automatically in these stories) so multiple tooltips share open/close delays. Each tooltip pairs a `TooltipTrigger` with a `TooltipContent` and supports placement, offsets, and two style variants.',
|
||||
component: 'Compound tooltip built on Base UI Tooltip. Wrap the app in `TooltipProvider` (done automatically in these stories) so multiple tooltips share open/close delays. Each tooltip pairs a `TooltipTrigger` with a `TooltipContent` and supports placement and offsets.\n\n**Usage contract** (mirrors the [Base UI tooltip guidelines](https://base-ui.com/react/components/tooltip#alternatives-to-tooltips)):\n\n- Tooltips are **supplementary visual labels** for sighted mouse and keyboard users. They are disabled on touch devices and are not announced to screen readers.\n- The trigger **must carry its own `aria-label` or visible text** that matches the tooltip — the tooltip does not replace labeling.\n- Keep content short and non-interactive (an icon-button label, a keyboard shortcut, one-word clarification).\n- **Do not** place descriptions, prose, links, or interactive controls inside a tooltip — touch and screen-reader users cannot reach them.\n- For hover-triggered rich previews that users move their cursor onto, use `PreviewCard` (dwell-able, structured content).\n- For an info icon that explains a concept (an "infotip"), or for any hover popup that needs interactive content or to reach touch/assistive-tech users, use `Popover` with `openOnHover` on the trigger.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -35,47 +35,58 @@ const meta = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<button type="button" className={triggerButtonClassName} />}
|
||||
>
|
||||
Hover me
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Tooltips describe interactive elements without a click.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
const ICON_ACTIONS = [
|
||||
{ icon: 'i-ri-pencil-line', label: 'Edit' },
|
||||
{ icon: 'i-ri-file-copy-line', label: 'Duplicate' },
|
||||
{ icon: 'i-ri-archive-line', label: 'Archive' },
|
||||
{ icon: 'i-ri-delete-bin-line', label: 'Delete' },
|
||||
] as const
|
||||
|
||||
export const Plain: Story = {
|
||||
export const IconButton: Story = {
|
||||
name: 'Icon button (canonical)',
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Use `variant="plain"` to render the popup without default chrome (background, padding, typography). Apply your own styling via `className` on `TooltipContent`.',
|
||||
story: 'The canonical tooltip use case: an icon-only button surfaces its accessible label as a tooltip for sighted mouse and keyboard users. The trigger already carries `aria-label` — the tooltip mirrors that label visually; it does **not** replace it.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="flex items-center gap-3">
|
||||
{ICON_ACTIONS.map(({ icon, label }) => (
|
||||
<Tooltip key={label}>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button type="button" aria-label={label} className={iconButtonClassName}>
|
||||
<span aria-hidden className={`${icon} h-4 w-4`} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const KeyboardShortcut: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A short, supplementary hint that surfaces a keyboard shortcut next to a visible button label. The trigger is fully self-describing ("Save"); the tooltip only adds non-essential extra clarity for mouse/keyboard users.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<button type="button" className={triggerButtonClassName} />}
|
||||
>
|
||||
Preview details
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
variant="plain"
|
||||
className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-lg"
|
||||
>
|
||||
<div className="flex w-64 flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-text-primary">Dataset preview</span>
|
||||
<span className="text-xs text-text-secondary">
|
||||
32 documents • Last indexed 2 minutes ago
|
||||
</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
render={(
|
||||
<button type="button" className={triggerButtonClassName}>
|
||||
Save
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>⌘S</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
@@ -116,14 +127,10 @@ const PlacementsDemo = () => {
|
||||
</div>
|
||||
<Tooltip open>
|
||||
<TooltipTrigger
|
||||
render={<button type="button" className={triggerButtonClassName} />}
|
||||
>
|
||||
Anchor
|
||||
</TooltipTrigger>
|
||||
render={<button type="button" aria-label="Placement anchor" className={iconButtonClassName}><span aria-hidden className="i-ri-pushpin-line h-4 w-4" /></button>}
|
||||
/>
|
||||
<TooltipContent placement={placement}>
|
||||
placement="
|
||||
{placement}
|
||||
"
|
||||
{`placement="${placement}"`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -133,113 +140,45 @@ const PlacementsDemo = () => {
|
||||
export const Placements: Story = {
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Placement reference. `placement` accepts the 12 standard side/align combinations; Base UI flips automatically if the tooltip would overflow the viewport.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => <PlacementsDemo />,
|
||||
}
|
||||
|
||||
export const OnIconButtons: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Tooltips are essential for icon-only buttons. The trigger is the button; the tooltip provides the accessible label and hover hint.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="flex items-center gap-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button type="button" aria-label="Edit" className={iconButtonClassName}>
|
||||
<span aria-hidden className="i-ri-pencil-line h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button type="button" aria-label="Duplicate" className={iconButtonClassName}>
|
||||
<span aria-hidden className="i-ri-file-copy-line h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>Duplicate</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button type="button" aria-label="Archive" className={iconButtonClassName}>
|
||||
<span aria-hidden className="i-ri-archive-line h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>Archive</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button type="button" aria-label="Delete" className={iconButtonClassName}>
|
||||
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<button type="button" className={triggerButtonClassName} />}
|
||||
>
|
||||
What are tokens?
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Tokens are the basic units a model reads. English text averages ~4 characters per token; non-Latin scripts often use more tokens per character. Both input and output count toward your quota.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
|
||||
const DELAY_PRESETS: Array<{ label: string, delay: number }> = [
|
||||
{ label: 'Instant (0ms)', delay: 0 },
|
||||
{ label: 'Fast (150ms)', delay: 150 },
|
||||
{ label: 'Default (600ms)', delay: 600 },
|
||||
{ label: 'Instant', delay: 0 },
|
||||
{ label: 'Fast', delay: 150 },
|
||||
{ label: 'Default', delay: 600 },
|
||||
]
|
||||
|
||||
const DelayDemo = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{DELAY_PRESETS.map(({ label, delay }) => (
|
||||
<TooltipProvider key={delay} delay={delay}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<button type="button" className={triggerButtonClassName} />}
|
||||
>
|
||||
{label}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Appeared after
|
||||
{delay}
|
||||
ms hover delay.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const DelayDemo = () => (
|
||||
<div className="flex items-center gap-3">
|
||||
{DELAY_PRESETS.map(({ label, delay }) => (
|
||||
<TooltipProvider key={delay} delay={delay}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button type="button" aria-label={`${label} (${delay}ms)`} className={iconButtonClassName}>
|
||||
<span aria-hidden className="i-ri-timer-line h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>{`${label} (${delay}ms)`}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const WithDelay: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: '`TooltipProvider` controls hover `delay` (and `closeDelay`) for the tooltips nested inside it. Adjacent tooltips under the same provider open instantly after the first has been shown.',
|
||||
story: '`TooltipProvider` controls hover `delay` (and `closeDelay`) for the tooltips nested inside it. Adjacent tooltips under the same provider open instantly after the first has been shown. The Dify app root sets `delay={300} closeDelay={200}` — override locally only when the surrounding UX demands it.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,7 +8,28 @@ import { parsePlacement } from '../placement'
|
||||
|
||||
export type { Placement }
|
||||
|
||||
type TooltipContentVariant = 'default' | 'plain'
|
||||
/**
|
||||
* Tooltip is an **ephemeral hint** tied to a trigger (typically an icon button,
|
||||
* badge, or short label). It follows Base UI's Tooltip semantics:
|
||||
*
|
||||
* - Opens on pointer hover or keyboard focus on the trigger.
|
||||
* - Closes as soon as the pointer leaves the trigger — the popup itself is
|
||||
* **not dwell-able**; users cannot move their cursor onto the tooltip.
|
||||
* - Must contain only short, non-interactive text. No links, buttons, form
|
||||
* controls, or structured panels.
|
||||
*
|
||||
* If you need any of the following, use `PreviewCard` instead (hover-triggered
|
||||
* rich preview that users can move their cursor onto):
|
||||
*
|
||||
* - Multi-line or structured content (icon + title + metadata)
|
||||
* - Content the user needs to "stop and read" for more than ~1 second
|
||||
* - Content wider than ~300px
|
||||
*
|
||||
* If you need interactive affordances (buttons, links, forms) use `Popover`.
|
||||
*/
|
||||
export const TooltipProvider = BaseTooltip.Provider
|
||||
export const Tooltip = BaseTooltip.Root
|
||||
export const TooltipTrigger = BaseTooltip.Trigger
|
||||
|
||||
type TooltipContentProps = {
|
||||
children: ReactNode
|
||||
@@ -17,7 +38,6 @@ type TooltipContentProps = {
|
||||
alignOffset?: number
|
||||
positionerClassName?: string
|
||||
className?: string
|
||||
variant?: TooltipContentVariant
|
||||
} & Omit<BaseTooltip.Popup.Props, 'children' | 'className'>
|
||||
|
||||
export function TooltipContent({
|
||||
@@ -27,7 +47,6 @@ export function TooltipContent({
|
||||
alignOffset = 0,
|
||||
positionerClassName,
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: TooltipContentProps) {
|
||||
const { side, align } = parsePlacement(placement)
|
||||
@@ -43,7 +62,7 @@ export function TooltipContent({
|
||||
>
|
||||
<BaseTooltip.Popup
|
||||
className={cn(
|
||||
variant === 'default' && 'max-w-[300px] rounded-md bg-components-panel-bg px-3 py-2 text-left system-xs-regular wrap-break-word text-text-tertiary shadow-lg',
|
||||
'max-w-[300px] rounded-md bg-components-panel-bg px-3 py-2 text-left system-xs-regular wrap-break-word text-text-tertiary shadow-lg',
|
||||
'origin-(--transform-origin) transition-opacity data-ending-style:opacity-0 data-instant:transition-none data-starting-style:opacity-0 motion-reduce:transition-none',
|
||||
className,
|
||||
)}
|
||||
@@ -55,7 +74,3 @@ export function TooltipContent({
|
||||
</BaseTooltip.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export const TooltipProvider = BaseTooltip.Provider
|
||||
export const Tooltip = BaseTooltip.Root
|
||||
export const TooltipTrigger = BaseTooltip.Trigger
|
||||
|
||||
@@ -2,5 +2,7 @@
|
||||
"extends": "@dify/tsconfig/react.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vite-plus/test/globals"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "tailwind.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "storybook-static", "coverage"]
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp pack",
|
||||
"type-check": "tsc"
|
||||
"type-check": "tsgo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -117,17 +117,17 @@ async function runTypeCheck(
|
||||
|
||||
await fs.mkdir(TYPECHECK_CACHE_DIR, { recursive: true })
|
||||
|
||||
const tscArgs = ['exec', 'tsc', '--noEmit', '--pretty', 'false']
|
||||
const tsgoArgs = ['exec', 'tsgo', '--noEmit', '--pretty', 'false']
|
||||
if (incremental) {
|
||||
tscArgs.push('--incremental', '--tsBuildInfoFile', buildInfoPath)
|
||||
tsgoArgs.push('--incremental', '--tsBuildInfoFile', buildInfoPath)
|
||||
}
|
||||
else {
|
||||
tscArgs.push('--incremental', 'false')
|
||||
tsgoArgs.push('--incremental', 'false')
|
||||
}
|
||||
tscArgs.push('--project', projectPath)
|
||||
tsgoArgs.push('--project', projectPath)
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('pnpm', tscArgs, {
|
||||
const { stdout, stderr } = await execFileAsync('pnpm', tsgoArgs, {
|
||||
cwd: projectDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
Generated
+1353
-1108
File diff suppressed because it is too large
Load Diff
+39
-39
@@ -42,17 +42,17 @@ overrides:
|
||||
svgo@>=3.0.0 <3.3.3: 3.3.3
|
||||
tar@<=7.5.10: 7.5.11
|
||||
undici@>=7.0.0 <7.24.0: 7.24.0
|
||||
vite: npm:@voidzero-dev/vite-plus-core@0.1.18
|
||||
vitest: npm:@voidzero-dev/vite-plus-test@0.1.18
|
||||
vite: npm:@voidzero-dev/vite-plus-core@0.1.19
|
||||
vitest: npm:@voidzero-dev/vite-plus-test@0.1.19
|
||||
yaml@>=2.0.0 <2.8.3: 2.8.3
|
||||
yauzl@<3.2.1: 3.2.1
|
||||
catalog:
|
||||
'@amplitude/analytics-browser': 2.39.0
|
||||
'@amplitude/plugin-session-replay-browser': 1.27.7
|
||||
'@amplitude/analytics-browser': 2.41.0
|
||||
'@amplitude/plugin-session-replay-browser': 1.27.10
|
||||
'@antfu/eslint-config': 8.2.0
|
||||
'@base-ui/react': 1.4.0
|
||||
'@base-ui/react': 1.4.1
|
||||
'@chromatic-com/storybook': 5.1.2
|
||||
'@cucumber/cucumber': 12.8.0
|
||||
'@cucumber/cucumber': 12.8.1
|
||||
'@egoist/tailwindcss-icons': 1.9.2
|
||||
'@emoji-mart/data': 1.2.1
|
||||
'@eslint-react/eslint-plugin': 3.0.0
|
||||
@@ -75,8 +75,8 @@ catalog:
|
||||
'@mdx-js/react': 3.1.1
|
||||
'@mdx-js/rollup': 3.1.1
|
||||
'@monaco-editor/react': 4.7.0
|
||||
'@next/eslint-plugin-next': 16.2.3
|
||||
'@next/mdx': 16.2.3
|
||||
'@next/eslint-plugin-next': 16.2.4
|
||||
'@next/mdx': 16.2.4
|
||||
'@orpc/client': 1.13.14
|
||||
'@orpc/contract': 1.13.14
|
||||
'@orpc/openapi-client': 1.13.14
|
||||
@@ -84,7 +84,7 @@ catalog:
|
||||
'@playwright/test': 1.59.1
|
||||
'@remixicon/react': 4.9.0
|
||||
'@rgrove/parse-xml': 4.2.0
|
||||
'@sentry/react': 10.48.0
|
||||
'@sentry/react': 10.49.0
|
||||
'@storybook/addon-docs': 10.3.5
|
||||
'@storybook/addon-links': 10.3.5
|
||||
'@storybook/addon-onboarding': 10.3.5
|
||||
@@ -95,23 +95,23 @@ catalog:
|
||||
'@streamdown/math': 1.0.2
|
||||
'@svgdotjs/svg.js': 3.2.5
|
||||
'@t3-oss/env-nextjs': 0.13.11
|
||||
'@tailwindcss/postcss': 4.2.2
|
||||
'@tailwindcss/postcss': 4.2.4
|
||||
'@tailwindcss/typography': 0.5.19
|
||||
'@tailwindcss/vite': 4.2.2
|
||||
'@tanstack/eslint-plugin-query': 5.99.0
|
||||
'@tailwindcss/vite': 4.2.4
|
||||
'@tanstack/eslint-plugin-query': 5.99.2
|
||||
'@tanstack/react-devtools': 0.10.2
|
||||
'@tanstack/react-form': 1.29.0
|
||||
'@tanstack/react-form-devtools': 0.2.21
|
||||
'@tanstack/react-query': 5.99.0
|
||||
'@tanstack/react-query-devtools': 5.99.0
|
||||
'@tanstack/react-virtual': 3.13.23
|
||||
'@tanstack/react-form': 1.29.1
|
||||
'@tanstack/react-form-devtools': 0.2.22
|
||||
'@tanstack/react-query': 5.99.2
|
||||
'@tanstack/react-query-devtools': 5.99.2
|
||||
'@tanstack/react-virtual': 3.13.24
|
||||
'@testing-library/dom': 10.4.1
|
||||
'@testing-library/jest-dom': 6.9.1
|
||||
'@testing-library/react': 16.3.2
|
||||
'@testing-library/user-event': 14.6.1
|
||||
'@tsslint/cli': 3.0.3
|
||||
'@tsslint/compat-eslint': 3.0.3
|
||||
'@tsslint/config': 3.0.3
|
||||
'@tsslint/cli': 3.0.4
|
||||
'@tsslint/compat-eslint': 3.0.4
|
||||
'@tsslint/config': 3.0.4
|
||||
'@types/js-cookie': 3.0.6
|
||||
'@types/js-yaml': 4.0.9
|
||||
'@types/negotiator': 0.6.4
|
||||
@@ -120,12 +120,12 @@ catalog:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3
|
||||
'@types/sortablejs': 1.15.9
|
||||
'@typescript-eslint/eslint-plugin': 8.58.2
|
||||
'@typescript-eslint/parser': 8.58.2
|
||||
'@typescript/native-preview': 7.0.0-dev.20260413.1
|
||||
'@typescript-eslint/eslint-plugin': 8.59.0
|
||||
'@typescript-eslint/parser': 8.59.0
|
||||
'@typescript/native-preview': 7.0.0-dev.20260422.1
|
||||
'@vitejs/plugin-react': 6.0.1
|
||||
'@vitejs/plugin-rsc': 0.5.24
|
||||
'@vitest/coverage-v8': 4.1.4
|
||||
'@vitest/coverage-v8': 4.1.5
|
||||
abcjs: 6.6.2
|
||||
agentation: 3.0.2
|
||||
ahooks: 3.9.7
|
||||
@@ -138,22 +138,22 @@ catalog:
|
||||
cron-parser: 5.5.0
|
||||
dayjs: 1.11.20
|
||||
decimal.js: 10.6.0
|
||||
dompurify: 3.4.0
|
||||
dompurify: 3.4.1
|
||||
echarts: 6.0.0
|
||||
echarts-for-react: 3.0.6
|
||||
elkjs: 0.11.1
|
||||
embla-carousel-autoplay: 8.6.0
|
||||
embla-carousel-react: 8.6.0
|
||||
emoji-mart: 5.6.0
|
||||
es-toolkit: 1.45.1
|
||||
eslint: 10.2.0
|
||||
es-toolkit: 1.46.0
|
||||
eslint: 10.2.1
|
||||
eslint-markdown: 0.6.1
|
||||
eslint-plugin-better-tailwindcss: 4.4.1
|
||||
eslint-plugin-hyoban: 0.14.1
|
||||
eslint-plugin-markdown-preferences: 0.41.1
|
||||
eslint-plugin-no-barrel-files: 1.3.1
|
||||
eslint-plugin-react-refresh: 0.5.2
|
||||
eslint-plugin-sonarjs: 4.0.2
|
||||
eslint-plugin-sonarjs: 4.0.3
|
||||
eslint-plugin-storybook: 10.3.5
|
||||
fast-deep-equal: 3.1.3
|
||||
happy-dom: 20.9.0
|
||||
@@ -161,7 +161,7 @@ catalog:
|
||||
hono: 4.12.14
|
||||
html-entities: 2.6.0
|
||||
html-to-image: 1.11.13
|
||||
i18next: 26.0.4
|
||||
i18next: 26.0.6
|
||||
i18next-resources-to-backend: 1.2.1
|
||||
iconify-import-svg: 0.2.0
|
||||
immer: 11.1.4
|
||||
@@ -171,21 +171,21 @@ catalog:
|
||||
js-yaml: 4.1.1
|
||||
jsonschema: 1.5.0
|
||||
katex: 0.16.45
|
||||
knip: 6.4.1
|
||||
ky: 2.0.0
|
||||
knip: 6.6.1
|
||||
ky: 2.0.2
|
||||
lamejs: 1.2.1
|
||||
lexical: 0.43.0
|
||||
loro-crdt: 1.10.8
|
||||
loro-crdt: 1.11.1
|
||||
mermaid: 11.14.0
|
||||
mime: 4.1.0
|
||||
mitt: 3.0.1
|
||||
negotiator: 1.0.0
|
||||
next: 16.2.3
|
||||
next: 16.2.4
|
||||
next-themes: 0.4.6
|
||||
nuqs: 2.8.9
|
||||
pinyin-pro: 3.28.1
|
||||
playwright: 1.59.1
|
||||
postcss: 8.5.9
|
||||
postcss: 8.5.10
|
||||
qrcode.react: 4.2.0
|
||||
qs: 6.15.1
|
||||
react: 19.2.5
|
||||
@@ -213,19 +213,19 @@ catalog:
|
||||
streamdown: 2.5.0
|
||||
string-ts: 2.3.1
|
||||
tailwind-merge: 3.5.0
|
||||
tailwindcss: 4.2.2
|
||||
tailwindcss: 4.2.4
|
||||
tldts: 7.0.28
|
||||
tsx: 4.21.0
|
||||
typescript: 6.0.2
|
||||
typescript: 6.0.3
|
||||
uglify-js: 3.19.3
|
||||
unist-util-visit: 5.1.0
|
||||
use-context-selector: 2.0.0
|
||||
uuid: 13.0.0
|
||||
vinext: 0.0.41
|
||||
vite: npm:@voidzero-dev/vite-plus-core@0.1.18
|
||||
vite: npm:@voidzero-dev/vite-plus-core@0.1.19
|
||||
vite-plugin-inspect: 12.0.0-beta.1
|
||||
vite-plus: 0.1.18
|
||||
vitest: npm:@voidzero-dev/vite-plus-test@0.1.18
|
||||
vite-plus: 0.1.19
|
||||
vitest: npm:@voidzero-dev/vite-plus-test@0.1.19
|
||||
vitest-browser-react: 2.2.0
|
||||
vitest-canvas-mock: 1.1.4
|
||||
zod: 4.3.6
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"build": "vp pack",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix",
|
||||
"type-check": "tsc",
|
||||
"type-check": "tsgo",
|
||||
"test": "vp test",
|
||||
"test:coverage": "vp test --coverage",
|
||||
"publish:check": "./scripts/publish.sh --dry-run",
|
||||
@@ -60,6 +60,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@@ -39,7 +39,7 @@ const jsonResponse = (
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(init.headers ?? {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ const textResponse = (body: string, init: ResponseInit = {}): Response =>
|
||||
new Response(body, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers ?? {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const jsonResponse = (body: unknown, init: ResponseInit = {}): Response =>
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(init.headers ?? {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
||||
pack: {
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
platform: "node",
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '../base-ui-popover'
|
||||
|
||||
type PopoverHarnessProps = {
|
||||
useRenderElement?: boolean
|
||||
preventDefaultOnTrigger?: boolean
|
||||
}
|
||||
|
||||
const PopoverHarness = ({
|
||||
useRenderElement = false,
|
||||
preventDefaultOnTrigger = false,
|
||||
}: PopoverHarnessProps) => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="outside-area">outside</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={useRenderElement
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="custom-trigger"
|
||||
onClick={(event) => {
|
||||
if (preventDefaultOnTrigger)
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
toggle
|
||||
</button>
|
||||
)
|
||||
: undefined}
|
||||
>
|
||||
fallback trigger
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="custom-content"
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
alignOffset={8}
|
||||
positionerProps={{ 'data-positioner': 'true' } as unknown as React.HTMLAttributes<HTMLDivElement>}
|
||||
popupProps={{ 'data-popup': 'true' } as unknown as React.HTMLAttributes<HTMLDivElement>}
|
||||
>
|
||||
<div>popover body</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div data-testid="open-state">{open ? 'open' : 'closed'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('base-ui-popover mock', () => {
|
||||
it('should toggle popover content from the fallback trigger and expose content props', () => {
|
||||
render(<PopoverHarness />)
|
||||
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
|
||||
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-placement', 'bottom-start')
|
||||
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-side-offset', '4')
|
||||
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-align-offset', '8')
|
||||
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-positioner', 'true')
|
||||
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-popup', 'true')
|
||||
expect(screen.getByTestId('popover-content')).toHaveClass('custom-content')
|
||||
})
|
||||
|
||||
it('should keep the popover open on inside clicks and close it on outside clicks or escape', () => {
|
||||
render(<PopoverHarness useRenderElement />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-trigger'))
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
|
||||
|
||||
fireEvent.mouseDown(screen.getByTestId('popover-content'))
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-trigger'))
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
|
||||
|
||||
fireEvent.mouseDown(screen.getByTestId('outside-area'))
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
|
||||
})
|
||||
|
||||
it('should preserve rendered trigger props and respect preventDefault', () => {
|
||||
render(<PopoverHarness useRenderElement preventDefaultOnTrigger />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-trigger'))
|
||||
|
||||
expect(screen.getByTestId('custom-trigger')).toHaveAttribute('data-popover-trigger', 'true')
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the popover closed when the fallback trigger click is prevented', () => {
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
render(
|
||||
<div>
|
||||
<Popover open={false} onOpenChange={vi.fn()}>
|
||||
<PopoverTrigger onClick={handleClick}>
|
||||
fallback trigger
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<div>popover body</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
|
||||
const PopoverContext = React.createContext({
|
||||
open: false,
|
||||
onOpenChange: (_open: boolean) => {},
|
||||
})
|
||||
|
||||
type PopoverProps = {
|
||||
children?: ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
type PopoverTriggerProps = React.HTMLAttributes<HTMLElement> & {
|
||||
children?: ReactNode
|
||||
nativeButton?: boolean
|
||||
render?: React.ReactElement
|
||||
}
|
||||
|
||||
type PopoverContentProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
children?: ReactNode
|
||||
placement?: string
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
positionerProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
popupProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
}
|
||||
|
||||
export const Popover = ({
|
||||
children,
|
||||
open = false,
|
||||
onOpenChange,
|
||||
}: PopoverProps) => {
|
||||
React.useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
const target = event.target as Element | null
|
||||
if (target?.closest?.('[data-popover-trigger="true"], [data-popover-content="true"]'))
|
||||
return
|
||||
|
||||
onOpenChange?.(false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape')
|
||||
onOpenChange?.(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleMouseDown)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [open, onOpenChange])
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={{
|
||||
open,
|
||||
onOpenChange: onOpenChange ?? (() => {}),
|
||||
}}
|
||||
>
|
||||
<div data-testid="popover" data-open={String(open)}>
|
||||
{children}
|
||||
</div>
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const PopoverTrigger = ({
|
||||
children,
|
||||
render,
|
||||
nativeButton: _nativeButton,
|
||||
onClick,
|
||||
...props
|
||||
}: PopoverTriggerProps) => {
|
||||
const { open, onOpenChange } = React.useContext(PopoverContext)
|
||||
const node = render ?? children
|
||||
|
||||
if (React.isValidElement(node)) {
|
||||
const triggerElement = node as React.ReactElement<Record<string, unknown>>
|
||||
const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement> & { 'data-testid'?: string }
|
||||
|
||||
return React.cloneElement(triggerElement, {
|
||||
...props,
|
||||
...childProps,
|
||||
'data-testid': childProps['data-testid'] ?? 'popover-trigger',
|
||||
'data-popover-trigger': 'true',
|
||||
'onClick': (event: React.MouseEvent<HTMLElement>) => {
|
||||
childProps.onClick?.(event)
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented)
|
||||
return
|
||||
onOpenChange(!open)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="popover-trigger"
|
||||
data-popover-trigger="true"
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented)
|
||||
return
|
||||
onOpenChange(!open)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{node}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PopoverContent = ({
|
||||
children,
|
||||
className,
|
||||
placement,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
...props
|
||||
}: PopoverContentProps) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
|
||||
if (!open)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="popover-content"
|
||||
data-popover-content="true"
|
||||
data-placement={placement}
|
||||
data-side-offset={sideOffset}
|
||||
data-align-offset={alignOffset}
|
||||
className={className}
|
||||
{...positionerProps}
|
||||
{...popupProps}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PopoverClose = ({ children }: { children?: ReactNode }) => <>{children}</>
|
||||
export const PopoverTitle = ({ children }: { children?: ReactNode }) => <>{children}</>
|
||||
export const PopoverDescription = ({ children }: { children?: ReactNode }) => <>{children}</>
|
||||
+40
-10
@@ -1,14 +1,17 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import dayjs from 'dayjs'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
|
||||
type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'>
|
||||
type TimePeriodOption = {
|
||||
value: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
periodMapping: { [key: string]: { value: number, name: TimePeriodName } }
|
||||
@@ -24,8 +27,18 @@ const LongTimeRangePicker: FC<Props> = ({
|
||||
queryDateFormat,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const items = React.useMemo<TimePeriodOption[]>(() => {
|
||||
return Object.entries(periodMapping).map(([key, period]) => ({
|
||||
value: key,
|
||||
name: t(`filter.period.${period.name}`, { ns: 'appLog' }),
|
||||
}))
|
||||
}, [periodMapping, t])
|
||||
const [value, setValue] = React.useState('2')
|
||||
const selectedItem = React.useMemo(() => {
|
||||
return items.find(item => item.value === value) ?? null
|
||||
}, [items, value])
|
||||
|
||||
const handleSelect = React.useCallback((item: Item) => {
|
||||
const handleSelect = React.useCallback((item: TimePeriodOption) => {
|
||||
const id = item.value
|
||||
const value = periodMapping[id]?.value ?? '-1'
|
||||
const name = item.name || t('filter.period.allTime', { ns: 'appLog' })
|
||||
@@ -55,13 +68,30 @@ const LongTimeRangePicker: FC<Props> = ({
|
||||
}, [onSelect, periodMapping, queryDateFormat, t])
|
||||
|
||||
return (
|
||||
<SimpleSelect
|
||||
items={Object.entries(periodMapping).map(([k, v]) => ({ value: k, name: t(`filter.period.${v.name}`, { ns: 'appLog' }) }))}
|
||||
className="mt-0 w-40!"
|
||||
notClearable={true}
|
||||
onSelect={handleSelect}
|
||||
defaultValue="2"
|
||||
/>
|
||||
<Select
|
||||
value={selectedItem?.value ?? null}
|
||||
onValueChange={(nextValue) => {
|
||||
if (!nextValue)
|
||||
return
|
||||
const nextItem = items.find(item => item.value === nextValue)
|
||||
if (!nextItem)
|
||||
return
|
||||
setValue(nextValue)
|
||||
handleSelect(nextItem)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-0 w-fit max-w-none">
|
||||
{selectedItem?.name ?? t('placeholder.select', { ns: 'common' })}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<SelectItemText>{item.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
export default React.memo(LongTimeRangePicker)
|
||||
|
||||
+50
-42
@@ -1,19 +1,22 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import dayjs from 'dayjs'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
|
||||
const today = dayjs()
|
||||
|
||||
type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'>
|
||||
type TimePeriodOption = {
|
||||
value: number
|
||||
name: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
isCustomRange: boolean
|
||||
@@ -27,8 +30,19 @@ const RangeSelector: FC<Props> = ({
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const items = useMemo<TimePeriodOption[]>(() => {
|
||||
return ranges.map(range => ({
|
||||
...range,
|
||||
name: t(`filter.period.${range.name}`, { ns: 'appLog' }),
|
||||
}))
|
||||
}, [ranges, t])
|
||||
const [value, setValue] = useState('0')
|
||||
const selectedItem = useMemo(() => {
|
||||
return items.find(item => String(item.value) === value) ?? null
|
||||
}, [items, value])
|
||||
|
||||
const handleSelectRange = useCallback((item: Item) => {
|
||||
const handleSelectRange = useCallback((item: TimePeriodOption) => {
|
||||
const { name, value } = item
|
||||
let period: TimeRange | null = null
|
||||
if (value === 0) {
|
||||
@@ -42,44 +56,38 @@ const RangeSelector: FC<Props> = ({
|
||||
onSelect({ query: period!, name })
|
||||
}, [onSelect])
|
||||
|
||||
const renderTrigger = useCallback((item: Item | null, isOpen: boolean) => {
|
||||
return (
|
||||
<div className={cn('flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pr-2 pl-3', isOpen && 'bg-state-base-hover-alt')}>
|
||||
<div className="system-sm-regular text-components-input-text-filled">{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : item?.name}</div>
|
||||
<RiArrowDownSLine className={cn('size-4 text-text-quaternary', isOpen && 'text-text-secondary')} />
|
||||
</div>
|
||||
)
|
||||
}, [isCustomRange])
|
||||
|
||||
const renderOption = useCallback(({ item, selected }: { item: Item, selected: boolean }) => {
|
||||
return (
|
||||
<>
|
||||
{selected && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-[9px] left-2 flex items-center text-text-accent',
|
||||
)}
|
||||
>
|
||||
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<span className={cn('block truncate system-md-regular')}>{item.name}</span>
|
||||
</>
|
||||
)
|
||||
}, [])
|
||||
return (
|
||||
<SimpleSelect
|
||||
items={ranges.map(v => ({ ...v, name: t(`filter.period.${v.name}`, { ns: 'appLog' }) }))}
|
||||
className="mt-0 w-40!"
|
||||
notClearable={true}
|
||||
onSelect={handleSelectRange}
|
||||
defaultValue={0}
|
||||
wrapperClassName="h-8"
|
||||
optionWrapClassName="w-[200px] translate-x-[-24px]"
|
||||
renderTrigger={renderTrigger}
|
||||
optionClassName="flex items-center py-0 pl-7 pr-2 h-8"
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
<Select
|
||||
value={selectedItem ? String(selectedItem.value) : null}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onValueChange={(nextValue) => {
|
||||
if (!nextValue)
|
||||
return
|
||||
const nextItem = items.find(item => String(item.value) === nextValue)
|
||||
if (!nextItem)
|
||||
return
|
||||
setValue(nextValue)
|
||||
handleSelectRange(nextItem)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-auto w-fit max-w-none border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"
|
||||
>
|
||||
<div className={cn('flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pr-2 pl-3', open && 'bg-state-base-hover-alt')}>
|
||||
<div className="system-sm-regular text-components-input-text-filled">{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : selectedItem?.name}</div>
|
||||
<RiArrowDownSLine className={cn('size-4 text-text-quaternary', open && 'text-text-secondary')} />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="translate-x-[-24px]" popupClassName="w-[200px]" listClassName="p-1">
|
||||
{items.map(item => (
|
||||
<SelectItem key={item.value} value={String(item.value)} className="h-8 py-0 pr-2 pl-7 system-md-regular">
|
||||
<SelectItemText className="px-0">{item.name}</SelectItemText>
|
||||
<SelectItemIndicator className="absolute top-[8px] left-2 ml-0" />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
export default React.memo(RangeSelector)
|
||||
|
||||
+22
-27
@@ -3,13 +3,13 @@ import type { FC } from 'react'
|
||||
import type { PopupProps } from './config-popup'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import ConfigPopup from './config-popup'
|
||||
|
||||
type Props = {
|
||||
@@ -25,36 +25,31 @@ const ConfigBtn: FC<Props> = ({
|
||||
children,
|
||||
...popupProps
|
||||
}) => {
|
||||
const [open, doSetOpen] = useState(false)
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
doSetOpen(v)
|
||||
openRef.current = v
|
||||
}, [doSetOpen])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (popupProps.readOnly && !hasConfigured)
|
||||
return null
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={12}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<div className={cn('select-none', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-11">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className={cn('select-none', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={12}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<ConfigPopup {...popupProps} />
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigBtn)
|
||||
|
||||
@@ -17,6 +17,15 @@ import DatasetSidebarDropdown from './dataset-sidebar-dropdown'
|
||||
import NavLink from './nav-link'
|
||||
import ToggleButton from './toggle-button'
|
||||
|
||||
const isShortcutFromInputArea = (target: EventTarget | null) => {
|
||||
if (!(target instanceof HTMLElement))
|
||||
return false
|
||||
|
||||
return target.tagName === 'INPUT'
|
||||
|| target.tagName === 'TEXTAREA'
|
||||
|| target.isContentEditable
|
||||
}
|
||||
|
||||
type IAppDetailNavProps = {
|
||||
iconType?: 'app' | 'dataset'
|
||||
navigation: Array<{
|
||||
@@ -70,6 +79,9 @@ const AppDetailNav = ({
|
||||
}, [appSidebarExpand, setAppSidebarExpand])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
|
||||
if (isShortcutFromInputArea(e.target))
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
handleToggle()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
+174
-18
@@ -5,16 +5,31 @@ import { InputVarType } from '@/app/components/workflow/types'
|
||||
import ConfigModalFormFields from '../form-fields'
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInAttachmentWrapper: ({ onChange }: { onChange: (files: Array<Record<string, unknown>>) => void }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([
|
||||
{ fileId: 'file-1', type: 'local_file', url: 'https://example.com/file.png' },
|
||||
{ fileId: 'file-2', type: 'remote_url', url: 'https://example.com/file-2.png' },
|
||||
])}
|
||||
>
|
||||
upload-file
|
||||
</button>
|
||||
FileUploaderInAttachmentWrapper: ({
|
||||
onChange,
|
||||
value,
|
||||
fileConfig,
|
||||
}: {
|
||||
onChange: (files?: Array<Record<string, unknown>>) => void
|
||||
value: Array<Record<string, unknown>>
|
||||
fileConfig: Record<string, unknown>
|
||||
}) => (
|
||||
<div>
|
||||
<span data-testid="file-uploader-value">{JSON.stringify(value)}</span>
|
||||
<span data-testid="file-uploader-config">{JSON.stringify(fileConfig)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([
|
||||
{ fileId: 'file-1', type: 'local_file', url: 'https://example.com/file.png' },
|
||||
{ fileId: 'file-2', type: 'remote_url', url: 'https://example.com/file-2.png' },
|
||||
])}
|
||||
>
|
||||
upload-file
|
||||
</button>
|
||||
<button type="button" data-testid="upload-empty-file" onClick={() => onChange(undefined)}>
|
||||
upload-empty-file
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -38,12 +53,6 @@ vi.mock('@/app/components/base/checkbox', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/select', () => ({
|
||||
default: ({ onSelect }: { onSelect: (item: { value: string }) => void }) => (
|
||||
<button type="button" onClick={() => onSelect({ value: 'beta' })}>legacy-select</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@langgenius/dify-ui/select')>()
|
||||
|
||||
@@ -52,6 +61,7 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
|
||||
Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onValueChange(value === 'true' ? 'false' : 'beta')}>{`ui-select:${value}`}</button>
|
||||
<button type="button" onClick={() => onValueChange('__empty__')}>ui-select-empty</button>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
@@ -86,8 +96,8 @@ vi.mock('../../config-select', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../config-string', () => ({
|
||||
default: ({ onChange }: { onChange: (value: number) => void }) => (
|
||||
<button type="button" onClick={() => onChange(64)}>config-string</button>
|
||||
default: ({ onChange, maxLength }: { onChange: (value: number) => void, maxLength: number }) => (
|
||||
<button type="button" data-max-length={String(maxLength)} onClick={() => onChange(64)}>config-string</button>
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -211,4 +221,150 @@ describe('ConfigModalFormFields', () => {
|
||||
fireEvent.click(screen.getByText('json-editor'))
|
||||
expect(jsonProps.onJSONSchemaChange).toHaveBeenCalledWith('{\n "type": "object"\n}')
|
||||
})
|
||||
|
||||
it('should update text input metadata and clear empty defaults for string inputs', () => {
|
||||
const textProps = createBaseProps()
|
||||
textProps.isStringInput = true
|
||||
textProps.tempPayload = {
|
||||
...textProps.tempPayload,
|
||||
type: InputVarType.textInput,
|
||||
default: 'hello',
|
||||
}
|
||||
|
||||
render(<ConfigModalFormFields {...textProps} />)
|
||||
|
||||
const variableInput = screen.getByDisplayValue('question')
|
||||
|
||||
fireEvent.click(screen.getByText('type-selector'))
|
||||
fireEvent.change(variableInput, { target: { value: 'prompt' } })
|
||||
fireEvent.blur(variableInput)
|
||||
fireEvent.change(screen.getByDisplayValue('Question'), { target: { value: 'Prompt Label' } })
|
||||
fireEvent.click(screen.getByText('config-string'))
|
||||
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: '' } })
|
||||
|
||||
expect(textProps.onTypeChange).toHaveBeenCalledWith({ value: InputVarType.select })
|
||||
expect(textProps.onVarNameChange).toHaveBeenCalled()
|
||||
expect(textProps.onVarKeyBlur).toHaveBeenCalled()
|
||||
expect(textProps.payloadChangeHandlers.label).toHaveBeenCalledWith('Prompt Label')
|
||||
expect(textProps.payloadChangeHandlers.max_length).toHaveBeenCalledWith(64)
|
||||
expect(textProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('should clear select defaults and apply uploader fallback values', () => {
|
||||
const selectProps = createBaseProps()
|
||||
selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: 'alpha' }
|
||||
selectProps.options = ['alpha', ' ', 'beta']
|
||||
render(<ConfigModalFormFields {...selectProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('ui-select-empty'))
|
||||
expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
|
||||
|
||||
const singleFallbackProps = createBaseProps()
|
||||
singleFallbackProps.tempPayload = {
|
||||
...singleFallbackProps.tempPayload,
|
||||
type: InputVarType.singleFile,
|
||||
default: undefined,
|
||||
}
|
||||
render(<ConfigModalFormFields {...singleFallbackProps} />)
|
||||
|
||||
expect(screen.getAllByTestId('file-uploader-value')[0]).toHaveTextContent('[]')
|
||||
expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"allowed_file_types":["document"]')
|
||||
expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"allowed_file_upload_methods":["remote_url"]')
|
||||
expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"number_limits":1')
|
||||
fireEvent.click(screen.getAllByTestId('upload-empty-file')[0]!)
|
||||
expect(singleFallbackProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
|
||||
|
||||
const multiFallbackProps = createBaseProps()
|
||||
multiFallbackProps.tempPayload = {
|
||||
...multiFallbackProps.tempPayload,
|
||||
type: InputVarType.multiFiles,
|
||||
default: undefined,
|
||||
max_length: undefined,
|
||||
}
|
||||
render(<ConfigModalFormFields {...multiFallbackProps} />)
|
||||
|
||||
expect(screen.getAllByTestId('file-uploader-value')[1]).toHaveTextContent('[]')
|
||||
expect(screen.getAllByTestId('file-uploader-config')[1]).toHaveTextContent('"number_limits":5')
|
||||
fireEvent.click(screen.getAllByTestId('upload-empty-file')[1]!)
|
||||
expect(multiFallbackProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('should clear number defaults and skip rendering the default selector when options are missing', () => {
|
||||
const numberProps = createBaseProps()
|
||||
numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: '9' }
|
||||
render(<ConfigModalFormFields {...numberProps} />)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('9'), { target: { value: '' } })
|
||||
expect(numberProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
|
||||
|
||||
const selectWithoutOptionsProps = createBaseProps()
|
||||
selectWithoutOptionsProps.tempPayload = { ...selectWithoutOptionsProps.tempPayload, type: InputVarType.select }
|
||||
selectWithoutOptionsProps.options = undefined
|
||||
render(<ConfigModalFormFields {...selectWithoutOptionsProps} />)
|
||||
|
||||
expect(screen.getAllByText('config-select')).toHaveLength(1)
|
||||
expect(screen.queryByText('ui-select:__empty__')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve existing select and file defaults when present', () => {
|
||||
const selectProps = createBaseProps()
|
||||
selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: undefined }
|
||||
selectProps.options = ['alpha', 'beta']
|
||||
render(<ConfigModalFormFields {...selectProps} />)
|
||||
|
||||
expect(screen.getByText('ui-select:__empty__')).toBeInTheDocument()
|
||||
|
||||
const existingFile = { fileId: 'existing-file', type: 'local_file', url: 'https://example.com/existing.png' }
|
||||
const singleFileProps = createBaseProps()
|
||||
singleFileProps.tempPayload = {
|
||||
...singleFileProps.tempPayload,
|
||||
type: InputVarType.singleFile,
|
||||
default: existingFile,
|
||||
}
|
||||
render(<ConfigModalFormFields {...singleFileProps} />)
|
||||
|
||||
expect(screen.getAllByTestId('file-uploader-value')[0]).toHaveTextContent('"fileId":"existing-file"')
|
||||
|
||||
const existingFiles = [
|
||||
{ fileId: 'file-1', type: 'local_file', url: 'https://example.com/1.png' },
|
||||
{ fileId: 'file-2', type: 'remote_url', url: 'https://example.com/2.png' },
|
||||
]
|
||||
const multiFileProps = createBaseProps()
|
||||
multiFileProps.tempPayload = {
|
||||
...multiFileProps.tempPayload,
|
||||
type: InputVarType.multiFiles,
|
||||
default: existingFiles,
|
||||
max_length: 2,
|
||||
}
|
||||
render(<ConfigModalFormFields {...multiFileProps} />)
|
||||
|
||||
expect(screen.getAllByTestId('file-uploader-value')[1]).toHaveTextContent('"fileId":"file-1"')
|
||||
expect(screen.getAllByTestId('file-uploader-config')[1]).toHaveTextContent('"number_limits":2')
|
||||
})
|
||||
|
||||
it('should render empty fallback values for text, paragraph, and number defaults', () => {
|
||||
const textProps = createBaseProps()
|
||||
textProps.isStringInput = true
|
||||
textProps.tempPayload = { ...textProps.tempPayload, type: InputVarType.textInput, default: undefined }
|
||||
const textView = render(<ConfigModalFormFields {...textProps} />)
|
||||
|
||||
expect(screen.getAllByPlaceholderText('variableConfig.inputPlaceholder')[2]).toHaveValue('')
|
||||
expect(screen.getByText('config-string')).toHaveAttribute('data-max-length', '256')
|
||||
textView.unmount()
|
||||
|
||||
const paragraphProps = createBaseProps()
|
||||
paragraphProps.isStringInput = true
|
||||
paragraphProps.tempPayload = { ...paragraphProps.tempPayload, type: InputVarType.paragraph, default: undefined }
|
||||
const paragraphView = render(<ConfigModalFormFields {...paragraphProps} />)
|
||||
|
||||
expect(screen.getByText('config-string')).toHaveAttribute('data-max-length', 'Infinity')
|
||||
expect(paragraphView.container.querySelector('textarea')).toHaveValue('')
|
||||
paragraphView.unmount()
|
||||
|
||||
const numberProps = createBaseProps()
|
||||
numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: undefined }
|
||||
render(<ConfigModalFormFields {...numberProps} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(null)
|
||||
})
|
||||
})
|
||||
|
||||
+73
-11
@@ -10,6 +10,72 @@ vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => '/test',
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => {
|
||||
const React = await import('react')
|
||||
const PopoverContext = React.createContext({
|
||||
open: false,
|
||||
setOpen: (_open: boolean) => {},
|
||||
})
|
||||
|
||||
const Popover = ({
|
||||
children,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? !!controlledOpen : uncontrolledOpen
|
||||
const setOpen = (nextOpen: boolean) => {
|
||||
if (!isControlled)
|
||||
setUncontrolledOpen(nextOpen)
|
||||
onOpenChange?.(nextOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
|
||||
const { open, setOpen } = React.useContext(PopoverContext)
|
||||
return (
|
||||
<div
|
||||
data-testid="popover-trigger"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{render}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverContent = ({
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
if (!open)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="popover-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
}
|
||||
})
|
||||
|
||||
type PortalToFollowElemProps = {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
@@ -209,20 +275,17 @@ describe('ContextVar', () => {
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
const varPickerTrigger = triggers[triggers.length - 1]
|
||||
const varPickerTrigger = screen.getByTestId('popover-trigger')
|
||||
|
||||
await user.click(varPickerTrigger!)
|
||||
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
|
||||
// Select a different option
|
||||
const options = screen.getAllByText('var2')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
await user.click(options[0]!)
|
||||
await user.click(screen.getByText('var2'))
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith('var2')
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle dropdown when clicking the trigger button', async () => {
|
||||
@@ -233,16 +296,15 @@ describe('ContextVar', () => {
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
const varPickerTrigger = triggers[triggers.length - 1]
|
||||
const varPickerTrigger = screen.getByTestId('popover-trigger')
|
||||
|
||||
// Open dropdown
|
||||
await user.click(varPickerTrigger!)
|
||||
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
|
||||
// Close dropdown
|
||||
await user.click(varPickerTrigger!)
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+31
-11
@@ -18,18 +18,21 @@ type PortalToFollowElemProps = {
|
||||
type PortalToFollowElemTriggerProps = React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode, asChild?: boolean }
|
||||
type PortalToFollowElemContentProps = React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
||||
const PortalContext = React.createContext({ open: false })
|
||||
vi.mock('@langgenius/dify-ui/popover', () => {
|
||||
const PortalContext = React.createContext({
|
||||
open: false,
|
||||
onOpenChange: undefined as ((open: boolean) => void) | undefined,
|
||||
})
|
||||
|
||||
const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => {
|
||||
const Popover = ({ children, open, onOpenChange }: PortalToFollowElemProps) => {
|
||||
return (
|
||||
<PortalContext.Provider value={{ open: !!open }}>
|
||||
<PortalContext.Provider value={{ open: !!open, onOpenChange }}>
|
||||
<div data-testid="portal">{children}</div>
|
||||
</PortalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
|
||||
const PopoverContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
|
||||
const { open } = React.useContext(PortalContext)
|
||||
if (!open)
|
||||
return null
|
||||
@@ -40,24 +43,41 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
||||
)
|
||||
}
|
||||
|
||||
const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => {
|
||||
const PopoverTrigger = ({ children, asChild, render, ...props }: PortalToFollowElemTriggerProps & { render?: React.ReactNode }) => {
|
||||
const { open, onOpenChange } = React.useContext(PortalContext)
|
||||
const content = render ?? children
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
props.onClick?.(e)
|
||||
if (!props.onClick)
|
||||
onOpenChange?.(!open)
|
||||
}
|
||||
|
||||
if (React.isValidElement(content)) {
|
||||
return React.cloneElement(content, {
|
||||
...props,
|
||||
'onClick': handleClick,
|
||||
'data-testid': 'portal-trigger',
|
||||
} as React.HTMLAttributes<HTMLElement>)
|
||||
}
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children, {
|
||||
...props,
|
||||
'onClick': handleClick,
|
||||
'data-testid': 'portal-trigger',
|
||||
} as React.HTMLAttributes<HTMLElement>)
|
||||
}
|
||||
return (
|
||||
<div data-testid="portal-trigger" {...props}>
|
||||
{children}
|
||||
<div data-testid="portal-trigger" {...props} onClick={handleClick}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@ import type { FC } from 'react'
|
||||
import type { IInputTypeIconProps } from '@/app/components/app/configuration/config-var/input-type-icon'
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import IconTypeIcon from '@/app/components/app/configuration/config-var/input-type-icon'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type Option = { name: string, value: string, type: string }
|
||||
export type Props = {
|
||||
@@ -33,6 +33,7 @@ const VarItem: FC<{ item: Option }> = ({ item }) => (
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const VarPicker: FC<Props> = ({
|
||||
triggerClassName,
|
||||
className,
|
||||
@@ -45,47 +46,51 @@ const VarPicker: FC<Props> = ({
|
||||
const [open, setOpen] = useState(false)
|
||||
const currItem = options.find(item => item.value === value)
|
||||
const notSetVar = !currItem
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 8,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger className={cn(triggerClassName)} onClick={() => setOpen(v => !v)}>
|
||||
<div className={cn(
|
||||
className,
|
||||
notSetVar ? 'border-[#FEDF89] bg-[#FFFCF5] text-[#DC6803]' : 'border-components-button-secondary-border text-text-accent hover:bg-components-button-secondary-bg',
|
||||
open ? 'bg-components-button-secondary-bg' : 'bg-transparent',
|
||||
`
|
||||
flex h-8 cursor-pointer items-center justify-center space-x-1 rounded-lg border px-2 text-[13px]
|
||||
font-medium shadow-xs
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{value
|
||||
? (
|
||||
<VarItem item={currItem as Option} />
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
{notSelectedVarTip || t('feature.dataSet.queryVariable.choosePlaceholder', { ns: 'appDebug' })}
|
||||
</div>
|
||||
)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className={cn(triggerClassName)}>
|
||||
<div className={cn(
|
||||
className,
|
||||
notSetVar ? 'border-[#FEDF89] bg-[#FFFCF5] text-[#DC6803]' : 'border-components-button-secondary-border text-text-accent hover:bg-components-button-secondary-bg',
|
||||
open ? 'bg-components-button-secondary-bg' : 'bg-transparent',
|
||||
`
|
||||
flex h-8 cursor-pointer items-center justify-center space-x-1 rounded-lg border px-2 text-[13px]
|
||||
font-medium shadow-xs
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{currItem
|
||||
? (
|
||||
<VarItem item={currItem} />
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
{notSelectedVarTip || t('feature.dataSet.queryVariable.choosePlaceholder', { ns: 'appDebug' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDownIcon className={cn(open && 'rotate-180 text-text-tertiary', 'h-3.5 w-3.5')} />
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDownIcon className={cn(open && 'rotate-180 text-text-tertiary', 'h-3.5 w-3.5')} />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={8}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 1000 } }}
|
||||
>
|
||||
{options.length > 0
|
||||
? (
|
||||
<div className="max-h-[50vh] w-[240px] overflow-y-auto rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg">
|
||||
{options.map(({ name, value, type }, index) => (
|
||||
{options.map(({ name, value, type }) => (
|
||||
<div
|
||||
key={index}
|
||||
key={value}
|
||||
className="flex cursor-pointer rounded-lg px-3 py-1 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange(value)
|
||||
@@ -103,9 +108,9 @@ const VarPicker: FC<Props> = ({
|
||||
<div className="text-xs leading-normal text-text-tertiary">{t('feature.dataSet.queryVariable.noVarTip', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(VarPicker)
|
||||
|
||||
@@ -40,28 +40,49 @@ vi.mock('@/app/components/base/input', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/select', () => ({
|
||||
default: ({ defaultValue, onSelect, items, disabled, className }: {
|
||||
defaultValue: string
|
||||
onSelect: (item: { value: string }) => void
|
||||
items: { name: string, value: string }[]
|
||||
allowSearch?: boolean
|
||||
vi.mock('@langgenius/dify-ui/select', async () => {
|
||||
const React = await import('react')
|
||||
const SelectContext = React.createContext<{
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}) => (
|
||||
<select
|
||||
data-testid="select-input"
|
||||
value={defaultValue}
|
||||
onChange={e => onSelect({ value: e.target.value })}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
>
|
||||
{items.map(item => (
|
||||
<option key={item.value} value={item.value}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
}))
|
||||
onValueChange?: (value: string) => void
|
||||
}>({})
|
||||
|
||||
return {
|
||||
Select: ({ children, disabled, onValueChange }: {
|
||||
children: React.ReactNode
|
||||
disabled?: boolean
|
||||
onValueChange?: (value: string) => void
|
||||
}) => (
|
||||
<SelectContext.Provider value={{ disabled, onValueChange }}>
|
||||
<div>{children}</div>
|
||||
</SelectContext.Provider>
|
||||
),
|
||||
SelectTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<div>
|
||||
<button data-testid="select-input" type="button" disabled={context.disabled} className={className}>
|
||||
{children}
|
||||
</button>
|
||||
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
|
||||
empty select value
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<button data-testid={`select-${value}`} type="button" role="option" onClick={() => context.onValueChange?.(value)}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
SelectItemIndicator: () => null,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/textarea', () => ({
|
||||
default: ({ value, onChange, placeholder, readOnly, className }: {
|
||||
@@ -410,11 +431,24 @@ describe('ChatUserInput', () => {
|
||||
}))
|
||||
|
||||
render(<ChatUserInput inputs={{ choice: 'A' }} />)
|
||||
fireEvent.change(screen.getByTestId('select-input'), { target: { value: 'B' } })
|
||||
fireEvent.click(screen.getByTestId('select-B'))
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith({ choice: 'B' })
|
||||
})
|
||||
|
||||
it('should ignore empty select updates', () => {
|
||||
mockUseContext.mockReturnValue(createContextValue({
|
||||
modelConfig: createModelConfig([
|
||||
createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: ['A', 'B', 'C'] }),
|
||||
]),
|
||||
}))
|
||||
|
||||
render(<ChatUserInput inputs={{}} />)
|
||||
fireEvent.click(screen.getByTestId('select-empty'))
|
||||
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call setInputs when number input changes', () => {
|
||||
mockUseContext.mockReturnValue(createContextValue({
|
||||
modelConfig: createModelConfig([
|
||||
@@ -443,20 +477,30 @@ describe('ChatUserInput', () => {
|
||||
})
|
||||
|
||||
it('should not call setInputs for unknown keys', () => {
|
||||
const filteredPromptVariables = {
|
||||
length: 1,
|
||||
forEach: vi.fn(),
|
||||
map: (callback: (value: ExtendedPromptVariable, index: number) => unknown) => [
|
||||
callback(createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), 0),
|
||||
],
|
||||
}
|
||||
mockUseContext.mockReturnValue(createContextValue({
|
||||
modelConfig: createModelConfig([
|
||||
createPromptVariable({ key: 'name', name: 'Name', type: 'string' }),
|
||||
]),
|
||||
modelConfig: {
|
||||
...createModelConfig(),
|
||||
configs: {
|
||||
prompt_template: '',
|
||||
prompt_variables: {
|
||||
filter: () => filteredPromptVariables,
|
||||
} as unknown as PromptVariable[],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
render(<ChatUserInput inputs={{}} />)
|
||||
|
||||
// The component filters by promptVariableObj, so unknown keys won't trigger updates
|
||||
// This is tested indirectly - only valid keys should trigger setInputs
|
||||
fireEvent.change(screen.getByTestId('input-Name'), { target: { value: 'Valid' } })
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetInputs).toHaveBeenCalledWith({ name: 'Valid' })
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -652,7 +696,7 @@ describe('ChatUserInput', () => {
|
||||
render(<ChatUserInput inputs={{}} />)
|
||||
const select = screen.getByTestId('select-input')
|
||||
expect(select).toBeInTheDocument()
|
||||
expect(select.children).toHaveLength(0)
|
||||
expect(screen.queryAllByRole('option')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle select with undefined options', () => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Inputs } from '@/models/debug'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Select from '@/app/components/base/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
@@ -102,13 +102,26 @@ const ChatUserInput = ({
|
||||
)}
|
||||
{type === 'select' && (
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultValue={inputs[key] as string}
|
||||
onSelect={(i) => { handleInputValueChange(key, i.value as string) }}
|
||||
items={(options || []).map(i => ({ name: i, value: i }))}
|
||||
allowSearch={false}
|
||||
value={inputs[key] ? String(inputs[key]) : null}
|
||||
disabled={readonly}
|
||||
/>
|
||||
onValueChange={(nextValue) => {
|
||||
if (!nextValue)
|
||||
return
|
||||
handleInputValueChange(key, nextValue)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
{String(inputs[key] || t('placeholder.select', { ns: 'common' }))}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-(--anchor-width)">
|
||||
{(options || []).map(option => (
|
||||
<SelectItem key={option} value={option}>
|
||||
<SelectItemText>{option}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{type === 'number' && (
|
||||
<Input
|
||||
|
||||
@@ -9,6 +9,29 @@ import PromptValuePanel from '../index'
|
||||
|
||||
const mockSetShowAppConfigureFeaturesModal = vi.fn()
|
||||
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-disabled={disabled ? 'true' : 'false'}
|
||||
className={className}
|
||||
onClick={() => onClick?.()}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => selector({
|
||||
setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal,
|
||||
@@ -24,15 +47,51 @@ vi.mock('@/app/components/base/features/new-feature-panel/feature-bar', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/select', () => ({
|
||||
default: ({ onSelect }: { onSelect: (item: { value: string }) => void }) => (
|
||||
<button type="button" onClick={() => onSelect({ value: 'selected-option' })}>select-input</button>
|
||||
),
|
||||
}))
|
||||
vi.mock('@langgenius/dify-ui/select', async () => {
|
||||
const React = await import('react')
|
||||
const SelectContext = React.createContext<{
|
||||
onValueChange?: (value: string) => void
|
||||
}>({})
|
||||
|
||||
return {
|
||||
Select: ({ children, onValueChange }: {
|
||||
children: React.ReactNode
|
||||
onValueChange?: (value: string) => void
|
||||
}) => (
|
||||
<SelectContext.Provider value={{ onValueChange }}>
|
||||
<div>{children}</div>
|
||||
</SelectContext.Provider>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<div>
|
||||
<button type="button">{children}</button>
|
||||
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
|
||||
empty select value
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<button type="button" onClick={() => context.onValueChange?.(value)}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
SelectItemIndicator: () => null,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({
|
||||
default: ({ onChange }: { onChange: (value: boolean) => void }) => (
|
||||
<button type="button" onClick={() => onChange(true)}>bool-input</button>
|
||||
default: ({ name, onChange }: { name: string, onChange: (value: boolean) => void }) => (
|
||||
<button type="button" data-testid={`bool-input-${name}`} onClick={() => onChange(true)}>
|
||||
bool-input
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -121,7 +180,7 @@ describe('PromptValuePanel', () => {
|
||||
})
|
||||
|
||||
const runButton = screen.getByRole('button', { name: 'appDebug.inputs.run' })
|
||||
expect(runButton).not.toBeDisabled()
|
||||
expect(runButton).toHaveAttribute('data-disabled', 'false')
|
||||
fireEvent.click(runButton)
|
||||
await waitFor(() => expect(mockOnSend).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
@@ -137,9 +196,22 @@ describe('PromptValuePanel', () => {
|
||||
})
|
||||
|
||||
const runButton = screen.getByRole('button', { name: 'appDebug.inputs.run' })
|
||||
expect(runButton).toBeDisabled()
|
||||
fireEvent.click(runButton)
|
||||
expect(mockOnSend).not.toHaveBeenCalled()
|
||||
expect(runButton).toHaveAttribute('data-disabled', 'true')
|
||||
})
|
||||
|
||||
it('invokes the tooltip-branch run handler when the click callback is triggered', () => {
|
||||
renderPanel({
|
||||
context: {
|
||||
mode: AppModeEnum.CHAT,
|
||||
},
|
||||
props: {
|
||||
appType: AppModeEnum.CHAT,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'appDebug.inputs.run' }))
|
||||
|
||||
expect(mockOnSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hydrates default values, supports advanced prompt gating, and toggles the feature panel', () => {
|
||||
@@ -163,12 +235,33 @@ describe('PromptValuePanel', () => {
|
||||
})
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith({ textVar: 'default text' })
|
||||
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true')
|
||||
|
||||
fireEvent.click(screen.getByText('feature bar'))
|
||||
expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables run for advanced completion mode when the completion prompt is empty', () => {
|
||||
renderPanel({
|
||||
context: {
|
||||
isAdvancedMode: true,
|
||||
modelModeType: ModelModeType.completion,
|
||||
completionPromptConfig: {
|
||||
prompt: { text: '' },
|
||||
conversation_histories_role: { user_prefix: 'user', assistant_prefix: 'assistant' },
|
||||
},
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: '',
|
||||
prompt_variables: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true')
|
||||
})
|
||||
|
||||
it('renders paragraph, select, number, checkbox, and vision inputs', () => {
|
||||
const onVisionFilesChange = vi.fn()
|
||||
renderPanel({
|
||||
@@ -203,13 +296,13 @@ describe('PromptValuePanel', () => {
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Paragraph Var'), { target: { value: 'updated paragraph' } })
|
||||
fireEvent.click(screen.getByText('select-input'))
|
||||
fireEvent.click(screen.getByText('b'))
|
||||
fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '2' } })
|
||||
fireEvent.click(screen.getByText('bool-input'))
|
||||
fireEvent.click(screen.getByText('image-uploader'))
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ paragraphVar: 'updated paragraph' }))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ selectVar: 'selected-option' }))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ selectVar: 'b' }))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ numberVar: '2' }))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ boolVar: true }))
|
||||
expect(onVisionFilesChange).toHaveBeenCalledWith([
|
||||
@@ -222,6 +315,127 @@ describe('PromptValuePanel', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores empty select values when choosing prompt options', () => {
|
||||
renderPanel({
|
||||
context: {
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: 'prompt template',
|
||||
prompt_variables: [
|
||||
{ key: 'selectVar', name: 'Select Var', type: 'select', options: ['a', 'b'], required: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
inputs: {
|
||||
selectVar: 'a',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-empty'))
|
||||
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores updates when the rendered field is not tracked in the prompt variable lookup', () => {
|
||||
const filteredPromptVariables = {
|
||||
length: 1,
|
||||
forEach: vi.fn(),
|
||||
map: (callback: (value: { key: string, name: string, type: string, required: boolean }, index: number) => unknown) => [
|
||||
callback({ key: 'textVar', name: 'Text Var', type: 'string', required: true }, 0),
|
||||
],
|
||||
}
|
||||
|
||||
renderPanel({
|
||||
context: {
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: 'prompt template',
|
||||
prompt_variables: {
|
||||
filter: () => filteredPromptVariables,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
inputs: { textVar: '' },
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Text Var'), { target: { value: 'ignored' } })
|
||||
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders empty select and number placeholders when no value is provided', () => {
|
||||
renderPanel({
|
||||
context: {
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: 'prompt template',
|
||||
prompt_variables: [
|
||||
{ key: 'selectVar', name: 'Select Var', type: 'select', required: false },
|
||||
{ key: 'numberVar', name: 'Number Var', type: 'number', required: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
inputs: {
|
||||
selectVar: '',
|
||||
numberVar: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('common.placeholder.select')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Number Var')).toHaveValue(null)
|
||||
expect(screen.queryAllByRole('option')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('falls back to the checkbox key when the label is missing from the rendered collection', () => {
|
||||
const filteredPromptVariables = {
|
||||
length: 1,
|
||||
forEach: vi.fn(),
|
||||
map: (callback: (value: { key: string, name: string, type: string, required: boolean }, index: number) => unknown) => [
|
||||
callback({ key: 'boolVar', name: '', type: 'checkbox', required: false }, 0),
|
||||
],
|
||||
}
|
||||
|
||||
renderPanel({
|
||||
context: {
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: 'prompt template',
|
||||
prompt_variables: {
|
||||
filter: () => filteredPromptVariables,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
inputs: {
|
||||
boolVar: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('bool-input-boolVar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks actions as disabled when readonly even if the prompt is runnable', () => {
|
||||
renderPanel({
|
||||
context: {
|
||||
readonly: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toHaveAttribute('data-disabled', 'true')
|
||||
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true')
|
||||
})
|
||||
|
||||
it('collapses the user input panel and hides the clear and run actions', () => {
|
||||
renderPanel()
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Inputs } from '@/models/debug'
|
||||
import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
@@ -17,7 +18,6 @@ import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
|
||||
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Select from '@/app/components/base/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
@@ -156,14 +156,26 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
|
||||
)}
|
||||
{type === 'select' && (
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultValue={inputs[key] as string}
|
||||
onSelect={(i) => { handleInputValueChange(key, i.value as string) }}
|
||||
items={(options || []).map(i => ({ name: i, value: i }))}
|
||||
allowSearch={false}
|
||||
bgClassName="bg-gray-50"
|
||||
value={inputs[key] ? String(inputs[key]) : null}
|
||||
disabled={readonly}
|
||||
/>
|
||||
onValueChange={(nextValue) => {
|
||||
if (!nextValue)
|
||||
return
|
||||
handleInputValueChange(key, nextValue)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-gray-50">
|
||||
{String(inputs[key] || t('placeholder.select', { ns: 'common' }))}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-(--anchor-width)">
|
||||
{(options || []).map(option => (
|
||||
<SelectItem key={option} value={option}>
|
||||
<SelectItemText>{option}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{type === 'number' && (
|
||||
<Input
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppIconType, AppSSO, Language } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiArrowRightSLine, RiCloseLine } from '@remixicon/react'
|
||||
@@ -19,7 +20,6 @@ import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
@@ -57,6 +57,10 @@ export type ConfigParams = {
|
||||
}
|
||||
|
||||
const prefixSettings = 'overview.appInfo.settings'
|
||||
type SelectOption = {
|
||||
value: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
isChat,
|
||||
@@ -110,6 +114,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext()
|
||||
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
|
||||
const isFreePlan = plan.type === 'sandbox'
|
||||
const languageOptions: SelectOption[] = languages.filter(item => item.supported)
|
||||
const selectedLanguage = languageOptions.find(item => item.value === language)
|
||||
const handlePlanClick = useCallback(() => {
|
||||
if (isFreePlan)
|
||||
setShowPricingModal()
|
||||
@@ -303,13 +309,26 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
{/* language */}
|
||||
<div className="flex items-center">
|
||||
<div className={cn('grow py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.language`, { ns: 'appOverview' })}</div>
|
||||
<SimpleSelect
|
||||
wrapperClassName="w-[200px]"
|
||||
items={languages.filter(item => item.supported)}
|
||||
defaultValue={language}
|
||||
onSelect={item => setLanguage(item.value as Language)}
|
||||
notClearable
|
||||
/>
|
||||
<Select
|
||||
value={selectedLanguage?.value ?? null}
|
||||
onValueChange={(nextValue) => {
|
||||
if (!nextValue)
|
||||
return
|
||||
setLanguage(nextValue as Language)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="large" className="w-[200px]">
|
||||
{selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-(--anchor-width)">
|
||||
{languageOptions.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<SelectItemText>{item.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* theme color */}
|
||||
{isChat && (
|
||||
|
||||
+1
-1
@@ -270,7 +270,7 @@ describe('InputsFormContent', () => {
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
const selNodes = screen.getAllByText('Sel')
|
||||
expect(selNodes.length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('existing')).toBeNull()
|
||||
expect(screen.getByText('existing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles select input empty branches (no current value -> show placeholder)', () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import * as React from 'react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { PortalSelect } from '@/app/components/base/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
@@ -85,13 +85,22 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.select && (
|
||||
<PortalSelect
|
||||
popupClassName="z-[60] w-[200px]"
|
||||
value={inputsFormValue?.[form.variable] ?? form.default ?? ''}
|
||||
items={form.options.map((option: string) => ({ value: option, name: option }))}
|
||||
onSelect={item => handleFormChange(form.variable, item.value as string)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
<Select
|
||||
value={(inputsFormValue?.[form.variable] ?? form.default ?? '') || null}
|
||||
onValueChange={value => value && handleFormChange(form.variable, value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
{String(inputsFormValue?.[form.variable] ?? form.default ?? form.label)}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="z-[60] w-(--anchor-width)">
|
||||
{form.options.map((option: string) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
<SelectItemText>{option}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{form.type === InputVarType.singleFile && (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
RiChatSettingsLine,
|
||||
} from '@remixicon/react'
|
||||
@@ -6,30 +7,29 @@ import { useTranslation } from 'react-i18next'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
|
||||
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
const ViewFormDropdown = () => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
||||
<RiChatSettingsLine className="h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
||||
<RiChatSettingsLine className="h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-xs">
|
||||
<div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4">
|
||||
<Message3Fill className="h-6 w-6 shrink-0" />
|
||||
@@ -39,8 +39,8 @@ const ViewFormDropdown = () => {
|
||||
<InputsFormContent />
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useDocumentDownload } from '@/service/knowledge/use-document'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import Popup from '../popup'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
vi.mock('@/service/knowledge/use-document', () => ({
|
||||
useDocumentDownload: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
import type { Resources } from './index'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import FileIcon from '@/app/components/base/file-icon'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Link from '@/next/link'
|
||||
import { useDocumentDownload } from '@/service/knowledge/use-document'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
@@ -47,22 +43,25 @@ const Popup: FC<PopupProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-start"
|
||||
offset={{
|
||||
mainAxis: 8,
|
||||
crossAxis: -2,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
|
||||
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
||||
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
|
||||
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
||||
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top-start"
|
||||
sideOffset={8}
|
||||
alignOffset={-2}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div data-testid="popup-content" className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]">
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<div className="flex h-[18px] items-center">
|
||||
@@ -156,8 +155,8 @@ const Popup: FC<PopupProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import * as React from 'react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { PortalSelect } from '@/app/components/base/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
@@ -85,13 +85,22 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.select && (
|
||||
<PortalSelect
|
||||
popupClassName="z-[60] w-[200px]"
|
||||
value={inputsFormValue?.[form.variable] ?? form.default ?? ''}
|
||||
items={form.options.map((option: string) => ({ value: option, name: option }))}
|
||||
onSelect={item => handleFormChange(form.variable, item.value as string)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
<Select
|
||||
value={(inputsFormValue?.[form.variable] ?? form.default ?? '') || null}
|
||||
onValueChange={value => value && handleFormChange(form.variable, value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
{String(inputsFormValue?.[form.variable] ?? form.default ?? form.label)}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="z-[60] w-(--anchor-width)">
|
||||
{form.options.map((option: string) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
<SelectItemText>{option}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{form.type === InputVarType.singleFile && (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type Props = {
|
||||
iconColor?: string
|
||||
@@ -17,25 +17,27 @@ const ViewFormDropdown = ({
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<ActionButton
|
||||
size="l"
|
||||
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
|
||||
data-testid="view-form-dropdown-trigger"
|
||||
>
|
||||
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-99">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
size="l"
|
||||
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
|
||||
data-testid="view-form-dropdown-trigger"
|
||||
>
|
||||
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div
|
||||
data-testid="view-form-dropdown-content"
|
||||
className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-xs"
|
||||
@@ -48,8 +50,8 @@ const ViewFormDropdown = ({
|
||||
<InputsFormContent />
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,11 @@ describe('CopyFeedback', () => {
|
||||
expect(mockCopy).toHaveBeenCalledWith('test content')
|
||||
})
|
||||
|
||||
it('calls reset on mouse leave', () => {
|
||||
it('does not reset on mouse leave (relies on hook timeout)', () => {
|
||||
render(<CopyFeedback content="test content" />)
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.mouseLeave(button.firstChild as Element)
|
||||
expect(mockReset).toHaveBeenCalledTimes(1)
|
||||
expect(mockReset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -88,11 +88,11 @@ describe('CopyFeedbackNew', () => {
|
||||
expect(mockCopy).toHaveBeenCalledWith('test content')
|
||||
})
|
||||
|
||||
it('calls reset on mouse leave', () => {
|
||||
it('does not reset on mouse leave (relies on hook timeout)', () => {
|
||||
const { container } = render(<CopyFeedbackNew content="test content" />)
|
||||
const clickableArea = container.querySelector('.cursor-pointer')!.firstChild as HTMLElement
|
||||
fireEvent.mouseLeave(clickableArea)
|
||||
expect(mockReset).toHaveBeenCalledTimes(1)
|
||||
expect(mockReset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user