Merge branch 'main' into feat/rag-2

This commit is contained in:
twwu 2025-08-11 11:15:58 +08:00
commit fc779d00df
214 changed files with 8987 additions and 838 deletions

View File

@ -1,9 +1,10 @@
name: Check i18n Files and Create PR name: Check i18n Files and Create PR
on: on:
pull_request: push:
types: [closed]
branches: [main] branches: [main]
paths:
- 'web/i18n/en-US/*.ts'
permissions: permissions:
contents: write contents: write
@ -11,7 +12,7 @@ permissions:
jobs: jobs:
check-and-update: check-and-update:
if: github.event.pull_request.merged == true if: github.repository == 'langgenius/dify'
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@ -19,7 +20,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 2 # last 2 commits fetch-depth: 2
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Check for file changes in i18n/en-US - name: Check for file changes in i18n/en-US
@ -31,6 +32,13 @@ jobs:
echo "Changed files: $changed_files" echo "Changed files: $changed_files"
if [ -n "$changed_files" ]; then if [ -n "$changed_files" ]; then
echo "FILES_CHANGED=true" >> $GITHUB_ENV echo "FILES_CHANGED=true" >> $GITHUB_ENV
file_args=""
for file in $changed_files; do
filename=$(basename "$file" .ts)
file_args="$file_args --file=$filename"
done
echo "FILE_ARGS=$file_args" >> $GITHUB_ENV
echo "File arguments: $file_args"
else else
echo "FILES_CHANGED=false" >> $GITHUB_ENV echo "FILES_CHANGED=false" >> $GITHUB_ENV
fi fi
@ -55,7 +63,7 @@ jobs:
- name: Generate i18n translations - name: Generate i18n translations
if: env.FILES_CHANGED == 'true' if: env.FILES_CHANGED == 'true'
run: pnpm run auto-gen-i18n run: pnpm run auto-gen-i18n ${{ env.FILE_ARGS }}
- name: Create Pull Request - name: Create Pull Request
if: env.FILES_CHANGED == 'true' if: env.FILES_CHANGED == 'true'

8
.gitignore vendored
View File

@ -215,10 +215,4 @@ mise.toml
# AI Assistant # AI Assistant
.roo/ .roo/
api/.env.backup api/.env.backup
/clickzetta
# Clickzetta test credentials
.env.clickzetta
.env.clickzetta.test
# Clickzetta plugin development folder (keep local, ignore for PR)
clickzetta/

View File

@ -19,7 +19,7 @@ RUN apt-get update \
# Install Python dependencies # Install Python dependencies
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
RUN uv sync --locked RUN uv sync --locked --no-dev
# production stage # production stage
FROM base AS production FROM base AS production

View File

@ -9,7 +9,7 @@ import sqlalchemy as sa
from flask import current_app from flask import current_app
from pydantic import TypeAdapter from pydantic import TypeAdapter
from sqlalchemy import select from sqlalchemy import select
from werkzeug.exceptions import NotFound from sqlalchemy.exc import SQLAlchemyError
from configs import dify_config from configs import dify_config
from constants.languages import languages from constants.languages import languages
@ -186,8 +186,8 @@ def migrate_annotation_vector_database():
) )
if not apps: if not apps:
break break
except NotFound: except SQLAlchemyError:
break raise
page += 1 page += 1
for app in apps: for app in apps:
@ -313,8 +313,8 @@ def migrate_knowledge_vector_database():
) )
datasets = db.paginate(select=stmt, page=page, per_page=50, max_per_page=50, error_out=False) datasets = db.paginate(select=stmt, page=page, per_page=50, max_per_page=50, error_out=False)
except NotFound: except SQLAlchemyError:
break raise
page += 1 page += 1
for dataset in datasets: for dataset in datasets:
@ -566,8 +566,8 @@ def old_metadata_migration():
.order_by(DatasetDocument.created_at.desc()) .order_by(DatasetDocument.created_at.desc())
) )
documents = db.paginate(select=stmt, page=page, per_page=50, max_per_page=50, error_out=False) documents = db.paginate(select=stmt, page=page, per_page=50, max_per_page=50, error_out=False)
except NotFound: except SQLAlchemyError:
break raise
if not documents: if not documents:
break break
for document in documents: for document in documents:

View File

@ -330,17 +330,17 @@ class HttpConfig(BaseSettings):
def WEB_API_CORS_ALLOW_ORIGINS(self) -> list[str]: def WEB_API_CORS_ALLOW_ORIGINS(self) -> list[str]:
return self.inner_WEB_API_CORS_ALLOW_ORIGINS.split(",") return self.inner_WEB_API_CORS_ALLOW_ORIGINS.split(",")
HTTP_REQUEST_MAX_CONNECT_TIMEOUT: Annotated[ HTTP_REQUEST_MAX_CONNECT_TIMEOUT: int = Field(
PositiveInt, Field(ge=10, description="Maximum connection timeout in seconds for HTTP requests") ge=1, description="Maximum connection timeout in seconds for HTTP requests", default=10
] = 10 )
HTTP_REQUEST_MAX_READ_TIMEOUT: Annotated[ HTTP_REQUEST_MAX_READ_TIMEOUT: int = Field(
PositiveInt, Field(ge=60, description="Maximum read timeout in seconds for HTTP requests") ge=1, description="Maximum read timeout in seconds for HTTP requests", default=60
] = 60 )
HTTP_REQUEST_MAX_WRITE_TIMEOUT: Annotated[ HTTP_REQUEST_MAX_WRITE_TIMEOUT: int = Field(
PositiveInt, Field(ge=10, description="Maximum write timeout in seconds for HTTP requests") ge=1, description="Maximum write timeout in seconds for HTTP requests", default=20
] = 20 )
HTTP_REQUEST_NODE_MAX_BINARY_SIZE: PositiveInt = Field( HTTP_REQUEST_NODE_MAX_BINARY_SIZE: PositiveInt = Field(
description="Maximum allowed size in bytes for binary data in HTTP requests", description="Maximum allowed size in bytes for binary data in HTTP requests",

View File

@ -28,6 +28,12 @@ from services.feature_service import FeatureService
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"] ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
def _validate_description_length(description):
if description and len(description) > 400:
raise ValueError("Description cannot exceed 400 characters.")
return description
class AppListApi(Resource): class AppListApi(Resource):
@setup_required @setup_required
@login_required @login_required
@ -94,7 +100,7 @@ class AppListApi(Resource):
"""Create app""" """Create app"""
parser = reqparse.RequestParser() parser = reqparse.RequestParser()
parser.add_argument("name", type=str, required=True, location="json") parser.add_argument("name", type=str, required=True, location="json")
parser.add_argument("description", type=str, location="json") parser.add_argument("description", type=_validate_description_length, location="json")
parser.add_argument("mode", type=str, choices=ALLOW_CREATE_APP_MODES, location="json") parser.add_argument("mode", type=str, choices=ALLOW_CREATE_APP_MODES, location="json")
parser.add_argument("icon_type", type=str, location="json") parser.add_argument("icon_type", type=str, location="json")
parser.add_argument("icon", type=str, location="json") parser.add_argument("icon", type=str, location="json")
@ -146,7 +152,7 @@ class AppApi(Resource):
parser = reqparse.RequestParser() parser = reqparse.RequestParser()
parser.add_argument("name", type=str, required=True, nullable=False, location="json") parser.add_argument("name", type=str, required=True, nullable=False, location="json")
parser.add_argument("description", type=str, location="json") parser.add_argument("description", type=_validate_description_length, location="json")
parser.add_argument("icon_type", type=str, location="json") parser.add_argument("icon_type", type=str, location="json")
parser.add_argument("icon", type=str, location="json") parser.add_argument("icon", type=str, location="json")
parser.add_argument("icon_background", type=str, location="json") parser.add_argument("icon_background", type=str, location="json")
@ -189,7 +195,7 @@ class AppCopyApi(Resource):
parser = reqparse.RequestParser() parser = reqparse.RequestParser()
parser.add_argument("name", type=str, location="json") parser.add_argument("name", type=str, location="json")
parser.add_argument("description", type=str, location="json") parser.add_argument("description", type=_validate_description_length, location="json")
parser.add_argument("icon_type", type=str, location="json") parser.add_argument("icon_type", type=str, location="json")
parser.add_argument("icon", type=str, location="json") parser.add_argument("icon", type=str, location="json")
parser.add_argument("icon_background", type=str, location="json") parser.add_argument("icon_background", type=str, location="json")

View File

@ -41,7 +41,7 @@ def _validate_name(name):
def _validate_description_length(description): def _validate_description_length(description):
if len(description) > 400: if description and len(description) > 400:
raise ValueError("Description cannot exceed 400 characters.") raise ValueError("Description cannot exceed 400 characters.")
return description return description
@ -113,7 +113,7 @@ class DatasetListApi(Resource):
) )
parser.add_argument( parser.add_argument(
"description", "description",
type=str, type=_validate_description_length,
nullable=True, nullable=True,
required=False, required=False,
default="", default="",

View File

@ -6,6 +6,6 @@ bp = Blueprint("service_api", __name__, url_prefix="/v1")
api = ExternalApi(bp) api = ExternalApi(bp)
from . import index from . import index
from .app import annotation, app, audio, completion, conversation, file, message, site, workflow from .app import annotation, app, audio, completion, conversation, file, file_preview, message, site, workflow
from .dataset import dataset, document, hit_testing, metadata, segment, upload_file from .dataset import dataset, document, hit_testing, metadata, segment, upload_file
from .workspace import models from .workspace import models

View File

@ -107,3 +107,15 @@ class UnsupportedFileTypeError(BaseHTTPException):
error_code = "unsupported_file_type" error_code = "unsupported_file_type"
description = "File type not allowed." description = "File type not allowed."
code = 415 code = 415
class FileNotFoundError(BaseHTTPException):
error_code = "file_not_found"
description = "The requested file was not found."
code = 404
class FileAccessDeniedError(BaseHTTPException):
error_code = "file_access_denied"
description = "Access to the requested file is denied."
code = 403

View File

@ -0,0 +1,186 @@
import logging
from urllib.parse import quote
from flask import Response
from flask_restful import Resource, reqparse
from controllers.service_api import api
from controllers.service_api.app.error import (
FileAccessDeniedError,
FileNotFoundError,
)
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from extensions.ext_database import db
from extensions.ext_storage import storage
from models.model import App, EndUser, Message, MessageFile, UploadFile
logger = logging.getLogger(__name__)
class FilePreviewApi(Resource):
"""
Service API File Preview endpoint
Provides secure file preview/download functionality for external API users.
Files can only be accessed if they belong to messages within the requesting app's context.
"""
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
def get(self, app_model: App, end_user: EndUser, file_id: str):
"""
Preview/Download a file that was uploaded via Service API
Args:
app_model: The authenticated app model
end_user: The authenticated end user (optional)
file_id: UUID of the file to preview
Query Parameters:
user: Optional user identifier
as_attachment: Boolean, whether to download as attachment (default: false)
Returns:
Stream response with file content
Raises:
FileNotFoundError: File does not exist
FileAccessDeniedError: File access denied (not owned by app)
"""
file_id = str(file_id)
# Parse query parameters
parser = reqparse.RequestParser()
parser.add_argument("as_attachment", type=bool, required=False, default=False, location="args")
args = parser.parse_args()
# Validate file ownership and get file objects
message_file, upload_file = self._validate_file_ownership(file_id, app_model.id)
# Get file content generator
try:
generator = storage.load(upload_file.key, stream=True)
except Exception as e:
raise FileNotFoundError(f"Failed to load file content: {str(e)}")
# Build response with appropriate headers
response = self._build_file_response(generator, upload_file, args["as_attachment"])
return response
def _validate_file_ownership(self, file_id: str, app_id: str) -> tuple[MessageFile, UploadFile]:
"""
Validate that the file belongs to a message within the requesting app's context
Security validations performed:
1. File exists in MessageFile table (was used in a conversation)
2. Message belongs to the requesting app
3. UploadFile record exists and is accessible
4. File tenant matches app tenant (additional security layer)
Args:
file_id: UUID of the file to validate
app_id: UUID of the requesting app
Returns:
Tuple of (MessageFile, UploadFile) if validation passes
Raises:
FileNotFoundError: File or related records not found
FileAccessDeniedError: File does not belong to the app's context
"""
try:
# Input validation
if not file_id or not app_id:
raise FileAccessDeniedError("Invalid file or app identifier")
# First, find the MessageFile that references this upload file
message_file = db.session.query(MessageFile).where(MessageFile.upload_file_id == file_id).first()
if not message_file:
raise FileNotFoundError("File not found in message context")
# Get the message and verify it belongs to the requesting app
message = (
db.session.query(Message).where(Message.id == message_file.message_id, Message.app_id == app_id).first()
)
if not message:
raise FileAccessDeniedError("File access denied: not owned by requesting app")
# Get the actual upload file record
upload_file = db.session.query(UploadFile).where(UploadFile.id == file_id).first()
if not upload_file:
raise FileNotFoundError("Upload file record not found")
# Additional security: verify tenant isolation
app = db.session.query(App).where(App.id == app_id).first()
if app and upload_file.tenant_id != app.tenant_id:
raise FileAccessDeniedError("File access denied: tenant mismatch")
return message_file, upload_file
except (FileNotFoundError, FileAccessDeniedError):
# Re-raise our custom exceptions
raise
except Exception as e:
# Log unexpected errors for debugging
logger.exception(
"Unexpected error during file ownership validation",
extra={"file_id": file_id, "app_id": app_id, "error": str(e)},
)
raise FileAccessDeniedError("File access validation failed")
def _build_file_response(self, generator, upload_file: UploadFile, as_attachment: bool = False) -> Response:
"""
Build Flask Response object with appropriate headers for file streaming
Args:
generator: File content generator from storage
upload_file: UploadFile database record
as_attachment: Whether to set Content-Disposition as attachment
Returns:
Flask Response object with streaming file content
"""
response = Response(
generator,
mimetype=upload_file.mime_type,
direct_passthrough=True,
headers={},
)
# Add Content-Length if known
if upload_file.size and upload_file.size > 0:
response.headers["Content-Length"] = str(upload_file.size)
# Add Accept-Ranges header for audio/video files to support seeking
if upload_file.mime_type in [
"audio/mpeg",
"audio/wav",
"audio/mp4",
"audio/ogg",
"audio/flac",
"audio/aac",
"video/mp4",
"video/webm",
"video/quicktime",
"audio/x-m4a",
]:
response.headers["Accept-Ranges"] = "bytes"
# Set Content-Disposition for downloads
if as_attachment and upload_file.name:
encoded_filename = quote(upload_file.name)
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
# Override content-type for downloads to force download
response.headers["Content-Type"] = "application/octet-stream"
# Add caching headers for performance
response.headers["Cache-Control"] = "public, max-age=3600" # Cache for 1 hour
return response
# Register the API endpoint
api.add_resource(FilePreviewApi, "/files/<uuid:file_id>/preview")

View File

@ -29,7 +29,7 @@ def _validate_name(name):
def _validate_description_length(description): def _validate_description_length(description):
if len(description) > 400: if description and len(description) > 400:
raise ValueError("Description cannot exceed 400 characters.") raise ValueError("Description cannot exceed 400 characters.")
return description return description
@ -87,7 +87,7 @@ class DatasetListApi(DatasetApiResource):
) )
parser.add_argument( parser.add_argument(
"description", "description",
type=str, type=_validate_description_length,
nullable=True, nullable=True,
required=False, required=False,
default="", default="",

View File

@ -1,5 +1,6 @@
from flask import request from flask import request
from flask_restful import Resource, marshal_with, reqparse from flask_restful import Resource, marshal_with, reqparse
from werkzeug.exceptions import Unauthorized
from controllers.common import fields from controllers.common import fields
from controllers.web import api from controllers.web import api
@ -75,14 +76,14 @@ class AppWebAuthPermission(Resource):
try: try:
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
if auth_header is None: if auth_header is None:
raise raise Unauthorized("Authorization header is missing.")
if " " not in auth_header: if " " not in auth_header:
raise raise Unauthorized("Invalid Authorization header format. Expected 'Bearer <api-key>' format.")
auth_scheme, tk = auth_header.split(None, 1) auth_scheme, tk = auth_header.split(None, 1)
auth_scheme = auth_scheme.lower() auth_scheme = auth_scheme.lower()
if auth_scheme != "bearer": if auth_scheme != "bearer":
raise raise Unauthorized("Authorization scheme must be 'Bearer'")
decoded = PassportService().verify(tk) decoded = PassportService().verify(tk)
user_id = decoded.get("user_id", "visitor") user_id = decoded.get("user_id", "visitor")

View File

@ -118,26 +118,8 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
): ):
return return
# Init conversation variables # Initialize conversation variables
stmt = select(ConversationVariable).where( conversation_variables = self._initialize_conversation_variables()
ConversationVariable.app_id == self.conversation.app_id,
ConversationVariable.conversation_id == self.conversation.id,
)
with Session(db.engine) as session:
db_conversation_variables = session.scalars(stmt).all()
if not db_conversation_variables:
# Create conversation variables if they don't exist.
db_conversation_variables = [
ConversationVariable.from_variable(
app_id=self.conversation.app_id, conversation_id=self.conversation.id, variable=variable
)
for variable in self._workflow.conversation_variables
]
session.add_all(db_conversation_variables)
# Convert database entities to variables.
conversation_variables = [item.to_variable() for item in db_conversation_variables]
session.commit()
# Create a variable pool. # Create a variable pool.
system_inputs = SystemVariable( system_inputs = SystemVariable(
@ -292,3 +274,100 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
message_id=message_id, message_id=message_id,
trace_manager=app_generate_entity.trace_manager, trace_manager=app_generate_entity.trace_manager,
) )
def _initialize_conversation_variables(self) -> list[VariableUnion]:
"""
Initialize conversation variables for the current conversation.
This method:
1. Loads existing variables from the database
2. Creates new variables if none exist
3. Syncs missing variables from the workflow definition
:return: List of conversation variables ready for use
"""
with Session(db.engine) as session:
existing_variables = self._load_existing_conversation_variables(session)
if not existing_variables:
# First time initialization - create all variables
existing_variables = self._create_all_conversation_variables(session)
else:
# Check and add any missing variables from the workflow
existing_variables = self._sync_missing_conversation_variables(session, existing_variables)
# Convert to Variable objects for use in the workflow
conversation_variables = [var.to_variable() for var in existing_variables]
session.commit()
return cast(list[VariableUnion], conversation_variables)
def _load_existing_conversation_variables(self, session: Session) -> list[ConversationVariable]:
"""
Load existing conversation variables from the database.
:param session: Database session
:return: List of existing conversation variables
"""
stmt = select(ConversationVariable).where(
ConversationVariable.app_id == self.conversation.app_id,
ConversationVariable.conversation_id == self.conversation.id,
)
return list(session.scalars(stmt).all())
def _create_all_conversation_variables(self, session: Session) -> list[ConversationVariable]:
"""
Create all conversation variables for a new conversation.
:param session: Database session
:return: List of created conversation variables
"""
new_variables = [
ConversationVariable.from_variable(
app_id=self.conversation.app_id, conversation_id=self.conversation.id, variable=variable
)
for variable in self._workflow.conversation_variables
]
if new_variables:
session.add_all(new_variables)
return new_variables
def _sync_missing_conversation_variables(
self, session: Session, existing_variables: list[ConversationVariable]
) -> list[ConversationVariable]:
"""
Sync missing conversation variables from the workflow definition.
This handles the case where new variables are added to a workflow
after conversations have already been created.
:param session: Database session
:param existing_variables: List of existing conversation variables
:return: Updated list including any newly created variables
"""
# Get IDs of existing and workflow variables
existing_ids = {var.id for var in existing_variables}
workflow_variables = {var.id: var for var in self._workflow.conversation_variables}
# Find missing variable IDs
missing_ids = set(workflow_variables.keys()) - existing_ids
if not missing_ids:
return existing_variables
# Create missing variables with their default values
new_variables = [
ConversationVariable.from_variable(
app_id=self.conversation.app_id,
conversation_id=self.conversation.id,
variable=workflow_variables[var_id],
)
for var_id in missing_ids
]
session.add_all(new_variables)
# Return combined list
return existing_variables + new_variables

View File

@ -23,6 +23,7 @@ from core.app.entities.task_entities import (
MessageFileStreamResponse, MessageFileStreamResponse,
MessageReplaceStreamResponse, MessageReplaceStreamResponse,
MessageStreamResponse, MessageStreamResponse,
StreamEvent,
WorkflowTaskState, WorkflowTaskState,
) )
from core.llm_generator.llm_generator import LLMGenerator from core.llm_generator.llm_generator import LLMGenerator
@ -180,11 +181,15 @@ class MessageCycleManager:
:param message_id: message id :param message_id: message id
:return: :return:
""" """
message_file = db.session.query(MessageFile).filter(MessageFile.id == message_id).first()
event_type = StreamEvent.MESSAGE_FILE if message_file else StreamEvent.MESSAGE
return MessageStreamResponse( return MessageStreamResponse(
task_id=self._application_generate_entity.task_id, task_id=self._application_generate_entity.task_id,
id=message_id, id=message_id,
answer=answer, answer=answer,
from_variable_selector=from_variable_selector, from_variable_selector=from_variable_selector,
event=event_type,
) )
def message_replace_to_stream_response(self, answer: str, reason: str = "") -> MessageReplaceStreamResponse: def message_replace_to_stream_response(self, answer: str, reason: str = "") -> MessageReplaceStreamResponse:

View File

@ -843,7 +843,7 @@ class ProviderConfiguration(BaseModel):
continue continue
status = ModelStatus.ACTIVE status = ModelStatus.ACTIVE
if m.model in model_setting_map: if m.model_type in model_setting_map and m.model in model_setting_map[m.model_type]:
model_setting = model_setting_map[m.model_type][m.model] model_setting = model_setting_map[m.model_type][m.model]
if model_setting.enabled is False: if model_setting.enabled is False:
status = ModelStatus.DISABLED status = ModelStatus.DISABLED

View File

@ -185,6 +185,6 @@ Clickzetta supports advanced full-text search with multiple analyzers:
## References ## References
- [Clickzetta Vector Search Documentation](../../../../../../../yunqidoc/cn_markdown_20250526/vector-search.md) - [Clickzetta Vector Search Documentation](https://yunqi.tech/documents/vector-search)
- [Clickzetta Inverted Index Documentation](../../../../../../../yunqidoc/cn_markdown_20250526/inverted-index.md) - [Clickzetta Inverted Index Documentation](https://yunqi.tech/documents/inverted-index)
- [Clickzetta SQL Functions](../../../../../../../yunqidoc/cn_markdown_20250526/sql_functions/) - [Clickzetta SQL Functions](https://yunqi.tech/documents/sql-reference)

View File

@ -1,9 +1,11 @@
import json import json
import logging import logging
import queue import queue
import re
import threading import threading
import time
import uuid import uuid
from typing import Any, Optional, TYPE_CHECKING from typing import TYPE_CHECKING, Any, Optional
import clickzetta # type: ignore import clickzetta # type: ignore
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
@ -67,6 +69,243 @@ class ClickzettaConfig(BaseModel):
return values return values
class ClickzettaConnectionPool:
"""
Global connection pool for ClickZetta connections.
Manages connection reuse across ClickzettaVector instances.
"""
_instance: Optional["ClickzettaConnectionPool"] = None
_lock = threading.Lock()
def __init__(self):
self._pools: dict[str, list[tuple[Connection, float]]] = {} # config_key -> [(connection, last_used_time)]
self._pool_locks: dict[str, threading.Lock] = {}
self._max_pool_size = 5 # Maximum connections per configuration
self._connection_timeout = 300 # 5 minutes timeout
self._cleanup_thread: Optional[threading.Thread] = None
self._shutdown = False
self._start_cleanup_thread()
@classmethod
def get_instance(cls) -> "ClickzettaConnectionPool":
"""Get singleton instance of connection pool."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def _get_config_key(self, config: ClickzettaConfig) -> str:
"""Generate unique key for connection configuration."""
return (
f"{config.username}:{config.instance}:{config.service}:"
f"{config.workspace}:{config.vcluster}:{config.schema_name}"
)
def _create_connection(self, config: ClickzettaConfig) -> "Connection":
"""Create a new ClickZetta connection."""
max_retries = 3
retry_delay = 1.0
for attempt in range(max_retries):
try:
connection = clickzetta.connect(
username=config.username,
password=config.password,
instance=config.instance,
service=config.service,
workspace=config.workspace,
vcluster=config.vcluster,
schema=config.schema_name,
)
# Configure connection session settings
self._configure_connection(connection)
logger.debug("Created new ClickZetta connection (attempt %d/%d)", attempt + 1, max_retries)
return connection
except Exception:
logger.exception("ClickZetta connection attempt %d/%d failed", attempt + 1, max_retries)
if attempt < max_retries - 1:
time.sleep(retry_delay * (2**attempt))
else:
raise
raise RuntimeError(f"Failed to create ClickZetta connection after {max_retries} attempts")
def _configure_connection(self, connection: "Connection") -> None:
"""Configure connection session settings."""
try:
with connection.cursor() as cursor:
# Temporarily suppress ClickZetta client logging to reduce noise
clickzetta_logger = logging.getLogger("clickzetta")
original_level = clickzetta_logger.level
clickzetta_logger.setLevel(logging.WARNING)
try:
# Use quote mode for string literal escaping
cursor.execute("SET cz.sql.string.literal.escape.mode = 'quote'")
# Apply performance optimization hints
performance_hints = [
# Vector index optimization
"SET cz.storage.parquet.vector.index.read.memory.cache = true",
"SET cz.storage.parquet.vector.index.read.local.cache = false",
# Query optimization
"SET cz.sql.table.scan.push.down.filter = true",
"SET cz.sql.table.scan.enable.ensure.filter = true",
"SET cz.storage.always.prefetch.internal = true",
"SET cz.optimizer.generate.columns.always.valid = true",
"SET cz.sql.index.prewhere.enabled = true",
# Storage optimization
"SET cz.storage.parquet.enable.io.prefetch = false",
"SET cz.optimizer.enable.mv.rewrite = false",
"SET cz.sql.dump.as.lz4 = true",
"SET cz.optimizer.limited.optimization.naive.query = true",
"SET cz.sql.table.scan.enable.push.down.log = false",
"SET cz.storage.use.file.format.local.stats = false",
"SET cz.storage.local.file.object.cache.level = all",
# Job execution optimization
"SET cz.sql.job.fast.mode = true",
"SET cz.storage.parquet.non.contiguous.read = true",
"SET cz.sql.compaction.after.commit = true",
]
for hint in performance_hints:
cursor.execute(hint)
finally:
# Restore original logging level
clickzetta_logger.setLevel(original_level)
except Exception:
logger.exception("Failed to configure connection, continuing with defaults")
def _is_connection_valid(self, connection: "Connection") -> bool:
"""Check if connection is still valid."""
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
return True
except Exception:
return False
def get_connection(self, config: ClickzettaConfig) -> "Connection":
"""Get a connection from the pool or create a new one."""
config_key = self._get_config_key(config)
# Ensure pool lock exists
if config_key not in self._pool_locks:
with self._lock:
if config_key not in self._pool_locks:
self._pool_locks[config_key] = threading.Lock()
self._pools[config_key] = []
with self._pool_locks[config_key]:
pool = self._pools[config_key]
current_time = time.time()
# Try to reuse existing connection
while pool:
connection, last_used = pool.pop(0)
# Check if connection is not expired and still valid
if current_time - last_used < self._connection_timeout and self._is_connection_valid(connection):
logger.debug("Reusing ClickZetta connection from pool")
return connection
else:
# Connection expired or invalid, close it
try:
connection.close()
except Exception:
pass
# No valid connection found, create new one
return self._create_connection(config)
def return_connection(self, config: ClickzettaConfig, connection: "Connection") -> None:
"""Return a connection to the pool."""
config_key = self._get_config_key(config)
if config_key not in self._pool_locks:
# Pool was cleaned up, just close the connection
try:
connection.close()
except Exception:
pass
return
with self._pool_locks[config_key]:
pool = self._pools[config_key]
# Only return to pool if not at capacity and connection is valid
if len(pool) < self._max_pool_size and self._is_connection_valid(connection):
pool.append((connection, time.time()))
logger.debug("Returned ClickZetta connection to pool")
else:
# Pool full or connection invalid, close it
try:
connection.close()
except Exception:
pass
def _cleanup_expired_connections(self) -> None:
"""Clean up expired connections from all pools."""
current_time = time.time()
with self._lock:
for config_key in list(self._pools.keys()):
if config_key not in self._pool_locks:
continue
with self._pool_locks[config_key]:
pool = self._pools[config_key]
valid_connections = []
for connection, last_used in pool:
if current_time - last_used < self._connection_timeout:
valid_connections.append((connection, last_used))
else:
try:
connection.close()
except Exception:
pass
self._pools[config_key] = valid_connections
def _start_cleanup_thread(self) -> None:
"""Start background thread for connection cleanup."""
def cleanup_worker():
while not self._shutdown:
try:
time.sleep(60) # Cleanup every minute
if not self._shutdown:
self._cleanup_expired_connections()
except Exception:
logger.exception("Error in connection pool cleanup")
self._cleanup_thread = threading.Thread(target=cleanup_worker, daemon=True)
self._cleanup_thread.start()
def shutdown(self) -> None:
"""Shutdown connection pool and close all connections."""
self._shutdown = True
with self._lock:
for config_key in list(self._pools.keys()):
if config_key not in self._pool_locks:
continue
with self._pool_locks[config_key]:
pool = self._pools[config_key]
for connection, _ in pool:
try:
connection.close()
except Exception:
pass
pool.clear()
class ClickzettaVector(BaseVector): class ClickzettaVector(BaseVector):
""" """
Clickzetta vector storage implementation. Clickzetta vector storage implementation.
@ -82,71 +321,74 @@ class ClickzettaVector(BaseVector):
super().__init__(collection_name) super().__init__(collection_name)
self._config = config self._config = config
self._table_name = collection_name.replace("-", "_").lower() # Ensure valid table name self._table_name = collection_name.replace("-", "_").lower() # Ensure valid table name
self._connection: Optional["Connection"] = None self._connection_pool = ClickzettaConnectionPool.get_instance()
self._init_connection()
self._init_write_queue() self._init_write_queue()
def _init_connection(self): def _get_connection(self) -> "Connection":
"""Initialize Clickzetta connection.""" """Get a connection from the pool."""
self._connection = clickzetta.connect( return self._connection_pool.get_connection(self._config)
username=self._config.username,
password=self._config.password,
instance=self._config.instance,
service=self._config.service,
workspace=self._config.workspace,
vcluster=self._config.vcluster,
schema=self._config.schema_name
)
# Set session parameters for better string handling and performance optimization def _return_connection(self, connection: "Connection") -> None:
if self._connection is not None: """Return a connection to the pool."""
with self._connection.cursor() as cursor: self._connection_pool.return_connection(self._config, connection)
# Use quote mode for string literal escaping to handle quotes better
cursor.execute("SET cz.sql.string.literal.escape.mode = 'quote'")
logger.info("Set string literal escape mode to 'quote' for better quote handling")
# Performance optimization hints for vector operations class ConnectionContext:
self._set_performance_hints(cursor) """Context manager for borrowing and returning connections."""
def _set_performance_hints(self, cursor): def __init__(self, vector_instance: "ClickzettaVector"):
"""Set ClickZetta performance optimization hints for vector operations.""" self.vector = vector_instance
self.connection: Optional[Connection] = None
def __enter__(self) -> "Connection":
self.connection = self.vector._get_connection()
return self.connection
def __exit__(self, exc_type, exc_val, exc_tb):
if self.connection:
self.vector._return_connection(self.connection)
def get_connection_context(self) -> "ClickzettaVector.ConnectionContext":
"""Get a connection context manager."""
return self.ConnectionContext(self)
def _parse_metadata(self, raw_metadata: str, row_id: str) -> dict:
"""
Parse metadata from JSON string with proper error handling and fallback.
Args:
raw_metadata: Raw JSON string from database
row_id: Row ID for fallback document_id
Returns:
Parsed metadata dict with guaranteed required fields
"""
try: try:
# Performance optimization hints for vector operations and query processing if raw_metadata:
performance_hints = [ metadata = json.loads(raw_metadata)
# Vector index optimization
"SET cz.storage.parquet.vector.index.read.memory.cache = true",
"SET cz.storage.parquet.vector.index.read.local.cache = false",
# Query optimization # Handle double-encoded JSON
"SET cz.sql.table.scan.push.down.filter = true", if isinstance(metadata, str):
"SET cz.sql.table.scan.enable.ensure.filter = true", metadata = json.loads(metadata)
"SET cz.storage.always.prefetch.internal = true",
"SET cz.optimizer.generate.columns.always.valid = true",
"SET cz.sql.index.prewhere.enabled = true",
# Storage optimization # Ensure we have a dict
"SET cz.storage.parquet.enable.io.prefetch = false", if not isinstance(metadata, dict):
"SET cz.optimizer.enable.mv.rewrite = false", metadata = {}
"SET cz.sql.dump.as.lz4 = true", else:
"SET cz.optimizer.limited.optimization.naive.query = true", metadata = {}
"SET cz.sql.table.scan.enable.push.down.log = false", except (json.JSONDecodeError, TypeError):
"SET cz.storage.use.file.format.local.stats = false", logger.exception("JSON parsing failed for metadata")
"SET cz.storage.local.file.object.cache.level = all", # Fallback: extract document_id with regex
doc_id_match = re.search(r'"document_id":\s*"([^"]+)"', raw_metadata or "")
metadata = {"document_id": doc_id_match.group(1)} if doc_id_match else {}
# Job execution optimization # Ensure required fields are set
"SET cz.sql.job.fast.mode = true", metadata["doc_id"] = row_id # segment id
"SET cz.storage.parquet.non.contiguous.read = true",
"SET cz.sql.compaction.after.commit = true"
]
for hint in performance_hints: # Ensure document_id exists (critical for Dify's format_retrieval_documents)
cursor.execute(hint) if "document_id" not in metadata:
metadata["document_id"] = row_id # fallback to segment id
logger.info("Applied %d performance optimization hints for ClickZetta vector operations", len(performance_hints)) return metadata
except Exception:
# Catch any errors setting performance hints but continue with defaults
logger.exception("Failed to set some performance hints, continuing with default settings")
@classmethod @classmethod
def _init_write_queue(cls): def _init_write_queue(cls):
@ -205,24 +447,33 @@ class ClickzettaVector(BaseVector):
return "clickzetta" return "clickzetta"
def _ensure_connection(self) -> "Connection": def _ensure_connection(self) -> "Connection":
"""Ensure connection is available and return it.""" """Get a connection from the pool."""
if self._connection is None: return self._get_connection()
raise RuntimeError("Database connection not initialized")
return self._connection
def _table_exists(self) -> bool: def _table_exists(self) -> bool:
"""Check if the table exists.""" """Check if the table exists."""
try: try:
connection = self._ensure_connection() with self.get_connection_context() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute(f"DESC {self._config.schema_name}.{self._table_name}") cursor.execute(f"DESC {self._config.schema_name}.{self._table_name}")
return True return True
except (RuntimeError, ValueError) as e: except Exception as e:
if "table or view not found" in str(e).lower(): error_message = str(e).lower()
# Handle ClickZetta specific "table or view not found" errors
if any(
phrase in error_message
for phrase in ["table or view not found", "czlh-42000", "semantic analysis exception"]
):
logger.debug("Table %s.%s does not exist", self._config.schema_name, self._table_name)
return False return False
else: else:
# Re-raise if it's a different error # For other connection/permission errors, log warning but return False to avoid blocking cleanup
raise logger.exception(
"Table existence check failed for %s.%s, assuming it doesn't exist",
self._config.schema_name,
self._table_name,
)
return False
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs): def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs):
"""Create the collection and add initial documents.""" """Create the collection and add initial documents."""
@ -254,17 +505,17 @@ class ClickzettaVector(BaseVector):
) COMMENT 'Dify RAG knowledge base vector storage table for document embeddings and content' ) COMMENT 'Dify RAG knowledge base vector storage table for document embeddings and content'
""" """
connection = self._ensure_connection() with self.get_connection_context() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute(create_table_sql) cursor.execute(create_table_sql)
logger.info("Created table %s.%s", self._config.schema_name, self._table_name) logger.info("Created table %s.%s", self._config.schema_name, self._table_name)
# Create vector index # Create vector index
self._create_vector_index(cursor) self._create_vector_index(cursor)
# Create inverted index for full-text search if enabled # Create inverted index for full-text search if enabled
if self._config.enable_inverted_index: if self._config.enable_inverted_index:
self._create_inverted_index(cursor) self._create_inverted_index(cursor)
def _create_vector_index(self, cursor): def _create_vector_index(self, cursor):
"""Create HNSW vector index for similarity search.""" """Create HNSW vector index for similarity search."""
@ -298,9 +549,7 @@ class ClickzettaVector(BaseVector):
logger.info("Created vector index: %s", index_name) logger.info("Created vector index: %s", index_name)
except (RuntimeError, ValueError) as e: except (RuntimeError, ValueError) as e:
error_msg = str(e).lower() error_msg = str(e).lower()
if ("already exists" in error_msg or if "already exists" in error_msg or "already has index" in error_msg or "with the same type" in error_msg:
"already has index" in error_msg or
"with the same type" in error_msg):
logger.info("Vector index already exists: %s", e) logger.info("Vector index already exists: %s", e)
else: else:
logger.exception("Failed to create vector index") logger.exception("Failed to create vector index")
@ -318,9 +567,11 @@ class ClickzettaVector(BaseVector):
for idx in existing_indexes: for idx in existing_indexes:
idx_str = str(idx).lower() idx_str = str(idx).lower()
# More precise check: look for inverted index specifically on the content column # More precise check: look for inverted index specifically on the content column
if ("inverted" in idx_str and if (
Field.CONTENT_KEY.value.lower() in idx_str and "inverted" in idx_str
(index_name.lower() in idx_str or f"idx_{self._table_name}_text" in idx_str)): and Field.CONTENT_KEY.value.lower() in idx_str
and (index_name.lower() in idx_str or f"idx_{self._table_name}_text" in idx_str)
):
logger.info("Inverted index already exists on column %s: %s", Field.CONTENT_KEY.value, idx) logger.info("Inverted index already exists on column %s: %s", Field.CONTENT_KEY.value, idx)
return return
except (RuntimeError, ValueError) as e: except (RuntimeError, ValueError) as e:
@ -340,11 +591,12 @@ class ClickzettaVector(BaseVector):
except (RuntimeError, ValueError) as e: except (RuntimeError, ValueError) as e:
error_msg = str(e).lower() error_msg = str(e).lower()
# Handle ClickZetta specific error messages # Handle ClickZetta specific error messages
if (("already exists" in error_msg or if (
"already has index" in error_msg or "already exists" in error_msg
"with the same type" in error_msg or or "already has index" in error_msg
"cannot create inverted index" in error_msg) and or "with the same type" in error_msg
"already has index" in error_msg): or "cannot create inverted index" in error_msg
) and "already has index" in error_msg:
logger.info("Inverted index already exists on column %s", Field.CONTENT_KEY.value) logger.info("Inverted index already exists on column %s", Field.CONTENT_KEY.value)
# Try to get the existing index name for logging # Try to get the existing index name for logging
try: try:
@ -360,7 +612,6 @@ class ClickzettaVector(BaseVector):
logger.warning("Failed to create inverted index: %s", e) logger.warning("Failed to create inverted index: %s", e)
# Continue without inverted index - full-text search will fall back to LIKE # Continue without inverted index - full-text search will fall back to LIKE
def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs): def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs):
"""Add documents with embeddings to the collection.""" """Add documents with embeddings to the collection."""
if not documents: if not documents:
@ -370,14 +621,20 @@ class ClickzettaVector(BaseVector):
total_batches = (len(documents) + batch_size - 1) // batch_size total_batches = (len(documents) + batch_size - 1) // batch_size
for i in range(0, len(documents), batch_size): for i in range(0, len(documents), batch_size):
batch_docs = documents[i:i + batch_size] batch_docs = documents[i : i + batch_size]
batch_embeddings = embeddings[i:i + batch_size] batch_embeddings = embeddings[i : i + batch_size]
# Execute batch insert through write queue # Execute batch insert through write queue
self._execute_write(self._insert_batch, batch_docs, batch_embeddings, i, batch_size, total_batches) self._execute_write(self._insert_batch, batch_docs, batch_embeddings, i, batch_size, total_batches)
def _insert_batch(self, batch_docs: list[Document], batch_embeddings: list[list[float]], def _insert_batch(
batch_index: int, batch_size: int, total_batches: int): self,
batch_docs: list[Document],
batch_embeddings: list[list[float]],
batch_index: int,
batch_size: int,
total_batches: int,
):
"""Insert a batch of documents using parameterized queries (executed in write worker thread).""" """Insert a batch of documents using parameterized queries (executed in write worker thread)."""
if not batch_docs or not batch_embeddings: if not batch_docs or not batch_embeddings:
logger.warning("Empty batch provided, skipping insertion") logger.warning("Empty batch provided, skipping insertion")
@ -411,7 +668,7 @@ class ClickzettaVector(BaseVector):
# According to ClickZetta docs, vector should be formatted as array string # According to ClickZetta docs, vector should be formatted as array string
# for external systems: '[1.0, 2.0, 3.0]' # for external systems: '[1.0, 2.0, 3.0]'
vector_str = '[' + ','.join(map(str, embedding)) + ']' vector_str = "[" + ",".join(map(str, embedding)) + "]"
data_rows.append([doc_id, content, metadata_json, vector_str]) data_rows.append([doc_id, content, metadata_json, vector_str])
# Check if we have any valid data to insert # Check if we have any valid data to insert
@ -427,37 +684,53 @@ class ClickzettaVector(BaseVector):
f"VALUES (?, ?, CAST(? AS JSON), CAST(? AS VECTOR({vector_dimension})))" f"VALUES (?, ?, CAST(? AS JSON), CAST(? AS VECTOR({vector_dimension})))"
) )
connection = self._ensure_connection() with self.get_connection_context() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
try: try:
# Set session-level hints for batch insert operations # Set session-level hints for batch insert operations
# Note: executemany doesn't support hints parameter, so we set them as session variables # Note: executemany doesn't support hints parameter, so we set them as session variables
cursor.execute("SET cz.sql.job.fast.mode = true") # Temporarily suppress ClickZetta client logging to reduce noise
cursor.execute("SET cz.sql.compaction.after.commit = true") clickzetta_logger = logging.getLogger("clickzetta")
cursor.execute("SET cz.storage.always.prefetch.internal = true") original_level = clickzetta_logger.level
clickzetta_logger.setLevel(logging.WARNING)
cursor.executemany(insert_sql, data_rows) try:
logger.info( cursor.execute("SET cz.sql.job.fast.mode = true")
f"Inserted batch {batch_index // batch_size + 1}/{total_batches} " cursor.execute("SET cz.sql.compaction.after.commit = true")
f"({len(data_rows)} valid docs using parameterized query with VECTOR({vector_dimension}) cast)" cursor.execute("SET cz.storage.always.prefetch.internal = true")
) finally:
except (RuntimeError, ValueError, TypeError, ConnectionError) as e: # Restore original logging level
logger.exception("Parameterized SQL execution failed for %d documents: %s", len(data_rows), e) clickzetta_logger.setLevel(original_level)
logger.exception("SQL template: %s", insert_sql)
logger.exception("Sample data row: %s", data_rows[0] if data_rows else 'None') cursor.executemany(insert_sql, data_rows)
raise logger.info(
"Inserted batch %d/%d (%d valid docs using parameterized query with VECTOR(%d) cast)",
batch_index // batch_size + 1,
total_batches,
len(data_rows),
vector_dimension,
)
except (RuntimeError, ValueError, TypeError, ConnectionError) as e:
logger.exception("Parameterized SQL execution failed for %d documents", len(data_rows))
logger.exception("SQL template: %s", insert_sql)
logger.exception("Sample data row: %s", data_rows[0] if data_rows else "None")
raise
def text_exists(self, id: str) -> bool: def text_exists(self, id: str) -> bool:
"""Check if a document exists by ID.""" """Check if a document exists by ID."""
# Check if table exists first
if not self._table_exists():
return False
safe_id = self._safe_doc_id(id) safe_id = self._safe_doc_id(id)
connection = self._ensure_connection() with self.get_connection_context() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute( cursor.execute(
f"SELECT COUNT(*) FROM {self._config.schema_name}.{self._table_name} WHERE id = ?", f"SELECT COUNT(*) FROM {self._config.schema_name}.{self._table_name} WHERE id = ?",
[safe_id] binding_params=[safe_id],
) )
result = cursor.fetchone() result = cursor.fetchone()
return result[0] > 0 if result else False return result[0] > 0 if result else False
def delete_by_ids(self, ids: list[str]) -> None: def delete_by_ids(self, ids: list[str]) -> None:
"""Delete documents by IDs.""" """Delete documents by IDs."""
@ -475,13 +748,14 @@ class ClickzettaVector(BaseVector):
def _delete_by_ids_impl(self, ids: list[str]) -> None: def _delete_by_ids_impl(self, ids: list[str]) -> None:
"""Implementation of delete by IDs (executed in write worker thread).""" """Implementation of delete by IDs (executed in write worker thread)."""
safe_ids = [self._safe_doc_id(id) for id in ids] safe_ids = [self._safe_doc_id(id) for id in ids]
# Create properly escaped string literals for SQL
id_list = ",".join(f"'{id}'" for id in safe_ids)
sql = f"DELETE FROM {self._config.schema_name}.{self._table_name} WHERE id IN ({id_list})"
connection = self._ensure_connection() # Use parameterized query to prevent SQL injection
with connection.cursor() as cursor: placeholders = ",".join("?" for _ in safe_ids)
cursor.execute(sql) sql = f"DELETE FROM {self._config.schema_name}.{self._table_name} WHERE id IN ({placeholders})"
with self.get_connection_context() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, binding_params=safe_ids)
def delete_by_metadata_field(self, key: str, value: str) -> None: def delete_by_metadata_field(self, key: str, value: str) -> None:
"""Delete documents by metadata field.""" """Delete documents by metadata field."""
@ -495,17 +769,28 @@ class ClickzettaVector(BaseVector):
def _delete_by_metadata_field_impl(self, key: str, value: str) -> None: def _delete_by_metadata_field_impl(self, key: str, value: str) -> None:
"""Implementation of delete by metadata field (executed in write worker thread).""" """Implementation of delete by metadata field (executed in write worker thread)."""
connection = self._ensure_connection() with self.get_connection_context() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
# Using JSON path to filter with parameterized query # Using JSON path to filter with parameterized query
# Note: JSON path requires literal key name, cannot be parameterized # Note: JSON path requires literal key name, cannot be parameterized
# Use json_extract_string function for ClickZetta compatibility # Use json_extract_string function for ClickZetta compatibility
sql = (f"DELETE FROM {self._config.schema_name}.{self._table_name} " sql = (
f"WHERE json_extract_string({Field.METADATA_KEY.value}, '$.{key}') = ?") f"DELETE FROM {self._config.schema_name}.{self._table_name} "
cursor.execute(sql, [value]) f"WHERE json_extract_string({Field.METADATA_KEY.value}, '$.{key}') = ?"
)
cursor.execute(sql, binding_params=[value])
def search_by_vector(self, query_vector: list[float], **kwargs: Any) -> list[Document]: def search_by_vector(self, query_vector: list[float], **kwargs: Any) -> list[Document]:
"""Search for documents by vector similarity.""" """Search for documents by vector similarity."""
# Check if table exists first
if not self._table_exists():
logger.warning(
"Table %s.%s does not exist, returning empty results",
self._config.schema_name,
self._table_name,
)
return []
top_k = kwargs.get("top_k", 10) top_k = kwargs.get("top_k", 10)
score_threshold = kwargs.get("score_threshold", 0.0) score_threshold = kwargs.get("score_threshold", 0.0)
document_ids_filter = kwargs.get("document_ids_filter") document_ids_filter = kwargs.get("document_ids_filter")
@ -532,15 +817,15 @@ class ClickzettaVector(BaseVector):
distance_func = "COSINE_DISTANCE" distance_func = "COSINE_DISTANCE"
if score_threshold > 0: if score_threshold > 0:
query_vector_str = f"CAST('[{self._format_vector_simple(query_vector)}]' AS VECTOR({vector_dimension}))" query_vector_str = f"CAST('[{self._format_vector_simple(query_vector)}]' AS VECTOR({vector_dimension}))"
filter_clauses.append(f"{distance_func}({Field.VECTOR.value}, " filter_clauses.append(
f"{query_vector_str}) < {2 - score_threshold}") f"{distance_func}({Field.VECTOR.value}, {query_vector_str}) < {2 - score_threshold}"
)
else: else:
# For L2 distance, smaller is better # For L2 distance, smaller is better
distance_func = "L2_DISTANCE" distance_func = "L2_DISTANCE"
if score_threshold > 0: if score_threshold > 0:
query_vector_str = f"CAST('[{self._format_vector_simple(query_vector)}]' AS VECTOR({vector_dimension}))" query_vector_str = f"CAST('[{self._format_vector_simple(query_vector)}]' AS VECTOR({vector_dimension}))"
filter_clauses.append(f"{distance_func}({Field.VECTOR.value}, " filter_clauses.append(f"{distance_func}({Field.VECTOR.value}, {query_vector_str}) < {score_threshold}")
f"{query_vector_str}) < {score_threshold}")
where_clause = " AND ".join(filter_clauses) if filter_clauses else "1=1" where_clause = " AND ".join(filter_clauses) if filter_clauses else "1=1"
@ -556,55 +841,31 @@ class ClickzettaVector(BaseVector):
""" """
documents = [] documents = []
connection = self._ensure_connection() with self.get_connection_context() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
# Use hints parameter for vector search optimization # Use hints parameter for vector search optimization
search_hints = { search_hints = {
'hints': { "hints": {
'sdk.job.timeout': 60, # Increase timeout for vector search "sdk.job.timeout": 60, # Increase timeout for vector search
'cz.sql.job.fast.mode': True, "cz.sql.job.fast.mode": True,
'cz.storage.parquet.vector.index.read.memory.cache': True "cz.storage.parquet.vector.index.read.memory.cache": True,
}
} }
} cursor.execute(search_sql, search_hints)
cursor.execute(search_sql, parameters=search_hints) results = cursor.fetchall()
results = cursor.fetchall()
for row in results: for row in results:
# Parse metadata from JSON string (may be double-encoded) # Parse metadata using centralized method
try: metadata = self._parse_metadata(row[2], row[0])
if row[2]:
metadata = json.loads(row[2])
# If result is a string, it's double-encoded JSON - parse again # Add score based on distance
if isinstance(metadata, str): if self._config.vector_distance_function == "cosine_distance":
metadata = json.loads(metadata) metadata["score"] = 1 - (row[3] / 2)
if not isinstance(metadata, dict):
metadata = {}
else: else:
metadata = {} metadata["score"] = 1 / (1 + row[3])
except (json.JSONDecodeError, TypeError) as e:
logger.error("JSON parsing failed: %s", e)
# Fallback: extract document_id with regex
import re
doc_id_match = re.search(r'"document_id":\s*"([^"]+)"', str(row[2] or ''))
metadata = {"document_id": doc_id_match.group(1)} if doc_id_match else {}
# Ensure required fields are set doc = Document(page_content=row[1], metadata=metadata)
metadata["doc_id"] = row[0] # segment id documents.append(doc)
# Ensure document_id exists (critical for Dify's format_retrieval_documents)
if "document_id" not in metadata:
metadata["document_id"] = row[0] # fallback to segment id
# Add score based on distance
if self._config.vector_distance_function == "cosine_distance":
metadata["score"] = 1 - (row[3] / 2)
else:
metadata["score"] = 1 / (1 + row[3])
doc = Document(page_content=row[1], metadata=metadata)
documents.append(doc)
return documents return documents
@ -614,6 +875,15 @@ class ClickzettaVector(BaseVector):
logger.warning("Full-text search is not enabled. Enable inverted index in config.") logger.warning("Full-text search is not enabled. Enable inverted index in config.")
return [] return []
# Check if table exists first
if not self._table_exists():
logger.warning(
"Table %s.%s does not exist, returning empty results",
self._config.schema_name,
self._table_name,
)
return []
top_k = kwargs.get("top_k", 10) top_k = kwargs.get("top_k", 10)
document_ids_filter = kwargs.get("document_ids_filter") document_ids_filter = kwargs.get("document_ids_filter")
@ -649,61 +919,70 @@ class ClickzettaVector(BaseVector):
""" """
documents = [] documents = []
connection = self._ensure_connection() with self.get_connection_context() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
try: try:
# Use hints parameter for full-text search optimization # Use hints parameter for full-text search optimization
fulltext_hints = { fulltext_hints = {
'hints': { "hints": {
'sdk.job.timeout': 30, # Timeout for full-text search "sdk.job.timeout": 30, # Timeout for full-text search
'cz.sql.job.fast.mode': True, "cz.sql.job.fast.mode": True,
'cz.sql.index.prewhere.enabled': True "cz.sql.index.prewhere.enabled": True,
}
} }
} cursor.execute(search_sql, fulltext_hints)
cursor.execute(search_sql, parameters=fulltext_hints) results = cursor.fetchall()
results = cursor.fetchall()
for row in results: for row in results:
# Parse metadata from JSON string (may be double-encoded) # Parse metadata from JSON string (may be double-encoded)
try: try:
if row[2]: if row[2]:
metadata = json.loads(row[2]) metadata = json.loads(row[2])
# If result is a string, it's double-encoded JSON - parse again # If result is a string, it's double-encoded JSON - parse again
if isinstance(metadata, str): if isinstance(metadata, str):
metadata = json.loads(metadata) metadata = json.loads(metadata)
if not isinstance(metadata, dict): if not isinstance(metadata, dict):
metadata = {}
else:
metadata = {} metadata = {}
else: except (json.JSONDecodeError, TypeError) as e:
metadata = {} logger.exception("JSON parsing failed")
except (json.JSONDecodeError, TypeError) as e: # Fallback: extract document_id with regex
logger.error("JSON parsing failed: %s", e)
# Fallback: extract document_id with regex
import re
doc_id_match = re.search(r'"document_id":\s*"([^"]+)"', str(row[2] or ''))
metadata = {"document_id": doc_id_match.group(1)} if doc_id_match else {}
# Ensure required fields are set doc_id_match = re.search(r'"document_id":\s*"([^"]+)"', str(row[2] or ""))
metadata["doc_id"] = row[0] # segment id metadata = {"document_id": doc_id_match.group(1)} if doc_id_match else {}
# Ensure document_id exists (critical for Dify's format_retrieval_documents) # Ensure required fields are set
if "document_id" not in metadata: metadata["doc_id"] = row[0] # segment id
metadata["document_id"] = row[0] # fallback to segment id
# Add a relevance score for full-text search # Ensure document_id exists (critical for Dify's format_retrieval_documents)
metadata["score"] = 1.0 # Clickzetta doesn't provide relevance scores if "document_id" not in metadata:
doc = Document(page_content=row[1], metadata=metadata) metadata["document_id"] = row[0] # fallback to segment id
documents.append(doc)
except (RuntimeError, ValueError, TypeError, ConnectionError) as e: # Add a relevance score for full-text search
logger.exception("Full-text search failed") metadata["score"] = 1.0 # Clickzetta doesn't provide relevance scores
# Fallback to LIKE search if full-text search fails doc = Document(page_content=row[1], metadata=metadata)
return self._search_by_like(query, **kwargs) documents.append(doc)
except (RuntimeError, ValueError, TypeError, ConnectionError) as e:
logger.exception("Full-text search failed")
# Fallback to LIKE search if full-text search fails
return self._search_by_like(query, **kwargs)
return documents return documents
def _search_by_like(self, query: str, **kwargs: Any) -> list[Document]: def _search_by_like(self, query: str, **kwargs: Any) -> list[Document]:
"""Fallback search using LIKE operator.""" """Fallback search using LIKE operator."""
# Check if table exists first
if not self._table_exists():
logger.warning(
"Table %s.%s does not exist, returning empty results",
self._config.schema_name,
self._table_name,
)
return []
top_k = kwargs.get("top_k", 10) top_k = kwargs.get("top_k", 10)
document_ids_filter = kwargs.get("document_ids_filter") document_ids_filter = kwargs.get("document_ids_filter")
@ -735,62 +1014,37 @@ class ClickzettaVector(BaseVector):
""" """
documents = [] documents = []
connection = self._ensure_connection() with self.get_connection_context() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
# Use hints parameter for LIKE search optimization # Use hints parameter for LIKE search optimization
like_hints = { like_hints = {
'hints': { "hints": {
'sdk.job.timeout': 20, # Timeout for LIKE search "sdk.job.timeout": 20, # Timeout for LIKE search
'cz.sql.job.fast.mode': True "cz.sql.job.fast.mode": True,
}
} }
} cursor.execute(search_sql, like_hints)
cursor.execute(search_sql, parameters=like_hints) results = cursor.fetchall()
results = cursor.fetchall()
for row in results: for row in results:
# Parse metadata from JSON string (may be double-encoded) # Parse metadata using centralized method
try: metadata = self._parse_metadata(row[2], row[0])
if row[2]:
metadata = json.loads(row[2])
# If result is a string, it's double-encoded JSON - parse again metadata["score"] = 0.5 # Lower score for LIKE search
if isinstance(metadata, str): doc = Document(page_content=row[1], metadata=metadata)
metadata = json.loads(metadata) documents.append(doc)
if not isinstance(metadata, dict):
metadata = {}
else:
metadata = {}
except (json.JSONDecodeError, TypeError) as e:
logger.error("JSON parsing failed: %s", e)
# Fallback: extract document_id with regex
import re
doc_id_match = re.search(r'"document_id":\s*"([^"]+)"', str(row[2] or ''))
metadata = {"document_id": doc_id_match.group(1)} if doc_id_match else {}
# Ensure required fields are set
metadata["doc_id"] = row[0] # segment id
# Ensure document_id exists (critical for Dify's format_retrieval_documents)
if "document_id" not in metadata:
metadata["document_id"] = row[0] # fallback to segment id
metadata["score"] = 0.5 # Lower score for LIKE search
doc = Document(page_content=row[1], metadata=metadata)
documents.append(doc)
return documents return documents
def delete(self) -> None: def delete(self) -> None:
"""Delete the entire collection.""" """Delete the entire collection."""
connection = self._ensure_connection() with self.get_connection_context() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute(f"DROP TABLE IF EXISTS {self._config.schema_name}.{self._table_name}") cursor.execute(f"DROP TABLE IF EXISTS {self._config.schema_name}.{self._table_name}")
def _format_vector_simple(self, vector: list[float]) -> str: def _format_vector_simple(self, vector: list[float]) -> str:
"""Simple vector formatting for SQL queries.""" """Simple vector formatting for SQL queries."""
return ','.join(map(str, vector)) return ",".join(map(str, vector))
def _safe_doc_id(self, doc_id: str) -> str: def _safe_doc_id(self, doc_id: str) -> str:
"""Ensure doc_id is safe for SQL and doesn't contain special characters.""" """Ensure doc_id is safe for SQL and doesn't contain special characters."""
@ -799,13 +1053,12 @@ class ClickzettaVector(BaseVector):
# Remove or replace potentially problematic characters # Remove or replace potentially problematic characters
safe_id = str(doc_id) safe_id = str(doc_id)
# Only allow alphanumeric, hyphens, underscores # Only allow alphanumeric, hyphens, underscores
safe_id = ''.join(c for c in safe_id if c.isalnum() or c in '-_') safe_id = "".join(c for c in safe_id if c.isalnum() or c in "-_")
if not safe_id: # If all characters were removed if not safe_id: # If all characters were removed
return str(uuid.uuid4()) return str(uuid.uuid4())
return safe_id[:255] # Limit length return safe_id[:255] # Limit length
class ClickzettaVectorFactory(AbstractVectorFactory): class ClickzettaVectorFactory(AbstractVectorFactory):
"""Factory for creating Clickzetta vector instances.""" """Factory for creating Clickzetta vector instances."""
@ -831,4 +1084,3 @@ class ClickzettaVectorFactory(AbstractVectorFactory):
collection_name = Dataset.gen_collection_name_by_id(dataset.id).lower() collection_name = Dataset.gen_collection_name_by_id(dataset.id).lower()
return ClickzettaVector(collection_name=collection_name, config=config) return ClickzettaVector(collection_name=collection_name, config=config)

View File

@ -246,6 +246,10 @@ class TencentVector(BaseVector):
return self._get_search_res(res, score_threshold) return self._get_search_res(res, score_threshold)
def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]: def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]:
document_ids_filter = kwargs.get("document_ids_filter")
filter = None
if document_ids_filter:
filter = Filter(Filter.In("metadata.document_id", document_ids_filter))
if not self._enable_hybrid_search: if not self._enable_hybrid_search:
return [] return []
res = self._client.hybrid_search( res = self._client.hybrid_search(
@ -269,6 +273,7 @@ class TencentVector(BaseVector):
), ),
retrieve_vector=False, retrieve_vector=False,
limit=kwargs.get("top_k", 4), limit=kwargs.get("top_k", 4),
filter=filter,
) )
score_threshold = float(kwargs.get("score_threshold") or 0.0) score_threshold = float(kwargs.get("score_threshold") or 0.0)
return self._get_search_res(res, score_threshold) return self._get_search_res(res, score_threshold)

View File

@ -62,7 +62,7 @@ class WordExtractor(BaseExtractor):
def extract(self) -> list[Document]: def extract(self) -> list[Document]:
"""Load given path as single page.""" """Load given path as single page."""
content = self.parse_docx(self.file_path, "storage") content = self.parse_docx(self.file_path)
return [ return [
Document( Document(
page_content=content, page_content=content,
@ -189,23 +189,8 @@ class WordExtractor(BaseExtractor):
paragraph_content.append(run.text) paragraph_content.append(run.text)
return "".join(paragraph_content).strip() return "".join(paragraph_content).strip()
def _parse_paragraph(self, paragraph, image_map): def parse_docx(self, docx_path):
paragraph_content = []
for run in paragraph.runs:
if run.element.xpath(".//a:blip"):
for blip in run.element.xpath(".//a:blip"):
embed_id = blip.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed")
if embed_id:
rel_target = run.part.rels[embed_id].target_ref
if rel_target in image_map:
paragraph_content.append(image_map[rel_target])
if run.text.strip():
paragraph_content.append(run.text.strip())
return " ".join(paragraph_content) if paragraph_content else ""
def parse_docx(self, docx_path, image_folder):
doc = DocxDocument(docx_path) doc = DocxDocument(docx_path)
os.makedirs(image_folder, exist_ok=True)
content = [] content = []

View File

@ -29,7 +29,7 @@ from core.tools.errors import (
ToolProviderCredentialValidationError, ToolProviderCredentialValidationError,
ToolProviderNotFoundError, ToolProviderNotFoundError,
) )
from core.tools.utils.message_transformer import ToolFileMessageTransformer from core.tools.utils.message_transformer import ToolFileMessageTransformer, safe_json_value
from core.tools.workflow_as_tool.tool import WorkflowTool from core.tools.workflow_as_tool.tool import WorkflowTool
from extensions.ext_database import db from extensions.ext_database import db
from models.enums import CreatorUserRole from models.enums import CreatorUserRole
@ -247,7 +247,8 @@ class ToolEngine:
) )
elif response.type == ToolInvokeMessage.MessageType.JSON: elif response.type == ToolInvokeMessage.MessageType.JSON:
result += json.dumps( result += json.dumps(
cast(ToolInvokeMessage.JsonMessage, response.message).json_object, ensure_ascii=False safe_json_value(cast(ToolInvokeMessage.JsonMessage, response.message).json_object),
ensure_ascii=False,
) )
else: else:
result += str(response.message) result += str(response.message)

View File

@ -1,7 +1,14 @@
import logging import logging
from collections.abc import Generator from collections.abc import Generator
from datetime import date, datetime
from decimal import Decimal
from mimetypes import guess_extension from mimetypes import guess_extension
from typing import Optional from typing import Optional, cast
from uuid import UUID
import numpy as np
import pytz
from flask_login import current_user
from core.file import File, FileTransferMethod, FileType from core.file import File, FileTransferMethod, FileType
from core.tools.entities.tool_entities import ToolInvokeMessage from core.tools.entities.tool_entities import ToolInvokeMessage
@ -10,6 +17,41 @@ from core.tools.tool_file_manager import ToolFileManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def safe_json_value(v):
if isinstance(v, datetime):
tz_name = getattr(current_user, "timezone", None) if current_user is not None else None
if not tz_name:
tz_name = "UTC"
return v.astimezone(pytz.timezone(tz_name)).isoformat()
elif isinstance(v, date):
return v.isoformat()
elif isinstance(v, UUID):
return str(v)
elif isinstance(v, Decimal):
return float(v)
elif isinstance(v, bytes):
try:
return v.decode("utf-8")
except UnicodeDecodeError:
return v.hex()
elif isinstance(v, memoryview):
return v.tobytes().hex()
elif isinstance(v, np.ndarray):
return v.tolist()
elif isinstance(v, dict):
return safe_json_dict(v)
elif isinstance(v, list | tuple | set):
return [safe_json_value(i) for i in v]
else:
return v
def safe_json_dict(d):
if not isinstance(d, dict):
raise TypeError("safe_json_dict() expects a dictionary (dict) as input")
return {k: safe_json_value(v) for k, v in d.items()}
class ToolFileMessageTransformer: class ToolFileMessageTransformer:
@classmethod @classmethod
def transform_tool_invoke_messages( def transform_tool_invoke_messages(
@ -113,6 +155,12 @@ class ToolFileMessageTransformer:
) )
else: else:
yield message yield message
elif message.type == ToolInvokeMessage.MessageType.JSON:
if isinstance(message.message, ToolInvokeMessage.JsonMessage):
json_msg = cast(ToolInvokeMessage.JsonMessage, message.message)
json_msg.json_object = safe_json_value(json_msg.json_object)
yield message
else: else:
yield message yield message

View File

@ -119,6 +119,13 @@ class ObjectSegment(Segment):
class ArraySegment(Segment): class ArraySegment(Segment):
@property
def text(self) -> str:
# Return empty string for empty arrays instead of "[]"
if not self.value:
return ""
return super().text
@property @property
def markdown(self) -> str: def markdown(self) -> str:
items = [] items = []
@ -155,6 +162,9 @@ class ArrayStringSegment(ArraySegment):
@property @property
def text(self) -> str: def text(self) -> str:
# Return empty string for empty arrays instead of "[]"
if not self.value:
return ""
return json.dumps(self.value, ensure_ascii=False) return json.dumps(self.value, ensure_ascii=False)

View File

@ -168,7 +168,57 @@ def _extract_text_by_mime_type(*, file_content: bytes, mime_type: str) -> str:
def _extract_text_by_file_extension(*, file_content: bytes, file_extension: str) -> str: def _extract_text_by_file_extension(*, file_content: bytes, file_extension: str) -> str:
"""Extract text from a file based on its file extension.""" """Extract text from a file based on its file extension."""
match file_extension: match file_extension:
case ".txt" | ".markdown" | ".md" | ".html" | ".htm" | ".xml": case (
".txt"
| ".markdown"
| ".md"
| ".html"
| ".htm"
| ".xml"
| ".c"
| ".h"
| ".cpp"
| ".hpp"
| ".cc"
| ".cxx"
| ".c++"
| ".py"
| ".js"
| ".ts"
| ".jsx"
| ".tsx"
| ".java"
| ".php"
| ".rb"
| ".go"
| ".rs"
| ".swift"
| ".kt"
| ".scala"
| ".sh"
| ".bash"
| ".bat"
| ".ps1"
| ".sql"
| ".r"
| ".m"
| ".pl"
| ".lua"
| ".vim"
| ".asm"
| ".s"
| ".css"
| ".scss"
| ".less"
| ".sass"
| ".ini"
| ".cfg"
| ".conf"
| ".toml"
| ".env"
| ".log"
| ".vtt"
):
return _extract_text_from_plain_text(file_content) return _extract_text_from_plain_text(file_content)
case ".json": case ".json":
return _extract_text_from_json(file_content) return _extract_text_from_json(file_content)
@ -194,8 +244,6 @@ def _extract_text_by_file_extension(*, file_content: bytes, file_extension: str)
return _extract_text_from_eml(file_content) return _extract_text_from_eml(file_content)
case ".msg": case ".msg":
return _extract_text_from_msg(file_content) return _extract_text_from_msg(file_content)
case ".vtt":
return _extract_text_from_vtt(file_content)
case ".properties": case ".properties":
return _extract_text_from_properties(file_content) return _extract_text_from_properties(file_content)
case _: case _:

View File

@ -1,5 +1,4 @@
import hashlib import hashlib
import os
from typing import Union from typing import Union
from Crypto.Cipher import AES from Crypto.Cipher import AES
@ -18,7 +17,7 @@ def generate_key_pair(tenant_id: str) -> str:
pem_private = private_key.export_key() pem_private = private_key.export_key()
pem_public = public_key.export_key() pem_public = public_key.export_key()
filepath = os.path.join("privkeys", tenant_id, "private.pem") filepath = f"privkeys/{tenant_id}/private.pem"
storage.save(filepath, pem_private) storage.save(filepath, pem_private)
@ -48,7 +47,7 @@ def encrypt(text: str, public_key: Union[str, bytes]) -> bytes:
def get_decrypt_decoding(tenant_id: str) -> tuple[RSA.RsaKey, object]: def get_decrypt_decoding(tenant_id: str) -> tuple[RSA.RsaKey, object]:
filepath = os.path.join("privkeys", tenant_id, "private.pem") filepath = f"privkeys/{tenant_id}/private.pem"
cache_key = f"tenant_privkey:{hashlib.sha3_256(filepath.encode()).hexdigest()}" cache_key = f"tenant_privkey:{hashlib.sha3_256(filepath.encode()).hexdigest()}"
private_key = redis_client.get(cache_key) private_key = redis_client.get(cache_key)

View File

@ -3,7 +3,7 @@ import time
import click import click
from sqlalchemy import text from sqlalchemy import text
from werkzeug.exceptions import NotFound from sqlalchemy.exc import SQLAlchemyError
import app import app
from configs import dify_config from configs import dify_config
@ -27,8 +27,8 @@ def clean_embedding_cache_task():
.all() .all()
) )
embedding_ids = [embedding_id[0] for embedding_id in embedding_ids] embedding_ids = [embedding_id[0] for embedding_id in embedding_ids]
except NotFound: except SQLAlchemyError:
break raise
if embedding_ids: if embedding_ids:
for embedding_id in embedding_ids: for embedding_id in embedding_ids:
db.session.execute( db.session.execute(

View File

@ -3,7 +3,7 @@ import logging
import time import time
import click import click
from werkzeug.exceptions import NotFound from sqlalchemy.exc import SQLAlchemyError
import app import app
from configs import dify_config from configs import dify_config
@ -42,8 +42,8 @@ def clean_messages():
.all() .all()
) )
except NotFound: except SQLAlchemyError:
break raise
if not messages: if not messages:
break break
for message in messages: for message in messages:

View File

@ -3,7 +3,7 @@ import time
import click import click
from sqlalchemy import func, select from sqlalchemy import func, select
from werkzeug.exceptions import NotFound from sqlalchemy.exc import SQLAlchemyError
import app import app
from configs import dify_config from configs import dify_config
@ -65,8 +65,8 @@ def clean_unused_datasets_task():
datasets = db.paginate(stmt, page=1, per_page=50) datasets = db.paginate(stmt, page=1, per_page=50)
except NotFound: except SQLAlchemyError:
break raise
if datasets.items is None or len(datasets.items) == 0: if datasets.items is None or len(datasets.items) == 0:
break break
for dataset in datasets: for dataset in datasets:
@ -146,8 +146,8 @@ def clean_unused_datasets_task():
) )
datasets = db.paginate(stmt, page=1, per_page=50) datasets = db.paginate(stmt, page=1, per_page=50)
except NotFound: except SQLAlchemyError:
break raise
if datasets.items is None or len(datasets.items) == 0: if datasets.items is None or len(datasets.items) == 0:
break break
for dataset in datasets: for dataset in datasets:

View File

@ -50,12 +50,16 @@ class ConversationService:
Conversation.from_account_id == (user.id if isinstance(user, Account) else None), Conversation.from_account_id == (user.id if isinstance(user, Account) else None),
or_(Conversation.invoke_from.is_(None), Conversation.invoke_from == invoke_from.value), or_(Conversation.invoke_from.is_(None), Conversation.invoke_from == invoke_from.value),
) )
# Check if include_ids is not None and not empty to avoid WHERE false condition # Check if include_ids is not None to apply filter
if include_ids is not None and len(include_ids) > 0: if include_ids is not None:
if len(include_ids) == 0:
# If include_ids is empty, return empty result
return InfiniteScrollPagination(data=[], limit=limit, has_more=False)
stmt = stmt.where(Conversation.id.in_(include_ids)) stmt = stmt.where(Conversation.id.in_(include_ids))
# Check if exclude_ids is not None and not empty to avoid WHERE false condition # Check if exclude_ids is not None to apply filter
if exclude_ids is not None and len(exclude_ids) > 0: if exclude_ids is not None:
stmt = stmt.where(~Conversation.id.in_(exclude_ids)) if len(exclude_ids) > 0:
stmt = stmt.where(~Conversation.id.in_(exclude_ids))
# define sort fields and directions # define sort fields and directions
sort_field, sort_direction = cls._get_sort_params(sort_by) sort_field, sort_direction = cls._get_sort_params(sort_by)

View File

@ -256,7 +256,7 @@ class WorkflowDraftVariableService:
def _reset_node_var_or_sys_var( def _reset_node_var_or_sys_var(
self, workflow: Workflow, variable: WorkflowDraftVariable self, workflow: Workflow, variable: WorkflowDraftVariable
) -> WorkflowDraftVariable | None: ) -> WorkflowDraftVariable | None:
# If a variable does not allow updating, it makes no sence to resetting it. # If a variable does not allow updating, it makes no sense to reset it.
if not variable.editable: if not variable.editable:
return variable return variable
# No execution record for this variable, delete the variable instead. # No execution record for this variable, delete the variable instead.
@ -478,7 +478,7 @@ def _batch_upsert_draft_variable(
"node_execution_id": stmt.excluded.node_execution_id, "node_execution_id": stmt.excluded.node_execution_id,
}, },
) )
elif _UpsertPolicy.IGNORE: elif policy == _UpsertPolicy.IGNORE:
stmt = stmt.on_conflict_do_nothing(index_elements=WorkflowDraftVariable.unique_app_id_node_id_name()) stmt = stmt.on_conflict_do_nothing(index_elements=WorkflowDraftVariable.unique_app_id_node_id_name())
else: else:
raise Exception("Invalid value for update policy.") raise Exception("Invalid value for update policy.")

View File

@ -56,15 +56,24 @@ def clean_dataset_task(
documents = db.session.query(Document).where(Document.dataset_id == dataset_id).all() documents = db.session.query(Document).where(Document.dataset_id == dataset_id).all()
segments = db.session.query(DocumentSegment).where(DocumentSegment.dataset_id == dataset_id).all() segments = db.session.query(DocumentSegment).where(DocumentSegment.dataset_id == dataset_id).all()
# Fix: Always clean vector database resources regardless of document existence
# This ensures all 33 vector databases properly drop tables/collections/indices
if doc_form is None:
# Use default paragraph index type for empty datasets to enable vector database cleanup
from core.rag.index_processor.constant.index_type import IndexType
doc_form = IndexType.PARAGRAPH_INDEX
logging.info(
click.style(f"No documents found, using default index type for cleanup: {doc_form}", fg="yellow")
)
index_processor = IndexProcessorFactory(doc_form).init_index_processor()
index_processor.clean(dataset, None, with_keywords=True, delete_child_chunks=True)
if documents is None or len(documents) == 0: if documents is None or len(documents) == 0:
logging.info(click.style(f"No documents found for dataset: {dataset_id}", fg="green")) logging.info(click.style(f"No documents found for dataset: {dataset_id}", fg="green"))
else: else:
logging.info(click.style(f"Cleaning documents for dataset: {dataset_id}", fg="green")) logging.info(click.style(f"Cleaning documents for dataset: {dataset_id}", fg="green"))
# Specify the index type before initializing the index processor
if doc_form is None:
raise ValueError("Index type must be specified.")
index_processor = IndexProcessorFactory(doc_form).init_index_processor()
index_processor.clean(dataset, None, with_keywords=True, delete_child_chunks=True)
for document in documents: for document in documents:
db.session.delete(document) db.session.delete(document)

View File

@ -0,0 +1,168 @@
"""
Unit tests for App description validation functions.
This test module validates the 400-character limit enforcement
for App descriptions across all creation and editing endpoints.
"""
import os
import sys
import pytest
# Add the API root to Python path for imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
class TestAppDescriptionValidationUnit:
"""Unit tests for description validation function"""
def test_validate_description_length_function(self):
"""Test the _validate_description_length function directly"""
from controllers.console.app.app import _validate_description_length
# Test valid descriptions
assert _validate_description_length("") == ""
assert _validate_description_length("x" * 400) == "x" * 400
assert _validate_description_length(None) is None
# Test invalid descriptions
with pytest.raises(ValueError) as exc_info:
_validate_description_length("x" * 401)
assert "Description cannot exceed 400 characters." in str(exc_info.value)
with pytest.raises(ValueError) as exc_info:
_validate_description_length("x" * 500)
assert "Description cannot exceed 400 characters." in str(exc_info.value)
with pytest.raises(ValueError) as exc_info:
_validate_description_length("x" * 1000)
assert "Description cannot exceed 400 characters." in str(exc_info.value)
def test_validation_consistency_with_dataset(self):
"""Test that App and Dataset validation functions are consistent"""
from controllers.console.app.app import _validate_description_length as app_validate
from controllers.console.datasets.datasets import _validate_description_length as dataset_validate
from controllers.service_api.dataset.dataset import _validate_description_length as service_dataset_validate
# Test same valid inputs
valid_desc = "x" * 400
assert app_validate(valid_desc) == dataset_validate(valid_desc) == service_dataset_validate(valid_desc)
assert app_validate("") == dataset_validate("") == service_dataset_validate("")
assert app_validate(None) == dataset_validate(None) == service_dataset_validate(None)
# Test same invalid inputs produce same error
invalid_desc = "x" * 401
app_error = None
dataset_error = None
service_dataset_error = None
try:
app_validate(invalid_desc)
except ValueError as e:
app_error = str(e)
try:
dataset_validate(invalid_desc)
except ValueError as e:
dataset_error = str(e)
try:
service_dataset_validate(invalid_desc)
except ValueError as e:
service_dataset_error = str(e)
assert app_error == dataset_error == service_dataset_error
assert app_error == "Description cannot exceed 400 characters."
def test_boundary_values(self):
"""Test boundary values for description validation"""
from controllers.console.app.app import _validate_description_length
# Test exact boundary
exactly_400 = "x" * 400
assert _validate_description_length(exactly_400) == exactly_400
# Test just over boundary
just_over_400 = "x" * 401
with pytest.raises(ValueError):
_validate_description_length(just_over_400)
# Test just under boundary
just_under_400 = "x" * 399
assert _validate_description_length(just_under_400) == just_under_400
def test_edge_cases(self):
"""Test edge cases for description validation"""
from controllers.console.app.app import _validate_description_length
# Test None input
assert _validate_description_length(None) is None
# Test empty string
assert _validate_description_length("") == ""
# Test single character
assert _validate_description_length("a") == "a"
# Test unicode characters
unicode_desc = "测试" * 200 # 400 characters in Chinese
assert _validate_description_length(unicode_desc) == unicode_desc
# Test unicode over limit
unicode_over = "测试" * 201 # 402 characters
with pytest.raises(ValueError):
_validate_description_length(unicode_over)
def test_whitespace_handling(self):
"""Test how validation handles whitespace"""
from controllers.console.app.app import _validate_description_length
# Test description with spaces
spaces_400 = " " * 400
assert _validate_description_length(spaces_400) == spaces_400
# Test description with spaces over limit
spaces_401 = " " * 401
with pytest.raises(ValueError):
_validate_description_length(spaces_401)
# Test mixed content
mixed_400 = "a" * 200 + " " * 200
assert _validate_description_length(mixed_400) == mixed_400
# Test mixed over limit
mixed_401 = "a" * 200 + " " * 201
with pytest.raises(ValueError):
_validate_description_length(mixed_401)
if __name__ == "__main__":
# Run tests directly
import traceback
test_instance = TestAppDescriptionValidationUnit()
test_methods = [method for method in dir(test_instance) if method.startswith("test_")]
passed = 0
failed = 0
for test_method in test_methods:
try:
print(f"Running {test_method}...")
getattr(test_instance, test_method)()
print(f"{test_method} PASSED")
passed += 1
except Exception as e:
print(f"{test_method} FAILED: {str(e)}")
traceback.print_exc()
failed += 1
print(f"\n📊 Test Results: {passed} passed, {failed} failed")
if failed == 0:
print("🎉 All tests passed!")
else:
print("💥 Some tests failed!")
sys.exit(1)

View File

@ -39,10 +39,7 @@ class TestClickzettaVector(AbstractVectorTest):
) )
with setup_mock_redis(): with setup_mock_redis():
vector = ClickzettaVector( vector = ClickzettaVector(collection_name="test_collection_" + str(os.getpid()), config=config)
collection_name="test_collection_" + str(os.getpid()),
config=config
)
yield vector yield vector
@ -114,7 +111,7 @@ class TestClickzettaVector(AbstractVectorTest):
"category": "technical" if i % 2 == 0 else "general", "category": "technical" if i % 2 == 0 else "general",
"document_id": f"doc_{i // 3}", # Group documents "document_id": f"doc_{i // 3}", # Group documents
"importance": i, "importance": i,
} },
) )
documents.append(doc) documents.append(doc)
# Create varied embeddings # Create varied embeddings
@ -124,22 +121,14 @@ class TestClickzettaVector(AbstractVectorTest):
# Test vector search with document filter # Test vector search with document filter
query_vector = [0.5, 1.0, 1.5, 2.0] query_vector = [0.5, 1.0, 1.5, 2.0]
results = vector_store.search_by_vector( results = vector_store.search_by_vector(query_vector, top_k=5, document_ids_filter=["doc_0", "doc_1"])
query_vector,
top_k=5,
document_ids_filter=["doc_0", "doc_1"]
)
assert len(results) > 0 assert len(results) > 0
# All results should belong to doc_0 or doc_1 groups # All results should belong to doc_0 or doc_1 groups
for result in results: for result in results:
assert result.metadata["document_id"] in ["doc_0", "doc_1"] assert result.metadata["document_id"] in ["doc_0", "doc_1"]
# Test score threshold # Test score threshold
results = vector_store.search_by_vector( results = vector_store.search_by_vector(query_vector, top_k=10, score_threshold=0.5)
query_vector,
top_k=10,
score_threshold=0.5
)
# Check that all results have a score above threshold # Check that all results have a score above threshold
for result in results: for result in results:
assert result.metadata.get("score", 0) >= 0.5 assert result.metadata.get("score", 0) >= 0.5
@ -154,7 +143,7 @@ class TestClickzettaVector(AbstractVectorTest):
for i in range(batch_size): for i in range(batch_size):
doc = Document( doc = Document(
page_content=f"Batch document {i}: This is a test document for batch processing.", page_content=f"Batch document {i}: This is a test document for batch processing.",
metadata={"doc_id": f"batch_doc_{i}", "batch": "test_batch"} metadata={"doc_id": f"batch_doc_{i}", "batch": "test_batch"},
) )
documents.append(doc) documents.append(doc)
embeddings.append([0.1 * (i % 10), 0.2 * (i % 10), 0.3 * (i % 10), 0.4 * (i % 10)]) embeddings.append([0.1 * (i % 10), 0.2 * (i % 10), 0.3 * (i % 10), 0.4 * (i % 10)])
@ -179,7 +168,7 @@ class TestClickzettaVector(AbstractVectorTest):
# Test special characters in content # Test special characters in content
special_doc = Document( special_doc = Document(
page_content="Special chars: 'quotes', \"double\", \\backslash, \n newline", page_content="Special chars: 'quotes', \"double\", \\backslash, \n newline",
metadata={"doc_id": "special_doc", "test": "edge_case"} metadata={"doc_id": "special_doc", "test": "edge_case"},
) )
embeddings = [[0.1, 0.2, 0.3, 0.4]] embeddings = [[0.1, 0.2, 0.3, 0.4]]
@ -199,20 +188,18 @@ class TestClickzettaVector(AbstractVectorTest):
# Prepare documents with various language content # Prepare documents with various language content
documents = [ documents = [
Document( Document(
page_content="云器科技提供强大的Lakehouse解决方案", page_content="云器科技提供强大的Lakehouse解决方案", metadata={"doc_id": "cn_doc_1", "lang": "chinese"}
metadata={"doc_id": "cn_doc_1", "lang": "chinese"}
), ),
Document( Document(
page_content="Clickzetta provides powerful Lakehouse solutions", page_content="Clickzetta provides powerful Lakehouse solutions",
metadata={"doc_id": "en_doc_1", "lang": "english"} metadata={"doc_id": "en_doc_1", "lang": "english"},
), ),
Document( Document(
page_content="Lakehouse是现代数据架构的重要组成部分", page_content="Lakehouse是现代数据架构的重要组成部分", metadata={"doc_id": "cn_doc_2", "lang": "chinese"}
metadata={"doc_id": "cn_doc_2", "lang": "chinese"}
), ),
Document( Document(
page_content="Modern data architecture includes Lakehouse technology", page_content="Modern data architecture includes Lakehouse technology",
metadata={"doc_id": "en_doc_2", "lang": "english"} metadata={"doc_id": "en_doc_2", "lang": "english"},
), ),
] ]

View File

@ -2,6 +2,7 @@
""" """
Test Clickzetta integration in Docker environment Test Clickzetta integration in Docker environment
""" """
import os import os
import time import time
@ -20,7 +21,7 @@ def test_clickzetta_connection():
service=os.getenv("CLICKZETTA_SERVICE", "api.clickzetta.com"), service=os.getenv("CLICKZETTA_SERVICE", "api.clickzetta.com"),
workspace=os.getenv("CLICKZETTA_WORKSPACE", "test_workspace"), workspace=os.getenv("CLICKZETTA_WORKSPACE", "test_workspace"),
vcluster=os.getenv("CLICKZETTA_VCLUSTER", "default"), vcluster=os.getenv("CLICKZETTA_VCLUSTER", "default"),
database=os.getenv("CLICKZETTA_SCHEMA", "dify") database=os.getenv("CLICKZETTA_SCHEMA", "dify"),
) )
with conn.cursor() as cursor: with conn.cursor() as cursor:
@ -36,7 +37,7 @@ def test_clickzetta_connection():
# Check if test collection exists # Check if test collection exists
test_collection = "collection_test_dataset" test_collection = "collection_test_dataset"
if test_collection in [t[1] for t in tables if t[0] == 'dify']: if test_collection in [t[1] for t in tables if t[0] == "dify"]:
cursor.execute(f"DESCRIBE dify.{test_collection}") cursor.execute(f"DESCRIBE dify.{test_collection}")
columns = cursor.fetchall() columns = cursor.fetchall()
print(f"✓ Table structure for {test_collection}:") print(f"✓ Table structure for {test_collection}:")
@ -55,6 +56,7 @@ def test_clickzetta_connection():
print(f"✗ Connection test failed: {e}") print(f"✗ Connection test failed: {e}")
return False return False
def test_dify_api(): def test_dify_api():
"""Test Dify API with Clickzetta backend""" """Test Dify API with Clickzetta backend"""
print("\n=== Testing Dify API ===") print("\n=== Testing Dify API ===")
@ -83,6 +85,7 @@ def test_dify_api():
print(f"✗ API test failed: {e}") print(f"✗ API test failed: {e}")
return False return False
def verify_table_structure(): def verify_table_structure():
"""Verify the table structure meets Dify requirements""" """Verify the table structure meets Dify requirements"""
print("\n=== Verifying Table Structure ===") print("\n=== Verifying Table Structure ===")
@ -91,15 +94,10 @@ def verify_table_structure():
"id": "VARCHAR", "id": "VARCHAR",
"page_content": "VARCHAR", "page_content": "VARCHAR",
"metadata": "VARCHAR", # JSON stored as VARCHAR in Clickzetta "metadata": "VARCHAR", # JSON stored as VARCHAR in Clickzetta
"vector": "ARRAY<FLOAT>" "vector": "ARRAY<FLOAT>",
} }
expected_metadata_fields = [ expected_metadata_fields = ["doc_id", "doc_hash", "document_id", "dataset_id"]
"doc_id",
"doc_hash",
"document_id",
"dataset_id"
]
print("✓ Expected table structure:") print("✓ Expected table structure:")
for col, dtype in expected_columns.items(): for col, dtype in expected_columns.items():
@ -117,6 +115,7 @@ def verify_table_structure():
return True return True
def main(): def main():
"""Run all tests""" """Run all tests"""
print("Starting Clickzetta integration tests for Dify Docker\n") print("Starting Clickzetta integration tests for Dify Docker\n")
@ -137,9 +136,9 @@ def main():
results.append((test_name, False)) results.append((test_name, False))
# Summary # Summary
print("\n" + "="*50) print("\n" + "=" * 50)
print("Test Summary:") print("Test Summary:")
print("="*50) print("=" * 50)
passed = sum(1 for _, success in results if success) passed = sum(1 for _, success in results if success)
total = len(results) total = len(results)
@ -161,5 +160,6 @@ def main():
print("\n⚠️ Some tests failed. Please check the errors above.") print("\n⚠️ Some tests failed. Please check the errors above.")
return 1 return 1
if __name__ == "__main__": if __name__ == "__main__":
exit(main()) exit(main())

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,487 @@
from unittest.mock import patch
import pytest
from faker import Faker
from models.api_based_extension import APIBasedExtension
from services.account_service import AccountService, TenantService
from services.api_based_extension_service import APIBasedExtensionService
class TestAPIBasedExtensionService:
"""Integration tests for APIBasedExtensionService using testcontainers."""
@pytest.fixture
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.api_based_extension_service.APIBasedExtensionRequestor") as mock_requestor,
):
# Setup default mock returns
mock_account_feature_service.get_features.return_value.billing.enabled = False
# Mock successful ping response
mock_requestor_instance = mock_requestor.return_value
mock_requestor_instance.request.return_value = {"result": "pong"}
yield {
"account_feature_service": mock_account_feature_service,
"requestor": mock_requestor,
"requestor_instance": mock_requestor_instance,
}
def _create_test_account_and_tenant(self, db_session_with_containers, mock_external_service_dependencies):
"""
Helper method to create a test account and tenant for testing.
Args:
db_session_with_containers: Database session from testcontainers infrastructure
mock_external_service_dependencies: Mock dependencies
Returns:
tuple: (account, tenant) - Created account and tenant instances
"""
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
# Create account and tenant
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),
interface_language="en-US",
password=fake.password(length=12),
)
TenantService.create_owner_tenant_if_not_exist(account, name=fake.company())
tenant = account.current_tenant
return account, tenant
def test_save_extension_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test successful saving of API-based extension.
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
# Setup extension data
extension_data = APIBasedExtension()
extension_data.tenant_id = tenant.id
extension_data.name = fake.company()
extension_data.api_endpoint = f"https://{fake.domain_name()}/api"
extension_data.api_key = fake.password(length=20)
# Save extension
saved_extension = APIBasedExtensionService.save(extension_data)
# Verify extension was saved correctly
assert saved_extension.id is not None
assert saved_extension.tenant_id == tenant.id
assert saved_extension.name == extension_data.name
assert saved_extension.api_endpoint == extension_data.api_endpoint
assert saved_extension.api_key == extension_data.api_key # Should be decrypted when retrieved
assert saved_extension.created_at is not None
# Verify extension was saved to database
from extensions.ext_database import db
db.session.refresh(saved_extension)
assert saved_extension.id is not None
# Verify ping connection was called
mock_external_service_dependencies["requestor_instance"].request.assert_called_once()
def test_save_extension_validation_errors(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test validation errors when saving extension with invalid data.
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
# Test empty name
extension_data = APIBasedExtension()
extension_data.tenant_id = tenant.id
extension_data.name = ""
extension_data.api_endpoint = f"https://{fake.domain_name()}/api"
extension_data.api_key = fake.password(length=20)
with pytest.raises(ValueError, match="name must not be empty"):
APIBasedExtensionService.save(extension_data)
# Test empty api_endpoint
extension_data.name = fake.company()
extension_data.api_endpoint = ""
with pytest.raises(ValueError, match="api_endpoint must not be empty"):
APIBasedExtensionService.save(extension_data)
# Test empty api_key
extension_data.api_endpoint = f"https://{fake.domain_name()}/api"
extension_data.api_key = ""
with pytest.raises(ValueError, match="api_key must not be empty"):
APIBasedExtensionService.save(extension_data)
def test_get_all_by_tenant_id_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test successful retrieval of all extensions by tenant ID.
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
# Create multiple extensions
extensions = []
for i in range(3):
extension_data = APIBasedExtension()
extension_data.tenant_id = tenant.id
extension_data.name = f"Extension {i}: {fake.company()}"
extension_data.api_endpoint = f"https://{fake.domain_name()}/api"
extension_data.api_key = fake.password(length=20)
saved_extension = APIBasedExtensionService.save(extension_data)
extensions.append(saved_extension)
# Get all extensions for tenant
extension_list = APIBasedExtensionService.get_all_by_tenant_id(tenant.id)
# Verify results
assert len(extension_list) == 3
# Verify all extensions belong to the correct tenant and are ordered by created_at desc
for i, extension in enumerate(extension_list):
assert extension.tenant_id == tenant.id
assert extension.api_key is not None # Should be decrypted
if i > 0:
# Verify descending order (newer first)
assert extension.created_at <= extension_list[i - 1].created_at
def test_get_with_tenant_id_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test successful retrieval of extension by tenant ID and extension ID.
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
# Create an extension
extension_data = APIBasedExtension()
extension_data.tenant_id = tenant.id
extension_data.name = fake.company()
extension_data.api_endpoint = f"https://{fake.domain_name()}/api"
extension_data.api_key = fake.password(length=20)
created_extension = APIBasedExtensionService.save(extension_data)
# Get extension by ID
retrieved_extension = APIBasedExtensionService.get_with_tenant_id(tenant.id, created_extension.id)
# Verify extension was retrieved correctly
assert retrieved_extension is not None
assert retrieved_extension.id == created_extension.id
assert retrieved_extension.tenant_id == tenant.id
assert retrieved_extension.name == extension_data.name
assert retrieved_extension.api_endpoint == extension_data.api_endpoint
assert retrieved_extension.api_key == extension_data.api_key # Should be decrypted
assert retrieved_extension.created_at is not None
def test_get_with_tenant_id_not_found(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test retrieval of extension when extension is not found.
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
non_existent_extension_id = fake.uuid4()
# Try to get non-existent extension
with pytest.raises(ValueError, match="API based extension is not found"):
APIBasedExtensionService.get_with_tenant_id(tenant.id, non_existent_extension_id)
def test_delete_extension_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test successful deletion of extension.
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
# Create an extension first
extension_data = APIBasedExtension()
extension_data.tenant_id = tenant.id
extension_data.name = fake.company()
extension_data.api_endpoint = f"https://{fake.domain_name()}/api"
extension_data.api_key = fake.password(length=20)
created_extension = APIBasedExtensionService.save(extension_data)
extension_id = created_extension.id
# Delete the extension
APIBasedExtensionService.delete(created_extension)
# Verify extension was deleted
from extensions.ext_database import db
deleted_extension = db.session.query(APIBasedExtension).filter(APIBasedExtension.id == extension_id).first()
assert deleted_extension is None
def test_save_extension_duplicate_name(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test validation error when saving extension with duplicate name.
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
# Create first extension
extension_data1 = APIBasedExtension()
extension_data1.tenant_id = tenant.id
extension_data1.name = "Test Extension"
extension_data1.api_endpoint = f"https://{fake.domain_name()}/api"
extension_data1.api_key = fake.password(length=20)
APIBasedExtensionService.save(extension_data1)
# Try to create second extension with same name
extension_data2 = APIBasedExtension()
extension_data2.tenant_id = tenant.id
extension_data2.name = "Test Extension" # Same name
extension_data2.api_endpoint = f"https://{fake.domain_name()}/api"
extension_data2.api_key = fake.password(length=20)
with pytest.raises(ValueError, match="name must be unique, it is already existed"):
APIBasedExtensionService.save(extension_data2)
def test_save_extension_update_existing(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test successful update of existing extension.
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
# Create initial extension
extension_data = APIBasedExtension()
extension_data.tenant_id = tenant.id
extension_data.name = fake.company()
extension_data.api_endpoint = f"https://{fake.domain_name()}/api"
extension_data.api_key = fake.password(length=20)
created_extension = APIBasedExtensionService.save(extension_data)
# Save original values for later comparison
original_name = created_extension.name
original_endpoint = created_extension.api_endpoint
# Update the extension
new_name = fake.company()
new_endpoint = f"https://{fake.domain_name()}/api"
new_api_key = fake.password(length=20)
created_extension.name = new_name
created_extension.api_endpoint = new_endpoint
created_extension.api_key = new_api_key
updated_extension = APIBasedExtensionService.save(created_extension)
# Verify extension was updated correctly
assert updated_extension.id == created_extension.id
assert updated_extension.tenant_id == tenant.id
assert updated_extension.name == new_name
assert updated_extension.api_endpoint == new_endpoint
# Verify original values were changed
assert updated_extension.name != original_name
assert updated_extension.api_endpoint != original_endpoint
# Verify ping connection was called for both create and update
assert mock_external_service_dependencies["requestor_instance"].request.call_count == 2
# Verify the update by retrieving the extension again
retrieved_extension = APIBasedExtensionService.get_with_tenant_id(tenant.id, created_extension.id)
assert retrieved_extension.name == new_name
assert retrieved_extension.api_endpoint == new_endpoint
assert retrieved_extension.api_key == new_api_key # Should be decrypted when retrieved
def test_save_extension_connection_error(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test connection error when saving extension with invalid endpoint.
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
# Mock connection error
mock_external_service_dependencies["requestor_instance"].request.side_effect = ValueError(
"connection error: request timeout"
)
# Setup extension data
extension_data = APIBasedExtension()
extension_data.tenant_id = tenant.id
extension_data.name = fake.company()
extension_data.api_endpoint = "https://invalid-endpoint.com/api"
extension_data.api_key = fake.password(length=20)
# Try to save extension with connection error
with pytest.raises(ValueError, match="connection error: request timeout"):
APIBasedExtensionService.save(extension_data)
def test_save_extension_invalid_api_key_length(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Test validation error when saving extension with API key that is too short.
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
# Setup extension data with short API key
extension_data = APIBasedExtension()
extension_data.tenant_id = tenant.id
extension_data.name = fake.company()
extension_data.api_endpoint = f"https://{fake.domain_name()}/api"
extension_data.api_key = "1234" # Less than 5 characters
# Try to save extension with short API key
with pytest.raises(ValueError, match="api_key must be at least 5 characters"):
APIBasedExtensionService.save(extension_data)
def test_save_extension_empty_fields(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test validation errors when saving extension with empty required fields.
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
# Test with None values
extension_data = APIBasedExtension()
extension_data.tenant_id = tenant.id
extension_data.name = None
extension_data.api_endpoint = f"https://{fake.domain_name()}/api"
extension_data.api_key = fake.password(length=20)
with pytest.raises(ValueError, match="name must not be empty"):
APIBasedExtensionService.save(extension_data)
# Test with None api_endpoint
extension_data.name = fake.company()
extension_data.api_endpoint = None
with pytest.raises(ValueError, match="api_endpoint must not be empty"):
APIBasedExtensionService.save(extension_data)
# Test with None api_key
extension_data.api_endpoint = f"https://{fake.domain_name()}/api"
extension_data.api_key = None
with pytest.raises(ValueError, match="api_key must not be empty"):
APIBasedExtensionService.save(extension_data)
def test_get_all_by_tenant_id_empty_list(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test retrieval of extensions when no extensions exist for tenant.
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
# Get all extensions for tenant (none exist)
extension_list = APIBasedExtensionService.get_all_by_tenant_id(tenant.id)
# Verify empty list is returned
assert len(extension_list) == 0
assert extension_list == []
def test_save_extension_invalid_ping_response(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test validation error when ping response is invalid.
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
# Mock invalid ping response
mock_external_service_dependencies["requestor_instance"].request.return_value = {"result": "invalid"}
# Setup extension data
extension_data = APIBasedExtension()
extension_data.tenant_id = tenant.id
extension_data.name = fake.company()
extension_data.api_endpoint = f"https://{fake.domain_name()}/api"
extension_data.api_key = fake.password(length=20)
# Try to save extension with invalid ping response
with pytest.raises(ValueError, match="{'result': 'invalid'}"):
APIBasedExtensionService.save(extension_data)
def test_save_extension_missing_ping_result(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test validation error when ping response is missing result field.
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
# Mock ping response without result field
mock_external_service_dependencies["requestor_instance"].request.return_value = {"status": "ok"}
# Setup extension data
extension_data = APIBasedExtension()
extension_data.tenant_id = tenant.id
extension_data.name = fake.company()
extension_data.api_endpoint = f"https://{fake.domain_name()}/api"
extension_data.api_key = fake.password(length=20)
# Try to save extension with missing ping result
with pytest.raises(ValueError, match="{'status': 'ok'}"):
APIBasedExtensionService.save(extension_data)
def test_get_with_tenant_id_wrong_tenant(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test retrieval of extension when tenant ID doesn't match.
"""
fake = Faker()
account1, tenant1 = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
# Create second account and tenant
account2, tenant2 = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
# Create extension in first tenant
extension_data = APIBasedExtension()
extension_data.tenant_id = tenant1.id
extension_data.name = fake.company()
extension_data.api_endpoint = f"https://{fake.domain_name()}/api"
extension_data.api_key = fake.password(length=20)
created_extension = APIBasedExtensionService.save(extension_data)
# Try to get extension with wrong tenant ID
with pytest.raises(ValueError, match="API based extension is not found"):
APIBasedExtensionService.get_with_tenant_id(tenant2.id, created_extension.id)

View File

@ -0,0 +1,473 @@
import json
from unittest.mock import MagicMock, patch
import pytest
import yaml
from faker import Faker
from models.model import App, AppModelConfig
from services.account_service import AccountService, TenantService
from services.app_dsl_service import AppDslService, ImportMode, ImportStatus
from services.app_service import AppService
class TestAppDslService:
"""Integration tests for AppDslService using testcontainers."""
@pytest.fixture
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.app_dsl_service.WorkflowService") as mock_workflow_service,
patch("services.app_dsl_service.DependenciesAnalysisService") as mock_dependencies_service,
patch("services.app_dsl_service.WorkflowDraftVariableService") as mock_draft_variable_service,
patch("services.app_dsl_service.ssrf_proxy") as mock_ssrf_proxy,
patch("services.app_dsl_service.redis_client") as mock_redis_client,
patch("services.app_dsl_service.app_was_created") as mock_app_was_created,
patch("services.app_dsl_service.app_model_config_was_updated") as mock_app_model_config_was_updated,
patch("services.app_service.ModelManager") as mock_model_manager,
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
):
# Setup default mock returns
mock_workflow_service.return_value.get_draft_workflow.return_value = None
mock_workflow_service.return_value.sync_draft_workflow.return_value = MagicMock()
mock_dependencies_service.generate_latest_dependencies.return_value = []
mock_dependencies_service.get_leaked_dependencies.return_value = []
mock_dependencies_service.generate_dependencies.return_value = []
mock_draft_variable_service.return_value.delete_workflow_variables.return_value = None
mock_ssrf_proxy.get.return_value.content = b"test content"
mock_ssrf_proxy.get.return_value.raise_for_status.return_value = None
mock_redis_client.setex.return_value = None
mock_redis_client.get.return_value = None
mock_redis_client.delete.return_value = None
mock_app_was_created.send.return_value = None
mock_app_model_config_was_updated.send.return_value = None
# Mock ModelManager for app service
mock_model_instance = mock_model_manager.return_value
mock_model_instance.get_default_model_instance.return_value = None
mock_model_instance.get_default_provider_model_name.return_value = ("openai", "gpt-3.5-turbo")
# Mock FeatureService and EnterpriseService
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
yield {
"workflow_service": mock_workflow_service,
"dependencies_service": mock_dependencies_service,
"draft_variable_service": mock_draft_variable_service,
"ssrf_proxy": mock_ssrf_proxy,
"redis_client": mock_redis_client,
"app_was_created": mock_app_was_created,
"app_model_config_was_updated": mock_app_model_config_was_updated,
"model_manager": mock_model_manager,
"feature_service": mock_feature_service,
"enterprise_service": mock_enterprise_service,
}
def _create_test_app_and_account(self, db_session_with_containers, mock_external_service_dependencies):
"""
Helper method to create a test app and account for testing.
Args:
db_session_with_containers: Database session from testcontainers infrastructure
mock_external_service_dependencies: Mock dependencies
Returns:
tuple: (app, account) - Created app and account instances
"""
fake = Faker()
# Setup mocks for account creation
with patch("services.account_service.FeatureService") as mock_account_feature_service:
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
# Create account and tenant first
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),
interface_language="en-US",
password=fake.password(length=12),
)
TenantService.create_owner_tenant_if_not_exist(account, name=fake.company())
tenant = account.current_tenant
# Setup app creation arguments
app_args = {
"name": fake.company(),
"description": fake.text(max_nb_chars=100),
"mode": "chat",
"icon_type": "emoji",
"icon": "🤖",
"icon_background": "#FF6B6B",
"api_rph": 100,
"api_rpm": 10,
}
# Create app
app_service = AppService()
app = app_service.create_app(tenant.id, app_args, account)
return app, account
def _create_simple_yaml_content(self, app_name="Test App", app_mode="chat"):
"""
Helper method to create simple YAML content for testing.
"""
yaml_data = {
"version": "0.3.0",
"kind": "app",
"app": {
"name": app_name,
"mode": app_mode,
"icon": "🤖",
"icon_background": "#FFEAD5",
"description": "Test app description",
"use_icon_as_answer_icon": False,
},
"model_config": {
"model": {
"provider": "openai",
"name": "gpt-3.5-turbo",
"mode": "chat",
"completion_params": {
"max_tokens": 1000,
"temperature": 0.7,
"top_p": 1.0,
},
},
"pre_prompt": "You are a helpful assistant.",
"prompt_type": "simple",
},
}
return yaml.dump(yaml_data, allow_unicode=True)
def test_import_app_yaml_content_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test successful app import from YAML content.
"""
fake = Faker()
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Create YAML content
yaml_content = self._create_simple_yaml_content(fake.company(), "chat")
# Import app
dsl_service = AppDslService(db_session_with_containers)
result = dsl_service.import_app(
account=account,
import_mode=ImportMode.YAML_CONTENT,
yaml_content=yaml_content,
name="Imported App",
description="Imported app description",
)
# Verify import result
assert result.status == ImportStatus.COMPLETED
assert result.app_id is not None
assert result.app_mode == "chat"
assert result.imported_dsl_version == "0.3.0"
assert result.error == ""
# Verify app was created in database
imported_app = db_session_with_containers.query(App).filter(App.id == result.app_id).first()
assert imported_app is not None
assert imported_app.name == "Imported App"
assert imported_app.description == "Imported app description"
assert imported_app.mode == "chat"
assert imported_app.tenant_id == account.current_tenant_id
assert imported_app.created_by == account.id
# Verify model config was created
model_config = (
db_session_with_containers.query(AppModelConfig).filter(AppModelConfig.app_id == result.app_id).first()
)
assert model_config is not None
# The provider and model_id are stored in the model field as JSON
model_dict = model_config.model_dict
assert model_dict["provider"] == "openai"
assert model_dict["name"] == "gpt-3.5-turbo"
def test_import_app_yaml_url_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test successful app import from YAML URL.
"""
fake = Faker()
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Create YAML content for mock response
yaml_content = self._create_simple_yaml_content(fake.company(), "chat")
# Setup mock response
mock_response = MagicMock()
mock_response.content = yaml_content.encode("utf-8")
mock_response.raise_for_status.return_value = None
mock_external_service_dependencies["ssrf_proxy"].get.return_value = mock_response
# Import app from URL
dsl_service = AppDslService(db_session_with_containers)
result = dsl_service.import_app(
account=account,
import_mode=ImportMode.YAML_URL,
yaml_url="https://example.com/app.yaml",
name="URL Imported App",
description="App imported from URL",
)
# Verify import result
assert result.status == ImportStatus.COMPLETED
assert result.app_id is not None
assert result.app_mode == "chat"
assert result.imported_dsl_version == "0.3.0"
assert result.error == ""
# Verify app was created in database
imported_app = db_session_with_containers.query(App).filter(App.id == result.app_id).first()
assert imported_app is not None
assert imported_app.name == "URL Imported App"
assert imported_app.description == "App imported from URL"
assert imported_app.mode == "chat"
assert imported_app.tenant_id == account.current_tenant_id
# Verify ssrf_proxy was called
mock_external_service_dependencies["ssrf_proxy"].get.assert_called_once_with(
"https://example.com/app.yaml", follow_redirects=True, timeout=(10, 10)
)
def test_import_app_invalid_yaml_format(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test app import with invalid YAML format.
"""
fake = Faker()
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Create invalid YAML content
invalid_yaml = "invalid: yaml: content: ["
# Import app with invalid YAML
dsl_service = AppDslService(db_session_with_containers)
result = dsl_service.import_app(
account=account,
import_mode=ImportMode.YAML_CONTENT,
yaml_content=invalid_yaml,
name="Invalid App",
)
# Verify import failed
assert result.status == ImportStatus.FAILED
assert result.app_id is None
assert "Invalid YAML format" in result.error
assert result.imported_dsl_version == ""
# Verify no app was created in database
apps_count = db_session_with_containers.query(App).filter(App.tenant_id == account.current_tenant_id).count()
assert apps_count == 1 # Only the original test app
def test_import_app_missing_yaml_content(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test app import with missing YAML content.
"""
fake = Faker()
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Import app without YAML content
dsl_service = AppDslService(db_session_with_containers)
result = dsl_service.import_app(
account=account,
import_mode=ImportMode.YAML_CONTENT,
name="Missing Content App",
)
# Verify import failed
assert result.status == ImportStatus.FAILED
assert result.app_id is None
assert "yaml_content is required" in result.error
assert result.imported_dsl_version == ""
# Verify no app was created in database
apps_count = db_session_with_containers.query(App).filter(App.tenant_id == account.current_tenant_id).count()
assert apps_count == 1 # Only the original test app
def test_import_app_missing_yaml_url(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test app import with missing YAML URL.
"""
fake = Faker()
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Import app without YAML URL
dsl_service = AppDslService(db_session_with_containers)
result = dsl_service.import_app(
account=account,
import_mode=ImportMode.YAML_URL,
name="Missing URL App",
)
# Verify import failed
assert result.status == ImportStatus.FAILED
assert result.app_id is None
assert "yaml_url is required" in result.error
assert result.imported_dsl_version == ""
# Verify no app was created in database
apps_count = db_session_with_containers.query(App).filter(App.tenant_id == account.current_tenant_id).count()
assert apps_count == 1 # Only the original test app
def test_import_app_invalid_import_mode(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test app import with invalid import mode.
"""
fake = Faker()
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Create YAML content
yaml_content = self._create_simple_yaml_content(fake.company(), "chat")
# Import app with invalid mode should raise ValueError
dsl_service = AppDslService(db_session_with_containers)
with pytest.raises(ValueError, match="Invalid import_mode: invalid-mode"):
dsl_service.import_app(
account=account,
import_mode="invalid-mode",
yaml_content=yaml_content,
name="Invalid Mode App",
)
# Verify no app was created in database
apps_count = db_session_with_containers.query(App).filter(App.tenant_id == account.current_tenant_id).count()
assert apps_count == 1 # Only the original test app
def test_export_dsl_chat_app_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test successful DSL export for chat app.
"""
fake = Faker()
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Create model config for the app
model_config = AppModelConfig()
model_config.id = fake.uuid4()
model_config.app_id = app.id
model_config.provider = "openai"
model_config.model_id = "gpt-3.5-turbo"
model_config.model = json.dumps(
{
"provider": "openai",
"name": "gpt-3.5-turbo",
"mode": "chat",
"completion_params": {
"max_tokens": 1000,
"temperature": 0.7,
},
}
)
model_config.pre_prompt = "You are a helpful assistant."
model_config.prompt_type = "simple"
model_config.created_by = account.id
model_config.updated_by = account.id
# Set the app_model_config_id to link the config
app.app_model_config_id = model_config.id
db_session_with_containers.add(model_config)
db_session_with_containers.commit()
# Export DSL
exported_dsl = AppDslService.export_dsl(app, include_secret=False)
# Parse exported YAML
exported_data = yaml.safe_load(exported_dsl)
# Verify exported data structure
assert exported_data["kind"] == "app"
assert exported_data["app"]["name"] == app.name
assert exported_data["app"]["mode"] == app.mode
assert exported_data["app"]["icon"] == app.icon
assert exported_data["app"]["icon_background"] == app.icon_background
assert exported_data["app"]["description"] == app.description
# Verify model config was exported
assert "model_config" in exported_data
# The exported model_config structure may be different from the database structure
# Check that the model config exists and has the expected content
assert exported_data["model_config"] is not None
# Verify dependencies were exported
assert "dependencies" in exported_data
assert isinstance(exported_data["dependencies"], list)
def test_export_dsl_workflow_app_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test successful DSL export for workflow app.
"""
fake = Faker()
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Update app to workflow mode
app.mode = "workflow"
db_session_with_containers.commit()
# Mock workflow service to return a workflow
mock_workflow = MagicMock()
mock_workflow.to_dict.return_value = {
"graph": {"nodes": [{"id": "start", "type": "start", "data": {"type": "start"}}], "edges": []},
"features": {},
"environment_variables": [],
"conversation_variables": [],
}
mock_external_service_dependencies[
"workflow_service"
].return_value.get_draft_workflow.return_value = mock_workflow
# Export DSL
exported_dsl = AppDslService.export_dsl(app, include_secret=False)
# Parse exported YAML
exported_data = yaml.safe_load(exported_dsl)
# Verify exported data structure
assert exported_data["kind"] == "app"
assert exported_data["app"]["name"] == app.name
assert exported_data["app"]["mode"] == "workflow"
# Verify workflow was exported
assert "workflow" in exported_data
assert "graph" in exported_data["workflow"]
assert "nodes" in exported_data["workflow"]["graph"]
# Verify dependencies were exported
assert "dependencies" in exported_data
assert isinstance(exported_data["dependencies"], list)
# Verify workflow service was called
mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.assert_called_once_with(
app
)
def test_check_dependencies_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test successful dependency checking.
"""
fake = Faker()
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Mock Redis to return dependencies
mock_dependencies_json = '{"app_id": "' + app.id + '", "dependencies": []}'
mock_external_service_dependencies["redis_client"].get.return_value = mock_dependencies_json
# Check dependencies
dsl_service = AppDslService(db_session_with_containers)
result = dsl_service.check_dependencies(app_model=app)
# Verify result
assert result.leaked_dependencies == []
# Verify Redis was queried
mock_external_service_dependencies["redis_client"].get.assert_called_once_with(
f"app_check_dependencies:{app.id}"
)
# Verify dependencies service was called
mock_external_service_dependencies["dependencies_service"].get_leaked_dependencies.assert_called_once()

View File

@ -0,0 +1,252 @@
import pytest
from controllers.console.app.app import _validate_description_length as app_validate
from controllers.console.datasets.datasets import _validate_description_length as dataset_validate
from controllers.service_api.dataset.dataset import _validate_description_length as service_dataset_validate
class TestDescriptionValidationUnit:
"""Unit tests for description validation functions in App and Dataset APIs"""
def test_app_validate_description_length_valid(self):
"""Test App validation function with valid descriptions"""
# Empty string should be valid
assert app_validate("") == ""
# None should be valid
assert app_validate(None) is None
# Short description should be valid
short_desc = "Short description"
assert app_validate(short_desc) == short_desc
# Exactly 400 characters should be valid
exactly_400 = "x" * 400
assert app_validate(exactly_400) == exactly_400
# Just under limit should be valid
just_under = "x" * 399
assert app_validate(just_under) == just_under
def test_app_validate_description_length_invalid(self):
"""Test App validation function with invalid descriptions"""
# 401 characters should fail
just_over = "x" * 401
with pytest.raises(ValueError) as exc_info:
app_validate(just_over)
assert "Description cannot exceed 400 characters." in str(exc_info.value)
# 500 characters should fail
way_over = "x" * 500
with pytest.raises(ValueError) as exc_info:
app_validate(way_over)
assert "Description cannot exceed 400 characters." in str(exc_info.value)
# 1000 characters should fail
very_long = "x" * 1000
with pytest.raises(ValueError) as exc_info:
app_validate(very_long)
assert "Description cannot exceed 400 characters." in str(exc_info.value)
def test_dataset_validate_description_length_valid(self):
"""Test Dataset validation function with valid descriptions"""
# Empty string should be valid
assert dataset_validate("") == ""
# Short description should be valid
short_desc = "Short description"
assert dataset_validate(short_desc) == short_desc
# Exactly 400 characters should be valid
exactly_400 = "x" * 400
assert dataset_validate(exactly_400) == exactly_400
# Just under limit should be valid
just_under = "x" * 399
assert dataset_validate(just_under) == just_under
def test_dataset_validate_description_length_invalid(self):
"""Test Dataset validation function with invalid descriptions"""
# 401 characters should fail
just_over = "x" * 401
with pytest.raises(ValueError) as exc_info:
dataset_validate(just_over)
assert "Description cannot exceed 400 characters." in str(exc_info.value)
# 500 characters should fail
way_over = "x" * 500
with pytest.raises(ValueError) as exc_info:
dataset_validate(way_over)
assert "Description cannot exceed 400 characters." in str(exc_info.value)
def test_service_dataset_validate_description_length_valid(self):
"""Test Service Dataset validation function with valid descriptions"""
# Empty string should be valid
assert service_dataset_validate("") == ""
# None should be valid
assert service_dataset_validate(None) is None
# Short description should be valid
short_desc = "Short description"
assert service_dataset_validate(short_desc) == short_desc
# Exactly 400 characters should be valid
exactly_400 = "x" * 400
assert service_dataset_validate(exactly_400) == exactly_400
# Just under limit should be valid
just_under = "x" * 399
assert service_dataset_validate(just_under) == just_under
def test_service_dataset_validate_description_length_invalid(self):
"""Test Service Dataset validation function with invalid descriptions"""
# 401 characters should fail
just_over = "x" * 401
with pytest.raises(ValueError) as exc_info:
service_dataset_validate(just_over)
assert "Description cannot exceed 400 characters." in str(exc_info.value)
# 500 characters should fail
way_over = "x" * 500
with pytest.raises(ValueError) as exc_info:
service_dataset_validate(way_over)
assert "Description cannot exceed 400 characters." in str(exc_info.value)
def test_app_dataset_validation_consistency(self):
"""Test that App and Dataset validation functions behave identically"""
test_cases = [
"", # Empty string
"Short description", # Normal description
"x" * 100, # Medium description
"x" * 400, # Exactly at limit
]
# Test valid cases produce same results
for test_desc in test_cases:
assert app_validate(test_desc) == dataset_validate(test_desc) == service_dataset_validate(test_desc)
# Test invalid cases produce same errors
invalid_cases = [
"x" * 401, # Just over limit
"x" * 500, # Way over limit
"x" * 1000, # Very long
]
for invalid_desc in invalid_cases:
app_error = None
dataset_error = None
service_dataset_error = None
# Capture App validation error
try:
app_validate(invalid_desc)
except ValueError as e:
app_error = str(e)
# Capture Dataset validation error
try:
dataset_validate(invalid_desc)
except ValueError as e:
dataset_error = str(e)
# Capture Service Dataset validation error
try:
service_dataset_validate(invalid_desc)
except ValueError as e:
service_dataset_error = str(e)
# All should produce errors
assert app_error is not None, f"App validation should fail for {len(invalid_desc)} characters"
assert dataset_error is not None, f"Dataset validation should fail for {len(invalid_desc)} characters"
error_msg = f"Service Dataset validation should fail for {len(invalid_desc)} characters"
assert service_dataset_error is not None, error_msg
# Errors should be identical
error_msg = f"Error messages should be identical for {len(invalid_desc)} characters"
assert app_error == dataset_error == service_dataset_error, error_msg
assert app_error == "Description cannot exceed 400 characters."
def test_boundary_values(self):
"""Test boundary values around the 400 character limit"""
boundary_tests = [
(0, True), # Empty
(1, True), # Minimum
(399, True), # Just under limit
(400, True), # Exactly at limit
(401, False), # Just over limit
(402, False), # Over limit
(500, False), # Way over limit
]
for length, should_pass in boundary_tests:
test_desc = "x" * length
if should_pass:
# Should not raise exception
assert app_validate(test_desc) == test_desc
assert dataset_validate(test_desc) == test_desc
assert service_dataset_validate(test_desc) == test_desc
else:
# Should raise ValueError
with pytest.raises(ValueError):
app_validate(test_desc)
with pytest.raises(ValueError):
dataset_validate(test_desc)
with pytest.raises(ValueError):
service_dataset_validate(test_desc)
def test_special_characters(self):
"""Test validation with special characters, Unicode, etc."""
# Unicode characters
unicode_desc = "测试描述" * 100 # Chinese characters
if len(unicode_desc) <= 400:
assert app_validate(unicode_desc) == unicode_desc
assert dataset_validate(unicode_desc) == unicode_desc
assert service_dataset_validate(unicode_desc) == unicode_desc
# Special characters
special_desc = "Special chars: !@#$%^&*()_+-=[]{}|;':\",./<>?" * 10
if len(special_desc) <= 400:
assert app_validate(special_desc) == special_desc
assert dataset_validate(special_desc) == special_desc
assert service_dataset_validate(special_desc) == special_desc
# Mixed content
mixed_desc = "Mixed content: 测试 123 !@# " * 15
if len(mixed_desc) <= 400:
assert app_validate(mixed_desc) == mixed_desc
assert dataset_validate(mixed_desc) == mixed_desc
assert service_dataset_validate(mixed_desc) == mixed_desc
elif len(mixed_desc) > 400:
with pytest.raises(ValueError):
app_validate(mixed_desc)
with pytest.raises(ValueError):
dataset_validate(mixed_desc)
with pytest.raises(ValueError):
service_dataset_validate(mixed_desc)
def test_whitespace_handling(self):
"""Test validation with various whitespace scenarios"""
# Leading/trailing whitespace
whitespace_desc = " Description with whitespace "
if len(whitespace_desc) <= 400:
assert app_validate(whitespace_desc) == whitespace_desc
assert dataset_validate(whitespace_desc) == whitespace_desc
assert service_dataset_validate(whitespace_desc) == whitespace_desc
# Newlines and tabs
multiline_desc = "Line 1\nLine 2\tTabbed content"
if len(multiline_desc) <= 400:
assert app_validate(multiline_desc) == multiline_desc
assert dataset_validate(multiline_desc) == multiline_desc
assert service_dataset_validate(multiline_desc) == multiline_desc
# Only whitespace over limit
only_spaces = " " * 401
with pytest.raises(ValueError):
app_validate(only_spaces)
with pytest.raises(ValueError):
dataset_validate(only_spaces)
with pytest.raises(ValueError):
service_dataset_validate(only_spaces)

View File

@ -0,0 +1,336 @@
"""
Unit tests for Service API File Preview endpoint
"""
import uuid
from unittest.mock import Mock, patch
import pytest
from controllers.service_api.app.error import FileAccessDeniedError, FileNotFoundError
from controllers.service_api.app.file_preview import FilePreviewApi
from models.model import App, EndUser, Message, MessageFile, UploadFile
class TestFilePreviewApi:
"""Test suite for FilePreviewApi"""
@pytest.fixture
def file_preview_api(self):
"""Create FilePreviewApi instance for testing"""
return FilePreviewApi()
@pytest.fixture
def mock_app(self):
"""Mock App model"""
app = Mock(spec=App)
app.id = str(uuid.uuid4())
app.tenant_id = str(uuid.uuid4())
return app
@pytest.fixture
def mock_end_user(self):
"""Mock EndUser model"""
end_user = Mock(spec=EndUser)
end_user.id = str(uuid.uuid4())
return end_user
@pytest.fixture
def mock_upload_file(self):
"""Mock UploadFile model"""
upload_file = Mock(spec=UploadFile)
upload_file.id = str(uuid.uuid4())
upload_file.name = "test_file.jpg"
upload_file.mime_type = "image/jpeg"
upload_file.size = 1024
upload_file.key = "storage/key/test_file.jpg"
upload_file.tenant_id = str(uuid.uuid4())
return upload_file
@pytest.fixture
def mock_message_file(self):
"""Mock MessageFile model"""
message_file = Mock(spec=MessageFile)
message_file.id = str(uuid.uuid4())
message_file.upload_file_id = str(uuid.uuid4())
message_file.message_id = str(uuid.uuid4())
return message_file
@pytest.fixture
def mock_message(self):
"""Mock Message model"""
message = Mock(spec=Message)
message.id = str(uuid.uuid4())
message.app_id = str(uuid.uuid4())
return message
def test_validate_file_ownership_success(
self, file_preview_api, mock_app, mock_upload_file, mock_message_file, mock_message
):
"""Test successful file ownership validation"""
file_id = str(uuid.uuid4())
app_id = mock_app.id
# Set up the mocks
mock_upload_file.tenant_id = mock_app.tenant_id
mock_message.app_id = app_id
mock_message_file.upload_file_id = file_id
mock_message_file.message_id = mock_message.id
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock database queries
mock_db.session.query.return_value.where.return_value.first.side_effect = [
mock_message_file, # MessageFile query
mock_message, # Message query
mock_upload_file, # UploadFile query
mock_app, # App query for tenant validation
]
# Execute the method
result_message_file, result_upload_file = file_preview_api._validate_file_ownership(file_id, app_id)
# Assertions
assert result_message_file == mock_message_file
assert result_upload_file == mock_upload_file
def test_validate_file_ownership_file_not_found(self, file_preview_api):
"""Test file ownership validation when MessageFile not found"""
file_id = str(uuid.uuid4())
app_id = str(uuid.uuid4())
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock MessageFile not found
mock_db.session.query.return_value.where.return_value.first.return_value = None
# Execute and assert exception
with pytest.raises(FileNotFoundError) as exc_info:
file_preview_api._validate_file_ownership(file_id, app_id)
assert "File not found in message context" in str(exc_info.value)
def test_validate_file_ownership_access_denied(self, file_preview_api, mock_message_file):
"""Test file ownership validation when Message not owned by app"""
file_id = str(uuid.uuid4())
app_id = str(uuid.uuid4())
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock MessageFile found but Message not owned by app
mock_db.session.query.return_value.where.return_value.first.side_effect = [
mock_message_file, # MessageFile query - found
None, # Message query - not found (access denied)
]
# Execute and assert exception
with pytest.raises(FileAccessDeniedError) as exc_info:
file_preview_api._validate_file_ownership(file_id, app_id)
assert "not owned by requesting app" in str(exc_info.value)
def test_validate_file_ownership_upload_file_not_found(self, file_preview_api, mock_message_file, mock_message):
"""Test file ownership validation when UploadFile not found"""
file_id = str(uuid.uuid4())
app_id = str(uuid.uuid4())
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock MessageFile and Message found but UploadFile not found
mock_db.session.query.return_value.where.return_value.first.side_effect = [
mock_message_file, # MessageFile query - found
mock_message, # Message query - found
None, # UploadFile query - not found
]
# Execute and assert exception
with pytest.raises(FileNotFoundError) as exc_info:
file_preview_api._validate_file_ownership(file_id, app_id)
assert "Upload file record not found" in str(exc_info.value)
def test_validate_file_ownership_tenant_mismatch(
self, file_preview_api, mock_app, mock_upload_file, mock_message_file, mock_message
):
"""Test file ownership validation with tenant mismatch"""
file_id = str(uuid.uuid4())
app_id = mock_app.id
# Set up tenant mismatch
mock_upload_file.tenant_id = "different_tenant_id"
mock_app.tenant_id = "app_tenant_id"
mock_message.app_id = app_id
mock_message_file.upload_file_id = file_id
mock_message_file.message_id = mock_message.id
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock database queries
mock_db.session.query.return_value.where.return_value.first.side_effect = [
mock_message_file, # MessageFile query
mock_message, # Message query
mock_upload_file, # UploadFile query
mock_app, # App query for tenant validation
]
# Execute and assert exception
with pytest.raises(FileAccessDeniedError) as exc_info:
file_preview_api._validate_file_ownership(file_id, app_id)
assert "tenant mismatch" in str(exc_info.value)
def test_validate_file_ownership_invalid_input(self, file_preview_api):
"""Test file ownership validation with invalid input"""
# Test with empty file_id
with pytest.raises(FileAccessDeniedError) as exc_info:
file_preview_api._validate_file_ownership("", "app_id")
assert "Invalid file or app identifier" in str(exc_info.value)
# Test with empty app_id
with pytest.raises(FileAccessDeniedError) as exc_info:
file_preview_api._validate_file_ownership("file_id", "")
assert "Invalid file or app identifier" in str(exc_info.value)
def test_build_file_response_basic(self, file_preview_api, mock_upload_file):
"""Test basic file response building"""
mock_generator = Mock()
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
# Check response properties
assert response.mimetype == mock_upload_file.mime_type
assert response.direct_passthrough is True
assert response.headers["Content-Length"] == str(mock_upload_file.size)
assert "Cache-Control" in response.headers
def test_build_file_response_as_attachment(self, file_preview_api, mock_upload_file):
"""Test file response building with attachment flag"""
mock_generator = Mock()
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, True)
# Check attachment-specific headers
assert "attachment" in response.headers["Content-Disposition"]
assert mock_upload_file.name in response.headers["Content-Disposition"]
assert response.headers["Content-Type"] == "application/octet-stream"
def test_build_file_response_audio_video(self, file_preview_api, mock_upload_file):
"""Test file response building for audio/video files"""
mock_generator = Mock()
mock_upload_file.mime_type = "video/mp4"
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
# Check Range support for media files
assert response.headers["Accept-Ranges"] == "bytes"
def test_build_file_response_no_size(self, file_preview_api, mock_upload_file):
"""Test file response building when size is unknown"""
mock_generator = Mock()
mock_upload_file.size = 0 # Unknown size
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
# Content-Length should not be set when size is unknown
assert "Content-Length" not in response.headers
@patch("controllers.service_api.app.file_preview.storage")
def test_get_method_integration(
self, mock_storage, file_preview_api, mock_app, mock_end_user, mock_upload_file, mock_message_file, mock_message
):
"""Test the full GET method integration (without decorator)"""
file_id = str(uuid.uuid4())
app_id = mock_app.id
# Set up mocks
mock_upload_file.tenant_id = mock_app.tenant_id
mock_message.app_id = app_id
mock_message_file.upload_file_id = file_id
mock_message_file.message_id = mock_message.id
mock_generator = Mock()
mock_storage.load.return_value = mock_generator
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock database queries
mock_db.session.query.return_value.where.return_value.first.side_effect = [
mock_message_file, # MessageFile query
mock_message, # Message query
mock_upload_file, # UploadFile query
mock_app, # App query for tenant validation
]
with patch("controllers.service_api.app.file_preview.reqparse") as mock_reqparse:
# Mock request parsing
mock_parser = Mock()
mock_parser.parse_args.return_value = {"as_attachment": False}
mock_reqparse.RequestParser.return_value = mock_parser
# Test the core logic directly without Flask decorators
# Validate file ownership
result_message_file, result_upload_file = file_preview_api._validate_file_ownership(file_id, app_id)
assert result_message_file == mock_message_file
assert result_upload_file == mock_upload_file
# Test file response building
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
assert response is not None
# Verify storage was called correctly
mock_storage.load.assert_not_called() # Since we're testing components separately
@patch("controllers.service_api.app.file_preview.storage")
def test_storage_error_handling(
self, mock_storage, file_preview_api, mock_app, mock_upload_file, mock_message_file, mock_message
):
"""Test storage error handling in the core logic"""
file_id = str(uuid.uuid4())
app_id = mock_app.id
# Set up mocks
mock_upload_file.tenant_id = mock_app.tenant_id
mock_message.app_id = app_id
mock_message_file.upload_file_id = file_id
mock_message_file.message_id = mock_message.id
# Mock storage error
mock_storage.load.side_effect = Exception("Storage error")
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock database queries for validation
mock_db.session.query.return_value.where.return_value.first.side_effect = [
mock_message_file, # MessageFile query
mock_message, # Message query
mock_upload_file, # UploadFile query
mock_app, # App query for tenant validation
]
# First validate file ownership works
result_message_file, result_upload_file = file_preview_api._validate_file_ownership(file_id, app_id)
assert result_message_file == mock_message_file
assert result_upload_file == mock_upload_file
# Test storage error handling
with pytest.raises(Exception) as exc_info:
mock_storage.load(mock_upload_file.key, stream=True)
assert "Storage error" in str(exc_info.value)
@patch("controllers.service_api.app.file_preview.logger")
def test_validate_file_ownership_unexpected_error_logging(self, mock_logger, file_preview_api):
"""Test that unexpected errors are logged properly"""
file_id = str(uuid.uuid4())
app_id = str(uuid.uuid4())
with patch("controllers.service_api.app.file_preview.db") as mock_db:
# Mock database query to raise unexpected exception
mock_db.session.query.side_effect = Exception("Unexpected database error")
# Execute and assert exception
with pytest.raises(FileAccessDeniedError) as exc_info:
file_preview_api._validate_file_ownership(file_id, app_id)
# Verify error message
assert "File access validation failed" in str(exc_info.value)
# Verify logging was called
mock_logger.exception.assert_called_once_with(
"Unexpected error during file ownership validation",
extra={"file_id": file_id, "app_id": app_id, "error": "Unexpected database error"},
)

View File

@ -0,0 +1,419 @@
"""Test conversation variable handling in AdvancedChatAppRunner."""
from unittest.mock import MagicMock, patch
from uuid import uuid4
from sqlalchemy.orm import Session
from core.app.apps.advanced_chat.app_runner import AdvancedChatAppRunner
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom
from core.variables import SegmentType
from factories import variable_factory
from models import ConversationVariable, Workflow
class TestAdvancedChatAppRunnerConversationVariables:
"""Test that AdvancedChatAppRunner correctly handles conversation variables."""
def test_missing_conversation_variables_are_added(self):
"""Test that new conversation variables added to workflow are created for existing conversations."""
# Setup
app_id = str(uuid4())
conversation_id = str(uuid4())
workflow_id = str(uuid4())
# Create workflow with two conversation variables
workflow_vars = [
variable_factory.build_conversation_variable_from_mapping(
{
"id": "var1",
"name": "existing_var",
"value_type": SegmentType.STRING,
"value": "default1",
}
),
variable_factory.build_conversation_variable_from_mapping(
{
"id": "var2",
"name": "new_var",
"value_type": SegmentType.STRING,
"value": "default2",
}
),
]
# Mock workflow with conversation variables
mock_workflow = MagicMock(spec=Workflow)
mock_workflow.conversation_variables = workflow_vars
mock_workflow.tenant_id = str(uuid4())
mock_workflow.app_id = app_id
mock_workflow.id = workflow_id
mock_workflow.type = "chat"
mock_workflow.graph_dict = {}
mock_workflow.environment_variables = []
# Create existing conversation variable (only var1 exists in DB)
existing_db_var = MagicMock(spec=ConversationVariable)
existing_db_var.id = "var1"
existing_db_var.app_id = app_id
existing_db_var.conversation_id = conversation_id
existing_db_var.to_variable = MagicMock(return_value=workflow_vars[0])
# Mock conversation and message
mock_conversation = MagicMock()
mock_conversation.app_id = app_id
mock_conversation.id = conversation_id
mock_message = MagicMock()
mock_message.id = str(uuid4())
# Mock app config
mock_app_config = MagicMock()
mock_app_config.app_id = app_id
mock_app_config.workflow_id = workflow_id
mock_app_config.tenant_id = str(uuid4())
# Mock app generate entity
mock_app_generate_entity = MagicMock(spec=AdvancedChatAppGenerateEntity)
mock_app_generate_entity.app_config = mock_app_config
mock_app_generate_entity.inputs = {}
mock_app_generate_entity.query = "test query"
mock_app_generate_entity.files = []
mock_app_generate_entity.user_id = str(uuid4())
mock_app_generate_entity.invoke_from = InvokeFrom.SERVICE_API
mock_app_generate_entity.workflow_run_id = str(uuid4())
mock_app_generate_entity.call_depth = 0
mock_app_generate_entity.single_iteration_run = None
mock_app_generate_entity.single_loop_run = None
mock_app_generate_entity.trace_manager = None
# Create runner
runner = AdvancedChatAppRunner(
application_generate_entity=mock_app_generate_entity,
queue_manager=MagicMock(),
conversation=mock_conversation,
message=mock_message,
dialogue_count=1,
variable_loader=MagicMock(),
workflow=mock_workflow,
system_user_id=str(uuid4()),
app=MagicMock(),
)
# Mock database session
mock_session = MagicMock(spec=Session)
# First query returns only existing variable
mock_scalars_result = MagicMock()
mock_scalars_result.all.return_value = [existing_db_var]
mock_session.scalars.return_value = mock_scalars_result
# Track what gets added to session
added_items = []
def track_add_all(items):
added_items.extend(items)
mock_session.add_all.side_effect = track_add_all
# Patch the necessary components
with (
patch("core.app.apps.advanced_chat.app_runner.Session") as mock_session_class,
patch("core.app.apps.advanced_chat.app_runner.select") as mock_select,
patch("core.app.apps.advanced_chat.app_runner.db") as mock_db,
patch.object(runner, "_init_graph") as mock_init_graph,
patch.object(runner, "handle_input_moderation", return_value=False),
patch.object(runner, "handle_annotation_reply", return_value=False),
patch("core.app.apps.advanced_chat.app_runner.WorkflowEntry") as mock_workflow_entry_class,
patch("core.app.apps.advanced_chat.app_runner.VariablePool") as mock_variable_pool_class,
):
# Setup mocks
mock_session_class.return_value.__enter__.return_value = mock_session
mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock() # App exists
mock_db.engine = MagicMock()
# Mock graph initialization
mock_init_graph.return_value = MagicMock()
# Mock workflow entry
mock_workflow_entry = MagicMock()
mock_workflow_entry.run.return_value = iter([]) # Empty generator
mock_workflow_entry_class.return_value = mock_workflow_entry
# Run the method
runner.run()
# Verify that the missing variable was added
assert len(added_items) == 1, "Should have added exactly one missing variable"
# Check that the added item is the missing variable (var2)
added_var = added_items[0]
assert hasattr(added_var, "id"), "Added item should be a ConversationVariable"
# Note: Since we're mocking ConversationVariable.from_variable,
# we can't directly check the id, but we can verify add_all was called
assert mock_session.add_all.called, "Session add_all should have been called"
assert mock_session.commit.called, "Session commit should have been called"
def test_no_variables_creates_all(self):
"""Test that all conversation variables are created when none exist in DB."""
# Setup
app_id = str(uuid4())
conversation_id = str(uuid4())
workflow_id = str(uuid4())
# Create workflow with conversation variables
workflow_vars = [
variable_factory.build_conversation_variable_from_mapping(
{
"id": "var1",
"name": "var1",
"value_type": SegmentType.STRING,
"value": "default1",
}
),
variable_factory.build_conversation_variable_from_mapping(
{
"id": "var2",
"name": "var2",
"value_type": SegmentType.STRING,
"value": "default2",
}
),
]
# Mock workflow
mock_workflow = MagicMock(spec=Workflow)
mock_workflow.conversation_variables = workflow_vars
mock_workflow.tenant_id = str(uuid4())
mock_workflow.app_id = app_id
mock_workflow.id = workflow_id
mock_workflow.type = "chat"
mock_workflow.graph_dict = {}
mock_workflow.environment_variables = []
# Mock conversation and message
mock_conversation = MagicMock()
mock_conversation.app_id = app_id
mock_conversation.id = conversation_id
mock_message = MagicMock()
mock_message.id = str(uuid4())
# Mock app config
mock_app_config = MagicMock()
mock_app_config.app_id = app_id
mock_app_config.workflow_id = workflow_id
mock_app_config.tenant_id = str(uuid4())
# Mock app generate entity
mock_app_generate_entity = MagicMock(spec=AdvancedChatAppGenerateEntity)
mock_app_generate_entity.app_config = mock_app_config
mock_app_generate_entity.inputs = {}
mock_app_generate_entity.query = "test query"
mock_app_generate_entity.files = []
mock_app_generate_entity.user_id = str(uuid4())
mock_app_generate_entity.invoke_from = InvokeFrom.SERVICE_API
mock_app_generate_entity.workflow_run_id = str(uuid4())
mock_app_generate_entity.call_depth = 0
mock_app_generate_entity.single_iteration_run = None
mock_app_generate_entity.single_loop_run = None
mock_app_generate_entity.trace_manager = None
# Create runner
runner = AdvancedChatAppRunner(
application_generate_entity=mock_app_generate_entity,
queue_manager=MagicMock(),
conversation=mock_conversation,
message=mock_message,
dialogue_count=1,
variable_loader=MagicMock(),
workflow=mock_workflow,
system_user_id=str(uuid4()),
app=MagicMock(),
)
# Mock database session
mock_session = MagicMock(spec=Session)
# Query returns empty list (no existing variables)
mock_scalars_result = MagicMock()
mock_scalars_result.all.return_value = []
mock_session.scalars.return_value = mock_scalars_result
# Track what gets added to session
added_items = []
def track_add_all(items):
added_items.extend(items)
mock_session.add_all.side_effect = track_add_all
# Patch the necessary components
with (
patch("core.app.apps.advanced_chat.app_runner.Session") as mock_session_class,
patch("core.app.apps.advanced_chat.app_runner.select") as mock_select,
patch("core.app.apps.advanced_chat.app_runner.db") as mock_db,
patch.object(runner, "_init_graph") as mock_init_graph,
patch.object(runner, "handle_input_moderation", return_value=False),
patch.object(runner, "handle_annotation_reply", return_value=False),
patch("core.app.apps.advanced_chat.app_runner.WorkflowEntry") as mock_workflow_entry_class,
patch("core.app.apps.advanced_chat.app_runner.VariablePool") as mock_variable_pool_class,
patch("core.app.apps.advanced_chat.app_runner.ConversationVariable") as mock_conv_var_class,
):
# Setup mocks
mock_session_class.return_value.__enter__.return_value = mock_session
mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock() # App exists
mock_db.engine = MagicMock()
# Mock ConversationVariable.from_variable to return mock objects
mock_conv_vars = []
for var in workflow_vars:
mock_cv = MagicMock()
mock_cv.id = var.id
mock_cv.to_variable.return_value = var
mock_conv_vars.append(mock_cv)
mock_conv_var_class.from_variable.side_effect = mock_conv_vars
# Mock graph initialization
mock_init_graph.return_value = MagicMock()
# Mock workflow entry
mock_workflow_entry = MagicMock()
mock_workflow_entry.run.return_value = iter([]) # Empty generator
mock_workflow_entry_class.return_value = mock_workflow_entry
# Run the method
runner.run()
# Verify that all variables were created
assert len(added_items) == 2, "Should have added both variables"
assert mock_session.add_all.called, "Session add_all should have been called"
assert mock_session.commit.called, "Session commit should have been called"
def test_all_variables_exist_no_changes(self):
"""Test that no changes are made when all variables already exist in DB."""
# Setup
app_id = str(uuid4())
conversation_id = str(uuid4())
workflow_id = str(uuid4())
# Create workflow with conversation variables
workflow_vars = [
variable_factory.build_conversation_variable_from_mapping(
{
"id": "var1",
"name": "var1",
"value_type": SegmentType.STRING,
"value": "default1",
}
),
variable_factory.build_conversation_variable_from_mapping(
{
"id": "var2",
"name": "var2",
"value_type": SegmentType.STRING,
"value": "default2",
}
),
]
# Mock workflow
mock_workflow = MagicMock(spec=Workflow)
mock_workflow.conversation_variables = workflow_vars
mock_workflow.tenant_id = str(uuid4())
mock_workflow.app_id = app_id
mock_workflow.id = workflow_id
mock_workflow.type = "chat"
mock_workflow.graph_dict = {}
mock_workflow.environment_variables = []
# Create existing conversation variables (both exist in DB)
existing_db_vars = []
for var in workflow_vars:
db_var = MagicMock(spec=ConversationVariable)
db_var.id = var.id
db_var.app_id = app_id
db_var.conversation_id = conversation_id
db_var.to_variable = MagicMock(return_value=var)
existing_db_vars.append(db_var)
# Mock conversation and message
mock_conversation = MagicMock()
mock_conversation.app_id = app_id
mock_conversation.id = conversation_id
mock_message = MagicMock()
mock_message.id = str(uuid4())
# Mock app config
mock_app_config = MagicMock()
mock_app_config.app_id = app_id
mock_app_config.workflow_id = workflow_id
mock_app_config.tenant_id = str(uuid4())
# Mock app generate entity
mock_app_generate_entity = MagicMock(spec=AdvancedChatAppGenerateEntity)
mock_app_generate_entity.app_config = mock_app_config
mock_app_generate_entity.inputs = {}
mock_app_generate_entity.query = "test query"
mock_app_generate_entity.files = []
mock_app_generate_entity.user_id = str(uuid4())
mock_app_generate_entity.invoke_from = InvokeFrom.SERVICE_API
mock_app_generate_entity.workflow_run_id = str(uuid4())
mock_app_generate_entity.call_depth = 0
mock_app_generate_entity.single_iteration_run = None
mock_app_generate_entity.single_loop_run = None
mock_app_generate_entity.trace_manager = None
# Create runner
runner = AdvancedChatAppRunner(
application_generate_entity=mock_app_generate_entity,
queue_manager=MagicMock(),
conversation=mock_conversation,
message=mock_message,
dialogue_count=1,
variable_loader=MagicMock(),
workflow=mock_workflow,
system_user_id=str(uuid4()),
app=MagicMock(),
)
# Mock database session
mock_session = MagicMock(spec=Session)
# Query returns all existing variables
mock_scalars_result = MagicMock()
mock_scalars_result.all.return_value = existing_db_vars
mock_session.scalars.return_value = mock_scalars_result
# Patch the necessary components
with (
patch("core.app.apps.advanced_chat.app_runner.Session") as mock_session_class,
patch("core.app.apps.advanced_chat.app_runner.select") as mock_select,
patch("core.app.apps.advanced_chat.app_runner.db") as mock_db,
patch.object(runner, "_init_graph") as mock_init_graph,
patch.object(runner, "handle_input_moderation", return_value=False),
patch.object(runner, "handle_annotation_reply", return_value=False),
patch("core.app.apps.advanced_chat.app_runner.WorkflowEntry") as mock_workflow_entry_class,
patch("core.app.apps.advanced_chat.app_runner.VariablePool") as mock_variable_pool_class,
):
# Setup mocks
mock_session_class.return_value.__enter__.return_value = mock_session
mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock() # App exists
mock_db.engine = MagicMock()
# Mock graph initialization
mock_init_graph.return_value = MagicMock()
# Mock workflow entry
mock_workflow_entry = MagicMock()
mock_workflow_entry.run.return_value = iter([]) # Empty generator
mock_workflow_entry_class.return_value = mock_workflow_entry
# Run the method
runner.run()
# Verify that no variables were added
assert not mock_session.add_all.called, "Session add_all should not have been called"
assert mock_session.commit.called, "Session commit should still be called"

View File

@ -0,0 +1,127 @@
import uuid
from unittest.mock import MagicMock, patch
from core.app.entities.app_invoke_entities import InvokeFrom
from services.conversation_service import ConversationService
class TestConversationService:
def test_pagination_with_empty_include_ids(self):
"""Test that empty include_ids returns empty result"""
mock_session = MagicMock()
mock_app_model = MagicMock(id=str(uuid.uuid4()))
mock_user = MagicMock(id=str(uuid.uuid4()))
result = ConversationService.pagination_by_last_id(
session=mock_session,
app_model=mock_app_model,
user=mock_user,
last_id=None,
limit=20,
invoke_from=InvokeFrom.WEB_APP,
include_ids=[], # Empty include_ids should return empty result
exclude_ids=None,
)
assert result.data == []
assert result.has_more is False
assert result.limit == 20
def test_pagination_with_non_empty_include_ids(self):
"""Test that non-empty include_ids filters properly"""
mock_session = MagicMock()
mock_app_model = MagicMock(id=str(uuid.uuid4()))
mock_user = MagicMock(id=str(uuid.uuid4()))
# Mock the query results
mock_conversations = [MagicMock(id=str(uuid.uuid4())) for _ in range(3)]
mock_session.scalars.return_value.all.return_value = mock_conversations
mock_session.scalar.return_value = 0
with patch("services.conversation_service.select") as mock_select:
mock_stmt = MagicMock()
mock_select.return_value = mock_stmt
mock_stmt.where.return_value = mock_stmt
mock_stmt.order_by.return_value = mock_stmt
mock_stmt.limit.return_value = mock_stmt
mock_stmt.subquery.return_value = MagicMock()
result = ConversationService.pagination_by_last_id(
session=mock_session,
app_model=mock_app_model,
user=mock_user,
last_id=None,
limit=20,
invoke_from=InvokeFrom.WEB_APP,
include_ids=["conv1", "conv2"], # Non-empty include_ids
exclude_ids=None,
)
# Verify the where clause was called with id.in_
assert mock_stmt.where.called
def test_pagination_with_empty_exclude_ids(self):
"""Test that empty exclude_ids doesn't filter"""
mock_session = MagicMock()
mock_app_model = MagicMock(id=str(uuid.uuid4()))
mock_user = MagicMock(id=str(uuid.uuid4()))
# Mock the query results
mock_conversations = [MagicMock(id=str(uuid.uuid4())) for _ in range(5)]
mock_session.scalars.return_value.all.return_value = mock_conversations
mock_session.scalar.return_value = 0
with patch("services.conversation_service.select") as mock_select:
mock_stmt = MagicMock()
mock_select.return_value = mock_stmt
mock_stmt.where.return_value = mock_stmt
mock_stmt.order_by.return_value = mock_stmt
mock_stmt.limit.return_value = mock_stmt
mock_stmt.subquery.return_value = MagicMock()
result = ConversationService.pagination_by_last_id(
session=mock_session,
app_model=mock_app_model,
user=mock_user,
last_id=None,
limit=20,
invoke_from=InvokeFrom.WEB_APP,
include_ids=None,
exclude_ids=[], # Empty exclude_ids should not filter
)
# Result should contain the mocked conversations
assert len(result.data) == 5
def test_pagination_with_non_empty_exclude_ids(self):
"""Test that non-empty exclude_ids filters properly"""
mock_session = MagicMock()
mock_app_model = MagicMock(id=str(uuid.uuid4()))
mock_user = MagicMock(id=str(uuid.uuid4()))
# Mock the query results
mock_conversations = [MagicMock(id=str(uuid.uuid4())) for _ in range(3)]
mock_session.scalars.return_value.all.return_value = mock_conversations
mock_session.scalar.return_value = 0
with patch("services.conversation_service.select") as mock_select:
mock_stmt = MagicMock()
mock_select.return_value = mock_stmt
mock_stmt.where.return_value = mock_stmt
mock_stmt.order_by.return_value = mock_stmt
mock_stmt.limit.return_value = mock_stmt
mock_stmt.subquery.return_value = MagicMock()
result = ConversationService.pagination_by_last_id(
session=mock_session,
app_model=mock_app_model,
user=mock_user,
last_id=None,
limit=20,
invoke_from=InvokeFrom.WEB_APP,
include_ids=None,
exclude_ids=["conv1", "conv2"], # Non-empty exclude_ids
)
# Verify the where clause was called for exclusion
assert mock_stmt.where.called

View File

@ -0,0 +1,97 @@
/**
* Description Validation Test
*
* Tests for the 400-character description validation across App and Dataset
* creation and editing workflows to ensure consistent validation behavior.
*/
describe('Description Validation Logic', () => {
// Simulate backend validation function
const validateDescriptionLength = (description?: string | null) => {
if (description && description.length > 400)
throw new Error('Description cannot exceed 400 characters.')
return description
}
describe('Backend Validation Function', () => {
test('allows description within 400 characters', () => {
const validDescription = 'x'.repeat(400)
expect(() => validateDescriptionLength(validDescription)).not.toThrow()
expect(validateDescriptionLength(validDescription)).toBe(validDescription)
})
test('allows empty description', () => {
expect(() => validateDescriptionLength('')).not.toThrow()
expect(() => validateDescriptionLength(null)).not.toThrow()
expect(() => validateDescriptionLength(undefined)).not.toThrow()
})
test('rejects description exceeding 400 characters', () => {
const invalidDescription = 'x'.repeat(401)
expect(() => validateDescriptionLength(invalidDescription)).toThrow(
'Description cannot exceed 400 characters.',
)
})
})
describe('Backend Validation Consistency', () => {
test('App and Dataset have consistent validation limits', () => {
const maxLength = 400
const validDescription = 'x'.repeat(maxLength)
const invalidDescription = 'x'.repeat(maxLength + 1)
// Both should accept exactly 400 characters
expect(validDescription.length).toBe(400)
expect(() => validateDescriptionLength(validDescription)).not.toThrow()
// Both should reject 401 characters
expect(invalidDescription.length).toBe(401)
expect(() => validateDescriptionLength(invalidDescription)).toThrow()
})
test('validation error messages are consistent', () => {
const expectedErrorMessage = 'Description cannot exceed 400 characters.'
// This would be the error message from both App and Dataset backend validation
expect(expectedErrorMessage).toBe('Description cannot exceed 400 characters.')
const invalidDescription = 'x'.repeat(401)
try {
validateDescriptionLength(invalidDescription)
}
catch (error) {
expect((error as Error).message).toBe(expectedErrorMessage)
}
})
})
describe('Character Length Edge Cases', () => {
const testCases = [
{ length: 0, shouldPass: true, description: 'empty description' },
{ length: 1, shouldPass: true, description: '1 character' },
{ length: 399, shouldPass: true, description: '399 characters' },
{ length: 400, shouldPass: true, description: '400 characters (boundary)' },
{ length: 401, shouldPass: false, description: '401 characters (over limit)' },
{ length: 500, shouldPass: false, description: '500 characters' },
{ length: 1000, shouldPass: false, description: '1000 characters' },
]
testCases.forEach(({ length, shouldPass, description }) => {
test(`handles ${description} correctly`, () => {
const testDescription = length > 0 ? 'x'.repeat(length) : ''
expect(testDescription.length).toBe(length)
if (shouldPass) {
expect(() => validateDescriptionLength(testDescription)).not.toThrow()
expect(validateDescriptionLength(testDescription)).toBe(testDescription)
}
else {
expect(() => validateDescriptionLength(testDescription)).toThrow(
'Description cannot exceed 400 characters.',
)
}
})
})
})
})

View File

@ -8,6 +8,7 @@ import Header from '@/app/components/header'
import { EventEmitterContextProvider } from '@/context/event-emitter' import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ProviderContextProvider } from '@/context/provider-context' import { ProviderContextProvider } from '@/context/provider-context'
import { ModalContextProvider } from '@/context/modal-context' import { ModalContextProvider } from '@/context/modal-context'
import GotoAnything from '@/app/components/goto-anything'
const Layout = ({ children }: { children: ReactNode }) => { const Layout = ({ children }: { children: ReactNode }) => {
return ( return (
@ -22,6 +23,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
<Header /> <Header />
</HeaderWrapper> </HeaderWrapper>
{children} {children}
<GotoAnything />
</ModalContextProvider> </ModalContextProvider>
</ProviderContextProvider> </ProviderContextProvider>
</EventEmitterContextProvider> </EventEmitterContextProvider>

View File

@ -87,7 +87,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
<Avatar {...props} /> <Avatar {...props} />
<div <div
onClick={() => { setIsShowAvatarPicker(true) }} onClick={() => { setIsShowAvatarPicker(true) }}
className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black bg-opacity-50 opacity-0 transition-opacity group-hover:opacity-100" className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
> >
<span className="text-xs text-white"> <span className="text-xs text-white">
<RiPencilLine /> <RiPencilLine />

View File

@ -12,7 +12,6 @@ import {
RiFileUploadLine, RiFileUploadLine,
} from '@remixicon/react' } from '@remixicon/react'
import AppIcon from '../base/app-icon' import AppIcon from '../base/app-icon'
import cn from '@/utils/classnames'
import { useStore as useAppStore } from '@/app/components/app/store' import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
@ -31,6 +30,7 @@ import Divider from '../base/divider'
import type { Operation } from './app-operations' import type { Operation } from './app-operations'
import AppOperations from './app-operations' import AppOperations from './app-operations'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import cn from '@/utils/classnames'
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), { const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
ssr: false, ssr: false,
@ -256,32 +256,40 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
}} }}
className='block w-full' className='block w-full'
> >
<div className={cn('flex rounded-lg', expand ? 'flex-col gap-2 p-2 pb-2.5' : 'items-start justify-center gap-1 p-1', open && 'bg-state-base-hover', isCurrentWorkspaceEditor && 'cursor-pointer hover:bg-state-base-hover')}> <div className='flex flex-col gap-2 rounded-lg p-1 hover:bg-state-base-hover'>
<div className={`flex items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`}> <div className='flex items-center gap-1'>
<AppIcon <div className={cn(!expand && 'ml-1')}>
size={expand ? 'large' : 'small'} <AppIcon
iconType={appDetail.icon_type} size={expand ? 'large' : 'small'}
icon={appDetail.icon} iconType={appDetail.icon_type}
background={appDetail.icon_background} icon={appDetail.icon}
imageUrl={appDetail.icon_url} background={appDetail.icon_background}
/> imageUrl={appDetail.icon_url}
<div className='flex items-center justify-center rounded-md p-0.5'> />
<div className='flex h-5 w-5 items-center justify-center'> </div>
{expand && (
<div className='ml-auto flex items-center justify-center rounded-md p-0.5'>
<div className='flex h-5 w-5 items-center justify-center'>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
</div>
)}
</div>
{!expand && (
<div className='flex items-center justify-center'>
<div className='flex h-5 w-5 items-center justify-center rounded-md p-0.5'>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' /> <RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div> </div>
</div> </div>
</div> )}
<div className={cn( {expand && (
'flex flex-col items-start gap-1 transition-all duration-200 ease-in-out', <div className='flex flex-col items-start gap-1'>
expand <div className='flex w-full'>
? 'w-auto opacity-100' <div className='system-md-semibold truncate whitespace-nowrap text-text-secondary'>{appDetail.name}</div>
: 'pointer-events-none w-0 overflow-hidden opacity-0', </div>
)}> <div className='system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary'>{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}</div>
<div className='flex w-full'>
<div className='system-md-semibold truncate whitespace-nowrap text-text-secondary'>{appDetail.name}</div>
</div> </div>
<div className='system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary'>{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}</div> )}
</div>
</div> </div>
</button> </button>
)} )}

View File

@ -32,7 +32,7 @@ const AccessControlDialog = ({
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 bg-background-overlay bg-opacity-25" /> <div className="bg-background-overlay/25 fixed inset-0" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 flex items-center justify-center"> <div className="fixed inset-0 flex items-center justify-center">

View File

@ -106,7 +106,7 @@ function SelectedGroupsBreadCrumb() {
setSelectedGroupsForBreadcrumb([]) setSelectedGroupsForBreadcrumb([])
}, [setSelectedGroupsForBreadcrumb]) }, [setSelectedGroupsForBreadcrumb])
return <div className='flex h-7 items-center gap-x-0.5 px-2 py-0.5'> return <div className='flex h-7 items-center gap-x-0.5 px-2 py-0.5'>
<span className={classNames('system-xs-regular text-text-tertiary', selectedGroupsForBreadcrumb.length > 0 && 'text-text-accent cursor-pointer')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}</span> <span className={classNames('system-xs-regular text-text-tertiary', selectedGroupsForBreadcrumb.length > 0 && 'cursor-pointer text-text-accent')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}</span>
{selectedGroupsForBreadcrumb.map((group, index) => { {selectedGroupsForBreadcrumb.map((group, index) => {
return <div key={index} className='system-xs-regular flex items-center gap-x-0.5 text-text-tertiary'> return <div key={index} className='system-xs-regular flex items-center gap-x-0.5 text-text-tertiary'>
<span>/</span> <span>/</span>
@ -198,7 +198,7 @@ type BaseItemProps = {
children: React.ReactNode children: React.ReactNode
} }
function BaseItem({ children, className }: BaseItemProps) { function BaseItem({ children, className }: BaseItemProps) {
return <div className={classNames('p-1 pl-2 flex items-center space-x-2 hover:rounded-lg hover:bg-state-base-hover cursor-pointer', className)}> return <div className={classNames('flex cursor-pointer items-center space-x-2 p-1 pl-2 hover:rounded-lg hover:bg-state-base-hover', className)}>
{children} {children}
</div> </div>
} }

View File

@ -4,7 +4,6 @@ import React, { useRef, useState } from 'react'
import { useGetState, useInfiniteScroll } from 'ahooks' import { useGetState, useInfiniteScroll } from 'ahooks'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Link from 'next/link' import Link from 'next/link'
import produce from 'immer'
import Modal from '@/app/components/base/modal' import Modal from '@/app/components/base/modal'
import type { DataSet } from '@/models/datasets' import type { DataSet } from '@/models/datasets'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
@ -29,9 +28,10 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
onSelect, onSelect,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [selected, setSelected] = React.useState<DataSet[]>(selectedIds.map(id => ({ id }) as any)) const [selected, setSelected] = React.useState<DataSet[]>([])
const [loaded, setLoaded] = React.useState(false) const [loaded, setLoaded] = React.useState(false)
const [datasets, setDataSets] = React.useState<DataSet[] | null>(null) const [datasets, setDataSets] = React.useState<DataSet[] | null>(null)
const [hasInitialized, setHasInitialized] = React.useState(false)
const hasNoData = !datasets || datasets?.length === 0 const hasNoData = !datasets || datasets?.length === 0
const canSelectMulti = true const canSelectMulti = true
@ -49,19 +49,17 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
const newList = [...(datasets || []), ...data.filter(item => item.indexing_technique || item.provider === 'external')] const newList = [...(datasets || []), ...data.filter(item => item.indexing_technique || item.provider === 'external')]
setDataSets(newList) setDataSets(newList)
setLoaded(true) setLoaded(true)
if (!selected.find(item => !item.name))
return { list: [] }
const newSelected = produce(selected, (draft) => { // Initialize selected datasets based on selectedIds and available datasets
selected.forEach((item, index) => { if (!hasInitialized) {
if (!item.name) { // not fetched database if (selectedIds.length > 0) {
const newItem = newList.find(i => i.id === item.id) const validSelectedDatasets = selectedIds
if (newItem) .map(id => newList.find(item => item.id === id))
draft[index] = newItem .filter(Boolean) as DataSet[]
} setSelected(validSelectedDatasets)
}) }
}) setHasInitialized(true)
setSelected(newSelected) }
} }
return { list: [] } return { list: [] }
}, },

View File

@ -40,13 +40,13 @@ type CategoryItemProps = {
} }
function CategoryItem({ category, active, onClick }: CategoryItemProps) { function CategoryItem({ category, active, onClick }: CategoryItemProps) {
return <li return <li
className={classNames('p-1 pl-3 h-8 rounded-lg flex items-center gap-2 group cursor-pointer hover:bg-state-base-hover [&.active]:bg-state-base-active', active && 'active')} className={classNames('group flex h-8 cursor-pointer items-center gap-2 rounded-lg p-1 pl-3 hover:bg-state-base-hover [&.active]:bg-state-base-active', active && 'active')}
onClick={() => { onClick?.(category) }}> onClick={() => { onClick?.(category) }}>
{category === AppCategories.RECOMMENDED && <div className='inline-flex h-5 w-5 items-center justify-center rounded-md'> {category === AppCategories.RECOMMENDED && <div className='inline-flex h-5 w-5 items-center justify-center rounded-md'>
<RiThumbUpLine className='h-4 w-4 text-components-menu-item-text group-[.active]:text-components-menu-item-text-active' /> <RiThumbUpLine className='h-4 w-4 text-components-menu-item-text group-[.active]:text-components-menu-item-text-active' />
</div>} </div>}
<AppCategoryLabel category={category} <AppCategoryLabel category={category}
className={classNames('system-sm-medium text-components-menu-item-text group-[.active]:text-components-menu-item-text-active group-hover:text-components-menu-item-text-hover', active && 'system-sm-semibold')} /> className={classNames('system-sm-medium text-components-menu-item-text group-hover:text-components-menu-item-text-hover group-[.active]:text-components-menu-item-text-active', active && 'system-sm-semibold')} />
</li > </li >
} }

View File

@ -82,8 +82,11 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
getRedirection(isCurrentWorkspaceEditor, app, push) getRedirection(isCurrentWorkspaceEditor, app, push)
} }
catch { catch (e: any) {
notify({ type: 'error', message: t('app.newApp.appCreateFailed') }) notify({
type: 'error',
message: e.message || t('app.newApp.appCreateFailed'),
})
} }
isCreatingRef.current = false isCreatingRef.current = false
}, [name, notify, t, appMode, appIcon, description, onSuccess, onClose, push, isCurrentWorkspaceEditor]) }, [name, notify, t, appMode, appIcon, description, onSuccess, onClose, push, isCurrentWorkspaceEditor])

View File

@ -117,8 +117,11 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
if (onRefresh) if (onRefresh)
onRefresh() onRefresh()
} }
catch { catch (e: any) {
notify({ type: 'error', message: t('app.editFailed') }) notify({
type: 'error',
message: e.message || t('app.editFailed'),
})
} }
}, [app.id, notify, onRefresh, t]) }, [app.id, notify, onRefresh, t])
@ -364,7 +367,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
</div> </div>
<div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'> <div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'>
<div <div
className={cn(tags.length ? 'line-clamp-2' : 'line-clamp-4', 'group-hover:line-clamp-2')} className='line-clamp-2'
title={app.description} title={app.description}
> >
{app.description} {app.description}

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react' import React from 'react'
import Link from 'next/link' import Link from 'next/link'
import { RiCloseLine, RiDiscordFill, RiGithubFill } from '@remixicon/react' import { RiDiscordFill, RiGithubFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
type CustomLinkProps = { type CustomLinkProps = {
@ -26,24 +26,9 @@ const CustomLink = React.memo(({
const Footer = () => { const Footer = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [isVisible, setIsVisible] = useState(true)
const handleClose = () => {
setIsVisible(false)
}
if (!isVisible)
return null
return ( return (
<footer className='relative shrink-0 grow-0 px-12 py-2'> <footer className='relative shrink-0 grow-0 px-12 py-2'>
<button
onClick={handleClose}
className='absolute right-2 top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full transition-colors duration-200 ease-in-out hover:bg-components-main-nav-nav-button-bg-active'
aria-label="Close footer"
>
<RiCloseLine className='h-4 w-4 text-text-tertiary hover:text-text-secondary' />
</button>
<h3 className='text-gradient text-xl font-semibold leading-tight'>{t('app.join')}</h3> <h3 className='text-gradient text-xl font-semibold leading-tight'>{t('app.join')}</h3>
<p className='system-sm-regular mt-1 text-text-tertiary'>{t('app.communityIntro')}</p> <p className='system-sm-regular mt-1 text-text-tertiary'>{t('app.communityIntro')}</p>
<div className='mt-3 flex items-center gap-2'> <div className='mt-3 flex items-center gap-2'>

View File

@ -1,14 +1,11 @@
'use client' 'use client'
import { useEducationInit } from '@/app/education-apply/hooks' import { useEducationInit } from '@/app/education-apply/hooks'
import { useGlobalPublicStore } from '@/context/global-public-context'
import List from './list' import List from './list'
import Footer from './footer'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const Apps = () => { const Apps = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore()
useDocumentTitle(t('common.menus.apps')) useDocumentTitle(t('common.menus.apps'))
useEducationInit() useEducationInit()
@ -16,9 +13,6 @@ const Apps = () => {
return ( return (
<div className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'> <div className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'>
<List /> <List />
{!systemFeatures.branding.enabled && (
<Footer />
)}
</div > </div >
) )
} }

View File

@ -32,6 +32,8 @@ import TagFilter from '@/app/components/base/tag-management/filter'
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label' import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import Empty from './empty' import Empty from './empty'
import Footer from './footer'
import { useGlobalPublicStore } from '@/context/global-public-context'
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
ssr: false, ssr: false,
@ -66,6 +68,7 @@ const getKey = (
const List = () => { const List = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore()
const router = useRouter() const router = useRouter()
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext() const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
@ -229,6 +232,9 @@ const List = () => {
<span className="system-xs-regular">{t('app.newApp.dropDSLToCreateApp')}</span> <span className="system-xs-regular">{t('app.newApp.dropDSLToCreateApp')}</span>
</div> </div>
)} )}
{!systemFeatures.branding.enabled && (
<Footer />
)}
<CheckModal /> <CheckModal />
<div ref={anchorRef} className='h-0'> </div> <div ref={anchorRef} className='h-0'> </div>
{showTagManagementModal && ( {showTagManagementModal && (

View File

@ -94,7 +94,7 @@ const ImageInput: FC<UploaderProps> = ({
<div <div
className={classNames( className={classNames(
isDragActive && 'border-primary-600', isDragActive && 'border-primary-600',
'relative aspect-square border-[1.5px] border-dashed rounded-lg flex flex-col justify-center items-center text-gray-500')} 'relative flex aspect-square flex-col items-center justify-center rounded-lg border-[1.5px] border-dashed text-gray-500')}
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}

View File

@ -112,7 +112,7 @@ const BlockInput: FC<IBlockInputProps> = ({
? <div className='h-full px-4 py-2'> ? <div className='h-full px-4 py-2'>
<textarea <textarea
ref={contentEditableRef} ref={contentEditableRef}
className={classNames(editAreaClassName, 'block w-full h-full resize-none')} className={classNames(editAreaClassName, 'block h-full w-full resize-none')}
placeholder={placeholder} placeholder={placeholder}
onChange={onValueChange} onChange={onValueChange}
value={currentValue} value={currentValue}
@ -130,7 +130,7 @@ const BlockInput: FC<IBlockInputProps> = ({
</div>) </div>)
return ( return (
<div className={classNames('block-input w-full overflow-y-auto bg-white border-none rounded-xl')}> <div className={classNames('block-input w-full overflow-y-auto rounded-xl border-none bg-white')}>
{textAreaContent} {textAreaContent}
{/* footer */} {/* footer */}
{!readonly && ( {!readonly && (

View File

@ -51,7 +51,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{...props} {...props}
> >
{children} {children}
{loading && <Spinner loading={loading} className={classNames('!text-white !h-3 !w-3 !border-2 !ml-1', spinnerClassName)} />} {loading && <Spinner loading={loading} className={classNames('!ml-1 !h-3 !w-3 !border-2 !text-white', spinnerClassName)} />}
</button> </button>
) )
}, },

View File

@ -78,7 +78,6 @@ const DatePicker = ({
setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone })) setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone }))
setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined) setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timezone]) }, [timezone])
const handleClickTrigger = (e: React.MouseEvent) => { const handleClickTrigger = (e: React.MouseEvent) => {
@ -192,7 +191,7 @@ const DatePicker = ({
setView(ViewType.date) setView(ViewType.date)
} }
const timeFormat = needTimePicker ? 'MMMM D, YYYY hh:mm A' : 'MMMM D, YYYY' const timeFormat = needTimePicker ? t('time.dateFormats.displayWithTime') : t('time.dateFormats.display')
const displayValue = value?.format(timeFormat) || '' const displayValue = value?.format(timeFormat) || ''
const displayTime = selectedDate?.format('hh:mm A') || '--:-- --' const displayTime = selectedDate?.format('hh:mm A') || '--:-- --'
const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('time.defaultPlaceholder')) const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('time.defaultPlaceholder'))

View File

@ -90,3 +90,49 @@ export const convertTimezoneToOffsetStr = (timezone?: string) => {
return DEFAULT_OFFSET_STR return DEFAULT_OFFSET_STR
return `UTC${tzItem.name.charAt(0)}${tzItem.name.charAt(2)}` return `UTC${tzItem.name.charAt(0)}${tzItem.name.charAt(2)}`
} }
// Parse date with multiple format support
export const parseDateWithFormat = (dateString: string, format?: string): Dayjs | null => {
if (!dateString) return null
// If format is specified, use it directly
if (format) {
const parsed = dayjs(dateString, format, true)
return parsed.isValid() ? parsed : null
}
// Try common date formats
const formats = [
'YYYY-MM-DD', // Standard format
'YYYY/MM/DD', // Slash format
'DD-MM-YYYY', // European format
'DD/MM/YYYY', // European slash format
'MM-DD-YYYY', // US format
'MM/DD/YYYY', // US slash format
'YYYY-MM-DDTHH:mm:ss.SSSZ', // ISO format
'YYYY-MM-DDTHH:mm:ssZ', // ISO format (no milliseconds)
'YYYY-MM-DD HH:mm:ss', // Standard datetime format
]
for (const fmt of formats) {
const parsed = dayjs(dateString, fmt, true)
if (parsed.isValid())
return parsed
}
return null
}
// Format date output with localization support
export const formatDateForOutput = (date: Dayjs, includeTime: boolean = false, locale: string = 'en-US'): string => {
if (!date || !date.isValid()) return ''
if (includeTime) {
// Output format with time
return date.format('YYYY-MM-DDTHH:mm:ss.SSSZ')
}
else {
// Date-only output format without timezone
return date.format('YYYY-MM-DD')
}
}

View File

@ -47,16 +47,16 @@ const CustomDialog = ({
<div className="flex min-h-full items-center justify-center"> <div className="flex min-h-full items-center justify-center">
<TransitionChild> <TransitionChild>
<DialogPanel className={classNames( <DialogPanel className={classNames(
'w-full max-w-[800px] p-6 overflow-hidden transition-all transform bg-components-panel-bg border-[0.5px] border-components-panel-border shadow-xl rounded-2xl', 'w-full max-w-[800px] overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl transition-all',
'duration-100 ease-in data-[closed]:opacity-0 data-[closed]:scale-95', 'duration-100 ease-in data-[closed]:scale-95 data-[closed]:opacity-0',
'data-[enter]:opacity-100 data-[enter]:scale-100', 'data-[enter]:scale-100 data-[enter]:opacity-100',
'data-[leave]:opacity-0 data-[enter]:scale-95', 'data-[enter]:scale-95 data-[leave]:opacity-0',
className, className,
)}> )}>
{Boolean(title) && ( {Boolean(title) && (
<DialogTitle <DialogTitle
as={titleAs || 'h3'} as={titleAs || 'h3'}
className={classNames('pr-8 pb-3 title-2xl-semi-bold text-text-primary', titleClassName)} className={classNames('title-2xl-semi-bold pb-3 pr-8 text-text-primary', titleClassName)}
> >
{title} {title}
</DialogTitle> </DialogTitle>

View File

@ -24,7 +24,7 @@ const DialogWrapper = ({
<Dialog as="div" className="relative z-40" onClose={close}> <Dialog as="div" className="relative z-40" onClose={close}>
<TransitionChild> <TransitionChild>
<div className={cn( <div className={cn(
'fixed inset-0 bg-black bg-opacity-25', 'fixed inset-0 bg-black/25',
'data-[closed]:opacity-0', 'data-[closed]:opacity-0',
'data-[enter]:opacity-100 data-[enter]:duration-300 data-[enter]:ease-out', 'data-[enter]:opacity-100 data-[enter]:duration-300 data-[enter]:ease-out',
'data-[leave]:opacity-0 data-[leave]:duration-200 data-[leave]:ease-in', 'data-[leave]:opacity-0 data-[leave]:duration-200 data-[leave]:ease-in',

View File

@ -36,7 +36,7 @@ describe('file-uploader utils', () => {
}) })
describe('fileUpload', () => { describe('fileUpload', () => {
it('should handle successful file upload', async () => { it('should handle successful file upload', () => {
const mockFile = new File(['test'], 'test.txt') const mockFile = new File(['test'], 'test.txt')
const mockCallbacks = { const mockCallbacks = {
onProgressCallback: jest.fn(), onProgressCallback: jest.fn(),
@ -46,13 +46,12 @@ describe('file-uploader utils', () => {
jest.mocked(upload).mockResolvedValue({ id: '123' }) jest.mocked(upload).mockResolvedValue({ id: '123' })
await fileUpload({ fileUpload({
file: mockFile, file: mockFile,
...mockCallbacks, ...mockCallbacks,
}) })
expect(upload).toHaveBeenCalled() expect(upload).toHaveBeenCalled()
expect(mockCallbacks.onSuccessCallback).toHaveBeenCalledWith({ id: '123' })
}) })
}) })
@ -284,7 +283,23 @@ describe('file-uploader utils', () => {
}) })
describe('getProcessedFilesFromResponse', () => { describe('getProcessedFilesFromResponse', () => {
it('should process files correctly', () => { beforeEach(() => {
jest.mocked(mime.getAllExtensions).mockImplementation((mimeType: string) => {
const mimeMap: Record<string, Set<string>> = {
'image/jpeg': new Set(['jpg', 'jpeg']),
'image/png': new Set(['png']),
'image/gif': new Set(['gif']),
'video/mp4': new Set(['mp4']),
'audio/mp3': new Set(['mp3']),
'application/pdf': new Set(['pdf']),
'text/plain': new Set(['txt']),
'application/json': new Set(['json']),
}
return mimeMap[mimeType] || new Set()
})
})
it('should process files correctly without type correction', () => {
const files = [{ const files = [{
related_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9', related_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9',
extension: '.jpeg', extension: '.jpeg',
@ -294,6 +309,8 @@ describe('file-uploader utils', () => {
transfer_method: TransferMethod.local_file, transfer_method: TransferMethod.local_file,
type: 'image', type: 'image',
url: 'https://upload.dify.dev/files/xxx/file-preview', url: 'https://upload.dify.dev/files/xxx/file-preview',
upload_file_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9',
remote_url: '',
}] }]
const result = getProcessedFilesFromResponse(files) const result = getProcessedFilesFromResponse(files)
@ -309,6 +326,215 @@ describe('file-uploader utils', () => {
url: 'https://upload.dify.dev/files/xxx/file-preview', url: 'https://upload.dify.dev/files/xxx/file-preview',
}) })
}) })
it('should correct image file misclassified as document', () => {
const files = [{
related_id: '123',
extension: '.jpg',
filename: 'image.jpg',
size: 1024,
mime_type: 'image/jpeg',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/image.jpg',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('image')
})
it('should correct video file misclassified as document', () => {
const files = [{
related_id: '123',
extension: '.mp4',
filename: 'video.mp4',
size: 1024,
mime_type: 'video/mp4',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/video.mp4',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('video')
})
it('should correct audio file misclassified as document', () => {
const files = [{
related_id: '123',
extension: '.mp3',
filename: 'audio.mp3',
size: 1024,
mime_type: 'audio/mp3',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/audio.mp3',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('audio')
})
it('should correct document file misclassified as image', () => {
const files = [{
related_id: '123',
extension: '.pdf',
filename: 'document.pdf',
size: 1024,
mime_type: 'application/pdf',
transfer_method: TransferMethod.local_file,
type: 'image',
url: 'https://example.com/document.pdf',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('document')
})
it('should NOT correct when filename and MIME type conflict', () => {
const files = [{
related_id: '123',
extension: '.pdf',
filename: 'document.pdf',
size: 1024,
mime_type: 'image/jpeg',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/document.pdf',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('document')
})
it('should NOT correct when filename and MIME type both point to wrong type', () => {
const files = [{
related_id: '123',
extension: '.jpg',
filename: 'image.jpg',
size: 1024,
mime_type: 'image/jpeg',
transfer_method: TransferMethod.local_file,
type: 'image',
url: 'https://example.com/image.jpg',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('image')
})
it('should handle files with missing filename', () => {
const files = [{
related_id: '123',
extension: '',
filename: '',
size: 1024,
mime_type: 'image/jpeg',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/file',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('document')
})
it('should handle files with missing MIME type', () => {
const files = [{
related_id: '123',
extension: '.jpg',
filename: 'image.jpg',
size: 1024,
mime_type: '',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/image.jpg',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('document')
})
it('should handle files with unknown extensions', () => {
const files = [{
related_id: '123',
extension: '.unknown',
filename: 'file.unknown',
size: 1024,
mime_type: 'application/unknown',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/file.unknown',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('document')
})
it('should handle multiple different file types correctly', () => {
const files = [
{
related_id: '1',
extension: '.jpg',
filename: 'correct-image.jpg',
mime_type: 'image/jpeg',
type: 'image',
size: 1024,
transfer_method: TransferMethod.local_file,
url: 'https://example.com/correct-image.jpg',
upload_file_id: '1',
remote_url: '',
},
{
related_id: '2',
extension: '.png',
filename: 'misclassified-image.png',
mime_type: 'image/png',
type: 'document',
size: 2048,
transfer_method: TransferMethod.local_file,
url: 'https://example.com/misclassified-image.png',
upload_file_id: '2',
remote_url: '',
},
{
related_id: '3',
extension: '.pdf',
filename: 'conflicted.pdf',
mime_type: 'image/jpeg',
type: 'document',
size: 3072,
transfer_method: TransferMethod.local_file,
url: 'https://example.com/conflicted.pdf',
upload_file_id: '3',
remote_url: '',
},
]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('image') // correct, no change
expect(result[1].supportFileType).toBe('image') // corrected from document to image
expect(result[2].supportFileType).toBe('document') // conflict, no change
})
}) })
describe('getFileNameFromUrl', () => { describe('getFileNameFromUrl', () => {

View File

@ -70,10 +70,13 @@ export const getFileExtension = (fileName: string, fileMimetype: string, isRemot
} }
} }
if (!extension) { if (!extension) {
if (extensions.size > 0) if (extensions.size > 0) {
extension = extensions.values().next().value.toLowerCase() const firstExtension = extensions.values().next().value
else extension = firstExtension ? firstExtension.toLowerCase() : ''
}
else {
extension = extensionInFileName extension = extensionInFileName
}
} }
if (isRemote) if (isRemote)
@ -145,6 +148,19 @@ export const getProcessedFiles = (files: FileEntity[]) => {
export const getProcessedFilesFromResponse = (files: FileResponse[]) => { export const getProcessedFilesFromResponse = (files: FileResponse[]) => {
return files.map((fileItem) => { return files.map((fileItem) => {
let supportFileType = fileItem.type
if (fileItem.filename && fileItem.mime_type) {
const detectedTypeFromFileName = getSupportFileType(fileItem.filename, '')
const detectedTypeFromMime = getSupportFileType('', fileItem.mime_type)
if (detectedTypeFromFileName
&& detectedTypeFromMime
&& detectedTypeFromFileName === detectedTypeFromMime
&& detectedTypeFromFileName !== fileItem.type)
supportFileType = detectedTypeFromFileName
}
return { return {
id: fileItem.related_id, id: fileItem.related_id,
name: fileItem.filename, name: fileItem.filename,
@ -152,7 +168,7 @@ export const getProcessedFilesFromResponse = (files: FileResponse[]) => {
type: fileItem.mime_type, type: fileItem.mime_type,
progress: 100, progress: 100,
transferMethod: fileItem.transfer_method, transferMethod: fileItem.transfer_method,
supportFileType: fileItem.type, supportFileType,
uploadedId: fileItem.upload_file_id || fileItem.related_id, uploadedId: fileItem.upload_file_id || fileItem.related_id,
url: fileItem.url || fileItem.remote_url, url: fileItem.url || fileItem.remote_url,
} }

View File

@ -48,9 +48,9 @@ export default function FullScreenModal({
<DialogPanel className={classNames( <DialogPanel className={classNames(
'h-full', 'h-full',
overflowVisible ? 'overflow-visible' : 'overflow-hidden', overflowVisible ? 'overflow-visible' : 'overflow-hidden',
'duration-100 ease-in data-[closed]:opacity-0 data-[closed]:scale-95', 'duration-100 ease-in data-[closed]:scale-95 data-[closed]:opacity-0',
'data-[enter]:opacity-100 data-[enter]:scale-100', 'data-[enter]:scale-100 data-[enter]:opacity-100',
'data-[leave]:opacity-0 data-[enter]:scale-95', 'data-[enter]:scale-95 data-[leave]:opacity-0',
className, className,
)}> )}>
{closable {closable

View File

@ -16,8 +16,8 @@ const GridMask: FC<GridMaskProps> = ({
}) => { }) => {
return ( return (
<div className={classNames('relative bg-saas-background', wrapperClassName)}> <div className={classNames('relative bg-saas-background', wrapperClassName)}>
<div className={classNames('absolute inset-0 w-full h-full z-0 opacity-70', canvasClassName, Style.gridBg)} /> <div className={classNames('absolute inset-0 z-0 h-full w-full opacity-70', canvasClassName, Style.gridBg)} />
<div className={classNames('absolute w-full h-full z-[1] bg-grid-mask-background rounded-lg', gradientClassName)} /> <div className={classNames('absolute z-[1] h-full w-full rounded-lg bg-grid-mask-background', gradientClassName)} />
<div className='relative z-[2]'>{children}</div> <div className='relative z-[2]'>{children}</div>
</div> </div>
) )

View File

@ -32,7 +32,7 @@ export type InputProps = {
unit?: string unit?: string
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & VariantProps<typeof inputVariants> } & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & VariantProps<typeof inputVariants>
const Input = ({ const Input = React.forwardRef<HTMLInputElement, InputProps>(({
size, size,
disabled, disabled,
destructive, destructive,
@ -47,12 +47,13 @@ const Input = ({
onChange = noop, onChange = noop,
unit, unit,
...props ...props
}: InputProps) => { }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div className={cn('relative w-full', wrapperClassName)}> <div className={cn('relative w-full', wrapperClassName)}>
{showLeftIcon && <RiSearchLine className={cn('absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-components-input-text-placeholder')} />} {showLeftIcon && <RiSearchLine className={cn('absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-components-input-text-placeholder')} />}
<input <input
ref={ref}
style={styleCss} style={styleCss}
className={cn( className={cn(
'w-full appearance-none border border-transparent bg-components-input-bg-normal py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs', 'w-full appearance-none border border-transparent bg-components-input-bg-normal py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
@ -92,6 +93,8 @@ const Input = ({
} }
</div> </div>
) )
} })
Input.displayName = 'Input'
export default Input export default Input

View File

@ -13,7 +13,7 @@ const LogoSite: FC<LogoSiteProps> = ({
return ( return (
<img <img
src={`${basePath}/logo/logo.png`} src={`${basePath}/logo/logo.png`}
className={classNames('block w-[22.651px] h-[24.5px]', className)} className={classNames('block h-[24.5px] w-[22.651px]', className)}
alt='logo' alt='logo'
/> />
) )

View File

@ -81,7 +81,6 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
const echartsRef = useRef<any>(null) const echartsRef = useRef<any>(null)
const contentRef = useRef<string>('') const contentRef = useRef<string>('')
const processedRef = useRef<boolean>(false) // Track if content was successfully processed const processedRef = useRef<boolean>(false) // Track if content was successfully processed
const instanceIdRef = useRef<string>(`chart-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`) // Unique ID for logging
const isInitialRenderRef = useRef<boolean>(true) // Track if this is initial render const isInitialRenderRef = useRef<boolean>(true) // Track if this is initial render
const chartInstanceRef = useRef<any>(null) // Direct reference to ECharts instance const chartInstanceRef = useRef<any>(null) // Direct reference to ECharts instance
const resizeTimerRef = useRef<NodeJS.Timeout | null>(null) // For debounce handling const resizeTimerRef = useRef<NodeJS.Timeout | null>(null) // For debounce handling

View File

@ -7,6 +7,7 @@ import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
import Checkbox from '@/app/components/base/checkbox' import Checkbox from '@/app/components/base/checkbox'
import Select from '@/app/components/base/select' import Select from '@/app/components/base/select'
import { useChatContext } from '@/app/components/base/chat/chat/context' import { useChatContext } from '@/app/components/base/chat/chat/context'
import { formatDateForOutput } from '@/app/components/base/date-and-time-picker/utils/dayjs'
enum DATA_FORMAT { enum DATA_FORMAT {
TEXT = 'text', TEXT = 'text',
@ -51,8 +52,20 @@ const MarkdownForm = ({ node }: any) => {
const getFormValues = (children: any) => { const getFormValues = (children: any) => {
const values: { [key: string]: any } = {} const values: { [key: string]: any } = {}
children.forEach((child: any) => { children.forEach((child: any) => {
if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) {
values[child.properties.name] = formValues[child.properties.name] let value = formValues[child.properties.name]
if (child.tagName === SUPPORTED_TAGS.INPUT
&& (child.properties.type === SUPPORTED_TYPES.DATE || child.properties.type === SUPPORTED_TYPES.DATETIME)) {
if (value && typeof value.format === 'function') {
// Format date output consistently
const includeTime = child.properties.type === SUPPORTED_TYPES.DATETIME
value = formatDateForOutput(value, includeTime)
}
}
values[child.properties.name] = value
}
}) })
return values return values
} }

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import mermaid, { type MermaidConfig } from 'mermaid' import mermaid, { type MermaidConfig } from 'mermaid'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline' import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
@ -122,14 +122,6 @@ const Flowchart = React.forwardRef((props: {
const renderTimeoutRef = useRef<NodeJS.Timeout>() const renderTimeoutRef = useRef<NodeJS.Timeout>()
const [errMsg, setErrMsg] = useState('') const [errMsg, setErrMsg] = useState('')
const [imagePreviewUrl, setImagePreviewUrl] = useState('') const [imagePreviewUrl, setImagePreviewUrl] = useState('')
const [isCodeComplete, setIsCodeComplete] = useState(false)
const codeCompletionCheckRef = useRef<NodeJS.Timeout>()
const prevCodeRef = useRef<string>()
// Create cache key from code, style and theme
const cacheKey = useMemo(() => {
return `${props.PrimitiveCode}-${look}-${currentTheme}`
}, [props.PrimitiveCode, look, currentTheme])
/** /**
* Renders Mermaid chart * Renders Mermaid chart
@ -537,11 +529,9 @@ const Flowchart = React.forwardRef((props: {
{isLoading && !svgString && ( {isLoading && !svgString && (
<div className='px-[26px] py-4'> <div className='px-[26px] py-4'>
<LoadingAnim type='text'/> <LoadingAnim type='text'/>
{!isCodeComplete && (
<div className="mt-2 text-sm text-gray-500"> <div className="mt-2 text-sm text-gray-500">
{t('common.wait_for_completion', 'Waiting for diagram code to complete...')} {t('common.wait_for_completion', 'Waiting for diagram code to complete...')}
</div> </div>
)}
</div> </div>
)} )}

View File

@ -50,11 +50,11 @@ export default function Modal({
<div className="flex min-h-full items-center justify-center p-4 text-center"> <div className="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild> <TransitionChild>
<DialogPanel className={classNames( <DialogPanel className={classNames(
'w-full max-w-[480px] transform rounded-2xl bg-components-panel-bg p-6 text-left align-middle shadow-xl transition-all', 'w-full max-w-[480px] rounded-2xl bg-components-panel-bg p-6 text-left align-middle shadow-xl transition-all',
overflowVisible ? 'overflow-visible' : 'overflow-hidden', overflowVisible ? 'overflow-visible' : 'overflow-hidden',
'duration-100 ease-in data-[closed]:opacity-0 data-[closed]:scale-95', 'duration-100 ease-in data-[closed]:scale-95 data-[closed]:opacity-0',
'data-[enter]:opacity-100 data-[enter]:scale-100', 'data-[enter]:scale-100 data-[enter]:opacity-100',
'data-[leave]:opacity-0 data-[enter]:scale-95', 'data-[enter]:scale-95 data-[leave]:opacity-0',
className, className,
)}> )}>
{title && <DialogTitle {title && <DialogTitle

View File

@ -61,7 +61,7 @@ const PremiumBadge: React.FC<PremiumBadgeProps> = ({
{children} {children}
<Highlight <Highlight
className={classNames( className={classNames(
'absolute top-0 opacity-50 right-1/2 translate-x-[20%] transition-all duration-100 ease-out hover:opacity-80 hover:translate-x-[30%]', 'absolute right-1/2 top-0 translate-x-[20%] opacity-50 transition-all duration-100 ease-out hover:translate-x-[30%] hover:opacity-80',
size === 's' ? 'h-[18px] w-12' : 'h-6 w-12', size === 's' ? 'h-[18px] w-12' : 'h-6 w-12',
)} )}
/> />

View File

@ -0,0 +1,61 @@
'use client'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { Fragment } from 'react'
import { GlobeAltIcon } from '@heroicons/react/24/outline'
type ISelectProps = {
items: Array<{ value: string; name: string }>
value?: string
className?: string
onChange?: (value: string) => void
}
export default function LocaleSigninSelect({
items,
value,
onChange,
}: ISelectProps) {
const item = items.filter(item => item.value === value)[0]
return (
<div className="w-56 text-right">
<Menu as="div" className="relative inline-block text-left">
<div>
<MenuButton className="h-[44px]justify-center inline-flex w-full items-center rounded-lg border border-components-button-secondary-border px-[10px] py-[6px] text-[13px] font-medium text-text-primary hover:bg-state-base-hover">
<GlobeAltIcon className="mr-1 h-5 w-5" aria-hidden="true" />
{item?.name}
</MenuButton>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems className="absolute right-0 z-10 mt-2 w-[200px] origin-top-right divide-y divide-divider-regular rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg focus:outline-none">
<div className="px-1 py-1 ">
{items.map((item) => {
return <MenuItem key={item.value}>
<button
className={'group flex w-full items-center rounded-lg px-3 py-2 text-sm text-text-secondary data-[active]:bg-state-base-hover'}
onClick={(evt) => {
evt.preventDefault()
onChange && onChange(item.value)
}}
>
{item.name}
</button>
</MenuItem>
})}
</div>
</MenuItems>
</Transition>
</Menu>
</div>
)
}

View File

@ -24,7 +24,7 @@ export const SkeletonRow: FC<SkeletonProps> = (props) => {
export const SkeletonRectangle: FC<SkeletonProps> = (props) => { export const SkeletonRectangle: FC<SkeletonProps> = (props) => {
const { className, children, ...rest } = props const { className, children, ...rest } = props
return ( return (
<div className={classNames('h-2 rounded-sm opacity-20 bg-text-quaternary my-1', className)} {...rest}> <div className={classNames('my-1 h-2 rounded-sm bg-text-quaternary opacity-20', className)} {...rest}>
{children} {children}
</div> </div>
) )
@ -33,7 +33,7 @@ export const SkeletonRectangle: FC<SkeletonProps> = (props) => {
export const SkeletonPoint: FC<SkeletonProps> = (props) => { export const SkeletonPoint: FC<SkeletonProps> = (props) => {
const { className, ...rest } = props const { className, ...rest } = props
return ( return (
<div className={classNames('text-text-quaternary text-xs font-medium', className)} {...rest}>·</div> <div className={classNames('text-xs font-medium text-text-quaternary', className)} {...rest}>·</div>
) )
} }
/** Usage /** Usage

View File

@ -63,8 +63,8 @@ const Switch = (
className={classNames( className={classNames(
wrapStyle[size], wrapStyle[size],
enabled ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked', enabled ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked',
'relative inline-flex flex-shrink-0 cursor-pointer rounded-[5px] border-2 border-transparent transition-colors duration-200 ease-in-out', 'relative inline-flex shrink-0 cursor-pointer rounded-[5px] border-2 border-transparent transition-colors duration-200 ease-in-out',
disabled ? '!opacity-50 !cursor-not-allowed' : '', disabled ? '!cursor-not-allowed !opacity-50' : '',
size === 'xs' && 'rounded-sm', size === 'xs' && 'rounded-sm',
className, className,
)} )}
@ -75,7 +75,7 @@ const Switch = (
circleStyle[size], circleStyle[size],
enabled ? translateLeft[size] : 'translate-x-0', enabled ? translateLeft[size] : 'translate-x-0',
size === 'xs' && 'rounded-[1px]', size === 'xs' && 'rounded-[1px]',
'pointer-events-none inline-block transform rounded-[3px] bg-components-toggle-knob shadow ring-0 transition duration-200 ease-in-out', 'pointer-events-none inline-block rounded-[3px] bg-components-toggle-knob shadow ring-0 transition duration-200 ease-in-out',
)} )}
/> />
</OriginalSwitch> </OriginalSwitch>

View File

@ -25,8 +25,8 @@ const TabSliderNew: FC<TabSliderProps> = ({
key={option.value} key={option.value}
onClick={() => onChange(option.value)} onClick={() => onChange(option.value)}
className={cn( className={cn(
'mr-1 flex h-[32px] cursor-pointer items-center rounded-lg border-[0.5px] border-transparent px-3 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-components-main-nav-nav-button-bg-active', 'mr-1 flex h-[32px] cursor-pointer items-center rounded-lg border-[0.5px] border-transparent px-3 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover',
value === option.value && 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active text-components-main-nav-nav-button-text-active shadow-xs', value === option.value && 'border-components-main-nav-nav-button-border bg-state-base-hover text-components-main-nav-nav-button-text-active shadow-xs',
)} )}
> >
{option.icon} {option.icon}

View File

@ -31,10 +31,10 @@ const COLOR_MAP = {
export default function Tag({ children, color = 'green', className = '', bordered = false, hideBg = false }: ITagProps) { export default function Tag({ children, color = 'green', className = '', bordered = false, hideBg = false }: ITagProps) {
return ( return (
<div className={ <div className={
classNames('px-2.5 py-px text-xs leading-5 rounded-md inline-flex items-center flex-shrink-0', classNames('inline-flex shrink-0 items-center rounded-md px-2.5 py-px text-xs leading-5',
COLOR_MAP[color] ? `${COLOR_MAP[color].text} ${COLOR_MAP[color].bg}` : '', COLOR_MAP[color] ? `${COLOR_MAP[color].text} ${COLOR_MAP[color].bg}` : '',
bordered ? 'border-[1px]' : '', bordered ? 'border-[1px]' : '',
hideBg ? 'bg-opacity-0' : '', hideBg ? 'bg-transparent' : '',
className)} > className)} >
{children} {children}
</div> </div>

View File

@ -71,14 +71,14 @@ const Pricing: FC<Props> = ({
{ {
value: 'cloud', value: 'cloud',
text: <div className={ text: <div className={
classNames('inline-flex items-center system-md-semibold-uppercase text-text-secondary', classNames('system-md-semibold-uppercase inline-flex items-center text-text-secondary',
currentPlan === 'cloud' && 'text-text-accent-light-mode-only')} > currentPlan === 'cloud' && 'text-text-accent-light-mode-only')} >
<RiCloudFill className='mr-2 size-4' />{t('billing.plansCommon.cloud')}</div>, <RiCloudFill className='mr-2 size-4' />{t('billing.plansCommon.cloud')}</div>,
}, },
{ {
value: 'self', value: 'self',
text: <div className={ text: <div className={
classNames('inline-flex items-center system-md-semibold-uppercase text-text-secondary', classNames('system-md-semibold-uppercase inline-flex items-center text-text-secondary',
currentPlan === 'self' && 'text-text-accent-light-mode-only')}> currentPlan === 'self' && 'text-text-accent-light-mode-only')}>
<RiTerminalBoxFill className='mr-2 size-4' />{t('billing.plansCommon.self')}</div>, <RiTerminalBoxFill className='mr-2 size-4' />{t('billing.plansCommon.self')}</div>,
}]} }]}

View File

@ -70,7 +70,7 @@ const style = {
priceTip: 'text-text-primary-on-surface', priceTip: 'text-text-primary-on-surface',
description: 'text-text-primary-on-surface', description: 'text-text-primary-on-surface',
bg: 'border-effects-highlight bg-[#155AEF] text-text-primary-on-surface', bg: 'border-effects-highlight bg-[#155AEF] text-text-primary-on-surface',
btnStyle: 'bg-white bg-opacity-96 hover:opacity-85 border-[0.5px] border-components-button-secondary-border text-[#155AEF] shadow-xs', btnStyle: 'bg-white/96 hover:opacity-85 border-[0.5px] border-components-button-secondary-border text-[#155AEF] shadow-xs',
values: 'text-text-primary-on-surface', values: 'text-text-primary-on-surface',
tooltipIconColor: 'text-text-primary-on-surface', tooltipIconColor: 'text-text-primary-on-surface',
}, },

View File

@ -17,15 +17,15 @@ export const StepperStep: FC<StepperStepProps> = (props) => {
const label = isActive ? `STEP ${index + 1}` : `${index + 1}` const label = isActive ? `STEP ${index + 1}` : `${index + 1}`
return <div className='flex items-center gap-2'> return <div className='flex items-center gap-2'>
<div className={classNames( <div className={classNames(
'h-5 py-1 rounded-3xl flex-col justify-center items-center gap-2 inline-flex', 'inline-flex h-5 flex-col items-center justify-center gap-2 rounded-3xl py-1',
isActive isActive
? 'px-2 bg-state-accent-solid' ? 'bg-state-accent-solid px-2'
: !isDisabled : !isDisabled
? 'w-5 border border-text-quaternary' ? 'w-5 border border-text-quaternary'
: 'w-5 border border-divider-deep', : 'w-5 border border-divider-deep',
)}> )}>
<div className={classNames( <div className={classNames(
'text-center system-2xs-semibold-uppercase', 'system-2xs-semibold-uppercase text-center',
isActive isActive
? 'text-text-primary-on-surface' ? 'text-text-primary-on-surface'
: !isDisabled : !isDisabled
@ -37,7 +37,7 @@ export const StepperStep: FC<StepperStepProps> = (props) => {
</div> </div>
<div className={classNames('system-xs-medium-uppercase', <div className={classNames('system-xs-medium-uppercase',
isActive isActive
? 'text-text-accent system-xs-semibold-uppercase' ? 'system-xs-semibold-uppercase text-text-accent'
: !isDisabled : !isDisabled
? 'text-text-tertiary' ? 'text-text-tertiary'
: 'text-text-quaternary', : 'text-text-quaternary',

View File

@ -20,10 +20,10 @@ const FullScreenDrawer: FC<IFullScreenDrawerProps> = ({
<Drawer <Drawer
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
panelClassName={classNames('!p-0 bg-components-panel-bg', panelClassName={classNames('bg-components-panel-bg !p-0',
fullScreen fullScreen
? '!max-w-full !w-full' ? '!w-full !max-w-full'
: 'mt-16 mr-2 mb-2 !max-w-[560px] !w-[560px] border-[0.5px] border-components-panel-border rounded-xl', : 'mb-2 mr-2 mt-16 !w-[560px] !max-w-[560px] rounded-xl border-[0.5px] border-components-panel-border',
)} )}
mask={false} mask={false}
unmount unmount

View File

@ -286,7 +286,7 @@ const EmbeddingDetail: FC<IEmbeddingDetailProps> = ({
{/* progress bar */} {/* progress bar */}
<div className={cn( <div className={cn(
'flex h-2 w-full items-center overflow-hidden rounded-md border border-components-progress-bar-border', 'flex h-2 w-full items-center overflow-hidden rounded-md border border-components-progress-bar-border',
isEmbedding ? 'bg-components-progress-bar-bg bg-opacity-50' : 'bg-components-progress-bar-bg', isEmbedding ? 'bg-components-progress-bar-bg/50' : 'bg-components-progress-bar-bg',
)}> )}>
<div <div
className={cn( className={cn(

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react' import React from 'react'
import type { FC } from 'react' import type { FC } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiBookOpenLine } from '@remixicon/react' import { RiBookOpenLine } from '@remixicon/react'
@ -28,10 +28,8 @@ const Form: FC<FormProps> = React.memo(({
}) => { }) => {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const docLink = useDocLink() const docLink = useDocLink()
const [changeKey, setChangeKey] = useState('')
const handleFormChange = (key: string, val: string) => { const handleFormChange = (key: string, val: string) => {
setChangeKey(key)
if (key === 'name') { if (key === 'name') {
onChange({ ...value, [key]: val }) onChange({ ...value, [key]: val })
} }

View File

@ -56,7 +56,7 @@ export const EditSlice: FC<EditSliceProps> = (props) => {
return ( return (
<> <>
<SliceContainer {...rest} <SliceContainer {...rest}
className={classNames('block mr-0', className)} className={classNames('mr-0 block', className)}
ref={(ref) => { ref={(ref) => {
refs.setReference(ref) refs.setReference(ref)
if (ref) if (ref)

View File

@ -13,7 +13,7 @@ export const SliceContainer: FC<SliceContainerProps> = (
) => { ) => {
const { className, ...rest } = props const { className, ...rest } = props
return <span {...rest} ref={ref} className={classNames( return <span {...rest} ref={ref} className={classNames(
'group align-bottom mr-1 select-none text-sm', 'group mr-1 select-none align-bottom text-sm',
className, className,
)} /> )} />
} }
@ -30,7 +30,7 @@ export const SliceLabel: FC<SliceLabelProps> = (
const { className, children, labelInnerClassName, ...rest } = props const { className, children, labelInnerClassName, ...rest } = props
return <span {...rest} ref={ref} className={classNames( return <span {...rest} ref={ref} className={classNames(
baseStyle, baseStyle,
'px-1 bg-state-base-hover-alt group-hover:bg-state-accent-solid group-hover:text-text-primary-on-surface uppercase text-text-tertiary', 'bg-state-base-hover-alt px-1 uppercase text-text-tertiary group-hover:bg-state-accent-solid group-hover:text-text-primary-on-surface',
className, className,
)}> )}>
<span className={classNames('text-nowrap', labelInnerClassName)}> <span className={classNames('text-nowrap', labelInnerClassName)}>
@ -51,7 +51,7 @@ export const SliceContent: FC<SliceContentProps> = (
const { className, children, ...rest } = props const { className, children, ...rest } = props
return <span {...rest} ref={ref} className={classNames( return <span {...rest} ref={ref} className={classNames(
baseStyle, baseStyle,
'px-1 bg-state-base-hover group-hover:bg-state-accent-hover-alt group-hover:text-text-primary leading-7 whitespace-pre-line break-all', 'whitespace-pre-line break-all bg-state-base-hover px-1 leading-7 group-hover:bg-state-accent-hover-alt group-hover:text-text-primary',
className, className,
)}> )}>
{children} {children}
@ -70,7 +70,7 @@ export const SliceDivider: FC<SliceDividerProps> = (
const { className, ...rest } = props const { className, ...rest } = props
return <span {...rest} ref={ref} className={classNames( return <span {...rest} ref={ref} className={classNames(
baseStyle, baseStyle,
'bg-state-base-active group-hover:bg-state-accent-solid text-sm px-[1px]', 'bg-state-base-active px-[1px] text-sm group-hover:bg-state-accent-solid',
className, className,
)}> )}>
{/* use a zero-width space to make the hover area bigger */} {/* use a zero-width space to make the hover area bigger */}

View File

@ -29,8 +29,8 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [name, setName] = useState<string>(dataset.name) const [name, setName] = useState<string>(dataset.name)
const [description, setDescription] = useState<string>(dataset.description) const [description, setDescription] = useState<string>(dataset.description)
const [externalKnowledgeId] = useState<string>(dataset.external_knowledge_info.external_knowledge_id) const externalKnowledgeId = dataset.external_knowledge_info.external_knowledge_id
const [externalKnowledgeApiId] = useState<string>(dataset.external_knowledge_info.external_knowledge_api_id) const externalKnowledgeApiId = dataset.external_knowledge_info.external_knowledge_api_id
const [appIcon, setAppIcon] = useState<AppIconSelection>( const [appIcon, setAppIcon] = useState<AppIconSelection>(
dataset.icon_info?.icon_type === 'image' dataset.icon_info?.icon_type === 'image'
? { type: 'image' as const, url: dataset.icon_info?.icon_url || '', fileId: dataset.icon_info?.icon || '' } ? { type: 'image' as const, url: dataset.icon_info?.icon_url || '', fileId: dataset.icon_info?.icon || '' }

View File

@ -66,10 +66,10 @@ function CopyButton({ code }: { code: string }) {
<button <button
type="button" type="button"
className={classNames( className={classNames(
'group/button absolute top-3.5 right-4 overflow-hidden rounded-full py-1 pl-2 pr-3 text-2xs font-medium opacity-0 backdrop-blur transition focus:opacity-100 group-hover:opacity-100', 'group/button absolute right-4 top-3.5 overflow-hidden rounded-full py-1 pl-2 pr-3 text-2xs font-medium opacity-0 backdrop-blur transition focus:opacity-100 group-hover:opacity-100',
copied copied
? 'bg-emerald-400/10 ring-1 ring-inset ring-emerald-400/20' ? 'bg-emerald-400/10 ring-1 ring-inset ring-emerald-400/20'
: 'bg-white/5 hover:bg-white/7.5 dark:bg-white/2.5 dark:hover:bg-white/5', : 'hover:bg-white/7.5 dark:bg-white/2.5 bg-white/5 dark:hover:bg-white/5',
)} )}
onClick={() => { onClick={() => {
writeTextToClipboard(code).then(() => { writeTextToClipboard(code).then(() => {

View File

@ -23,7 +23,9 @@ const SecretKeyGenerateModal = ({
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<Modal isShow={isShow} onClose={onClose} title={`${t('appApi.apiKeyModal.apiSecretKey')}`} className={`px-8 ${className}`}> <Modal isShow={isShow} onClose={onClose} title={`${t('appApi.apiKeyModal.apiSecretKey')}`} className={`px-8 ${className}`}>
<XMarkIcon className={`absolute h-6 w-6 cursor-pointer text-text-tertiary ${s.close}`} onClick={onClose} /> <div className="-mr-2 -mt-6 mb-4 flex justify-end">
<XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={onClose} />
</div>
<p className='mt-1 text-[13px] font-normal leading-5 text-text-tertiary'>{t('appApi.apiKeyModal.generateTips')}</p> <p className='mt-1 text-[13px] font-normal leading-5 text-text-tertiary'>{t('appApi.apiKeyModal.generateTips')}</p>
<div className='my-4'> <div className='my-4'>
<InputCopy className='w-full' value={newKey?.token} /> <InputCopy className='w-full' value={newKey?.token} />

View File

@ -84,7 +84,9 @@ const SecretKeyModal = ({
return ( return (
<Modal isShow={isShow} onClose={onClose} title={`${t('appApi.apiKeyModal.apiSecretKey')}`} className={`${s.customModal} flex flex-col px-8`}> <Modal isShow={isShow} onClose={onClose} title={`${t('appApi.apiKeyModal.apiSecretKey')}`} className={`${s.customModal} flex flex-col px-8`}>
<XMarkIcon className={`absolute h-6 w-6 cursor-pointer text-text-tertiary ${s.close}`} onClick={onClose} /> <div className="-mr-2 -mt-6 mb-4 flex justify-end">
<XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={onClose} />
</div>
<p className='mt-1 shrink-0 text-[13px] font-normal leading-5 text-text-tertiary'>{t('appApi.apiKeyModal.apiSecretKeyTips')}</p> <p className='mt-1 shrink-0 text-[13px] font-normal leading-5 text-text-tertiary'>{t('appApi.apiKeyModal.apiSecretKeyTips')}</p>
{!apiKeysList && <div className='mt-4'><Loading /></div>} {!apiKeysList && <div className='mt-4'><Loading /></div>}
{ {

View File

@ -277,6 +277,85 @@ The text generation application offers non-session support and is ideal for tran
</Row> </Row>
--- ---
<Heading
url='/files/:file_id/preview'
method='GET'
title='File Preview'
name='#file-preview'
/>
<Row>
<Col>
Preview or download uploaded files. This endpoint allows you to access files that have been previously uploaded via the File Upload API.
<i>Files can only be accessed if they belong to messages within the requesting application.</i>
### Path Parameters
- `file_id` (string) Required
The unique identifier of the file to preview, obtained from the File Upload API response.
### Query Parameters
- `as_attachment` (boolean) Optional
Whether to force download the file as an attachment. Default is `false` (preview in browser).
### Response
Returns the file content with appropriate headers for browser display or download.
- `Content-Type` Set based on file mime type
- `Content-Length` File size in bytes (if available)
- `Content-Disposition` Set to "attachment" if `as_attachment=true`
- `Cache-Control` Caching headers for performance
- `Accept-Ranges` Set to "bytes" for audio/video files
### Errors
- 400, `invalid_param`, abnormal parameter input
- 403, `file_access_denied`, file access denied or file does not belong to current application
- 404, `file_not_found`, file not found or has been deleted
- 500, internal server error
</Col>
<Col sticky>
### Request Example
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
--header 'Authorization: Bearer {api_key}'
```
</CodeGroup>
### Download as Attachment
<CodeGroup title="Download Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
--header 'Authorization: Bearer {api_key}' \
--output downloaded_file.png
```
</CodeGroup>
### Response Headers Example
<CodeGroup title="Response Headers">
```http {{ title: 'Headers - Image Preview' }}
Content-Type: image/png
Content-Length: 1024
Cache-Control: public, max-age=3600
```
</CodeGroup>
### Download Response Headers
<CodeGroup title="Download Response Headers">
```http {{ title: 'Headers - File Download' }}
Content-Type: image/png
Content-Length: 1024
Content-Disposition: attachment; filename*=UTF-8''example.png
Cache-Control: public, max-age=3600
```
</CodeGroup>
</Col>
</Row>
---
<Heading <Heading
url='/completion-messages/:task_id/stop' url='/completion-messages/:task_id/stop'
method='POST' method='POST'

View File

@ -276,6 +276,85 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
</Row> </Row>
--- ---
<Heading
url='/files/:file_id/preview'
method='GET'
title='ファイルプレビュー'
name='#file-preview'
/>
<Row>
<Col>
アップロードされたファイルをプレビューまたはダウンロードします。このエンドポイントを使用すると、以前にファイルアップロード API でアップロードされたファイルにアクセスできます。
<i>ファイルは、リクエストしているアプリケーションのメッセージ範囲内にある場合のみアクセス可能です。</i>
### パスパラメータ
- `file_id` (string) 必須
プレビューするファイルの一意識別子。ファイルアップロード API レスポンスから取得します。
### クエリパラメータ
- `as_attachment` (boolean) オプション
ファイルを添付ファイルとして強制ダウンロードするかどうか。デフォルトは `false`(ブラウザでプレビュー)。
### レスポンス
ブラウザ表示またはダウンロード用の適切なヘッダー付きでファイル内容を返します。
- `Content-Type` ファイル MIME タイプに基づいて設定
- `Content-Length` ファイルサイズ(バイト、利用可能な場合)
- `Content-Disposition` `as_attachment=true` の場合は "attachment" に設定
- `Cache-Control` パフォーマンス向上のためのキャッシュヘッダー
- `Accept-Ranges` 音声/動画ファイルの場合は "bytes" に設定
### エラー
- 400, `invalid_param`, パラメータ入力異常
- 403, `file_access_denied`, ファイルアクセス拒否またはファイルが現在のアプリケーションに属していません
- 404, `file_not_found`, ファイルが見つからないか削除されています
- 500, サーバー内部エラー
</Col>
<Col sticky>
### リクエスト例
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
--header 'Authorization: Bearer {api_key}'
```
</CodeGroup>
### 添付ファイルとしてダウンロード
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
--header 'Authorization: Bearer {api_key}' \
--output downloaded_file.png
```
</CodeGroup>
### レスポンスヘッダー例
<CodeGroup title="Response Headers">
```http {{ title: 'ヘッダー - 画像プレビュー' }}
Content-Type: image/png
Content-Length: 1024
Cache-Control: public, max-age=3600
```
</CodeGroup>
### ファイルダウンロードレスポンスヘッダー
<CodeGroup title="Response Headers">
```http {{ title: 'ヘッダー - ファイルダウンロード' }}
Content-Type: image/png
Content-Length: 1024
Content-Disposition: attachment; filename*=UTF-8''example.png
Cache-Control: public, max-age=3600
```
</CodeGroup>
</Col>
</Row>
---
<Heading <Heading
url='/completion-messages/:task_id/stop' url='/completion-messages/:task_id/stop'
method='POST' method='POST'

View File

@ -252,6 +252,86 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
</Col> </Col>
</Row> </Row>
--- ---
<Heading
url='/files/:file_id/preview'
method='GET'
title='文件预览'
name='#file-preview'
/>
<Row>
<Col>
预览或下载已上传的文件。此端点允许您访问先前通过文件上传 API 上传的文件。
<i>文件只能在属于请求应用程序的消息范围内访问。</i>
### 路径参数
- `file_id` (string) 必需
要预览的文件的唯一标识符,从文件上传 API 响应中获得。
### 查询参数
- `as_attachment` (boolean) 可选
是否强制将文件作为附件下载。默认为 `false`(在浏览器中预览)。
### 响应
返回带有适当浏览器显示或下载标头的文件内容。
- `Content-Type` 根据文件 MIME 类型设置
- `Content-Length` 文件大小(以字节为单位,如果可用)
- `Content-Disposition` 如果 `as_attachment=true` 则设置为 "attachment"
- `Cache-Control` 用于性能的缓存标头
- `Accept-Ranges` 对于音频/视频文件设置为 "bytes"
### 错误
- 400, `invalid_param`, 参数输入异常
- 403, `file_access_denied`, 文件访问被拒绝或文件不属于当前应用程序
- 404, `file_not_found`, 文件未找到或已被删除
- 500, 服务内部错误
</Col>
<Col sticky>
### 请求示例
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
--header 'Authorization: Bearer {api_key}'
```
</CodeGroup>
### 作为附件下载
<CodeGroup title="下载请求" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
--header 'Authorization: Bearer {api_key}' \
--output downloaded_file.png
```
</CodeGroup>
### 响应标头示例
<CodeGroup title="Response Headers">
```http {{ title: 'Headers - 图片预览' }}
Content-Type: image/png
Content-Length: 1024
Cache-Control: public, max-age=3600
```
</CodeGroup>
### 文件下载响应标头
<CodeGroup title="Download Response Headers">
```http {{ title: 'Headers - 文件下载' }}
Content-Type: image/png
Content-Length: 1024
Content-Disposition: attachment; filename*=UTF-8''example.png
Cache-Control: public, max-age=3600
```
</CodeGroup>
</Col>
</Row>
---
<Heading <Heading
url='/completion-messages/:task_id/stop' url='/completion-messages/:task_id/stop'
method='POST' method='POST'

View File

@ -392,6 +392,85 @@ Chat applications support session persistence, allowing previous chat history to
</Row> </Row>
--- ---
<Heading
url='/files/:file_id/preview'
method='GET'
title='File Preview'
name='#file-preview'
/>
<Row>
<Col>
Preview or download uploaded files. This endpoint allows you to access files that have been previously uploaded via the File Upload API.
<i>Files can only be accessed if they belong to messages within the requesting application.</i>
### Path Parameters
- `file_id` (string) Required
The unique identifier of the file to preview, obtained from the File Upload API response.
### Query Parameters
- `as_attachment` (boolean) Optional
Whether to force download the file as an attachment. Default is `false` (preview in browser).
### Response
Returns the file content with appropriate headers for browser display or download.
- `Content-Type` Set based on file mime type
- `Content-Length` File size in bytes (if available)
- `Content-Disposition` Set to "attachment" if `as_attachment=true`
- `Cache-Control` Caching headers for performance
- `Accept-Ranges` Set to "bytes" for audio/video files
### Errors
- 400, `invalid_param`, abnormal parameter input
- 403, `file_access_denied`, file access denied or file does not belong to current application
- 404, `file_not_found`, file not found or has been deleted
- 500, internal server error
</Col>
<Col sticky>
### Request Example
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
--header 'Authorization: Bearer {api_key}'
```
</CodeGroup>
### Download as Attachment
<CodeGroup title="Download Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
--header 'Authorization: Bearer {api_key}' \
--output downloaded_file.png
```
</CodeGroup>
### Response Headers Example
<CodeGroup title="Response Headers">
```http {{ title: 'Headers - Image Preview' }}
Content-Type: image/png
Content-Length: 1024
Cache-Control: public, max-age=3600
```
</CodeGroup>
### Download Response Headers
<CodeGroup title="Download Response Headers">
```http {{ title: 'Headers - File Download' }}
Content-Type: image/png
Content-Length: 1024
Content-Disposition: attachment; filename*=UTF-8''example.png
Cache-Control: public, max-age=3600
```
</CodeGroup>
</Col>
</Row>
---
<Heading <Heading
url='/chat-messages/:task_id/stop' url='/chat-messages/:task_id/stop'
method='POST' method='POST'
@ -653,7 +732,7 @@ Chat applications support session persistence, allowing previous chat history to
- `message_files` (array[object]) Message files - `message_files` (array[object]) Message files
- `id` (string) ID - `id` (string) ID
- `type` (string) File type, image for images - `type` (string) File type, image for images
- `url` (string) Preview image URL - `url` (string) File preview URL, use the File Preview API (`/files/{file_id}/preview`) to access the file
- `belongs_to` (string) belongs touser orassistant - `belongs_to` (string) belongs touser orassistant
- `answer` (string) Response message content - `answer` (string) Response message content
- `created_at` (timestamp) Creation timestamp, e.g., 1705395332 - `created_at` (timestamp) Creation timestamp, e.g., 1705395332

View File

@ -392,6 +392,86 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
</Row> </Row>
--- ---
<Heading
url='/files/:file_id/preview'
method='GET'
title='ファイルプレビュー'
name='#file-preview'
/>
<Row>
<Col>
アップロードされたファイルをプレビューまたはダウンロードします。このエンドポイントを使用すると、以前にファイルアップロード API でアップロードされたファイルにアクセスできます。
<i>ファイルは、リクエストしているアプリケーションのメッセージ範囲内にある場合のみアクセス可能です。</i>
### パスパラメータ
- `file_id` (string) 必須
プレビューするファイルの一意識別子。ファイルアップロード API レスポンスから取得します。
### クエリパラメータ
- `as_attachment` (boolean) オプション
ファイルを添付ファイルとして強制ダウンロードするかどうか。デフォルトは `false`(ブラウザでプレビュー)。
### レスポンス
ブラウザ表示またはダウンロード用の適切なヘッダー付きでファイル内容を返します。
- `Content-Type` ファイル MIME タイプに基づいて設定
- `Content-Length` ファイルサイズ(バイト、利用可能な場合)
- `Content-Disposition` `as_attachment=true` の場合は "attachment" に設定
- `Cache-Control` パフォーマンス向上のためのキャッシュヘッダー
- `Accept-Ranges` 音声/動画ファイルの場合は "bytes" に設定
### エラー
- 400, `invalid_param`, パラメータ入力異常
- 403, `file_access_denied`, ファイルアクセス拒否またはファイルが現在のアプリケーションに属していません
- 404, `file_not_found`, ファイルが見つからないか削除されています
- 500, サーバー内部エラー
</Col>
<Col sticky>
### リクエスト例
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
```bash {{ title: 'cURL - ブラウザプレビュー' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
--header 'Authorization: Bearer {api_key}'
```
</CodeGroup>
### 添付ファイルとしてダウンロード
<CodeGroup title="Download Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
--header 'Authorization: Bearer {api_key}' \
--output downloaded_file.png
```
</CodeGroup>
### レスポンスヘッダー例
<CodeGroup title="Response Headers">
```http {{ title: 'ヘッダー - 画像プレビュー' }}
Content-Type: image/png
Content-Length: 1024
Cache-Control: public, max-age=3600
```
</CodeGroup>
### ダウンロードレスポンスヘッダー
<CodeGroup title="Download Response Headers">
```http {{ title: 'ヘッダー - ファイルダウンロード' }}
Content-Type: image/png
Content-Length: 1024
Content-Disposition: attachment; filename*=UTF-8''example.png
Cache-Control: public, max-age=3600
```
</CodeGroup>
</Col>
</Row>
---
<Heading <Heading
url='/chat-messages/:task_id/stop' url='/chat-messages/:task_id/stop'
method='POST' method='POST'
@ -654,7 +734,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
- `message_files` (array[object]) メッセージファイル - `message_files` (array[object]) メッセージファイル
- `id` (string) ID - `id` (string) ID
- `type` (string) ファイルタイプ、画像の場合はimage - `type` (string) ファイルタイプ、画像の場合はimage
- `url` (string) プレビュー画像URL - `url` (string) ファイルプレビューURL、ファイルアクセスにはファイルプレビューAPI`/files/{file_id}/preview`)を使用してください
- `belongs_to` (string) 所属、userまたはassistant - `belongs_to` (string) 所属、userまたはassistant
- `answer` (string) 応答メッセージ内容 - `answer` (string) 応答メッセージ内容
- `created_at` (timestamp) 作成タイムスタンプ、例1705395332 - `created_at` (timestamp) 作成タイムスタンプ、例1705395332

View File

@ -399,6 +399,86 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
</Col> </Col>
</Row> </Row>
--- ---
<Heading
url='/files/:file_id/preview'
method='GET'
title='文件预览'
name='#file-preview'
/>
<Row>
<Col>
预览或下载已上传的文件。此端点允许您访问先前通过文件上传 API 上传的文件。
<i>文件只能在属于请求应用程序的消息范围内访问。</i>
### 路径参数
- `file_id` (string) 必需
要预览的文件的唯一标识符,从文件上传 API 响应中获得。
### 查询参数
- `as_attachment` (boolean) 可选
是否强制将文件作为附件下载。默认为 `false`(在浏览器中预览)。
### 响应
返回带有适当浏览器显示或下载标头的文件内容。
- `Content-Type` 根据文件 MIME 类型设置
- `Content-Length` 文件大小(以字节为单位,如果可用)
- `Content-Disposition` 如果 `as_attachment=true` 则设置为 "attachment"
- `Cache-Control` 用于性能的缓存标头
- `Accept-Ranges` 对于音频/视频文件设置为 "bytes"
### 错误
- 400, `invalid_param`, 参数输入异常
- 403, `file_access_denied`, 文件访问被拒绝或文件不属于当前应用程序
- 404, `file_not_found`, 文件未找到或已被删除
- 500, 服务内部错误
</Col>
<Col sticky>
### 请求示例
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
--header 'Authorization: Bearer {api_key}'
```
</CodeGroup>
### 作为附件下载
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
--header 'Authorization: Bearer {api_key}' \
--output downloaded_file.png
```
</CodeGroup>
### 响应标头示例
<CodeGroup title="Response Headers">
```http {{ title: 'Headers - 图片预览' }}
Content-Type: image/png
Content-Length: 1024
Cache-Control: public, max-age=3600
```
</CodeGroup>
### 文件下载响应标头
<CodeGroup title="Download Response Headers">
```http {{ title: 'Headers - 文件下载' }}
Content-Type: image/png
Content-Length: 1024
Content-Disposition: attachment; filename*=UTF-8''example.png
Cache-Control: public, max-age=3600
```
</CodeGroup>
</Col>
</Row>
---
<Heading <Heading
url='/chat-messages/:task_id/stop' url='/chat-messages/:task_id/stop'
method='POST' method='POST'
@ -661,7 +741,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
- `message_files` (array[object]) 消息文件 - `message_files` (array[object]) 消息文件
- `id` (string) ID - `id` (string) ID
- `type` (string) 文件类型image 图片 - `type` (string) 文件类型image 图片
- `url` (string) 预览图片地址 - `url` (string) 文件预览地址,使用文件预览 API (`/files/{file_id}/preview`) 访问文件
- `belongs_to` (string) 文件归属方user 或 assistant - `belongs_to` (string) 文件归属方user 或 assistant
- `answer` (string) 回答消息内容 - `answer` (string) 回答消息内容
- `created_at` (timestamp) 创建时间 - `created_at` (timestamp) 创建时间

View File

@ -356,6 +356,85 @@ Chat applications support session persistence, allowing previous chat history to
</Row> </Row>
--- ---
<Heading
url='/files/:file_id/preview'
method='GET'
title='File Preview'
name='#file-preview'
/>
<Row>
<Col>
Preview or download uploaded files. This endpoint allows you to access files that have been previously uploaded via the File Upload API.
<i>Files can only be accessed if they belong to messages within the requesting application.</i>
### Path Parameters
- `file_id` (string) Required
The unique identifier of the file to preview, obtained from the File Upload API response.
### Query Parameters
- `as_attachment` (boolean) Optional
Whether to force download the file as an attachment. Default is `false` (preview in browser).
### Response
Returns the file content with appropriate headers for browser display or download.
- `Content-Type` Set based on file mime type
- `Content-Length` File size in bytes (if available)
- `Content-Disposition` Set to "attachment" if `as_attachment=true`
- `Cache-Control` Caching headers for performance
- `Accept-Ranges` Set to "bytes" for audio/video files
### Errors
- 400, `invalid_param`, abnormal parameter input
- 403, `file_access_denied`, file access denied or file does not belong to current application
- 404, `file_not_found`, file not found or has been deleted
- 500, internal server error
</Col>
<Col sticky>
### Request Example
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
--header 'Authorization: Bearer {api_key}'
```
</CodeGroup>
### Download as Attachment
<CodeGroup title="Download Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
--header 'Authorization: Bearer {api_key}' \
--output downloaded_file.png
```
</CodeGroup>
### Response Headers Example
<CodeGroup title="Response Headers">
```http {{ title: 'Headers - Image Preview' }}
Content-Type: image/png
Content-Length: 1024
Cache-Control: public, max-age=3600
```
</CodeGroup>
### Download Response Headers
<CodeGroup title="Download Response Headers">
```http {{ title: 'Headers - File Download' }}
Content-Type: image/png
Content-Length: 1024
Content-Disposition: attachment; filename*=UTF-8''example.png
Cache-Control: public, max-age=3600
```
</CodeGroup>
</Col>
</Row>
---
<Heading <Heading
url='/chat-messages/:task_id/stop' url='/chat-messages/:task_id/stop'
method='POST' method='POST'
@ -617,7 +696,7 @@ Chat applications support session persistence, allowing previous chat history to
- `message_files` (array[object]) Message files - `message_files` (array[object]) Message files
- `id` (string) ID - `id` (string) ID
- `type` (string) File type, image for images - `type` (string) File type, image for images
- `url` (string) Preview image URL - `url` (string) File preview URL, use the File Preview API (`/files/{file_id}/preview`) to access the file
- `belongs_to` (string) belongs touser or assistant - `belongs_to` (string) belongs touser or assistant
- `agent_thoughts` (array[object]) Agent thoughtEmpty if it's a Basic Assistant - `agent_thoughts` (array[object]) Agent thoughtEmpty if it's a Basic Assistant
- `id` (string) Agent thought ID, every iteration has a unique agent thought ID - `id` (string) Agent thought ID, every iteration has a unique agent thought ID

View File

@ -356,6 +356,85 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
</Row> </Row>
--- ---
<Heading
url='/files/:file_id/preview'
method='GET'
title='ファイルプレビュー'
name='#file-preview'
/>
<Row>
<Col>
アップロードされたファイルをプレビューまたはダウンロードします。このエンドポイントを使用すると、以前にファイルアップロード API でアップロードされたファイルにアクセスできます。
<i>ファイルは、リクエストしているアプリケーションのメッセージ範囲内にある場合のみアクセス可能です。</i>
### パスパラメータ
- `file_id` (string) 必須
プレビューするファイルの一意識別子。ファイルアップロード API レスポンスから取得します。
### クエリパラメータ
- `as_attachment` (boolean) オプション
ファイルを添付ファイルとして強制ダウンロードするかどうか。デフォルトは `false`(ブラウザでプレビュー)。
### レスポンス
ブラウザ表示またはダウンロード用の適切なヘッダー付きでファイル内容を返します。
- `Content-Type` ファイル MIME タイプに基づいて設定
- `Content-Length` ファイルサイズ(バイト、利用可能な場合)
- `Content-Disposition` `as_attachment=true` の場合は "attachment" に設定
- `Cache-Control` パフォーマンス向上のためのキャッシュヘッダー
- `Accept-Ranges` 音声/動画ファイルの場合は "bytes" に設定
### エラー
- 400, `invalid_param`, パラメータ入力異常
- 403, `file_access_denied`, ファイルアクセス拒否またはファイルが現在のアプリケーションに属していません
- 404, `file_not_found`, ファイルが見つからないか削除されています
- 500, サーバー内部エラー
</Col>
<Col sticky>
### リクエスト例
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
--header 'Authorization: Bearer {api_key}'
```
</CodeGroup>
### 添付ファイルとしてダウンロード
<CodeGroup title="Download Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
--header 'Authorization: Bearer {api_key}' \
--output downloaded_file.png
```
</CodeGroup>
### レスポンスヘッダー例
<CodeGroup title="Response Headers">
```http {{ title: 'Headers - 画像プレビュー' }}
Content-Type: image/png
Content-Length: 1024
Cache-Control: public, max-age=3600
```
</CodeGroup>
### ダウンロードレスポンスヘッダー
<CodeGroup title="Download Response Headers">
```http {{ title: 'Headers - ファイルダウンロード' }}
Content-Type: image/png
Content-Length: 1024
Content-Disposition: attachment; filename*=UTF-8''example.png
Cache-Control: public, max-age=3600
```
</CodeGroup>
</Col>
</Row>
---
<Heading <Heading
url='/chat-messages/:task_id/stop' url='/chat-messages/:task_id/stop'
method='POST' method='POST'
@ -618,7 +697,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
- `message_files` (array[object]) メッセージファイル - `message_files` (array[object]) メッセージファイル
- `id` (string) ID - `id` (string) ID
- `type` (string) ファイルタイプ、画像の場合はimage - `type` (string) ファイルタイプ、画像の場合はimage
- `url` (string) プレビュー画像URL - `url` (string) ファイルプレビューURL、ファイルアクセスにはファイルプレビューAPI`/files/{file_id}/preview`)を使用してください
- `belongs_to` (string) 所属、ユーザーまたはアシスタント - `belongs_to` (string) 所属、ユーザーまたはアシスタント
- `agent_thoughts` (array[object]) エージェントの思考(基本アシスタントの場合は空) - `agent_thoughts` (array[object]) エージェントの思考(基本アシスタントの場合は空)
- `id` (string) エージェント思考ID、各反復には一意のエージェント思考IDがあります - `id` (string) エージェント思考ID、各反復には一意のエージェント思考IDがあります

View File

@ -371,6 +371,86 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
</Col> </Col>
</Row> </Row>
--- ---
<Heading
url='/files/:file_id/preview'
method='GET'
title='文件预览'
name='#file-preview'
/>
<Row>
<Col>
预览或下载已上传的文件。此端点允许您访问先前通过文件上传 API 上传的文件。
<i>文件只能在属于请求应用程序的消息范围内访问。</i>
### 路径参数
- `file_id` (string) 必需
要预览的文件的唯一标识符,从文件上传 API 响应中获得。
### 查询参数
- `as_attachment` (boolean) 可选
是否强制将文件作为附件下载。默认为 `false`(在浏览器中预览)。
### 响应
返回带有适当浏览器显示或下载标头的文件内容。
- `Content-Type` 根据文件 MIME 类型设置
- `Content-Length` 文件大小(以字节为单位,如果可用)
- `Content-Disposition` 如果 `as_attachment=true` 则设置为 "attachment"
- `Cache-Control` 用于性能的缓存标头
- `Accept-Ranges` 对于音频/视频文件设置为 "bytes"
### 错误
- 400, `invalid_param`, 参数输入异常
- 403, `file_access_denied`, 文件访问被拒绝或文件不属于当前应用程序
- 404, `file_not_found`, 文件未找到或已被删除
- 500, 服务内部错误
</Col>
<Col sticky>
### 请求示例
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
--header 'Authorization: Bearer {api_key}'
```
</CodeGroup>
### 作为附件下载
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\\\\n--header 'Authorization: Bearer {api_key}' \\\\\\n--output downloaded_file.png`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
--header 'Authorization: Bearer {api_key}' \
--output downloaded_file.png
```
</CodeGroup>
### 响应标头示例
<CodeGroup title="Response Headers">
```http {{ title: 'Headers - 图片预览' }}
Content-Type: image/png
Content-Length: 1024
Cache-Control: public, max-age=3600
```
</CodeGroup>
### 文件下载响应标头
<CodeGroup title="Download Response Headers">
```http {{ title: 'Headers - 文件下载' }}
Content-Type: image/png
Content-Length: 1024
Content-Disposition: attachment; filename*=UTF-8''example.png
Cache-Control: public, max-age=3600
```
</CodeGroup>
</Col>
</Row>
---
<Heading <Heading
url='/chat-messages/:task_id/stop' url='/chat-messages/:task_id/stop'
method='POST' method='POST'
@ -631,7 +711,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
- `message_files` (array[object]) 消息文件 - `message_files` (array[object]) 消息文件
- `id` (string) ID - `id` (string) ID
- `type` (string) 文件类型image 图片 - `type` (string) 文件类型image 图片
- `url` (string) 预览图片地址 - `url` (string) 文件预览地址,使用文件预览 API (`/files/{file_id}/preview`) 访问文件
- `belongs_to` (string) 文件归属方user 或 assistant - `belongs_to` (string) 文件归属方user 或 assistant
- `agent_thoughts` (array[object]) Agent思考内容仅Agent模式下不为空 - `agent_thoughts` (array[object]) Agent思考内容仅Agent模式下不为空
- `id` (string) agent_thought ID每一轮Agent迭代都会有一个唯一的id - `id` (string) agent_thought ID每一轮Agent迭代都会有一个唯一的id

View File

@ -747,6 +747,86 @@ Workflow applications offers non-session support and is ideal for translation, a
--- ---
<Heading
url='/files/:file_id/preview'
method='GET'
title='File Preview'
name='#file-preview'
/>
<Row>
<Col>
Preview or download uploaded files. This endpoint allows you to access files that have been previously uploaded via the File Upload API.
<i>Files can only be accessed if they belong to messages within the requesting application.</i>
### Path Parameters
- `file_id` (string) Required
The unique identifier of the file to preview, obtained from the File Upload API response.
### Query Parameters
- `as_attachment` (boolean) Optional
Whether to force download the file as an attachment. Default is `false` (preview in browser).
### Response
Returns the file content with appropriate headers for browser display or download.
- `Content-Type` Set based on file mime type
- `Content-Length` File size in bytes (if available)
- `Content-Disposition` Set to "attachment" if `as_attachment=true`
- `Cache-Control` Caching headers for performance
- `Accept-Ranges` Set to "bytes" for audio/video files
### Errors
- 400, `invalid_param`, abnormal parameter input
- 403, `file_access_denied`, file access denied or file does not belong to current application
- 404, `file_not_found`, file not found or has been deleted
- 500, internal server error
</Col>
<Col sticky>
### Request Example
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
--header 'Authorization: Bearer {api_key}'
```
</CodeGroup>
### Download as Attachment
<CodeGroup title="Download Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
--header 'Authorization: Bearer {api_key}' \
--output downloaded_file.png
```
</CodeGroup>
### Response Headers Example
<CodeGroup title="Response Headers">
```http {{ title: 'Headers - Image Preview' }}
Content-Type: image/png
Content-Length: 1024
Cache-Control: public, max-age=3600
```
</CodeGroup>
### Download Response Headers
<CodeGroup title="Download Response Headers">
```http {{ title: 'Headers - File Download' }}
Content-Type: image/png
Content-Length: 1024
Content-Disposition: attachment; filename*=UTF-8''example.png
Cache-Control: public, max-age=3600
```
</CodeGroup>
</Col>
</Row>
---
<Heading <Heading
url='/workflows/logs' url='/workflows/logs'
method='GET' method='GET'

View File

@ -742,6 +742,86 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
--- ---
<Heading
url='/files/:file_id/preview'
method='GET'
title='ファイルプレビュー'
name='#file-preview'
/>
<Row>
<Col>
アップロードされたファイルをプレビューまたはダウンロードします。このエンドポイントを使用すると、以前にファイルアップロード API でアップロードされたファイルにアクセスできます。
<i>ファイルは、リクエストしているアプリケーションのメッセージ範囲内にある場合のみアクセス可能です。</i>
### パスパラメータ
- `file_id` (string) 必須
プレビューするファイルの一意識別子。ファイルアップロード API レスポンスから取得します。
### クエリパラメータ
- `as_attachment` (boolean) オプション
ファイルを添付ファイルとして強制ダウンロードするかどうか。デフォルトは `false`(ブラウザでプレビュー)。
### レスポンス
ブラウザ表示またはダウンロード用の適切なヘッダー付きでファイル内容を返します。
- `Content-Type` ファイル MIME タイプに基づいて設定
- `Content-Length` ファイルサイズ(バイト、利用可能な場合)
- `Content-Disposition` `as_attachment=true` の場合は "attachment" に設定
- `Cache-Control` パフォーマンス向上のためのキャッシュヘッダー
- `Accept-Ranges` 音声/動画ファイルの場合は "bytes" に設定
### エラー
- 400, `invalid_param`, パラメータ入力異常
- 403, `file_access_denied`, ファイルアクセス拒否またはファイルが現在のアプリケーションに属していません
- 404, `file_not_found`, ファイルが見つからないか削除されています
- 500, サーバー内部エラー
</Col>
<Col sticky>
### リクエスト例
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
```bash {{ title: 'cURL - ブラウザプレビュー' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
--header 'Authorization: Bearer {api_key}'
```
</CodeGroup>
### 添付ファイルとしてダウンロード
<CodeGroup title="Download Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
--header 'Authorization: Bearer {api_key}' \
--output downloaded_file.png
```
</CodeGroup>
### レスポンスヘッダー例
<CodeGroup title="Response Headers">
```http {{ title: 'ヘッダー - 画像プレビュー' }}
Content-Type: image/png
Content-Length: 1024
Cache-Control: public, max-age=3600
```
</CodeGroup>
### ダウンロードレスポンスヘッダー
<CodeGroup title="Download Response Headers">
```http {{ title: 'ヘッダー - ファイルダウンロード' }}
Content-Type: image/png
Content-Length: 1024
Content-Disposition: attachment; filename*=UTF-8''example.png
Cache-Control: public, max-age=3600
```
</CodeGroup>
</Col>
</Row>
---
<Heading <Heading
url='/workflows/logs' url='/workflows/logs'
method='GET' method='GET'

View File

@ -730,6 +730,85 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
</Row> </Row>
--- ---
<Heading
url='/files/:file_id/preview'
method='GET'
title='文件预览'
name='#file-preview'
/>
<Row>
<Col>
预览或下载已上传的文件。此端点允许您访问先前通过文件上传 API 上传的文件。
<i>文件只能在属于请求应用程序的消息范围内访问。</i>
### 路径参数
- `file_id` (string) 必需
要预览的文件的唯一标识符,从文件上传 API 响应中获得。
### 查询参数
- `as_attachment` (boolean) 可选
是否强制将文件作为附件下载。默认为 `false`(在浏览器中预览)。
### 响应
返回带有适当浏览器显示或下载标头的文件内容。
- `Content-Type` 根据文件 MIME 类型设置
- `Content-Length` 文件大小(以字节为单位,如果可用)
- `Content-Disposition` 如果 `as_attachment=true` 则设置为 "attachment"
- `Cache-Control` 用于性能的缓存标头
- `Accept-Ranges` 对于音频/视频文件设置为 "bytes"
### 错误
- 400, `invalid_param`, 参数输入异常
- 403, `file_access_denied`, 文件访问被拒绝或文件不属于当前应用程序
- 404, `file_not_found`, 文件未找到或已被删除
- 500, 服务内部错误
</Col>
<Col sticky>
### 请求示例
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
--header 'Authorization: Bearer {api_key}'
```
</CodeGroup>
### 作为附件下载
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\\\\n--header 'Authorization: Bearer {api_key}' \\\\\\n--output downloaded_file.png`}>
```bash {{ title: 'cURL' }}
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
--header 'Authorization: Bearer {api_key}' \
--output downloaded_file.png
```
</CodeGroup>
### 响应标头示例
<CodeGroup title="Response Headers">
```http {{ title: 'Headers - 图片预览' }}
Content-Type: image/png
Content-Length: 1024
Cache-Control: public, max-age=3600
```
</CodeGroup>
### 文件下载响应标头
<CodeGroup title="Download Response Headers">
```http {{ title: 'Headers - 文件下载' }}
Content-Type: image/png
Content-Length: 1024
Content-Disposition: attachment; filename*=UTF-8''example.png
Cache-Control: public, max-age=3600
```
</CodeGroup>
</Col>
</Row>
---
<Heading <Heading
url='/workflows/logs' url='/workflows/logs'
method='GET' method='GET'

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