Merge branch 'fix/react-cve-2025' into deploy/dev

This commit is contained in:
NFish 2025-12-12 09:37:10 +08:00
commit dcd95632cb
56 changed files with 2472 additions and 727 deletions

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
22.11.0

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -232,7 +232,7 @@ const translation = {
},
addChunks: {
title: 'Upgrade to continue adding chunks',
description: 'Youve reached the limit of adding chunks for this plan. ',
description: 'Youve reached the limit of adding chunks for this plan.',
},
},
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -112,9 +112,9 @@
"pinyin-pro": "^3.27.0",
"qrcode.react": "^4.2.0",
"qs": "^6.14.0",
"react": "19.2.1",
"react": "19.2.3",
"react-18-input-autosize": "^3.0.0",
"react-dom": "19.2.1",
"react-dom": "19.2.3",
"react-easy-crop": "^5.5.3",
"react-hook-form": "^7.65.0",
"react-hotkeys-hook": "^4.6.2",

File diff suppressed because it is too large Load Diff