diff --git a/api/.env.example b/api/.env.example index c7e7abfe3ea..998d8b5d69a 100644 --- a/api/.env.example +++ b/api/.env.example @@ -333,6 +333,7 @@ TIDB_ON_QDRANT_API_KEY=dify TIDB_ON_QDRANT_CLIENT_TIMEOUT=20 TIDB_ON_QDRANT_GRPC_ENABLED=false TIDB_ON_QDRANT_GRPC_PORT=6334 +TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB=sandbox:60,professional:6400,team:25600 TIDB_PUBLIC_KEY=dify TIDB_PRIVATE_KEY=dify TIDB_API_URL=http://127.0.0.1 @@ -432,6 +433,7 @@ OPENGAUSS_MAX_CONNECTION=5 # Upload configuration UPLOAD_FILE_SIZE_LIMIT=15 +KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN=15 UPLOAD_FILE_BATCH_LIMIT=5 UPLOAD_IMAGE_FILE_SIZE_LIMIT=10 UPLOAD_VIDEO_FILE_SIZE_LIMIT=100 diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 6cf125543ac..82c46cf8d26 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -450,6 +450,11 @@ class FileUploadConfig(BaseSettings): default=15, ) + KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN: NonNegativeInt = Field( + description="Maximum allowed file size for knowledge uploads on paid cloud plans in megabytes", + default=15, + ) + UPLOAD_FILE_BATCH_LIMIT: NonNegativeInt = Field( description="Maximum number of files allowed in a single upload batch", default=5, diff --git a/api/configs/middleware/vdb/tidb_on_qdrant_config.py b/api/configs/middleware/vdb/tidb_on_qdrant_config.py index 9ca09551294..6fb7ff08efb 100644 --- a/api/configs/middleware/vdb/tidb_on_qdrant_config.py +++ b/api/configs/middleware/vdb/tidb_on_qdrant_config.py @@ -32,6 +32,11 @@ class TidbOnQdrantConfig(BaseSettings): default=6334, ) + TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB: str = Field( + description="Cloud pre-write thresholds for projected TiDB vector storage usage, in plan:MB pairs.", + default="sandbox:60,professional:6400,team:25600", + ) + TIDB_PUBLIC_KEY: str | None = Field( description="Tidb account public key", default=None, diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py index d4694d60333..cd61545dd11 100644 --- a/api/controllers/console/datasets/datasets_document.py +++ b/api/controllers/console/datasets/datasets_document.py @@ -60,6 +60,7 @@ from services.dataset_ref_service import DatasetRefService from services.dataset_service import DatasetService, DocumentService from services.entities.knowledge_entities.knowledge_entities import KnowledgeConfig, ProcessRule, RetrievalModel from services.file_service import FileService +from services.vector_space_admission_service import get_vector_space_admission_error_fields from tasks.generate_summary_index_task import generate_summary_index_task from ..app.error import ( @@ -935,6 +936,7 @@ class DocumentBatchIndexingStatusApi(DocumentResource): "completed_at": document.completed_at, "paused_at": document.paused_at, "error": document.error, + **get_vector_space_admission_error_fields(document.error), "stopped_at": document.stopped_at, "completed_segments": completed_segments, "total_segments": total_segments, @@ -995,6 +997,7 @@ class DocumentIndexingStatusApi(DocumentResource): "completed_at": document.completed_at, "paused_at": document.paused_at, "error": document.error, + **get_vector_space_admission_error_fields(document.error), "stopped_at": document.stopped_at, "completed_segments": completed_segments, "total_segments": total_segments, diff --git a/api/controllers/console/feature.py b/api/controllers/console/feature.py index 594a8431513..6b6482442af 100644 --- a/api/controllers/console/feature.py +++ b/api/controllers/console/feature.py @@ -10,6 +10,7 @@ from services.feature_service import ( LicenseModel, LimitationModel, SystemFeatureModel, + VectorSpaceLimitationModel, ) from . import console_ns @@ -37,6 +38,7 @@ register_response_schema_models( LimitationModel, SystemFeatureModel, TrialModelsResponse, + VectorSpaceLimitationModel, ) @@ -71,7 +73,7 @@ class FeatureVectorSpaceApi(Resource): @console_ns.response( 200, "Success", - console_ns.models[LimitationModel.__name__], + console_ns.models[VectorSpaceLimitationModel.__name__], ) @setup_required @login_required diff --git a/api/controllers/console/files.py b/api/controllers/console/files.py index 110f4ad0a4b..2f166340846 100644 --- a/api/controllers/console/files.py +++ b/api/controllers/console/files.py @@ -30,6 +30,7 @@ from fields.file_fields import FileResponse, UploadConfig from libs.helper import dump_response from libs.login import login_required from models import Account, UploadFile +from services.feature_service import FeatureService from services.file_service import FileService from . import console_ns @@ -58,7 +59,7 @@ FILE_UPLOAD_PARAMS = { def upload_file_from_request(*, current_user: Account, resource_tenant_id: str | None = None) -> UploadFile: """Validate the multipart request and persist the file under the requested resource tenant.""" - source_str = request.form.get("source") + source_str = request.args.get("source") or request.form.get("source") source: Literal["datasets"] | None = "datasets" if source_str == "datasets" else None if "file" not in request.files: @@ -76,6 +77,12 @@ def upload_file_from_request(*, current_user: Account, resource_tenant_id: str | if source not in ("datasets", None): source = None + default_file_size_limit = ( + FeatureService.get_knowledge_file_size_limit(resource_tenant_id or current_user.current_tenant_id) + if source == "datasets" + else None + ) + try: return FileService(db.engine).upload_file( filename=file.filename, @@ -84,6 +91,7 @@ def upload_file_from_request(*, current_user: Account, resource_tenant_id: str | user=current_user, tenant_id=resource_tenant_id, source=source, + default_file_size_limit=default_file_size_limit, ) except services.errors.file.FileTooLargeError as file_too_large_error: raise FileTooLargeError(file_too_large_error.description) @@ -99,9 +107,11 @@ class FileApi(Resource): @login_required @account_initialization_required @console_ns.response(200, "Success", console_ns.models[UploadConfig.__name__]) - def get(self): + @with_current_tenant_id + def get(self, current_tenant_id: str): config = UploadConfig( file_size_limit=dify_config.UPLOAD_FILE_SIZE_LIMIT, + knowledge_file_size_limit=FeatureService.get_knowledge_file_size_limit(current_tenant_id), batch_count_limit=dify_config.UPLOAD_FILE_BATCH_LIMIT, file_upload_limit=dify_config.BATCH_UPLOAD_LIMIT, image_file_size_limit=dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT, diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index b77c78761a0..f7c023584c6 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -215,7 +215,7 @@ def cloud_edition_billing_resource_check[**P, R](resource: str) -> Callable[[Cal elif resource == "documents" and 0 < documents_upload_quota.limit <= documents_upload_quota.size: # The api of file upload is used in the multiple places, # so we need to check the source of the request from datasets - source = request.args.get("source") + source = request.args.get("source") or request.form.get("source") if source == "datasets": abort(403, "The number of documents has reached the limit of your subscription.") else: diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index 44a7169fc91..2047246f52d 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -85,6 +85,7 @@ from services.entities.knowledge_entities.knowledge_entities import ( ProcessRule, RetrievalModel, ) +from services.feature_service import FeatureService from services.file_service import FileService from services.summary_index_service import SummaryIndexService @@ -699,9 +700,10 @@ class DocumentAddByFileApi(DatasetApiResource): "- `provider_not_initialize` : No valid model provider credentials found. Please go to " "Settings -> Model Provider to complete your provider credentials.\n" "- `invalid_param` : Knowledge base does not exist, external datasets not supported, " - "file too large, unsupported file type, missing required fields, or invalid doc_form " + "unsupported file type, missing required fields, or invalid doc_form " "(must be `text_model`, `hierarchical_model`, or `qa_model`)." ), + 413: "`file_too_large` : File size exceeded.", }, ) @service_api_ns.doc("create_document_by_file") @@ -712,6 +714,7 @@ class DocumentAddByFileApi(DatasetApiResource): 200: "Document created successfully", 401: "Unauthorized - invalid API token", 400: "Bad request - invalid file or parameters", + 413: "File too large", } ) @service_api_ns.response( @@ -778,13 +781,17 @@ class DocumentAddByFileApi(DatasetApiResource): if not current_user: raise ValueError("current_user is required") - upload_file = FileService(db.engine).upload_file( - filename=file.filename, - content=file.stream.read(), - mimetype=file.mimetype, - user=current_user, - source="datasets", - ) + try: + upload_file = FileService(db.engine).upload_file( + filename=file.filename, + content=file.stream.read(), + mimetype=file.mimetype, + user=current_user, + source="datasets", + default_file_size_limit=FeatureService.get_knowledge_file_size_limit(tenant_id), + ) + except services.errors.file.FileTooLargeError as file_too_large_error: + raise FileTooLargeError(file_too_large_error.description) data_source = { "type": "upload_file", "info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}}, @@ -859,6 +866,7 @@ def _update_document_by_file( mimetype=file.mimetype, user=current_user, source="datasets", + default_file_size_limit=FeatureService.get_knowledge_file_size_limit(tenant_id), ) except services.errors.file.FileTooLargeError as file_too_large_error: raise FileTooLargeError(file_too_large_error.description) @@ -916,9 +924,10 @@ class DeprecatedDocumentUpdateByFileApi(DatasetApiResource): "- `provider_not_initialize` : No valid model provider credentials found. Please go to " "Settings -> Model Provider to complete your provider credentials.\n" "- `invalid_param` : Knowledge base does not exist, external datasets not supported, " - "file too large, unsupported file type, or invalid doc_form (must be `text_model`, " - "`hierarchical_model`, or `qa_model`)." + "unsupported file type, or invalid doc_form (must be `text_model`, `hierarchical_model`, " + "or `qa_model`)." ), + 413: "`file_too_large` : File size exceeded.", }, ) @service_api_ns.doc("update_document_by_file_deprecated") @@ -935,6 +944,7 @@ class DeprecatedDocumentUpdateByFileApi(DatasetApiResource): 200: "Document updated successfully", 401: "Unauthorized - invalid API token", 404: "Document not found", + 413: "File too large", } ) @service_api_ns.response( @@ -1400,9 +1410,10 @@ class DocumentApi(DatasetApiResource): "- `provider_not_initialize` : No valid model provider credentials found. Please go to " "Settings -> Model Provider to complete your provider credentials.\n" "- `invalid_param` : Knowledge base does not exist, external datasets not supported, " - "file too large, unsupported file type, or invalid doc_form (must be `text_model`, " - "`hierarchical_model`, or `qa_model`)." + "unsupported file type, or invalid doc_form (must be `text_model`, `hierarchical_model`, " + "or `qa_model`)." ), + 413: "`file_too_large` : File size exceeded.", }, ) @service_api_ns.doc("update_document_by_file") @@ -1413,6 +1424,7 @@ class DocumentApi(DatasetApiResource): 200: "Document updated successfully", 401: "Unauthorized - invalid API token", 404: "Document not found", + 413: "File too large", } ) @service_api_ns.response( diff --git a/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py index 35f3a4c01a0..a2a1ae40bf1 100644 --- a/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py @@ -10,7 +10,12 @@ from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden, NotFound import services -from controllers.common.errors import FilenameNotExistsError, NoFileUploadedError, TooManyFilesError +from controllers.common.errors import ( + FilenameNotExistsError, + FileTooLargeError, + NoFileUploadedError, + TooManyFilesError, +) from controllers.common.fields import GeneratedAppResponse from controllers.common.schema import ( query_params_from_model, @@ -32,7 +37,8 @@ from libs.login import current_user from models import Account from models.dataset import Dataset, Pipeline from models.engine import db -from services.errors.file import FileTooLargeError, UnsupportedFileTypeError +from services.errors.file import UnsupportedFileTypeError +from services.feature_service import FeatureService from services.file_service import FileService from services.rag_pipeline.entity.pipeline_service_api_entities import ( DatasourceNodeRunApiEntity, @@ -363,6 +369,7 @@ class KnowledgebasePipelineFileUploadApi(DatasetApiResource): content=file.stream.read(), mimetype=file.mimetype, user=current_user, + default_file_size_limit=FeatureService.get_knowledge_file_size_limit(tenant_id), ) except services.errors.file.FileTooLargeError as file_too_large_error: raise FileTooLargeError(file_too_large_error.description) diff --git a/api/controllers/service_api/wraps.py b/api/controllers/service_api/wraps.py index 1f84b533bd4..8724e65e674 100644 --- a/api/controllers/service_api/wraps.py +++ b/api/controllers/service_api/wraps.py @@ -13,7 +13,7 @@ from flask_restx.utils import merge from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import sessionmaker -from werkzeug.exceptions import Forbidden, NotFound, Unauthorized +from werkzeug.exceptions import Forbidden, NotFound, ServiceUnavailable, Unauthorized from configs import dify_config from controllers.service_api.schema import ( @@ -190,6 +190,12 @@ def cloud_edition_billing_resource_check[**P, R]( return view(*args, **kwargs) vector_space = FeatureService.get_vector_space(api_token.tenant_id) + if vector_space.usage_unknown: + features = FeatureService.get_features(api_token.tenant_id, exclude_vector_space=True) + if features.billing.enabled and features.billing.subscription.plan == CloudPlan.SANDBOX: + raise ServiceUnavailable( + "Unable to verify vector space usage right now. Please try again later." + ) if 0 < vector_space.limit <= vector_space.size: raise Forbidden("The capacity of the vector space has reached the limit of your subscription.") return view(*args, **kwargs) diff --git a/api/core/app/apps/pipeline/pipeline_generator.py b/api/core/app/apps/pipeline/pipeline_generator.py index 9e97ce836ca..1c3787518d4 100644 --- a/api/core/app/apps/pipeline/pipeline_generator.py +++ b/api/core/app/apps/pipeline/pipeline_generator.py @@ -188,7 +188,7 @@ class PipelineGenerator(BaseAppGenerator): datasource_type=datasource_type, datasource_info=datasource_info, dataset_id=dataset.id, - original_document_id=args.get("original_document_id"), + original_document_id=None if is_retry else args.get("original_document_id"), start_node_id=start_node_id, batch=batch, document_id=document_id, diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py index 63e00f48324..45bba181ba9 100644 --- a/api/core/indexing_runner.py +++ b/api/core/indexing_runner.py @@ -44,13 +44,19 @@ from models.dataset import AutomaticRulesConfig, ChildChunk, Dataset, DatasetPro from models.dataset import Document as DatasetDocument from models.enums import DataSourceType, IndexingStatus, ProcessRuleMode, SegmentStatus from models.model import UploadFile +from services.vector_space_admission_service import VectorSpaceAdmissionService logger = logging.getLogger(__name__) class IndexingRunner: - def __init__(self): + def __init__( + self, + *, + enforce_vector_space_admission: bool = False, + ): self.storage = storage + self.enforce_vector_space_admission = enforce_vector_space_admission @staticmethod def _get_model_manager(tenant_id: str) -> ModelManager: @@ -73,6 +79,7 @@ class IndexingRunner: The phase commits keep document locks short and make newly created segments visible to the worker sessions used for keyword and vector indexing. """ + vector_space_admission = VectorSpaceAdmissionService() for dataset_document in dataset_documents: document_id = dataset_document.id try: @@ -114,6 +121,15 @@ class IndexingRunner: current_user=current_user, session=session, ) + if self.enforce_vector_space_admission: + vector_space_admission.ensure_document_can_be_indexed( + dataset=dataset, + document_id=requeried_document.id, + doc_form=requeried_document.doc_form, + documents=documents, + include_summaries=bool(requeried_document.need_summary), + session=session, + ) token_counts = calculate_segment_token_counts(dataset=dataset, documents=documents) total_tokens = sum(token_counts) # save segment diff --git a/api/core/rag/datasource/vdb/vector_factory.py b/api/core/rag/datasource/vdb/vector_factory.py index 1bd0d8cafbb..a355a90d0f4 100644 --- a/api/core/rag/datasource/vdb/vector_factory.py +++ b/api/core/rag/datasource/vdb/vector_factory.py @@ -128,15 +128,16 @@ class Vector: self._session = session self._vector_processor = self._init_vector(session=session) - def _init_vector(self, *, session: Session) -> BaseVector: + @staticmethod + def resolve_vector_type(dataset: Dataset, *, session: Session) -> str: vector_type = dify_config.VECTOR_STORE - if self._dataset.index_struct_dict: - vector_type = self._dataset.index_struct_dict["type"] + if dataset.index_struct_dict: + vector_type = dataset.index_struct_dict["type"] else: if dify_config.VECTOR_STORE_WHITELIST_ENABLE: stmt = select(Whitelist).where( - Whitelist.tenant_id == self._dataset.tenant_id, Whitelist.category == "vector_db" + Whitelist.tenant_id == dataset.tenant_id, Whitelist.category == "vector_db" ) whitelist = session.scalars(stmt).one_or_none() if whitelist: @@ -145,6 +146,10 @@ class Vector: if not vector_type: raise ValueError("Vector store must be specified.") + return vector_type + + def _init_vector(self, *, session: Session) -> BaseVector: + vector_type = self.resolve_vector_type(self._dataset, session=session) vector_factory_cls = self.get_vector_factory(vector_type) return vector_factory_cls().init_vector(self._dataset, self._attributes, self._embeddings) diff --git a/api/core/rag/index_processor/index_processor.py b/api/core/rag/index_processor/index_processor.py index bf6eb0a1262..dc951c554fe 100644 --- a/api/core/rag/index_processor/index_processor.py +++ b/api/core/rag/index_processor/index_processor.py @@ -15,6 +15,7 @@ from core.rag.index_processor.index_processor_base import SummaryIndexSettingDic from core.workflow.nodes.knowledge_index.exc import KnowledgeIndexNodeError from core.workflow.nodes.knowledge_index.protocols import IndexingResultDict, Preview, PreviewItem, QaPreview from models.dataset import Dataset, Document, DocumentSegment +from services.vector_space_admission_service import VectorSpaceAdmissionService from .index_processor_factory import IndexProcessorFactory from .processor.paragraph_index_processor import ParagraphIndexProcessor @@ -103,7 +104,18 @@ class IndexProcessor: indexing_start_at = time.perf_counter() # The metadata reads above must not keep a transaction open across vector I/O. session.commit() - # delete from vector index + + # V1 guards only first-time indexing. + if not original_document_id: + VectorSpaceAdmissionService().ensure_pipeline_can_be_indexed( + dataset=dataset, + document_id=document.id, + chunk_structure=dataset.chunk_structure, + chunks=chunks, + include_summaries=bool(summary_index_setting and summary_index_setting.get("enable")), + session=session, + ) + if index_node_ids: index_processor.clean( dataset, index_node_ids, with_keywords=True, delete_child_chunks=True, session=session diff --git a/api/events/event_handlers/create_document_index.py b/api/events/event_handlers/create_document_index.py index 8bc4240251f..aba387dd187 100644 --- a/api/events/event_handlers/create_document_index.py +++ b/api/events/event_handlers/create_document_index.py @@ -21,7 +21,7 @@ def handle(sender, **kwargs): document_ids = kwargs.get("document_ids", []) start_at = time.perf_counter() try: - indexing_runner = IndexingRunner() + indexing_runner = IndexingRunner(enforce_vector_space_admission=True) with session_factory.create_session() as session: documents = [] for document_id in document_ids: diff --git a/api/fields/document_fields.py b/api/fields/document_fields.py index 16cc49541b7..64de3f00bb4 100644 --- a/api/fields/document_fields.py +++ b/api/fields/document_fields.py @@ -121,6 +121,9 @@ class DocumentStatusResponse(ResponseModel): completed_at: int | None paused_at: int | None error: str | None + error_code: str | None = None + estimated_vector_space_mb: int | None = None + vector_space_limit_mb: int | None = None stopped_at: int | None completed_segments: int | None = None total_segments: int | None = None diff --git a/api/fields/file_fields.py b/api/fields/file_fields.py index 480c165a362..094f6895bec 100644 --- a/api/fields/file_fields.py +++ b/api/fields/file_fields.py @@ -10,6 +10,7 @@ from libs.helper import to_timestamp class UploadConfig(ResponseModel): file_size_limit: int + knowledge_file_size_limit: int batch_count_limit: int file_upload_limit: int image_file_size_limit: int diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 815c394b132..4b43a89366a 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -6899,7 +6899,7 @@ Check if dataset is in use | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [LimitationModel](#limitationmodel)
| +| 200 | Success | **application/json**: [VectorSpaceLimitationModel](#vectorspacelimitationmodel)
| ### [GET] /files/support-type #### Responses @@ -23024,6 +23024,7 @@ Payload for updating a snippet. | file_upload_limit | integer | | Yes | | image_file_batch_limit | integer | | Yes | | image_file_size_limit | integer | | Yes | +| knowledge_file_size_limit | integer | | Yes | | single_chunk_attachment_limit | integer | | Yes | | skill_file_size_limit | integer | | Yes | | video_file_size_limit | integer | | Yes | @@ -23093,6 +23094,14 @@ in form definition, or a variable while the workflow is running. | ---- | ---- | ----------- | -------- | | ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definition, or a variable while the workflow is running. | | +#### VectorSpaceLimitationModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer | | Yes | +| size | integer | | Yes | +| usage_unknown | boolean | | No | + #### VerificationTokenResponse | Name | Type | Description | Required | diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index 00515af57e7..b7915d02a22 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -1165,9 +1165,10 @@ Create a document by uploading a file. Supports common document formats (PDF, TX | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Document created successfully. | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| -| 400 | - `no_file_uploaded` : Please upload your file. - `too_many_files` : Only one file is allowed. - `filename_not_exists_error` : The specified filename does not exist. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, external datasets not supported, file too large, unsupported file type, missing required fields, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | +| 400 | - `no_file_uploaded` : Please upload your file. - `too_many_files` : Only one file is allowed. - `filename_not_exists_error` : The specified filename does not exist. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, external datasets not supported, unsupported file type, missing required fields, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - dataset API access or workspace access denied | | +| 413 | `file_too_large` : File size exceeded. | | ### [POST] /datasets/{dataset_id}/document/create-by-text **Create Document by Text** @@ -1220,9 +1221,10 @@ Create a document by uploading a file. Supports common document formats (PDF, TX | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Document created successfully. | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| -| 400 | - `no_file_uploaded` : Please upload your file. - `too_many_files` : Only one file is allowed. - `filename_not_exists_error` : The specified filename does not exist. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, external datasets not supported, file too large, unsupported file type, missing required fields, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | +| 400 | - `no_file_uploaded` : Please upload your file. - `too_many_files` : Only one file is allowed. - `filename_not_exists_error` : The specified filename does not exist. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, external datasets not supported, unsupported file type, missing required fields, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - dataset API access or workspace access denied | | +| 413 | `file_too_large` : File size exceeded. | | ### [GET] /datasets/{dataset_id}/documents **List Documents** @@ -1391,10 +1393,11 @@ Update an existing document by uploading a new file. Re-triggers indexing — us | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Document updated successfully. | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| -| 400 | - `too_many_files` : Only one file is allowed. - `filename_not_exists_error` : The specified filename does not exist. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, external datasets not supported, file too large, unsupported file type, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | +| 400 | - `too_many_files` : Only one file is allowed. - `filename_not_exists_error` : The specified filename does not exist. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, external datasets not supported, unsupported file type, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - dataset API access or workspace access denied | | | 404 | Document not found | | +| 413 | `file_too_large` : File size exceeded. | | ### [GET] /datasets/{dataset_id}/documents/{document_id}/download **Download Document** @@ -1443,10 +1446,11 @@ Update an existing document by uploading a new file. Re-triggers indexing — us | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Document updated successfully. | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| -| 400 | - `too_many_files` : Only one file is allowed. - `filename_not_exists_error` : The specified filename does not exist. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, external datasets not supported, file too large, unsupported file type, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | +| 400 | - `too_many_files` : Only one file is allowed. - `filename_not_exists_error` : The specified filename does not exist. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, external datasets not supported, unsupported file type, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - dataset API access or workspace access denied | | | 404 | Document not found | | +| 413 | `file_too_large` : File size exceeded. | | ### [POST] /datasets/{dataset_id}/documents/{document_id}/update-by-text **Update Document by Text** @@ -1502,10 +1506,11 @@ Update an existing document by uploading a new file. Re-triggers indexing — us | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Document updated successfully. | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| -| 400 | - `too_many_files` : Only one file is allowed. - `filename_not_exists_error` : The specified filename does not exist. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, external datasets not supported, file too large, unsupported file type, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | +| 400 | - `too_many_files` : Only one file is allowed. - `filename_not_exists_error` : The specified filename does not exist. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, external datasets not supported, unsupported file type, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - dataset API access or workspace access denied | | | 404 | Document not found | | +| 413 | `file_too_large` : File size exceeded. | | --- ## default @@ -3057,6 +3062,8 @@ Request payload for bulk downloading documents as a zip archive. | completed_at | integer | | Yes | | completed_segments | integer | | No | | error | string | | Yes | +| error_code | string | | No | +| estimated_vector_space_mb | integer | | No | | id | string | | Yes | | indexing_status | string | | Yes | | parsing_completed_at | integer | | Yes | @@ -3065,6 +3072,7 @@ Request payload for bulk downloading documents as a zip archive. | splitting_completed_at | integer | | Yes | | stopped_at | integer | | Yes | | total_segments | integer | | No | +| vector_space_limit_mb | integer | | No | #### DocumentTextCreatePayload diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 0ac38c9257b..636828b9070 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -105,6 +105,7 @@ class _BillingQuota(TypedDict): class _VectorSpaceQuota(TypedDict): size: float limit: int + usage_unknown: NotRequired[bool] class _KnowledgeRateLimit(TypedDict): diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 7b3451596a6..b900fde62f0 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -39,6 +39,14 @@ class LimitationModel(FeatureResponseModel): limit: int = 0 +class VectorSpaceLimitationModel(LimitationModel): + model_config = ConfigDict(json_schema_serialization_defaults_required=False, protected_namespaces=()) + + size: int + limit: int + usage_unknown: bool = Field(default=False, exclude_if=lambda value: not value) + + class LicenseLimitationModel(FeatureResponseModel): """ - enabled: whether this limit is enforced @@ -228,14 +236,15 @@ class FeatureService: return features @classmethod - def get_vector_space(cls, tenant_id: str) -> LimitationModel: - vector_space = LimitationModel(size=0, limit=5) + def get_vector_space(cls, tenant_id: str) -> VectorSpaceLimitationModel: + vector_space = VectorSpaceLimitationModel(size=0, limit=5) if dify_config.BILLING_ENABLED and tenant_id: billing_vector_space = BillingService.get_vector_space(tenant_id) # NOTE: billing API returns vector_space.size as float (e.g. 0.0), # but feature API keeps LimitationModel.size as int for compatibility. vector_space.size = int(billing_vector_space["size"]) vector_space.limit = billing_vector_space["limit"] + vector_space.usage_unknown = billing_vector_space.get("usage_unknown", False) return vector_space @@ -249,6 +258,21 @@ class FeatureService: knowledge_rate_limit.subscription_plan = limit_info.get("subscription_plan", CloudPlan.SANDBOX) return knowledge_rate_limit + @classmethod + def get_knowledge_file_size_limit(cls, tenant_id: str | None) -> int: + default_limit = dify_config.UPLOAD_FILE_SIZE_LIMIT + if not dify_config.BILLING_ENABLED or not tenant_id: + return default_limit + + billing_info = BillingService.get_info(tenant_id, exclude_vector_space=True) + if billing_info["enabled"] and billing_info["subscription"]["plan"] in ( + CloudPlan.PROFESSIONAL, + CloudPlan.TEAM, + ): + return max(default_limit, dify_config.KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN) + + return default_limit + @classmethod def _resolve_human_input_email_delivery_enabled(cls, *, features: FeatureModel, tenant_id: str | None) -> bool: if dify_config.ENTERPRISE_ENABLED or not dify_config.BILLING_ENABLED: diff --git a/api/services/file_service.py b/api/services/file_service.py index acedc049518..3ddc81ab41f 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -56,6 +56,7 @@ class FileService: tenant_id: str | None = None, source: Literal["datasets"] | None = None, source_url: str = "", + default_file_size_limit: int | None = None, ) -> UploadFile: # get file extension extension = os.path.splitext(filename)[1].lstrip(".").lower() @@ -79,7 +80,11 @@ class FileService: file_size = len(content) # check if the file size is exceeded - if not FileService.is_file_size_within_limit(extension=extension, file_size=file_size): + if not FileService.is_file_size_within_limit( + extension=extension, + file_size=file_size, + default_file_size_limit=default_file_size_limit, + ): raise FileTooLargeError # generate file key @@ -119,7 +124,12 @@ class FileService: return upload_file @staticmethod - def is_file_size_within_limit(*, extension: str, file_size: int) -> bool: + def is_file_size_within_limit( + *, + extension: str, + file_size: int, + default_file_size_limit: int | None = None, + ) -> bool: if extension in IMAGE_EXTENSIONS: file_size_limit = dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT * 1024 * 1024 elif extension in VIDEO_EXTENSIONS: @@ -127,7 +137,12 @@ class FileService: elif extension in AUDIO_EXTENSIONS: file_size_limit = dify_config.UPLOAD_AUDIO_FILE_SIZE_LIMIT * 1024 * 1024 else: - file_size_limit = dify_config.UPLOAD_FILE_SIZE_LIMIT * 1024 * 1024 + # Context-specific uploads may override the default limit without changing media-specific limits. + file_size_limit = ( + (default_file_size_limit if default_file_size_limit is not None else dify_config.UPLOAD_FILE_SIZE_LIMIT) + * 1024 + * 1024 + ) return file_size <= file_size_limit diff --git a/api/services/vector_space_admission_service.py b/api/services/vector_space_admission_service.py new file mode 100644 index 00000000000..7d098072c3d --- /dev/null +++ b/api/services/vector_space_admission_service.py @@ -0,0 +1,439 @@ +import json +import logging +import math +import re +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from sqlalchemy.orm import Session + +from configs import dify_config +from core.model_manager import ModelManager +from core.rag.datasource.vdb.vector_factory import Vector +from core.rag.datasource.vdb.vector_type import VectorType +from core.rag.embedding.cached_embedding import CacheEmbedding +from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType +from core.rag.models.document import Document +from enums.cloud_plan import CloudPlan +from enums.deployment_edition import DeploymentEdition +from extensions.ext_redis import redis_client +from graphon.model_runtime.entities.model_entities import ModelType +from models.dataset import Dataset +from services.billing_service import BillingService + +logger = logging.getLogger(__name__) + +_MEBIBYTE = 1024 * 1024 +_FLOAT32_BYTES = 4 +_TIDB_VECTOR_COPIES = 2 +_TIDB_POINT_OVERHEAD_BYTES = 3584 +_WATERMARK_LOCK_TIMEOUT_SECONDS = 5 +_WATERMARK_TTL_SECONDS = 30 * 60 +_ERROR_PATTERN = re.compile( + r"Vector storage is estimated to reach (?P\d+) MB after this upload, " + r"exceeding the (?P\d+) MB limit of the current plan\." +) + +VECTOR_SPACE_ADMISSION_ERROR_CODE = "vector_space_estimate_exceeded" + + +class VectorSpaceAdmissionError(ValueError): + def __init__(self, message: str): + self.description = message + super().__init__(message) + + +@dataclass(frozen=True) +class VectorStorageWorkload: + text_points: int + summary_points: int + probe_text: str | None + + @property + def total_points(self) -> int: + return self.text_points + self.summary_points + + +@dataclass(frozen=True) +class VectorSpaceAdmissionErrorDetails: + estimated_mb: int + plan_limit_mb: int + + +def estimate_tidb_storage_bytes(point_count: int, dimension: int) -> int: + """Estimate TiDB row and columnar storage for vector points.""" + return point_count * (dimension * _FLOAT32_BYTES * _TIDB_VECTOR_COPIES + _TIDB_POINT_OVERHEAD_BYTES) + + +def parse_vector_space_estimate_limits(value: str) -> dict[CloudPlan, int]: + limits: dict[CloudPlan, int] = {} + for item in value.split(","): + plan_name, separator, raw_limit = item.strip().partition(":") + if not separator: + raise ValueError(f"Invalid vector-space estimate limit: {item!r}") + try: + plan = CloudPlan(plan_name) + limit = int(raw_limit) + except (TypeError, ValueError) as error: + raise ValueError(f"Invalid vector-space estimate limit: {item!r}") from error + if limit <= 0 or plan in limits: + raise ValueError(f"Invalid vector-space estimate limit: {item!r}") + limits[plan] = limit + if set(limits) != set(CloudPlan): + raise ValueError(f"Invalid vector-space estimate limits: {value!r}; include sandbox, professional, and team") + return limits + + +def format_vector_space_admission_error(estimated_mb: int, plan_limit_mb: int) -> str: + return ( + f"Vector storage is estimated to reach {estimated_mb} MB after this upload, " + f"exceeding the {plan_limit_mb} MB limit of the current plan." + ) + + +def get_vector_space_admission_error_details(error: str | None) -> VectorSpaceAdmissionErrorDetails | None: + if not error or not (match := _ERROR_PATTERN.fullmatch(error)): + return None + return VectorSpaceAdmissionErrorDetails( + estimated_mb=int(match.group("estimated")), + plan_limit_mb=int(match.group("limit")), + ) + + +def get_vector_space_admission_error_fields(error: str | None) -> dict[str, str | int | None]: + details = get_vector_space_admission_error_details(error) + return { + "error_code": VECTOR_SPACE_ADMISSION_ERROR_CODE if details else None, + "estimated_vector_space_mb": details.estimated_mb if details else None, + "vector_space_limit_mb": details.plan_limit_mb if details else None, + } + + +def build_document_workload( + doc_form: str, + documents: list[Document], + *, + include_summaries: bool, +) -> VectorStorageWorkload: + # V1 estimates text vectors only; attachments are excluded. + texts: list[str] = [] + for document in documents: + if doc_form == IndexStructureType.PARENT_CHILD_INDEX: + texts.extend( + child.page_content + for child in document.children or [] + if child.page_content and child.page_content.strip() + ) + elif document.page_content and document.page_content.strip(): + texts.append(document.page_content) + + summary_points = 0 + if include_summaries and doc_form != IndexStructureType.QA_INDEX: + summary_points = sum(1 for document in documents if document.page_content and document.page_content.strip()) + + return VectorStorageWorkload( + text_points=len(texts), + summary_points=summary_points, + probe_text=texts[0] if texts else None, + ) + + +def build_pipeline_workload( + chunk_structure: str, + chunks: Any, + *, + include_summaries: bool, +) -> VectorStorageWorkload: + # V1 estimates chunk text only; file and image metadata are excluded. + texts: list[str] = [] + summary_points = 0 + + if chunk_structure == IndexStructureType.QA_INDEX: + for chunk in _items(chunks, "qa_chunks"): + question = _field(chunk, "question") + if isinstance(question, str) and question.strip(): + texts.append(question) + elif chunk_structure == IndexStructureType.PARENT_CHILD_INDEX: + for chunk in _items(chunks, "parent_child_chunks"): + parent_content = _field(chunk, "parent_content") + if include_summaries and isinstance(parent_content, str) and parent_content.strip(): + summary_points += 1 + for child in _field(chunk, "child_contents") or []: + if isinstance(child, str) and child.strip(): + texts.append(child) + else: + raw_chunks = chunks if isinstance(chunks, list) else _items(chunks, "general_chunks") + for chunk in raw_chunks: + content = chunk if isinstance(chunk, str) else _field(chunk, "content") + if isinstance(content, str) and content.strip(): + texts.append(content) + if include_summaries: + summary_points += 1 + + return VectorStorageWorkload( + text_points=len(texts), + summary_points=summary_points, + probe_text=texts[0] if texts else None, + ) + + +def _field(value: Any, name: str) -> Any: + if isinstance(value, Mapping): + return value.get(name) + return getattr(value, name, None) # guard-ignore: no-new-getattr -- supports validated chunk models + + +def _items(value: Any, name: str) -> list[Any]: + items = _field(value, name) + return list(items) if items else [] + + +class VectorSpaceAdmissionService: + """Cloud-only pre-write guard for unusually large TiDB vector workloads.""" + + def __init__(self) -> None: + self._dimension_by_dataset: dict[str, int] = {} + self._plan_by_tenant: dict[str, CloudPlan | None] = {} + + def ensure_document_can_be_indexed( + self, + *, + dataset: Dataset, + document_id: str, + doc_form: str, + documents: list[Document], + include_summaries: bool, + session: Session, + ) -> None: + self._ensure_can_write( + dataset=dataset, + document_id=document_id, + workload=build_document_workload( + doc_form, + documents, + include_summaries=include_summaries, + ), + session=session, + ) + + def ensure_pipeline_can_be_indexed( + self, + *, + dataset: Dataset, + document_id: str, + chunk_structure: str, + chunks: Any, + include_summaries: bool, + session: Session, + ) -> None: + self._ensure_can_write( + dataset=dataset, + document_id=document_id, + workload=build_pipeline_workload( + chunk_structure, + chunks, + include_summaries=include_summaries, + ), + session=session, + ) + + def _ensure_can_write( + self, + *, + dataset: Dataset, + document_id: str, + workload: VectorStorageWorkload, + session: Session, + ) -> None: + if ( + dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD + or not dify_config.BILLING_ENABLED + or dataset.indexing_technique != IndexTechniqueType.HIGH_QUALITY + or workload.total_points == 0 + or workload.probe_text is None + ): + return + if Vector.resolve_vector_type(dataset, session=session) != VectorType.TIDB_ON_QDRANT: + return + + plan = self._get_plan(dataset.tenant_id) + if plan is None: + return + estimate_limit_mb = parse_vector_space_estimate_limits( + dify_config.TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB + ).get(plan) + if estimate_limit_mb is None: + return + + current_usage_mb, plan_limit_mb = self._get_usage_and_limit_mb(dataset.tenant_id) + dimension = self._get_embedding_dimension(dataset, workload.probe_text) + estimate_bytes = math.ceil(estimate_tidb_storage_bytes(workload.total_points, dimension)) + document_estimated_mb = estimate_bytes / _MEBIBYTE + base_usage_bytes, projected_usage_bytes = self._reserve_projected_usage( + tenant_id=dataset.tenant_id, + document_id=document_id, + current_usage_bytes=math.ceil(current_usage_mb * _MEBIBYTE), + document_estimate_bytes=estimate_bytes, + estimate_limit_bytes=estimate_limit_mb * _MEBIBYTE, + ) + base_usage_mb = base_usage_bytes / _MEBIBYTE + projected_usage_mb = projected_usage_bytes / _MEBIBYTE + if projected_usage_bytes > estimate_limit_mb * _MEBIBYTE: + logger.warning( + "TiDB vector-space admission rejected tenant_id=%s document_id=%s plan=%s " + "points=%s dimension=%s current_usage_mb=%s document_estimated_mb=%s " + "watermark_base_usage_mb=%s projected_usage_mb=%s plan_limit_mb=%s estimate_limit_mb=%s", + dataset.tenant_id, + document_id, + plan, + workload.total_points, + dimension, + current_usage_mb, + document_estimated_mb, + base_usage_mb, + projected_usage_mb, + plan_limit_mb, + estimate_limit_mb, + ) + raise VectorSpaceAdmissionError( + format_vector_space_admission_error(math.ceil(projected_usage_mb), plan_limit_mb) + ) + + logger.info( + "TiDB vector-space admission allowed tenant_id=%s document_id=%s plan=%s " + "points=%s dimension=%s current_usage_mb=%s document_estimated_mb=%s " + "watermark_base_usage_mb=%s projected_usage_mb=%s estimate_limit_mb=%s", + dataset.tenant_id, + document_id, + plan, + workload.total_points, + dimension, + current_usage_mb, + document_estimated_mb, + base_usage_mb, + projected_usage_mb, + estimate_limit_mb, + ) + + def _get_usage_and_limit_mb(self, tenant_id: str) -> tuple[float, int]: + try: + vector_space = BillingService.get_vector_space(tenant_id) + current_usage_mb = float(vector_space["size"]) + plan_limit_mb = int(vector_space["limit"]) + except Exception as error: + raise VectorSpaceAdmissionError( + "Unable to verify vector storage usage right now. Please try again later." + ) from error + return current_usage_mb, plan_limit_mb + + def _reserve_projected_usage( + self, + *, + tenant_id: str, + document_id: str, + current_usage_bytes: int, + document_estimate_bytes: int, + estimate_limit_bytes: int, + ) -> tuple[int, int]: + watermark_key = f"tenant:{tenant_id}:vector_space_estimate_watermark" + lock_key = f"{watermark_key}:lock" + + try: + with redis_client.lock( + lock_key, + timeout=_WATERMARK_LOCK_TIMEOUT_SECONDS, + blocking_timeout=_WATERMARK_LOCK_TIMEOUT_SECONDS, + ): + raw_state = redis_client.get(watermark_key) + stored_usage_bytes = 0 + document_ids: set[str] = set() + if raw_state: + state = json.loads(raw_state) + stored_usage_bytes = state.get("projected_usage_bytes") + raw_document_ids = state.get("document_ids") + if ( + type(stored_usage_bytes) is not int + or stored_usage_bytes < 0 + or not isinstance(raw_document_ids, list) + or not all(isinstance(item, str) for item in raw_document_ids) + ): + raise ValueError("Invalid vector-space estimate watermark") + document_ids = set(raw_document_ids) + + base_usage_bytes = max(current_usage_bytes, stored_usage_bytes) + projected_usage_bytes = base_usage_bytes + if document_id not in document_ids: + projected_usage_bytes += document_estimate_bytes + + if projected_usage_bytes <= estimate_limit_bytes: + document_ids.add(document_id) + redis_client.setex( + watermark_key, + _WATERMARK_TTL_SECONDS, + json.dumps( + { + "projected_usage_bytes": projected_usage_bytes, + "document_ids": sorted(document_ids), + }, + separators=(",", ":"), + ), + ) + + return base_usage_bytes, projected_usage_bytes + except Exception as error: + raise VectorSpaceAdmissionError( + "Unable to reserve estimated vector storage right now. Please try again later." + ) from error + + def _get_plan(self, tenant_id: str) -> CloudPlan | None: + if tenant_id in self._plan_by_tenant: + return self._plan_by_tenant[tenant_id] + try: + billing_info = BillingService.get_info(tenant_id, exclude_vector_space=True) + except Exception as error: + raise VectorSpaceAdmissionError( + "Unable to verify the subscription plan right now. Please try again later." + ) from error + + plan = None + if billing_info["enabled"]: + try: + plan = CloudPlan(billing_info["subscription"]["plan"]) + except ValueError: + logger.warning( + "Skipping TiDB vector-space admission for unknown plan tenant_id=%s plan=%s", + tenant_id, + billing_info["subscription"]["plan"], + ) + self._plan_by_tenant[tenant_id] = plan + return plan + + def _get_embedding_dimension(self, dataset: Dataset, probe_text: str) -> int: + cached_dimension = self._dimension_by_dataset.get(dataset.id) + if cached_dimension is not None: + return cached_dimension + + model_manager = ModelManager.for_tenant(tenant_id=dataset.tenant_id) + if dataset.embedding_model_provider: + model_instance = model_manager.get_model_instance( + tenant_id=dataset.tenant_id, + provider=dataset.embedding_model_provider, + model_type=ModelType.TEXT_EMBEDDING, + model=dataset.embedding_model, + ) + else: + model_instance = model_manager.get_default_model_instance( + tenant_id=dataset.tenant_id, + model_type=ModelType.TEXT_EMBEDDING, + ) + + embeddings = CacheEmbedding(model_instance).embed_documents([probe_text]) + if not embeddings or not embeddings[0]: + raise VectorSpaceAdmissionError( + "Unable to estimate vector storage for this document. Please try again later." + ) + + dimension = len(embeddings[0]) + self._dimension_by_dataset[dataset.id] = dimension + return dimension diff --git a/api/tasks/document_indexing_task.py b/api/tasks/document_indexing_task.py index 5d8e6dd701c..e319225079d 100644 --- a/api/tasks/document_indexing_task.py +++ b/api/tasks/document_indexing_task.py @@ -107,7 +107,7 @@ def _document_indexing(dataset_id: str, document_ids: Sequence[str]): # Phase 2: Execute indexing without holding locks from the parsing-status update. has_error = False try: - indexing_runner = IndexingRunner() + indexing_runner = IndexingRunner(enforce_vector_space_admission=True) with session_factory.create_session() as session: dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: diff --git a/api/tasks/retry_document_indexing_task.py b/api/tasks/retry_document_indexing_task.py index f8430cc206a..dcf74737544 100644 --- a/api/tasks/retry_document_indexing_task.py +++ b/api/tasks/retry_document_indexing_task.py @@ -113,7 +113,7 @@ def retry_document_indexing_task(dataset_id: str, document_ids: list[str], user_ rag_pipeline_service = RagPipelineService(rag_session) rag_pipeline_service.retry_error_document(dataset, document, user) else: - indexing_runner = IndexingRunner() + indexing_runner = IndexingRunner(enforce_vector_space_admission=True) indexing_runner.run([document], session) session.commit() redis_client.delete(retry_indexing_cache_key) diff --git a/api/tests/integration_tests/.env.example b/api/tests/integration_tests/.env.example index 986ced5f85d..98186d59e83 100644 --- a/api/tests/integration_tests/.env.example +++ b/api/tests/integration_tests/.env.example @@ -95,6 +95,7 @@ HOLOGRES_EF_CONSTRUCTION=400 # Upload configuration UPLOAD_FILE_SIZE_LIMIT=15 +KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN=15 UPLOAD_FILE_BATCH_LIMIT=5 UPLOAD_IMAGE_FILE_SIZE_LIMIT=10 UPLOAD_VIDEO_FILE_SIZE_LIMIT=100 diff --git a/api/tests/test_containers_integration_tests/controllers/console/test_files.py b/api/tests/test_containers_integration_tests/controllers/console/test_files.py index 5e51b2ced98..e8f1f3ec778 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/test_files.py +++ b/api/tests/test_containers_integration_tests/controllers/console/test_files.py @@ -32,6 +32,7 @@ def test_file_upload_config_returns_console_limits( assert response.status_code == 200 assert response.json == { "file_size_limit": dify_config.UPLOAD_FILE_SIZE_LIMIT, + "knowledge_file_size_limit": dify_config.UPLOAD_FILE_SIZE_LIMIT, "batch_count_limit": dify_config.UPLOAD_FILE_BATCH_LIMIT, "file_upload_limit": dify_config.BATCH_UPLOAD_LIMIT, "image_file_size_limit": dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT, diff --git a/api/tests/unit_tests/configs/test_file_upload_config.py b/api/tests/unit_tests/configs/test_file_upload_config.py new file mode 100644 index 00000000000..666ff3f666e --- /dev/null +++ b/api/tests/unit_tests/configs/test_file_upload_config.py @@ -0,0 +1,23 @@ +import pytest + +from configs.feature import FileUploadConfig + + +def test_paid_plan_file_size_limit_uses_its_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UPLOAD_FILE_SIZE_LIMIT", "23") + monkeypatch.delenv("KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN", raising=False) + + config = FileUploadConfig() + + assert config.UPLOAD_FILE_SIZE_LIMIT == 23 + assert config.KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN == 15 + + +def test_paid_plan_file_size_limit_can_be_configured_separately(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UPLOAD_FILE_SIZE_LIMIT", "23") + monkeypatch.setenv("KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN", "50") + + config = FileUploadConfig() + + assert config.UPLOAD_FILE_SIZE_LIMIT == 23 + assert config.KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN == 50 diff --git a/api/tests/unit_tests/configs/test_tidb_on_qdrant_config.py b/api/tests/unit_tests/configs/test_tidb_on_qdrant_config.py new file mode 100644 index 00000000000..5e7ff57dde3 --- /dev/null +++ b/api/tests/unit_tests/configs/test_tidb_on_qdrant_config.py @@ -0,0 +1,19 @@ +import pytest + +from configs.middleware.vdb.tidb_on_qdrant_config import TidbOnQdrantConfig + + +def test_estimated_storage_limits_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB", raising=False) + + config = TidbOnQdrantConfig() + + assert config.TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB == "sandbox:60,professional:6400,team:25600" + + +def test_estimated_storage_limits_custom(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB", "sandbox:61,professional:6500,team:26000") + + config = TidbOnQdrantConfig() + + assert config.TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB == "sandbox:61,professional:6500,team:26000" diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py index 705b747a5aa..188f32f76f0 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py @@ -41,6 +41,10 @@ from core.rag.index_processor.constant.index_type import IndexStructureType from models.dataset import Dataset from models.dataset import Document as DatasetDocument from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus +from services.vector_space_admission_service import ( + VECTOR_SPACE_ADMISSION_ERROR_CODE, + format_vector_space_admission_error, +) def make_serializable_document(**overrides): @@ -1115,9 +1119,10 @@ class TestDocumentBatchIndexingStatusApi: api = DocumentBatchIndexingStatusApi() method = unwrap(api.get) user, _ = patch_tenant + error = format_vector_space_admission_error(61, 50) document = MagicMock( id="doc-1", - indexing_status=IndexingStatus.COMPLETED, + indexing_status=IndexingStatus.ERROR, is_paused=False, processing_started_at=None, parsing_completed_at=None, @@ -1125,7 +1130,7 @@ class TestDocumentBatchIndexingStatusApi: splitting_completed_at=None, completed_at=None, paused_at=None, - error=None, + error=error, stopped_at=None, ) session = MagicMock() @@ -1136,14 +1141,17 @@ class TestDocumentBatchIndexingStatusApi: "data": [ { "id": "doc-1", - "indexing_status": "completed", + "indexing_status": "error", "processing_started_at": None, "parsing_completed_at": None, "cleaning_completed_at": None, "splitting_completed_at": None, "completed_at": None, "paused_at": None, - "error": None, + "error": error, + "error_code": VECTOR_SPACE_ADMISSION_ERROR_CODE, + "estimated_vector_space_mb": 61, + "vector_space_limit_mb": 50, "stopped_at": None, "completed_segments": 2, "total_segments": 3, diff --git a/api/tests/unit_tests/controllers/console/test_feature.py b/api/tests/unit_tests/controllers/console/test_feature.py index 19e30a1b08d..952f2610161 100644 --- a/api/tests/unit_tests/controllers/console/test_feature.py +++ b/api/tests/unit_tests/controllers/console/test_feature.py @@ -10,6 +10,7 @@ from services.feature_service import ( LicenseStatus, LimitationModel, SystemFeatureModel, + VectorSpaceLimitationModel, ) @@ -40,7 +41,7 @@ class TestFeatureVectorSpaceApi: from controllers.console.feature import FeatureVectorSpaceApi get_vector_space = mocker.patch("controllers.console.feature.FeatureService.get_vector_space") - get_vector_space.return_value = LimitationModel(size=5120, limit=20480) + get_vector_space.return_value = VectorSpaceLimitationModel(size=5120, limit=20480) api = FeatureVectorSpaceApi() @@ -50,6 +51,24 @@ class TestFeatureVectorSpaceApi: assert result == {"size": 5120, "limit": 20480} get_vector_space.assert_called_once_with("tenant_123") + def test_get_vector_space_preserves_unknown_usage(self, mocker: MockerFixture): + from controllers.console.feature import FeatureVectorSpaceApi + + get_vector_space = mocker.patch("controllers.console.feature.FeatureService.get_vector_space") + get_vector_space.return_value = VectorSpaceLimitationModel(size=0, limit=50, usage_unknown=True) + + result = unwrap(FeatureVectorSpaceApi.get)(FeatureVectorSpaceApi(), "tenant_123") + + assert result == {"size": 0, "limit": 50, "usage_unknown": True} + get_vector_space.assert_called_once_with("tenant_123") + + def test_vector_space_response_schema_marks_usage_unknown_optional(self): + schema = VectorSpaceLimitationModel.model_json_schema(mode="serialization") + + assert schema["required"] == ["size", "limit"] + assert schema["properties"]["usage_unknown"]["type"] == "boolean" + assert "usage_unknown" not in schema["required"] + class TestTrialModelsApi: def test_get_trial_models_success(self, mocker: MockerFixture): diff --git a/api/tests/unit_tests/controllers/console/test_files.py b/api/tests/unit_tests/controllers/console/test_files.py index cc407cd51b8..f894e04f481 100644 --- a/api/tests/unit_tests/controllers/console/test_files.py +++ b/api/tests/unit_tests/controllers/console/test_files.py @@ -87,12 +87,20 @@ class TestFileApiGet: api = FileApi() get_method = unwrap(api.get) - with app.test_request_context(): - data, status = get_method(api) + with ( + app.test_request_context(), + patch( + "controllers.console.files.FeatureService.get_knowledge_file_size_limit", + return_value=50, + ) as get_knowledge_file_size_limit, + ): + data, status = get_method(api, "tenant-1") assert status == 200 assert "file_size_limit" in data + assert data["knowledge_file_size_limit"] == 50 assert "batch_count_limit" in data + get_knowledge_file_size_limit.assert_called_once_with("tenant-1") assert data["skill_file_size_limit"] == dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT @@ -200,6 +208,33 @@ class TestFileApiPost: assert result is upload_file assert mock_file_service.upload_file.call_args.kwargs["tenant_id"] == "app-tenant-id" + def test_dataset_source_from_query_uses_knowledge_limit( + self, + app: Flask, + mock_account_context, + mock_file_service, + ): + upload_file = MagicMock() + mock_file_service.upload_file.return_value = upload_file + + with ( + app.test_request_context( + "/?source=datasets", + method="POST", + data={"file": (io.BytesIO(b"hello"), "test.txt")}, + ), + patch( + "controllers.console.files.FeatureService.get_knowledge_file_size_limit", + return_value=50, + ) as get_knowledge_file_size_limit, + ): + result = upload_file_from_request(current_user=mock_account_context) + + assert result is upload_file + assert mock_file_service.upload_file.call_args.kwargs["source"] == "datasets" + assert mock_file_service.upload_file.call_args.kwargs["default_file_size_limit"] == 50 + get_knowledge_file_size_limit.assert_called_once_with(mock_account_context.current_tenant_id) + def test_upload_with_invalid_source(self, app: Flask, mock_account_context, mock_file_service): """Test that invalid source parameter gets normalized to None""" api = FileApi() diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index 03d78985ac1..046bb1a432e 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -735,6 +735,17 @@ class TestBillingResourceLimits: result = upload_document() assert result == "document_uploaded" + # Test 3: Form source must enforce the same quota as query source + with app.test_request_context("/", method="POST", data={"source": "datasets"}): + with patch( + "controllers.console.wraps.current_account_with_tenant", + return_value=(MockUser("test_user"), "tenant123"), + ): + with patch("controllers.console.wraps.FeatureService.get_features", return_value=mock_features): + with pytest.raises(HTTPException) as exc_info: + upload_document() + assert exc_info.value.code == 403 + class TestRateLimiting: """Test rate limiting decorator""" diff --git a/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py index fe7d035df5f..a55e3b8cbaa 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py @@ -28,7 +28,14 @@ from sqlalchemy.orm import Session from werkzeug.datastructures import FileStorage from werkzeug.exceptions import Forbidden, NotFound -from controllers.common.errors import FilenameNotExistsError, NoFileUploadedError, TooManyFilesError +from controllers.common.errors import ( + FilenameNotExistsError, + NoFileUploadedError, + TooManyFilesError, +) +from controllers.common.errors import ( + FileTooLargeError as FileTooLargeHTTPError, +) from controllers.service_api.dataset.error import PipelineRunError from controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow import ( DatasourceNodeRunApi, @@ -40,7 +47,8 @@ from controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow import ( from core.app.entities.app_invoke_entities import InvokeFrom from models.account import Account from models.dataset import Dataset -from services.errors.file import FileTooLargeError, UnsupportedFileTypeError +from services.errors.file import FileTooLargeError as FileTooLargeServiceError +from services.errors.file import UnsupportedFileTypeError from services.rag_pipeline.entity.pipeline_service_api_entities import ( DatasourceNodeRunApiEntity, PipelineRunApiEntity, @@ -143,7 +151,7 @@ class TestFileUploadErrors: def test_file_too_large_error(self): """Test FileTooLargeError can be raised.""" - error = FileTooLargeError("File exceeds size limit") + error = FileTooLargeServiceError("File exceeds size limit") assert error is not None def test_unsupported_file_type_error(self): @@ -684,6 +692,38 @@ class TestFileUploadApiPost: assert response["name"] == "doc.pdf" assert response["extension"] == "pdf" + @patch( + "controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.FeatureService" + ".get_knowledge_file_size_limit", + return_value=15, + ) + @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.FileService") + @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.current_user") + @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.db") + def test_upload_file_too_large_returns_http_413( + self, mock_db, mock_current_user, mock_file_svc_cls, mock_get_limit, app: Flask + ): + mock_current_user.__bool__ = Mock(return_value=True) + mock_file_svc_cls.return_value.upload_file.side_effect = FileTooLargeServiceError() + file_data = FileStorage( + stream=io.BytesIO(b"oversized content"), + filename="doc.pdf", + content_type="application/pdf", + ) + + with app.test_request_context( + "/datasets/pipeline/file-upload", + method="POST", + content_type="multipart/form-data", + data={"file": file_data}, + ): + with pytest.raises(FileTooLargeHTTPError) as exc_info: + KnowledgebasePipelineFileUploadApi().post(tenant_id="tenant-1") + + assert exc_info.value.code == 413 + assert exc_info.value.error_code == "file_too_large" + mock_get_limit.assert_called_once_with("tenant-1") + def test_upload_no_file(self, app: Flask): """Test error when no file is uploaded.""" with app.test_request_context( diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py index 9713caabeb5..c73ef67af85 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py @@ -26,6 +26,7 @@ import pytest from flask import Flask from werkzeug.exceptions import Forbidden, NotFound +from controllers.common.errors import FileTooLargeError as FileTooLargeHTTPError from controllers.service_api.dataset.document import ( DeprecatedDocumentAddByTextApi, DeprecatedDocumentUpdateByFileApi, @@ -47,6 +48,7 @@ from models.dataset import Dataset, Document from models.enums import DataSourceType, DocumentCreatedFrom, DocumentDocType, IndexingStatus from services.dataset_service import DocumentService from services.entities.knowledge_entities.knowledge_entities import ProcessRule, RetrievalModel +from services.errors.file import FileTooLargeError as FileTooLargeServiceError def _document_data_source_info() -> dict[str, str]: @@ -1155,6 +1157,9 @@ class TestDocumentIndexingStatusApi: "completed_at": 1609459204, "paused_at": None, "error": None, + "error_code": None, + "estimated_vector_space_mb": None, + "vector_space_limit_mb": None, "stopped_at": None, "completed_segments": 5, "total_segments": 5, @@ -1593,6 +1598,52 @@ class TestDocumentAddByFileApiPost: 200, ) + @patch( + "controllers.service_api.dataset.document.FeatureService.get_knowledge_file_size_limit", + return_value=15, + ) + @patch("controllers.service_api.dataset.document.FileService") + @patch("controllers.service_api.dataset.document.current_user") + @patch("controllers.service_api.dataset.document.db") + def test_add_by_file_too_large_returns_http_413( + self, + mock_db, + mock_current_user, + mock_file_svc_cls, + mock_get_limit, + app: Flask, + mock_tenant, + mock_dataset, + ): + mock_dataset.provider = "vendor" + mock_dataset.indexing_technique = "economy" + mock_dataset.chunk_structure = None + mock_db.session.scalar.return_value = mock_dataset + mock_current_user.__bool__ = Mock(return_value=True) + mock_file_svc_cls.return_value.upload_file.side_effect = FileTooLargeServiceError() + + from io import BytesIO + + data = { + "file": (BytesIO(b"oversized content"), "test.pdf", "application/pdf"), + "data": json.dumps({"process_rule": {"mode": "automatic", "rules": None}}), + } + with app.test_request_context( + f"/datasets/{mock_dataset.id}/document/create-by-file", + method="POST", + content_type="multipart/form-data", + data=data, + ): + api = DocumentAddByFileApi() + with pytest.raises(FileTooLargeHTTPError) as exc_info: + _unwrap_non_wrapped_controller(type(api).post)( + api, mock_db.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id + ) + + assert exc_info.value.code == 413 + assert exc_info.value.error_code == "file_too_large" + mock_get_limit.assert_called_once_with(mock_tenant) + @patch("controllers.service_api.dataset.document.db") @patch("controllers.service_api.wraps.FeatureService") @patch("controllers.service_api.wraps.validate_and_get_api_token") diff --git a/api/tests/unit_tests/controllers/service_api/test_wraps.py b/api/tests/unit_tests/controllers/service_api/test_wraps.py index f5b8c15d6d1..c6809375b4b 100644 --- a/api/tests/unit_tests/controllers/service_api/test_wraps.py +++ b/api/tests/unit_tests/controllers/service_api/test_wraps.py @@ -10,7 +10,7 @@ import pytest from flask import Flask from sqlalchemy import select from sqlalchemy.orm import Session -from werkzeug.exceptions import Forbidden, NotFound, Unauthorized +from werkzeug.exceptions import Forbidden, NotFound, ServiceUnavailable, Unauthorized from controllers.service_api.wraps import ( DatasetApiResource, @@ -338,6 +338,7 @@ class TestCloudEditionBillingResourceCheck: mock_vector_space = Mock() mock_vector_space.limit = 10 mock_vector_space.size = 5 + mock_vector_space.usage_unknown = False mock_get_vector_space.return_value = mock_vector_space @cloud_edition_billing_resource_check("vector_space", "dataset") @@ -356,6 +357,64 @@ class TestCloudEditionBillingResourceCheck: mock_get_vector_space.assert_called_once_with("tenant123") mock_get_features.assert_not_called() + @patch("controllers.service_api.wraps.validate_and_get_api_token") + @patch("controllers.service_api.wraps.FeatureService.get_features") + @patch("controllers.service_api.wraps.FeatureService.get_vector_space") + def test_rejects_sandbox_when_vector_space_usage_is_unknown( + self, mock_get_vector_space, mock_get_features, mock_validate_token, app: Flask + ): + mock_validate_token.return_value = Mock(tenant_id="tenant123") + mock_get_vector_space.return_value = Mock(size=0, limit=50, usage_unknown=True) + mock_get_features.return_value = SimpleNamespace( + billing=SimpleNamespace( + enabled=True, + subscription=SimpleNamespace(plan=CloudPlan.SANDBOX), + ) + ) + + @cloud_edition_billing_resource_check("vector_space", "dataset") + def upload_document(): + return "document_uploaded" + + with ( + app.test_request_context("/", method="GET"), + patch("controllers.service_api.wraps.dify_config.BILLING_ENABLED", True), + pytest.raises(ServiceUnavailable) as exc_info, + ): + upload_document() + + assert "Please try again later" in str(exc_info.value) + mock_get_features.assert_called_once_with("tenant123", exclude_vector_space=True) + + @patch("controllers.service_api.wraps.validate_and_get_api_token") + @patch("controllers.service_api.wraps.FeatureService.get_features") + @patch("controllers.service_api.wraps.FeatureService.get_vector_space") + @pytest.mark.parametrize("plan", [CloudPlan.PROFESSIONAL, CloudPlan.TEAM]) + def test_allows_paid_plan_when_vector_space_usage_is_unknown( + self, mock_get_vector_space, mock_get_features, mock_validate_token, app: Flask, plan: CloudPlan + ): + mock_validate_token.return_value = Mock(tenant_id="tenant123") + mock_get_vector_space.return_value = Mock(size=0, limit=50, usage_unknown=True) + mock_get_features.return_value = SimpleNamespace( + billing=SimpleNamespace( + enabled=True, + subscription=SimpleNamespace(plan=plan), + ) + ) + + @cloud_edition_billing_resource_check("vector_space", "dataset") + def upload_document(): + return "document_uploaded" + + with ( + app.test_request_context("/", method="GET"), + patch("controllers.service_api.wraps.dify_config.BILLING_ENABLED", True), + ): + result = upload_document() + + assert result == "document_uploaded" + mock_get_features.assert_called_once_with("tenant123", exclude_vector_space=True) + @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.FeatureService.get_features") def test_loads_features_when_checking_non_vector_space_limit( diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py index 9000cc94ed4..07a47eae3aa 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py @@ -179,7 +179,7 @@ def test_generate_published_pipeline_creates_documents_and_delay(generator, mock mocker.patch("services.dataset_service.DocumentService.get_documents_position", return_value=1) features = SimpleNamespace() - mocker.patch("services.feature_service.FeatureService.get_features", return_value=features) + get_features = mocker.patch("services.feature_service.FeatureService.get_features", return_value=features) check_limits = mocker.patch("services.dataset_service.DocumentService.check_document_creation_limits") document1 = SimpleNamespace( @@ -236,6 +236,7 @@ def test_generate_published_pipeline_creates_documents_and_delay(generator, mock session.flush.assert_called_once_with() session.commit.assert_called_once_with() task_proxy.delay.assert_called_once() + get_features.assert_called_once_with("tenant") def test_generate_published_pipeline_rejects_when_document_creation_limits_exceeded(generator, mocker: MockerFixture): @@ -309,20 +310,26 @@ def test_generate_is_retry_calls_generate(generator, mocker: MockerFixture): return_value=MagicMock(), ) - mocker.patch.object(generator, "_generate", return_value={"result": "ok"}) + generate = mocker.patch.object(generator, "_generate", return_value={"result": "ok"}) + + args = _build_args() + args["original_document_id"] = "document-1" result = generator.generate( session=session, pipeline=pipeline, workflow=workflow, user=_build_user(), - args=_build_args(), + args=args, invoke_from=InvokeFrom.PUBLISHED_PIPELINE, streaming=True, is_retry=True, ) assert result == {"result": "ok"} + application_generate_entity = generate.call_args.kwargs["application_generate_entity"] + assert application_generate_entity.document_id == "document-1" + assert application_generate_entity.original_document_id is None def test_generate_worker_handles_errors(generator, mocker: MockerFixture): diff --git a/api/tests/unit_tests/core/rag/indexing/test_index_processor.py b/api/tests/unit_tests/core/rag/indexing/test_index_processor.py index ed943fe1129..11137e74380 100644 --- a/api/tests/unit_tests/core/rag/indexing/test_index_processor.py +++ b/api/tests/unit_tests/core/rag/indexing/test_index_processor.py @@ -47,19 +47,77 @@ class TestIndexProcessor: index_processor = MagicMock() index_processor.index.side_effect = lambda *args: phase_events.append("index") + processor = IndexProcessor() + admission_service = MagicMock() + chunks = {"general_chunks": ["content"]} - with patch("core.rag.index_processor.index_processor.IndexProcessorFactory") as index_processor_factory: + with ( + patch( + "core.rag.index_processor.index_processor.VectorSpaceAdmissionService", + return_value=admission_service, + ), + patch("core.rag.index_processor.index_processor.IndexProcessorFactory") as index_processor_factory, + ): index_processor_factory.return_value.init_index_processor.return_value = index_processor - IndexProcessor().index_and_clean( + processor.index_and_clean( dataset_id=dataset.id, document_id=document.id, original_document_id="", - chunks={"general_chunks": ["content"]}, + chunks=chunks, batch="batch-1", session=session, ) assert phase_events == ["commit", "index", "commit"] + admission_service.ensure_pipeline_can_be_indexed.assert_called_once_with( + dataset=dataset, + document_id=document.id, + chunk_structure=dataset.chunk_structure, + chunks=chunks, + include_summaries=False, + session=session, + ) + + def test_index_and_clean_skips_admission_for_replacement_without_existing_vector_points(self) -> None: + document = SimpleNamespace( + id="document-1", + name="Document", + created_at=datetime.datetime(2026, 1, 1), + indexing_latency=None, + indexing_status=None, + completed_at=None, + word_count=0, + need_summary=False, + ) + dataset = SimpleNamespace( + id="dataset-1", + tenant_id="tenant-1", + name="Dataset", + chunk_structure="text_model", + summary_index_setting=None, + ) + session = MagicMock() + session.scalar.side_effect = [dataset, document, 3] + session.scalars.return_value.all.return_value = [] + index_processor = MagicMock() + processor = IndexProcessor() + chunks = {"general_chunks": ["content"]} + + with ( + patch("core.rag.index_processor.index_processor.VectorSpaceAdmissionService") as admission_service_class, + patch("core.rag.index_processor.index_processor.IndexProcessorFactory") as index_processor_factory, + ): + index_processor_factory.return_value.init_index_processor.return_value = index_processor + processor.index_and_clean( + dataset_id=dataset.id, + document_id=document.id, + original_document_id=document.id, + chunks=chunks, + batch="batch-1", + session=session, + ) + + admission_service_class.assert_not_called() def test_index_and_clean_scopes_replacement_queries_to_dataset_owner(self) -> None: dataset = SimpleNamespace( @@ -90,9 +148,13 @@ class TestIndexProcessor: session.scalar.side_effect = resolve_owner session.scalars.return_value.all.return_value = [segment] - with patch("core.rag.index_processor.index_processor.IndexProcessorFactory") as index_processor_factory: + processor = IndexProcessor() + with ( + patch("core.rag.index_processor.index_processor.VectorSpaceAdmissionService") as admission_service_class, + patch("core.rag.index_processor.index_processor.IndexProcessorFactory") as index_processor_factory, + ): index_backend = index_processor_factory.return_value.init_index_processor.return_value - IndexProcessor().index_and_clean( + processor.index_and_clean( dataset_id="dataset-1", document_id="doc-1", original_document_id="original-doc", @@ -126,6 +188,7 @@ class TestIndexProcessor: session=session, ) index_backend.index.assert_called_once_with(dataset, document, {}, session) + admission_service_class.assert_not_called() def test_get_preview_output_scopes_document_to_dataset_owner(self) -> None: dataset = SimpleNamespace( diff --git a/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py b/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py index c129e51c5ae..919c98662cb 100644 --- a/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py +++ b/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py @@ -71,6 +71,7 @@ from models.dataset import Dataset, DatasetProcessRule, DocumentSegment from models.dataset import Document as DatasetDocument from models.enums import SegmentStatus from models.model import Account +from services.vector_space_admission_service import VectorSpaceAdmissionError # ============================================================================ # Helper Functions @@ -1084,6 +1085,65 @@ class TestIndexingRunnerRun: session=mock_dependencies["session"], ) + @patch.object(Account, "set_tenant_id_with_session", autospec=True) + def test_run_rejects_before_segment_or_vector_writes( + self, set_tenant_id, mock_dependencies, sample_dataset_documents + ): + runner = IndexingRunner(enforce_vector_space_admission=True) + dataset_document = sample_dataset_documents[0] + dataset_document.need_summary = False + dataset = Dataset( + id=dataset_document.dataset_id, + tenant_id=dataset_document.tenant_id, + indexing_technique=IndexTechniqueType.HIGH_QUALITY, + ) + current_user = Account(name="Test Account", email="test@example.com") + model_dispatch = { + DatasetDocument: dataset_document, + Dataset: dataset, + Account: current_user, + } + mock_dependencies["session"].get.side_effect = lambda model, _: model_dispatch.get(model) + process_rule = DatasetProcessRule( + dataset_id="dataset-id", mode="automatic", rules="{}", created_by="account-id" + ) + mock_dependencies["session"].scalar.return_value = process_rule + transformed_documents = [Document(page_content="Chunk", metadata={"doc_id": "c1", "doc_hash": "h1"})] + admission_error = VectorSpaceAdmissionError("estimated storage exceeds capacity") + admission_service = Mock() + admission_service.ensure_document_can_be_indexed.side_effect = admission_error + + with ( + patch("core.indexing_runner.VectorSpaceAdmissionService", return_value=admission_service), + patch.object(runner, "_extract", return_value=[Document(page_content="source", metadata={})]), + patch.object( + runner, + "_transform", + return_value=transformed_documents, + ), + patch.object(runner, "_load_segments") as load_segments, + patch.object(runner, "_load") as load, + patch.object(runner, "_handle_indexing_error") as handle_error, + ): + runner.run([dataset_document], mock_dependencies["session"]) + + load_segments.assert_not_called() + load.assert_not_called() + admission_service.ensure_document_can_be_indexed.assert_called_once_with( + dataset=dataset, + document_id=dataset_document.id, + doc_form=dataset_document.doc_form, + documents=transformed_documents, + include_summaries=False, + session=mock_dependencies["session"], + ) + handle_error.assert_called_once_with(dataset_document.id, admission_error, mock_dependencies["session"]) + set_tenant_id.assert_called_once_with( + current_user, + dataset.tenant_id, + session=mock_dependencies["session"], + ) + @patch.object(Account, "set_tenant_id_with_session", autospec=True) def test_run_in_splitting_status_counts_each_transformed_document_once( self, set_tenant_id, mock_dependencies, sample_dataset_documents diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py index cefcf04ae02..2cba38dd96e 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py @@ -679,7 +679,13 @@ class TestInvokeKnowledgeIndex: dataset_id, document_id, False, summary_setting ) mock_index_processor.index_and_clean.assert_called_once_with( - dataset_id, document_id, original_document_id, chunks, batch, summary_setting, session=session + dataset_id, + document_id, + original_document_id, + chunks, + batch, + summary_setting, + session=session, ) session.commit.assert_called_once() assert result == {"status": "indexed"} diff --git a/api/tests/unit_tests/fields/test_file_fields.py b/api/tests/unit_tests/fields/test_file_fields.py index 28f3e2f6f9d..c35ca5f7b1d 100644 --- a/api/tests/unit_tests/fields/test_file_fields.py +++ b/api/tests/unit_tests/fields/test_file_fields.py @@ -67,6 +67,7 @@ def test_remote_file_info_and_upload_config() -> None: config = UploadConfig( file_size_limit=1, + knowledge_file_size_limit=11, batch_count_limit=2, file_upload_limit=3, image_file_size_limit=4, @@ -81,6 +82,7 @@ def test_remote_file_info_and_upload_config() -> None: dumped = config.model_dump(mode="json") assert dumped["file_upload_limit"] == 3 + assert dumped["knowledge_file_size_limit"] == 11 assert dumped["skill_file_size_limit"] == 7 assert dumped["attachment_image_file_size_limit"] == 11 diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index 349e81ba1f1..d3b01e71a04 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -462,6 +462,37 @@ class TestBillingServiceSubscriptionInfo: params={"tenant_id": tenant_id}, ) + def test_get_vector_space_preserves_unknown_usage(self, mock_send_request): + tenant_id = "tenant-123" + expected_response = {"size": 0.0, "limit": 50, "usage_unknown": True} + mock_send_request.return_value = expected_response + + result = BillingService.get_vector_space(tenant_id) + + assert result == expected_response + + def test_get_info_preserves_unknown_vector_space_usage(self, mock_send_request): + tenant_id = "tenant-123" + expected_response = { + "enabled": True, + "subscription": {"plan": "sandbox", "interval": "", "education": False}, + "members": {"size": 1, "limit": 1}, + "apps": {"size": 1, "limit": 10}, + "vector_space": {"size": 0.0, "limit": 50, "usage_unknown": True}, + "knowledge_rate_limit": {"limit": 10}, + "documents_upload_quota": {"size": 1, "limit": 50}, + "annotation_quota_limit": {"size": 0, "limit": 10}, + "docs_processing": "standard", + "can_replace_logo": False, + "model_load_balancing_enabled": False, + "knowledge_pipeline_publish_enabled": False, + } + mock_send_request.return_value = expected_response + + result = BillingService.get_info(tenant_id) + + assert result["vector_space"]["usage_unknown"] is True + def test_get_vector_space_bypasses_cache(self, mock_send_request): tenant_id = "tenant-123" mock_send_request.return_value = {"size": 4096, "limit": 20480} @@ -1989,6 +2020,8 @@ class TestBillingServiceSubscriptionInfoDataType: if "vector_space" in result: assert isinstance(result["vector_space"]["size"], float) assert isinstance(result["vector_space"]["limit"], int) + if "usage_unknown" in result["vector_space"]: + assert isinstance(result["vector_space"]["usage_unknown"], bool) assert isinstance(result["knowledge_rate_limit"]["limit"], int) diff --git a/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py b/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py index 8614d351f19..1b2f3e3e9f2 100644 --- a/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py +++ b/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py @@ -116,3 +116,19 @@ def test_get_vector_space_converts_billing_float_size(monkeypatch: pytest.Monkey assert result.size == 5120 assert result.limit == 20480 + assert result.usage_unknown is False + + +def test_get_vector_space_preserves_unknown_usage(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(feature_service_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr( + feature_service_module.BillingService, + "get_vector_space", + lambda tenant_id: {"size": 0.0, "limit": 50, "usage_unknown": True}, + ) + + result = FeatureService.get_vector_space("tenant-1") + + assert result.size == 0 + assert result.limit == 50 + assert result.usage_unknown is True diff --git a/api/tests/unit_tests/services/test_feature_service_knowledge_file_size_limit.py b/api/tests/unit_tests/services/test_feature_service_knowledge_file_size_limit.py new file mode 100644 index 00000000000..b8b75cf5020 --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_knowledge_file_size_limit.py @@ -0,0 +1,69 @@ +from unittest.mock import Mock + +import pytest + +from enums.cloud_plan import CloudPlan +from services import feature_service as feature_service_module +from services.feature_service import FeatureService + + +@pytest.mark.parametrize( + ("billing_enabled", "tenant_id", "billing_feature_enabled", "plan", "expected"), + [ + (False, "tenant-1", True, CloudPlan.PROFESSIONAL, 15), + (True, None, True, CloudPlan.PROFESSIONAL, 15), + (True, "tenant-1", False, CloudPlan.PROFESSIONAL, 15), + (True, "tenant-1", True, CloudPlan.SANDBOX, 15), + (True, "tenant-1", True, CloudPlan.PROFESSIONAL, 50), + (True, "tenant-1", True, CloudPlan.TEAM, 50), + ], +) +def test_get_knowledge_file_size_limit( + monkeypatch: pytest.MonkeyPatch, + billing_enabled: bool, + tenant_id: str | None, + billing_feature_enabled: bool, + plan: CloudPlan, + expected: int, +) -> None: + monkeypatch.setattr(feature_service_module.dify_config, "BILLING_ENABLED", billing_enabled) + monkeypatch.setattr(feature_service_module.dify_config, "UPLOAD_FILE_SIZE_LIMIT", 15) + monkeypatch.setattr( + feature_service_module.dify_config, + "KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN", + 50, + ) + get_info = Mock( + return_value={ + "enabled": billing_feature_enabled, + "subscription": {"plan": plan}, + } + ) + monkeypatch.setattr(feature_service_module.BillingService, "get_info", get_info) + + assert FeatureService.get_knowledge_file_size_limit(tenant_id) == expected + + if billing_enabled and tenant_id: + get_info.assert_called_once_with(tenant_id, exclude_vector_space=True) + else: + get_info.assert_not_called() + + +def test_paid_knowledge_file_size_limit_never_reduces_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(feature_service_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(feature_service_module.dify_config, "UPLOAD_FILE_SIZE_LIMIT", 100) + monkeypatch.setattr( + feature_service_module.dify_config, + "KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN", + 50, + ) + monkeypatch.setattr( + feature_service_module.BillingService, + "get_info", + lambda *_args, **_kwargs: { + "enabled": True, + "subscription": {"plan": CloudPlan.PROFESSIONAL}, + }, + ) + + assert FeatureService.get_knowledge_file_size_limit("tenant-1") == 100 diff --git a/api/tests/unit_tests/services/test_feature_service_vector_space.py b/api/tests/unit_tests/services/test_feature_service_vector_space.py index dc0e7ddb548..d3499c1e185 100644 --- a/api/tests/unit_tests/services/test_feature_service_vector_space.py +++ b/api/tests/unit_tests/services/test_feature_service_vector_space.py @@ -1,6 +1,8 @@ +from typing import cast from unittest.mock import patch -from services.feature_service import FeatureService +from services.billing_service import BillingInfo +from services.feature_service import FeatureService, LimitationModel def test_get_features_exclude_vector_space_sets_vector_space_to_none(): @@ -35,3 +37,15 @@ def test_get_features_exclude_vector_space_sets_vector_space_to_none(): assert features.vector_space is None get_info.assert_called_once_with(tenant_id, exclude_vector_space=True) + + +def test_full_features_keep_treating_unknown_vector_usage_as_zero(): + vector_space = LimitationModel() + + FeatureService._fulfill_vector_space_from_billing_info( + vector_space, + cast(BillingInfo, {"vector_space": {"size": 0.0, "limit": 50, "usage_unknown": True}}), + ) + + assert vector_space.size == 0 + assert vector_space.limit == 50 diff --git a/api/tests/unit_tests/services/test_file_service.py b/api/tests/unit_tests/services/test_file_service.py index f1d36f5e9bf..172aa909100 100644 --- a/api/tests/unit_tests/services/test_file_service.py +++ b/api/tests/unit_tests/services/test_file_service.py @@ -224,6 +224,32 @@ class TestFileService: # Default assert FileService.is_file_size_within_limit(extension="txt", file_size=5 * 1024 * 1024) is True assert FileService.is_file_size_within_limit(extension="pdf", file_size=6 * 1024 * 1024) is False + assert ( + FileService.is_file_size_within_limit( + extension="pdf", + file_size=6 * 1024 * 1024, + default_file_size_limit=7, + ) + is True + ) + assert ( + FileService.is_file_size_within_limit( + extension="pdf", + file_size=8 * 1024 * 1024, + default_file_size_limit=7, + ) + is False + ) + + # Media-specific limits are not affected by the knowledge document override. + assert ( + FileService.is_file_size_within_limit( + extension="jpg", + file_size=11 * 1024 * 1024, + default_file_size_limit=100, + ) + is False + ) def test_get_file_base64_success(self, file_service: FileService, db_session: Session): self._persist_upload_file(db_session, key="test_key") diff --git a/api/tests/unit_tests/services/test_vector_space_admission_service.py b/api/tests/unit_tests/services/test_vector_space_admission_service.py new file mode 100644 index 00000000000..a3241dfd49e --- /dev/null +++ b/api/tests/unit_tests/services/test_vector_space_admission_service.py @@ -0,0 +1,570 @@ +import json +import threading +from concurrent.futures import ThreadPoolExecutor +from types import SimpleNamespace, TracebackType +from typing import cast +from unittest.mock import PropertyMock, call, patch + +import pytest +from sqlalchemy.orm import Session + +from configs import dify_config +from core.rag.datasource.vdb.vector_type import VectorType +from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType +from core.rag.models.document import AttachmentDocument, ChildDocument, Document +from enums.cloud_plan import CloudPlan +from enums.deployment_edition import DeploymentEdition +from models.dataset import Dataset +from services.vector_space_admission_service import ( + VECTOR_SPACE_ADMISSION_ERROR_CODE, + VectorSpaceAdmissionError, + VectorSpaceAdmissionService, + VectorStorageWorkload, + build_document_workload, + build_pipeline_workload, + estimate_tidb_storage_bytes, + format_vector_space_admission_error, + get_vector_space_admission_error_fields, + parse_vector_space_estimate_limits, +) + +_MEBIBYTE = 1024 * 1024 +_ESTIMATE_LIMITS = "sandbox:60,professional:6400,team:25600" + + +class _FakeRedisLock: + def __init__(self, lock: threading.Lock) -> None: + self._lock = lock + + def __enter__(self) -> "_FakeRedisLock": + self._lock.acquire() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self._lock.release() + + +class _FakeRedis: + def __init__(self) -> None: + self.values: dict[str, str] = {} + self.ttls: dict[str, int] = {} + self._locks: dict[str, threading.Lock] = {} + + def lock(self, key: str, **_kwargs: object) -> _FakeRedisLock: + return _FakeRedisLock(self._locks.setdefault(key, threading.Lock())) + + def get(self, key: str) -> str | None: + return self.values.get(key) + + def setex(self, key: str, ttl: int, value: str) -> None: + self.values[key] = value + self.ttls[key] = ttl + + +def _dataset() -> Dataset: + return cast( + Dataset, + SimpleNamespace( + id="dataset-1", + tenant_id="tenant-1", + indexing_technique=IndexTechniqueType.HIGH_QUALITY, + embedding_model_provider="provider", + embedding_model="model", + index_struct_dict={"type": VectorType.TIDB_ON_QDRANT}, + ), + ) + + +def _workload() -> VectorStorageWorkload: + return VectorStorageWorkload(text_points=1, summary_points=0, probe_text="probe") + + +def _check_estimate( + plan: CloudPlan, + estimated_mb: float, + *, + usage_mb: float = 0, + plan_limit_mb: int = 50, + service: VectorSpaceAdmissionService | None = None, + document_id: str = "document-1", + redis: _FakeRedis | None = None, +) -> VectorSpaceAdmissionService: + service = service or VectorSpaceAdmissionService() + redis = redis or _FakeRedis() + with ( + patch.object(service, "_get_plan", return_value=plan), + patch.object(service, "_get_embedding_dimension", return_value=3072), + patch.object( + type(dify_config), + "DEPLOYMENT_EDITION", + new_callable=PropertyMock, + return_value=DeploymentEdition.CLOUD, + ), + patch("services.vector_space_admission_service.dify_config.BILLING_ENABLED", True), + patch( + "services.vector_space_admission_service.dify_config.TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB", + _ESTIMATE_LIMITS, + ), + patch( + "services.vector_space_admission_service.Vector.resolve_vector_type", + return_value=VectorType.TIDB_ON_QDRANT, + ), + patch( + "services.vector_space_admission_service.estimate_tidb_storage_bytes", + return_value=estimated_mb * _MEBIBYTE, + ), + patch( + "services.vector_space_admission_service.BillingService.get_vector_space", + return_value={"size": usage_mb, "limit": plan_limit_mb}, + ), + patch("services.vector_space_admission_service.redis_client", redis), + ): + service._ensure_can_write( + dataset=_dataset(), + document_id=document_id, + workload=_workload(), + session=cast(Session, SimpleNamespace()), + ) + return service + + +def test_estimate_tidb_storage_bytes_counts_both_vector_copies_and_point_overhead() -> None: + assert estimate_tidb_storage_bytes(point_count=10, dimension=1536) == 10 * (1536 * 4 * 2 + 3584) + + +def test_parse_vector_space_estimate_limits_supports_all_plans() -> None: + assert parse_vector_space_estimate_limits("sandbox:1,professional:2,team:3") == { + CloudPlan.SANDBOX: 1, + CloudPlan.PROFESSIONAL: 2, + CloudPlan.TEAM: 3, + } + + +@pytest.mark.parametrize( + "value", + [ + "", + "sandbox", + "sandbox:60", + "unknown:60", + "sandbox:not-a-number", + "sandbox:0", + "sandbox:-1", + "sandbox:1,pro:2,team:3", + "pro:6400,professional:6401", + ], +) +def test_parse_vector_space_estimate_limits_rejects_invalid_values(value: str) -> None: + with pytest.raises(ValueError, match="Invalid vector-space estimate limit"): + parse_vector_space_estimate_limits(value) + + +def test_vector_space_admission_error_fields() -> None: + message = format_vector_space_admission_error(61, 50) + + assert get_vector_space_admission_error_fields(message) == { + "error_code": VECTOR_SPACE_ADMISSION_ERROR_CODE, + "estimated_vector_space_mb": 61, + "vector_space_limit_mb": 50, + } + assert get_vector_space_admission_error_fields("another indexing error") == { + "error_code": None, + "estimated_vector_space_mb": None, + "vector_space_limit_mb": None, + } + + +def test_workloads_ignore_images_and_attachments() -> None: + document_workload = build_document_workload( + IndexStructureType.PARAGRAPH_INDEX, + [ + Document( + page_content="text", + attachments=[AttachmentDocument(page_content="image", metadata={"doc_id": "file-1"})], + ) + ], + include_summaries=False, + ) + pipeline_workload = build_pipeline_workload( + IndexStructureType.PARAGRAPH_INDEX, + { + "general_chunks": [ + { + "content": "text ![image](/files/file-1/file-preview)", + "files": [{"id": "file-1"}], + } + ] + }, + include_summaries=False, + ) + + assert document_workload.total_points == 1 + assert pipeline_workload.total_points == 1 + + +def test_parent_child_workload_counts_child_and_summary_vectors() -> None: + workload = build_document_workload( + IndexStructureType.PARENT_CHILD_INDEX, + [ + Document( + page_content="parent-1", + children=[ChildDocument(page_content="child-1"), ChildDocument(page_content="child-2")], + ), + Document(page_content="parent-2", children=[ChildDocument(page_content="child-3")]), + ], + include_summaries=True, + ) + + assert workload.text_points == 3 + assert workload.summary_points == 2 + assert workload.total_points == 5 + + +def test_pipeline_qa_workload_counts_question_vectors_without_summaries() -> None: + workload = build_pipeline_workload( + IndexStructureType.QA_INDEX, + { + "qa_chunks": [ + {"question": "question-1", "answer": "answer-1"}, + {"question": "question-2", "answer": "answer-2"}, + ] + }, + include_summaries=True, + ) + + assert workload.text_points == 2 + assert workload.summary_points == 0 + + +def test_admission_is_cloud_only() -> None: + service = VectorSpaceAdmissionService() + with ( + patch.object( + type(dify_config), + "DEPLOYMENT_EDITION", + new_callable=PropertyMock, + return_value=DeploymentEdition.COMMUNITY, + ), + patch("services.vector_space_admission_service.dify_config.BILLING_ENABLED", True), + patch("services.vector_space_admission_service.Vector.resolve_vector_type") as resolve_vector_type, + patch("services.vector_space_admission_service.BillingService.get_info") as get_info, + ): + service._ensure_can_write( + dataset=_dataset(), + document_id="document-1", + workload=_workload(), + session=cast(Session, SimpleNamespace()), + ) + + resolve_vector_type.assert_not_called() + get_info.assert_not_called() + + +def test_admission_skips_non_tidb_vector_backends() -> None: + service = VectorSpaceAdmissionService() + with ( + patch.object( + type(dify_config), + "DEPLOYMENT_EDITION", + new_callable=PropertyMock, + return_value=DeploymentEdition.CLOUD, + ), + patch("services.vector_space_admission_service.dify_config.BILLING_ENABLED", True), + patch("services.vector_space_admission_service.Vector.resolve_vector_type", return_value=VectorType.QDRANT), + patch("services.vector_space_admission_service.BillingService.get_info") as get_info, + ): + service._ensure_can_write( + dataset=_dataset(), + document_id="document-1", + workload=_workload(), + session=cast(Session, SimpleNamespace()), + ) + + get_info.assert_not_called() + + +def test_sandbox_allows_60_mb_estimate() -> None: + _check_estimate(CloudPlan.SANDBOX, 60) + + +def test_sandbox_compares_current_usage_plus_document_estimate() -> None: + _check_estimate(CloudPlan.SANDBOX, 20, usage_mb=40) + + with pytest.raises(VectorSpaceAdmissionError): + _check_estimate(CloudPlan.SANDBOX, 21, usage_mb=40) + + +def test_admission_compares_fractional_usage_without_rounding_down() -> None: + _check_estimate(CloudPlan.SANDBOX, 10.5, usage_mb=49.5) + + with pytest.raises(VectorSpaceAdmissionError) as exc_info: + _check_estimate(CloudPlan.SANDBOX, 10.6, usage_mb=49.5) + + assert get_vector_space_admission_error_fields(str(exc_info.value)) == { + "error_code": VECTOR_SPACE_ADMISSION_ERROR_CODE, + "estimated_vector_space_mb": 61, + "vector_space_limit_mb": 50, + } + + +def test_admission_uses_configured_threshold_above_nominal_limit() -> None: + _check_estimate(CloudPlan.SANDBOX, 10, usage_mb=50) + + with pytest.raises(VectorSpaceAdmissionError): + _check_estimate(CloudPlan.SANDBOX, 10.1, usage_mb=50) + + +@pytest.mark.parametrize( + ("plan", "usage_mb", "allowed_estimate_mb", "rejected_estimate_mb"), + [ + (CloudPlan.PROFESSIONAL, 5000, 1400, 1401), + (CloudPlan.TEAM, 20000, 5600, 5601), + ], +) +def test_paid_plan_projected_usage_boundaries( + plan: CloudPlan, + usage_mb: int, + allowed_estimate_mb: int, + rejected_estimate_mb: int, +) -> None: + _check_estimate(plan, allowed_estimate_mb, usage_mb=usage_mb) + + with pytest.raises(VectorSpaceAdmissionError): + _check_estimate(plan, rejected_estimate_mb, usage_mb=usage_mb) + + +def test_same_batch_accumulates_projected_usage() -> None: + service = VectorSpaceAdmissionService() + redis = _FakeRedis() + _check_estimate( + CloudPlan.SANDBOX, + 10, + usage_mb=40, + service=service, + document_id="document-1", + redis=redis, + ) + _check_estimate( + CloudPlan.SANDBOX, + 10, + usage_mb=40, + service=service, + document_id="document-2", + redis=redis, + ) + + with pytest.raises(VectorSpaceAdmissionError): + _check_estimate( + CloudPlan.SANDBOX, + 1, + usage_mb=40, + service=service, + document_id="document-3", + redis=redis, + ) + + +def test_usage_lookup_is_refreshed_for_each_document() -> None: + service = VectorSpaceAdmissionService() + redis = _FakeRedis() + with ( + patch.object(service, "_get_plan", return_value=CloudPlan.SANDBOX), + patch.object(service, "_get_embedding_dimension", return_value=3072), + patch.object( + type(dify_config), + "DEPLOYMENT_EDITION", + new_callable=PropertyMock, + return_value=DeploymentEdition.CLOUD, + ), + patch("services.vector_space_admission_service.dify_config.BILLING_ENABLED", True), + patch( + "services.vector_space_admission_service.dify_config.TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB", + _ESTIMATE_LIMITS, + ), + patch( + "services.vector_space_admission_service.Vector.resolve_vector_type", + return_value=VectorType.TIDB_ON_QDRANT, + ), + patch( + "services.vector_space_admission_service.estimate_tidb_storage_bytes", + side_effect=[20 * _MEBIBYTE, 1 * _MEBIBYTE], + ), + patch( + "services.vector_space_admission_service.BillingService.get_vector_space", + side_effect=[{"size": 40.0, "limit": 50}, {"size": 50.0, "limit": 50}], + ) as get_vector_space, + patch("services.vector_space_admission_service.redis_client", redis), + ): + service._ensure_can_write( + dataset=_dataset(), + document_id="document-1", + workload=_workload(), + session=cast(Session, SimpleNamespace()), + ) + with pytest.raises(VectorSpaceAdmissionError): + service._ensure_can_write( + dataset=_dataset(), + document_id="document-2", + workload=_workload(), + session=cast(Session, SimpleNamespace()), + ) + + assert get_vector_space.call_args_list == [call("tenant-1"), call("tenant-1")] + + +def test_independent_services_use_watermark_without_double_counting_fresh_usage() -> None: + redis = _FakeRedis() + _check_estimate( + CloudPlan.SANDBOX, + 10, + usage_mb=40, + service=VectorSpaceAdmissionService(), + document_id="document-1", + redis=redis, + ) + _check_estimate( + CloudPlan.SANDBOX, + 10, + usage_mb=50, + service=VectorSpaceAdmissionService(), + document_id="document-2", + redis=redis, + ) + + with pytest.raises(VectorSpaceAdmissionError): + _check_estimate( + CloudPlan.SANDBOX, + 1, + usage_mb=50, + service=VectorSpaceAdmissionService(), + document_id="document-3", + redis=redis, + ) + + state = json.loads(redis.values["tenant:tenant-1:vector_space_estimate_watermark"]) + assert state["projected_usage_bytes"] == 60 * _MEBIBYTE + assert state["document_ids"] == ["document-1", "document-2"] + assert redis.ttls["tenant:tenant-1:vector_space_estimate_watermark"] == 1800 + + +def test_fresh_usage_above_watermark_becomes_next_projection_base() -> None: + redis = _FakeRedis() + _check_estimate( + CloudPlan.SANDBOX, + 10, + usage_mb=40, + service=VectorSpaceAdmissionService(), + document_id="document-1", + redis=redis, + ) + _check_estimate( + CloudPlan.SANDBOX, + 5, + usage_mb=55, + service=VectorSpaceAdmissionService(), + document_id="document-2", + redis=redis, + ) + + state = json.loads(redis.values["tenant:tenant-1:vector_space_estimate_watermark"]) + assert state["projected_usage_bytes"] == 60 * _MEBIBYTE + + +def test_same_document_is_not_added_to_watermark_twice() -> None: + redis = _FakeRedis() + for _ in range(2): + _check_estimate( + CloudPlan.SANDBOX, + 10, + usage_mb=40, + service=VectorSpaceAdmissionService(), + document_id="document-1", + redis=redis, + ) + + _check_estimate( + CloudPlan.SANDBOX, + 10, + usage_mb=40, + service=VectorSpaceAdmissionService(), + document_id="document-2", + redis=redis, + ) + + state = json.loads(redis.values["tenant:tenant-1:vector_space_estimate_watermark"]) + assert state["projected_usage_bytes"] == 60 * _MEBIBYTE + assert state["document_ids"] == ["document-1", "document-2"] + + +def test_concurrent_services_reserve_watermark_atomically() -> None: + redis = _FakeRedis() + barrier = threading.Barrier(2) + + def reserve(document_id: str) -> bool: + barrier.wait() + _, projected_usage_bytes = VectorSpaceAdmissionService()._reserve_projected_usage( + tenant_id="tenant-1", + document_id=document_id, + current_usage_bytes=40 * _MEBIBYTE, + document_estimate_bytes=15 * _MEBIBYTE, + estimate_limit_bytes=60 * _MEBIBYTE, + ) + return projected_usage_bytes <= 60 * _MEBIBYTE + + with ( + patch("services.vector_space_admission_service.redis_client", redis), + ThreadPoolExecutor(max_workers=2) as executor, + ): + results = list(executor.map(reserve, ["document-1", "document-2"])) + + assert sorted(results) == [False, True] + state = json.loads(redis.values["tenant:tenant-1:vector_space_estimate_watermark"]) + assert state["projected_usage_bytes"] == 55 * _MEBIBYTE + assert len(state["document_ids"]) == 1 + + +@pytest.mark.parametrize( + ("plan", "estimated_mb", "plan_limit_mb"), + [ + (CloudPlan.SANDBOX, 61, 55), + (CloudPlan.PROFESSIONAL, 6401, 6000), + (CloudPlan.TEAM, 25601, 24000), + ], +) +def test_plan_threshold_rejection_reports_billing_limit( + plan: CloudPlan, + estimated_mb: int, + plan_limit_mb: int, +) -> None: + with pytest.raises(VectorSpaceAdmissionError) as exc_info: + _check_estimate(plan, estimated_mb, plan_limit_mb=plan_limit_mb) + + assert get_vector_space_admission_error_fields(str(exc_info.value)) == { + "error_code": VECTOR_SPACE_ADMISSION_ERROR_CODE, + "estimated_vector_space_mb": estimated_mb, + "vector_space_limit_mb": plan_limit_mb, + } + + +def test_2060_mb_estimate_rejects_sandbox_but_allows_pro() -> None: + with pytest.raises(VectorSpaceAdmissionError): + _check_estimate(CloudPlan.SANDBOX, 2060) + + _check_estimate(CloudPlan.PROFESSIONAL, 2060) + + +def test_billing_plan_lookup_excludes_vector_space_and_is_cached() -> None: + service = VectorSpaceAdmissionService() + with patch( + "services.vector_space_admission_service.BillingService.get_info", + return_value={"enabled": True, "subscription": {"plan": "professional"}}, + ) as get_info: + assert service._get_plan("tenant-1") == CloudPlan.PROFESSIONAL + assert service._get_plan("tenant-1") == CloudPlan.PROFESSIONAL + + get_info.assert_called_once_with("tenant-1", exclude_vector_space=True) diff --git a/api/tests/unit_tests/tasks/test_dataset_indexing_task.py b/api/tests/unit_tests/tasks/test_dataset_indexing_task.py index ff2cf2f92e3..bc692dc0023 100644 --- a/api/tests/unit_tests/tasks/test_dataset_indexing_task.py +++ b/api/tests/unit_tests/tasks/test_dataset_indexing_task.py @@ -228,6 +228,7 @@ def mock_indexing_runner(): with patch("tasks.document_indexing_task.IndexingRunner") as mock_runner_class: mock_runner = MagicMock() mock_runner_class.return_value = mock_runner + mock_runner._constructor_mock = mock_runner_class yield mock_runner @@ -424,6 +425,7 @@ class TestBatchProcessing: assert doc.processing_started_at is not None # IndexingRunner should be called with all documents + mock_indexing_runner._constructor_mock.assert_called_once_with(enforce_vector_space_admission=True) mock_indexing_runner.run.assert_called_once() call_args = mock_indexing_runner.run.call_args[0][0] assert len(call_args) == len(document_ids) @@ -668,7 +670,12 @@ class TestErrorHandling: """Test cases for error handling and retry mechanisms.""" def test_error_handling_sets_document_error_status( - self, dataset_id, document_ids, mock_db_session, mock_dataset, mock_feature_service + self, + dataset_id, + document_ids, + mock_db_session, + mock_dataset, + mock_feature_service, ): """ Test that errors during validation set document error status. @@ -694,8 +701,8 @@ class TestErrorHandling: # Set up to trigger vector space limit error mock_feature_service.get_features.return_value.billing.enabled = True mock_feature_service.get_features.return_value.billing.subscription.plan = CloudPlan.PROFESSIONAL + mock_feature_service.get_features.return_value.vector_space.size = 100 mock_feature_service.get_features.return_value.vector_space.limit = 100 - mock_feature_service.get_features.return_value.vector_space.size = 100 # At limit # Act _document_indexing(dataset_id, document_ids) @@ -984,7 +991,12 @@ class TestAdvancedScenarios: assert mock_redis.setex.call_count >= concurrency_limit def test_vector_space_limit_edge_case_at_exact_limit( - self, dataset_id, document_ids, mock_db_session, mock_dataset, mock_feature_service + self, + dataset_id, + document_ids, + mock_db_session, + mock_dataset, + mock_feature_service, ): """ Test vector space limit validation at exact boundary. @@ -1019,8 +1031,8 @@ class TestAdvancedScenarios: # Set vector space exactly at limit mock_feature_service.get_features.return_value.billing.enabled = True mock_feature_service.get_features.return_value.billing.subscription.plan = CloudPlan.PROFESSIONAL + mock_feature_service.get_features.return_value.vector_space.size = 100 mock_feature_service.get_features.return_value.vector_space.limit = 100 - mock_feature_service.get_features.return_value.vector_space.size = 100 # Exactly at limit # Act _document_indexing(dataset_id, document_ids) @@ -1335,7 +1347,12 @@ class TestPerformanceScenarios: """Test performance-related scenarios and optimizations.""" def test_large_document_batch_processing( - self, dataset_id, mock_db_session, mock_dataset, mock_indexing_runner, mock_feature_service + self, + dataset_id, + mock_db_session, + mock_dataset, + mock_indexing_runner, + mock_feature_service, ): """ Test processing a large batch of documents at batch limit. @@ -1373,8 +1390,8 @@ class TestPerformanceScenarios: # Configure billing with sufficient limits mock_feature_service.get_features.return_value.billing.enabled = True mock_feature_service.get_features.return_value.billing.subscription.plan = CloudPlan.PROFESSIONAL + mock_feature_service.get_features.return_value.vector_space.size = 40.75 mock_feature_service.get_features.return_value.vector_space.limit = 10000 - mock_feature_service.get_features.return_value.vector_space.size = 0 with patch("tasks.document_indexing_task.dify_config.BATCH_UPLOAD_LIMIT", str(batch_limit)): # Act @@ -1387,6 +1404,7 @@ class TestPerformanceScenarios: mock_indexing_runner.run.assert_called_once() call_args = mock_indexing_runner.run.call_args[0][0] assert len(call_args) == batch_limit + mock_feature_service.get_features.assert_called_once_with(mock_dataset.tenant_id) def test_tenant_queue_handles_burst_traffic(self, tenant_id, dataset_id, mock_redis, mock_db_session, mock_dataset): """ diff --git a/api/tests/unit_tests/tasks/test_retry_document_indexing_task.py b/api/tests/unit_tests/tasks/test_retry_document_indexing_task.py new file mode 100644 index 00000000000..cd3afb4904b --- /dev/null +++ b/api/tests/unit_tests/tasks/test_retry_document_indexing_task.py @@ -0,0 +1,34 @@ +from unittest.mock import MagicMock, patch + +from tasks.retry_document_indexing_task import retry_document_indexing_task + + +def test_retry_enforces_vector_space_admission() -> None: + session = MagicMock() + dataset = MagicMock(id="dataset-1", tenant_id="tenant-1", runtime_mode="general") + user = MagicMock(id="user-1") + tenant = MagicMock(id="tenant-1") + document = MagicMock(id="document-1", dataset_id="dataset-1", doc_form="paragraph") + session.scalar.side_effect = [dataset, user, tenant, document] + empty_segments: list[MagicMock] = [] + session.scalars.return_value.all.return_value = empty_segments + + session_context = MagicMock() + session_context.__enter__.return_value = session + features = MagicMock() + features.billing.enabled = False + + with ( + patch( + "tasks.retry_document_indexing_task.session_factory.create_session", + return_value=session_context, + ), + patch("tasks.retry_document_indexing_task.FeatureService.get_features", return_value=features), + patch("tasks.retry_document_indexing_task.IndexProcessorFactory"), + patch("tasks.retry_document_indexing_task.IndexingRunner") as indexing_runner, + patch("tasks.retry_document_indexing_task.redis_client"), + ): + retry_document_indexing_task.run(dataset.id, [document.id], user.id) + + indexing_runner.assert_called_once_with(enforce_vector_space_admission=True) + indexing_runner.return_value.run.assert_called_once_with([document], session) diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index b3abad64778..be9f8312cdf 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -48,6 +48,7 @@ LINDORM_URL=http://localhost:30070 LINDORM_USERNAME=admin UPSTASH_VECTOR_URL=https://xxx-vector.upstash.io UPLOAD_FILE_SIZE_LIMIT=15 +KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN=15 UPLOAD_FILE_BATCH_LIMIT=5 UPLOAD_FILE_EXTENSION_BLACKLIST= SINGLE_CHUNK_ATTACHMENT_LIMIT=10 @@ -419,6 +420,7 @@ TIDB_VECTOR_PASSWORD= TIDB_ON_QDRANT_CLIENT_TIMEOUT=20 TIDB_ON_QDRANT_GRPC_ENABLED=false TIDB_ON_QDRANT_GRPC_PORT=6334 +TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB=sandbox:60,professional:6400,team:25600 TIDB_PUBLIC_KEY=dify TIDB_PRIVATE_KEY=dify RELYT_HOST=db diff --git a/packages/contracts/generated/api/console/features/types.gen.ts b/packages/contracts/generated/api/console/features/types.gen.ts index 52c6cf80402..c78ebbca40c 100644 --- a/packages/contracts/generated/api/console/features/types.gen.ts +++ b/packages/contracts/generated/api/console/features/types.gen.ts @@ -27,6 +27,12 @@ export type FeatureModel = { workspace_members: LicenseLimitationModel } +export type VectorSpaceLimitationModel = { + limit: number + size: number + usage_unknown?: boolean +} + export type LimitationModel = { limit: number size: number @@ -84,7 +90,7 @@ export type GetFeaturesVectorSpaceData = { } export type GetFeaturesVectorSpaceResponses = { - 200: LimitationModel + 200: VectorSpaceLimitationModel } export type GetFeaturesVectorSpaceResponse = diff --git a/packages/contracts/generated/api/console/features/zod.gen.ts b/packages/contracts/generated/api/console/features/zod.gen.ts index a5a66a25782..6e4e713b049 100644 --- a/packages/contracts/generated/api/console/features/zod.gen.ts +++ b/packages/contracts/generated/api/console/features/zod.gen.ts @@ -2,6 +2,15 @@ import * as z from 'zod' +/** + * VectorSpaceLimitationModel + */ +export const zVectorSpaceLimitationModel = z.object({ + limit: z.int(), + size: z.int(), + usage_unknown: z.boolean().optional().default(false), +}) + /** * LimitationModel */ @@ -112,4 +121,4 @@ export const zGetFeaturesResponse = zFeatureModel /** * Success */ -export const zGetFeaturesVectorSpaceResponse = zLimitationModel +export const zGetFeaturesVectorSpaceResponse = zVectorSpaceLimitationModel diff --git a/packages/contracts/generated/api/console/files/types.gen.ts b/packages/contracts/generated/api/console/files/types.gen.ts index 84ecb3af4af..f68df4af336 100644 --- a/packages/contracts/generated/api/console/files/types.gen.ts +++ b/packages/contracts/generated/api/console/files/types.gen.ts @@ -16,6 +16,7 @@ export type UploadConfig = { file_upload_limit: number image_file_batch_limit: number image_file_size_limit: number + knowledge_file_size_limit: number single_chunk_attachment_limit: number skill_file_size_limit: number video_file_size_limit: number diff --git a/packages/contracts/generated/api/console/files/zod.gen.ts b/packages/contracts/generated/api/console/files/zod.gen.ts index b10011a62e2..ac4400b777e 100644 --- a/packages/contracts/generated/api/console/files/zod.gen.ts +++ b/packages/contracts/generated/api/console/files/zod.gen.ts @@ -20,6 +20,7 @@ export const zUploadConfig = z.object({ file_upload_limit: z.int(), image_file_batch_limit: z.int(), image_file_size_limit: z.int(), + knowledge_file_size_limit: z.int(), single_chunk_attachment_limit: z.int(), skill_file_size_limit: z.int(), video_file_size_limit: z.int(), diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index 671fa451c08..ca880a75653 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -722,6 +722,8 @@ export type DocumentStatusResponse = { completed_at: number | null completed_segments?: number | null error: string | null + error_code?: string | null + estimated_vector_space_mb?: number | null id: string indexing_status: string parsing_completed_at: number | null @@ -730,6 +732,7 @@ export type DocumentStatusResponse = { splitting_completed_at: number | null stopped_at: number | null total_segments?: number | null + vector_space_limit_mb?: number | null } export type DocumentTextCreatePayload = { @@ -2414,6 +2417,7 @@ export type PostDatasetsByDatasetIdDocumentCreateByFileErrors = { 400: unknown 401: unknown 403: unknown + 413: unknown } export type PostDatasetsByDatasetIdDocumentCreateByFileResponses = { @@ -2461,6 +2465,7 @@ export type PostDatasetsByDatasetIdDocumentCreateByFile2Errors = { 400: unknown 401: unknown 403: unknown + 413: unknown } export type PostDatasetsByDatasetIdDocumentCreateByFile2Responses = { @@ -2681,6 +2686,7 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdErrors = { 401: unknown 403: unknown 404: unknown + 413: unknown } export type PatchDatasetsByDatasetIdDocumentsByDocumentIdResponses = { @@ -2966,6 +2972,7 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileErrors = { 401: unknown 403: unknown 404: unknown + 413: unknown } export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileResponses = { @@ -3017,6 +3024,7 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Errors = { 401: unknown 403: unknown 404: unknown + 413: unknown } export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Responses = { diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index 9e34890b435..0dfa5ee4a0e 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -872,6 +872,8 @@ export const zDocumentStatusResponse = z.object({ completed_at: z.int().nullable(), completed_segments: z.int().nullish(), error: z.string().nullable(), + error_code: z.string().nullish(), + estimated_vector_space_mb: z.int().nullish(), id: z.string(), indexing_status: z.string(), parsing_completed_at: z.int().nullable(), @@ -880,6 +882,7 @@ export const zDocumentStatusResponse = z.object({ splitting_completed_at: z.int().nullable(), stopped_at: z.int().nullable(), total_segments: z.int().nullish(), + vector_space_limit_mb: z.int().nullish(), }) /** diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx index f522f39159e..088feeb2f0f 100644 --- a/web/__tests__/billing/billing-integration.test.tsx +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -23,7 +23,7 @@ import { render as renderWithConsoleState } from '@/test/console/render' let mockProviderCtx: Record = {} let mockConsoleState: Record = {} -const render = (ui: ReactElement, options: RenderOptions = {}) => { +const render = (ui: ReactElement, options: RenderOptions = {}, vectorSpaceUsageUnknown = false) => { const queryClient = createConsoleQueryClient() const plan = mockProviderCtx.plan as { usage: { vectorSpace: number } @@ -32,6 +32,7 @@ const render = (ui: ReactElement, options: RenderOptions = {}) => { queryClient.setQueryData(consoleQuery.features.vectorSpace.get.queryOptions().queryKey, { size: plan.usage.vectorSpace, limit: plan.total.vectorSpace, + usage_unknown: vectorSpaceUsageUnknown, }) const { wrapper } = createConsoleQueryWrapper({ systemFeatures: { deployment_edition: 'CLOUD' }, @@ -222,6 +223,21 @@ describe('Billing Page + Plan Integration', () => { expect(quotaValue).toHaveTextContent(/3\s*\/\s*5/) }) + it('should display unknown vector space usage as a placeholder', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { vectorSpace: 0 }, + total: { vectorSpace: 50 }, + }) + + render(, {}, true) + + const quotaCard = screen.getByRole('group', { name: /usagePage\.vectorSpace/i }) + const quotaValue = within(quotaCard).getByTestId('billing-quota-value') + expect(quotaValue).toHaveTextContent('--') + expect(quotaValue).not.toHaveTextContent('< 50') + }) + it('should show "unlimited" for infinite quotas (professional API rate limit)', () => { setupProviderContext({ type: Plan.professional, diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx index 769a00a30d0..e1325c21293 100644 --- a/web/__tests__/billing/education-verification-flow.test.tsx +++ b/web/__tests__/billing/education-verification-flow.test.tsx @@ -32,6 +32,7 @@ const render = (ui: ReactElement, options: RenderOptions = {}) => { queryClient.setQueryData(consoleQuery.features.vectorSpace.get.queryOptions().queryKey, { size: plan.usage.vectorSpace, limit: plan.total.vectorSpace, + usage_unknown: false, }) const { wrapper } = createConsoleQueryWrapper({ systemFeatures: { deployment_edition: 'CLOUD' }, diff --git a/web/app/components/billing/usage-info/index.tsx b/web/app/components/billing/usage-info/index.tsx index 53cfabdc97c..d71507ca02c 100644 --- a/web/app/components/billing/usage-info/index.tsx +++ b/web/app/components/billing/usage-info/index.tsx @@ -26,6 +26,7 @@ type Props = Readonly<{ storageThreshold?: number storageTooltip?: string isSandboxPlan?: boolean + usageUnknown?: boolean }> const UsageInfo: FC = ({ @@ -44,11 +45,12 @@ const UsageInfo: FC = ({ storageThreshold = 50, storageTooltip, isSandboxPlan = false, + usageUnknown = false, }) => { const { t } = useTranslation() - const isBelowThreshold = storageMode && usage < storageThreshold - const isSandboxFull = storageMode && isSandboxPlan && usage >= storageThreshold + const isBelowThreshold = !usageUnknown && storageMode && usage < storageThreshold + const isSandboxFull = !usageUnknown && storageMode && isSandboxPlan && usage >= storageThreshold // Single source of truth: sandbox full is visually clamped to 100%; all other // determinate cases show the real percent capped at 100. Tone derives from @@ -79,6 +81,8 @@ const UsageInfo: FC = ({ ) : null const usageDisplay: ReactNode = (() => { + if (usageUnknown) return -- + if (storageMode) { if (isSandboxFull) { return ( @@ -142,7 +146,7 @@ const UsageInfo: FC = ({ ) const wrapWithStorageTooltip = (children: ReactNode) => { - if (storageMode && storageTooltip) { + if (!usageUnknown && storageMode && storageTooltip) { return ( {children}} /> @@ -177,7 +181,7 @@ const UsageInfo: FC = ({ {rightInfo} - {wrapWithStorageTooltip(bar)} + {!usageUnknown && wrapWithStorageTooltip(bar)} ) } diff --git a/web/app/components/billing/usage-info/vector-space-info.tsx b/web/app/components/billing/usage-info/vector-space-info.tsx index 657df8ab07a..744e228dee8 100644 --- a/web/app/components/billing/usage-info/vector-space-info.tsx +++ b/web/app/components/billing/usage-info/vector-space-info.tsx @@ -61,6 +61,7 @@ const VectorSpaceInfo: FC = ({ className }) => { storageThreshold={STORAGE_THRESHOLD_MB} storageTooltip={t(($) => $['usagePage.storageThresholdTooltip'], { ns: 'billing' }) as string} isSandboxPlan={isSandbox} + usageUnknown={vectorSpace?.usage_unknown} /> ) } diff --git a/web/app/components/billing/vector-space-unavailable/__tests__/index.spec.tsx b/web/app/components/billing/vector-space-unavailable/__tests__/index.spec.tsx new file mode 100644 index 00000000000..6a1411c3c8f --- /dev/null +++ b/web/app/components/billing/vector-space-unavailable/__tests__/index.spec.tsx @@ -0,0 +1,22 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import VectorSpaceUnavailable from '../index' + +describe('VectorSpaceUnavailable', () => { + it('retries the vector-space query', () => { + const onRetry = vi.fn() + + render() + fireEvent.click(screen.getByRole('button', { name: 'common.operation.retry' })) + + expect(onRetry).toHaveBeenCalledOnce() + }) + + it('disables retry while the query is running', () => { + render() + + expect(screen.getByRole('button', { name: 'common.operation.retry' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + }) +}) diff --git a/web/app/components/billing/vector-space-unavailable/index.tsx b/web/app/components/billing/vector-space-unavailable/index.tsx new file mode 100644 index 00000000000..6491a6b71ae --- /dev/null +++ b/web/app/components/billing/vector-space-unavailable/index.tsx @@ -0,0 +1,31 @@ +'use client' + +import { Button } from '@langgenius/dify-ui/button' +import { useTranslation } from 'react-i18next' + +type Props = { + isRetrying: boolean + onRetry: () => void +} + +const VectorSpaceUnavailable = ({ isRetrying, onRetry }: Props) => { + const { t } = useTranslation() + + return ( +
+ +
+ {t(($) => $['usagePage.vectorSpace'], { ns: 'billing' })}:{' '} + {t(($) => $['plansCommon.unavailable'], { ns: 'billing' })} +
+ +
+ ) +} + +export default VectorSpaceUnavailable diff --git a/web/app/components/datasets/common/__tests__/vector-space-admission-alert.spec.tsx b/web/app/components/datasets/common/__tests__/vector-space-admission-alert.spec.tsx new file mode 100644 index 00000000000..b75931ea483 --- /dev/null +++ b/web/app/components/datasets/common/__tests__/vector-space-admission-alert.spec.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react' +import VectorSpaceAdmissionAlert from '../vector-space-admission-alert' + +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + return createReactI18nextMock({ + 'datasetDocuments.embedding.vectorSpaceEstimateExceeded.description': + 'After upload total {{estimated}}MB / plan limit {{limit}}MB', + }) +}) + +vi.mock('@/app/components/billing/upgrade-btn', () => ({ + default: () => , +})) + +describe('VectorSpaceAdmissionAlert', () => { + it('does not suggest an unavailable upgrade', () => { + render() + + expect(screen.getByText('After upload total 61MB / plan limit 50MB')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'upgrade plan' })).not.toBeInTheDocument() + }) + + it('offers an upgrade when the current plan has one', () => { + render() + + expect(screen.getByText('After upload total 61MB / plan limit 50MB')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'upgrade plan' })).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/common/vector-space-admission-alert.tsx b/web/app/components/datasets/common/vector-space-admission-alert.tsx new file mode 100644 index 00000000000..b1f6cdb184e --- /dev/null +++ b/web/app/components/datasets/common/vector-space-admission-alert.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from 'react-i18next' +import UpgradeBtn from '@/app/components/billing/upgrade-btn' + +type VectorSpaceAdmissionAlertProps = { + showUpgrade: boolean + estimatedMb: number + planLimitMb: number +} + +const VectorSpaceAdmissionAlert = ({ + showUpgrade, + estimatedMb, + planLimitMb, +}: VectorSpaceAdmissionAlertProps) => { + const { t } = useTranslation() + + return ( +
+ +
+
+ {t(($) => $['embedding.vectorSpaceEstimateExceeded.title'], { + ns: 'datasetDocuments', + })} +
+
+ {t(($) => $['embedding.vectorSpaceEstimateExceeded.description'], { + ns: 'datasetDocuments', + estimated: estimatedMb, + limit: planLimitMb, + })} +
+
+ {showUpgrade && } +
+ ) +} + +export default VectorSpaceAdmissionAlert diff --git a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx index 98f8d7b6ddd..09eb56eb06f 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx @@ -73,6 +73,22 @@ vi.mock('../upgrade-banner', () => ({ default: () =>
upgrade processing priority
, })) +vi.mock('@/app/components/datasets/common/vector-space-admission-alert', () => ({ + default: ({ + showUpgrade, + estimatedMb, + planLimitMb, + }: { + showUpgrade: boolean + estimatedMb: number + planLimitMb: number + }) => ( +
{`vector space admission alert ${estimatedMb}MB / ${planLimitMb}MB ${ + showUpgrade ? 'with upgrade' : 'without upgrade' + }`}
+ ), +})) + describe('EmbeddingProcess', () => { beforeEach(() => { vi.clearAllMocks() @@ -101,6 +117,73 @@ describe('EmbeddingProcess', () => { expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() }) + it('shows the vector-space admission alert after processing completes', () => { + mockPollingState = { + statusList: [ + { + id: 'document-1', + indexing_status: 'error', + error_code: 'vector_space_estimate_exceeded', + estimated_vector_space_mb: 61, + vector_space_limit_mb: 50, + } as IndexingStatusResponse, + ], + isEmbedding: false, + isEmbeddingCompleted: true, + } + + render() + + expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() + expect( + screen.getByText('vector space admission alert 61MB / 50MB without upgrade'), + ).toBeInTheDocument() + }) + + it('does not show the vector-space alert for another indexing error', () => { + mockPollingState = { + statusList: [ + { + id: 'document-1', + indexing_status: 'error', + error_code: null, + estimated_vector_space_mb: 61, + vector_space_limit_mb: 50, + } as IndexingStatusResponse, + ], + isEmbedding: false, + isEmbeddingCompleted: true, + } + + render() + + expect(screen.queryByText(/vector space admission alert/)).not.toBeInTheDocument() + }) + + it('does not suggest an upgrade to team users', () => { + mockEnableBilling = true + mockPlanType = 'team' + mockPollingState = { + statusList: [ + { + id: 'document-1', + indexing_status: 'error', + error_code: 'vector_space_estimate_exceeded', + estimated_vector_space_mb: 61, + vector_space_limit_mb: 50, + } as IndexingStatusResponse, + ], + isEmbedding: false, + isEmbeddingCompleted: true, + } + + render() + + expect( + screen.getByText('vector space admission alert 61MB / 50MB without upgrade'), + ).toBeInTheDocument() + }) + it('invalidates the document list before navigating to it', async () => { const user = userEvent.setup() render() diff --git a/web/app/components/datasets/create/embedding-process/index.tsx b/web/app/components/datasets/create/embedding-process/index.tsx index 648aeb3a0a0..f3ebaec9958 100644 --- a/web/app/components/datasets/create/embedding-process/index.tsx +++ b/web/app/components/datasets/create/embedding-process/index.tsx @@ -7,6 +7,7 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import { Plan } from '@/app/components/billing/type' +import VectorSpaceAdmissionAlert from '@/app/components/datasets/common/vector-space-admission-alert' import { useProviderContext } from '@/context/provider-context' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' import Link from '@/next/link' @@ -101,12 +102,26 @@ const EmbeddingProcess: FC = ({ } const showUpgradeBanner = enableBilling && plan.type !== Plan.team + const showVectorSpaceUpgrade = + enableBilling && (plan.type === Plan.sandbox || plan.type === Plan.professional) + const vectorSpaceAdmissionError = statusList.find( + (detail) => detail.error_code === 'vector_space_estimate_exceeded', + ) return ( <>
+ {vectorSpaceAdmissionError?.estimated_vector_space_mb != null && + vectorSpaceAdmissionError.vector_space_limit_mb != null && ( + + )} + {showUpgradeBanner && }
diff --git a/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx b/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx index 43f1a984bce..8c0ebf193c9 100644 --- a/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx @@ -25,6 +25,7 @@ vi.mock('@/service/base', () => ({ // Mock file upload config const mockFileUploadConfig = { file_size_limit: 15, + knowledge_file_size_limit: 50, batch_count_limit: 5, file_upload_limit: 10, } @@ -80,6 +81,7 @@ describe('useFileUpload', () => { expect(result.current.dropRef.current).toBeNull() expect(result.current.dragRef.current).toBeNull() expect(result.current.fileUploaderRef.current).toBeNull() + expect(result.current.fileUploadConfig.file_size_limit).toBe(50) }) it('should set hideUpload true when not batch upload and has files', () => { @@ -300,10 +302,8 @@ describe('useFileUpload', () => { wrapper: createWrapper(), }) - // Create a file larger than the limit (15MB) - const largeFile = new File([new ArrayBuffer(20 * 1024 * 1024)], 'large.pdf', { - type: 'application/pdf', - }) + const largeFile = new File(['content'], 'large.pdf', { type: 'application/pdf' }) + Object.defineProperty(largeFile, 'size', { value: 51 * 1024 * 1024 }) const event = { target: { files: [largeFile] }, diff --git a/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts b/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts index be86ccab1b6..e85a7ee920d 100644 --- a/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts +++ b/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts @@ -114,7 +114,10 @@ export const useFileUpload = ({ const fileUploadConfig = useMemo( () => ({ - file_size_limit: fileUploadConfigResponse?.file_size_limit ?? 15, + file_size_limit: + fileUploadConfigResponse?.knowledge_file_size_limit ?? + fileUploadConfigResponse?.file_size_limit ?? + 15, batch_count_limit: supportBatchUpload ? (fileUploadConfigResponse?.batch_count_limit ?? 5) : 1, diff --git a/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx index eea1393226a..c13fb3cea81 100644 --- a/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx @@ -14,11 +14,12 @@ let mockPlan = { total: { vectorSpace: 100, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, } -const render = (ui: React.ReactElement) => { +const render = (ui: React.ReactElement, vectorSpaceUsageUnknown = false) => { const queryClient = createConsoleQueryClient() queryClient.setQueryData(consoleQuery.features.vectorSpace.get.queryOptions().queryKey, { size: mockPlan.usage.vectorSpace, limit: mockPlan.total.vectorSpace, + usage_unknown: vectorSpaceUsageUnknown, }) return renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' }, @@ -425,12 +426,11 @@ describe('StepOne', () => { expect(screen.getByRole('dialog')).toBeInTheDocument() }) - it('should show upgrade card when in sandbox plan with files', () => { + it('should show upgrade card immediately when in sandbox plan', () => { mockEnableBilling = true mockPlan.type = Plan.sandbox - const files = [createMockFileItem()] - render() + render() expect(screen.getByTestId('upgrade-card')).toBeInTheDocument() }) @@ -459,6 +459,32 @@ describe('StepOne', () => { expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() }) + + it('should require sandbox users to retry when vector space usage is unknown', () => { + mockEnableBilling = true + mockPlan.type = Plan.sandbox + mockPlan.usage.vectorSpace = 100 + mockPlan.total.vectorSpace = 100 + const files = [createMockFileItem()] + + render(, true) + + expect(screen.queryByTestId('vector-space-full')).not.toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent('billing.plansCommon.unavailable') + expect(screen.getByRole('button', { name: 'common.operation.retry' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() + }) + + it('should allow paid users to continue when vector space usage is unknown', () => { + mockEnableBilling = true + mockPlan.type = Plan.professional + const files = [createMockFileItem()] + + render(, true) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeEnabled() + }) }) // Preview Integration Tests diff --git a/web/app/components/datasets/create/step-one/index.tsx b/web/app/components/datasets/create/step-one/index.tsx index b144b0d7fcb..170eaf7437d 100644 --- a/web/app/components/datasets/create/step-one/index.tsx +++ b/web/app/components/datasets/create/step-one/index.tsx @@ -13,6 +13,7 @@ import NotionConnector from '@/app/components/base/notion-connector' import { NotionPageSelector } from '@/app/components/base/notion-page-selector' import { Plan } from '@/app/components/billing/type' import VectorSpaceFull from '@/app/components/billing/vector-space-full' +import VectorSpaceUnavailable from '@/app/components/billing/vector-space-unavailable' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useProviderContext } from '@/context/provider-context' import { DataSourceType } from '@/models/datasets' @@ -135,12 +136,21 @@ const StepOne = ({ const allFileLoaded = files.length > 0 && files.every((file) => file.file.id) const hasNotion = notionPages.length > 0 const shouldCheckVectorSpace = enableBilling && (allFileLoaded || hasNotion) - const { data: vectorSpace, isFetching: isFetchingVectorSpacePlan } = useQuery( + const { + data: vectorSpace, + isFetching: isFetchingVectorSpacePlan, + refetch: refetchVectorSpace, + } = useQuery( consoleQuery.features.vectorSpace.get.queryOptions({ enabled: shouldCheckVectorSpace }), ) const isCheckingVectorSpace = shouldCheckVectorSpace && !vectorSpace && isFetchingVectorSpacePlan + const isVectorSpaceUnavailable = + shouldCheckVectorSpace && plan.type === Plan.sandbox && !!vectorSpace?.usage_unknown const isVectorSpaceFull = - !!vectorSpace && vectorSpace.limit > 0 && vectorSpace.size >= vectorSpace.limit + !!vectorSpace && + !vectorSpace.usage_unknown && + vectorSpace.limit > 0 && + vectorSpace.size >= vectorSpace.limit const isShowVectorSpaceFull = (allFileLoaded || hasNotion) && isVectorSpaceFull && enableBilling const supportBatchUpload = !enableBilling || plan.type !== Plan.sandbox @@ -157,8 +167,8 @@ const StepOne = ({ if (!files.length) return true if (files.some((file) => !file.file.id)) return true if (isCheckingVectorSpace) return true - return isShowVectorSpaceFull - }, [files, isCheckingVectorSpace, isShowVectorSpaceFull]) + return isShowVectorSpaceFull || isVectorSpaceUnavailable + }, [files, isCheckingVectorSpace, isShowVectorSpaceFull, isVectorSpaceUnavailable]) // Clear previews when switching data source type const handleClearPreviews = useCallback( @@ -230,8 +240,16 @@ const StepOne = ({
)} + {isVectorSpaceUnavailable && ( +
+ void refetchVectorSpace()} + /> +
+ )} - {enableBilling && plan.type === Plan.sandbox && files.length > 0 && ( + {enableBilling && plan.type === Plan.sandbox && (
@@ -265,8 +283,18 @@ const StepOne = ({
)} + {isVectorSpaceUnavailable && ( +
+ void refetchVectorSpace()} + /> +
+ )} diff --git a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx index 8c196b9e432..f9d7e45089e 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx @@ -9,17 +9,20 @@ const mockPlan = { type: 'professional', } -const render = (ui: React.ReactElement) => { +const render = (ui: React.ReactElement, vectorSpaceUsageUnknown = false) => { const queryClient = createConsoleQueryClient() queryClient.setQueryData(consoleQuery.features.vectorSpace.get.queryOptions().queryKey, { size: mockPlan.usage.vectorSpace, limit: mockPlan.total.vectorSpace, + usage_unknown: vectorSpaceUsageUnknown, }) return renderWithConsoleQuery(ui, { queryClient }) } let mockDatasetPermissionKeys = ['dataset.acl.use'] +let mockAllFileLoaded = false const mockRouterReplace = vi.fn() +const mockStepOneContent = vi.fn() vi.mock('@/context/provider-context', () => ({ useProviderContextSelector: ( @@ -87,6 +90,7 @@ vi.mock('@/context/dataset-detail', () => ({ })) vi.mock('@/next/navigation', () => ({ + useParams: () => ({ datasetId: 'test-dataset-id' }), useRouter: () => ({ push: vi.fn(), replace: mockRouterReplace, @@ -115,6 +119,15 @@ vi.mock('../data-source/store/provider', () => ({ default: ({ children }: { children: React.ReactNode }) => <>{children}, })) +vi.mock('../steps', () => ({ + StepOneContent: (props: object) => { + mockStepOneContent(props) + return null + }, + StepTwoContent: () => null, + StepThreeContent: () => null, +})) + vi.mock('../hooks', () => ({ useAddDocumentsSteps: () => ({ steps: [], @@ -124,7 +137,7 @@ vi.mock('../hooks', () => ({ }), useLocalFile: () => ({ localFileList: [], - allFileLoaded: false, + allFileLoaded: mockAllFileLoaded, currentLocalFile: undefined, hidePreviewLocalFile: vi.fn(), }), @@ -178,7 +191,10 @@ vi.mock('../hooks', () => ({ describe('CreateFromPipeline permission guard', () => { beforeEach(() => { mockRouterReplace.mockClear() + mockStepOneContent.mockClear() mockDatasetPermissionKeys = ['dataset.acl.use'] + mockAllFileLoaded = false + mockPlan.type = 'professional' }) it('redirects users who cannot add documents to the dataset', async () => { @@ -190,4 +206,25 @@ describe('CreateFromPipeline permission guard', () => { expect(mockRouterReplace).toHaveBeenCalledWith('/datasets/test-dataset-id/documents') }) }) + + it('requires sandbox users to retry when vector space usage is unknown', () => { + mockAllFileLoaded = true + mockPlan.type = 'sandbox' + + render(, true) + + expect(mockStepOneContent).toHaveBeenCalledWith( + expect.objectContaining({ isShowVectorSpaceUnavailable: true }), + ) + }) + + it('allows paid users to continue when vector space usage is unknown', () => { + mockAllFileLoaded = true + + render(, true) + + expect(mockStepOneContent).toHaveBeenCalledWith( + expect.objectContaining({ isShowVectorSpaceUnavailable: false }), + ) + }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/index.tsx index ac2e9ca9bc7..6e1286c480f 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/index.tsx @@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' +import { Plan } from '@/app/components/billing/type' import { userProfileIdAtom } from '@/context/account-state' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { @@ -73,11 +74,14 @@ const CreateFormPipeline = () => { const { data: fileUploadConfigResponse } = useFileUploadConfig() const fileUploadConfig = useMemo( - () => - fileUploadConfigResponse ?? { - file_size_limit: 15, - batch_count_limit: 5, - }, + () => ({ + ...fileUploadConfigResponse, + file_size_limit: + fileUploadConfigResponse?.knowledge_file_size_limit ?? + fileUploadConfigResponse?.file_size_limit ?? + 15, + batch_count_limit: fileUploadConfigResponse?.batch_count_limit ?? 5, + }), [fileUploadConfigResponse], ) @@ -118,13 +122,22 @@ const CreateFormPipeline = () => { onlineDocuments.length > 0 || websitePages.length > 0 || selectedFileIds.length > 0) - const { data: vectorSpace, isFetching: isFetchingVectorSpacePlan } = useQuery( + const { + data: vectorSpace, + isFetching: isFetchingVectorSpacePlan, + refetch: refetchVectorSpace, + } = useQuery( consoleQuery.features.vectorSpace.get.queryOptions({ enabled: shouldCheckVectorSpace }), ) const isCheckingVectorSpace = shouldCheckVectorSpace && !vectorSpace && isFetchingVectorSpacePlan + const isVectorSpaceUnavailable = + shouldCheckVectorSpace && plan.type === Plan.sandbox && !!vectorSpace?.usage_unknown const isVectorSpaceFull = - !!vectorSpace && vectorSpace.limit > 0 && vectorSpace.size >= vectorSpace.limit - const supportBatchUpload = !enableBilling || plan.type !== 'sandbox' + !!vectorSpace && + !vectorSpace.usage_unknown && + vectorSpace.limit > 0 && + vectorSpace.size >= vectorSpace.limit + const supportBatchUpload = !enableBilling || plan.type !== Plan.sandbox // UI state const { @@ -144,7 +157,7 @@ const CreateFormPipeline = () => { selectedFileIdsLength: selectedFileIds.length, onlineDriveFileList, isVectorSpaceFull, - isCheckingVectorSpace, + isCheckingVectorSpace: isCheckingVectorSpace || isVectorSpaceUnavailable, enableBilling, currentWorkspacePagesLength: currentWorkspace?.pages.length ?? 0, fileUploadConfig, @@ -242,8 +255,9 @@ const CreateFormPipeline = () => { datasourceType={datasourceType} pipelineNodes={(pipelineInfo?.graph.nodes || []) as Node[]} supportBatchUpload={supportBatchUpload} - localFileListLength={localFileList.length} isShowVectorSpaceFull={isShowVectorSpaceFull} + isShowVectorSpaceUnavailable={isVectorSpaceUnavailable} + isRetryingVectorSpace={isFetchingVectorSpacePlan} showSelect={showSelect} totalOptions={totalOptions} selectedOptions={selectedOptions} @@ -252,6 +266,7 @@ const CreateFormPipeline = () => { onSelectDataSource={handleSwitchDataSource} onCredentialChange={handleCredentialChange} onSelectAll={handleSelectAll} + onRetryVectorSpace={() => void refetchVectorSpace()} onNextStep={handleNextStep} /> )} diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx index a7d5da50ae5..55cecaf9f2b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx @@ -45,6 +45,22 @@ vi.mock('@/context/provider-context', () => ({ }), })) +vi.mock('@/app/components/datasets/common/vector-space-admission-alert', () => ({ + default: ({ + showUpgrade, + estimatedMb, + planLimitMb, + }: { + showUpgrade: boolean + estimatedMb: number + planLimitMb: number + }) => ( +
{`vector space admission alert ${estimatedMb}MB / ${planLimitMb}MB ${ + showUpgrade ? 'with upgrade' : 'without upgrade' + }`}
+ ), +})) + // Mock useIndexingStatusBatch hook let mockFetchIndexingStatus: Mock let mockIndexingStatusData: IndexingStatusResponse[] = [] @@ -323,13 +339,16 @@ describe('EmbeddingProcess', () => { expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() }) - it('should show completed status when all documents have error status', async () => { + it('should show the vector-space admission alert after processing completes', async () => { const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'error', error: 'Processing failed', + error_code: 'vector_space_estimate_exceeded', + estimated_vector_space_mb: 61, + vector_space_limit_mb: 50, }), ] const props = createDefaultProps({ documents: [doc1] }) @@ -340,6 +359,55 @@ describe('EmbeddingProcess', () => { }) expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() + expect( + screen.getByText('vector space admission alert 61MB / 50MB without upgrade'), + ).toBeInTheDocument() + }) + + it('should not show the vector-space alert for another indexing error', async () => { + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ + id: 'doc-1', + indexing_status: 'error', + error_code: null, + estimated_vector_space_mb: 61, + vector_space_limit_mb: 50, + }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + expect(screen.queryByText(/vector space admission alert/)).not.toBeInTheDocument() + }) + + it('should not suggest an upgrade to team users', async () => { + mockEnableBilling = true + mockPlanType = Plan.team + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ + id: 'doc-1', + indexing_status: 'error', + error_code: 'vector_space_estimate_exceeded', + estimated_vector_space_mb: 61, + vector_space_limit_mb: 50, + }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + expect( + screen.getByText('vector space admission alert 61MB / 50MB without upgrade'), + ).toBeInTheDocument() }) it('should show completed status when all documents are paused', async () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx index c5f109a2b99..34e0eb2bdf9 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx @@ -22,6 +22,7 @@ import PriorityLabel from '@/app/components/billing/priority-label' import { Plan } from '@/app/components/billing/type' import UpgradeBtn from '@/app/components/billing/upgrade-btn' import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon' +import VectorSpaceAdmissionAlert from '@/app/components/datasets/common/vector-space-admission-alert' import { useProviderContext } from '@/context/provider-context' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' import { DatasourceType } from '@/models/pipeline' @@ -112,6 +113,15 @@ const EmbeddingProcess = ({ ['completed', 'error', 'paused'].includes(indexingStatusDetail?.indexing_status || ''), ) }, [indexingStatusBatchDetail]) + const vectorSpaceAdmissionError = useMemo( + () => + indexingStatusBatchDetail.find( + (detail) => detail.error_code === 'vector_space_estimate_exceeded', + ), + [indexingStatusBatchDetail], + ) + const showUpgrade = + enableBilling && (plan.type === Plan.sandbox || plan.type === Plan.professional) const getSourceName = (id: string) => { const doc = documents.find((document) => document.id === id) @@ -155,6 +165,14 @@ const EmbeddingProcess = ({ )} {isEmbeddingCompleted && t(($) => $['embedding.completed'], { ns: 'datasetDocuments' })}
+ {vectorSpaceAdmissionError?.estimated_vector_space_mb != null && + vectorSpaceAdmissionError.vector_space_limit_mb != null && ( + + )} {enableBilling && plan.type !== Plan.team && (
diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx index d0e12496d7f..c2e548c7e6c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx @@ -254,8 +254,9 @@ describe('StepOneContent', () => { datasourceType: DatasourceType.localFile, pipelineNodes: mockPipelineNodes, supportBatchUpload: true, - localFileListLength: 0, isShowVectorSpaceFull: false, + isShowVectorSpaceUnavailable: false, + isRetryingVectorSpace: false, showSelect: false, totalOptions: 10, selectedOptions: 5, @@ -264,6 +265,7 @@ describe('StepOneContent', () => { onSelectDataSource: vi.fn(), onCredentialChange: vi.fn(), onSelectAll: vi.fn(), + onRetryVectorSpace: vi.fn(), onNextStep: vi.fn(), } @@ -326,14 +328,30 @@ describe('StepOneContent', () => { }) }) + describe('Conditional Rendering - VectorSpaceUnavailable', () => { + it('should render the retry action when vector space usage is unavailable', () => { + const onRetryVectorSpace = vi.fn() + render( + , + ) + + screen.getByRole('button', { name: 'common.operation.retry' }).click() + + expect(onRetryVectorSpace).toHaveBeenCalledOnce() + }) + }) + describe('Conditional Rendering - UpgradeCard', () => { - it('should render UpgradeCard when batch upload not supported and has local files', () => { + it('should render UpgradeCard immediately when batch upload is not supported', () => { render( , ) // UpgradeCard contains an upgrade button @@ -346,7 +364,6 @@ describe('StepOneContent', () => { {...defaultProps} supportBatchUpload={true} datasourceType={DatasourceType.localFile} - localFileListLength={3} />, ) // The upgrade card should not be present @@ -356,24 +373,7 @@ describe('StepOneContent', () => { it('should not render UpgradeCard when datasourceType is not localFile', () => { render( - , - ) - expect(screen.queryByTestId('upgrade-btn')).not.toBeInTheDocument() - }) - - it('should not render UpgradeCard when localFileListLength is 0', () => { - render( - , + , ) expect(screen.queryByTestId('upgrade-btn')).not.toBeInTheDocument() }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.tsx index c37804ed3b1..440c8a33b7d 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.tsx @@ -5,6 +5,7 @@ import type { Node } from '@/app/components/workflow/types' import { memo } from 'react' import Divider from '@/app/components/base/divider' import VectorSpaceFull from '@/app/components/billing/vector-space-full' +import VectorSpaceUnavailable from '@/app/components/billing/vector-space-unavailable' import LocalFile from '@/app/components/datasets/documents/create-from-pipeline/data-source/local-file' import OnlineDocuments from '@/app/components/datasets/documents/create-from-pipeline/data-source/online-documents' import OnlineDrive from '@/app/components/datasets/documents/create-from-pipeline/data-source/online-drive' @@ -19,8 +20,9 @@ type StepOneContentProps = { datasourceType: string | undefined pipelineNodes: Node[] supportBatchUpload: boolean - localFileListLength: number isShowVectorSpaceFull: boolean + isShowVectorSpaceUnavailable: boolean + isRetryingVectorSpace: boolean showSelect: boolean totalOptions: number | undefined selectedOptions: number | undefined @@ -29,6 +31,7 @@ type StepOneContentProps = { onSelectDataSource: (dataSource: Datasource) => void onCredentialChange: (credentialId: string) => void onSelectAll: (checked: boolean) => void + onRetryVectorSpace: () => void onNextStep: () => void } @@ -37,8 +40,9 @@ const StepOneContent = ({ datasourceType, pipelineNodes, supportBatchUpload, - localFileListLength, isShowVectorSpaceFull, + isShowVectorSpaceUnavailable, + isRetryingVectorSpace, showSelect, totalOptions, selectedOptions, @@ -47,10 +51,10 @@ const StepOneContent = ({ onSelectDataSource, onCredentialChange, onSelectAll, + onRetryVectorSpace, onNextStep, }: StepOneContentProps) => { - const showUpgradeCard = - !supportBatchUpload && datasourceType === DatasourceType.localFile && localFileListLength > 0 + const showUpgradeCard = !supportBatchUpload && datasourceType === DatasourceType.localFile return (
@@ -87,6 +91,9 @@ const StepOneContent = ({ /> )} {isShowVectorSpaceFull && } + {isShowVectorSpaceUnavailable && ( + + )} = ({ file, updateFile }) => { const fileUploader = useRef(null) const { data: fileUploadConfigResponse } = useFileUploadConfig() const fileUploadConfig = useMemo( - () => - fileUploadConfigResponse ?? { - file_size_limit: 15, - }, + () => ({ + ...fileUploadConfigResponse, + file_size_limit: + fileUploadConfigResponse?.knowledge_file_size_limit ?? + fileUploadConfigResponse?.file_size_limit ?? + 15, + }), [fileUploadConfigResponse], ) type UploadResult = Awaited> diff --git a/web/i18n/ar-TN/billing.json b/web/i18n/ar-TN/billing.json index 62becfc241a..e69f3e455dc 100644 --- a/web/i18n/ar-TN/billing.json +++ b/web/i18n/ar-TN/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "أحداث المشغل", "upgrade.addChunks.description": "لقد وصلت إلى الحد الأقصى لإضافة الأجزاء لهذا الخطة.", "upgrade.addChunks.title": "قم بالترقية لمواصلة إضافة المقاطع", - "upgrade.uploadMultipleFiles.description": "قم بتحميل المزيد من المستندات دفعة واحدة لتوفير الوقت وتحسين الكفاءة.", - "upgrade.uploadMultipleFiles.title": "قم بالترقية لفتح ميزة تحميل المستندات دفعة واحدة", + "upgrade.uploadMultipleFiles.description": "ارفع عدة مستندات دفعة واحدة وزِد الحد الأقصى لحجم كل ملف إلى 50 MB.", + "upgrade.uploadMultipleFiles.title": "قم بالترقية لإتاحة رفع الملفات دفعة واحدة والملفات الأكبر حجمًا", "upgrade.uploadMultiplePages.description": "لقد وصلت إلى حد التحميل — يمكن اختيار ورفع مستند واحد فقط في كل مرة على الخطة الحالية الخاصة بك.", "upgrade.uploadMultiplePages.title": "قم بالترقية لتحميل عدة مستندات دفعة واحدة", "upgrade.workflowRestore.description": "استعادة إصدارات سير العمل غير متاحة في خطتك الحالية.", diff --git a/web/i18n/ar-TN/dataset-documents.json b/web/i18n/ar-TN/dataset-documents.json index 923022954a5..e5ce0f59f04 100644 --- a/web/i18n/ar-TN/dataset-documents.json +++ b/web/i18n/ar-TN/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "أقصى طول للقطعة", "embedding.segments": "الفقرات", "embedding.textCleaning": "قواعد المعالجة المسبقة للنص", + "embedding.vectorSpaceEstimateExceeded.description": "تم استلام المستند، لكن مساحة تخزين المتجهات المقدّرة تبلغ {{estimated}} MB، وتتجاوز حد خطتك البالغ {{limit}} MB، لذلك لم تتم كتابة أي متجهات. إذا كان مستند آخر قيد المعالجة أو تعذّرت معالجته للتو، فأعد المحاولة لاحقًا؛ وإلا فقلّل حجم الملف أو عدد المقاطع.", + "embedding.vectorSpaceEstimateExceeded.title": "تتجاوز مساحة تخزين المتجهات المقدّرة بعد الرفع سعة خطتك", "embedding.waiting": "انتظار التضمين...", "list.action.addButton": "إضافة قطعة", "list.action.archive": "أرشيف", diff --git a/web/i18n/de-DE/billing.json b/web/i18n/de-DE/billing.json index ac504d3ce47..0679e969d43 100644 --- a/web/i18n/de-DE/billing.json +++ b/web/i18n/de-DE/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "AUSLÖSEEREIGNISSE", "upgrade.addChunks.description": "Sie haben das Limit für das Hinzufügen von Abschnitten in diesem Tarif erreicht.", "upgrade.addChunks.title": "Upgraden, um weiterhin Abschnitte hinzuzufügen", - "upgrade.uploadMultipleFiles.description": "Lade mehrere Dokumente gleichzeitig hoch, um Zeit zu sparen und die Effizienz zu steigern.", - "upgrade.uploadMultipleFiles.title": "Upgrade, um den Massen-Upload von Dokumenten freizuschalten", + "upgrade.uploadMultipleFiles.description": "Laden Sie mehrere Dokumente gleichzeitig hoch und erhöhen Sie die maximale Größe pro Datei auf 50 MB.", + "upgrade.uploadMultipleFiles.title": "Upgrade durchführen, um Batch-Uploads und größere Dateien freizuschalten", "upgrade.uploadMultiplePages.description": "Sie haben das Upload-Limit erreicht – in Ihrem aktuellen Tarif kann jeweils nur ein Dokument ausgewählt und hochgeladen werden.", "upgrade.uploadMultiplePages.title": "Upgrade, um mehrere Dokumente gleichzeitig hochzuladen", "upgrade.workflowRestore.description": "Die Wiederherstellung von Workflow-Versionen ist in Ihrem aktuellen Tarif nicht verfügbar.", diff --git a/web/i18n/de-DE/dataset-documents.json b/web/i18n/de-DE/dataset-documents.json index a73804d0924..4e527bc6726 100644 --- a/web/i18n/de-DE/dataset-documents.json +++ b/web/i18n/de-DE/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "Chunk-Länge", "embedding.segments": "Absätze", "embedding.textCleaning": "Textvordefinition und -bereinigung", + "embedding.vectorSpaceEstimateExceeded.description": "Dokument empfangen. Der geschätzte Vektorspeicherbedarf von {{estimated}} MB überschreitet jedoch das Limit Ihres Tarifs von {{limit}} MB. Daher wurden keine Vektoren gespeichert. Wenn gerade ein anderes Dokument verarbeitet wird oder dessen Verarbeitung soeben fehlgeschlagen ist, versuchen Sie es später erneut. Andernfalls reduzieren Sie die Dateigröße oder die Anzahl der Chunks.", + "embedding.vectorSpaceEstimateExceeded.title": "Der geschätzte Vektorspeicher nach dem Upload überschreitet Ihre Tarifkapazität", "embedding.waiting": "Einbettung wartet...", "list.action.addButton": "Chunk hinzufügen", "list.action.archive": "Archivieren", diff --git a/web/i18n/en-US/billing.json b/web/i18n/en-US/billing.json index 87645d7d1c6..f17e2a18e8c 100644 --- a/web/i18n/en-US/billing.json +++ b/web/i18n/en-US/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "TRIGGER EVENTS", "upgrade.addChunks.description": "You’ve reached the limit of adding chunks for this plan.", "upgrade.addChunks.title": "Upgrade to continue adding chunks", - "upgrade.uploadMultipleFiles.description": "Batch-upload more documents at once to save time and improve efficiency.", - "upgrade.uploadMultipleFiles.title": "Upgrade to unlock batch document upload", + "upgrade.uploadMultipleFiles.description": "Upload multiple documents at once and increase the maximum size per file to 50 MB.", + "upgrade.uploadMultipleFiles.title": "Upgrade to unlock batch uploads and larger files", "upgrade.uploadMultiplePages.description": "You’ve reached the upload limit — only one document can be selected and uploaded at a time on your current plan.", "upgrade.uploadMultiplePages.title": "Upgrade to upload multiple documents at once", "upgrade.workflowRestore.description": "Workflow version restoration is not available on your current plan.", diff --git a/web/i18n/en-US/dataset-documents.json b/web/i18n/en-US/dataset-documents.json index 0ae9bd1c4c8..1ef93b8b5fe 100644 --- a/web/i18n/en-US/dataset-documents.json +++ b/web/i18n/en-US/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "Maximum Chunk Length", "embedding.segments": "Paragraphs", "embedding.textCleaning": "Text Preprocessing Rules", + "embedding.vectorSpaceEstimateExceeded.description": "Document received, but estimated vector storage is {{estimated}} MB, above your plan limit of {{limit}} MB, so no vectors were written. If another document is processing or just failed, try again later; otherwise, reduce the file size or number of chunks.", + "embedding.vectorSpaceEstimateExceeded.title": "Estimated vector storage after upload exceeds your plan capacity", "embedding.waiting": "Embedding waiting...", "list.action.addButton": "Add chunk", "list.action.archive": "Archive", diff --git a/web/i18n/es-ES/billing.json b/web/i18n/es-ES/billing.json index 4d3b2a5f1b0..926cf25f0a5 100644 --- a/web/i18n/es-ES/billing.json +++ b/web/i18n/es-ES/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "EVENTOS DESENCADENANTES", "upgrade.addChunks.description": "Has alcanzado el límite de agregar fragmentos para este plan.", "upgrade.addChunks.title": "Actualiza para seguir agregando fragmentos", - "upgrade.uploadMultipleFiles.description": "Carga en lote más documentos a la vez para ahorrar tiempo y mejorar la eficiencia.", - "upgrade.uploadMultipleFiles.title": "Actualiza para desbloquear la carga de documentos en lote", + "upgrade.uploadMultipleFiles.description": "Sube varios documentos a la vez y aumenta el tamaño máximo por archivo a 50 MB.", + "upgrade.uploadMultipleFiles.title": "Mejora tu plan para desbloquear las cargas por lotes y los archivos de mayor tamaño", "upgrade.uploadMultiplePages.description": "Has alcanzado el límite de carga: solo se puede seleccionar y subir un documento a la vez en tu plan actual.", "upgrade.uploadMultiplePages.title": "Actualiza para subir varios documentos a la vez", "upgrade.workflowRestore.description": "La restauración de versiones del flujo de trabajo no está disponible en tu plan actual.", diff --git a/web/i18n/es-ES/dataset-documents.json b/web/i18n/es-ES/dataset-documents.json index 346a4096409..1343f4d5a67 100644 --- a/web/i18n/es-ES/dataset-documents.json +++ b/web/i18n/es-ES/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "Longitud de fragmentos", "embedding.segments": "Párrafos", "embedding.textCleaning": "Definición de texto y limpieza previa", + "embedding.vectorSpaceEstimateExceeded.description": "Documento recibido, pero el almacenamiento vectorial estimado es de {{estimated}} MB y supera el límite de {{limit}} MB de tu plan, por lo que no se guardó ningún vector. Si se está procesando otro documento o acaba de producirse un error, inténtalo de nuevo más tarde. De lo contrario, reduce el tamaño del archivo o el número de fragmentos.", + "embedding.vectorSpaceEstimateExceeded.title": "El almacenamiento vectorial estimado tras la carga supera la capacidad de tu plan", "embedding.waiting": "Esperando incrustación...", "list.action.addButton": "Agregar fragmento", "list.action.archive": "Archivar", diff --git a/web/i18n/fa-IR/billing.json b/web/i18n/fa-IR/billing.json index bc03706f9c8..086250af5c6 100644 --- a/web/i18n/fa-IR/billing.json +++ b/web/i18n/fa-IR/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "رویدادهای محرک", "upgrade.addChunks.description": "شما به حد اضافه کردن بخش‌ها برای این طرح رسیده‌اید.", "upgrade.addChunks.title": "برای ادامه افزودن بخش‌ها ارتقا دهید", - "upgrade.uploadMultipleFiles.description": "بارگذاری دسته‌ای چندین سند به‌طور همزمان برای صرفه‌جویی در زمان و افزایش کارایی.", - "upgrade.uploadMultipleFiles.title": "ارتقا دهید تا امکان بارگذاری دسته‌ای اسناد فعال شود", + "upgrade.uploadMultipleFiles.description": "چند سند را به‌طور هم‌زمان بارگذاری کنید و حداکثر اندازه هر فایل را به 50 MB افزایش دهید.", + "upgrade.uploadMultipleFiles.title": "برای فعال‌کردن بارگذاری گروهی و فایل‌های بزرگ‌تر، ارتقا دهید", "upgrade.uploadMultiplePages.description": "شما به حد آپلود رسیده‌اید — در طرح فعلی خود تنها می‌توانید یک سند را در هر بار انتخاب و آپلود کنید.", "upgrade.uploadMultiplePages.title": "ارتقا برای آپلود همزمان چندین سند", "upgrade.workflowRestore.description": "بازیابی نسخه‌های گردش‌کار در طرح فعلی شما در دسترس نیست.", diff --git a/web/i18n/fa-IR/dataset-documents.json b/web/i18n/fa-IR/dataset-documents.json index 02f6ed3530c..f87d1edc656 100644 --- a/web/i18n/fa-IR/dataset-documents.json +++ b/web/i18n/fa-IR/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "طول قطعات", "embedding.segments": "پاراگراف‌ها", "embedding.textCleaning": "پیش‌تعریف و تمیز کردن متن", + "embedding.vectorSpaceEstimateExceeded.description": "سند دریافت شد، اما فضای ذخیره‌سازی تخمینی بردارها {{estimated}} MB است که از سقف {{limit}} MB طرح شما بیشتر است؛ بنابراین هیچ برداری ذخیره نشد. اگر سند دیگری در حال پردازش است یا پردازش آن به‌تازگی ناموفق بوده، بعداً دوباره تلاش کنید؛ در غیر این صورت، حجم فایل یا تعداد بخش‌ها را کاهش دهید.", + "embedding.vectorSpaceEstimateExceeded.title": "فضای ذخیره‌سازی برداری تخمینی پس از بارگذاری از ظرفیت طرح شما بیشتر است", "embedding.waiting": "در حال انتظار برای جاسازی...", "list.action.addButton": "اضافه کردن قطعه", "list.action.archive": "بایگانی", diff --git a/web/i18n/fr-FR/billing.json b/web/i18n/fr-FR/billing.json index 607702b3569..68b9f2fdfe5 100644 --- a/web/i18n/fr-FR/billing.json +++ b/web/i18n/fr-FR/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "ÉVÉNEMENTS DÉCLENCHEURS", "upgrade.addChunks.description": "Vous avez atteint la limite d'ajout de morceaux pour ce plan.", "upgrade.addChunks.title": "Mettez à niveau pour continuer à ajouter des morceaux", - "upgrade.uploadMultipleFiles.description": "Téléchargez plusieurs documents à la fois pour gagner du temps et améliorer l'efficacité.", - "upgrade.uploadMultipleFiles.title": "Passez à la version supérieure pour débloquer le téléchargement de documents en lot", + "upgrade.uploadMultipleFiles.description": "Importez plusieurs documents à la fois et augmentez la taille maximale par fichier à 50 MB.", + "upgrade.uploadMultipleFiles.title": "Passez à une offre supérieure pour débloquer les importations groupées et les fichiers plus volumineux", "upgrade.uploadMultiplePages.description": "Vous avez atteint la limite de téléchargement — un seul document peut être sélectionné et téléchargé à la fois avec votre abonnement actuel.", "upgrade.uploadMultiplePages.title": "Passez à la version supérieure pour télécharger plusieurs documents à la fois", "upgrade.workflowRestore.description": "La restauration des versions du workflow n'est pas disponible avec votre plan actuel.", diff --git a/web/i18n/fr-FR/dataset-documents.json b/web/i18n/fr-FR/dataset-documents.json index eccd0b26867..d13b96d26d9 100644 --- a/web/i18n/fr-FR/dataset-documents.json +++ b/web/i18n/fr-FR/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "Longueur des morceaux", "embedding.segments": "Paragraphes", "embedding.textCleaning": "Pré-définition du texte et nettoyage", + "embedding.vectorSpaceEstimateExceeded.description": "Document reçu, mais le stockage vectoriel estimé à {{estimated}} MB dépasse la limite de votre forfait de {{limit}} MB. Aucun vecteur n’a donc été enregistré. Si un autre document est en cours de traitement ou vient d’échouer, réessayez plus tard. Sinon, réduisez la taille du fichier ou le nombre de segments.", + "embedding.vectorSpaceEstimateExceeded.title": "Le stockage vectoriel estimé après l’import dépasse la capacité de votre forfait", "embedding.waiting": "En attente d'incorporation...", "list.action.addButton": "Ajouter un morceau", "list.action.archive": "Archive", diff --git a/web/i18n/hi-IN/billing.json b/web/i18n/hi-IN/billing.json index 7d11f271e99..261cae2e376 100644 --- a/web/i18n/hi-IN/billing.json +++ b/web/i18n/hi-IN/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "ट्रिगर घटनाएँ", "upgrade.addChunks.description": "आप इस योजना के लिए टुकड़े जोड़ने की सीमा तक पहुँच चुके हैं।", "upgrade.addChunks.title": "अधिक चंक्स जोड़ने के लिए अपग्रेड करें", - "upgrade.uploadMultipleFiles.description": "समय बचाने और कार्यक्षमता बढ़ाने के लिए एक बार में अधिक दस्तावेज़ बैच-अपलोड करें।", - "upgrade.uploadMultipleFiles.title": "बैच दस्तावेज़ अपलोड अनलॉक करने के लिए अपग्रेड करें", + "upgrade.uploadMultipleFiles.description": "एक साथ कई दस्तावेज़ अपलोड करें और प्रति फ़ाइल अधिकतम साइज़ सीमा बढ़ाकर 50 MB करें।", + "upgrade.uploadMultipleFiles.title": "बैच अपलोड और बड़ी फ़ाइलों की सुविधा अनलॉक करने के लिए अपग्रेड करें", "upgrade.uploadMultiplePages.description": "आपने अपलोड की सीमा तक पहुँच लिया है — आपके वर्तमान प्लान पर एक समय में केवल एक ही दस्तावेज़ चुना और अपलोड किया जा सकता है।", "upgrade.uploadMultiplePages.title": "एक बार में कई दस्तावेज़ अपलोड करने के लिए अपग्रेड करें", "upgrade.workflowRestore.description": "आपकी वर्तमान योजना में वर्कफ़्लो संस्करण पुनर्स्थापना उपलब्ध नहीं है।", diff --git a/web/i18n/hi-IN/dataset-documents.json b/web/i18n/hi-IN/dataset-documents.json index 86cb6480b24..c74ec2bcbdc 100644 --- a/web/i18n/hi-IN/dataset-documents.json +++ b/web/i18n/hi-IN/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "खंडों की लंबाई", "embedding.segments": "पैराग्राफ", "embedding.textCleaning": "पाठ पूर्व-परिभाषा और सफाई", + "embedding.vectorSpaceEstimateExceeded.description": "दस्तावेज़ मिल गया, लेकिन अनुमानित वेक्टर स्टोरेज {{estimated}} MB है, जो आपके प्लान की {{limit}} MB सीमा से अधिक है, इसलिए कोई वेक्टर नहीं लिखा गया। यदि कोई अन्य दस्तावेज़ प्रोसेस हो रहा है या हाल ही में विफल हुआ है, तो बाद में फिर कोशिश करें; अन्यथा फ़ाइल का आकार या खंडों की संख्या कम करें।", + "embedding.vectorSpaceEstimateExceeded.title": "अपलोड के बाद अनुमानित वेक्टर स्टोरेज आपके प्लान की क्षमता से अधिक है", "embedding.waiting": "इनपुट की प्रतीक्षा कर रहा हूं...", "list.action.addButton": "खंड जोड़ें", "list.action.archive": "संग्रहीत करें", diff --git a/web/i18n/id-ID/billing.json b/web/i18n/id-ID/billing.json index 421382d401f..208e4935703 100644 --- a/web/i18n/id-ID/billing.json +++ b/web/i18n/id-ID/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "PERISTIWA PEMICU", "upgrade.addChunks.description": "Anda telah mencapai batas penambahan potongan untuk paket ini.", "upgrade.addChunks.title": "Tingkatkan untuk terus menambahkan potongan", - "upgrade.uploadMultipleFiles.description": "Unggah lebih banyak dokumen sekaligus untuk menghemat waktu dan meningkatkan efisiensi.", - "upgrade.uploadMultipleFiles.title": "Tingkatkan untuk membuka unggahan dokumen batch", + "upgrade.uploadMultipleFiles.description": "Upload beberapa dokumen sekaligus dan tingkatkan ukuran maksimum per file menjadi 50 MB.", + "upgrade.uploadMultipleFiles.title": "Upgrade untuk membuka upload batch dan file berukuran lebih besar", "upgrade.uploadMultiplePages.description": "Anda telah mencapai batas unggah — hanya satu dokumen yang dapat dipilih dan diunggah sekaligus dengan paket Anda saat ini.", "upgrade.uploadMultiplePages.title": "Tingkatkan untuk mengunggah beberapa dokumen sekaligus", "upgrade.workflowRestore.description": "Pemulihan versi workflow tidak tersedia di paket Anda saat ini.", diff --git a/web/i18n/id-ID/dataset-documents.json b/web/i18n/id-ID/dataset-documents.json index b257f7b689a..51000f1ac09 100644 --- a/web/i18n/id-ID/dataset-documents.json +++ b/web/i18n/id-ID/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "Panjang Potongan Maksimum", "embedding.segments": "Paragraf", "embedding.textCleaning": "Aturan Prapemrosesan Teks", + "embedding.vectorSpaceEstimateExceeded.description": "Dokumen diterima, tetapi estimasi penyimpanan vektornya {{estimated}} MB, melebihi batas paket Anda sebesar {{limit}} MB, sehingga tidak ada vektor yang ditulis. Jika dokumen lain sedang diproses atau baru saja gagal, coba lagi nanti; jika tidak, kurangi ukuran file atau jumlah chunk.", + "embedding.vectorSpaceEstimateExceeded.title": "Perkiraan penyimpanan vektor setelah diunggah melebihi kapasitas paket Anda", "embedding.waiting": "Menunggu embedding...", "list.action.addButton": "Tambahkan potongan", "list.action.archive": "Mengarsipkan", diff --git a/web/i18n/it-IT/billing.json b/web/i18n/it-IT/billing.json index 3dcb34223e5..fadc2fa849a 100644 --- a/web/i18n/it-IT/billing.json +++ b/web/i18n/it-IT/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "EVENTI DI ATTIVAZIONE", "upgrade.addChunks.description": "Hai raggiunto il limite di aggiunta di blocchi per questo piano.", "upgrade.addChunks.title": "Aggiorna per continuare ad aggiungere blocchi", - "upgrade.uploadMultipleFiles.description": "Carica più documenti contemporaneamente per risparmiare tempo e migliorare l'efficienza.", - "upgrade.uploadMultipleFiles.title": "Aggiorna per sbloccare il caricamento di documenti in batch", + "upgrade.uploadMultipleFiles.description": "Carica più documenti contemporaneamente e aumenta la dimensione massima per file a 50 MB.", + "upgrade.uploadMultipleFiles.title": "Passa a un piano superiore per sbloccare i caricamenti in batch e i file di dimensioni maggiori", "upgrade.uploadMultiplePages.description": "Hai raggiunto il limite di caricamento: sul tuo piano attuale può essere selezionato e caricato un solo documento alla volta.", "upgrade.uploadMultiplePages.title": "Aggiorna per caricare più documenti contemporaneamente", "upgrade.workflowRestore.description": "Il ripristino delle versioni del workflow non è disponibile nel tuo piano attuale.", diff --git a/web/i18n/it-IT/dataset-documents.json b/web/i18n/it-IT/dataset-documents.json index 4e08a9bb3a1..2e2120ba78a 100644 --- a/web/i18n/it-IT/dataset-documents.json +++ b/web/i18n/it-IT/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "Lunghezza dei segmenti", "embedding.segments": "Paragrafi", "embedding.textCleaning": "Pre-definizione e pulizia del testo", + "embedding.vectorSpaceEstimateExceeded.description": "Documento ricevuto, ma lo spazio di archiviazione vettoriale stimato è di {{estimated}} MB e supera il limite del piano di {{limit}} MB, quindi non è stato salvato alcun vettore. Se è in corso l’elaborazione di un altro documento o questa è appena fallita, riprova più tardi. Altrimenti, riduci le dimensioni del file o il numero di segmenti.", + "embedding.vectorSpaceEstimateExceeded.title": "Lo spazio vettoriale stimato dopo il caricamento supera la capacità del tuo piano", "embedding.waiting": "Attesa dell'incorporamento...", "list.action.addButton": "Aggiungi blocco", "list.action.archive": "Archivia", diff --git a/web/i18n/ja-JP/billing.json b/web/i18n/ja-JP/billing.json index 33c4da7c7a0..0d72e5c46b5 100644 --- a/web/i18n/ja-JP/billing.json +++ b/web/i18n/ja-JP/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "TRIGGER EVENTS", "upgrade.addChunks.description": "このプランでは、チャンク追加の上限に達しています。", "upgrade.addChunks.title": "アップグレードして、チャンクを引き続き追加できるようにしてください。", - "upgrade.uploadMultipleFiles.description": "複数のドキュメントを一度にバッチアップロードすることで、時間を節約し、作業効率を向上できます。", - "upgrade.uploadMultipleFiles.title": "一括ドキュメントアップロード機能を解放するにはアップグレードが必要です", + "upgrade.uploadMultipleFiles.description": "複数のドキュメントを一度にアップロードでき、1ファイルあたりのサイズ上限も50 MBに引き上げられます。", + "upgrade.uploadMultipleFiles.title": "アップグレードして一括アップロードと大容量ファイルを利用", "upgrade.uploadMultiplePages.description": "現在のプランではアップロード上限に達しています。1回の操作で選択・アップロードできるドキュメントは1つのみです。", "upgrade.uploadMultiplePages.title": "複数ドキュメントを一度にアップロードするにはアップグレード", "upgrade.workflowRestore.description": "現在のプランでは、ワークフローバージョンの復元は利用できません。", diff --git a/web/i18n/ja-JP/dataset-documents.json b/web/i18n/ja-JP/dataset-documents.json index 19f1c2298e9..1661a4034c2 100644 --- a/web/i18n/ja-JP/dataset-documents.json +++ b/web/i18n/ja-JP/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "最大なチャンクの長さ", "embedding.segments": "段落", "embedding.textCleaning": "テキストの前処理ルール", + "embedding.vectorSpaceEstimateExceeded.description": "ドキュメントを受け取りましたが、ベクトルストレージの推定使用量({{estimated}} MB)がプラン上限({{limit}} MB)を超えるため、ベクトルは書き込まれませんでした。別のドキュメントが処理中または処理に失敗した直後の場合は、しばらくしてから再試行してください。それ以外の場合は、ファイルサイズまたはチャンク数を減らしてください。", + "embedding.vectorSpaceEstimateExceeded.title": "アップロード後の推定ベクトルストレージがプラン容量を超えています", "embedding.waiting": "埋め込み待機中...", "list.action.addButton": "チャンクを追加", "list.action.archive": "アーカイブ", diff --git a/web/i18n/ko-KR/billing.json b/web/i18n/ko-KR/billing.json index 89b8c7ac4f1..a0836737444 100644 --- a/web/i18n/ko-KR/billing.json +++ b/web/i18n/ko-KR/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "트리거 이벤트", "upgrade.addChunks.description": "이 요금제에서는 더 이상 청크를 추가할 수 있는 한도에 도달했습니다.", "upgrade.addChunks.title": "계속해서 조각을 추가하려면 업그레이드하세요", - "upgrade.uploadMultipleFiles.description": "한 번에 더 많은 문서를 일괄 업로드하여 시간 절약과 효율성을 높이세요.", - "upgrade.uploadMultipleFiles.title": "업그레이드하여 대량 문서 업로드 기능 잠금 해제", + "upgrade.uploadMultipleFiles.description": "여러 문서를 한 번에 업로드하고 파일당 최대 크기를 50MB로 늘리세요.", + "upgrade.uploadMultipleFiles.title": "업그레이드하여 일괄 업로드와 대용량 파일을 이용하세요", "upgrade.uploadMultiplePages.description": "업로드 한도에 도달했습니다 — 현재 요금제에서는 한 번에 한 개의 문서만 선택하고 업로드할 수 있습니다.", "upgrade.uploadMultiplePages.title": "한 번에 여러 문서를 업로드하려면 업그레이드하세요", "upgrade.workflowRestore.description": "현재 플랜에서는 워크플로 버전 복원을 사용할 수 없습니다.", diff --git a/web/i18n/ko-KR/dataset-documents.json b/web/i18n/ko-KR/dataset-documents.json index 6c15635c540..adc8bdef35a 100644 --- a/web/i18n/ko-KR/dataset-documents.json +++ b/web/i18n/ko-KR/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "청크의 길이", "embedding.segments": "세그먼트", "embedding.textCleaning": "텍스트 전처리", + "embedding.vectorSpaceEstimateExceeded.description": "문서를 받았지만 예상 벡터 저장 공간이 {{estimated}} MB로 요금제 한도인 {{limit}} MB를 초과하여 벡터가 기록되지 않았습니다. 다른 문서가 처리 중이거나 방금 처리에 실패했다면 잠시 후 다시 시도하세요. 그렇지 않으면 파일 크기나 청크 수를 줄이세요.", + "embedding.vectorSpaceEstimateExceeded.title": "업로드 후 예상 벡터 저장 공간이 요금제 용량을 초과합니다", "embedding.waiting": "임베딩 대기 중...", "list.action.addButton": "청크 추가", "list.action.archive": "아카이브", diff --git a/web/i18n/nl-NL/billing.json b/web/i18n/nl-NL/billing.json index 8ce1f2be3f3..d0d5734e80a 100644 --- a/web/i18n/nl-NL/billing.json +++ b/web/i18n/nl-NL/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "TRIGGER EVENTS", "upgrade.addChunks.description": "You’ve reached the limit of adding chunks for this plan.", "upgrade.addChunks.title": "Upgrade to continue adding chunks", - "upgrade.uploadMultipleFiles.description": "Batch-upload more documents at once to save time and improve efficiency.", - "upgrade.uploadMultipleFiles.title": "Upgrade to unlock batch document upload", + "upgrade.uploadMultipleFiles.description": "Upload meerdere documenten tegelijk en verhoog de maximale bestandsgrootte naar 50 MB.", + "upgrade.uploadMultipleFiles.title": "Upgrade je abonnement om batchuploads en grotere bestanden te ontgrendelen", "upgrade.uploadMultiplePages.description": "You’ve reached the upload limit — only one document can be selected and uploaded at a time on your current plan.", "upgrade.uploadMultiplePages.title": "Upgrade to upload multiple documents at once", "upgrade.workflowRestore.description": "Het herstellen van workflowversies is niet beschikbaar in je huidige abonnement.", diff --git a/web/i18n/nl-NL/dataset-documents.json b/web/i18n/nl-NL/dataset-documents.json index 0ae9bd1c4c8..8e1cc80ddd3 100644 --- a/web/i18n/nl-NL/dataset-documents.json +++ b/web/i18n/nl-NL/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "Maximum Chunk Length", "embedding.segments": "Paragraphs", "embedding.textCleaning": "Text Preprocessing Rules", + "embedding.vectorSpaceEstimateExceeded.description": "Document ontvangen, maar de geschatte vectoropslag van {{estimated}} MB overschrijdt de limiet van uw abonnement van {{limit}} MB. Er zijn daarom geen vectoren opgeslagen. Als een ander document wordt verwerkt of de verwerking zojuist is mislukt, probeer het dan later opnieuw. Verklein anders het bestand of verminder het aantal chunks.", + "embedding.vectorSpaceEstimateExceeded.title": "De geschatte vectoropslag na het uploaden overschrijdt de capaciteit van je abonnement", "embedding.waiting": "Embedding waiting...", "list.action.addButton": "Add chunk", "list.action.archive": "Archive", diff --git a/web/i18n/pl-PL/billing.json b/web/i18n/pl-PL/billing.json index b8c6a9725b9..0e739d12809 100644 --- a/web/i18n/pl-PL/billing.json +++ b/web/i18n/pl-PL/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "WYDARZENIA WYZWALAJĄCE", "upgrade.addChunks.description": "Osiągnąłeś limit dodawania fragmentów w tym planie.", "upgrade.addChunks.title": "Uaktualnij, aby kontynuować dodawanie fragmentów", - "upgrade.uploadMultipleFiles.description": "Przesyłaj wiele dokumentów jednocześnie, aby zaoszczędzić czas i zwiększyć wydajność.", - "upgrade.uploadMultipleFiles.title": "Uaktualnij, aby odblokować przesyłanie dokumentów wsadowych", + "upgrade.uploadMultipleFiles.description": "Przesyłaj wiele dokumentów jednocześnie i zwiększ maksymalny rozmiar pojedynczego pliku do 50 MB.", + "upgrade.uploadMultipleFiles.title": "Przejdź na wyższy plan, aby odblokować przesyłanie zbiorcze i większe pliki", "upgrade.uploadMultiplePages.description": "Osiągnąłeś limit przesyłania — w ramach obecnego planu można wybrać i przesłać tylko jeden dokument naraz.", "upgrade.uploadMultiplePages.title": "Przejdź na wyższą wersję, aby przesyłać wiele dokumentów jednocześnie", "upgrade.workflowRestore.description": "Przywracanie wersji przepływu pracy nie jest dostępne w obecnym planie.", diff --git a/web/i18n/pl-PL/dataset-documents.json b/web/i18n/pl-PL/dataset-documents.json index 77bfb4b3578..4f36dfced1e 100644 --- a/web/i18n/pl-PL/dataset-documents.json +++ b/web/i18n/pl-PL/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "Długość fragmentów", "embedding.segments": "Akapity", "embedding.textCleaning": "Predefinicja tekstu i czyszczenie", + "embedding.vectorSpaceEstimateExceeded.description": "Dokument odebrano, ale szacowane użycie pamięci wektorowej wynosi {{estimated}} MB i przekracza limit planu {{limit}} MB, dlatego nie zapisano żadnych wektorów. Jeśli inny dokument jest przetwarzany lub jego przetwarzanie właśnie się nie powiodło, spróbuj ponownie później. W przeciwnym razie zmniejsz rozmiar pliku lub liczbę fragmentów.", + "embedding.vectorSpaceEstimateExceeded.title": "Szacowane wykorzystanie pamięci wektorowej po przesłaniu przekracza limit Twojego planu", "embedding.waiting": "Oczekiwanie na osadzenie...", "list.action.addButton": "Dodaj fragment", "list.action.archive": "Archiwum", diff --git a/web/i18n/pt-BR/billing.json b/web/i18n/pt-BR/billing.json index adfa8511320..f2165017bc3 100644 --- a/web/i18n/pt-BR/billing.json +++ b/web/i18n/pt-BR/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "EVENTOS DE GATILHO", "upgrade.addChunks.description": "Você atingiu o limite de adição de blocos para este plano.", "upgrade.addChunks.title": "Faça upgrade para continuar adicionando blocos", - "upgrade.uploadMultipleFiles.description": "Faça upload de mais documentos de uma vez para economizar tempo e aumentar a eficiência.", - "upgrade.uploadMultipleFiles.title": "Atualize para desbloquear o envio de documentos em lote", + "upgrade.uploadMultipleFiles.description": "Envie vários documentos de uma só vez e aumente o tamanho máximo por arquivo para 50 MB.", + "upgrade.uploadMultipleFiles.title": "Faça upgrade para liberar uploads em lote e arquivos maiores", "upgrade.uploadMultiplePages.description": "Você atingiu o limite de upload — apenas um documento pode ser selecionado e enviado por vez no seu plano atual.", "upgrade.uploadMultiplePages.title": "Atualize para enviar vários documentos de uma vez", "upgrade.workflowRestore.description": "A restauração de versões do fluxo de trabalho não está disponível no seu plano atual.", diff --git a/web/i18n/pt-BR/dataset-documents.json b/web/i18n/pt-BR/dataset-documents.json index 4cacdd37cfd..495eda3c725 100644 --- a/web/i18n/pt-BR/dataset-documents.json +++ b/web/i18n/pt-BR/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "Comprimento dos fragmentos", "embedding.segments": "Parágrafos", "embedding.textCleaning": "Definição prévia e limpeza de texto", + "embedding.vectorSpaceEstimateExceeded.description": "Documento recebido, mas o armazenamento vetorial estimado é de {{estimated}} MB, acima do limite de {{limit}} MB do seu plano. Por isso, nenhum vetor foi gravado. Se outro documento estiver sendo processado ou tiver acabado de falhar, tente novamente mais tarde. Caso contrário, reduza o tamanho do arquivo ou o número de blocos.", + "embedding.vectorSpaceEstimateExceeded.title": "O armazenamento vetorial estimado após o upload excede a capacidade do seu plano", "embedding.waiting": "Aguarde a incorporação...", "list.action.addButton": "Adicionar fragmento", "list.action.archive": "Arquivar", diff --git a/web/i18n/ro-RO/billing.json b/web/i18n/ro-RO/billing.json index 0ead2f3ae9d..7f8c0f523d7 100644 --- a/web/i18n/ro-RO/billing.json +++ b/web/i18n/ro-RO/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "EVENIMENTE DECLANȘATOARE", "upgrade.addChunks.description": "Ai atins limita de adăugare a segmentelor pentru acest plan.", "upgrade.addChunks.title": "Actualizează pentru a continua să adaugi segmente", - "upgrade.uploadMultipleFiles.description": "Încărcați mai multe documente simultan pentru a economisi timp și a îmbunătăți eficiența.", - "upgrade.uploadMultipleFiles.title": "Fă upgrade pentru a debloca încărcarea documentelor în masă", + "upgrade.uploadMultipleFiles.description": "Încarcă mai multe documente simultan și mărește dimensiunea maximă pentru fiecare fișier la 50 MB.", + "upgrade.uploadMultipleFiles.title": "Treci la un plan superior pentru a debloca încărcările în lot și fișierele mai mari", "upgrade.uploadMultiplePages.description": "Ați atins limita de încărcare — poate fi selectat și încărcat doar un singur document odată în planul dvs. actual.", "upgrade.uploadMultiplePages.title": "Actualizează pentru a încărca mai multe documente odată", "upgrade.workflowRestore.description": "Restaurarea versiunilor fluxului de lucru nu este disponibilă în planul tău actual.", diff --git a/web/i18n/ro-RO/dataset-documents.json b/web/i18n/ro-RO/dataset-documents.json index 3d1d663a605..c7d43105298 100644 --- a/web/i18n/ro-RO/dataset-documents.json +++ b/web/i18n/ro-RO/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "Lungime segmente", "embedding.segments": "Paragrafe", "embedding.textCleaning": "Pre-definiție și curățare text", + "embedding.vectorSpaceEstimateExceeded.description": "Document primit, dar spațiul de stocare vectorial estimat este de {{estimated}} MB, peste limita planului dvs. de {{limit}} MB, astfel că nu a fost salvat niciun vector. Dacă un alt document este în curs de procesare sau tocmai a eșuat, încercați din nou mai târziu. În caz contrar, reduceți dimensiunea fișierului sau numărul de fragmente.", + "embedding.vectorSpaceEstimateExceeded.title": "Spațiul vectorial estimat după încărcare depășește capacitatea planului tău", "embedding.waiting": "Așteptând încorporarea...", "list.action.addButton": "Adaugă segment", "list.action.archive": "Arhivează", diff --git a/web/i18n/ru-RU/billing.json b/web/i18n/ru-RU/billing.json index f684ef7fd9c..3f3707ed763 100644 --- a/web/i18n/ru-RU/billing.json +++ b/web/i18n/ru-RU/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "СОБЫТИЯ-ИНИЦИАТОРЫ", "upgrade.addChunks.description": "Вы достигли предела добавления чанков по этому тарифному плану.", "upgrade.addChunks.title": "Обновите версию, чтобы продолжить добавление блоков", - "upgrade.uploadMultipleFiles.description": "Загружайте больше документов одновременно, чтобы сэкономить время и повысить эффективность.", - "upgrade.uploadMultipleFiles.title": "Обновите версию, чтобы включить массовую загрузку документов", + "upgrade.uploadMultipleFiles.description": "Загружайте несколько документов одновременно и увеличьте максимальный размер одного файла до 50 МБ.", + "upgrade.uploadMultipleFiles.title": "Перейдите на более высокий тариф, чтобы разблокировать пакетную загрузку и файлы большего размера", "upgrade.uploadMultiplePages.description": "Вы достигли лимита загрузки — на вашем текущем тарифном плане можно выбрать и загрузить только один документ за раз.", "upgrade.uploadMultiplePages.title": "Обновите версию, чтобы загружать несколько документов одновременно", "upgrade.workflowRestore.description": "Восстановление версий рабочего процесса недоступно в вашем текущем плане.", diff --git a/web/i18n/ru-RU/dataset-documents.json b/web/i18n/ru-RU/dataset-documents.json index 95250f4f2e5..84c8d309545 100644 --- a/web/i18n/ru-RU/dataset-documents.json +++ b/web/i18n/ru-RU/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "Длина сегментов", "embedding.segments": "Абзацы", "embedding.textCleaning": "Предварительная очистка текста", + "embedding.vectorSpaceEstimateExceeded.description": "Документ получен, но расчётный объём векторного хранилища составляет {{estimated}} MB и превышает лимит вашего плана в {{limit}} MB, поэтому векторы не были записаны. Если другой документ сейчас обрабатывается или его обработка только что завершилась с ошибкой, повторите попытку позже. В противном случае уменьшите размер файла или количество фрагментов.", + "embedding.vectorSpaceEstimateExceeded.title": "Расчётный объём векторного хранилища после загрузки превышает лимит вашего тарифа", "embedding.waiting": "Ожидание встраивания...", "list.action.addButton": "Добавить фрагмент", "list.action.archive": "Архивировать", diff --git a/web/i18n/sl-SI/billing.json b/web/i18n/sl-SI/billing.json index e8c20adb81e..db1742c3fbd 100644 --- a/web/i18n/sl-SI/billing.json +++ b/web/i18n/sl-SI/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "SPROŽITVENI DOGODKI", "upgrade.addChunks.description": "Dosegli ste omejitev dodajanja delov za ta načrt.", "upgrade.addChunks.title": "Nadgradite, da nadaljujete z dodajanjem delov", - "upgrade.uploadMultipleFiles.description": "Naložite več dokumentov hkrati, da prihranite čas in izboljšate učinkovitost.", - "upgrade.uploadMultipleFiles.title": "Nadgradite za odklep nalaganja dokumentov v skupkih", + "upgrade.uploadMultipleFiles.description": "Naložite več dokumentov hkrati in povečajte največjo velikost posamezne datoteke na 50 MB.", + "upgrade.uploadMultipleFiles.title": "Nadgradite paket, da odklenete paketno nalaganje in večje datoteke", "upgrade.uploadMultiplePages.description": "Dosegli ste omejitev nalaganja — na vašem trenutnem načrtu je mogoče izbrati in naložiti le en dokument naenkrat.", "upgrade.uploadMultiplePages.title": "Nadgradite za nalaganje več dokumentov hkrati", "upgrade.workflowRestore.description": "Obnovitev različic poteka dela v vašem trenutnem paketu ni na voljo.", diff --git a/web/i18n/sl-SI/dataset-documents.json b/web/i18n/sl-SI/dataset-documents.json index aed4ce22544..d15f3702ced 100644 --- a/web/i18n/sl-SI/dataset-documents.json +++ b/web/i18n/sl-SI/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "Dolžina segmentov", "embedding.segments": "Odstavki", "embedding.textCleaning": "Predobdelava in čiščenje besedila", + "embedding.vectorSpaceEstimateExceeded.description": "Dokument je bil prejet, vendar je ocenjena velikost vektorske shrambe {{estimated}} MB, kar presega omejitev vašega paketa {{limit}} MB, zato vektorji niso bili shranjeni. Če se drug dokument obdeluje ali je njegova obdelava pravkar spodletela, poskusite znova pozneje. Sicer zmanjšajte velikost datoteke ali število segmentov.", + "embedding.vectorSpaceEstimateExceeded.title": "Ocenjena poraba vektorskega prostora po nalaganju presega omejitev vašega paketa", "embedding.waiting": "Čakanje na zajemanje...", "list.action.addButton": "Dodaj del", "list.action.archive": "Arhiviraj", diff --git a/web/i18n/th-TH/billing.json b/web/i18n/th-TH/billing.json index e8614371640..cd6826a59ba 100644 --- a/web/i18n/th-TH/billing.json +++ b/web/i18n/th-TH/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "เหตุการณ์ทริกเกอร์", "upgrade.addChunks.description": "คุณได้ถึงขีดจำกัดในการเพิ่มชิ้นส่วนสำหรับแผนนี้แล้ว", "upgrade.addChunks.title": "อัปเกรดเพื่อเพิ่มชิ้นส่วนต่อ", - "upgrade.uploadMultipleFiles.description": "อัปโหลดเอกสารหลายชิ้นพร้อมกันในครั้งเดียวเพื่อประหยัดเวลาและเพิ่มประสิทธิภาพ", - "upgrade.uploadMultipleFiles.title": "อัปเกรดเพื่อปลดล็อกการอัปโหลดเอกสารเป็นชุด", + "upgrade.uploadMultipleFiles.description": "อัปโหลดเอกสารหลายไฟล์พร้อมกัน และเพิ่มขนาดสูงสุดต่อไฟล์เป็น 50 MB", + "upgrade.uploadMultipleFiles.title": "อัปเกรดเพื่อปลดล็อกการอัปโหลดแบบเป็นชุดและไฟล์ขนาดใหญ่ขึ้น", "upgrade.uploadMultiplePages.description": "คุณได้ถึงขีดจำกัดการอัปโหลดแล้ว — สามารถเลือกและอัปโหลดเอกสารได้เพียงไฟล์เดียวต่อครั้งในแผนปัจจุบันของคุณ", "upgrade.uploadMultiplePages.title": "อัปเกรดเพื่ออัปโหลดเอกสารหลายฉบับพร้อมกัน", "upgrade.workflowRestore.description": "การกู้คืนเวอร์ชันเวิร์กโฟลว์ไม่พร้อมใช้งานในแผนปัจจุบันของคุณ", diff --git a/web/i18n/th-TH/dataset-documents.json b/web/i18n/th-TH/dataset-documents.json index c1be06cb6cc..3473b570938 100644 --- a/web/i18n/th-TH/dataset-documents.json +++ b/web/i18n/th-TH/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "ความยาวของก้อน", "embedding.segments": "ย่อหน้า", "embedding.textCleaning": "การกําหนดข้อความล่วงหน้าและการทําความสะอาด", + "embedding.vectorSpaceEstimateExceeded.description": "ได้รับเอกสารแล้ว แต่พื้นที่จัดเก็บเวกเตอร์โดยประมาณคือ {{estimated}} MB ซึ่งเกินขีดจำกัดแพ็กเกจ {{limit}} MB จึงไม่มีการเขียนเวกเตอร์ หากมีเอกสารอื่นกำลังประมวลผลหรือเพิ่งประมวลผลล้มเหลว โปรดลองอีกครั้งภายหลัง มิฉะนั้น ให้ลดขนาดไฟล์หรือจำนวนชังก์", + "embedding.vectorSpaceEstimateExceeded.title": "พื้นที่จัดเก็บเวกเตอร์โดยประมาณหลังอัปโหลดเกินความจุของแพ็กเกจ", "embedding.waiting": "กำลังรอสัญญาณ...", "list.action.addButton": "เพิ่มก้อน", "list.action.archive": "หอจดหมายเหตุ", diff --git a/web/i18n/tr-TR/billing.json b/web/i18n/tr-TR/billing.json index f5f00216fef..aca8350844b 100644 --- a/web/i18n/tr-TR/billing.json +++ b/web/i18n/tr-TR/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "TETİKLEYİCİ OLAYLAR", "upgrade.addChunks.description": "Bu plan için parça ekleme sınırına ulaştınız.", "upgrade.addChunks.title": "Parçalar eklemeye devam etmek için yükseltin", - "upgrade.uploadMultipleFiles.description": "Zaman kazanmak ve verimliliği artırmak için bir kerede daha fazla belgeyi toplu olarak yükleyin.", - "upgrade.uploadMultipleFiles.title": "Toplu belge yüklemeyi açmak için yükseltin", + "upgrade.uploadMultipleFiles.description": "Birden fazla belgeyi aynı anda yükleyin ve dosya başına maksimum boyutu 50 MB'a çıkarın.", + "upgrade.uploadMultipleFiles.title": "Toplu yüklemelerin ve daha büyük dosyaların kilidini açmak için yükseltin", "upgrade.uploadMultiplePages.description": "Yükleme sınırına ulaştınız — mevcut planınızda aynı anda yalnızca bir belge seçip yükleyebilirsiniz.", "upgrade.uploadMultiplePages.title": "Aynı anda birden fazla belge yüklemek için yükseltin", "upgrade.workflowRestore.description": "İş akışı sürümü geri yükleme mevcut planınızda kullanılamaz.", diff --git a/web/i18n/tr-TR/dataset-documents.json b/web/i18n/tr-TR/dataset-documents.json index 13aa1f77719..8db7cc228d5 100644 --- a/web/i18n/tr-TR/dataset-documents.json +++ b/web/i18n/tr-TR/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "Parçalar uzunluğu", "embedding.segments": "Paragraflar", "embedding.textCleaning": "Metin önişleme ve temizlik", + "embedding.vectorSpaceEstimateExceeded.description": "Belge alındı ancak tahmini vektör depolama alanı {{estimated}} MB ile planınızın {{limit}} MB sınırını aşıyor. Bu nedenle hiçbir vektör kaydedilmedi. Başka bir belgenin işlemi devam ediyorsa veya az önce başarısız olduysa daha sonra tekrar deneyin. Aksi takdirde dosya boyutunu ya da parça sayısını azaltın.", + "embedding.vectorSpaceEstimateExceeded.title": "Yükleme sonrası tahmini vektör depolama alanı plan kapasitenizi aşıyor", "embedding.waiting": "Gömme bekleniyor...", "list.action.addButton": "Parça ekle", "list.action.archive": "Arşivle", diff --git a/web/i18n/uk-UA/billing.json b/web/i18n/uk-UA/billing.json index 2073c79eb8d..f417ad5c3f8 100644 --- a/web/i18n/uk-UA/billing.json +++ b/web/i18n/uk-UA/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "ПОДІЇ-ТРИГЕРИ", "upgrade.addChunks.description": "Ви досягли межі додавання фрагментів для цього плану.", "upgrade.addChunks.title": "Оновіть, щоб продовжити додавати частини", - "upgrade.uploadMultipleFiles.description": "Завантажуйте кілька документів одночасно, щоб заощадити час і підвищити ефективність.", - "upgrade.uploadMultipleFiles.title": "Оновіть, щоб розблокувати пакетне завантаження документів", + "upgrade.uploadMultipleFiles.description": "Завантажуйте кілька документів одночасно та збільште максимальний розмір одного файлу до 50 МБ.", + "upgrade.uploadMultipleFiles.title": "Перейдіть на вищий тариф, щоб розблокувати пакетне завантаження та більші файли", "upgrade.uploadMultiplePages.description": "Ви досягли ліміту завантаження — на вашому поточному плані можна вибрати та завантажити лише один документ одночасно.", "upgrade.uploadMultiplePages.title": "Оновіть, щоб завантажувати кілька документів одночасно", "upgrade.workflowRestore.description": "Відновлення версій робочого процесу недоступне у вашому поточному плані.", diff --git a/web/i18n/uk-UA/dataset-documents.json b/web/i18n/uk-UA/dataset-documents.json index 14e89398e67..ed52bb0f1a9 100644 --- a/web/i18n/uk-UA/dataset-documents.json +++ b/web/i18n/uk-UA/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "Довжина фрагментів", "embedding.segments": "Параграфи", "embedding.textCleaning": "Попереднє визначення тексту та очищення", + "embedding.vectorSpaceEstimateExceeded.description": "Документ отримано, але розрахунковий обсяг векторного сховища становить {{estimated}} MB і перевищує ліміт вашого плану в {{limit}} MB, тому вектори не було записано. Якщо інший документ зараз обробляється або його обробка щойно завершилася помилкою, повторіть спробу пізніше. Інакше зменште розмір файлу або кількість фрагментів.", + "embedding.vectorSpaceEstimateExceeded.title": "Орієнтовний обсяг векторного сховища після завантаження перевищує ліміт вашого тарифу", "embedding.waiting": "Виконання очікує...", "list.action.addButton": "Додати фрагмент", "list.action.archive": "Архів", diff --git a/web/i18n/vi-VN/billing.json b/web/i18n/vi-VN/billing.json index 5c44fe5f688..3fdd2a57426 100644 --- a/web/i18n/vi-VN/billing.json +++ b/web/i18n/vi-VN/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "SỰ KIỆN KÍCH HOẠT", "upgrade.addChunks.description": "Bạn đã đạt đến giới hạn thêm phần cho gói này.", "upgrade.addChunks.title": "Nâng cấp để tiếp tục thêm các phần", - "upgrade.uploadMultipleFiles.description": "Tải lên nhiều tài liệu cùng lúc để tiết kiệm thời gian và nâng cao hiệu quả.", - "upgrade.uploadMultipleFiles.title": "Nâng cấp để mở khóa tải lên nhiều tài liệu", + "upgrade.uploadMultipleFiles.description": "Tải lên nhiều tài liệu cùng lúc và tăng kích thước tối đa cho mỗi tệp lên 50 MB.", + "upgrade.uploadMultipleFiles.title": "Nâng cấp để mở khóa tính năng tải lên hàng loạt và tệp có dung lượng lớn hơn", "upgrade.uploadMultiplePages.description": "Bạn đã đạt đến giới hạn tải lên — chỉ có thể chọn và tải lên một tài liệu trong một lần với gói hiện tại của bạn.", "upgrade.uploadMultiplePages.title": "Nâng cấp để tải lên nhiều tài liệu cùng lúc", "upgrade.workflowRestore.description": "Tính năng khôi phục phiên bản quy trình không khả dụng trong gói hiện tại của bạn.", diff --git a/web/i18n/vi-VN/dataset-documents.json b/web/i18n/vi-VN/dataset-documents.json index 0535d91d2a4..9c3a3dd08fc 100644 --- a/web/i18n/vi-VN/dataset-documents.json +++ b/web/i18n/vi-VN/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "Độ dài đoạn", "embedding.segments": "Đoạn", "embedding.textCleaning": "Định nghĩa và làm sạch văn bản", + "embedding.vectorSpaceEstimateExceeded.description": "Đã nhận tài liệu, nhưng dung lượng lưu trữ vector ước tính là {{estimated}} MB, vượt quá giới hạn {{limit}} MB của gói, nên không có vector nào được ghi. Nếu tài liệu khác đang được xử lý hoặc vừa xử lý thất bại, hãy thử lại sau; nếu không, hãy giảm kích thước tệp hoặc số lượng phân đoạn.", + "embedding.vectorSpaceEstimateExceeded.title": "Dung lượng lưu trữ vector ước tính sau khi tải lên vượt quá giới hạn gói", "embedding.waiting": "Đang chờ nhúng...", "list.action.addButton": "Thêm đoạn", "list.action.archive": "Lưu trữ", diff --git a/web/i18n/zh-Hans/billing.json b/web/i18n/zh-Hans/billing.json index dac7dfab3b2..7a86ef074a9 100644 --- a/web/i18n/zh-Hans/billing.json +++ b/web/i18n/zh-Hans/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "触发事件额度", "upgrade.addChunks.description": "您已达到此计划的添加分段上限。", "upgrade.addChunks.title": "升级以继续添加分段", - "upgrade.uploadMultipleFiles.description": "一次性批量上传更多文档,以节省时间并提升效率。", - "upgrade.uploadMultipleFiles.title": "升级以解锁批量文档上传功能", + "upgrade.uploadMultipleFiles.description": "一次可上传多个文档,单个文件大小上限也将提升至 50 MB。", + "upgrade.uploadMultipleFiles.title": "升级以解锁批量上传和更大的文件", "upgrade.uploadMultiplePages.description": "您已达到当前套餐的上传限制 —— 该套餐每次只能选择并上传 1 个文档。", "upgrade.uploadMultiplePages.title": "升级以一次性上传多个文档", "upgrade.workflowRestore.description": "当前套餐不支持恢复工作流版本。", diff --git a/web/i18n/zh-Hans/dataset-documents.json b/web/i18n/zh-Hans/dataset-documents.json index 3994dbaa097..6d1d26bd5d9 100644 --- a/web/i18n/zh-Hans/dataset-documents.json +++ b/web/i18n/zh-Hans/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "最大分段长度", "embedding.segments": "段落", "embedding.textCleaning": "文本预处理规则", + "embedding.vectorSpaceEstimateExceeded.description": "文档已接收,但预计向量存储占用为 {{estimated}} MB,超过套餐上限 {{limit}} MB,因此未写入向量。如果有其他文档正在处理或刚处理失败,请稍后重试;否则,请减小文件大小或减少分段数。", + "embedding.vectorSpaceEstimateExceeded.title": "上传后预计向量占用超出套餐容量", "embedding.waiting": "嵌入等待中...", "list.action.addButton": "添加分段", "list.action.archive": "归档", diff --git a/web/i18n/zh-Hant/billing.json b/web/i18n/zh-Hant/billing.json index c2deb4d78c7..7fbf1c78151 100644 --- a/web/i18n/zh-Hant/billing.json +++ b/web/i18n/zh-Hant/billing.json @@ -161,8 +161,8 @@ "triggerLimitModal.usageTitle": "觸發事件", "upgrade.addChunks.description": "您已達到此方案可新增區塊的上限。", "upgrade.addChunks.title": "升級以繼續添加區塊", - "upgrade.uploadMultipleFiles.description": "一次批量上傳更多文件,以節省時間並提高效率。", - "upgrade.uploadMultipleFiles.title": "升級以解鎖批量上傳文件功能", + "upgrade.uploadMultipleFiles.description": "一次上傳多份文件,並將單一檔案的大小上限提高至 50 MB。", + "upgrade.uploadMultipleFiles.title": "升級方案,解鎖批次上傳與更大檔案", "upgrade.uploadMultiplePages.description": "您已達到上傳限制 — 在您目前的方案下,每次只能選擇並上傳一個文件。", "upgrade.uploadMultiplePages.title": "升級以一次上傳多個文件", "upgrade.workflowRestore.description": "目前方案不支援還原工作流程版本。", diff --git a/web/i18n/zh-Hant/dataset-documents.json b/web/i18n/zh-Hant/dataset-documents.json index 1fabba73101..a150e5a0288 100644 --- a/web/i18n/zh-Hant/dataset-documents.json +++ b/web/i18n/zh-Hant/dataset-documents.json @@ -13,6 +13,8 @@ "embedding.segmentLength": "分段長度", "embedding.segments": "段落", "embedding.textCleaning": "文字預定義與清洗", + "embedding.vectorSpaceEstimateExceeded.description": "文件已接收,但預估向量儲存用量為 {{estimated}} MB,超過方案上限 {{limit}} MB,因此未寫入向量。如果有其他文件正在處理或剛處理失敗,請稍後再試;否則,請縮小檔案或減少分段數。", + "embedding.vectorSpaceEstimateExceeded.title": "上傳後預估向量占用超出方案容量", "embedding.waiting": "嵌入等待中...", "list.action.addButton": "新增分段", "list.action.archive": "歸檔", diff --git a/web/models/common.ts b/web/models/common.ts index 4df4e6dc2eb..8a8e9f5b7a4 100644 --- a/web/models/common.ts +++ b/web/models/common.ts @@ -125,6 +125,7 @@ export type FileUploadConfigResponse = { single_chunk_attachment_limit: number // default is 10, for dataset attachment upload only attachment_image_file_size_limit: number // default is 2MB, for dataset attachment upload only file_size_limit: number // default is 15MB + knowledge_file_size_limit?: number // current workspace's knowledge upload limit in MB audio_file_size_limit?: number // default is 50MB video_file_size_limit?: number // default is 100MB skill_file_size_limit?: number // default is 50MB diff --git a/web/models/datasets.ts b/web/models/datasets.ts index 5d4503e932a..61181fbd74a 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -251,6 +251,9 @@ export type IndexingStatusResponse = { completed_at: any paused_at: any error: any + error_code?: 'vector_space_estimate_exceeded' | null + estimated_vector_space_mb?: number | null + vector_space_limit_mb?: number | null stopped_at: any completed_segments: number total_segments: number