Compare commits

...

24 Commits

Author SHA1 Message Date
063b39ada5 fix: conversation rename payload validation (#29510) 2025-12-11 18:05:41 +09:00
6419ce02c7 test: add testcase for config prompt components (#29491)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-11 16:56:20 +08:00
yyh
1a877bb4d0 chore: add .nvmrc for Node 22 alignment (#29495) 2025-12-11 16:29:30 +08:00
281e9d4f51 fix: chat api in explore page reject blank conversation id (#29500) 2025-12-11 16:26:42 +08:00
a195b410d1 chore(i18n): translate i18n files and update type definitions (#29499)
Co-authored-by: iamjoel <2120155+iamjoel@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2025-12-11 16:08:52 +08:00
91e5db3e83 chore: Advance the timing of the dataset payment prompt (#29497)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: twwu <twwu@dify.ai>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-11 15:49:42 +08:00
f20a2d1586 chore: add placeholder for Amplitude API key in .env.example (#29489)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
2025-12-11 15:21:52 +08:00
6e802a343e perf: remove the n+1 query (#29483) 2025-12-11 15:18:27 +08:00
yyh
a30cbe3c95 test: add debug-with-multiple-model spec (#29490) 2025-12-11 15:05:37 +08:00
7344adf65e feat: add Amplitude API key to Docker entrypoint script (#29477)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
2025-12-11 14:44:12 +08:00
fcadee9413 fix: flask db downgrade not work (#29465) 2025-12-11 14:30:09 +08:00
69a22af1c9 fix: optimize database query when retrieval knowledge in App (#29467) 2025-12-11 13:50:46 +08:00
aac6f44562 fix: workflow end node validate error (#29473)
Co-authored-by: Novice <novice12185727@gmail.com>
2025-12-11 13:47:37 +08:00
2e1efd62e1 revert: "fix(ops): add streaming metrics and LLM span for agent-chat traces" (#29469) 2025-12-11 12:53:37 +08:00
1847609926 fix: failed to delete model (#29456) 2025-12-11 12:05:44 +08:00
91f6d25dae fix: knowledge dataset description field validation error #29404 (#29405)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-12-11 11:17:08 +08:00
yyh
acdbcdb6f8 chore: update packageManager version in package.json to pnpm@10.25.0 (#29407) 2025-12-11 09:51:30 +08:00
a9627ba60a chore(deps): bump types-shapely from 2.0.0.20250404 to 2.1.0.20250917 in /api (#29441)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-11 09:49:19 +08:00
266d1c70ac fix: fix custom model credentials display as plaintext (#29425) 2025-12-11 09:48:45 +08:00
d152d63e7d chore: update remove_leading_symbols pattern, keep 【 (#29419)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-12-11 09:47:39 +08:00
b4afc7e435 fix: Can not blank conversation ID validation in chat payloads (#29436)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-12-11 09:47:10 +08:00
2d496e7e08 ci: enforce semantic pull request titles (#29438)
Signed-off-by: -LAN- <laipz8200@outlook.com>
2025-12-11 09:45:55 +08:00
693877e5e4 Fix: Prevent binary content from being stored in process_data for HTTP nodes (#27532)
Signed-off-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-11 02:52:40 +08:00
8cab3e5a1e minor fix: get_tools wrong condition (#27253)
Signed-off-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2025-12-11 02:33:14 +08:00
98 changed files with 2786 additions and 532 deletions

View File

@ -0,0 +1,21 @@
name: Semantic Pull Request
on:
pull_request:
types:
- opened
- edited
- reopened
- synchronize
jobs:
lint:
name: Validate PR title
permissions:
pull-requests: read
runs-on: ubuntu-latest
steps:
- name: Check title
uses: amannn/action-semantic-pull-request@v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
22.11.0

View File

@ -2,7 +2,7 @@ import logging
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from werkzeug.exceptions import InternalServerError, NotFound
import services
@ -52,10 +52,24 @@ class ChatMessagePayload(BaseModel):
inputs: dict[str, Any]
query: str
files: list[dict[str, Any]] | None = None
conversation_id: UUID | None = None
parent_message_id: UUID | None = None
conversation_id: str | None = None
parent_message_id: str | None = None
retriever_from: str = Field(default="explore_app")
@field_validator("conversation_id", "parent_message_id", mode="before")
@classmethod
def normalize_uuid(cls, value: str | UUID | None) -> str | None:
"""
Accept blank IDs and validate UUID format when provided.
"""
if not value:
return None
try:
return helper.uuid_value(value)
except ValueError as exc:
raise ValueError("must be a valid UUID") from exc
register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload)

View File

@ -3,7 +3,7 @@ from uuid import UUID
from flask import request
from flask_restx import marshal_with
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
@ -30,9 +30,16 @@ class ConversationListQuery(BaseModel):
class ConversationRenamePayload(BaseModel):
name: str
name: str | None = None
auto_generate: bool = False
@model_validator(mode="after")
def validate_name_requirement(self):
if not self.auto_generate:
if self.name is None or not self.name.strip():
raise ValueError("name is required when auto_generate is false")
return self
register_schema_models(console_ns, ConversationListQuery, ConversationRenamePayload)

View File

@ -230,7 +230,7 @@ class ModelProviderModelApi(Resource):
return {"result": "success"}, 200
@console_ns.expect(console_ns.models[ParserDeleteModels.__name__], validate=True)
@console_ns.expect(console_ns.models[ParserDeleteModels.__name__])
@setup_required
@login_required
@is_admin_or_owner_required

View File

@ -4,7 +4,7 @@ from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
import services
@ -52,11 +52,23 @@ class ChatRequestPayload(BaseModel):
query: str
files: list[dict[str, Any]] | None = None
response_mode: Literal["blocking", "streaming"] | None = None
conversation_id: UUID | None = None
conversation_id: str | None = Field(default=None, description="Conversation UUID")
retriever_from: str = Field(default="dev")
auto_generate_name: bool = Field(default=True, description="Auto generate conversation name")
workflow_id: str | None = Field(default=None, description="Workflow ID for advanced chat")
@field_validator("conversation_id", mode="before")
@classmethod
def normalize_conversation_id(cls, value: str | UUID | None) -> str | None:
"""Allow missing or blank conversation IDs; enforce UUID format when provided."""
if not value:
return None
try:
return helper.uuid_value(value)
except ValueError as exc:
raise ValueError("conversation_id must be a valid UUID") from exc
register_schema_models(service_api_ns, CompletionRequestPayload, ChatRequestPayload)

View File

@ -4,7 +4,7 @@ from uuid import UUID
from flask import request
from flask_restx import Resource
from flask_restx._http import HTTPStatus
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, NotFound
@ -37,9 +37,16 @@ class ConversationListQuery(BaseModel):
class ConversationRenamePayload(BaseModel):
name: str = Field(description="New conversation name")
name: str | None = Field(default=None, description="New conversation name (required if auto_generate is false)")
auto_generate: bool = Field(default=False, description="Auto-generate conversation name")
@model_validator(mode="after")
def validate_name_requirement(self):
if not self.auto_generate:
if self.name is None or not self.name.strip():
raise ValueError("name is required when auto_generate is false")
return self
class ConversationVariablesQuery(BaseModel):
last_id: UUID | None = Field(default=None, description="Last variable ID for pagination")

View File

@ -62,8 +62,7 @@ from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
from core.model_runtime.entities.llm_entities import LLMUsage
from core.model_runtime.utils.encoders import jsonable_encoder
from core.ops.entities.trace_entity import TraceTaskName
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
from core.ops.ops_trace_manager import TraceQueueManager
from core.workflow.enums import WorkflowExecutionStatus
from core.workflow.nodes import NodeType
from core.workflow.repositories.draft_variable_repository import DraftVariableSaverFactory
@ -73,7 +72,7 @@ from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from models import Account, Conversation, EndUser, Message, MessageFile
from models.enums import CreatorUserRole
from models.workflow import Workflow, WorkflowNodeExecutionModel
from models.workflow import Workflow
logger = logging.getLogger(__name__)
@ -581,7 +580,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
with self._database_session() as session:
# Save message
self._save_message(session=session, graph_runtime_state=resolved_state, trace_manager=trace_manager)
self._save_message(session=session, graph_runtime_state=resolved_state)
yield workflow_finish_resp
elif event.stopped_by in (
@ -591,7 +590,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
# When hitting input-moderation or annotation-reply, the workflow will not start
with self._database_session() as session:
# Save message
self._save_message(session=session, trace_manager=trace_manager)
self._save_message(session=session)
yield self._message_end_to_stream_response()
@ -600,7 +599,6 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
event: QueueAdvancedChatMessageEndEvent,
*,
graph_runtime_state: GraphRuntimeState | None = None,
trace_manager: TraceQueueManager | None = None,
**kwargs,
) -> Generator[StreamResponse, None, None]:
"""Handle advanced chat message end events."""
@ -618,7 +616,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
# Save message
with self._database_session() as session:
self._save_message(session=session, graph_runtime_state=resolved_state, trace_manager=trace_manager)
self._save_message(session=session, graph_runtime_state=resolved_state)
yield self._message_end_to_stream_response()
@ -772,13 +770,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
if self._conversation_name_generate_thread:
logger.debug("Conversation name generation running as daemon thread")
def _save_message(
self,
*,
session: Session,
graph_runtime_state: GraphRuntimeState | None = None,
trace_manager: TraceQueueManager | None = None,
):
def _save_message(self, *, session: Session, graph_runtime_state: GraphRuntimeState | None = None):
message = self._get_message(session=session)
# If there are assistant files, remove markdown image links from answer
@ -817,14 +809,6 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
metadata = self._task_state.metadata.model_dump()
message.message_metadata = json.dumps(jsonable_encoder(metadata))
# Extract model provider and model_id from workflow node executions for tracing
if message.workflow_run_id:
model_info = self._extract_model_info_from_workflow(session, message.workflow_run_id)
if model_info:
message.model_provider = model_info.get("provider")
message.model_id = model_info.get("model")
message_files = [
MessageFile(
message_id=message.id,
@ -842,68 +826,6 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
]
session.add_all(message_files)
# Trigger MESSAGE_TRACE for tracing integrations
if trace_manager:
trace_manager.add_trace_task(
TraceTask(
TraceTaskName.MESSAGE_TRACE, conversation_id=self._conversation_id, message_id=self._message_id
)
)
def _extract_model_info_from_workflow(self, session: Session, workflow_run_id: str) -> dict[str, str] | None:
"""
Extract model provider and model_id from workflow node executions.
Returns dict with 'provider' and 'model' keys, or None if not found.
"""
try:
# Query workflow node executions for LLM or Agent nodes
stmt = (
select(WorkflowNodeExecutionModel)
.where(WorkflowNodeExecutionModel.workflow_run_id == workflow_run_id)
.where(WorkflowNodeExecutionModel.node_type.in_(["llm", "agent"]))
.order_by(WorkflowNodeExecutionModel.created_at.desc())
.limit(1)
)
node_execution = session.scalar(stmt)
if not node_execution:
return None
# Try to extract from execution_metadata for agent nodes
if node_execution.execution_metadata:
try:
metadata = json.loads(node_execution.execution_metadata)
agent_log = metadata.get("agent_log", [])
# Look for the first agent thought with provider info
for log_entry in agent_log:
entry_metadata = log_entry.get("metadata", {})
provider_str = entry_metadata.get("provider")
if provider_str:
# Parse format like "langgenius/deepseek/deepseek"
parts = provider_str.split("/")
if len(parts) >= 3:
return {"provider": parts[1], "model": parts[2]}
elif len(parts) == 2:
return {"provider": parts[0], "model": parts[1]}
except (json.JSONDecodeError, KeyError, AttributeError) as e:
logger.debug("Failed to parse execution_metadata: %s", e)
# Try to extract from process_data for llm nodes
if node_execution.process_data:
try:
process_data = json.loads(node_execution.process_data)
provider = process_data.get("model_provider")
model = process_data.get("model_name")
if provider and model:
return {"provider": provider, "model": model}
except (json.JSONDecodeError, KeyError) as e:
logger.debug("Failed to parse process_data: %s", e)
return None
except Exception as e:
logger.warning("Failed to extract model info from workflow: %s", e)
return None
def _seed_graph_runtime_state_from_queue_manager(self) -> None:
"""Bootstrap the cached runtime state from the queue manager when present."""
candidate = self._base_task_pipeline.queue_manager.graph_runtime_state

View File

@ -40,9 +40,6 @@ class EasyUITaskState(TaskState):
"""
llm_result: LLMResult
first_token_time: float | None = None
last_token_time: float | None = None
is_streaming_response: bool = False
class WorkflowTaskState(TaskState):

View File

@ -332,12 +332,6 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline):
if not self._task_state.llm_result.prompt_messages:
self._task_state.llm_result.prompt_messages = chunk.prompt_messages
# Track streaming response times
if self._task_state.first_token_time is None:
self._task_state.first_token_time = time.perf_counter()
self._task_state.is_streaming_response = True
self._task_state.last_token_time = time.perf_counter()
# handle output moderation chunk
should_direct_answer = self._handle_output_moderation_chunk(cast(str, delta_text))
if should_direct_answer:
@ -404,18 +398,6 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline):
message.total_price = usage.total_price
message.currency = usage.currency
self._task_state.llm_result.usage.latency = message.provider_response_latency
# Add streaming metrics to usage if available
if self._task_state.is_streaming_response and self._task_state.first_token_time:
start_time = self.start_at
first_token_time = self._task_state.first_token_time
last_token_time = self._task_state.last_token_time or first_token_time
usage.time_to_first_token = round(first_token_time - start_time, 3)
usage.time_to_generate = round(last_token_time - first_token_time, 3)
# Update metadata with the complete usage info
self._task_state.metadata.usage = usage
message.message_metadata = self._task_state.metadata.model_dump_json()
if trace_manager:

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, Field
class PreviewDetail(BaseModel):
@ -20,7 +20,7 @@ class IndexingEstimate(BaseModel):
class PipelineDataset(BaseModel):
id: str
name: str
description: str
description: str | None = Field(default="", description="knowledge dataset description")
chunk_structure: str

View File

@ -222,59 +222,6 @@ class TencentSpanBuilder:
links=links,
)
@staticmethod
def build_message_llm_span(
trace_info: MessageTraceInfo, trace_id: int, parent_span_id: int, user_id: str
) -> SpanData:
"""Build LLM span for message traces with detailed LLM attributes."""
status = Status(StatusCode.OK)
if trace_info.error:
status = Status(StatusCode.ERROR, trace_info.error)
# Extract model information from `metadata`` or `message_data`
trace_metadata = trace_info.metadata or {}
message_data = trace_info.message_data or {}
model_provider = trace_metadata.get("ls_provider") or (
message_data.get("model_provider", "") if isinstance(message_data, dict) else ""
)
model_name = trace_metadata.get("ls_model_name") or (
message_data.get("model_id", "") if isinstance(message_data, dict) else ""
)
inputs_str = str(trace_info.inputs or "")
outputs_str = str(trace_info.outputs or "")
attributes = {
GEN_AI_SESSION_ID: trace_metadata.get("conversation_id", ""),
GEN_AI_USER_ID: str(user_id),
GEN_AI_SPAN_KIND: GenAISpanKind.GENERATION.value,
GEN_AI_FRAMEWORK: "dify",
GEN_AI_MODEL_NAME: str(model_name),
GEN_AI_PROVIDER: str(model_provider),
GEN_AI_USAGE_INPUT_TOKENS: str(trace_info.message_tokens or 0),
GEN_AI_USAGE_OUTPUT_TOKENS: str(trace_info.answer_tokens or 0),
GEN_AI_USAGE_TOTAL_TOKENS: str(trace_info.total_tokens or 0),
GEN_AI_PROMPT: inputs_str,
GEN_AI_COMPLETION: outputs_str,
INPUT_VALUE: inputs_str,
OUTPUT_VALUE: outputs_str,
}
if trace_info.is_streaming_request:
attributes[GEN_AI_IS_STREAMING_REQUEST] = "true"
return SpanData(
trace_id=trace_id,
parent_span_id=parent_span_id,
span_id=TencentTraceUtils.convert_to_span_id(trace_info.message_id, "llm"),
name="GENERATION",
start_time=TencentSpanBuilder._get_time_nanoseconds(trace_info.start_time),
end_time=TencentSpanBuilder._get_time_nanoseconds(trace_info.end_time),
attributes=attributes,
status=status,
)
@staticmethod
def build_tool_span(trace_info: ToolTraceInfo, trace_id: int, parent_span_id: int) -> SpanData:
"""Build tool span."""

View File

@ -107,12 +107,8 @@ class TencentDataTrace(BaseTraceInstance):
links.append(TencentTraceUtils.create_link(trace_info.trace_id))
message_span = TencentSpanBuilder.build_message_span(trace_info, trace_id, str(user_id), links)
self.trace_client.add_span(message_span)
# Add LLM child span with detailed attributes
parent_span_id = TencentTraceUtils.convert_to_span_id(trace_info.message_id, "message")
llm_span = TencentSpanBuilder.build_message_llm_span(trace_info, trace_id, parent_span_id, str(user_id))
self.trace_client.add_span(llm_span)
self.trace_client.add_span(message_span)
self._record_message_llm_metrics(trace_info)

View File

@ -592,111 +592,116 @@ class DatasetRetrieval:
"""Handle retrieval end."""
with flask_app.app_context():
dify_documents = [document for document in documents if document.provider == "dify"]
segment_ids = []
segment_index_node_ids = []
if not dify_documents:
self._send_trace_task(message_id, documents, timer)
return
with Session(db.engine) as session:
for document in dify_documents:
if document.metadata is not None:
dataset_document_stmt = select(DatasetDocument).where(
DatasetDocument.id == document.metadata["document_id"]
)
dataset_document = session.scalar(dataset_document_stmt)
if dataset_document:
if dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
segment_id = None
if (
"doc_type" not in document.metadata
or document.metadata.get("doc_type") == DocType.TEXT
):
child_chunk_stmt = select(ChildChunk).where(
ChildChunk.index_node_id == document.metadata["doc_id"],
ChildChunk.dataset_id == dataset_document.dataset_id,
ChildChunk.document_id == dataset_document.id,
)
child_chunk = session.scalar(child_chunk_stmt)
if child_chunk:
segment_id = child_chunk.segment_id
elif (
"doc_type" in document.metadata
and document.metadata.get("doc_type") == DocType.IMAGE
):
attachment_info_dict = RetrievalService.get_segment_attachment_info(
dataset_document.dataset_id,
dataset_document.tenant_id,
document.metadata.get("doc_id") or "",
session,
)
if attachment_info_dict:
segment_id = attachment_info_dict["segment_id"]
# Collect all document_ids and batch fetch DatasetDocuments
document_ids = {
doc.metadata["document_id"]
for doc in dify_documents
if doc.metadata and "document_id" in doc.metadata
}
if not document_ids:
self._send_trace_task(message_id, documents, timer)
return
dataset_docs_stmt = select(DatasetDocument).where(DatasetDocument.id.in_(document_ids))
dataset_docs = session.scalars(dataset_docs_stmt).all()
dataset_doc_map = {str(doc.id): doc for doc in dataset_docs}
# Categorize documents by type and collect necessary IDs
parent_child_text_docs: list[tuple[Document, DatasetDocument]] = []
parent_child_image_docs: list[tuple[Document, DatasetDocument]] = []
normal_text_docs: list[tuple[Document, DatasetDocument]] = []
normal_image_docs: list[tuple[Document, DatasetDocument]] = []
for doc in dify_documents:
if not doc.metadata or "document_id" not in doc.metadata:
continue
dataset_doc = dataset_doc_map.get(doc.metadata["document_id"])
if not dataset_doc:
continue
is_image = doc.metadata.get("doc_type") == DocType.IMAGE
is_parent_child = dataset_doc.doc_form == IndexStructureType.PARENT_CHILD_INDEX
if is_parent_child:
if is_image:
parent_child_image_docs.append((doc, dataset_doc))
else:
parent_child_text_docs.append((doc, dataset_doc))
else:
if is_image:
normal_image_docs.append((doc, dataset_doc))
else:
normal_text_docs.append((doc, dataset_doc))
segment_ids_to_update: set[str] = set()
# Process PARENT_CHILD_INDEX text documents - batch fetch ChildChunks
if parent_child_text_docs:
index_node_ids = [doc.metadata["doc_id"] for doc, _ in parent_child_text_docs if doc.metadata]
if index_node_ids:
child_chunks_stmt = select(ChildChunk).where(ChildChunk.index_node_id.in_(index_node_ids))
child_chunks = session.scalars(child_chunks_stmt).all()
child_chunk_map = {chunk.index_node_id: chunk.segment_id for chunk in child_chunks}
for doc, _ in parent_child_text_docs:
if doc.metadata:
segment_id = child_chunk_map.get(doc.metadata["doc_id"])
if segment_id:
if segment_id not in segment_ids:
segment_ids.append(segment_id)
_ = (
session.query(DocumentSegment)
.where(DocumentSegment.id == segment_id)
.update(
{DocumentSegment.hit_count: DocumentSegment.hit_count + 1},
synchronize_session=False,
)
)
else:
query = None
if (
"doc_type" not in document.metadata
or document.metadata.get("doc_type") == DocType.TEXT
):
if document.metadata["doc_id"] not in segment_index_node_ids:
segment = (
session.query(DocumentSegment)
.where(DocumentSegment.index_node_id == document.metadata["doc_id"])
.first()
)
if segment:
segment_index_node_ids.append(document.metadata["doc_id"])
segment_ids.append(segment.id)
query = session.query(DocumentSegment).where(
DocumentSegment.id == segment.id
)
elif (
"doc_type" in document.metadata
and document.metadata.get("doc_type") == DocType.IMAGE
):
attachment_info_dict = RetrievalService.get_segment_attachment_info(
dataset_document.dataset_id,
dataset_document.tenant_id,
document.metadata.get("doc_id") or "",
session,
)
if attachment_info_dict:
segment_id = attachment_info_dict["segment_id"]
if segment_id not in segment_ids:
segment_ids.append(segment_id)
query = session.query(DocumentSegment).where(DocumentSegment.id == segment_id)
if query:
# if 'dataset_id' in document.metadata:
if "dataset_id" in document.metadata:
query = query.where(
DocumentSegment.dataset_id == document.metadata["dataset_id"]
)
segment_ids_to_update.add(str(segment_id))
# add hit count to document segment
query.update(
{DocumentSegment.hit_count: DocumentSegment.hit_count + 1},
synchronize_session=False,
)
# Process non-PARENT_CHILD_INDEX text documents - batch fetch DocumentSegments
if normal_text_docs:
index_node_ids = [doc.metadata["doc_id"] for doc, _ in normal_text_docs if doc.metadata]
if index_node_ids:
segments_stmt = select(DocumentSegment).where(DocumentSegment.index_node_id.in_(index_node_ids))
segments = session.scalars(segments_stmt).all()
segment_map = {seg.index_node_id: seg.id for seg in segments}
for doc, _ in normal_text_docs:
if doc.metadata:
segment_id = segment_map.get(doc.metadata["doc_id"])
if segment_id:
segment_ids_to_update.add(str(segment_id))
db.session.commit()
# Process IMAGE documents - batch fetch SegmentAttachmentBindings
all_image_docs = parent_child_image_docs + normal_image_docs
if all_image_docs:
attachment_ids = [
doc.metadata["doc_id"]
for doc, _ in all_image_docs
if doc.metadata and doc.metadata.get("doc_id")
]
if attachment_ids:
bindings_stmt = select(SegmentAttachmentBinding).where(
SegmentAttachmentBinding.attachment_id.in_(attachment_ids)
)
bindings = session.scalars(bindings_stmt).all()
segment_ids_to_update.update(str(binding.segment_id) for binding in bindings)
# get tracing instance
trace_manager: TraceQueueManager | None = (
self.application_generate_entity.trace_manager if self.application_generate_entity else None
)
if trace_manager:
trace_manager.add_trace_task(
TraceTask(
TraceTaskName.DATASET_RETRIEVAL_TRACE, message_id=message_id, documents=documents, timer=timer
# Batch update hit_count for all segments
if segment_ids_to_update:
session.query(DocumentSegment).where(DocumentSegment.id.in_(segment_ids_to_update)).update(
{DocumentSegment.hit_count: DocumentSegment.hit_count + 1},
synchronize_session=False,
)
session.commit()
self._send_trace_task(message_id, documents, timer)
def _send_trace_task(self, message_id: str | None, documents: list[Document], timer: dict | None):
"""Send trace task if trace manager is available."""
trace_manager: TraceQueueManager | None = (
self.application_generate_entity.trace_manager if self.application_generate_entity else None
)
if trace_manager:
trace_manager.add_trace_task(
TraceTask(
TraceTaskName.DATASET_RETRIEVAL_TRACE, message_id=message_id, documents=documents, timer=timer
)
)
def _on_query(
self,

View File

@ -13,5 +13,5 @@ def remove_leading_symbols(text: str) -> str:
"""
# Match Unicode ranges for punctuation and symbols
# FIXME this pattern is confused quick fix for #11868 maybe refactor it later
pattern = r"^[\u2000-\u206F\u2E00-\u2E7F\u3000-\u303F\"#$%&'()*+,./:;<=>?@^_`~]+"
pattern = r'^[\[\]\u2000-\u2025\u2027-\u206F\u2E00-\u2E7F\u3000-\u300F\u3011-\u303F"#$%&\'()*+,./:;<=>?@^_`~]+'
return re.sub(pattern, "", text)

View File

@ -221,7 +221,7 @@ class WorkflowToolProviderController(ToolProviderController):
session.query(WorkflowToolProvider)
.where(
WorkflowToolProvider.tenant_id == tenant_id,
WorkflowToolProvider.app_id == self.provider_id,
WorkflowToolProvider.id == self.provider_id,
)
.first()
)

View File

@ -59,7 +59,7 @@ class OutputVariableEntity(BaseModel):
"""
variable: str
value_type: OutputVariableType
value_type: OutputVariableType = OutputVariableType.ANY
value_selector: Sequence[str]
@field_validator("value_type", mode="before")

View File

@ -412,16 +412,20 @@ class Executor:
body_string += f"--{boundary}\r\n"
body_string += f'Content-Disposition: form-data; name="{key}"\r\n\r\n'
# decode content safely
try:
body_string += content.decode("utf-8")
except UnicodeDecodeError:
body_string += content.decode("utf-8", errors="replace")
body_string += "\r\n"
# Do not decode binary content; use a placeholder with file metadata instead.
# Includes filename, size, and MIME type for better logging context.
body_string += (
f"<file_content_binary: '{file_entry[1][0] or 'unknown'}', "
f"type='{file_entry[1][2] if len(file_entry[1]) > 2 else 'unknown'}', "
f"size={len(content)} bytes>\r\n"
)
body_string += f"--{boundary}--\r\n"
elif self.node_data.body:
if self.content:
# If content is bytes, do not decode it; show a placeholder with size.
# Provides content size information for binary data without exposing the raw bytes.
if isinstance(self.content, bytes):
body_string = self.content.decode("utf-8", errors="replace")
body_string = f"<binary_content: size={len(self.content)} bytes>"
else:
body_string = self.content
elif self.data and self.node_data.body.type == "x-www-form-urlencoded":

View File

@ -107,7 +107,7 @@ def email(email):
EmailStr = Annotated[str, AfterValidator(email)]
def uuid_value(value):
def uuid_value(value: Any) -> str:
if value == "":
return str(value)

View File

@ -1,4 +1,4 @@
"""empty message
"""mysql adaptation
Revision ID: 09cfdda155d1
Revises: 669ffd70119c
@ -97,11 +97,31 @@ def downgrade():
batch_op.alter_column('include_plugins',
existing_type=sa.JSON(),
type_=postgresql.ARRAY(sa.VARCHAR(length=255)),
existing_nullable=False)
existing_nullable=False,
postgresql_using="""
COALESCE(
regexp_replace(
replace(replace(include_plugins::text, '[', '{'), ']', '}'),
'"',
'',
'g'
)::varchar(255)[],
ARRAY[]::varchar(255)[]
)""")
batch_op.alter_column('exclude_plugins',
existing_type=sa.JSON(),
type_=postgresql.ARRAY(sa.VARCHAR(length=255)),
existing_nullable=False)
existing_nullable=False,
postgresql_using="""
COALESCE(
regexp_replace(
replace(replace(exclude_plugins::text, '[', '{'), ']', '}'),
'"',
'',
'g'
)::varchar(255)[],
ARRAY[]::varchar(255)[]
)""")
with op.batch_alter_table('external_knowledge_bindings', schema=None) as batch_op:
batch_op.alter_column('external_knowledge_id',

View File

@ -835,7 +835,29 @@ class Conversation(Base):
@property
def status_count(self):
messages = db.session.scalars(select(Message).where(Message.conversation_id == self.id)).all()
from models.workflow import WorkflowRun
# Get all messages with workflow_run_id for this conversation
messages = db.session.scalars(
select(Message).where(Message.conversation_id == self.id, Message.workflow_run_id.isnot(None))
).all()
if not messages:
return None
# Batch load all workflow runs in a single query, filtered by this conversation's app_id
workflow_run_ids = [msg.workflow_run_id for msg in messages if msg.workflow_run_id]
workflow_runs = {}
if workflow_run_ids:
workflow_runs_query = db.session.scalars(
select(WorkflowRun).where(
WorkflowRun.id.in_(workflow_run_ids),
WorkflowRun.app_id == self.app_id, # Filter by this conversation's app_id
)
).all()
workflow_runs = {run.id: run for run in workflow_runs_query}
status_counts = {
WorkflowExecutionStatus.RUNNING: 0,
WorkflowExecutionStatus.SUCCEEDED: 0,
@ -845,18 +867,24 @@ class Conversation(Base):
}
for message in messages:
if message.workflow_run:
status_counts[WorkflowExecutionStatus(message.workflow_run.status)] += 1
# Guard against None to satisfy type checker and avoid invalid dict lookups
if message.workflow_run_id is None:
continue
workflow_run = workflow_runs.get(message.workflow_run_id)
if not workflow_run:
continue
return (
{
"success": status_counts[WorkflowExecutionStatus.SUCCEEDED],
"failed": status_counts[WorkflowExecutionStatus.FAILED],
"partial_success": status_counts[WorkflowExecutionStatus.PARTIAL_SUCCEEDED],
}
if messages
else None
)
try:
status_counts[WorkflowExecutionStatus(workflow_run.status)] += 1
except (ValueError, KeyError):
# Handle invalid status values gracefully
pass
return {
"success": status_counts[WorkflowExecutionStatus.SUCCEEDED],
"failed": status_counts[WorkflowExecutionStatus.FAILED],
"partial_success": status_counts[WorkflowExecutionStatus.PARTIAL_SUCCEEDED],
}
@property
def first_message(self):
@ -1255,13 +1283,9 @@ class Message(Base):
"id": self.id,
"app_id": self.app_id,
"conversation_id": self.conversation_id,
"model_provider": self.model_provider,
"model_id": self.model_id,
"inputs": self.inputs,
"query": self.query,
"message_tokens": self.message_tokens,
"answer_tokens": self.answer_tokens,
"provider_response_latency": self.provider_response_latency,
"total_price": self.total_price,
"message": self.message,
"answer": self.answer,
@ -1283,12 +1307,8 @@ class Message(Base):
id=data["id"],
app_id=data["app_id"],
conversation_id=data["conversation_id"],
model_provider=data.get("model_provider"),
model_id=data["model_id"],
inputs=data["inputs"],
message_tokens=data.get("message_tokens", 0),
answer_tokens=data.get("answer_tokens", 0),
provider_response_latency=data.get("provider_response_latency", 0.0),
total_price=data["total_price"],
query=data["query"],
message=data["message"],

View File

@ -151,7 +151,7 @@ dev = [
"types-pywin32~=310.0.0",
"types-pyyaml~=6.0.12",
"types-regex~=2024.11.6",
"types-shapely~=2.0.0",
"types-shapely~=2.1.0",
"types-simplejson>=3.20.0",
"types-six>=1.17.0",
"types-tensorflow>=2.18.0",

View File

@ -118,7 +118,7 @@ class ConversationService:
app_model: App,
conversation_id: str,
user: Union[Account, EndUser] | None,
name: str,
name: str | None,
auto_generate: bool,
):
conversation = cls.get_conversation(app_model, conversation_id, user)

View File

@ -70,9 +70,28 @@ class ModelProviderService:
continue
provider_config = provider_configuration.custom_configuration.provider
model_config = provider_configuration.custom_configuration.models
models = provider_configuration.custom_configuration.models
can_added_models = provider_configuration.custom_configuration.can_added_models
# IMPORTANT: Never expose decrypted credentials in the provider list API.
# Sanitize custom model configurations by dropping the credentials payload.
sanitized_model_config = []
if models:
from core.entities.provider_entities import CustomModelConfiguration # local import to avoid cycles
for model in models:
sanitized_model_config.append(
CustomModelConfiguration(
model=model.model,
model_type=model.model_type,
credentials=None, # strip secrets from list view
current_credential_id=model.current_credential_id,
current_credential_name=model.current_credential_name,
available_model_credentials=model.available_model_credentials,
unadded_to_model_list=model.unadded_to_model_list,
)
)
provider_response = ProviderResponse(
tenant_id=tenant_id,
provider=provider_configuration.provider.provider,
@ -95,7 +114,7 @@ class ModelProviderService:
current_credential_id=getattr(provider_config, "current_credential_id", None),
current_credential_name=getattr(provider_config, "current_credential_name", None),
available_credentials=getattr(provider_config, "available_credentials", []),
custom_models=model_config,
custom_models=sanitized_model_config,
can_added_models=can_added_models,
),
system_configuration=SystemConfigurationResponse(

View File

@ -0,0 +1,127 @@
app:
description: 'End node without value_type field reproduction'
icon: 🤖
icon_background: '#FFEAD5'
mode: workflow
name: end_node_without_value_type_field_reproduction
use_icon_as_answer_icon: false
dependencies: []
kind: app
version: 0.5.0
workflow:
conversation_variables: []
environment_variables: []
features:
file_upload:
allowed_file_extensions:
- .JPG
- .JPEG
- .PNG
- .GIF
- .WEBP
- .SVG
allowed_file_types:
- image
allowed_file_upload_methods:
- local_file
- remote_url
enabled: false
fileUploadConfig:
audio_file_size_limit: 50
batch_count_limit: 5
file_size_limit: 15
image_file_batch_limit: 10
image_file_size_limit: 10
single_chunk_attachment_limit: 10
video_file_size_limit: 100
workflow_file_upload_limit: 10
image:
enabled: false
number_limits: 3
transfer_methods:
- local_file
- remote_url
number_limits: 3
opening_statement: ''
retriever_resource:
enabled: true
sensitive_word_avoidance:
enabled: false
speech_to_text:
enabled: false
suggested_questions: []
suggested_questions_after_answer:
enabled: false
text_to_speech:
enabled: false
language: ''
voice: ''
graph:
edges:
- data:
isInIteration: false
isInLoop: false
sourceType: start
targetType: end
id: 1765423445456-source-1765423454810-target
source: '1765423445456'
sourceHandle: source
target: '1765423454810'
targetHandle: target
type: custom
zIndex: 0
nodes:
- data:
selected: false
title: 用户输入
type: start
variables:
- default: ''
hint: ''
label: query
max_length: 48
options: []
placeholder: ''
required: true
type: text-input
variable: query
height: 109
id: '1765423445456'
position:
x: -48
y: 261
positionAbsolute:
x: -48
y: 261
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 242
- data:
outputs:
- value_selector:
- '1765423445456'
- query
variable: query
selected: true
title: 输出
type: end
height: 88
id: '1765423454810'
position:
x: 382
y: 282
positionAbsolute:
x: 382
y: 282
selected: true
sourcePosition: right
targetPosition: left
type: custom
width: 242
viewport:
x: 139
y: -135
zoom: 1
rag_pipeline_variables: []

View File

@ -0,0 +1,25 @@
import uuid
import pytest
from pydantic import ValidationError
from controllers.service_api.app.completion import ChatRequestPayload
def test_chat_request_payload_accepts_blank_conversation_id():
payload = ChatRequestPayload.model_validate({"inputs": {}, "query": "hello", "conversation_id": ""})
assert payload.conversation_id is None
def test_chat_request_payload_validates_uuid():
conversation_id = str(uuid.uuid4())
payload = ChatRequestPayload.model_validate({"inputs": {}, "query": "hello", "conversation_id": conversation_id})
assert payload.conversation_id == conversation_id
def test_chat_request_payload_rejects_invalid_uuid():
with pytest.raises(ValidationError):
ChatRequestPayload.model_validate({"inputs": {}, "query": "hello", "conversation_id": "invalid"})

View File

@ -0,0 +1,20 @@
import pytest
from pydantic import ValidationError
from controllers.console.explore.conversation import ConversationRenamePayload as ConsolePayload
from controllers.service_api.app.conversation import ConversationRenamePayload as ServicePayload
@pytest.mark.parametrize("payload_cls", [ConsolePayload, ServicePayload])
def test_payload_allows_auto_generate_without_name(payload_cls):
payload = payload_cls.model_validate({"auto_generate": True})
assert payload.auto_generate is True
assert payload.name is None
@pytest.mark.parametrize("payload_cls", [ConsolePayload, ServicePayload])
@pytest.mark.parametrize("value", [None, "", " "])
def test_payload_requires_name_when_not_auto_generate(payload_cls, value):
with pytest.raises(ValidationError):
payload_cls.model_validate({"name": value, "auto_generate": False})

View File

@ -0,0 +1,60 @@
"""
Test case for end node without value_type field (backward compatibility).
This test validates that end nodes work correctly even when the value_type
field is missing from the output configuration, ensuring backward compatibility
with older workflow definitions.
"""
from core.workflow.graph_events import (
GraphRunStartedEvent,
GraphRunSucceededEvent,
NodeRunStartedEvent,
NodeRunStreamChunkEvent,
NodeRunSucceededEvent,
)
from .test_table_runner import TableTestRunner, WorkflowTestCase
def test_end_node_without_value_type_field():
"""
Test that end node works without explicit value_type field.
The fixture implements a simple workflow that:
1. Takes a query input from start node
2. Passes it directly to end node
3. End node outputs the value without specifying value_type
4. Should correctly infer the type and output the value
This ensures backward compatibility with workflow definitions
created before value_type became a required field.
"""
fixture_name = "end_node_without_value_type_field_workflow"
case = WorkflowTestCase(
fixture_path=fixture_name,
inputs={"query": "test query"},
expected_outputs={"query": "test query"},
expected_event_sequence=[
# Graph start
GraphRunStartedEvent,
# Start node
NodeRunStartedEvent,
NodeRunStreamChunkEvent, # Start node streams the input value
NodeRunSucceededEvent,
# End node
NodeRunStartedEvent,
NodeRunSucceededEvent,
# Graph end
GraphRunSucceededEvent,
],
description="End node without value_type field should work correctly",
)
runner = TableTestRunner()
result = runner.run_test_case(case)
assert result.success, f"Test failed: {result.error}"
assert result.actual_outputs == {"query": "test query"}, (
f"Expected output to be {{'query': 'test query'}}, got {result.actual_outputs}"
)

View File

@ -1149,3 +1149,258 @@ class TestModelIntegration:
# Assert
assert site.app_id == app.id
assert app.enable_site is True
class TestConversationStatusCount:
"""Test suite for Conversation.status_count property N+1 query fix."""
def test_status_count_no_messages(self):
"""Test status_count returns None when conversation has no messages."""
# Arrange
conversation = Conversation(
app_id=str(uuid4()),
mode=AppMode.CHAT,
name="Test Conversation",
status="normal",
from_source="api",
)
conversation.id = str(uuid4())
# Mock the database query to return no messages
with patch("models.model.db.session.scalars") as mock_scalars:
mock_scalars.return_value.all.return_value = []
# Act
result = conversation.status_count
# Assert
assert result is None
def test_status_count_messages_without_workflow_runs(self):
"""Test status_count when messages have no workflow_run_id."""
# Arrange
app_id = str(uuid4())
conversation_id = str(uuid4())
conversation = Conversation(
app_id=app_id,
mode=AppMode.CHAT,
name="Test Conversation",
status="normal",
from_source="api",
)
conversation.id = conversation_id
# Mock the database query to return no messages with workflow_run_id
with patch("models.model.db.session.scalars") as mock_scalars:
mock_scalars.return_value.all.return_value = []
# Act
result = conversation.status_count
# Assert
assert result is None
def test_status_count_batch_loading_implementation(self):
"""Test that status_count uses batch loading instead of N+1 queries."""
# Arrange
from core.workflow.enums import WorkflowExecutionStatus
app_id = str(uuid4())
conversation_id = str(uuid4())
# Create workflow run IDs
workflow_run_id_1 = str(uuid4())
workflow_run_id_2 = str(uuid4())
workflow_run_id_3 = str(uuid4())
conversation = Conversation(
app_id=app_id,
mode=AppMode.CHAT,
name="Test Conversation",
status="normal",
from_source="api",
)
conversation.id = conversation_id
# Mock messages with workflow_run_id
mock_messages = [
MagicMock(
conversation_id=conversation_id,
workflow_run_id=workflow_run_id_1,
),
MagicMock(
conversation_id=conversation_id,
workflow_run_id=workflow_run_id_2,
),
MagicMock(
conversation_id=conversation_id,
workflow_run_id=workflow_run_id_3,
),
]
# Mock workflow runs with different statuses
mock_workflow_runs = [
MagicMock(
id=workflow_run_id_1,
status=WorkflowExecutionStatus.SUCCEEDED.value,
app_id=app_id,
),
MagicMock(
id=workflow_run_id_2,
status=WorkflowExecutionStatus.FAILED.value,
app_id=app_id,
),
MagicMock(
id=workflow_run_id_3,
status=WorkflowExecutionStatus.PARTIAL_SUCCEEDED.value,
app_id=app_id,
),
]
# Track database calls
calls_made = []
def mock_scalars(query):
calls_made.append(str(query))
mock_result = MagicMock()
# Return messages for the first query (messages with workflow_run_id)
if "messages" in str(query) and "conversation_id" in str(query):
mock_result.all.return_value = mock_messages
# Return workflow runs for the batch query
elif "workflow_runs" in str(query):
mock_result.all.return_value = mock_workflow_runs
else:
mock_result.all.return_value = []
return mock_result
# Act & Assert
with patch("models.model.db.session.scalars", side_effect=mock_scalars):
result = conversation.status_count
# Verify only 2 database queries were made (not N+1)
assert len(calls_made) == 2, f"Expected 2 queries, got {len(calls_made)}: {calls_made}"
# Verify the first query gets messages
assert "messages" in calls_made[0]
assert "conversation_id" in calls_made[0]
# Verify the second query batch loads workflow runs with proper filtering
assert "workflow_runs" in calls_made[1]
assert "app_id" in calls_made[1] # Security filter applied
assert "IN" in calls_made[1] # Batch loading with IN clause
# Verify correct status counts
assert result["success"] == 1 # One SUCCEEDED
assert result["failed"] == 1 # One FAILED
assert result["partial_success"] == 1 # One PARTIAL_SUCCEEDED
def test_status_count_app_id_filtering(self):
"""Test that status_count filters workflow runs by app_id for security."""
# Arrange
app_id = str(uuid4())
other_app_id = str(uuid4())
conversation_id = str(uuid4())
workflow_run_id = str(uuid4())
conversation = Conversation(
app_id=app_id,
mode=AppMode.CHAT,
name="Test Conversation",
status="normal",
from_source="api",
)
conversation.id = conversation_id
# Mock message with workflow_run_id
mock_messages = [
MagicMock(
conversation_id=conversation_id,
workflow_run_id=workflow_run_id,
),
]
calls_made = []
def mock_scalars(query):
calls_made.append(str(query))
mock_result = MagicMock()
if "messages" in str(query):
mock_result.all.return_value = mock_messages
elif "workflow_runs" in str(query):
# Return empty list because no workflow run matches the correct app_id
mock_result.all.return_value = [] # Workflow run filtered out by app_id
else:
mock_result.all.return_value = []
return mock_result
# Act
with patch("models.model.db.session.scalars", side_effect=mock_scalars):
result = conversation.status_count
# Assert - query should include app_id filter
workflow_query = calls_made[1]
assert "app_id" in workflow_query
# Since workflow run has wrong app_id, it shouldn't be included in counts
assert result["success"] == 0
assert result["failed"] == 0
assert result["partial_success"] == 0
def test_status_count_handles_invalid_workflow_status(self):
"""Test that status_count gracefully handles invalid workflow status values."""
# Arrange
app_id = str(uuid4())
conversation_id = str(uuid4())
workflow_run_id = str(uuid4())
conversation = Conversation(
app_id=app_id,
mode=AppMode.CHAT,
name="Test Conversation",
status="normal",
from_source="api",
)
conversation.id = conversation_id
mock_messages = [
MagicMock(
conversation_id=conversation_id,
workflow_run_id=workflow_run_id,
),
]
# Mock workflow run with invalid status
mock_workflow_runs = [
MagicMock(
id=workflow_run_id,
status="invalid_status", # Invalid status that should raise ValueError
app_id=app_id,
),
]
with patch("models.model.db.session.scalars") as mock_scalars:
# Mock the messages query
def mock_scalars_side_effect(query):
mock_result = MagicMock()
if "messages" in str(query):
mock_result.all.return_value = mock_messages
elif "workflow_runs" in str(query):
mock_result.all.return_value = mock_workflow_runs
else:
mock_result.all.return_value = []
return mock_result
mock_scalars.side_effect = mock_scalars_side_effect
# Act - should not raise exception
result = conversation.status_count
# Assert - should handle invalid status gracefully
assert result["success"] == 0
assert result["failed"] == 0
assert result["partial_success"] == 0

View File

@ -0,0 +1,88 @@
import types
import pytest
from core.entities.provider_entities import CredentialConfiguration, CustomModelConfiguration
from core.model_runtime.entities.common_entities import I18nObject
from core.model_runtime.entities.model_entities import ModelType
from core.model_runtime.entities.provider_entities import ConfigurateMethod
from models.provider import ProviderType
from services.model_provider_service import ModelProviderService
class _FakeConfigurations:
def __init__(self, provider_configuration: types.SimpleNamespace) -> None:
self._provider_configuration = provider_configuration
def values(self) -> list[types.SimpleNamespace]:
return [self._provider_configuration]
@pytest.fixture
def service_with_fake_configurations():
# Build a fake provider schema with minimal fields used by ProviderResponse
fake_provider = types.SimpleNamespace(
provider="langgenius/openai_api_compatible/openai_api_compatible",
label=I18nObject(en_US="OpenAI API Compatible", zh_Hans="OpenAI API Compatible"),
description=None,
icon_small=None,
icon_small_dark=None,
icon_large=None,
background=None,
help=None,
supported_model_types=[ModelType.LLM],
configurate_methods=[ConfigurateMethod.CUSTOMIZABLE_MODEL],
provider_credential_schema=None,
model_credential_schema=None,
)
# Include decrypted credentials to simulate the leak source
custom_model = CustomModelConfiguration(
model="gpt-4o-mini",
model_type=ModelType.LLM,
credentials={"api_key": "sk-plain-text", "endpoint": "https://example.com"},
current_credential_id="cred-1",
current_credential_name="API KEY 1",
available_model_credentials=[],
unadded_to_model_list=False,
)
fake_custom_provider = types.SimpleNamespace(
current_credential_id="cred-1",
current_credential_name="API KEY 1",
available_credentials=[CredentialConfiguration(credential_id="cred-1", credential_name="API KEY 1")],
)
fake_custom_configuration = types.SimpleNamespace(
provider=fake_custom_provider, models=[custom_model], can_added_models=[]
)
fake_system_configuration = types.SimpleNamespace(enabled=False, current_quota_type=None, quota_configurations=[])
fake_provider_configuration = types.SimpleNamespace(
provider=fake_provider,
preferred_provider_type=ProviderType.CUSTOM,
custom_configuration=fake_custom_configuration,
system_configuration=fake_system_configuration,
is_custom_configuration_available=lambda: True,
)
class _FakeProviderManager:
def get_configurations(self, tenant_id: str) -> _FakeConfigurations:
return _FakeConfigurations(fake_provider_configuration)
svc = ModelProviderService()
svc.provider_manager = _FakeProviderManager()
return svc
def test_get_provider_list_strips_credentials(service_with_fake_configurations: ModelProviderService):
providers = service_with_fake_configurations.get_provider_list(tenant_id="tenant-1", model_type=None)
assert len(providers) == 1
custom_models = providers[0].custom_configuration.custom_models
assert custom_models is not None
assert len(custom_models) == 1
# The sanitizer should drop credentials in list response
assert custom_models[0].credentials is None

View File

@ -14,6 +14,7 @@ from core.tools.utils.text_processing_utils import remove_leading_symbols
("Hello, World!", "Hello, World!"),
("", ""),
(" ", " "),
("【测试】", "【测试】"),
],
)
def test_remove_leading_symbols(input_text, expected_output):

8
api/uv.lock generated
View File

@ -1681,7 +1681,7 @@ dev = [
{ name = "types-redis", specifier = ">=4.6.0.20241004" },
{ name = "types-regex", specifier = "~=2024.11.6" },
{ name = "types-setuptools", specifier = ">=80.9.0" },
{ name = "types-shapely", specifier = "~=2.0.0" },
{ name = "types-shapely", specifier = "~=2.1.0" },
{ name = "types-simplejson", specifier = ">=3.20.0" },
{ name = "types-six", specifier = ">=1.17.0" },
{ name = "types-tensorflow", specifier = ">=2.18.0" },
@ -6557,14 +6557,14 @@ wheels = [
[[package]]
name = "types-shapely"
version = "2.0.0.20250404"
version = "2.1.0.20250917"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/55/c71a25fd3fc9200df4d0b5fd2f6d74712a82f9a8bbdd90cefb9e6aee39dd/types_shapely-2.0.0.20250404.tar.gz", hash = "sha256:863f540b47fa626c33ae64eae06df171f9ab0347025d4458d2df496537296b4f", size = 25066, upload-time = "2025-04-04T02:54:30.592Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/19/7f28b10994433d43b9caa66f3b9bd6a0a9192b7ce8b5a7fc41534e54b821/types_shapely-2.1.0.20250917.tar.gz", hash = "sha256:5c56670742105aebe40c16414390d35fcaa55d6f774d328c1a18273ab0e2134a", size = 26363, upload-time = "2025-09-17T02:47:44.604Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/ff/7f4d414eb81534ba2476f3d54f06f1463c2ebf5d663fd10cff16ba607dd6/types_shapely-2.0.0.20250404-py3-none-any.whl", hash = "sha256:170fb92f5c168a120db39b3287697fdec5c93ef3e1ad15e52552c36b25318821", size = 36350, upload-time = "2025-04-04T02:54:29.506Z" },
{ url = "https://files.pythonhosted.org/packages/e5/a9/554ac40810e530263b6163b30a2b623bc16aae3fb64416f5d2b3657d0729/types_shapely-2.1.0.20250917-py3-none-any.whl", hash = "sha256:9334a79339504d39b040426be4938d422cec419168414dc74972aa746a8bf3a1", size = 37813, upload-time = "2025-09-17T02:47:43.788Z" },
]
[[package]]

View File

@ -1432,3 +1432,6 @@ WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK=0
# Tenant isolated task queue configuration
TENANT_ISOLATED_TASK_CONCURRENCY=1
# The API key of amplitude
AMPLITUDE_API_KEY=

View File

@ -136,6 +136,7 @@ services:
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
APP_API_URL: ${APP_API_URL:-}
AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-}
NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-}
SENTRY_DSN: ${WEB_SENTRY_DSN:-}
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0}

View File

@ -635,6 +635,7 @@ x-shared-env: &shared-api-worker-env
WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE: ${WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE:-100}
WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK: ${WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK:-0}
TENANT_ISOLATED_TASK_CONCURRENCY: ${TENANT_ISOLATED_TASK_CONCURRENCY:-1}
AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-}
services:
# Init container to fix permissions
@ -773,6 +774,7 @@ services:
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
APP_API_URL: ${APP_API_URL:-}
AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-}
NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-}
SENTRY_DSN: ${WEB_SENTRY_DSN:-}
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0}

View File

@ -70,3 +70,6 @@ NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
# The maximum number of tree node depth for workflow
NEXT_PUBLIC_MAX_TREE_DEPTH=50
# The API key of amplitude
NEXT_PUBLIC_AMPLITUDE_API_KEY=

View File

@ -0,0 +1,49 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import ConfirmAddVar from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('../../base/var-highlight', () => ({
__esModule: true,
default: ({ name }: { name: string }) => <span data-testid="var-highlight">{name}</span>,
}))
describe('ConfirmAddVar', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should render variable names', () => {
render(<ConfirmAddVar varNameArr={['foo', 'bar']} onConfirm={jest.fn()} onCancel={jest.fn()} onHide={jest.fn()} />)
const highlights = screen.getAllByTestId('var-highlight')
expect(highlights).toHaveLength(2)
expect(highlights[0]).toHaveTextContent('foo')
expect(highlights[1]).toHaveTextContent('bar')
})
it('should trigger cancel actions', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
render(<ConfirmAddVar varNameArr={['foo']} onConfirm={onConfirm} onCancel={onCancel} onHide={jest.fn()} />)
fireEvent.click(screen.getByText('common.operation.cancel'))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should trigger confirm actions', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
render(<ConfirmAddVar varNameArr={['foo']} onConfirm={onConfirm} onCancel={onCancel} onHide={jest.fn()} />)
fireEvent.click(screen.getByText('common.operation.add'))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,56 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import EditModal from './edit-modal'
import type { ConversationHistoriesRole } from '@/models/debug'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('@/app/components/base/modal', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
describe('Conversation history edit modal', () => {
const data: ConversationHistoriesRole = {
user_prefix: 'user',
assistant_prefix: 'assistant',
}
beforeEach(() => {
jest.clearAllMocks()
})
it('should render provided prefixes', () => {
render(<EditModal isShow saveLoading={false} data={data} onClose={jest.fn()} onSave={jest.fn()} />)
expect(screen.getByDisplayValue('user')).toBeInTheDocument()
expect(screen.getByDisplayValue('assistant')).toBeInTheDocument()
})
it('should update prefixes and save changes', () => {
const onSave = jest.fn()
render(<EditModal isShow saveLoading={false} data={data} onClose={jest.fn()} onSave={onSave} />)
fireEvent.change(screen.getByDisplayValue('user'), { target: { value: 'member' } })
fireEvent.change(screen.getByDisplayValue('assistant'), { target: { value: 'helper' } })
fireEvent.click(screen.getByText('common.operation.save'))
expect(onSave).toHaveBeenCalledWith({
user_prefix: 'member',
assistant_prefix: 'helper',
})
})
it('should call close handler', () => {
const onClose = jest.fn()
render(<EditModal isShow saveLoading={false} data={data} onClose={onClose} onSave={jest.fn()} />)
fireEvent.click(screen.getByText('common.operation.cancel'))
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,48 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import HistoryPanel from './history-panel'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockDocLink = jest.fn(() => 'doc-link')
jest.mock('@/context/i18n', () => ({
useDocLink: () => mockDocLink,
}))
jest.mock('@/app/components/app/configuration/base/operation-btn', () => ({
__esModule: true,
default: ({ onClick }: { onClick: () => void }) => (
<button type="button" data-testid="edit-button" onClick={onClick}>
edit
</button>
),
}))
jest.mock('@/app/components/app/configuration/base/feature-panel', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
describe('HistoryPanel', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should render warning content and link when showWarning is true', () => {
render(<HistoryPanel showWarning onShowEditModal={jest.fn()} />)
expect(screen.getByText('appDebug.feature.conversationHistory.tip')).toBeInTheDocument()
const link = screen.getByText('appDebug.feature.conversationHistory.learnMore')
expect(link).toHaveAttribute('href', 'doc-link')
})
it('should hide warning when showWarning is false', () => {
render(<HistoryPanel showWarning={false} onShowEditModal={jest.fn()} />)
expect(screen.queryByText('appDebug.feature.conversationHistory.tip')).toBeNull()
})
})

View File

@ -0,0 +1,351 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import Prompt, { type IPromptProps } from './index'
import ConfigContext from '@/context/debug-configuration'
import { MAX_PROMPT_MESSAGE_LENGTH } from '@/config'
import { type PromptItem, PromptRole, type PromptVariable } from '@/models/debug'
import { AppModeEnum, ModelModeType } from '@/types/app'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
type DebugConfiguration = {
isAdvancedMode: boolean
currentAdvancedPrompt: PromptItem | PromptItem[]
setCurrentAdvancedPrompt: (prompt: PromptItem | PromptItem[], isUserChanged?: boolean) => void
modelModeType: ModelModeType
dataSets: Array<{
id: string
name?: string
}>
hasSetBlockStatus: {
context: boolean
history: boolean
query: boolean
}
}
const defaultPromptVariables: PromptVariable[] = [
{ key: 'var', name: 'Variable', type: 'string', required: true },
]
let mockSimplePromptInputProps: IPromptProps | null = null
jest.mock('./simple-prompt-input', () => ({
__esModule: true,
default: (props: IPromptProps) => {
mockSimplePromptInputProps = props
return (
<div
data-testid="simple-prompt-input"
data-mode={props.mode}
data-template={props.promptTemplate}
data-readonly={props.readonly ?? false}
data-no-title={props.noTitle ?? false}
data-gradient-border={props.gradientBorder ?? false}
data-editor-height={props.editorHeight ?? ''}
data-no-resize={props.noResize ?? false}
onClick={() => props.onChange?.('mocked prompt', props.promptVariables)}
>
SimplePromptInput Mock
</div>
)
},
}))
type AdvancedMessageInputProps = {
isChatMode: boolean
type: PromptRole
value: string
onTypeChange: (value: PromptRole) => void
canDelete: boolean
onDelete: () => void
onChange: (value: string) => void
promptVariables: PromptVariable[]
isContextMissing: boolean
onHideContextMissingTip: () => void
noResize?: boolean
}
jest.mock('./advanced-prompt-input', () => ({
__esModule: true,
default: (props: AdvancedMessageInputProps) => {
return (
<div
data-testid="advanced-message-input"
data-type={props.type}
data-value={props.value}
data-chat-mode={props.isChatMode}
data-can-delete={props.canDelete}
data-context-missing={props.isContextMissing}
>
<button type="button" onClick={() => props.onChange('updated text')}>
change
</button>
<button type="button" onClick={() => props.onTypeChange(PromptRole.assistant)}>
type
</button>
<button type="button" onClick={props.onDelete}>
delete
</button>
<button type="button" onClick={props.onHideContextMissingTip}>
hide-context
</button>
</div>
)
},
}))
const getContextValue = (overrides: Partial<DebugConfiguration> = {}): DebugConfiguration => {
return {
setCurrentAdvancedPrompt: jest.fn(),
isAdvancedMode: false,
currentAdvancedPrompt: [],
modelModeType: ModelModeType.chat,
dataSets: [],
hasSetBlockStatus: {
context: false,
history: false,
query: false,
},
...overrides,
}
}
const renderComponent = (
props: Partial<IPromptProps> = {},
contextOverrides: Partial<DebugConfiguration> = {},
) => {
const mergedProps: IPromptProps = {
mode: AppModeEnum.CHAT,
promptTemplate: 'initial template',
promptVariables: defaultPromptVariables,
onChange: jest.fn(),
...props,
}
const contextValue = getContextValue(contextOverrides)
return {
contextValue,
...render(
<ConfigContext.Provider value={contextValue as any}>
<Prompt {...mergedProps} />
</ConfigContext.Provider>,
),
}
}
describe('Prompt config component', () => {
beforeEach(() => {
jest.clearAllMocks()
mockSimplePromptInputProps = null
})
// Rendering simple mode
it('should render simple prompt when advanced mode is disabled', () => {
const onChange = jest.fn()
renderComponent({ onChange }, { isAdvancedMode: false })
const simplePrompt = screen.getByTestId('simple-prompt-input')
expect(simplePrompt).toBeInTheDocument()
expect(simplePrompt).toHaveAttribute('data-mode', AppModeEnum.CHAT)
expect(mockSimplePromptInputProps?.promptTemplate).toBe('initial template')
fireEvent.click(simplePrompt)
expect(onChange).toHaveBeenCalledWith('mocked prompt', defaultPromptVariables)
expect(screen.queryByTestId('advanced-message-input')).toBeNull()
})
// Rendering advanced chat messages
it('should render advanced chat prompts and show context missing tip when dataset context is not set', () => {
const currentAdvancedPrompt: PromptItem[] = [
{ role: PromptRole.user, text: 'first' },
{ role: PromptRole.assistant, text: 'second' },
]
renderComponent(
{},
{
isAdvancedMode: true,
currentAdvancedPrompt,
modelModeType: ModelModeType.chat,
dataSets: [{ id: 'ds' } as unknown as DebugConfiguration['dataSets'][number]],
hasSetBlockStatus: { context: false, history: true, query: true },
},
)
const renderedMessages = screen.getAllByTestId('advanced-message-input')
expect(renderedMessages).toHaveLength(2)
expect(renderedMessages[0]).toHaveAttribute('data-context-missing', 'true')
fireEvent.click(screen.getAllByText('hide-context')[0])
expect(screen.getAllByTestId('advanced-message-input')[0]).toHaveAttribute('data-context-missing', 'false')
})
// Chat message mutations
it('should update chat prompt value and call setter with user change flag', () => {
const currentAdvancedPrompt: PromptItem[] = [
{ role: PromptRole.user, text: 'first' },
{ role: PromptRole.assistant, text: 'second' },
]
const setCurrentAdvancedPrompt = jest.fn()
renderComponent(
{},
{
isAdvancedMode: true,
currentAdvancedPrompt,
modelModeType: ModelModeType.chat,
setCurrentAdvancedPrompt,
},
)
fireEvent.click(screen.getAllByText('change')[0])
expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith(
[
{ role: PromptRole.user, text: 'updated text' },
{ role: PromptRole.assistant, text: 'second' },
],
true,
)
})
it('should update chat prompt role when type changes', () => {
const currentAdvancedPrompt: PromptItem[] = [
{ role: PromptRole.user, text: 'first' },
{ role: PromptRole.user, text: 'second' },
]
const setCurrentAdvancedPrompt = jest.fn()
renderComponent(
{},
{
isAdvancedMode: true,
currentAdvancedPrompt,
modelModeType: ModelModeType.chat,
setCurrentAdvancedPrompt,
},
)
fireEvent.click(screen.getAllByText('type')[1])
expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith(
[
{ role: PromptRole.user, text: 'first' },
{ role: PromptRole.assistant, text: 'second' },
],
)
})
it('should delete chat prompt item', () => {
const currentAdvancedPrompt: PromptItem[] = [
{ role: PromptRole.user, text: 'first' },
{ role: PromptRole.assistant, text: 'second' },
]
const setCurrentAdvancedPrompt = jest.fn()
renderComponent(
{},
{
isAdvancedMode: true,
currentAdvancedPrompt,
modelModeType: ModelModeType.chat,
setCurrentAdvancedPrompt,
},
)
fireEvent.click(screen.getAllByText('delete')[0])
expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith([{ role: PromptRole.assistant, text: 'second' }])
})
// Add message behavior
it('should append a mirrored role message when clicking add in chat mode', () => {
const currentAdvancedPrompt: PromptItem[] = [
{ role: PromptRole.user, text: 'first' },
]
const setCurrentAdvancedPrompt = jest.fn()
renderComponent(
{},
{
isAdvancedMode: true,
currentAdvancedPrompt,
modelModeType: ModelModeType.chat,
setCurrentAdvancedPrompt,
},
)
fireEvent.click(screen.getByText('appDebug.promptMode.operation.addMessage'))
expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith([
{ role: PromptRole.user, text: 'first' },
{ role: PromptRole.assistant, text: '' },
])
})
it('should append a user role when the last chat prompt is from assistant', () => {
const currentAdvancedPrompt: PromptItem[] = [
{ role: PromptRole.assistant, text: 'reply' },
]
const setCurrentAdvancedPrompt = jest.fn()
renderComponent(
{},
{
isAdvancedMode: true,
currentAdvancedPrompt,
modelModeType: ModelModeType.chat,
setCurrentAdvancedPrompt,
},
)
fireEvent.click(screen.getByText('appDebug.promptMode.operation.addMessage'))
expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith([
{ role: PromptRole.assistant, text: 'reply' },
{ role: PromptRole.user, text: '' },
])
})
it('should insert a system message when adding to an empty chat prompt list', () => {
const setCurrentAdvancedPrompt = jest.fn()
renderComponent(
{},
{
isAdvancedMode: true,
currentAdvancedPrompt: [],
modelModeType: ModelModeType.chat,
setCurrentAdvancedPrompt,
},
)
fireEvent.click(screen.getByText('appDebug.promptMode.operation.addMessage'))
expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith([{ role: PromptRole.system, text: '' }])
})
it('should not show add button when reaching max prompt length', () => {
const prompts: PromptItem[] = Array.from({ length: MAX_PROMPT_MESSAGE_LENGTH }, (_, index) => ({
role: PromptRole.user,
text: `item-${index}`,
}))
renderComponent(
{},
{
isAdvancedMode: true,
currentAdvancedPrompt: prompts,
modelModeType: ModelModeType.chat,
},
)
expect(screen.queryByText('appDebug.promptMode.operation.addMessage')).toBeNull()
})
// Completion mode
it('should update completion prompt value and flag as user change', () => {
const setCurrentAdvancedPrompt = jest.fn()
renderComponent(
{},
{
isAdvancedMode: true,
currentAdvancedPrompt: { role: PromptRole.user, text: 'single' },
modelModeType: ModelModeType.completion,
setCurrentAdvancedPrompt,
},
)
fireEvent.click(screen.getByText('change'))
expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith({ role: PromptRole.user, text: 'updated text' }, true)
})
})

View File

@ -0,0 +1,37 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import MessageTypeSelector from './message-type-selector'
import { PromptRole } from '@/models/debug'
describe('MessageTypeSelector', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should render current value and keep options hidden by default', () => {
render(<MessageTypeSelector value={PromptRole.user} onChange={jest.fn()} />)
expect(screen.getByText(PromptRole.user)).toBeInTheDocument()
expect(screen.queryByText(PromptRole.system)).toBeNull()
})
it('should toggle option list when clicking the selector', () => {
render(<MessageTypeSelector value={PromptRole.system} onChange={jest.fn()} />)
fireEvent.click(screen.getByText(PromptRole.system))
expect(screen.getByText(PromptRole.user)).toBeInTheDocument()
expect(screen.getByText(PromptRole.assistant)).toBeInTheDocument()
})
it('should call onChange with selected type and close the list', () => {
const onChange = jest.fn()
render(<MessageTypeSelector value={PromptRole.assistant} onChange={onChange} />)
fireEvent.click(screen.getByText(PromptRole.assistant))
fireEvent.click(screen.getByText(PromptRole.user))
expect(onChange).toHaveBeenCalledWith(PromptRole.user)
expect(screen.queryByText(PromptRole.system)).toBeNull()
})
})

View File

@ -0,0 +1,66 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
describe('PromptEditorHeightResizeWrap', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.useFakeTimers()
})
afterEach(() => {
jest.runOnlyPendingTimers()
jest.useRealTimers()
})
it('should render children, footer, and hide resize handler when requested', () => {
const { container } = render(
<PromptEditorHeightResizeWrap
className="wrapper"
height={150}
minHeight={100}
onHeightChange={jest.fn()}
footer={<div>footer</div>}
hideResize
>
<div>content</div>
</PromptEditorHeightResizeWrap>,
)
expect(screen.getByText('content')).toBeInTheDocument()
expect(screen.getByText('footer')).toBeInTheDocument()
expect(container.querySelector('.cursor-row-resize')).toBeNull()
})
it('should resize height with mouse events and clamp to minHeight', () => {
const onHeightChange = jest.fn()
const { container } = render(
<PromptEditorHeightResizeWrap
height={150}
minHeight={100}
onHeightChange={onHeightChange}
>
<div>content</div>
</PromptEditorHeightResizeWrap>,
)
const handle = container.querySelector('.cursor-row-resize')
expect(handle).not.toBeNull()
fireEvent.mouseDown(handle as Element, { clientY: 100 })
expect(document.body.style.userSelect).toBe('none')
fireEvent.mouseMove(document, { clientY: 130 })
jest.runAllTimers()
expect(onHeightChange).toHaveBeenLastCalledWith(180)
onHeightChange.mockClear()
fireEvent.mouseMove(document, { clientY: -100 })
jest.runAllTimers()
expect(onHeightChange).toHaveBeenLastCalledWith(100)
fireEvent.mouseUp(document)
expect(document.body.style.userSelect).toBe('')
})
})

View File

@ -0,0 +1,480 @@
import '@testing-library/jest-dom'
import type { CSSProperties } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import DebugWithMultipleModel from './index'
import type { DebugWithMultipleModelContextType } from './context'
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types'
import type { ModelAndParameter } from '../types'
import type { Inputs, ModelConfig } from '@/models/debug'
import { DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import type { FeatureStoreState } from '@/app/components/base/features/store'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { InputForm } from '@/app/components/base/chat/chat/type'
import { AppModeEnum, ModelModeType, type PromptVariable, Resolution, TransferMethod } from '@/types/app'
type PromptVariableWithMeta = Omit<PromptVariable, 'type' | 'required'> & {
type: PromptVariable['type'] | 'api'
required?: boolean
hide?: boolean
}
const mockUseDebugConfigurationContext = jest.fn()
const mockUseFeaturesSelector = jest.fn()
const mockUseEventEmitterContext = jest.fn()
const mockUseAppStoreSelector = jest.fn()
const mockEventEmitter = { emit: jest.fn() }
const mockSetShowAppConfigureFeaturesModal = jest.fn()
let capturedChatInputProps: MockChatInputAreaProps | null = null
let modelIdCounter = 0
let featureState: FeatureStoreState
type MockChatInputAreaProps = {
onSend?: (message: string, files?: FileEntity[]) => void
onFeatureBarClick?: (state: boolean) => void
showFeatureBar?: boolean
showFileUpload?: boolean
inputs?: Record<string, any>
inputsForm?: InputForm[]
speechToTextConfig?: unknown
visionConfig?: unknown
}
const mockFiles: FileEntity[] = [
{
id: 'file-1',
name: 'file.txt',
size: 10,
type: 'text/plain',
progress: 100,
transferMethod: TransferMethod.remote_url,
supportFileType: 'text',
},
]
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('@/context/debug-configuration', () => ({
__esModule: true,
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
}))
jest.mock('@/app/components/base/features/hooks', () => ({
__esModule: true,
useFeatures: (selector: (state: FeatureStoreState) => unknown) => mockUseFeaturesSelector(selector),
}))
jest.mock('@/context/event-emitter', () => ({
__esModule: true,
useEventEmitterContextContext: () => mockUseEventEmitterContext(),
}))
jest.mock('@/app/components/app/store', () => ({
__esModule: true,
useStore: (selector: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => mockUseAppStoreSelector(selector),
}))
jest.mock('./debug-item', () => ({
__esModule: true,
default: ({
modelAndParameter,
className,
style,
}: {
modelAndParameter: ModelAndParameter
className?: string
style?: CSSProperties
}) => (
<div
data-testid='debug-item'
data-model-id={modelAndParameter.id}
className={className}
style={style}
>
DebugItem-{modelAndParameter.id}
</div>
),
}))
jest.mock('@/app/components/base/chat/chat/chat-input-area', () => ({
__esModule: true,
default: (props: MockChatInputAreaProps) => {
capturedChatInputProps = props
return (
<div data-testid='chat-input-area'>
<button type='button' onClick={() => props.onSend?.('test message', mockFiles)}>send</button>
<button type='button' onClick={() => props.onFeatureBarClick?.(true)}>feature</button>
</div>
)
},
}))
const createFeatureState = (): FeatureStoreState => ({
features: {
speech2text: { enabled: true },
file: {
image: {
enabled: true,
detail: Resolution.high,
number_limits: 2,
transfer_methods: [TransferMethod.remote_url],
},
},
},
setFeatures: jest.fn(),
showFeaturesModal: false,
setShowFeaturesModal: jest.fn(),
})
const createModelConfig = (promptVariables: PromptVariableWithMeta[] = []): ModelConfig => ({
provider: 'OPENAI',
model_id: 'gpt-4',
mode: ModelModeType.chat,
configs: {
prompt_template: '',
prompt_variables: promptVariables as unknown as PromptVariable[],
},
chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG,
completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG,
opening_statement: '',
more_like_this: null,
suggested_questions: [],
suggested_questions_after_answer: null,
speech_to_text: null,
text_to_speech: null,
file_upload: null,
retriever_resource: null,
sensitive_word_avoidance: null,
annotation_reply: null,
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 0,
file_size_limit: 0,
image_file_size_limit: 0,
video_file_size_limit: 0,
workflow_file_upload_limit: 0,
},
dataSets: [],
agentConfig: DEFAULT_AGENT_SETTING,
})
type DebugConfiguration = {
mode: AppModeEnum
inputs: Inputs
modelConfig: ModelConfig
}
const createDebugConfiguration = (overrides: Partial<DebugConfiguration> = {}): DebugConfiguration => ({
mode: AppModeEnum.CHAT,
inputs: {},
modelConfig: createModelConfig(),
...overrides,
})
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
id: `model-${++modelIdCounter}`,
model: 'gpt-3.5-turbo',
provider: 'openai',
parameters: {},
...overrides,
})
const createProps = (overrides: Partial<DebugWithMultipleModelContextType> = {}): DebugWithMultipleModelContextType => ({
multipleModelConfigs: [createModelAndParameter()],
onMultipleModelConfigsChange: jest.fn(),
onDebugWithMultipleModelChange: jest.fn(),
...overrides,
})
const renderComponent = (props?: Partial<DebugWithMultipleModelContextType>) => {
const mergedProps = createProps(props)
return render(<DebugWithMultipleModel {...mergedProps} />)
}
describe('DebugWithMultipleModel', () => {
beforeEach(() => {
jest.clearAllMocks()
capturedChatInputProps = null
modelIdCounter = 0
featureState = createFeatureState()
mockUseFeaturesSelector.mockImplementation(selector => selector(featureState))
mockUseEventEmitterContext.mockReturnValue({ eventEmitter: mockEventEmitter })
mockUseAppStoreSelector.mockImplementation(selector => selector({ setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal }))
mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration())
})
describe('chat input rendering', () => {
it('should render chat input in chat mode with transformed prompt variables and feature handler', () => {
// Arrange
const promptVariables: PromptVariableWithMeta[] = [
{ key: 'city', name: 'City', type: 'string', required: true },
{ key: 'audience', name: 'Audience', type: 'number' },
{ key: 'hidden', name: 'Hidden', type: 'select', hide: true },
{ key: 'api-only', name: 'API Only', type: 'api' },
]
const debugConfiguration = createDebugConfiguration({
inputs: { audience: 'engineers' },
modelConfig: createModelConfig(promptVariables),
})
mockUseDebugConfigurationContext.mockReturnValue(debugConfiguration)
// Act
renderComponent()
fireEvent.click(screen.getByRole('button', { name: /feature/i }))
// Assert
expect(screen.getByTestId('chat-input-area')).toBeInTheDocument()
expect(capturedChatInputProps?.inputs).toEqual({ audience: 'engineers' })
expect(capturedChatInputProps?.inputsForm).toEqual([
expect.objectContaining({ label: 'City', variable: 'city', hide: false, required: true }),
expect.objectContaining({ label: 'Audience', variable: 'audience', hide: false, required: false }),
expect.objectContaining({ label: 'Hidden', variable: 'hidden', hide: true, required: false }),
])
expect(capturedChatInputProps?.showFeatureBar).toBe(true)
expect(capturedChatInputProps?.showFileUpload).toBe(false)
expect(capturedChatInputProps?.speechToTextConfig).toEqual(featureState.features.speech2text)
expect(capturedChatInputProps?.visionConfig).toEqual(featureState.features.file)
expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalledWith(true)
})
it('should render chat input in agent chat mode', () => {
// Arrange
mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration({
mode: AppModeEnum.AGENT_CHAT,
}))
// Act
renderComponent()
// Assert
expect(screen.getByTestId('chat-input-area')).toBeInTheDocument()
})
it('should hide chat input when not in chat mode', () => {
// Arrange
mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration({
mode: AppModeEnum.COMPLETION,
}))
const multipleModelConfigs = [createModelAndParameter()]
// Act
renderComponent({ multipleModelConfigs })
// Assert
expect(screen.queryByTestId('chat-input-area')).not.toBeInTheDocument()
expect(screen.getAllByTestId('debug-item')).toHaveLength(1)
})
})
describe('sending flow', () => {
it('should emit chat event when allowed to send', () => {
// Arrange
const checkCanSend = jest.fn(() => true)
const multipleModelConfigs = [createModelAndParameter(), createModelAndParameter()]
renderComponent({ multipleModelConfigs, checkCanSend })
// Act
fireEvent.click(screen.getByRole('button', { name: /send/i }))
// Assert
expect(checkCanSend).toHaveBeenCalled()
expect(mockEventEmitter.emit).toHaveBeenCalledWith({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: {
message: 'test message',
files: mockFiles,
},
})
})
it('should emit when no checkCanSend is provided', () => {
renderComponent()
fireEvent.click(screen.getByRole('button', { name: /send/i }))
expect(mockEventEmitter.emit).toHaveBeenCalledWith({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: {
message: 'test message',
files: mockFiles,
},
})
})
it('should block sending when checkCanSend returns false', () => {
// Arrange
const checkCanSend = jest.fn(() => false)
renderComponent({ checkCanSend })
// Act
fireEvent.click(screen.getByRole('button', { name: /send/i }))
// Assert
expect(checkCanSend).toHaveBeenCalled()
expect(mockEventEmitter.emit).not.toHaveBeenCalled()
})
it('should tolerate missing event emitter without throwing', () => {
mockUseEventEmitterContext.mockReturnValue({ eventEmitter: null })
renderComponent()
expect(() => fireEvent.click(screen.getByRole('button', { name: /send/i }))).not.toThrow()
expect(mockEventEmitter.emit).not.toHaveBeenCalled()
})
})
describe('layout sizing and positioning', () => {
const expectItemLayout = (
element: HTMLElement,
expectation: {
width?: string
height?: string
transform: string
classes?: string[]
},
) => {
if (expectation.width !== undefined)
expect(element.style.width).toBe(expectation.width)
else
expect(element.style.width).toBe('')
if (expectation.height !== undefined)
expect(element.style.height).toBe(expectation.height)
else
expect(element.style.height).toBe('')
expect(element.style.transform).toBe(expectation.transform)
expectation.classes?.forEach(cls => expect(element).toHaveClass(cls))
}
it('should arrange items in two-column layout for two models', () => {
// Arrange
const multipleModelConfigs = [createModelAndParameter(), createModelAndParameter()]
// Act
renderComponent({ multipleModelConfigs })
const items = screen.getAllByTestId('debug-item')
// Assert
expect(items).toHaveLength(2)
expectItemLayout(items[0], {
width: 'calc(50% - 4px - 24px)',
height: '100%',
transform: 'translateX(0) translateY(0)',
classes: ['mr-2'],
})
expectItemLayout(items[1], {
width: 'calc(50% - 4px - 24px)',
height: '100%',
transform: 'translateX(calc(100% + 8px)) translateY(0)',
classes: [],
})
})
it('should arrange items in thirds for three models', () => {
// Arrange
const multipleModelConfigs = [createModelAndParameter(), createModelAndParameter(), createModelAndParameter()]
// Act
renderComponent({ multipleModelConfigs })
const items = screen.getAllByTestId('debug-item')
// Assert
expect(items).toHaveLength(3)
expectItemLayout(items[0], {
width: 'calc(33.3% - 5.33px - 16px)',
height: '100%',
transform: 'translateX(0) translateY(0)',
classes: ['mr-2'],
})
expectItemLayout(items[1], {
width: 'calc(33.3% - 5.33px - 16px)',
height: '100%',
transform: 'translateX(calc(100% + 8px)) translateY(0)',
classes: ['mr-2'],
})
expectItemLayout(items[2], {
width: 'calc(33.3% - 5.33px - 16px)',
height: '100%',
transform: 'translateX(calc(200% + 16px)) translateY(0)',
classes: [],
})
})
it('should position items on a grid for four models', () => {
// Arrange
const multipleModelConfigs = [
createModelAndParameter(),
createModelAndParameter(),
createModelAndParameter(),
createModelAndParameter(),
]
// Act
renderComponent({ multipleModelConfigs })
const items = screen.getAllByTestId('debug-item')
// Assert
expect(items).toHaveLength(4)
expectItemLayout(items[0], {
width: 'calc(50% - 4px - 24px)',
height: 'calc(50% - 4px)',
transform: 'translateX(0) translateY(0)',
classes: ['mr-2', 'mb-2'],
})
expectItemLayout(items[1], {
width: 'calc(50% - 4px - 24px)',
height: 'calc(50% - 4px)',
transform: 'translateX(calc(100% + 8px)) translateY(0)',
classes: ['mb-2'],
})
expectItemLayout(items[2], {
width: 'calc(50% - 4px - 24px)',
height: 'calc(50% - 4px)',
transform: 'translateX(0) translateY(calc(100% + 8px))',
classes: ['mr-2'],
})
expectItemLayout(items[3], {
width: 'calc(50% - 4px - 24px)',
height: 'calc(50% - 4px)',
transform: 'translateX(calc(100% + 8px)) translateY(calc(100% + 8px))',
classes: [],
})
})
it('should fall back to single column layout when only one model is provided', () => {
// Arrange
const multipleModelConfigs = [createModelAndParameter()]
// Act
renderComponent({ multipleModelConfigs })
const item = screen.getByTestId('debug-item')
// Assert
expectItemLayout(item, {
transform: 'translateX(0) translateY(0)',
classes: [],
})
})
it('should set scroll area height for chat modes', () => {
const { container } = renderComponent()
const scrollArea = container.querySelector('.relative.mb-3.grow.overflow-auto.px-6') as HTMLElement
expect(scrollArea).toBeInTheDocument()
expect(scrollArea.style.height).toBe('calc(100% - 60px)')
})
it('should set full height when chat input is hidden', () => {
mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration({
mode: AppModeEnum.COMPLETION,
}))
const { container } = renderComponent()
const scrollArea = container.querySelector('.relative.mb-3.grow.overflow-auto.px-6') as HTMLElement
expect(scrollArea.style.height).toBe('100%')
})
})
})

View File

@ -11,13 +11,19 @@ export type IAmplitudeProps = {
sessionReplaySampleRate?: number
}
// Check if Amplitude should be enabled
export const isAmplitudeEnabled = () => {
const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY
return IS_CLOUD_EDITION && !!apiKey
}
const AmplitudeProvider: FC<IAmplitudeProps> = ({
apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY ?? '',
sessionReplaySampleRate = 1,
}) => {
useEffect(() => {
// Only enable in Saas edition
if (!IS_CLOUD_EDITION)
// Only enable in Saas edition with valid API key
if (!isAmplitudeEnabled())
return
// Initialize Amplitude

View File

@ -1,2 +1,2 @@
export { default } from './AmplitudeProvider'
export { default, isAmplitudeEnabled } from './AmplitudeProvider'
export { resetUser, setUserId, setUserProperties, trackEvent } from './utils'

View File

@ -1,4 +1,5 @@
import * as amplitude from '@amplitude/analytics-browser'
import { isAmplitudeEnabled } from './AmplitudeProvider'
/**
* Track custom event
@ -6,6 +7,8 @@ import * as amplitude from '@amplitude/analytics-browser'
* @param eventProperties Event properties (optional)
*/
export const trackEvent = (eventName: string, eventProperties?: Record<string, any>) => {
if (!isAmplitudeEnabled())
return
amplitude.track(eventName, eventProperties)
}
@ -14,6 +17,8 @@ export const trackEvent = (eventName: string, eventProperties?: Record<string, a
* @param userId User ID
*/
export const setUserId = (userId: string) => {
if (!isAmplitudeEnabled())
return
amplitude.setUserId(userId)
}
@ -22,6 +27,8 @@ export const setUserId = (userId: string) => {
* @param properties User properties
*/
export const setUserProperties = (properties: Record<string, any>) => {
if (!isAmplitudeEnabled())
return
const identifyEvent = new amplitude.Identify()
Object.entries(properties).forEach(([key, value]) => {
identifyEvent.set(key, value)
@ -33,5 +40,7 @@ export const setUserProperties = (properties: Record<string, any>) => {
* Reset user (e.g., when user logs out)
*/
export const resetUser = () => {
if (!isAmplitudeEnabled())
return
amplitude.reset()
}

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 6C19 5.44771 18.5523 5 18 5H6C5.44771 5 5 5.44771 5 6V18C5 18.5523 5.44771 19 6 19H18C18.5523 19 19 18.5523 19 18V6ZM9.73926 13.1533C10.0706 12.7115 10.6978 12.6218 11.1396 12.9531C11.5815 13.2845 11.6712 13.9117 11.3398 14.3535L9.46777 16.8486C9.14935 17.2732 8.55487 17.3754 8.11328 17.0811L6.98828 16.3311C6.52878 16.0247 6.40465 15.4039 6.71094 14.9443C7.01729 14.4848 7.63813 14.3606 8.09766 14.667L8.43457 14.8916L9.73926 13.1533ZM16 14C16.5523 14 17 14.4477 17 15C17 15.5523 16.5523 16 16 16H14C13.4477 16 13 15.5523 13 15C13 14.4477 13.4477 14 14 14H16ZM9.73926 7.15234C10.0706 6.71052 10.6978 6.62079 11.1396 6.95215C11.5815 7.28352 11.6712 7.91071 11.3398 8.35254L9.46777 10.8477C9.14936 11.2722 8.55487 11.3744 8.11328 11.0801L6.98828 10.3301C6.52884 10.0238 6.40476 9.40286 6.71094 8.94336C7.0173 8.48384 7.63814 8.35965 8.09766 8.66602L8.43457 8.89062L9.73926 7.15234ZM16.0576 8C16.6099 8 17.0576 8.44772 17.0576 9C17.0576 9.55228 16.6099 10 16.0576 10H14.0576C13.5055 9.99985 13.0576 9.55219 13.0576 9C13.0576 8.44781 13.5055 8.00015 14.0576 8H16.0576ZM21 18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34315 4.34315 3 6 3H18C19.6569 3 21 4.34315 21 6V18Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,26 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "24",
"height": "24",
"viewBox": "0 0 24 24",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M19 6C19 5.44771 18.5523 5 18 5H6C5.44771 5 5 5.44771 5 6V18C5 18.5523 5.44771 19 6 19H18C18.5523 19 19 18.5523 19 18V6ZM9.73926 13.1533C10.0706 12.7115 10.6978 12.6218 11.1396 12.9531C11.5815 13.2845 11.6712 13.9117 11.3398 14.3535L9.46777 16.8486C9.14935 17.2732 8.55487 17.3754 8.11328 17.0811L6.98828 16.3311C6.52878 16.0247 6.40465 15.4039 6.71094 14.9443C7.01729 14.4848 7.63813 14.3606 8.09766 14.667L8.43457 14.8916L9.73926 13.1533ZM16 14C16.5523 14 17 14.4477 17 15C17 15.5523 16.5523 16 16 16H14C13.4477 16 13 15.5523 13 15C13 14.4477 13.4477 14 14 14H16ZM9.73926 7.15234C10.0706 6.71052 10.6978 6.62079 11.1396 6.95215C11.5815 7.28352 11.6712 7.91071 11.3398 8.35254L9.46777 10.8477C9.14936 11.2722 8.55487 11.3744 8.11328 11.0801L6.98828 10.3301C6.52884 10.0238 6.40476 9.40286 6.71094 8.94336C7.0173 8.48384 7.63814 8.35965 8.09766 8.66602L8.43457 8.89062L9.73926 7.15234ZM16.0576 8C16.6099 8 17.0576 8.44772 17.0576 9C17.0576 9.55228 16.6099 10 16.0576 10H14.0576C13.5055 9.99985 13.0576 9.55219 13.0576 9C13.0576 8.44781 13.5055 8.00015 14.0576 8H16.0576ZM21 18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34315 4.34315 3 6 3H18C19.6569 3 21 4.34315 21 6V18Z",
"fill": "currentColor"
},
"children": []
}
]
},
"name": "SquareChecklist"
}

View File

@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './SquareChecklist.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'SquareChecklist'
export default Icon

View File

@ -6,3 +6,4 @@ export { default as Mcp } from './Mcp'
export { default as NoToolPlaceholder } from './NoToolPlaceholder'
export { default as Openai } from './Openai'
export { default as ReplayLine } from './ReplayLine'
export { default as SquareChecklist } from './SquareChecklist'

View File

@ -21,7 +21,6 @@ type NotionPageSelectorProps = {
datasetId?: string
credentialList: DataSourceCredential[]
onSelectCredential?: (credentialId: string) => void
supportBatchUpload?: boolean
}
const NotionPageSelector = ({
@ -33,7 +32,6 @@ const NotionPageSelector = ({
datasetId = '',
credentialList,
onSelectCredential,
supportBatchUpload = false,
}: NotionPageSelectorProps) => {
const [searchValue, setSearchValue] = useState('')
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
@ -177,7 +175,6 @@ const NotionPageSelector = ({
canPreview={canPreview}
previewPageId={previewPageId}
onPreview={handlePreviewPage}
isMultipleChoice={supportBatchUpload}
/>
)}
</div>

View File

@ -7,7 +7,6 @@ import Checkbox from '../../checkbox'
import NotionIcon from '../../notion-icon'
import cn from '@/utils/classnames'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import Radio from '@/app/components/base/radio/ui'
type PageSelectorProps = {
value: Set<string>
@ -82,7 +81,6 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
searchValue: string
previewPageId: string
pagesMap: DataSourceNotionPageMap
isMultipleChoice?: boolean
}>) => {
const { t } = useTranslation()
const {
@ -97,7 +95,6 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
searchValue,
previewPageId,
pagesMap,
isMultipleChoice,
} = data
const current = dataList[index]
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[current.page_id]
@ -138,24 +135,14 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
previewPageId === current.page_id && 'bg-state-base-hover')}
style={{ ...style, top: style.top as number + 8, left: 8, right: 8, width: 'calc(100% - 16px)' }}
>
{isMultipleChoice ? (
<Checkbox
className='mr-2 shrink-0'
checked={checkedIds.has(current.page_id)}
disabled={disabled}
onCheck={() => {
handleCheck(index)
}}
/>) : (
<Radio
className='mr-2 shrink-0'
isChecked={checkedIds.has(current.page_id)}
disabled={disabled}
onCheck={() => {
handleCheck(index)
}}
/>
)}
<Checkbox
className='mr-2 shrink-0'
checked={checkedIds.has(current.page_id)}
disabled={disabled}
onCheck={() => {
handleCheck(index)
}}
/>
{!searchValue && renderArrow()}
<NotionIcon
className='mr-1 shrink-0'
@ -204,7 +191,6 @@ const PageSelector = ({
canPreview = true,
previewPageId,
onPreview,
isMultipleChoice = true,
}: PageSelectorProps) => {
const { t } = useTranslation()
const [dataList, setDataList] = useState<NotionPageItem[]>([])
@ -278,7 +264,7 @@ const PageSelector = ({
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
if (copyValue.has(pageId)) {
if (!searchValue && isMultipleChoice) {
if (!searchValue) {
for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.delete(item)
}
@ -286,18 +272,12 @@ const PageSelector = ({
copyValue.delete(pageId)
}
else {
if (!searchValue && isMultipleChoice) {
if (!searchValue) {
for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.add(item)
}
// Single choice mode, clear previous selection
if (!isMultipleChoice && copyValue.size > 0) {
copyValue.clear()
copyValue.add(pageId)
}
else {
copyValue.add(pageId)
}
copyValue.add(pageId)
}
onSelect(new Set(copyValue))
@ -341,7 +321,6 @@ const PageSelector = ({
searchValue,
previewPageId: currentPreviewPageId,
pagesMap,
isMultipleChoice,
}}
>
{Item}

View File

@ -12,6 +12,7 @@ const PremiumBadgeVariants = cva(
size: {
s: 'premium-badge-s',
m: 'premium-badge-m',
custom: '',
},
color: {
blue: 'premium-badge-blue',
@ -33,7 +34,7 @@ const PremiumBadgeVariants = cva(
)
type PremiumBadgeProps = {
size?: 's' | 'm'
size?: 's' | 'm' | 'custom'
color?: 'blue' | 'indigo' | 'gray' | 'orange'
allowHover?: boolean
styleCss?: CSSProperties

View File

@ -0,0 +1,118 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import PlanUpgradeModal from './index'
const mockSetShowPricingModal = jest.fn()
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('@/app/components/base/modal', () => {
const MockModal = ({ isShow, children }: { isShow: boolean; children: React.ReactNode }) => (
isShow ? <div data-testid="plan-upgrade-modal">{children}</div> : null
)
return {
__esModule: true,
default: MockModal,
}
})
jest.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
}),
}))
const baseProps = {
title: 'Upgrade Required',
description: 'You need to upgrade your plan.',
show: true,
onClose: jest.fn(),
}
const renderComponent = (props: Partial<React.ComponentProps<typeof PlanUpgradeModal>> = {}) => {
const mergedProps = { ...baseProps, ...props }
return render(<PlanUpgradeModal {...mergedProps} />)
}
describe('PlanUpgradeModal', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Rendering and props-driven content
it('should render modal with provided content when visible', () => {
// Arrange
const extraInfoText = 'Additional upgrade details'
renderComponent({
extraInfo: <div>{extraInfoText}</div>,
})
// Assert
expect(screen.getByText(baseProps.title)).toBeInTheDocument()
expect(screen.getByText(baseProps.description)).toBeInTheDocument()
expect(screen.getByText(extraInfoText)).toBeInTheDocument()
expect(screen.getByText('billing.triggerLimitModal.dismiss')).toBeInTheDocument()
expect(screen.getByText('billing.triggerLimitModal.upgrade')).toBeInTheDocument()
})
// Guard against rendering when modal is hidden
it('should not render content when show is false', () => {
// Act
renderComponent({ show: false })
// Assert
expect(screen.queryByText(baseProps.title)).not.toBeInTheDocument()
expect(screen.queryByText(baseProps.description)).not.toBeInTheDocument()
})
// User closes the modal from dismiss button
it('should call onClose when dismiss button is clicked', async () => {
// Arrange
const user = userEvent.setup()
const onClose = jest.fn()
renderComponent({ onClose })
// Act
await user.click(screen.getByText('billing.triggerLimitModal.dismiss'))
// Assert
expect(onClose).toHaveBeenCalledTimes(1)
})
// Upgrade path uses provided callback over pricing modal
it('should call onUpgrade and onClose when upgrade button is clicked with onUpgrade provided', async () => {
// Arrange
const user = userEvent.setup()
const onClose = jest.fn()
const onUpgrade = jest.fn()
renderComponent({ onClose, onUpgrade })
// Act
await user.click(screen.getByText('billing.triggerLimitModal.upgrade'))
// Assert
expect(onClose).toHaveBeenCalledTimes(1)
expect(onUpgrade).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
// Fallback upgrade path opens pricing modal when no onUpgrade is supplied
it('should open pricing modal when upgrade button is clicked without onUpgrade', async () => {
// Arrange
const user = userEvent.setup()
const onClose = jest.fn()
renderComponent({ onClose, onUpgrade: undefined })
// Act
await user.click(screen.getByText('billing.triggerLimitModal.upgrade'))
// Assert
expect(onClose).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,87 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import styles from './style.module.css'
import { SquareChecklist } from '../../base/icons/src/vender/other'
import { useModalContext } from '@/context/modal-context'
type Props = {
Icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>
title: string
description: string
extraInfo?: React.ReactNode
show: boolean
onClose: () => void
onUpgrade?: () => void
}
const PlanUpgradeModal: FC<Props> = ({
Icon = SquareChecklist,
title,
description,
extraInfo,
show,
onClose,
onUpgrade,
}) => {
const { t } = useTranslation()
const { setShowPricingModal } = useModalContext()
const handleUpgrade = useCallback(() => {
onClose()
onUpgrade ? onUpgrade() : setShowPricingModal()
}, [onClose, onUpgrade, setShowPricingModal])
return (
<Modal
isShow={show}
onClose={onClose}
closable={false}
clickOutsideNotClose
className={`${styles.surface} w-[580px] rounded-2xl !p-0`}
>
<div className='relative'>
<div
aria-hidden
className={`${styles.heroOverlay} pointer-events-none absolute inset-0`}
/>
<div className='px-8 pt-8'>
<div className={`${styles.icon} flex size-12 items-center justify-center rounded-xl shadow-lg backdrop-blur-[5px]`}>
<Icon className='size-6 text-text-primary-on-surface' />
</div>
<div className='mt-6 space-y-2'>
<div className={`${styles.highlight} title-3xl-semi-bold`}>
{title}
</div>
<div className='system-md-regular text-text-tertiary'>
{description}
</div>
</div>
{extraInfo}
</div>
</div>
<div className='mb-8 mt-10 flex justify-end space-x-2 px-8'>
<Button
onClick={onClose}
>
{t('billing.triggerLimitModal.dismiss')}
</Button>
<UpgradeBtn
size='custom'
isShort
onClick={handleUpgrade}
className='!h-8 !rounded-lg px-2'
labelKey='billing.triggerLimitModal.upgrade'
loc='trigger-events-limit-modal'
/>
</div>
</Modal>
)
}
export default React.memo(PlanUpgradeModal)

View File

@ -19,7 +19,6 @@
background:
linear-gradient(180deg, var(--color-components-avatar-bg-mask-stop-0, rgba(255, 255, 255, 0.12)) 0%, var(--color-components-avatar-bg-mask-stop-100, rgba(255, 255, 255, 0.08)) 100%),
var(--color-util-colors-blue-brand-blue-brand-500, #296dff);
box-shadow: 0 10px 20px color-mix(in srgb, var(--color-util-colors-blue-brand-blue-brand-500, #296dff) 35%, transparent);
}
.highlight {

View File

@ -2,27 +2,22 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import UsageInfo from '@/app/components/billing/usage-info'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import type { Plan } from '@/app/components/billing/type'
import styles from './index.module.css'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
type Props = {
show: boolean
onDismiss: () => void
onClose: () => void
onUpgrade: () => void
usage: number
total: number
resetInDays?: number
planType: Plan
}
const TriggerEventsLimitModal: FC<Props> = ({
show,
onDismiss,
onClose,
onUpgrade,
usage,
total,
@ -31,59 +26,25 @@ const TriggerEventsLimitModal: FC<Props> = ({
const { t } = useTranslation()
return (
<Modal
isShow={show}
onClose={onDismiss}
closable={false}
clickOutsideNotClose
className={`${styles.surface} flex h-[360px] w-[580px] flex-col overflow-hidden rounded-2xl !p-0 shadow-xl`}
>
<div className='relative flex w-full flex-1 items-stretch justify-center'>
<div
aria-hidden
className={`${styles.heroOverlay} pointer-events-none absolute inset-0`}
<PlanUpgradeModal
show={show}
onClose={onClose}
onUpgrade={onUpgrade}
Icon={TriggerAll as React.ComponentType<React.SVGProps<SVGSVGElement>>}
title={t('billing.triggerLimitModal.title')}
description={t('billing.triggerLimitModal.description')}
extraInfo={(
<UsageInfo
className='mt-4 w-full rounded-[12px] bg-components-panel-on-panel-item-bg'
Icon={TriggerAll}
name={t('billing.triggerLimitModal.usageTitle')}
usage={usage}
total={total}
resetInDays={resetInDays}
hideIcon
/>
<div className='relative z-10 flex w-full flex-col items-start gap-4 px-8 pt-8'>
<div className={`${styles.icon} flex h-12 w-12 items-center justify-center rounded-[12px]`}>
<TriggerAll className='h-5 w-5 text-text-primary-on-surface' />
</div>
<div className='flex flex-col items-start gap-2'>
<div className={`${styles.highlight} title-lg-semi-bold`}>
{t('billing.triggerLimitModal.title')}
</div>
<div className='body-md-regular text-text-secondary'>
{t('billing.triggerLimitModal.description')}
</div>
</div>
<UsageInfo
className='mb-5 w-full rounded-[12px] bg-components-panel-on-panel-item-bg'
Icon={TriggerAll}
name={t('billing.triggerLimitModal.usageTitle')}
usage={usage}
total={total}
resetInDays={resetInDays}
hideIcon
/>
</div>
</div>
<div className='flex h-[76px] w-full items-center justify-end gap-2 px-8 pb-8 pt-5'>
<Button
className='h-8 w-[77px] min-w-[72px] !rounded-lg !border-[0.5px] px-3 py-2'
onClick={onDismiss}
>
{t('billing.triggerLimitModal.dismiss')}
</Button>
<UpgradeBtn
isShort
onClick={onUpgrade}
className='flex w-[93px] items-center justify-center !rounded-lg !px-2'
style={{ height: 32 }}
labelKey='billing.triggerLimitModal.upgrade'
loc='trigger-events-limit-modal'
/>
</div>
</Modal>
)}
/>
)
}

View File

@ -11,7 +11,7 @@ type Props = {
className?: string
style?: CSSProperties
isFull?: boolean
size?: 'md' | 'lg'
size?: 's' | 'm' | 'custom'
isPlain?: boolean
isShort?: boolean
onClick?: () => void
@ -21,6 +21,7 @@ type Props = {
const UpgradeBtn: FC<Props> = ({
className,
size = 'm',
style,
isPlain = false,
isShort = false,
@ -62,7 +63,7 @@ const UpgradeBtn: FC<Props> = ({
return (
<PremiumBadge
size='m'
size={size}
color='blue'
allowHover={true}
onClick={onClick}

View File

@ -22,6 +22,10 @@ import classNames from '@/utils/classnames'
import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config'
import NotionConnector from '@/app/components/base/notion-connector'
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import { useBoolean } from 'ahooks'
import { Plan } from '@/app/components/billing/type'
import UpgradeCard from './upgrade-card'
type IStepOneProps = {
datasetId?: string
@ -52,7 +56,7 @@ const StepOne = ({
dataSourceTypeDisable,
changeType,
onSetting,
onStepChange,
onStepChange: doOnStepChange,
files,
updateFileList,
updateFile,
@ -110,7 +114,33 @@ const StepOne = ({
const hasNotin = notionPages.length > 0
const isVectorSpaceFull = plan.usage.vectorSpace >= plan.total.vectorSpace
const isShowVectorSpaceFull = (allFileLoaded || hasNotin) && isVectorSpaceFull && enableBilling
const supportBatchUpload = !enableBilling || plan.type !== 'sandbox'
const supportBatchUpload = !enableBilling || plan.type !== Plan.sandbox
const notSupportBatchUpload = !supportBatchUpload
const [isShowPlanUpgradeModal, {
setTrue: showPlanUpgradeModal,
setFalse: hidePlanUpgradeModal,
}] = useBoolean(false)
const onStepChange = useCallback(() => {
if (notSupportBatchUpload) {
let isMultiple = false
if (dataSourceType === DataSourceType.FILE && files.length > 1)
isMultiple = true
if (dataSourceType === DataSourceType.NOTION && notionPages.length > 1)
isMultiple = true
if (dataSourceType === DataSourceType.WEB && websitePages.length > 1)
isMultiple = true
if (isMultiple) {
showPlanUpgradeModal()
return
}
}
doOnStepChange()
}, [dataSourceType, doOnStepChange, files.length, notSupportBatchUpload, notionPages.length, showPlanUpgradeModal, websitePages.length])
const nextDisabled = useMemo(() => {
if (!files.length)
return true
@ -244,6 +274,14 @@ const StepOne = ({
</span>
</Button>
</div>
{
enableBilling && plan.type === Plan.sandbox && files.length > 0 && (
<div className='mt-5'>
<div className='mb-4 h-px bg-divider-subtle'></div>
<UpgradeCard />
</div>
)
}
</>
)}
{dataSourceType === DataSourceType.NOTION && (
@ -259,7 +297,6 @@ const StepOne = ({
credentialList={notionCredentialList}
onSelectCredential={updateNotionCredentialId}
datasetId={datasetId}
supportBatchUpload={supportBatchUpload}
/>
</div>
{isShowVectorSpaceFull && (
@ -291,7 +328,6 @@ const StepOne = ({
crawlOptions={crawlOptions}
onCrawlOptionsChange={onCrawlOptionsChange}
authedDataSourceList={authedDataSourceList}
supportBatchUpload={supportBatchUpload}
/>
</div>
{isShowVectorSpaceFull && (
@ -332,6 +368,14 @@ const StepOne = ({
/>
)}
{currentWebsite && <WebsitePreview payload={currentWebsite} hidePreview={hideWebsitePreview} />}
{isShowPlanUpgradeModal && (
<PlanUpgradeModal
show
onClose={hidePlanUpgradeModal}
title={t('billing.upgrade.uploadMultiplePages.title')!}
description={t('billing.upgrade.uploadMultiplePages.description')!}
/>
)}
</div>
</div>
</div>

View File

@ -0,0 +1,33 @@
'use client'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import { useModalContext } from '@/context/modal-context'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
const UpgradeCard: FC = () => {
const { t } = useTranslation()
const { setShowPricingModal } = useModalContext()
const handleUpgrade = useCallback(() => {
setShowPricingModal()
}, [setShowPricingModal])
return (
<div className='flex items-center justify-between rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg py-3 pl-4 pr-3.5 shadow-xs backdrop-blur-[5px] '>
<div>
<div className='title-md-semi-bold bg-[linear-gradient(92deg,_var(--components-input-border-active-prompt-1,_#0BA5EC)_0%,_var(--components-input-border-active-prompt-2,_#155AEF)_99.21%)] bg-clip-text text-transparent'>{t('billing.upgrade.uploadMultipleFiles.title')}</div>
<div className='system-xs-regular text-text-tertiary'>{t('billing.upgrade.uploadMultipleFiles.description')}</div>
</div>
<UpgradeBtn
size='custom'
isShort
className='ml-3 !h-8 !rounded-lg px-2'
labelKey='billing.triggerLimitModal.upgrade'
loc='upload-multiple-files'
onClick={handleUpgrade}
/>
</div>
)
}
export default React.memo(UpgradeCard)

View File

@ -6,7 +6,6 @@ import cn from '@/utils/classnames'
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
import Checkbox from '@/app/components/base/checkbox'
import Button from '@/app/components/base/button'
import Radio from '@/app/components/base/radio/ui'
type Props = {
payload: CrawlResultItemType
@ -14,7 +13,6 @@ type Props = {
isPreview: boolean
onCheckChange: (checked: boolean) => void
onPreview: () => void
isMultipleChoice: boolean
}
const CrawledResultItem: FC<Props> = ({
@ -23,7 +21,6 @@ const CrawledResultItem: FC<Props> = ({
isChecked,
onCheckChange,
onPreview,
isMultipleChoice,
}) => {
const { t } = useTranslation()
@ -34,21 +31,7 @@ const CrawledResultItem: FC<Props> = ({
<div className={cn(isPreview ? 'bg-state-base-active' : 'group hover:bg-state-base-hover', 'cursor-pointer rounded-lg p-2')}>
<div className='relative flex'>
<div className='flex h-5 items-center'>
{
isMultipleChoice ? (
<Checkbox
className='mr-2 shrink-0'
checked={isChecked}
onCheck={handleCheckChange}
/>
) : (
<Radio
className='mr-2 shrink-0'
isChecked={isChecked}
onCheck={handleCheckChange}
/>
)
}
<Checkbox className='mr-2 shrink-0' checked={isChecked} onCheck={handleCheckChange} />
</div>
<div className='flex min-w-0 grow flex-col'>
<div

View File

@ -16,7 +16,6 @@ type Props = {
onSelectedChange: (selected: CrawlResultItem[]) => void
onPreview: (payload: CrawlResultItem) => void
usedTime: number
isMultipleChoice: boolean
}
const CrawledResult: FC<Props> = ({
@ -26,7 +25,6 @@ const CrawledResult: FC<Props> = ({
onSelectedChange,
onPreview,
usedTime,
isMultipleChoice,
}) => {
const { t } = useTranslation()
@ -42,17 +40,13 @@ const CrawledResult: FC<Props> = ({
const handleItemCheckChange = useCallback((item: CrawlResultItem) => {
return (checked: boolean) => {
if (checked) {
if (isMultipleChoice)
onSelectedChange([...checkedList, item])
else
onSelectedChange([item])
}
else {
if (checked)
onSelectedChange([...checkedList, item])
else
onSelectedChange(checkedList.filter(checkedItem => checkedItem.source_url !== item.source_url))
}
}
}, [checkedList, isMultipleChoice, onSelectedChange])
}, [checkedList, onSelectedChange])
const [previewIndex, setPreviewIndex] = React.useState<number>(-1)
const handlePreview = useCallback((index: number) => {
@ -65,13 +59,11 @@ const CrawledResult: FC<Props> = ({
return (
<div className={cn(className, 'border-t-[0.5px] border-divider-regular shadow-xs shadow-shadow-shadow-3')}>
<div className='flex h-[34px] items-center justify-between px-4'>
{isMultipleChoice && (
<CheckboxWithLabel
isChecked={isCheckAll}
onChange={handleCheckedAll} label={isCheckAll ? t(`${I18N_PREFIX}.resetAll`) : t(`${I18N_PREFIX}.selectAll`)}
labelClassName='system-[13px] leading-[16px] font-medium text-text-secondary'
/>
)}
<CheckboxWithLabel
isChecked={isCheckAll}
onChange={handleCheckedAll} label={isCheckAll ? t(`${I18N_PREFIX}.resetAll`) : t(`${I18N_PREFIX}.selectAll`)}
labelClassName='system-[13px] leading-[16px] font-medium text-text-secondary'
/>
<div className='text-xs text-text-tertiary'>
{t(`${I18N_PREFIX}.scrapTimeInfo`, {
total: list.length,
@ -88,7 +80,6 @@ const CrawledResult: FC<Props> = ({
payload={item}
isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)}
onCheckChange={handleItemCheckChange(item)}
isMultipleChoice={isMultipleChoice}
/>
))}
</div>

View File

@ -26,7 +26,6 @@ type Props = {
onJobIdChange: (jobId: string) => void
crawlOptions: CrawlOptions
onCrawlOptionsChange: (payload: CrawlOptions) => void
supportBatchUpload: boolean
}
enum Step {
@ -42,7 +41,6 @@ const FireCrawl: FC<Props> = ({
onJobIdChange,
crawlOptions,
onCrawlOptionsChange,
supportBatchUpload,
}) => {
const { t } = useTranslation()
const [step, setStep] = useState<Step>(Step.init)
@ -168,12 +166,8 @@ const FireCrawl: FC<Props> = ({
setCrawlErrorMessage(errorMessage || t(`${I18N_PREFIX}.unknownError`))
}
else {
data.data = data.data.map((item: any) => ({
...item,
content: item.markdown,
}))
setCrawlResult(data)
onCheckedCrawlResultChange(supportBatchUpload ? (data.data || []) : (data.data?.slice(0, 1) || [])) // default select the crawl result
onCheckedCrawlResultChange(data.data || []) // default select the crawl result
setCrawlErrorMessage('')
}
}
@ -184,7 +178,7 @@ const FireCrawl: FC<Props> = ({
finally {
setStep(Step.finished)
}
}, [checkValid, crawlOptions, onJobIdChange, waitForCrawlFinished, t, onCheckedCrawlResultChange, supportBatchUpload])
}, [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished, onCheckedCrawlResultChange])
return (
<div>
@ -223,7 +217,6 @@ const FireCrawl: FC<Props> = ({
onSelectedChange={onCheckedCrawlResultChange}
onPreview={onPreview}
usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0}
isMultipleChoice={supportBatchUpload}
/>
}
</div>

View File

@ -24,7 +24,6 @@ type Props = {
crawlOptions: CrawlOptions
onCrawlOptionsChange: (payload: CrawlOptions) => void
authedDataSourceList: DataSourceAuth[]
supportBatchUpload?: boolean
}
const Website: FC<Props> = ({
@ -36,7 +35,6 @@ const Website: FC<Props> = ({
crawlOptions,
onCrawlOptionsChange,
authedDataSourceList,
supportBatchUpload = false,
}) => {
const { t } = useTranslation()
const { setShowAccountSettingModal } = useModalContext()
@ -118,7 +116,6 @@ const Website: FC<Props> = ({
onJobIdChange={onJobIdChange}
crawlOptions={crawlOptions}
onCrawlOptionsChange={onCrawlOptionsChange}
supportBatchUpload={supportBatchUpload}
/>
)}
{source && selectedProvider === DataSourceProvider.waterCrawl && (
@ -129,7 +126,6 @@ const Website: FC<Props> = ({
onJobIdChange={onJobIdChange}
crawlOptions={crawlOptions}
onCrawlOptionsChange={onCrawlOptionsChange}
supportBatchUpload={supportBatchUpload}
/>
)}
{source && selectedProvider === DataSourceProvider.jinaReader && (
@ -140,7 +136,6 @@ const Website: FC<Props> = ({
onJobIdChange={onJobIdChange}
crawlOptions={crawlOptions}
onCrawlOptionsChange={onCrawlOptionsChange}
supportBatchUpload={supportBatchUpload}
/>
)}
{!source && (

View File

@ -26,7 +26,6 @@ type Props = {
onJobIdChange: (jobId: string) => void
crawlOptions: CrawlOptions
onCrawlOptionsChange: (payload: CrawlOptions) => void
supportBatchUpload: boolean
}
enum Step {
@ -42,7 +41,6 @@ const JinaReader: FC<Props> = ({
onJobIdChange,
crawlOptions,
onCrawlOptionsChange,
supportBatchUpload,
}) => {
const { t } = useTranslation()
const [step, setStep] = useState<Step>(Step.init)
@ -178,7 +176,7 @@ const JinaReader: FC<Props> = ({
}
else {
setCrawlResult(data)
onCheckedCrawlResultChange(supportBatchUpload ? (data.data || []) : (data.data?.slice(0, 1) || [])) // default select the crawl result
onCheckedCrawlResultChange(data.data || []) // default select the crawl result
setCrawlErrorMessage('')
}
}
@ -190,7 +188,7 @@ const JinaReader: FC<Props> = ({
finally {
setStep(Step.finished)
}
}, [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, supportBatchUpload, t, waitForCrawlFinished])
}, [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, t, waitForCrawlFinished])
return (
<div>
@ -229,7 +227,6 @@ const JinaReader: FC<Props> = ({
onSelectedChange={onCheckedCrawlResultChange}
onPreview={onPreview}
usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0}
isMultipleChoice={supportBatchUpload}
/>
}
</div>

View File

@ -26,7 +26,6 @@ type Props = {
onJobIdChange: (jobId: string) => void
crawlOptions: CrawlOptions
onCrawlOptionsChange: (payload: CrawlOptions) => void
supportBatchUpload: boolean
}
enum Step {
@ -42,7 +41,6 @@ const WaterCrawl: FC<Props> = ({
onJobIdChange,
crawlOptions,
onCrawlOptionsChange,
supportBatchUpload,
}) => {
const { t } = useTranslation()
const [step, setStep] = useState<Step>(Step.init)
@ -165,7 +163,7 @@ const WaterCrawl: FC<Props> = ({
}
else {
setCrawlResult(data)
onCheckedCrawlResultChange(supportBatchUpload ? (data.data || []) : (data.data?.slice(0, 1) || [])) // default select the crawl result
onCheckedCrawlResultChange(data.data || []) // default select the crawl result
setCrawlErrorMessage('')
}
}
@ -176,7 +174,7 @@ const WaterCrawl: FC<Props> = ({
finally {
setStep(Step.finished)
}
}, [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, supportBatchUpload, t, waitForCrawlFinished])
}, [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, t, waitForCrawlFinished])
return (
<div>
@ -215,7 +213,6 @@ const WaterCrawl: FC<Props> = ({
onSelectedChange={onCheckedCrawlResultChange}
onPreview={onPreview}
usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0}
isMultipleChoice={supportBatchUpload}
/>
}
</div>

View File

@ -28,7 +28,7 @@ export type LocalFileProps = {
const LocalFile = ({
allowedExtensions,
supportBatchUpload = false,
supportBatchUpload = true,
}: LocalFileProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)

View File

@ -30,7 +30,7 @@ const OnlineDocuments = ({
nodeId,
nodeData,
isInPipeline = false,
supportBatchUpload = false,
supportBatchUpload = true,
onCredentialChange,
}: OnlineDocumentsProps) => {
const docLink = useDocLink()

View File

@ -29,7 +29,7 @@ const OnlineDrive = ({
nodeId,
nodeData,
isInPipeline = false,
supportBatchUpload = false,
supportBatchUpload = true,
onCredentialChange,
}: OnlineDriveProps) => {
const docLink = useDocLink()

View File

@ -42,7 +42,7 @@ const WebsiteCrawl = ({
nodeId,
nodeData,
isInPipeline = false,
supportBatchUpload = false,
supportBatchUpload = true,
onCredentialChange,
}: WebsiteCrawlProps) => {
const { t } = useTranslation()

View File

@ -36,6 +36,10 @@ import { useAddDocumentsSteps, useLocalFile, useOnlineDocument, useOnlineDrive,
import DataSourceProvider from './data-source/store/provider'
import { useDataSourceStore } from './data-source/store'
import { useFileUploadConfig } from '@/service/use-common'
import UpgradeCard from '../../create/step-one/upgrade-card'
import Divider from '@/app/components/base/divider'
import { useBoolean } from 'ahooks'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
const CreateFormPipeline = () => {
const { t } = useTranslation()
@ -57,7 +61,7 @@ const CreateFormPipeline = () => {
const {
steps,
currentStep,
handleNextStep,
handleNextStep: doHandleNextStep,
handleBackStep,
} = useAddDocumentsSteps()
const {
@ -104,6 +108,33 @@ const CreateFormPipeline = () => {
}, [allFileLoaded, datasource, datasourceType, enableBilling, isVectorSpaceFull, onlineDocuments.length, onlineDriveFileList.length, websitePages.length])
const supportBatchUpload = !enableBilling || plan.type !== 'sandbox'
const [isShowPlanUpgradeModal, {
setTrue: showPlanUpgradeModal,
setFalse: hidePlanUpgradeModal,
}] = useBoolean(false)
const handleNextStep = useCallback(() => {
if (!supportBatchUpload) {
let isMultiple = false
if (datasourceType === DatasourceType.localFile && localFileList.length > 1)
isMultiple = true
if (datasourceType === DatasourceType.onlineDocument && onlineDocuments.length > 1)
isMultiple = true
if (datasourceType === DatasourceType.websiteCrawl && websitePages.length > 1)
isMultiple = true
if (datasourceType === DatasourceType.onlineDrive && selectedFileIds.length > 1)
isMultiple = true
if (isMultiple) {
showPlanUpgradeModal()
return
}
}
doHandleNextStep()
}, [datasourceType, doHandleNextStep, localFileList.length, onlineDocuments.length, selectedFileIds.length, showPlanUpgradeModal, supportBatchUpload, websitePages.length])
const nextBtnDisabled = useMemo(() => {
if (!datasource) return true
if (datasourceType === DatasourceType.localFile)
@ -125,16 +156,16 @@ const CreateFormPipeline = () => {
const showSelect = useMemo(() => {
if (datasourceType === DatasourceType.onlineDocument) {
const pagesCount = currentWorkspace?.pages.length ?? 0
return supportBatchUpload && pagesCount > 0
return pagesCount > 0
}
if (datasourceType === DatasourceType.onlineDrive) {
const isBucketList = onlineDriveFileList.some(file => file.type === 'bucket')
return supportBatchUpload && !isBucketList && onlineDriveFileList.filter((item) => {
return !isBucketList && onlineDriveFileList.filter((item) => {
return item.type !== 'bucket'
}).length > 0
}
return false
}, [currentWorkspace?.pages.length, datasourceType, supportBatchUpload, onlineDriveFileList])
}, [currentWorkspace?.pages.length, datasourceType, onlineDriveFileList])
const totalOptions = useMemo(() => {
if (datasourceType === DatasourceType.onlineDocument)
@ -390,11 +421,12 @@ const CreateFormPipeline = () => {
}, [PagesMapAndSelectedPagesId, currentWorkspace?.pages, dataSourceStore, datasourceType])
const clearDataSourceData = useCallback((dataSource: Datasource) => {
if (dataSource.nodeData.provider_type === DatasourceType.onlineDocument)
const providerType = dataSource.nodeData.provider_type
if (providerType === DatasourceType.onlineDocument)
clearOnlineDocumentData()
else if (dataSource.nodeData.provider_type === DatasourceType.websiteCrawl)
else if (providerType === DatasourceType.websiteCrawl)
clearWebsiteCrawlData()
else if (dataSource.nodeData.provider_type === DatasourceType.onlineDrive)
else if (providerType === DatasourceType.onlineDrive)
clearOnlineDriveData()
}, [clearOnlineDocumentData, clearOnlineDriveData, clearWebsiteCrawlData])
@ -452,7 +484,6 @@ const CreateFormPipeline = () => {
nodeId={datasource!.nodeId}
nodeData={datasource!.nodeData}
onCredentialChange={handleCredentialChange}
supportBatchUpload={supportBatchUpload}
/>
)}
{datasourceType === DatasourceType.websiteCrawl && (
@ -460,7 +491,6 @@ const CreateFormPipeline = () => {
nodeId={datasource!.nodeId}
nodeData={datasource!.nodeData}
onCredentialChange={handleCredentialChange}
supportBatchUpload={supportBatchUpload}
/>
)}
{datasourceType === DatasourceType.onlineDrive && (
@ -468,7 +498,6 @@ const CreateFormPipeline = () => {
nodeId={datasource!.nodeId}
nodeData={datasource!.nodeData}
onCredentialChange={handleCredentialChange}
supportBatchUpload={supportBatchUpload}
/>
)}
{isShowVectorSpaceFull && (
@ -483,6 +512,14 @@ const CreateFormPipeline = () => {
handleNextStep={handleNextStep}
tip={tip}
/>
{
!supportBatchUpload && datasourceType === DatasourceType.localFile && localFileList.length > 0 && (
<>
<Divider type='horizontal' className='my-4 h-px bg-divider-subtle' />
<UpgradeCard />
</>
)
}
</div>
)
}
@ -561,6 +598,14 @@ const CreateFormPipeline = () => {
</div>
)
}
{isShowPlanUpgradeModal && (
<PlanUpgradeModal
show
onClose={hidePlanUpgradeModal}
title={t('billing.upgrade.uploadMultiplePages.title')!}
description={t('billing.upgrade.uploadMultiplePages.description')!}
/>
)}
</div>
)
}

View File

@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import React, { useMemo } from 'react'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiAddLine,
@ -11,6 +11,10 @@ import {
import cn from '@/utils/classnames'
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
import Popover from '@/app/components/base/popover'
import { useBoolean } from 'ahooks'
import { useProviderContext } from '@/context/provider-context'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import { Plan } from '@/app/components/billing/type'
export type ISegmentAddProps = {
importStatus: ProcessStatus | string | undefined
@ -35,6 +39,23 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
embedding,
}) => {
const { t } = useTranslation()
const [isShowPlanUpgradeModal, {
setTrue: showPlanUpgradeModal,
setFalse: hidePlanUpgradeModal,
}] = useBoolean(false)
const { plan, enableBilling } = useProviderContext()
const { type } = plan
const canAdd = enableBilling ? type !== Plan.sandbox : true
const withNeedUpgradeCheck = useCallback((fn: () => void) => {
return () => {
if (!canAdd) {
showPlanUpgradeModal()
return
}
fn()
}
}, [canAdd, showPlanUpgradeModal])
const textColor = useMemo(() => {
return embedding
? 'text-components-button-secondary-accent-text-disabled'
@ -90,7 +111,7 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
type='button'
className={`inline-flex items-center rounded-l-lg border-r-[1px] border-r-divider-subtle px-2.5 py-2
hover:bg-state-base-hover disabled:cursor-not-allowed disabled:hover:bg-transparent`}
onClick={showNewSegmentModal}
onClick={withNeedUpgradeCheck(showNewSegmentModal)}
disabled={embedding}
>
<RiAddLine className={cn('h-4 w-4', textColor)} />
@ -108,7 +129,7 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
<button
type='button'
className='system-md-regular flex w-full items-center rounded-lg px-2 py-1.5 text-text-secondary'
onClick={showBatchModal}
onClick={withNeedUpgradeCheck(showBatchModal)}
>
{t('datasetDocuments.list.action.batchAdd')}
</button>
@ -116,7 +137,7 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
}
btnElement={
<div className='flex items-center justify-center' >
<RiArrowDownSLine className={cn('h-4 w-4', textColor)}/>
<RiArrowDownSLine className={cn('h-4 w-4', textColor)} />
</div>
}
btnClassName={open => cn(
@ -129,7 +150,16 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
className='h-fit min-w-[128px]'
disabled={embedding}
/>
{isShowPlanUpgradeModal && (
<PlanUpgradeModal
show
onClose={hidePlanUpgradeModal}
title={t('billing.upgrade.addChunks.title')!}
description={t('billing.upgrade.addChunks.description')!}
/>
)}
</div>
)
}
export default React.memo(SegmentAdd)

View File

@ -9,7 +9,6 @@ export type TriggerEventsLimitModalPayload = {
usage: number
total: number
resetInDays?: number
planType: Plan
storageKey?: string
persistDismiss?: boolean
}
@ -98,7 +97,6 @@ export const useTriggerEventsLimitModal = ({
payload: {
usage: usage.triggerEvents,
total: total.triggerEvents,
planType: type,
resetInDays: triggerResetInDays,
storageKey,
persistDismiss,

View File

@ -31,7 +31,7 @@ const triggerEventsLimitModalMock = jest.fn((props: any) => {
latestTriggerEventsModalProps = props
return (
<div data-testid="trigger-limit-modal">
<button type="button" onClick={props.onDismiss}>dismiss</button>
<button type="button" onClick={props.onClose}>dismiss</button>
<button type="button" onClick={props.onUpgrade}>upgrade</button>
</div>
)
@ -115,11 +115,10 @@ describe('ModalContextProvider trigger events limit modal', () => {
usage: 3000,
total: 3000,
resetInDays: 5,
planType: Plan.professional,
})
act(() => {
latestTriggerEventsModalProps.onDismiss()
latestTriggerEventsModalProps.onClose()
})
await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument())
@ -149,7 +148,7 @@ describe('ModalContextProvider trigger events limit modal', () => {
await waitFor(() => expect(screen.getByTestId('trigger-limit-modal')).toBeInTheDocument())
act(() => {
latestTriggerEventsModalProps.onDismiss()
latestTriggerEventsModalProps.onClose()
})
await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument())
@ -177,7 +176,7 @@ describe('ModalContextProvider trigger events limit modal', () => {
await waitFor(() => expect(screen.getByTestId('trigger-limit-modal')).toBeInTheDocument())
act(() => {
latestTriggerEventsModalProps.onDismiss()
latestTriggerEventsModalProps.onClose()
})
await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument())

View File

@ -485,9 +485,8 @@ export const ModalContextProvider = ({
show
usage={showTriggerEventsLimitModal.payload.usage}
total={showTriggerEventsLimitModal.payload.total}
planType={showTriggerEventsLimitModal.payload.planType}
resetInDays={showTriggerEventsLimitModal.payload.resetInDays}
onDismiss={() => {
onClose={() => {
persistTriggerEventsLimitModalDismiss()
setShowTriggerEventsLimitModal(null)
}}

View File

@ -25,6 +25,8 @@ export NEXT_PUBLIC_SENTRY_DSN=${SENTRY_DSN}
export NEXT_PUBLIC_SITE_ABOUT=${SITE_ABOUT}
export NEXT_TELEMETRY_DISABLED=${NEXT_TELEMETRY_DISABLED}
export NEXT_PUBLIC_AMPLITUDE_API_KEY=${AMPLITUDE_API_KEY}
export NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS=${TEXT_GENERATION_TIMEOUT_MS}
export NEXT_PUBLIC_CSP_WHITELIST=${CSP_WHITELIST}
export NEXT_PUBLIC_ALLOW_EMBED=${ALLOW_EMBED}

View File

@ -202,6 +202,20 @@ const translation = {
viewBillingTitle: 'Abrechnung und Abonnements',
viewBillingDescription: 'Zahlungsmethoden, Rechnungen und Abonnementänderungen verwalten',
viewBillingAction: 'Verwalten',
upgrade: {
uploadMultiplePages: {
title: 'Upgrade, um mehrere Dokumente gleichzeitig hochzuladen',
description: 'Sie haben das Upload-Limit erreicht in Ihrem aktuellen Tarif kann jeweils nur ein Dokument ausgewählt und hochgeladen werden.',
},
uploadMultipleFiles: {
title: 'Upgrade, um den Massen-Upload von Dokumenten freizuschalten',
description: 'Lade mehrere Dokumente gleichzeitig hoch, um Zeit zu sparen und die Effizienz zu steigern.',
},
addChunks: {
title: 'Upgraden, um weiterhin Abschnitte hinzuzufügen',
description: 'Sie haben das Limit für das Hinzufügen von Abschnitten in diesem Tarif erreicht.',
},
},
}
export default translation

View File

@ -221,6 +221,20 @@ const translation = {
fullTipLine2: 'annotate more conversations.',
quotaTitle: 'Annotation Reply Quota',
},
upgrade: {
uploadMultiplePages: {
title: 'Upgrade to upload multiple documents at once',
description: 'Youve reached the upload limit — only one document can be selected and uploaded at a time on your current plan.',
},
uploadMultipleFiles: {
title: 'Upgrade to unlock batch document upload',
description: 'Batch-upload more documents at once to save time and improve efficiency.',
},
addChunks: {
title: 'Upgrade to continue adding chunks',
description: 'Youve reached the limit of adding chunks for this plan.',
},
},
}
export default translation

View File

@ -161,7 +161,7 @@ const translation = {
includesTitle: 'Todo de Community, además:',
name: 'Premium',
for: 'Para organizaciones y equipos de tamaño mediano',
features: ['Confiabilidad Autogestionada por Diversos Proveedores de Nube', 'Espacio de trabajo único', 'Personalización de Logotipo y Marca de la Aplicación Web', 'Soporte prioritario por correo electrónico y chat'],
features: ['Confiabilidad autogestionada por varios proveedores de la nube', 'Espacio de trabajo único', 'Personalización de Logotipo y Marca de la Aplicación Web', 'Soporte prioritario por correo electrónico y chat'],
},
},
vectorSpace: {
@ -202,6 +202,20 @@ const translation = {
viewBillingTitle: 'Facturación y Suscripciones',
viewBillingDescription: 'Gestiona métodos de pago, facturas y cambios de suscripción',
viewBillingAction: 'Gestionar',
upgrade: {
uploadMultiplePages: {
title: 'Actualiza para subir varios documentos a la vez',
description: 'Has alcanzado el límite de carga: solo se puede seleccionar y subir un documento a la vez en tu plan actual.',
},
uploadMultipleFiles: {
title: 'Actualiza para desbloquear la carga de documentos en lote',
description: 'Carga en lote más documentos a la vez para ahorrar tiempo y mejorar la eficiencia.',
},
addChunks: {
title: 'Actualiza para seguir agregando fragmentos',
description: 'Has alcanzado el límite de agregar fragmentos para este plan.',
},
},
}
export default translation

View File

@ -161,7 +161,7 @@ const translation = {
name: 'پیشرفته',
priceTip: 'بر اساس بازار ابری',
comingSoon: 'پشتیبانی مایکروسافت آژور و گوگل کلود به زودی در دسترس خواهد بود',
features: ['قابلیت اطمینان خودمدیریتی توسط ارائه‌دهندگان مختلف ابری', 'فضای کاری تنها', 'سفارشی‌سازی لوگو و برند وب‌اپ', 'پشتیبانی اولویت‌دار ایمیل و چت'],
features: ['قابلیت اطمینان خودمدیریتی توسط ارائه‌دهندگان مختلف ابری', 'فضای کاری تنها', 'سفارشی‌سازی لوگو و برندینگ وب‌اپ', 'پشتیبانی اولویت‌دار ایمیل و چت'],
},
},
vectorSpace: {
@ -202,6 +202,20 @@ const translation = {
viewBillingTitle: 'صورتحساب و اشتراک‌ها',
viewBillingDescription: 'مدیریت روش‌های پرداخت، صورت‌حساب‌ها و تغییرات اشتراک',
viewBillingAction: 'مدیریت',
upgrade: {
uploadMultiplePages: {
title: 'ارتقا برای آپلود همزمان چندین سند',
description: 'شما به حد آپلود رسیده‌اید — در طرح فعلی خود تنها می‌توانید یک سند را در هر بار انتخاب و آپلود کنید.',
},
uploadMultipleFiles: {
title: 'ارتقا دهید تا امکان بارگذاری دسته‌ای اسناد فعال شود',
description: 'بارگذاری دسته‌ای چندین سند به‌طور همزمان برای صرفه‌جویی در زمان و افزایش کارایی.',
},
addChunks: {
title: 'برای ادامه افزودن بخش‌ها ارتقا دهید',
description: 'شما به حد اضافه کردن بخش‌ها برای این طرح رسیده‌اید.',
},
},
}
export default translation

View File

@ -137,7 +137,7 @@ const translation = {
name: 'Entreprise',
description: 'Obtenez toutes les capacités et le support pour les systèmes à grande échelle et critiques pour la mission.',
includesTitle: 'Tout ce qui est inclus dans le plan Équipe, plus :',
features: ['Solutions de déploiement évolutives de niveau entreprise', 'Autorisation de licence commerciale', 'Fonctionnalités exclusives pour les entreprises', 'Espaces de travail multiples et gestion d\'entreprise', 'SSO', 'Accords de niveau de service négociés par les partenaires de Dify', 'Sécurité et contrôles avancés', 'Mises à jour et maintenance par Dify Officiellement', 'Assistance technique professionnelle'],
features: ['Solutions de déploiement évolutives de niveau entreprise', 'Autorisation de licence commerciale', 'Fonctionnalités exclusives pour les entreprises', 'Espaces de travail multiples et gestion d\'entreprise', 'SSO', 'Accords sur les SLA négociés par les partenaires Dify', 'Sécurité et contrôles avancés', 'Mises à jour et maintenance par Dify Officiellement', 'Assistance technique professionnelle'],
for: 'Pour les équipes de grande taille',
btnText: 'Contacter les ventes',
priceTip: 'Facturation Annuel Seulement',
@ -202,6 +202,20 @@ const translation = {
viewBillingTitle: 'Facturation et abonnements',
viewBillingDescription: 'Gérer les méthodes de paiement, les factures et les modifications d\'abonnement',
viewBillingAction: 'Gérer',
upgrade: {
uploadMultiplePages: {
title: 'Passez à la version supérieure pour télécharger plusieurs documents à la fois',
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.',
},
uploadMultipleFiles: {
title: 'Passez à la version supérieure pour débloquer le téléchargement de documents en lot',
description: 'Téléchargez plusieurs documents à la fois pour gagner du temps et améliorer l\'efficacité.',
},
addChunks: {
title: 'Mettez à niveau pour continuer à ajouter des morceaux',
description: 'Vous avez atteint la limite d\'ajout de morceaux pour ce plan.',
},
},
}
export default translation

View File

@ -213,6 +213,20 @@ const translation = {
viewBillingTitle: 'बिलिंग और सब्सक्रिप्शन',
viewBillingDescription: 'भुगतान के तरीकों, चालानों और सदस्यता में बदलावों का प्रबंधन करें',
viewBillingAction: 'प्रबंध करना',
upgrade: {
uploadMultiplePages: {
title: 'एक बार में कई दस्तावेज़ अपलोड करने के लिए अपग्रेड करें',
description: 'आपने अपलोड की सीमा तक पहुँच लिया है — आपके वर्तमान प्लान पर एक समय में केवल एक ही दस्तावेज़ चुना और अपलोड किया जा सकता है।',
},
uploadMultipleFiles: {
title: 'बैच दस्तावेज़ अपलोड अनलॉक करने के लिए अपग्रेड करें',
description: 'समय बचाने और कार्यक्षमता बढ़ाने के लिए एक बार में अधिक दस्तावेज़ बैच-अपलोड करें।',
},
addChunks: {
title: 'अधिक चंक्स जोड़ने के लिए अपग्रेड करें',
description: 'आप इस योजना के लिए टुकड़े जोड़ने की सीमा तक पहुँच चुके हैं।',
},
},
}
export default translation

View File

@ -202,6 +202,20 @@ const translation = {
viewBillingTitle: 'Penagihan dan Langganan',
viewBillingDescription: 'Kelola metode pembayaran, faktur, dan perubahan langganan',
viewBillingAction: 'Kelola',
upgrade: {
uploadMultiplePages: {
title: 'Tingkatkan untuk mengunggah beberapa dokumen sekaligus',
description: 'Anda telah mencapai batas unggah — hanya satu dokumen yang dapat dipilih dan diunggah sekaligus dengan paket Anda saat ini.',
},
uploadMultipleFiles: {
title: 'Tingkatkan untuk membuka unggahan dokumen batch',
description: 'Unggah lebih banyak dokumen sekaligus untuk menghemat waktu dan meningkatkan efisiensi.',
},
addChunks: {
title: 'Tingkatkan untuk terus menambahkan potongan',
description: 'Anda telah mencapai batas penambahan potongan untuk paket ini.',
},
},
}
export default translation

View File

@ -164,7 +164,7 @@ const translation = {
for: 'Per utenti individuali, piccole squadre o progetti non commerciali',
},
premium: {
features: ['Affidabilità autogestita dai vari provider cloud', 'Spazio di lavoro singolo', 'Personalizzazione del Logo e del Marchio dell\'App Web', 'Assistenza Prioritaria via Email e Chat'],
features: ['Affidabilità Autogestita dai Vari Provider Cloud', 'Spazio di lavoro singolo', 'Personalizzazione del Logo e del Marchio dell\'App Web', 'Assistenza Prioritaria via Email e Chat'],
name: 'Premium',
priceTip: 'Basato su Cloud Marketplace',
includesTitle: 'Tutto dalla Community, oltre a:',
@ -213,6 +213,20 @@ const translation = {
viewBillingTitle: 'Fatturazione e Abbonamenti',
viewBillingDescription: 'Gestisci metodi di pagamento, fatture e modifiche all\'abbonamento',
viewBillingAction: 'Gestire',
upgrade: {
uploadMultiplePages: {
title: 'Aggiorna per caricare più documenti contemporaneamente',
description: 'Hai raggiunto il limite di caricamento: sul tuo piano attuale può essere selezionato e caricato un solo documento alla volta.',
},
uploadMultipleFiles: {
title: 'Aggiorna per sbloccare il caricamento di documenti in batch',
description: 'Carica più documenti contemporaneamente per risparmiare tempo e migliorare l\'efficienza.',
},
addChunks: {
title: 'Aggiorna per continuare ad aggiungere blocchi',
description: 'Hai raggiunto il limite di aggiunta di blocchi per questo piano.',
},
},
}
export default translation

View File

@ -202,6 +202,20 @@ const translation = {
quotaTitle: '注釈返信クォータ',
},
teamMembers: 'チームメンバー',
upgrade: {
uploadMultiplePages: {
title: '複数ドキュメントを一度にアップロードするにはアップグレード',
description: '現在のプランではアップロード上限に達しています。1回の操作で選択・アップロードできるドキュメントは1つのみです。',
},
uploadMultipleFiles: {
title: '一括ドキュメントアップロード機能を解放するにはアップグレードが必要です',
description: '複数のドキュメントを一度にバッチアップロードすることで、時間を節約し、作業効率を向上できます。',
},
addChunks: {
title: 'アップグレードして、チャンクを引き続き追加できるようにしてください。',
description: 'このプランでは、チャンク追加の上限に達しています。',
},
},
}
export default translation

View File

@ -152,7 +152,7 @@ const translation = {
btnText: '판매 문의하기',
for: '대규모 팀을 위해',
priceTip: '연간 청구 전용',
features: ['기업용 확장형 배포 솔루션', '상업용 라이선스 승인', '독점 기업 기능', '여러 작업 공간 및 기업 관리', '싱글 사인온', 'Dify 파트너가 협상한 SLA', '고급 보안 및 제어', 'Dify 공식 업데이트 및 유지보수', '전문 기술 지원'],
features: ['기업용 확장형 배포 솔루션', '상업용 라이선스 승인', '독점 기업 기능', '여러 작업 공간 및 기업 관리', '싱글 사인온', 'Dify 파트너가 협상한 SLA', '고급 보안 및 제어', 'Dify 공식 업데이트 및 유지 관리', '전문 기술 지원'],
},
community: {
btnText: '커뮤니티 시작하기',
@ -215,6 +215,20 @@ const translation = {
viewBillingTitle: '청구 및 구독',
viewBillingDescription: '결제 수단, 청구서 및 구독 변경 관리',
viewBillingAction: '관리하다',
upgrade: {
uploadMultiplePages: {
title: '한 번에 여러 문서를 업로드하려면 업그레이드하세요',
description: '업로드 한도에 도달했습니다 — 현재 요금제에서는 한 번에 한 개의 문서만 선택하고 업로드할 수 있습니다.',
},
uploadMultipleFiles: {
title: '업그레이드하여 대량 문서 업로드 기능 잠금 해제',
description: '한 번에 더 많은 문서를 일괄 업로드하여 시간 절약과 효율성을 높이세요.',
},
addChunks: {
title: '계속해서 조각을 추가하려면 업그레이드하세요',
description: '이 요금제에서는 더 이상 청크를 추가할 수 있는 한도에 도달했습니다.',
},
},
}
export default translation

View File

@ -147,7 +147,7 @@ const translation = {
description:
'Uzyskaj pełne możliwości i wsparcie dla systemów o kluczowym znaczeniu dla misji.',
includesTitle: 'Wszystko w planie Zespołowym, plus:',
features: ['Skalowalne rozwiązania wdrożeniowe klasy korporacyjnej', 'Autoryzacja licencji komercyjnej', 'Ekskluzywne funkcje dla przedsiębiorstw', 'Wiele przestrzeni roboczych i zarządzanie przedsiębiorstwem', 'SSO', 'Negocjowane umowy SLA przez partnerów Dify', 'Zaawansowane zabezpieczenia i kontrola', 'Aktualizacje i konserwacja przez Dify oficjalnie', 'Profesjonalne wsparcie techniczne'],
features: ['Rozwiązania wdrożeniowe klasy korporacyjnej, skalowalne', 'Autoryzacja licencji komercyjnej', 'Ekskluzywne funkcje dla przedsiębiorstw', 'Wiele przestrzeni roboczych i zarządzanie przedsiębiorstwem', 'SSO', 'Negocjowane umowy SLA przez partnerów Dify', 'Zaawansowane zabezpieczenia i kontrola', 'Aktualizacje i konserwacja przez Dify oficjalnie', 'Profesjonalne wsparcie techniczne'],
priceTip: 'Tylko roczne fakturowanie',
btnText: 'Skontaktuj się z działem sprzedaży',
for: 'Dla dużych zespołów',
@ -163,7 +163,7 @@ const translation = {
for: 'Dla użytkowników indywidualnych, małych zespołów lub projektów niekomercyjnych',
},
premium: {
features: ['Niezawodność zarządzana samodzielnie przez różnych dostawców chmury', 'Pojedyncza przestrzeń robocza', 'Dostosowywanie logo i identyfikacji wizualnej aplikacji webowej', 'Priorytetowe wsparcie e-mail i czat'],
features: ['Niezawodność zarządzana samodzielnie przez różnych dostawców chmury', 'Pojedyncza przestrzeń robocza', 'Dostosowywanie logo i marki aplikacji webowej', 'Priorytetowe wsparcie e-mail i czat'],
description: 'Dla średnich organizacji i zespołów',
for: 'Dla średnich organizacji i zespołów',
name: 'Premium',
@ -212,6 +212,20 @@ const translation = {
viewBillingTitle: 'Rozliczenia i subskrypcje',
viewBillingDescription: 'Zarządzaj metodami płatności, fakturami i zmianami subskrypcji',
viewBillingAction: 'Zarządzać',
upgrade: {
uploadMultiplePages: {
title: 'Przejdź na wyższą wersję, aby przesyłać wiele dokumentów jednocześnie',
description: 'Osiągnąłeś limit przesyłania — w ramach obecnego planu można wybrać i przesłać tylko jeden dokument naraz.',
},
uploadMultipleFiles: {
title: 'Uaktualnij, aby odblokować przesyłanie dokumentów wsadowych',
description: 'Przesyłaj wiele dokumentów jednocześnie, aby zaoszczędzić czas i zwiększyć wydajność.',
},
addChunks: {
title: 'Uaktualnij, aby kontynuować dodawanie fragmentów',
description: 'Osiągnąłeś limit dodawania fragmentów w tym planie.',
},
},
}
export default translation

View File

@ -153,7 +153,7 @@ const translation = {
for: 'Para Usuários Individuais, Pequenas Equipes ou Projetos Não Comerciais',
},
premium: {
features: ['Confiabilidade Autogerenciada por Diversos Provedores de Nuvem', 'Espaço de Trabalho Único', 'Personalização de Logo e Marca do WebApp', 'Suporte Prioritário por Email e Chat'],
features: ['Confiabilidade Autogerenciada por Diversos Provedores de Nuvem', 'Espaço de Trabalho Único', 'Personalização de Logo e Branding do WebApp', 'Suporte Prioritário por E-mail e Chat'],
includesTitle: 'Tudo da Comunidade, além de:',
for: 'Para organizações e equipes de médio porte',
price: 'Escalável',
@ -202,6 +202,20 @@ const translation = {
viewBillingTitle: 'Faturamento e Assinaturas',
viewBillingDescription: 'Gerencie métodos de pagamento, faturas e alterações de assinatura',
viewBillingAction: 'Gerenciar',
upgrade: {
uploadMultiplePages: {
title: 'Atualize para enviar vários documentos de uma vez',
description: 'Você atingiu o limite de upload — apenas um documento pode ser selecionado e enviado por vez no seu plano atual.',
},
uploadMultipleFiles: {
title: 'Atualize para desbloquear o envio de documentos em lote',
description: 'Faça upload de mais documentos de uma vez para economizar tempo e aumentar a eficiência.',
},
addChunks: {
title: 'Faça upgrade para continuar adicionando blocos',
description: 'Você atingiu o limite de adição de blocos para este plano.',
},
},
}
export default translation

View File

@ -137,14 +137,14 @@ const translation = {
name: 'Întreprindere',
description: 'Obțineți capacități și asistență complete pentru sisteme critice la scară largă.',
includesTitle: 'Tot ce este în planul Echipă, plus:',
features: ['Soluții de implementare scalabile la nivel de întreprindere', 'Autorizație de licență comercială', 'Funcții Exclusive pentru Afaceri', 'Multiple spații de lucru și gestionarea întreprinderii', 'Autentificare unică', 'SLA-uri negociate de partenerii Dify', 'Securitate și Control Avansate', 'Actualizări și întreținere de către Dify Oficial', 'Asistență Tehnică Profesională'],
features: ['Soluții de implementare scalabile la nivel de întreprindere', 'Autorizație de licență comercială', 'Funcții Exclusive pentru Afaceri', 'Mai multe spații de lucru și managementul întreprinderii', 'Autentificare unică', 'SLA-uri negociate de partenerii Dify', 'Securitate și Control Avansate', 'Actualizări și întreținere de către Dify Oficial', 'Asistență Tehnică Profesională'],
for: 'Pentru echipe de mari dimensiuni',
price: 'Personalizat',
priceTip: 'Facturare anuală doar',
btnText: 'Contactați Vânzări',
},
community: {
features: ['Toate Funcțiile Principale Lansate în Repositorul Public', 'Spațiu de lucru unic', 'Respectă Licența Open Source Dify'],
features: ['Toate Funcționalitățile de Bază Lansate în Repositorul Public', 'Spațiu de lucru unic', 'Respectă Licența Open Source Dify'],
description: 'Pentru utilizatori individuali, echipe mici sau proiecte necomerciale',
btnText: 'Începe cu Comunitatea',
price: 'Gratuit',
@ -153,7 +153,7 @@ const translation = {
includesTitle: 'Funcții gratuite:',
},
premium: {
features: ['Fiabilitate autogestionată de diferiți furnizori de cloud', 'Spațiu de lucru unic', 'Personalizare Logo și Branding pentru WebApp', 'Asistență prioritară prin email și chat'],
features: ['Fiabilitate autogestionată de diferiți furnizori de cloud', 'Spațiu de lucru unic', 'Personalizare logo și branding pentru aplicația web', 'Asistență prioritară prin e-mail și chat'],
btnText: 'Obține Premium în',
description: 'Pentru organizații și echipe de dimensiuni medii',
includesTitle: 'Totul din Comunitate, plus:',
@ -202,6 +202,20 @@ const translation = {
viewBillingTitle: 'Facturare și abonamente',
viewBillingDescription: 'Gestionează metodele de plată, facturile și modificările abonamentului',
viewBillingAction: 'Gestiona',
upgrade: {
uploadMultiplePages: {
title: 'Actualizează pentru a încărca mai multe documente odată',
description: 'Ați atins limita de încărcare — poate fi selectat și încărcat doar un singur document odată în planul dvs. actual.',
},
uploadMultipleFiles: {
title: 'Fă upgrade pentru a debloca încărcarea documentelor în masă',
description: 'Încărcați mai multe documente simultan pentru a economisi timp și a îmbunătăți eficiența.',
},
addChunks: {
title: 'Actualizează pentru a continua să adaugi segmente',
description: 'Ai atins limita de adăugare a segmentelor pentru acest plan.',
},
},
}
export default translation

View File

@ -137,14 +137,14 @@ const translation = {
name: 'Корпоративный',
description: 'Получите полный набор возможностей и поддержку для крупномасштабных критически важных систем.',
includesTitle: 'Все в командном плане, плюс:',
features: ['Масштабируемые решения для развертывания корпоративного уровня', 'Разрешение на коммерческую лицензию', 'Эксклюзивные корпоративные функции', 'Несколько рабочих пространств и корпоративное управление', 'Единый вход (SSO)', 'Договоренные SLA с партнёрами Dify', 'Расширенные функции безопасности и управления', 'Обновления и обслуживание от Dify официально', 'Профессиональная техническая поддержка'],
features: ['Масштабируемые решения для развертывания корпоративного уровня', 'Разрешение на коммерческую лицензию', 'Эксклюзивные корпоративные функции', 'Несколько рабочих пространств и корпоративное управление', 'Единый вход (SSO)', 'Согласованные SLA с партнёрами Dify', 'Расширенные функции безопасности и управления', 'Обновления и обслуживание от Dify официально', 'Профессиональная техническая поддержка'],
price: 'Пользовательский',
priceTip: 'Только годовая подписка',
for: 'Для команд большого размера',
btnText: 'Связаться с отделом продаж',
},
community: {
features: ['Все основные функции выпущены в публичный репозиторий', 'Одиночное рабочее пространство', 'Соответствует лицензии с открытым исходным кодом Dify'],
features: ['Все основные функции выпущены в публичный репозиторий', 'Единое рабочее пространство', 'Соответствует лицензии с открытым исходным кодом Dify'],
name: 'Сообщество',
btnText: 'Начните с сообщества',
price: 'Свободно',
@ -153,7 +153,7 @@ const translation = {
for: 'Для отдельных пользователей, малых команд или некоммерческих проектов',
},
premium: {
features: ['Самоуправляемая надежность у различных облачных провайдеров', 'Одиночное рабочее пространство', 'Настройка логотипа и брендинга веб-приложения', 'Приоритетная поддержка по электронной почте и в чате'],
features: ['Самоуправляемая надежность у различных облачных провайдеров', 'Единое рабочее пространство', 'Настройка логотипа и брендинга веб-приложения', 'Приоритетная поддержка по электронной почте и в чате'],
description: 'Для средних организаций и команд',
includesTitle: 'Всё из Сообщества, плюс:',
priceTip: 'На основе облачного маркетплейса',
@ -202,6 +202,20 @@ const translation = {
viewBillingTitle: 'Платежи и подписки',
viewBillingDescription: 'Управляйте способами оплаты, счетами и изменениями подписки',
viewBillingAction: 'Управлять',
upgrade: {
uploadMultiplePages: {
title: 'Обновите версию, чтобы загружать несколько документов одновременно',
description: 'Вы достигли лимита загрузки — на вашем текущем тарифном плане можно выбрать и загрузить только один документ за раз.',
},
uploadMultipleFiles: {
title: 'Обновите версию, чтобы включить массовую загрузку документов',
description: 'Загружайте больше документов одновременно, чтобы сэкономить время и повысить эффективность.',
},
addChunks: {
title: 'Обновите версию, чтобы продолжить добавление блоков',
description: 'Вы достигли предела добавления чанков по этому тарифному плану.',
},
},
}
export default translation

View File

@ -137,14 +137,14 @@ const translation = {
name: 'Podjetje',
description: 'Pridobite vse zmogljivosti in podporo za velike sisteme kritične za misijo.',
includesTitle: 'Vse v načrtu Ekipa, plus:',
features: ['Razširljive rešitve za uvajanje na ravni podjetja', 'Pooblastilo za komercialno licenco', 'Ekskluzivne funkcije za podjetja', 'Več delovnih prostorov in upravljanje podjetja', 'SSO', 'Pogajani SLA-ji s strani partnerjev Dify', 'Napredna varnost in nadzor', 'Posodobitve in vzdrževanje uradno s strani Dify', 'Strokovna tehnična podpora'],
features: ['Razširljive rešitve za uvajanje na ravni podjetja', 'Pooblastilo za komercialno licenco', 'Ekskluzivne funkcije za podjetja', 'Več delovnih prostorov in upravljanje podjetja', 'SSO', 'Pogajani SLA-ji s strani partnerjev Dify', 'Napredna varnost in nadzor', 'Posodobitve in vzdrževanje s strani Dify uradno', 'Strokovna tehnična podpora'],
priceTip: 'Letno zaračunavanje samo',
price: 'Po meri',
btnText: 'Kontaktirajte prodajo',
for: 'Za velike ekipe',
},
community: {
features: ['Vse osnovne funkcije so izdane v javnem repozitoriju', 'Enotno delovno okolje', 'V skladu z Dify licenco odprte kode'],
features: ['Vse osnovne funkcije so izdane v javni repozitorij', 'Enotno delovno okolje', 'V skladu z Dify licenco odprte kode'],
includesTitle: 'Brezplačne funkcije:',
price: 'Brezplačno',
name: 'Skupnost',
@ -202,6 +202,20 @@ const translation = {
viewBillingTitle: 'Fakturiranje in naročnine',
viewBillingDescription: 'Upravljajte načine plačila, račune in spremembe naročnin',
viewBillingAction: 'Upravljaj',
upgrade: {
uploadMultiplePages: {
title: 'Nadgradite za nalaganje več dokumentov hkrati',
description: 'Dosegli ste omejitev nalaganja — na vašem trenutnem načrtu je mogoče izbrati in naložiti le en dokument naenkrat.',
},
uploadMultipleFiles: {
title: 'Nadgradite za odklep nalaganja dokumentov v skupkih',
description: 'Naložite več dokumentov hkrati, da prihranite čas in izboljšate učinkovitost.',
},
addChunks: {
title: 'Nadgradite, da nadaljujete z dodajanjem delov',
description: 'Dosegli ste omejitev dodajanja delov za ta načrt.',
},
},
}
export default translation

View File

@ -137,14 +137,14 @@ const translation = {
name: 'กิจการ',
description: 'รับความสามารถและการสนับสนุนเต็มรูปแบบสําหรับระบบที่สําคัญต่อภารกิจขนาดใหญ่',
includesTitle: 'ทุกอย่างในแผนทีม รวมถึง:',
features: ['โซลูชันการปรับใช้ที่ปรับขนาดได้สำหรับองค์กร', 'การอนุญาตใบอนุญาตเชิงพาณิชย์', 'ฟีเจอร์สำหรับองค์กรแบบพิเศษ', 'หลายพื้นที่ทำงานและการจัดการองค์กร', 'ระบบลงชื่อเพียงครั้งเดียว', 'ข้อตกลงระดับการให้บริการที่เจรจาโดยพันธมิตร Dify', 'ระบบความปลอดภัยและการควบคุมขั้นสูง', 'การอัปเดตและการบำรุงรักษาโดย Dify อย่างเป็นทางการ', 'การสนับสนุนทางเทคนิคระดับมืออาชีพ'],
features: ['โซลูชันการปรับใช้ที่ปรับขนาดได้สำหรับองค์กร', 'การอนุญาตใบอนุญาตเชิงพาณิชย์', 'ฟีเจอร์เฉพาะสำหรับองค์กร', 'หลายพื้นที่ทำงานและการจัดการองค์กร', 'ระกันสังคม', 'ข้อตกลง SLA ที่เจรจากับพันธมิตร Dify', 'ระบบความปลอดภัยและการควบคุมขั้นสูง', 'การอัปเดตและการบำรุงรักษาโดย Dify อย่างเป็นทางการ', 'การสนับสนุนทางเทคนิคระดับมืออาชีพ'],
btnText: 'ติดต่อฝ่ายขาย',
price: 'ที่กำหนดเอง',
for: 'สำหรับทีมขนาดใหญ่',
priceTip: 'การเรียกเก็บเงินประจำปีเท่านั้น',
},
community: {
features: ['คุณลักษณะหลักทั้งหมดถูกปล่อยภายใต้ที่เก็บสาธารณะ', 'พื้นที่ทำงานเดียว', 'เป็นไปตามใบอนุญาตแบบเปิดของ Dify'],
features: ['คุณลักษณะหลักทั้งหมดถูกปล่อยภายใต้ที่เก็บสาธารณะ', 'พื้นที่ทำงานเดียว', 'เป็นไปตามใบอนุญาตแบบโอเพ่นซอร์สของ Dify'],
name: 'ชุมชน',
price: 'ฟรี',
includesTitle: 'คุณสมบัติเสรี:',
@ -202,6 +202,20 @@ const translation = {
viewBillingTitle: 'การเรียกเก็บเงินและการสมัครสมาชิก',
viewBillingDescription: 'จัดการวิธีการชำระเงิน ใบแจ้งหนี้ และการเปลี่ยนแปลงการสมัครสมาชิก',
viewBillingAction: 'จัดการ',
upgrade: {
uploadMultiplePages: {
title: 'อัปเกรดเพื่ออัปโหลดเอกสารหลายฉบับพร้อมกัน',
description: 'คุณได้ถึงขีดจำกัดการอัปโหลดแล้ว — สามารถเลือกและอัปโหลดเอกสารได้เพียงไฟล์เดียวต่อครั้งในแผนปัจจุบันของคุณ',
},
uploadMultipleFiles: {
title: 'อัปเกรดเพื่อปลดล็อกการอัปโหลดเอกสารเป็นชุด',
description: 'อัปโหลดเอกสารหลายชิ้นพร้อมกันในครั้งเดียวเพื่อประหยัดเวลาและเพิ่มประสิทธิภาพ',
},
addChunks: {
title: 'อัปเกรดเพื่อเพิ่มชิ้นส่วนต่อ',
description: 'คุณได้ถึงขีดจำกัดในการเพิ่มชิ้นส่วนสำหรับแผนนี้แล้ว',
},
},
}
export default translation

View File

@ -144,7 +144,7 @@ const translation = {
price: 'Özel',
},
community: {
features: ['Tüm Temel Özellikler Açık Kaynak Depoda Yayınlandı', 'Tek Çalışma Alanı', 'Dify Açık Kaynak Lisansına uygundur'],
features: ['Tüm Temel Özellikler Açık Depoda Yayınlandı', 'Tek Çalışma Alanı', 'Dify Açık Kaynak Lisansına uygundur'],
price: 'Ücretsiz',
includesTitle: 'Ücretsiz Özellikler:',
name: 'Topluluk',
@ -153,7 +153,7 @@ const translation = {
description: 'Bireysel Kullanıcılar, Küçük Ekipler veya Ticari Olmayan Projeler İçin',
},
premium: {
features: ['Çeşitli Bulut Sağlayıcıları Tarafından Kendi Kendine Yönetilen Güvenilirlik', 'Tek Çalışma Alanı', 'Web Uygulaması Logo ve Marka Özelleştirme', 'Öncelikli E-posta ve Sohbet Desteği'],
features: ['Çeşitli Bulut Sağlayıcıları Tarafından Kendi Kendine Yönetilen Güvenilirlik', 'Tek Çalışma Alanı', 'Web Uygulama Logo ve Marka Özelleştirme', 'Öncelikli E-posta ve Sohbet Desteği'],
name: 'Premium',
includesTitle: 'Topluluktan her şey, artı:',
for: 'Orta Büyüklükteki Organizasyonlar ve Ekipler için',
@ -202,6 +202,20 @@ const translation = {
viewBillingTitle: 'Faturalama ve Abonelikler',
viewBillingDescription: 'Ödeme yöntemlerini, faturaları ve abonelik değişikliklerini yönetin',
viewBillingAction: 'Yönet',
upgrade: {
uploadMultiplePages: {
title: 'Aynı anda birden fazla belge yüklemek için yükseltin',
description: 'Yükleme sınırına ulaştınız — mevcut planınızda aynı anda yalnızca bir belge seçip yükleyebilirsiniz.',
},
uploadMultipleFiles: {
title: 'Toplu belge yüklemeyi açmak için yükseltin',
description: 'Zaman kazanmak ve verimliliği artırmak için bir kerede daha fazla belgeyi toplu olarak yükleyin.',
},
addChunks: {
title: 'Parçalar eklemeye devam etmek için yükseltin',
description: 'Bu plan için parça ekleme sınırına ulaştınız.',
},
},
}
export default translation

View File

@ -137,7 +137,7 @@ const translation = {
name: 'Ентерпрайз',
description: 'Отримайте повні можливості та підтримку для масштабних критично важливих систем.',
includesTitle: 'Все, що входить до плану Team, плюс:',
features: ['Масштабовані рішення для розгортання корпоративного рівня', 'Авторизація комерційної ліцензії', 'Ексклюзивні корпоративні функції', 'Кілька робочих просторів та управління підприємством', 'ЄДИНА СИСТЕМА АВТОРИЗАЦІЇ', 'Узгоджені SLA партнерами Dify', 'Розширена безпека та контроль', 'Оновлення та обслуговування від Dify офіційно', 'Професійна технічна підтримка'],
features: ['Масштабовані рішення для розгортання корпоративного рівня', 'Авторизація комерційної ліцензії', 'Ексклюзивні корпоративні функції', 'Кілька робочих просторів та управління підприємством', 'ЄД', 'Узгоджені SLA партнерами Dify', 'Розширена безпека та контроль', 'Оновлення та обслуговування від Dify офіційно', 'Професійна технічна підтримка'],
btnText: 'Зв\'язатися з відділом продажу',
priceTip: 'Тільки річна оплата',
for: 'Для великих команд',
@ -153,7 +153,7 @@ const translation = {
name: 'Спільнота',
},
premium: {
features: ['Самокерована надійність від різних хмарних постачальників', 'Один робочий простір', 'Налаштування логотипу та бренду WebApp', 'Пріоритетна підтримка електронної пошти та чату'],
features: ['Самокерована надійність від різних хмарних постачальників', 'Один робочий простір', 'Налаштування логотипу та бренду веб-додатку', 'Пріоритетна підтримка електронної пошти та чату'],
description: 'Для середніх підприємств та команд',
btnText: 'Отримайте Преміум у',
price: 'Масштабований',
@ -202,6 +202,20 @@ const translation = {
viewBillingTitle: 'Білінг та підписки',
viewBillingDescription: 'Керуйте способами оплати, рахунками та змінами підписки',
viewBillingAction: 'Керувати',
upgrade: {
uploadMultiplePages: {
title: 'Оновіть, щоб завантажувати кілька документів одночасно',
description: 'Ви досягли ліміту завантаження — на вашому поточному плані можна вибрати та завантажити лише один документ одночасно.',
},
uploadMultipleFiles: {
title: 'Оновіть, щоб розблокувати пакетне завантаження документів',
description: 'Завантажуйте кілька документів одночасно, щоб заощадити час і підвищити ефективність.',
},
addChunks: {
title: 'Оновіть, щоб продовжити додавати частини',
description: 'Ви досягли межі додавання фрагментів для цього плану.',
},
},
}
export default translation

View File

@ -202,6 +202,20 @@ const translation = {
viewBillingTitle: 'Thanh toán và Đăng ký',
viewBillingDescription: 'Quản lý phương thức thanh toán, hóa đơn và thay đổi đăng ký',
viewBillingAction: 'Quản lý',
upgrade: {
uploadMultiplePages: {
title: 'Nâng cấp để tải lên nhiều tài liệu cùng lúc',
description: 'Bạn đã đạt đến giới hạn tải lên — chỉ có thể chọn và tải lên một tài liệu trong một lần với gói hiện tại của bạn.',
},
uploadMultipleFiles: {
title: 'Nâng cấp để mở khóa tải lên nhiều tài liệu',
description: 'Tải lên nhiều tài liệu cùng lúc để tiết kiệm thời gian và nâng cao hiệu quả.',
},
addChunks: {
title: 'Nâng cấp để tiếp tục thêm các phần',
description: 'Bạn đã đạt đến giới hạn thêm phần cho gói này.',
},
},
}
export default translation

View File

@ -202,6 +202,20 @@ const translation = {
quotaTitle: '标注的配额',
},
teamMembers: '团队成员',
upgrade: {
uploadMultiplePages: {
title: '升级以一次性上传多个文档',
description: '您已达到当前套餐的上传限制 —— 该套餐每次只能选择并上传 1 个文档。',
},
uploadMultipleFiles: {
title: '升级以解锁批量文档上传功能',
description: '一次性批量上传更多文档,以节省时间并提升效率。',
},
addChunks: {
title: '升级以继续添加分段',
description: '您已达到此计划的添加分段上限。',
},
},
}
export default translation

View File

@ -202,6 +202,20 @@ const translation = {
viewBillingTitle: '帳單與訂閱',
viewBillingDescription: '管理付款方式、發票和訂閱變更',
viewBillingAction: '管理',
upgrade: {
uploadMultiplePages: {
title: '升級以一次上傳多個文件',
description: '您已達到上傳限制 — 在您目前的方案下,每次只能選擇並上傳一個文件。',
},
uploadMultipleFiles: {
title: '升級以解鎖批量上傳文件功能',
description: '一次批量上傳更多文件,以節省時間並提高效率。',
},
addChunks: {
title: '升級以繼續添加區塊',
description: '您已達到此方案可新增區塊的上限。',
},
},
}
export default translation

View File

@ -2,7 +2,7 @@
"name": "dify-web",
"version": "1.11.0",
"private": true,
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501",
"engines": {
"node": ">=v22.11.0"
},