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