mirror of
https://github.com/langgenius/dify.git
synced 2026-01-19 19:55:06 +08:00
Compare commits
24 Commits
drop-draft
...
1.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 063b39ada5 | |||
| 6419ce02c7 | |||
| 1a877bb4d0 | |||
| 281e9d4f51 | |||
| a195b410d1 | |||
| 91e5db3e83 | |||
| f20a2d1586 | |||
| 6e802a343e | |||
| a30cbe3c95 | |||
| 7344adf65e | |||
| fcadee9413 | |||
| 69a22af1c9 | |||
| aac6f44562 | |||
| 2e1efd62e1 | |||
| 1847609926 | |||
| 91f6d25dae | |||
| acdbcdb6f8 | |||
| a9627ba60a | |||
| 266d1c70ac | |||
| d152d63e7d | |||
| b4afc7e435 | |||
| 2d496e7e08 | |||
| 693877e5e4 | |||
| 8cab3e5a1e |
21
.github/workflows/semantic-pull-request.yml
vendored
Normal file
21
.github/workflows/semantic-pull-request.yml
vendored
Normal 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 }}
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
127
api/tests/fixtures/workflow/end_node_without_value_type_field_workflow.yml
vendored
Normal file
127
api/tests/fixtures/workflow/end_node_without_value_type_field_workflow.yml
vendored
Normal 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: []
|
||||
@ -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"})
|
||||
@ -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})
|
||||
@ -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}"
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
8
api/uv.lock
generated
@ -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]]
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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('')
|
||||
})
|
||||
})
|
||||
@ -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%')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
export { default } from './AmplitudeProvider'
|
||||
export { default, isAmplitudeEnabled } from './AmplitudeProvider'
|
||||
export { resetUser, setUserId, setUserProperties, trackEvent } from './utils'
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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 |
@ -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"
|
||||
}
|
||||
@ -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
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
118
web/app/components/billing/plan-upgrade-modal/index.spec.tsx
Normal file
118
web/app/components/billing/plan-upgrade-modal/index.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
87
web/app/components/billing/plan-upgrade-modal/index.tsx
Normal file
87
web/app/components/billing/plan-upgrade-modal/index.tsx
Normal 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)
|
||||
@ -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 {
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
33
web/app/components/datasets/create/step-one/upgrade-card.tsx
Normal file
33
web/app/components/datasets/create/step-one/upgrade-card.tsx
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -28,7 +28,7 @@ export type LocalFileProps = {
|
||||
|
||||
const LocalFile = ({
|
||||
allowedExtensions,
|
||||
supportBatchUpload = false,
|
||||
supportBatchUpload = true,
|
||||
}: LocalFileProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
|
||||
@ -30,7 +30,7 @@ const OnlineDocuments = ({
|
||||
nodeId,
|
||||
nodeData,
|
||||
isInPipeline = false,
|
||||
supportBatchUpload = false,
|
||||
supportBatchUpload = true,
|
||||
onCredentialChange,
|
||||
}: OnlineDocumentsProps) => {
|
||||
const docLink = useDocLink()
|
||||
|
||||
@ -29,7 +29,7 @@ const OnlineDrive = ({
|
||||
nodeId,
|
||||
nodeData,
|
||||
isInPipeline = false,
|
||||
supportBatchUpload = false,
|
||||
supportBatchUpload = true,
|
||||
onCredentialChange,
|
||||
}: OnlineDriveProps) => {
|
||||
const docLink = useDocLink()
|
||||
|
||||
@ -42,7 +42,7 @@ const WebsiteCrawl = ({
|
||||
nodeId,
|
||||
nodeData,
|
||||
isInPipeline = false,
|
||||
supportBatchUpload = false,
|
||||
supportBatchUpload = true,
|
||||
onCredentialChange,
|
||||
}: WebsiteCrawlProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
}}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: 'You’ve 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: 'You’ve reached the limit of adding chunks for this plan.',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -213,6 +213,20 @@ const translation = {
|
||||
viewBillingTitle: 'बिलिंग और सब्सक्रिप्शन',
|
||||
viewBillingDescription: 'भुगतान के तरीकों, चालानों और सदस्यता में बदलावों का प्रबंधन करें',
|
||||
viewBillingAction: 'प्रबंध करना',
|
||||
upgrade: {
|
||||
uploadMultiplePages: {
|
||||
title: 'एक बार में कई दस्तावेज़ अपलोड करने के लिए अपग्रेड करें',
|
||||
description: 'आपने अपलोड की सीमा तक पहुँच लिया है — आपके वर्तमान प्लान पर एक समय में केवल एक ही दस्तावेज़ चुना और अपलोड किया जा सकता है।',
|
||||
},
|
||||
uploadMultipleFiles: {
|
||||
title: 'बैच दस्तावेज़ अपलोड अनलॉक करने के लिए अपग्रेड करें',
|
||||
description: 'समय बचाने और कार्यक्षमता बढ़ाने के लिए एक बार में अधिक दस्तावेज़ बैच-अपलोड करें।',
|
||||
},
|
||||
addChunks: {
|
||||
title: 'अधिक चंक्स जोड़ने के लिए अपग्रेड करें',
|
||||
description: 'आप इस योजना के लिए टुकड़े जोड़ने की सीमा तक पहुँच चुके हैं।',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -202,6 +202,20 @@ const translation = {
|
||||
quotaTitle: '注釈返信クォータ',
|
||||
},
|
||||
teamMembers: 'チームメンバー',
|
||||
upgrade: {
|
||||
uploadMultiplePages: {
|
||||
title: '複数ドキュメントを一度にアップロードするにはアップグレード',
|
||||
description: '現在のプランではアップロード上限に達しています。1回の操作で選択・アップロードできるドキュメントは1つのみです。',
|
||||
},
|
||||
uploadMultipleFiles: {
|
||||
title: '一括ドキュメントアップロード機能を解放するにはアップグレードが必要です',
|
||||
description: '複数のドキュメントを一度にバッチアップロードすることで、時間を節約し、作業効率を向上できます。',
|
||||
},
|
||||
addChunks: {
|
||||
title: 'アップグレードして、チャンクを引き続き追加できるようにしてください。',
|
||||
description: 'このプランでは、チャンク追加の上限に達しています。',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -202,6 +202,20 @@ const translation = {
|
||||
quotaTitle: '标注的配额',
|
||||
},
|
||||
teamMembers: '团队成员',
|
||||
upgrade: {
|
||||
uploadMultiplePages: {
|
||||
title: '升级以一次性上传多个文档',
|
||||
description: '您已达到当前套餐的上传限制 —— 该套餐每次只能选择并上传 1 个文档。',
|
||||
},
|
||||
uploadMultipleFiles: {
|
||||
title: '升级以解锁批量文档上传功能',
|
||||
description: '一次性批量上传更多文档,以节省时间并提升效率。',
|
||||
},
|
||||
addChunks: {
|
||||
title: '升级以继续添加分段',
|
||||
description: '您已达到此计划的添加分段上限。',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||
@ -202,6 +202,20 @@ const translation = {
|
||||
viewBillingTitle: '帳單與訂閱',
|
||||
viewBillingDescription: '管理付款方式、發票和訂閱變更',
|
||||
viewBillingAction: '管理',
|
||||
upgrade: {
|
||||
uploadMultiplePages: {
|
||||
title: '升級以一次上傳多個文件',
|
||||
description: '您已達到上傳限制 — 在您目前的方案下,每次只能選擇並上傳一個文件。',
|
||||
},
|
||||
uploadMultipleFiles: {
|
||||
title: '升級以解鎖批量上傳文件功能',
|
||||
description: '一次批量上傳更多文件,以節省時間並提高效率。',
|
||||
},
|
||||
addChunks: {
|
||||
title: '升級以繼續添加區塊',
|
||||
description: '您已達到此方案可新增區塊的上限。',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user