chore: more upload file size for paid user (#39967)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
非法操作 2026-08-06 11:23:16 +08:00 committed by GitHub
parent 70051950ab
commit 8170a2a5f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
125 changed files with 2478 additions and 178 deletions

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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:

View File

@ -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(

View File

@ -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)

View File

@ -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)

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -6899,7 +6899,7 @@ Check if dataset is in use
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [LimitationModel](#limitationmodel)<br> |
| 200 | Success | **application/json**: [VectorSpaceLimitationModel](#vectorspacelimitationmodel)<br> |
### [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 |

View File

@ -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)<br> |
| 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)<br> |
| 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)<br> |
| 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)<br> |
| 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)<br> |
| 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

View File

@ -105,6 +105,7 @@ class _BillingQuota(TypedDict):
class _VectorSpaceQuota(TypedDict):
size: float
limit: int
usage_unknown: NotRequired[bool]
class _KnowledgeRateLimit(TypedDict):

View File

@ -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:

View File

@ -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

View File

@ -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<estimated>\d+) MB after this upload, "
r"exceeding the (?P<limit>\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

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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"

View File

@ -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,

View File

@ -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):

View File

@ -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()

View File

@ -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"""

View File

@ -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(

View File

@ -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")

View File

@ -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(

View File

@ -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):

View File

@ -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(

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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):
"""

View File

@ -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)

View File

@ -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

View File

@ -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 =

View File

@ -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

View File

@ -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

View File

@ -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(),

View File

@ -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 = {

View File

@ -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(),
})
/**

View File

@ -23,7 +23,7 @@ import { render as renderWithConsoleState } from '@/test/console/render'
let mockProviderCtx: Record<string, unknown> = {}
let mockConsoleState: Record<string, unknown> = {}
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(<PlanComp loc="test" />, {}, 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,

View File

@ -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' },

View File

@ -26,6 +26,7 @@ type Props = Readonly<{
storageThreshold?: number
storageTooltip?: string
isSandboxPlan?: boolean
usageUnknown?: boolean
}>
const UsageInfo: FC<Props> = ({
@ -44,11 +45,12 @@ const UsageInfo: FC<Props> = ({
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<Props> = ({
) : null
const usageDisplay: ReactNode = (() => {
if (usageUnknown) return <span>--</span>
if (storageMode) {
if (isSandboxFull) {
return (
@ -142,7 +146,7 @@ const UsageInfo: FC<Props> = ({
)
const wrapWithStorageTooltip = (children: ReactNode) => {
if (storageMode && storageTooltip) {
if (!usageUnknown && storageMode && storageTooltip) {
return (
<Tooltip>
<TooltipTrigger render={<div className="cursor-default">{children}</div>} />
@ -177,7 +181,7 @@ const UsageInfo: FC<Props> = ({
{rightInfo}
</dd>
</dl>
{wrapWithStorageTooltip(bar)}
{!usageUnknown && wrapWithStorageTooltip(bar)}
</div>
)
}

View File

@ -61,6 +61,7 @@ const VectorSpaceInfo: FC<Props> = ({ className }) => {
storageThreshold={STORAGE_THRESHOLD_MB}
storageTooltip={t(($) => $['usagePage.storageThresholdTooltip'], { ns: 'billing' }) as string}
isSandboxPlan={isSandbox}
usageUnknown={vectorSpace?.usage_unknown}
/>
)
}

View File

@ -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(<VectorSpaceUnavailable isRetrying={false} onRetry={onRetry} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.retry' }))
expect(onRetry).toHaveBeenCalledOnce()
})
it('disables retry while the query is running', () => {
render(<VectorSpaceUnavailable isRetrying onRetry={vi.fn()} />)
expect(screen.getByRole('button', { name: 'common.operation.retry' })).toHaveAttribute(
'aria-disabled',
'true',
)
})
})

View File

@ -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 (
<div
role="alert"
className="flex items-center gap-2 rounded-xl border border-state-destructive-border bg-state-destructive-hover-alt p-3"
>
<span className="i-ri-error-warning-fill size-4 shrink-0 text-text-destructive" />
<div className="grow system-sm-medium text-text-destructive">
{t(($) => $['usagePage.vectorSpace'], { ns: 'billing' })}:{' '}
{t(($) => $['plansCommon.unavailable'], { ns: 'billing' })}
</div>
<Button size="small" variant="secondary" loading={isRetrying} onClick={onRetry}>
{t(($) => $['operation.retry'], { ns: 'common' })}
</Button>
</div>
)
}
export default VectorSpaceUnavailable

View File

@ -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: () => <button>upgrade plan</button>,
}))
describe('VectorSpaceAdmissionAlert', () => {
it('does not suggest an unavailable upgrade', () => {
render(<VectorSpaceAdmissionAlert showUpgrade={false} estimatedMb={61} planLimitMb={50} />)
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(<VectorSpaceAdmissionAlert showUpgrade estimatedMb={61} planLimitMb={50} />)
expect(screen.getByText('After upload total 61MB / plan limit 50MB')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'upgrade plan' })).toBeInTheDocument()
})
})

View File

@ -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 (
<div
role="alert"
className="flex items-start gap-2 rounded-xl border border-state-destructive-border bg-state-destructive-hover-alt p-3"
>
<span className="mt-0.5 i-ri-error-warning-fill size-4 shrink-0 text-text-destructive" />
<div className="grow">
<div className="system-sm-semibold text-text-destructive">
{t(($) => $['embedding.vectorSpaceEstimateExceeded.title'], {
ns: 'datasetDocuments',
})}
</div>
<div className="mt-0.5 body-xs-regular text-text-secondary">
{t(($) => $['embedding.vectorSpaceEstimateExceeded.description'], {
ns: 'datasetDocuments',
estimated: estimatedMb,
limit: planLimitMb,
})}
</div>
</div>
{showUpgrade && <UpgradeBtn loc="knowledge-vector-space-admission" />}
</div>
)
}
export default VectorSpaceAdmissionAlert

View File

@ -73,6 +73,22 @@ vi.mock('../upgrade-banner', () => ({
default: () => <div>upgrade processing priority</div>,
}))
vi.mock('@/app/components/datasets/common/vector-space-admission-alert', () => ({
default: ({
showUpgrade,
estimatedMb,
planLimitMb,
}: {
showUpgrade: boolean
estimatedMb: number
planLimitMb: number
}) => (
<div>{`vector space admission alert ${estimatedMb}MB / ${planLimitMb}MB ${
showUpgrade ? 'with upgrade' : 'without upgrade'
}`}</div>
),
}))
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(<EmbeddingProcess datasetId="dataset-1" batchId="batch-1" />)
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(<EmbeddingProcess datasetId="dataset-1" batchId="batch-1" />)
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(<EmbeddingProcess datasetId="dataset-1" batchId="batch-1" />)
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(<EmbeddingProcess datasetId="dataset-1" batchId="batch-1" />)

View File

@ -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<EmbeddingProcessProps> = ({
}
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 (
<>
<div className="flex flex-col gap-y-3">
<StatusHeader isEmbedding={isEmbedding} isCompleted={isEmbeddingCompleted} />
{vectorSpaceAdmissionError?.estimated_vector_space_mb != null &&
vectorSpaceAdmissionError.vector_space_limit_mb != null && (
<VectorSpaceAdmissionAlert
showUpgrade={showVectorSpaceUpgrade}
estimatedMb={vectorSpaceAdmissionError.estimated_vector_space_mb}
planLimitMb={vectorSpaceAdmissionError.vector_space_limit_mb}
/>
)}
{showUpgradeBanner && <UpgradeBanner />}
<div className="flex flex-col gap-0.5 pb-2">

View File

@ -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] },

View File

@ -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,

View File

@ -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(<StepOne {...defaultProps} files={files} />)
render(<StepOne {...defaultProps} files={[]} />)
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(<StepOne {...defaultProps} files={files} />, 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(<StepOne {...defaultProps} files={files} />, true)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeEnabled()
})
})
// Preview Integration Tests

View File

@ -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 = ({
<VectorSpaceFull />
</div>
)}
{isVectorSpaceUnavailable && (
<div className="mb-4 max-w-160">
<VectorSpaceUnavailable
isRetrying={isFetchingVectorSpacePlan}
onRetry={() => void refetchVectorSpace()}
/>
</div>
)}
<NextStepButton disabled={fileNextDisabled} onClick={onStepChange} />
{enableBilling && plan.type === Plan.sandbox && files.length > 0 && (
{enableBilling && plan.type === Plan.sandbox && (
<div className="mt-5">
<div className="mb-4 h-px bg-divider-subtle" />
<UpgradeCard />
@ -265,8 +283,18 @@ const StepOne = ({
<VectorSpaceFull />
</div>
)}
{isVectorSpaceUnavailable && (
<div className="mb-4 max-w-160">
<VectorSpaceUnavailable
isRetrying={isFetchingVectorSpacePlan}
onRetry={() => void refetchVectorSpace()}
/>
</div>
)}
<NextStepButton
disabled={isShowVectorSpaceFull || !notionPages.length}
disabled={
isShowVectorSpaceFull || isVectorSpaceUnavailable || !notionPages.length
}
onClick={onStepChange}
/>
</>

View File

@ -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(<CreateFromPipeline />, true)
expect(mockStepOneContent).toHaveBeenCalledWith(
expect.objectContaining({ isShowVectorSpaceUnavailable: true }),
)
})
it('allows paid users to continue when vector space usage is unknown', () => {
mockAllFileLoaded = true
render(<CreateFromPipeline />, true)
expect(mockStepOneContent).toHaveBeenCalledWith(
expect.objectContaining({ isShowVectorSpaceUnavailable: false }),
)
})
})

View File

@ -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<DataSourceNodeType>[]}
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}
/>
)}

View File

@ -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
}) => (
<div>{`vector space admission alert ${estimatedMb}MB / ${planLimitMb}MB ${
showUpgrade ? 'with upgrade' : 'without upgrade'
}`}</div>
),
}))
// 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(<EmbeddingProcess {...props} />)
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(<EmbeddingProcess {...props} />)
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 () => {

View File

@ -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' })}
</div>
{vectorSpaceAdmissionError?.estimated_vector_space_mb != null &&
vectorSpaceAdmissionError.vector_space_limit_mb != null && (
<VectorSpaceAdmissionAlert
showUpgrade={showUpgrade}
estimatedMb={vectorSpaceAdmissionError.estimated_vector_space_mb}
planLimitMb={vectorSpaceAdmissionError.vector_space_limit_mb}
/>
)}
{enableBilling && plan.type !== Plan.team && (
<div className="flex h-13 items-center gap-x-2 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-2.5 pl-3 shadow-xs shadow-shadow-shadow-3">
<div className="flex shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 shadow-md shadow-shadow-shadow-5">

View File

@ -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(
<StepOneContent
{...defaultProps}
isShowVectorSpaceUnavailable
onRetryVectorSpace={onRetryVectorSpace}
/>,
)
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(
<StepOneContent
{...defaultProps}
supportBatchUpload={false}
datasourceType={DatasourceType.localFile}
localFileListLength={3}
/>,
)
// 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(
<StepOneContent
{...defaultProps}
supportBatchUpload={false}
datasourceType={undefined}
localFileListLength={3}
/>,
)
expect(screen.queryByTestId('upgrade-btn')).not.toBeInTheDocument()
})
it('should not render UpgradeCard when localFileListLength is 0', () => {
render(
<StepOneContent
{...defaultProps}
supportBatchUpload={false}
datasourceType={DatasourceType.localFile}
localFileListLength={0}
/>,
<StepOneContent {...defaultProps} supportBatchUpload={false} datasourceType={undefined} />,
)
expect(screen.queryByTestId('upgrade-btn')).not.toBeInTheDocument()
})

View File

@ -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<DataSourceNodeType>[]
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 (
<div className="flex flex-col gap-y-5 pt-4">
@ -87,6 +91,9 @@ const StepOneContent = ({
/>
)}
{isShowVectorSpaceFull && <VectorSpaceFull />}
{isShowVectorSpaceUnavailable && (
<VectorSpaceUnavailable isRetrying={isRetryingVectorSpace} onRetry={onRetryVectorSpace} />
)}
<Actions
showSelect={showSelect}
totalOptions={totalOptions}

View File

@ -28,10 +28,13 @@ const CSVUploader: FC<Props> = ({ file, updateFile }) => {
const fileUploader = useRef<HTMLInputElement>(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<ReturnType<typeof upload>>

View File

@ -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": "استعادة إصدارات سير العمل غير متاحة في خطتك الحالية.",

View File

@ -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": "أرشيف",

View File

@ -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.",

View File

@ -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",

View File

@ -161,8 +161,8 @@
"triggerLimitModal.usageTitle": "TRIGGER EVENTS",
"upgrade.addChunks.description": "Youve 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": "Youve 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.",

View File

@ -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",

View File

@ -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.",

View File

@ -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",

View File

@ -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": "بازیابی نسخه‌های گردش‌کار در طرح فعلی شما در دسترس نیست.",

View File

@ -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": "بایگانی",

View File

@ -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.",

View File

@ -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 na 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 limport dépasse la capacité de votre forfait",
"embedding.waiting": "En attente d'incorporation...",
"list.action.addButton": "Ajouter un morceau",
"list.action.archive": "Archive",

View File

@ -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": "आपकी वर्तमान योजना में वर्कफ़्लो संस्करण पुनर्स्थापना उपलब्ध नहीं है।",

View File

@ -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": "संग्रहीत करें",

View File

@ -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.",

View File

@ -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",

View File

@ -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.",

View File

@ -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 lelaborazione 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",

View File

@ -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": "現在のプランでは、ワークフローバージョンの復元は利用できません。",

View File

@ -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": "アーカイブ",

View File

@ -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": "현재 플랜에서는 워크플로 버전 복원을 사용할 수 없습니다.",

View File

@ -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": "아카이브",

View File

@ -161,8 +161,8 @@
"triggerLimitModal.usageTitle": "TRIGGER EVENTS",
"upgrade.addChunks.description": "Youve 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": "Youve 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.",

Some files were not shown because too many files have changed in this diff Show More