| @@ -1,9 +1,10 @@ | |||
| name: Check i18n Files and Create PR | |||
| on: | |||
| pull_request: | |||
| types: [closed] | |||
| push: | |||
| branches: [main] | |||
| paths: | |||
| - 'web/i18n/en-US/*.ts' | |||
| permissions: | |||
| contents: write | |||
| @@ -11,7 +12,7 @@ permissions: | |||
| jobs: | |||
| check-and-update: | |||
| if: github.event.pull_request.merged == true | |||
| if: github.repository == 'langgenius/dify' | |||
| runs-on: ubuntu-latest | |||
| defaults: | |||
| run: | |||
| @@ -19,7 +20,7 @@ jobs: | |||
| steps: | |||
| - uses: actions/checkout@v4 | |||
| with: | |||
| fetch-depth: 2 # last 2 commits | |||
| fetch-depth: 2 | |||
| token: ${{ secrets.GITHUB_TOKEN }} | |||
| - name: Check for file changes in i18n/en-US | |||
| @@ -31,6 +32,13 @@ jobs: | |||
| echo "Changed files: $changed_files" | |||
| if [ -n "$changed_files" ]; then | |||
| 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 | |||
| echo "FILES_CHANGED=false" >> $GITHUB_ENV | |||
| fi | |||
| @@ -55,7 +63,7 @@ jobs: | |||
| - name: Generate i18n translations | |||
| if: env.FILES_CHANGED == 'true' | |||
| run: pnpm run auto-gen-i18n | |||
| run: pnpm run auto-gen-i18n ${{ env.FILE_ARGS }} | |||
| - name: Create Pull Request | |||
| if: env.FILES_CHANGED == 'true' | |||
| @@ -215,10 +215,4 @@ mise.toml | |||
| # AI Assistant | |||
| .roo/ | |||
| api/.env.backup | |||
| # Clickzetta test credentials | |||
| .env.clickzetta | |||
| .env.clickzetta.test | |||
| # Clickzetta plugin development folder (keep local, ignore for PR) | |||
| clickzetta/ | |||
| /clickzetta | |||
| @@ -19,7 +19,7 @@ RUN apt-get update \ | |||
| # Install Python dependencies | |||
| COPY pyproject.toml uv.lock ./ | |||
| RUN uv sync --locked | |||
| RUN uv sync --locked --no-dev | |||
| # production stage | |||
| FROM base AS production | |||
| @@ -9,7 +9,7 @@ import sqlalchemy as sa | |||
| from flask import current_app | |||
| from pydantic import TypeAdapter | |||
| from sqlalchemy import select | |||
| from werkzeug.exceptions import NotFound | |||
| from sqlalchemy.exc import SQLAlchemyError | |||
| from configs import dify_config | |||
| from constants.languages import languages | |||
| @@ -186,8 +186,8 @@ def migrate_annotation_vector_database(): | |||
| ) | |||
| if not apps: | |||
| break | |||
| except NotFound: | |||
| break | |||
| except SQLAlchemyError: | |||
| raise | |||
| page += 1 | |||
| 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) | |||
| except NotFound: | |||
| break | |||
| except SQLAlchemyError: | |||
| raise | |||
| page += 1 | |||
| for dataset in datasets: | |||
| @@ -566,8 +566,8 @@ def old_metadata_migration(): | |||
| .order_by(DatasetDocument.created_at.desc()) | |||
| ) | |||
| documents = db.paginate(select=stmt, page=page, per_page=50, max_per_page=50, error_out=False) | |||
| except NotFound: | |||
| break | |||
| except SQLAlchemyError: | |||
| raise | |||
| if not documents: | |||
| break | |||
| for document in documents: | |||
| @@ -330,17 +330,17 @@ class HttpConfig(BaseSettings): | |||
| def WEB_API_CORS_ALLOW_ORIGINS(self) -> list[str]: | |||
| return self.inner_WEB_API_CORS_ALLOW_ORIGINS.split(",") | |||
| HTTP_REQUEST_MAX_CONNECT_TIMEOUT: Annotated[ | |||
| PositiveInt, Field(ge=10, description="Maximum connection timeout in seconds for HTTP requests") | |||
| ] = 10 | |||
| HTTP_REQUEST_MAX_CONNECT_TIMEOUT: int = Field( | |||
| ge=1, description="Maximum connection timeout in seconds for HTTP requests", default=10 | |||
| ) | |||
| HTTP_REQUEST_MAX_READ_TIMEOUT: Annotated[ | |||
| PositiveInt, Field(ge=60, description="Maximum read timeout in seconds for HTTP requests") | |||
| ] = 60 | |||
| HTTP_REQUEST_MAX_READ_TIMEOUT: int = Field( | |||
| ge=1, description="Maximum read timeout in seconds for HTTP requests", default=60 | |||
| ) | |||
| HTTP_REQUEST_MAX_WRITE_TIMEOUT: Annotated[ | |||
| PositiveInt, Field(ge=10, description="Maximum write timeout in seconds for HTTP requests") | |||
| ] = 20 | |||
| HTTP_REQUEST_MAX_WRITE_TIMEOUT: int = Field( | |||
| ge=1, description="Maximum write timeout in seconds for HTTP requests", default=20 | |||
| ) | |||
| HTTP_REQUEST_NODE_MAX_BINARY_SIZE: PositiveInt = Field( | |||
| description="Maximum allowed size in bytes for binary data in HTTP requests", | |||
| @@ -28,6 +28,12 @@ from services.feature_service import FeatureService | |||
| 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): | |||
| @setup_required | |||
| @login_required | |||
| @@ -94,7 +100,7 @@ class AppListApi(Resource): | |||
| """Create app""" | |||
| parser = reqparse.RequestParser() | |||
| 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("icon_type", type=str, location="json") | |||
| parser.add_argument("icon", type=str, location="json") | |||
| @@ -146,7 +152,7 @@ class AppApi(Resource): | |||
| parser = reqparse.RequestParser() | |||
| 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=str, location="json") | |||
| parser.add_argument("icon_background", type=str, location="json") | |||
| @@ -189,7 +195,7 @@ class AppCopyApi(Resource): | |||
| parser = reqparse.RequestParser() | |||
| 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=str, location="json") | |||
| parser.add_argument("icon_background", type=str, location="json") | |||
| @@ -41,7 +41,7 @@ def _validate_name(name): | |||
| def _validate_description_length(description): | |||
| if len(description) > 400: | |||
| if description and len(description) > 400: | |||
| raise ValueError("Description cannot exceed 400 characters.") | |||
| return description | |||
| @@ -113,7 +113,7 @@ class DatasetListApi(Resource): | |||
| ) | |||
| parser.add_argument( | |||
| "description", | |||
| type=str, | |||
| type=_validate_description_length, | |||
| nullable=True, | |||
| required=False, | |||
| default="", | |||
| @@ -6,6 +6,6 @@ bp = Blueprint("service_api", __name__, url_prefix="/v1") | |||
| api = ExternalApi(bp) | |||
| 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 .workspace import models | |||
| @@ -107,3 +107,15 @@ class UnsupportedFileTypeError(BaseHTTPException): | |||
| error_code = "unsupported_file_type" | |||
| description = "File type not allowed." | |||
| 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 | |||
| @@ -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") | |||
| @@ -29,7 +29,7 @@ def _validate_name(name): | |||
| def _validate_description_length(description): | |||
| if len(description) > 400: | |||
| if description and len(description) > 400: | |||
| raise ValueError("Description cannot exceed 400 characters.") | |||
| return description | |||
| @@ -87,7 +87,7 @@ class DatasetListApi(DatasetApiResource): | |||
| ) | |||
| parser.add_argument( | |||
| "description", | |||
| type=str, | |||
| type=_validate_description_length, | |||
| nullable=True, | |||
| required=False, | |||
| default="", | |||
| @@ -1,5 +1,6 @@ | |||
| from flask import request | |||
| from flask_restful import Resource, marshal_with, reqparse | |||
| from werkzeug.exceptions import Unauthorized | |||
| from controllers.common import fields | |||
| from controllers.web import api | |||
| @@ -75,14 +76,14 @@ class AppWebAuthPermission(Resource): | |||
| try: | |||
| auth_header = request.headers.get("Authorization") | |||
| if auth_header is None: | |||
| raise | |||
| raise Unauthorized("Authorization header is missing.") | |||
| 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 = auth_scheme.lower() | |||
| if auth_scheme != "bearer": | |||
| raise | |||
| raise Unauthorized("Authorization scheme must be 'Bearer'") | |||
| decoded = PassportService().verify(tk) | |||
| user_id = decoded.get("user_id", "visitor") | |||
| @@ -118,26 +118,8 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner): | |||
| ): | |||
| return | |||
| # Init conversation variables | |||
| stmt = select(ConversationVariable).where( | |||
| 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() | |||
| # Initialize conversation variables | |||
| conversation_variables = self._initialize_conversation_variables() | |||
| # Create a variable pool. | |||
| system_inputs = SystemVariable( | |||
| @@ -292,3 +274,100 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner): | |||
| message_id=message_id, | |||
| 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 | |||
| @@ -23,6 +23,7 @@ from core.app.entities.task_entities import ( | |||
| MessageFileStreamResponse, | |||
| MessageReplaceStreamResponse, | |||
| MessageStreamResponse, | |||
| StreamEvent, | |||
| WorkflowTaskState, | |||
| ) | |||
| from core.llm_generator.llm_generator import LLMGenerator | |||
| @@ -180,11 +181,15 @@ class MessageCycleManager: | |||
| :param message_id: message id | |||
| :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( | |||
| task_id=self._application_generate_entity.task_id, | |||
| id=message_id, | |||
| answer=answer, | |||
| from_variable_selector=from_variable_selector, | |||
| event=event_type, | |||
| ) | |||
| def message_replace_to_stream_response(self, answer: str, reason: str = "") -> MessageReplaceStreamResponse: | |||
| @@ -843,7 +843,7 @@ class ProviderConfiguration(BaseModel): | |||
| continue | |||
| 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] | |||
| if model_setting.enabled is False: | |||
| status = ModelStatus.DISABLED | |||
| @@ -185,6 +185,6 @@ Clickzetta supports advanced full-text search with multiple analyzers: | |||
| ## References | |||
| - [Clickzetta Vector Search Documentation](../../../../../../../yunqidoc/cn_markdown_20250526/vector-search.md) | |||
| - [Clickzetta Inverted Index Documentation](../../../../../../../yunqidoc/cn_markdown_20250526/inverted-index.md) | |||
| - [Clickzetta SQL Functions](../../../../../../../yunqidoc/cn_markdown_20250526/sql_functions/) | |||
| - [Clickzetta Vector Search Documentation](https://yunqi.tech/documents/vector-search) | |||
| - [Clickzetta Inverted Index Documentation](https://yunqi.tech/documents/inverted-index) | |||
| - [Clickzetta SQL Functions](https://yunqi.tech/documents/sql-reference) | |||
| @@ -246,6 +246,10 @@ class TencentVector(BaseVector): | |||
| return self._get_search_res(res, score_threshold) | |||
| 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: | |||
| return [] | |||
| res = self._client.hybrid_search( | |||
| @@ -269,6 +273,7 @@ class TencentVector(BaseVector): | |||
| ), | |||
| retrieve_vector=False, | |||
| limit=kwargs.get("top_k", 4), | |||
| filter=filter, | |||
| ) | |||
| score_threshold = float(kwargs.get("score_threshold") or 0.0) | |||
| return self._get_search_res(res, score_threshold) | |||
| @@ -62,7 +62,7 @@ class WordExtractor(BaseExtractor): | |||
| def extract(self) -> list[Document]: | |||
| """Load given path as single page.""" | |||
| content = self.parse_docx(self.file_path, "storage") | |||
| content = self.parse_docx(self.file_path) | |||
| return [ | |||
| Document( | |||
| page_content=content, | |||
| @@ -189,23 +189,8 @@ class WordExtractor(BaseExtractor): | |||
| paragraph_content.append(run.text) | |||
| return "".join(paragraph_content).strip() | |||
| def _parse_paragraph(self, paragraph, image_map): | |||
| 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): | |||
| def parse_docx(self, docx_path): | |||
| doc = DocxDocument(docx_path) | |||
| os.makedirs(image_folder, exist_ok=True) | |||
| content = [] | |||
| @@ -29,7 +29,7 @@ from core.tools.errors import ( | |||
| ToolProviderCredentialValidationError, | |||
| 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 extensions.ext_database import db | |||
| from models.enums import CreatorUserRole | |||
| @@ -247,7 +247,8 @@ class ToolEngine: | |||
| ) | |||
| elif response.type == ToolInvokeMessage.MessageType.JSON: | |||
| 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: | |||
| result += str(response.message) | |||
| @@ -1,7 +1,14 @@ | |||
| import logging | |||
| from collections.abc import Generator | |||
| from datetime import date, datetime | |||
| from decimal import Decimal | |||
| 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.tools.entities.tool_entities import ToolInvokeMessage | |||
| @@ -10,6 +17,41 @@ from core.tools.tool_file_manager import ToolFileManager | |||
| 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: | |||
| @classmethod | |||
| def transform_tool_invoke_messages( | |||
| @@ -113,6 +155,12 @@ class ToolFileMessageTransformer: | |||
| ) | |||
| else: | |||
| 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: | |||
| yield message | |||
| @@ -119,6 +119,13 @@ class ObjectSegment(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 | |||
| def markdown(self) -> str: | |||
| items = [] | |||
| @@ -155,6 +162,9 @@ class ArrayStringSegment(ArraySegment): | |||
| @property | |||
| 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) | |||
| @@ -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: | |||
| """Extract text from a file based on its 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) | |||
| case ".json": | |||
| 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) | |||
| case ".msg": | |||
| return _extract_text_from_msg(file_content) | |||
| case ".vtt": | |||
| return _extract_text_from_vtt(file_content) | |||
| case ".properties": | |||
| return _extract_text_from_properties(file_content) | |||
| case _: | |||
| @@ -1,5 +1,4 @@ | |||
| import hashlib | |||
| import os | |||
| from typing import Union | |||
| from Crypto.Cipher import AES | |||
| @@ -18,7 +17,7 @@ def generate_key_pair(tenant_id: str) -> str: | |||
| pem_private = private_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) | |||
| @@ -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]: | |||
| 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()}" | |||
| private_key = redis_client.get(cache_key) | |||
| @@ -3,7 +3,7 @@ import time | |||
| import click | |||
| from sqlalchemy import text | |||
| from werkzeug.exceptions import NotFound | |||
| from sqlalchemy.exc import SQLAlchemyError | |||
| import app | |||
| from configs import dify_config | |||
| @@ -27,8 +27,8 @@ def clean_embedding_cache_task(): | |||
| .all() | |||
| ) | |||
| embedding_ids = [embedding_id[0] for embedding_id in embedding_ids] | |||
| except NotFound: | |||
| break | |||
| except SQLAlchemyError: | |||
| raise | |||
| if embedding_ids: | |||
| for embedding_id in embedding_ids: | |||
| db.session.execute( | |||
| @@ -3,7 +3,7 @@ import logging | |||
| import time | |||
| import click | |||
| from werkzeug.exceptions import NotFound | |||
| from sqlalchemy.exc import SQLAlchemyError | |||
| import app | |||
| from configs import dify_config | |||
| @@ -42,8 +42,8 @@ def clean_messages(): | |||
| .all() | |||
| ) | |||
| except NotFound: | |||
| break | |||
| except SQLAlchemyError: | |||
| raise | |||
| if not messages: | |||
| break | |||
| for message in messages: | |||
| @@ -3,7 +3,7 @@ import time | |||
| import click | |||
| from sqlalchemy import func, select | |||
| from werkzeug.exceptions import NotFound | |||
| from sqlalchemy.exc import SQLAlchemyError | |||
| import app | |||
| from configs import dify_config | |||
| @@ -65,8 +65,8 @@ def clean_unused_datasets_task(): | |||
| datasets = db.paginate(stmt, page=1, per_page=50) | |||
| except NotFound: | |||
| break | |||
| except SQLAlchemyError: | |||
| raise | |||
| if datasets.items is None or len(datasets.items) == 0: | |||
| break | |||
| for dataset in datasets: | |||
| @@ -146,8 +146,8 @@ def clean_unused_datasets_task(): | |||
| ) | |||
| datasets = db.paginate(stmt, page=1, per_page=50) | |||
| except NotFound: | |||
| break | |||
| except SQLAlchemyError: | |||
| raise | |||
| if datasets.items is None or len(datasets.items) == 0: | |||
| break | |||
| for dataset in datasets: | |||
| @@ -50,12 +50,16 @@ class ConversationService: | |||
| Conversation.from_account_id == (user.id if isinstance(user, Account) else None), | |||
| 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 | |||
| if include_ids is not None and len(include_ids) > 0: | |||
| # Check if include_ids is not None to apply filter | |||
| 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)) | |||
| # Check if exclude_ids is not None and not empty to avoid WHERE false condition | |||
| if exclude_ids is not None and len(exclude_ids) > 0: | |||
| stmt = stmt.where(~Conversation.id.in_(exclude_ids)) | |||
| # Check if exclude_ids is not None to apply filter | |||
| if exclude_ids is not None: | |||
| if len(exclude_ids) > 0: | |||
| stmt = stmt.where(~Conversation.id.in_(exclude_ids)) | |||
| # define sort fields and directions | |||
| sort_field, sort_direction = cls._get_sort_params(sort_by) | |||
| @@ -256,7 +256,7 @@ class WorkflowDraftVariableService: | |||
| def _reset_node_var_or_sys_var( | |||
| self, workflow: Workflow, variable: WorkflowDraftVariable | |||
| ) -> 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: | |||
| return variable | |||
| # 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, | |||
| }, | |||
| ) | |||
| elif _UpsertPolicy.IGNORE: | |||
| elif policy == _UpsertPolicy.IGNORE: | |||
| stmt = stmt.on_conflict_do_nothing(index_elements=WorkflowDraftVariable.unique_app_id_node_id_name()) | |||
| else: | |||
| raise Exception("Invalid value for update policy.") | |||
| @@ -56,15 +56,24 @@ def clean_dataset_task( | |||
| documents = db.session.query(Document).where(Document.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: | |||
| logging.info(click.style(f"No documents found for dataset: {dataset_id}", fg="green")) | |||
| else: | |||
| 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: | |||
| db.session.delete(document) | |||
| @@ -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) | |||
| @@ -39,10 +39,7 @@ class TestClickzettaVector(AbstractVectorTest): | |||
| ) | |||
| with setup_mock_redis(): | |||
| vector = ClickzettaVector( | |||
| collection_name="test_collection_" + str(os.getpid()), | |||
| config=config | |||
| ) | |||
| vector = ClickzettaVector(collection_name="test_collection_" + str(os.getpid()), config=config) | |||
| yield vector | |||
| @@ -114,7 +111,7 @@ class TestClickzettaVector(AbstractVectorTest): | |||
| "category": "technical" if i % 2 == 0 else "general", | |||
| "document_id": f"doc_{i // 3}", # Group documents | |||
| "importance": i, | |||
| } | |||
| }, | |||
| ) | |||
| documents.append(doc) | |||
| # Create varied embeddings | |||
| @@ -124,22 +121,14 @@ class TestClickzettaVector(AbstractVectorTest): | |||
| # Test vector search with document filter | |||
| query_vector = [0.5, 1.0, 1.5, 2.0] | |||
| results = vector_store.search_by_vector( | |||
| query_vector, | |||
| top_k=5, | |||
| document_ids_filter=["doc_0", "doc_1"] | |||
| ) | |||
| results = vector_store.search_by_vector(query_vector, top_k=5, document_ids_filter=["doc_0", "doc_1"]) | |||
| assert len(results) > 0 | |||
| # All results should belong to doc_0 or doc_1 groups | |||
| for result in results: | |||
| assert result.metadata["document_id"] in ["doc_0", "doc_1"] | |||
| # Test score threshold | |||
| results = vector_store.search_by_vector( | |||
| query_vector, | |||
| top_k=10, | |||
| score_threshold=0.5 | |||
| ) | |||
| results = vector_store.search_by_vector(query_vector, top_k=10, score_threshold=0.5) | |||
| # Check that all results have a score above threshold | |||
| for result in results: | |||
| assert result.metadata.get("score", 0) >= 0.5 | |||
| @@ -154,7 +143,7 @@ class TestClickzettaVector(AbstractVectorTest): | |||
| for i in range(batch_size): | |||
| doc = Document( | |||
| 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) | |||
| 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 | |||
| special_doc = Document( | |||
| 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]] | |||
| @@ -199,20 +188,18 @@ class TestClickzettaVector(AbstractVectorTest): | |||
| # Prepare documents with various language content | |||
| documents = [ | |||
| Document( | |||
| page_content="云器科技提供强大的Lakehouse解决方案", | |||
| metadata={"doc_id": "cn_doc_1", "lang": "chinese"} | |||
| page_content="云器科技提供强大的Lakehouse解决方案", metadata={"doc_id": "cn_doc_1", "lang": "chinese"} | |||
| ), | |||
| Document( | |||
| page_content="Clickzetta provides powerful Lakehouse solutions", | |||
| metadata={"doc_id": "en_doc_1", "lang": "english"} | |||
| metadata={"doc_id": "en_doc_1", "lang": "english"}, | |||
| ), | |||
| Document( | |||
| page_content="Lakehouse是现代数据架构的重要组成部分", | |||
| metadata={"doc_id": "cn_doc_2", "lang": "chinese"} | |||
| page_content="Lakehouse是现代数据架构的重要组成部分", metadata={"doc_id": "cn_doc_2", "lang": "chinese"} | |||
| ), | |||
| Document( | |||
| page_content="Modern data architecture includes Lakehouse technology", | |||
| metadata={"doc_id": "en_doc_2", "lang": "english"} | |||
| metadata={"doc_id": "en_doc_2", "lang": "english"}, | |||
| ), | |||
| ] | |||
| @@ -2,6 +2,7 @@ | |||
| """ | |||
| Test Clickzetta integration in Docker environment | |||
| """ | |||
| import os | |||
| import time | |||
| @@ -20,7 +21,7 @@ def test_clickzetta_connection(): | |||
| service=os.getenv("CLICKZETTA_SERVICE", "api.clickzetta.com"), | |||
| workspace=os.getenv("CLICKZETTA_WORKSPACE", "test_workspace"), | |||
| vcluster=os.getenv("CLICKZETTA_VCLUSTER", "default"), | |||
| database=os.getenv("CLICKZETTA_SCHEMA", "dify") | |||
| database=os.getenv("CLICKZETTA_SCHEMA", "dify"), | |||
| ) | |||
| with conn.cursor() as cursor: | |||
| @@ -36,7 +37,7 @@ def test_clickzetta_connection(): | |||
| # Check if test collection exists | |||
| 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}") | |||
| columns = cursor.fetchall() | |||
| print(f"✓ Table structure for {test_collection}:") | |||
| @@ -55,6 +56,7 @@ def test_clickzetta_connection(): | |||
| print(f"✗ Connection test failed: {e}") | |||
| return False | |||
| def test_dify_api(): | |||
| """Test Dify API with Clickzetta backend""" | |||
| print("\n=== Testing Dify API ===") | |||
| @@ -83,6 +85,7 @@ def test_dify_api(): | |||
| print(f"✗ API test failed: {e}") | |||
| return False | |||
| def verify_table_structure(): | |||
| """Verify the table structure meets Dify requirements""" | |||
| print("\n=== Verifying Table Structure ===") | |||
| @@ -91,15 +94,10 @@ def verify_table_structure(): | |||
| "id": "VARCHAR", | |||
| "page_content": "VARCHAR", | |||
| "metadata": "VARCHAR", # JSON stored as VARCHAR in Clickzetta | |||
| "vector": "ARRAY<FLOAT>" | |||
| "vector": "ARRAY<FLOAT>", | |||
| } | |||
| expected_metadata_fields = [ | |||
| "doc_id", | |||
| "doc_hash", | |||
| "document_id", | |||
| "dataset_id" | |||
| ] | |||
| expected_metadata_fields = ["doc_id", "doc_hash", "document_id", "dataset_id"] | |||
| print("✓ Expected table structure:") | |||
| for col, dtype in expected_columns.items(): | |||
| @@ -117,6 +115,7 @@ def verify_table_structure(): | |||
| return True | |||
| def main(): | |||
| """Run all tests""" | |||
| print("Starting Clickzetta integration tests for Dify Docker\n") | |||
| @@ -137,9 +136,9 @@ def main(): | |||
| results.append((test_name, False)) | |||
| # Summary | |||
| print("\n" + "="*50) | |||
| print("\n" + "=" * 50) | |||
| print("Test Summary:") | |||
| print("="*50) | |||
| print("=" * 50) | |||
| passed = sum(1 for _, success in results if success) | |||
| total = len(results) | |||
| @@ -161,5 +160,6 @@ def main(): | |||
| print("\n⚠️ Some tests failed. Please check the errors above.") | |||
| return 1 | |||
| if __name__ == "__main__": | |||
| exit(main()) | |||
| @@ -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) | |||
| @@ -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() | |||
| @@ -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) | |||
| @@ -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"}, | |||
| ) | |||
| @@ -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" | |||
| @@ -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 | |||
| @@ -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.', | |||
| ) | |||
| } | |||
| }) | |||
| }) | |||
| }) | |||
| }) | |||
| @@ -8,6 +8,7 @@ import Header from '@/app/components/header' | |||
| import { EventEmitterContextProvider } from '@/context/event-emitter' | |||
| import { ProviderContextProvider } from '@/context/provider-context' | |||
| import { ModalContextProvider } from '@/context/modal-context' | |||
| import GotoAnything from '@/app/components/goto-anything' | |||
| const Layout = ({ children }: { children: ReactNode }) => { | |||
| return ( | |||
| @@ -22,6 +23,7 @@ const Layout = ({ children }: { children: ReactNode }) => { | |||
| <Header /> | |||
| </HeaderWrapper> | |||
| {children} | |||
| <GotoAnything /> | |||
| </ModalContextProvider> | |||
| </ProviderContextProvider> | |||
| </EventEmitterContextProvider> | |||
| @@ -87,7 +87,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => { | |||
| <Avatar {...props} /> | |||
| <div | |||
| 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"> | |||
| <RiPencilLine /> | |||
| @@ -12,7 +12,6 @@ import { | |||
| RiFileUploadLine, | |||
| } from '@remixicon/react' | |||
| import AppIcon from '../base/app-icon' | |||
| import cn from '@/utils/classnames' | |||
| import { useStore as useAppStore } from '@/app/components/app/store' | |||
| import { ToastContext } from '@/app/components/base/toast' | |||
| import { useAppContext } from '@/context/app-context' | |||
| @@ -31,6 +30,7 @@ import Divider from '../base/divider' | |||
| import type { Operation } from './app-operations' | |||
| import AppOperations from './app-operations' | |||
| import dynamic from 'next/dynamic' | |||
| import cn from '@/utils/classnames' | |||
| const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), { | |||
| ssr: false, | |||
| @@ -256,32 +256,40 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx | |||
| }} | |||
| 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 items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`}> | |||
| <AppIcon | |||
| size={expand ? 'large' : 'small'} | |||
| iconType={appDetail.icon_type} | |||
| icon={appDetail.icon} | |||
| 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 className='flex flex-col gap-2 rounded-lg p-1 hover:bg-state-base-hover'> | |||
| <div className='flex items-center gap-1'> | |||
| <div className={cn(!expand && 'ml-1')}> | |||
| <AppIcon | |||
| size={expand ? 'large' : 'small'} | |||
| iconType={appDetail.icon_type} | |||
| icon={appDetail.icon} | |||
| background={appDetail.icon_background} | |||
| imageUrl={appDetail.icon_url} | |||
| /> | |||
| </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' /> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div className={cn( | |||
| 'flex flex-col items-start gap-1 transition-all duration-200 ease-in-out', | |||
| expand | |||
| ? 'w-auto opacity-100' | |||
| : 'pointer-events-none w-0 overflow-hidden opacity-0', | |||
| )}> | |||
| <div className='flex w-full'> | |||
| <div className='system-md-semibold truncate whitespace-nowrap text-text-secondary'>{appDetail.name}</div> | |||
| )} | |||
| {expand && ( | |||
| <div className='flex flex-col items-start gap-1'> | |||
| <div className='flex w-full'> | |||
| <div className='system-md-semibold truncate whitespace-nowrap text-text-secondary'>{appDetail.name}</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 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> | |||
| </button> | |||
| )} | |||
| @@ -32,7 +32,7 @@ const AccessControlDialog = ({ | |||
| leaveFrom="opacity-100" | |||
| 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> | |||
| <div className="fixed inset-0 flex items-center justify-center"> | |||
| @@ -106,7 +106,7 @@ function SelectedGroupsBreadCrumb() { | |||
| setSelectedGroupsForBreadcrumb([]) | |||
| }, [setSelectedGroupsForBreadcrumb]) | |||
| 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) => { | |||
| return <div key={index} className='system-xs-regular flex items-center gap-x-0.5 text-text-tertiary'> | |||
| <span>/</span> | |||
| @@ -198,7 +198,7 @@ type BaseItemProps = { | |||
| children: React.ReactNode | |||
| } | |||
| 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} | |||
| </div> | |||
| } | |||
| @@ -4,7 +4,6 @@ import React, { useRef, useState } from 'react' | |||
| import { useGetState, useInfiniteScroll } from 'ahooks' | |||
| import { useTranslation } from 'react-i18next' | |||
| import Link from 'next/link' | |||
| import produce from 'immer' | |||
| import Modal from '@/app/components/base/modal' | |||
| import type { DataSet } from '@/models/datasets' | |||
| import Button from '@/app/components/base/button' | |||
| @@ -29,9 +28,10 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({ | |||
| onSelect, | |||
| }) => { | |||
| 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 [datasets, setDataSets] = React.useState<DataSet[] | null>(null) | |||
| const [hasInitialized, setHasInitialized] = React.useState(false) | |||
| const hasNoData = !datasets || datasets?.length === 0 | |||
| const canSelectMulti = true | |||
| @@ -49,19 +49,17 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({ | |||
| const newList = [...(datasets || []), ...data.filter(item => item.indexing_technique || item.provider === 'external')] | |||
| setDataSets(newList) | |||
| setLoaded(true) | |||
| if (!selected.find(item => !item.name)) | |||
| return { list: [] } | |||
| const newSelected = produce(selected, (draft) => { | |||
| selected.forEach((item, index) => { | |||
| if (!item.name) { // not fetched database | |||
| const newItem = newList.find(i => i.id === item.id) | |||
| if (newItem) | |||
| draft[index] = newItem | |||
| } | |||
| }) | |||
| }) | |||
| setSelected(newSelected) | |||
| // Initialize selected datasets based on selectedIds and available datasets | |||
| if (!hasInitialized) { | |||
| if (selectedIds.length > 0) { | |||
| const validSelectedDatasets = selectedIds | |||
| .map(id => newList.find(item => item.id === id)) | |||
| .filter(Boolean) as DataSet[] | |||
| setSelected(validSelectedDatasets) | |||
| } | |||
| setHasInitialized(true) | |||
| } | |||
| } | |||
| return { list: [] } | |||
| }, | |||
| @@ -40,13 +40,13 @@ type CategoryItemProps = { | |||
| } | |||
| function CategoryItem({ category, active, onClick }: CategoryItemProps) { | |||
| 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) }}> | |||
| {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' /> | |||
| </div>} | |||
| <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 > | |||
| } | |||
| @@ -82,8 +82,11 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps) | |||
| localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') | |||
| getRedirection(isCurrentWorkspaceEditor, app, push) | |||
| } | |||
| catch { | |||
| notify({ type: 'error', message: t('app.newApp.appCreateFailed') }) | |||
| catch (e: any) { | |||
| notify({ | |||
| type: 'error', | |||
| message: e.message || t('app.newApp.appCreateFailed'), | |||
| }) | |||
| } | |||
| isCreatingRef.current = false | |||
| }, [name, notify, t, appMode, appIcon, description, onSuccess, onClose, push, isCurrentWorkspaceEditor]) | |||
| @@ -117,8 +117,11 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { | |||
| if (onRefresh) | |||
| onRefresh() | |||
| } | |||
| catch { | |||
| notify({ type: 'error', message: t('app.editFailed') }) | |||
| catch (e: any) { | |||
| notify({ | |||
| type: 'error', | |||
| message: e.message || t('app.editFailed'), | |||
| }) | |||
| } | |||
| }, [app.id, notify, onRefresh, t]) | |||
| @@ -364,7 +367,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { | |||
| </div> | |||
| <div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'> | |||
| <div | |||
| className={cn(tags.length ? 'line-clamp-2' : 'line-clamp-4', 'group-hover:line-clamp-2')} | |||
| className='line-clamp-2' | |||
| title={app.description} | |||
| > | |||
| {app.description} | |||
| @@ -1,6 +1,6 @@ | |||
| import React, { useState } from 'react' | |||
| import React from 'react' | |||
| import Link from 'next/link' | |||
| import { RiCloseLine, RiDiscordFill, RiGithubFill } from '@remixicon/react' | |||
| import { RiDiscordFill, RiGithubFill } from '@remixicon/react' | |||
| import { useTranslation } from 'react-i18next' | |||
| type CustomLinkProps = { | |||
| @@ -26,24 +26,9 @@ const CustomLink = React.memo(({ | |||
| const Footer = () => { | |||
| const { t } = useTranslation() | |||
| const [isVisible, setIsVisible] = useState(true) | |||
| const handleClose = () => { | |||
| setIsVisible(false) | |||
| } | |||
| if (!isVisible) | |||
| return null | |||
| return ( | |||
| <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> | |||
| <p className='system-sm-regular mt-1 text-text-tertiary'>{t('app.communityIntro')}</p> | |||
| <div className='mt-3 flex items-center gap-2'> | |||
| @@ -1,14 +1,11 @@ | |||
| 'use client' | |||
| import { useEducationInit } from '@/app/education-apply/hooks' | |||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||
| import List from './list' | |||
| import Footer from './footer' | |||
| import useDocumentTitle from '@/hooks/use-document-title' | |||
| import { useTranslation } from 'react-i18next' | |||
| const Apps = () => { | |||
| const { t } = useTranslation() | |||
| const { systemFeatures } = useGlobalPublicStore() | |||
| useDocumentTitle(t('common.menus.apps')) | |||
| useEducationInit() | |||
| @@ -16,9 +13,6 @@ const Apps = () => { | |||
| return ( | |||
| <div className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'> | |||
| <List /> | |||
| {!systemFeatures.branding.enabled && ( | |||
| <Footer /> | |||
| )} | |||
| </div > | |||
| ) | |||
| } | |||
| @@ -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 dynamic from 'next/dynamic' | |||
| import Empty from './empty' | |||
| import Footer from './footer' | |||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||
| const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { | |||
| ssr: false, | |||
| @@ -66,6 +68,7 @@ const getKey = ( | |||
| const List = () => { | |||
| const { t } = useTranslation() | |||
| const { systemFeatures } = useGlobalPublicStore() | |||
| const router = useRouter() | |||
| const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext() | |||
| const showTagManagementModal = useTagStore(s => s.showTagManagementModal) | |||
| @@ -229,6 +232,9 @@ const List = () => { | |||
| <span className="system-xs-regular">{t('app.newApp.dropDSLToCreateApp')}</span> | |||
| </div> | |||
| )} | |||
| {!systemFeatures.branding.enabled && ( | |||
| <Footer /> | |||
| )} | |||
| <CheckModal /> | |||
| <div ref={anchorRef} className='h-0'> </div> | |||
| {showTagManagementModal && ( | |||
| @@ -94,7 +94,7 @@ const ImageInput: FC<UploaderProps> = ({ | |||
| <div | |||
| className={classNames( | |||
| 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} | |||
| onDragOver={handleDragOver} | |||
| onDragLeave={handleDragLeave} | |||
| @@ -112,7 +112,7 @@ const BlockInput: FC<IBlockInputProps> = ({ | |||
| ? <div className='h-full px-4 py-2'> | |||
| <textarea | |||
| ref={contentEditableRef} | |||
| className={classNames(editAreaClassName, 'block w-full h-full resize-none')} | |||
| className={classNames(editAreaClassName, 'block h-full w-full resize-none')} | |||
| placeholder={placeholder} | |||
| onChange={onValueChange} | |||
| value={currentValue} | |||
| @@ -130,7 +130,7 @@ const BlockInput: FC<IBlockInputProps> = ({ | |||
| </div>) | |||
| 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} | |||
| {/* footer */} | |||
| {!readonly && ( | |||
| @@ -51,7 +51,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( | |||
| {...props} | |||
| > | |||
| {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> | |||
| ) | |||
| }, | |||
| @@ -78,7 +78,6 @@ const DatePicker = ({ | |||
| setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone })) | |||
| setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined) | |||
| } | |||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||
| }, [timezone]) | |||
| const handleClickTrigger = (e: React.MouseEvent) => { | |||
| @@ -192,7 +191,7 @@ const DatePicker = ({ | |||
| 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 displayTime = selectedDate?.format('hh:mm A') || '--:-- --' | |||
| const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('time.defaultPlaceholder')) | |||
| @@ -90,3 +90,49 @@ export const convertTimezoneToOffsetStr = (timezone?: string) => { | |||
| return DEFAULT_OFFSET_STR | |||
| 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') | |||
| } | |||
| } | |||
| @@ -47,16 +47,16 @@ const CustomDialog = ({ | |||
| <div className="flex min-h-full items-center justify-center"> | |||
| <TransitionChild> | |||
| <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', | |||
| 'duration-100 ease-in data-[closed]:opacity-0 data-[closed]:scale-95', | |||
| 'data-[enter]:opacity-100 data-[enter]:scale-100', | |||
| 'data-[leave]:opacity-0 data-[enter]:scale-95', | |||
| '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]:scale-95 data-[closed]:opacity-0', | |||
| 'data-[enter]:scale-100 data-[enter]:opacity-100', | |||
| 'data-[enter]:scale-95 data-[leave]:opacity-0', | |||
| className, | |||
| )}> | |||
| {Boolean(title) && ( | |||
| <DialogTitle | |||
| 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} | |||
| </DialogTitle> | |||
| @@ -24,7 +24,7 @@ const DialogWrapper = ({ | |||
| <Dialog as="div" className="relative z-40" onClose={close}> | |||
| <TransitionChild> | |||
| <div className={cn( | |||
| 'fixed inset-0 bg-black bg-opacity-25', | |||
| 'fixed inset-0 bg-black/25', | |||
| 'data-[closed]:opacity-0', | |||
| 'data-[enter]:opacity-100 data-[enter]:duration-300 data-[enter]:ease-out', | |||
| 'data-[leave]:opacity-0 data-[leave]:duration-200 data-[leave]:ease-in', | |||
| @@ -36,7 +36,7 @@ describe('file-uploader utils', () => { | |||
| }) | |||
| describe('fileUpload', () => { | |||
| it('should handle successful file upload', async () => { | |||
| it('should handle successful file upload', () => { | |||
| const mockFile = new File(['test'], 'test.txt') | |||
| const mockCallbacks = { | |||
| onProgressCallback: jest.fn(), | |||
| @@ -46,13 +46,12 @@ describe('file-uploader utils', () => { | |||
| jest.mocked(upload).mockResolvedValue({ id: '123' }) | |||
| await fileUpload({ | |||
| fileUpload({ | |||
| file: mockFile, | |||
| ...mockCallbacks, | |||
| }) | |||
| expect(upload).toHaveBeenCalled() | |||
| expect(mockCallbacks.onSuccessCallback).toHaveBeenCalledWith({ id: '123' }) | |||
| }) | |||
| }) | |||
| @@ -284,7 +283,23 @@ describe('file-uploader utils', () => { | |||
| }) | |||
| 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 = [{ | |||
| related_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9', | |||
| extension: '.jpeg', | |||
| @@ -294,6 +309,8 @@ describe('file-uploader utils', () => { | |||
| transfer_method: TransferMethod.local_file, | |||
| type: 'image', | |||
| url: 'https://upload.dify.dev/files/xxx/file-preview', | |||
| upload_file_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9', | |||
| remote_url: '', | |||
| }] | |||
| const result = getProcessedFilesFromResponse(files) | |||
| @@ -309,6 +326,215 @@ describe('file-uploader utils', () => { | |||
| 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', () => { | |||
| @@ -70,10 +70,13 @@ export const getFileExtension = (fileName: string, fileMimetype: string, isRemot | |||
| } | |||
| } | |||
| if (!extension) { | |||
| if (extensions.size > 0) | |||
| extension = extensions.values().next().value.toLowerCase() | |||
| else | |||
| if (extensions.size > 0) { | |||
| const firstExtension = extensions.values().next().value | |||
| extension = firstExtension ? firstExtension.toLowerCase() : '' | |||
| } | |||
| else { | |||
| extension = extensionInFileName | |||
| } | |||
| } | |||
| if (isRemote) | |||
| @@ -145,6 +148,19 @@ export const getProcessedFiles = (files: FileEntity[]) => { | |||
| export const getProcessedFilesFromResponse = (files: FileResponse[]) => { | |||
| 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 { | |||
| id: fileItem.related_id, | |||
| name: fileItem.filename, | |||
| @@ -152,7 +168,7 @@ export const getProcessedFilesFromResponse = (files: FileResponse[]) => { | |||
| type: fileItem.mime_type, | |||
| progress: 100, | |||
| transferMethod: fileItem.transfer_method, | |||
| supportFileType: fileItem.type, | |||
| supportFileType, | |||
| uploadedId: fileItem.upload_file_id || fileItem.related_id, | |||
| url: fileItem.url || fileItem.remote_url, | |||
| } | |||
| @@ -48,9 +48,9 @@ export default function FullScreenModal({ | |||
| <DialogPanel className={classNames( | |||
| 'h-full', | |||
| overflowVisible ? 'overflow-visible' : 'overflow-hidden', | |||
| 'duration-100 ease-in data-[closed]:opacity-0 data-[closed]:scale-95', | |||
| 'data-[enter]:opacity-100 data-[enter]:scale-100', | |||
| 'data-[leave]:opacity-0 data-[enter]:scale-95', | |||
| 'duration-100 ease-in data-[closed]:scale-95 data-[closed]:opacity-0', | |||
| 'data-[enter]:scale-100 data-[enter]:opacity-100', | |||
| 'data-[enter]:scale-95 data-[leave]:opacity-0', | |||
| className, | |||
| )}> | |||
| {closable | |||
| @@ -16,8 +16,8 @@ const GridMask: FC<GridMaskProps> = ({ | |||
| }) => { | |||
| return ( | |||
| <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 w-full h-full z-[1] bg-grid-mask-background rounded-lg', gradientClassName)} /> | |||
| <div className={classNames('absolute inset-0 z-0 h-full w-full opacity-70', canvasClassName, Style.gridBg)} /> | |||
| <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> | |||
| ) | |||
| @@ -32,7 +32,7 @@ export type InputProps = { | |||
| unit?: string | |||
| } & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & VariantProps<typeof inputVariants> | |||
| const Input = ({ | |||
| const Input = React.forwardRef<HTMLInputElement, InputProps>(({ | |||
| size, | |||
| disabled, | |||
| destructive, | |||
| @@ -47,12 +47,13 @@ const Input = ({ | |||
| onChange = noop, | |||
| unit, | |||
| ...props | |||
| }: InputProps) => { | |||
| }, ref) => { | |||
| const { t } = useTranslation() | |||
| return ( | |||
| <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')} />} | |||
| <input | |||
| ref={ref} | |||
| style={styleCss} | |||
| 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', | |||
| @@ -92,6 +93,8 @@ const Input = ({ | |||
| } | |||
| </div> | |||
| ) | |||
| } | |||
| }) | |||
| Input.displayName = 'Input' | |||
| export default Input | |||
| @@ -13,7 +13,7 @@ const LogoSite: FC<LogoSiteProps> = ({ | |||
| return ( | |||
| <img | |||
| 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' | |||
| /> | |||
| ) | |||
| @@ -81,7 +81,6 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any | |||
| const echartsRef = useRef<any>(null) | |||
| const contentRef = useRef<string>('') | |||
| 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 chartInstanceRef = useRef<any>(null) // Direct reference to ECharts instance | |||
| const resizeTimerRef = useRef<NodeJS.Timeout | null>(null) // For debounce handling | |||
| @@ -7,6 +7,7 @@ import TimePicker from '@/app/components/base/date-and-time-picker/time-picker' | |||
| import Checkbox from '@/app/components/base/checkbox' | |||
| import Select from '@/app/components/base/select' | |||
| import { useChatContext } from '@/app/components/base/chat/chat/context' | |||
| import { formatDateForOutput } from '@/app/components/base/date-and-time-picker/utils/dayjs' | |||
| enum DATA_FORMAT { | |||
| TEXT = 'text', | |||
| @@ -51,8 +52,20 @@ const MarkdownForm = ({ node }: any) => { | |||
| const getFormValues = (children: any) => { | |||
| const values: { [key: string]: any } = {} | |||
| children.forEach((child: any) => { | |||
| if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) | |||
| values[child.properties.name] = formValues[child.properties.name] | |||
| if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) { | |||
| 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 | |||
| } | |||
| @@ -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 { useTranslation } from 'react-i18next' | |||
| import { ExclamationTriangleIcon } from '@heroicons/react/24/outline' | |||
| @@ -122,14 +122,6 @@ const Flowchart = React.forwardRef((props: { | |||
| const renderTimeoutRef = useRef<NodeJS.Timeout>() | |||
| const [errMsg, setErrMsg] = 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 | |||
| @@ -537,11 +529,9 @@ const Flowchart = React.forwardRef((props: { | |||
| {isLoading && !svgString && ( | |||
| <div className='px-[26px] py-4'> | |||
| <LoadingAnim type='text'/> | |||
| {!isCodeComplete && ( | |||
| <div className="mt-2 text-sm text-gray-500"> | |||
| {t('common.wait_for_completion', 'Waiting for diagram code to complete...')} | |||
| </div> | |||
| )} | |||
| </div> | |||
| )} | |||
| @@ -50,11 +50,11 @@ export default function Modal({ | |||
| <div className="flex min-h-full items-center justify-center p-4 text-center"> | |||
| <TransitionChild> | |||
| <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', | |||
| 'duration-100 ease-in data-[closed]:opacity-0 data-[closed]:scale-95', | |||
| 'data-[enter]:opacity-100 data-[enter]:scale-100', | |||
| 'data-[leave]:opacity-0 data-[enter]:scale-95', | |||
| 'duration-100 ease-in data-[closed]:scale-95 data-[closed]:opacity-0', | |||
| 'data-[enter]:scale-100 data-[enter]:opacity-100', | |||
| 'data-[enter]:scale-95 data-[leave]:opacity-0', | |||
| className, | |||
| )}> | |||
| {title && <DialogTitle | |||
| @@ -61,7 +61,7 @@ const PremiumBadge: React.FC<PremiumBadgeProps> = ({ | |||
| {children} | |||
| <Highlight | |||
| 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', | |||
| )} | |||
| /> | |||
| @@ -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> | |||
| ) | |||
| } | |||
| @@ -24,7 +24,7 @@ export const SkeletonRow: FC<SkeletonProps> = (props) => { | |||
| export const SkeletonRectangle: FC<SkeletonProps> = (props) => { | |||
| const { className, children, ...rest } = props | |||
| 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} | |||
| </div> | |||
| ) | |||
| @@ -33,7 +33,7 @@ export const SkeletonRectangle: FC<SkeletonProps> = (props) => { | |||
| export const SkeletonPoint: FC<SkeletonProps> = (props) => { | |||
| const { className, ...rest } = props | |||
| 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 | |||
| @@ -63,8 +63,8 @@ const Switch = ( | |||
| className={classNames( | |||
| wrapStyle[size], | |||
| 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', | |||
| disabled ? '!opacity-50 !cursor-not-allowed' : '', | |||
| 'relative inline-flex shrink-0 cursor-pointer rounded-[5px] border-2 border-transparent transition-colors duration-200 ease-in-out', | |||
| disabled ? '!cursor-not-allowed !opacity-50' : '', | |||
| size === 'xs' && 'rounded-sm', | |||
| className, | |||
| )} | |||
| @@ -75,7 +75,7 @@ const Switch = ( | |||
| circleStyle[size], | |||
| enabled ? translateLeft[size] : 'translate-x-0', | |||
| 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> | |||
| @@ -25,8 +25,8 @@ const TabSliderNew: FC<TabSliderProps> = ({ | |||
| key={option.value} | |||
| onClick={() => onChange(option.value)} | |||
| 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', | |||
| 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', | |||
| '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-state-base-hover text-components-main-nav-nav-button-text-active shadow-xs', | |||
| )} | |||
| > | |||
| {option.icon} | |||
| @@ -31,10 +31,10 @@ const COLOR_MAP = { | |||
| export default function Tag({ children, color = 'green', className = '', bordered = false, hideBg = false }: ITagProps) { | |||
| return ( | |||
| <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}` : '', | |||
| bordered ? 'border-[1px]' : '', | |||
| hideBg ? 'bg-opacity-0' : '', | |||
| hideBg ? 'bg-transparent' : '', | |||
| className)} > | |||
| {children} | |||
| </div> | |||
| @@ -71,14 +71,14 @@ const Pricing: FC<Props> = ({ | |||
| { | |||
| value: 'cloud', | |||
| 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')} > | |||
| <RiCloudFill className='mr-2 size-4' />{t('billing.plansCommon.cloud')}</div>, | |||
| }, | |||
| { | |||
| value: 'self', | |||
| 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')}> | |||
| <RiTerminalBoxFill className='mr-2 size-4' />{t('billing.plansCommon.self')}</div>, | |||
| }]} | |||
| @@ -70,7 +70,7 @@ const style = { | |||
| priceTip: 'text-text-primary-on-surface', | |||
| description: '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', | |||
| tooltipIconColor: 'text-text-primary-on-surface', | |||
| }, | |||
| @@ -17,15 +17,15 @@ export const StepperStep: FC<StepperStepProps> = (props) => { | |||
| const label = isActive ? `STEP ${index + 1}` : `${index + 1}` | |||
| return <div className='flex items-center gap-2'> | |||
| <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 | |||
| ? 'px-2 bg-state-accent-solid' | |||
| ? 'bg-state-accent-solid px-2' | |||
| : !isDisabled | |||
| ? 'w-5 border border-text-quaternary' | |||
| : 'w-5 border border-divider-deep', | |||
| )}> | |||
| <div className={classNames( | |||
| 'text-center system-2xs-semibold-uppercase', | |||
| 'system-2xs-semibold-uppercase text-center', | |||
| isActive | |||
| ? 'text-text-primary-on-surface' | |||
| : !isDisabled | |||
| @@ -37,7 +37,7 @@ export const StepperStep: FC<StepperStepProps> = (props) => { | |||
| </div> | |||
| <div className={classNames('system-xs-medium-uppercase', | |||
| isActive | |||
| ? 'text-text-accent system-xs-semibold-uppercase' | |||
| ? 'system-xs-semibold-uppercase text-text-accent' | |||
| : !isDisabled | |||
| ? 'text-text-tertiary' | |||
| : 'text-text-quaternary', | |||
| @@ -20,10 +20,10 @@ const FullScreenDrawer: FC<IFullScreenDrawerProps> = ({ | |||
| <Drawer | |||
| isOpen={isOpen} | |||
| onClose={onClose} | |||
| panelClassName={classNames('!p-0 bg-components-panel-bg', | |||
| panelClassName={classNames('bg-components-panel-bg !p-0', | |||
| fullScreen | |||
| ? '!max-w-full !w-full' | |||
| : 'mt-16 mr-2 mb-2 !max-w-[560px] !w-[560px] border-[0.5px] border-components-panel-border rounded-xl', | |||
| ? '!w-full !max-w-full' | |||
| : 'mb-2 mr-2 mt-16 !w-[560px] !max-w-[560px] rounded-xl border-[0.5px] border-components-panel-border', | |||
| )} | |||
| mask={false} | |||
| unmount | |||
| @@ -286,7 +286,7 @@ const EmbeddingDetail: FC<IEmbeddingDetailProps> = ({ | |||
| {/* progress bar */} | |||
| <div className={cn( | |||
| '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 | |||
| className={cn( | |||
| @@ -1,4 +1,4 @@ | |||
| import React, { useState } from 'react' | |||
| import React from 'react' | |||
| import type { FC } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { RiBookOpenLine } from '@remixicon/react' | |||
| @@ -28,10 +28,8 @@ const Form: FC<FormProps> = React.memo(({ | |||
| }) => { | |||
| const { t, i18n } = useTranslation() | |||
| const docLink = useDocLink() | |||
| const [changeKey, setChangeKey] = useState('') | |||
| const handleFormChange = (key: string, val: string) => { | |||
| setChangeKey(key) | |||
| if (key === 'name') { | |||
| onChange({ ...value, [key]: val }) | |||
| } | |||
| @@ -56,7 +56,7 @@ export const EditSlice: FC<EditSliceProps> = (props) => { | |||
| return ( | |||
| <> | |||
| <SliceContainer {...rest} | |||
| className={classNames('block mr-0', className)} | |||
| className={classNames('mr-0 block', className)} | |||
| ref={(ref) => { | |||
| refs.setReference(ref) | |||
| if (ref) | |||
| @@ -13,7 +13,7 @@ export const SliceContainer: FC<SliceContainerProps> = ( | |||
| ) => { | |||
| const { className, ...rest } = props | |||
| 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, | |||
| )} /> | |||
| } | |||
| @@ -30,7 +30,7 @@ export const SliceLabel: FC<SliceLabelProps> = ( | |||
| const { className, children, labelInnerClassName, ...rest } = props | |||
| return <span {...rest} ref={ref} className={classNames( | |||
| 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, | |||
| )}> | |||
| <span className={classNames('text-nowrap', labelInnerClassName)}> | |||
| @@ -51,7 +51,7 @@ export const SliceContent: FC<SliceContentProps> = ( | |||
| const { className, children, ...rest } = props | |||
| return <span {...rest} ref={ref} className={classNames( | |||
| 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, | |||
| )}> | |||
| {children} | |||
| @@ -70,7 +70,7 @@ export const SliceDivider: FC<SliceDividerProps> = ( | |||
| const { className, ...rest } = props | |||
| return <span {...rest} ref={ref} className={classNames( | |||
| 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, | |||
| )}> | |||
| {/* use a zero-width space to make the hover area bigger */} | |||
| @@ -29,8 +29,8 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset | |||
| const [loading, setLoading] = useState(false) | |||
| const [name, setName] = useState<string>(dataset.name) | |||
| const [description, setDescription] = useState<string>(dataset.description) | |||
| const [externalKnowledgeId] = useState<string>(dataset.external_knowledge_info.external_knowledge_id) | |||
| const [externalKnowledgeApiId] = useState<string>(dataset.external_knowledge_info.external_knowledge_api_id) | |||
| const externalKnowledgeId = dataset.external_knowledge_info.external_knowledge_id | |||
| const externalKnowledgeApiId = dataset.external_knowledge_info.external_knowledge_api_id | |||
| const [appIcon, setAppIcon] = useState<AppIconSelection>( | |||
| dataset.icon_info?.icon_type === 'image' | |||
| ? { type: 'image' as const, url: dataset.icon_info?.icon_url || '', fileId: dataset.icon_info?.icon || '' } | |||
| @@ -66,10 +66,10 @@ function CopyButton({ code }: { code: string }) { | |||
| <button | |||
| type="button" | |||
| 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 | |||
| ? '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={() => { | |||
| writeTextToClipboard(code).then(() => { | |||
| @@ -23,7 +23,9 @@ const SecretKeyGenerateModal = ({ | |||
| const { t } = useTranslation() | |||
| return ( | |||
| <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> | |||
| <div className='my-4'> | |||
| <InputCopy className='w-full' value={newKey?.token} /> | |||
| @@ -84,7 +84,9 @@ const SecretKeyModal = ({ | |||
| return ( | |||
| <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> | |||
| {!apiKeysList && <div className='mt-4'><Loading /></div>} | |||
| { | |||
| @@ -277,6 +277,85 @@ The text generation application offers non-session support and is ideal for tran | |||
| </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 | |||
| url='/completion-messages/:task_id/stop' | |||
| method='POST' | |||
| @@ -276,6 +276,85 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from | |||
| </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 | |||
| url='/completion-messages/:task_id/stop' | |||
| method='POST' | |||
| @@ -252,6 +252,86 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' | |||
| </Col> | |||
| </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 | |||
| url='/completion-messages/:task_id/stop' | |||
| method='POST' | |||
| @@ -392,6 +392,85 @@ Chat applications support session persistence, allowing previous chat history to | |||
| </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 | |||
| url='/chat-messages/:task_id/stop' | |||
| method='POST' | |||
| @@ -653,7 +732,7 @@ Chat applications support session persistence, allowing previous chat history to | |||
| - `message_files` (array[object]) Message files | |||
| - `id` (string) ID | |||
| - `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 to,user orassistant | |||
| - `answer` (string) Response message content | |||
| - `created_at` (timestamp) Creation timestamp, e.g., 1705395332 | |||
| @@ -392,6 +392,86 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from | |||
| </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 | |||
| url='/chat-messages/:task_id/stop' | |||
| method='POST' | |||
| @@ -654,7 +734,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from | |||
| - `message_files` (array[object]) メッセージファイル | |||
| - `id` (string) ID | |||
| - `type` (string) ファイルタイプ、画像の場合はimage | |||
| - `url` (string) プレビュー画像URL | |||
| - `url` (string) ファイルプレビューURL、ファイルアクセスにはファイルプレビューAPI(`/files/{file_id}/preview`)を使用してください | |||
| - `belongs_to` (string) 所属、userまたはassistant | |||
| - `answer` (string) 応答メッセージ内容 | |||
| - `created_at` (timestamp) 作成タイムスタンプ、例:1705395332 | |||
| @@ -399,6 +399,86 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' | |||
| </Col> | |||
| </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 | |||
| url='/chat-messages/:task_id/stop' | |||
| method='POST' | |||
| @@ -661,7 +741,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' | |||
| - `message_files` (array[object]) 消息文件 | |||
| - `id` (string) ID | |||
| - `type` (string) 文件类型,image 图片 | |||
| - `url` (string) 预览图片地址 | |||
| - `url` (string) 文件预览地址,使用文件预览 API (`/files/{file_id}/preview`) 访问文件 | |||
| - `belongs_to` (string) 文件归属方,user 或 assistant | |||
| - `answer` (string) 回答消息内容 | |||
| - `created_at` (timestamp) 创建时间 | |||
| @@ -356,6 +356,85 @@ Chat applications support session persistence, allowing previous chat history to | |||
| </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 | |||
| url='/chat-messages/:task_id/stop' | |||
| method='POST' | |||
| @@ -617,7 +696,7 @@ Chat applications support session persistence, allowing previous chat history to | |||
| - `message_files` (array[object]) Message files | |||
| - `id` (string) ID | |||
| - `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 to,user or assistant | |||
| - `agent_thoughts` (array[object]) Agent thought(Empty if it's a Basic Assistant) | |||
| - `id` (string) Agent thought ID, every iteration has a unique agent thought ID | |||
| @@ -356,6 +356,85 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from | |||
| </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 | |||
| url='/chat-messages/:task_id/stop' | |||
| method='POST' | |||
| @@ -618,7 +697,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from | |||
| - `message_files` (array[object]) メッセージファイル | |||
| - `id` (string) ID | |||
| - `type` (string) ファイルタイプ、画像の場合はimage | |||
| - `url` (string) プレビュー画像URL | |||
| - `url` (string) ファイルプレビューURL、ファイルアクセスにはファイルプレビューAPI(`/files/{file_id}/preview`)を使用してください | |||
| - `belongs_to` (string) 所属、ユーザーまたはアシスタント | |||
| - `agent_thoughts` (array[object]) エージェントの思考(基本アシスタントの場合は空) | |||
| - `id` (string) エージェント思考ID、各反復には一意のエージェント思考IDがあります | |||
| @@ -371,6 +371,86 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' | |||
| </Col> | |||
| </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 | |||
| url='/chat-messages/:task_id/stop' | |||
| method='POST' | |||
| @@ -631,7 +711,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' | |||
| - `message_files` (array[object]) 消息文件 | |||
| - `id` (string) ID | |||
| - `type` (string) 文件类型,image 图片 | |||
| - `url` (string) 预览图片地址 | |||
| - `url` (string) 文件预览地址,使用文件预览 API (`/files/{file_id}/preview`) 访问文件 | |||
| - `belongs_to` (string) 文件归属方,user 或 assistant | |||
| - `agent_thoughts` (array[object]) Agent思考内容(仅Agent模式下不为空) | |||
| - `id` (string) agent_thought ID,每一轮Agent迭代都会有一个唯一的id | |||
| @@ -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 | |||
| url='/workflows/logs' | |||
| method='GET' | |||
| @@ -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 | |||
| url='/workflows/logs' | |||
| method='GET' | |||