Преглед изворни кода

Merge branch 'main' into feat/rag-2

tags/2.0.0-beta.1
twwu пре 2 месеци
родитељ
комит
fc779d00df
100 измењених фајлова са 6065 додато и 586 уклоњено
  1. 13
    5
      .github/workflows/translate-i18n-base-on-english.yml
  2. 1
    7
      .gitignore
  3. 1
    1
      api/Dockerfile
  4. 7
    7
      api/commands.py
  5. 9
    9
      api/configs/feature/__init__.py
  6. 9
    3
      api/controllers/console/app/app.py
  7. 2
    2
      api/controllers/console/datasets/datasets.py
  8. 1
    1
      api/controllers/service_api/__init__.py
  9. 12
    0
      api/controllers/service_api/app/error.py
  10. 186
    0
      api/controllers/service_api/app/file_preview.py
  11. 2
    2
      api/controllers/service_api/dataset/dataset.py
  12. 4
    3
      api/controllers/web/app.py
  13. 99
    20
      api/core/app/apps/advanced_chat/app_runner.py
  14. 5
    0
      api/core/app/task_pipeline/message_cycle_manager.py
  15. 1
    1
      api/core/entities/provider_configuration.py
  16. 3
    3
      api/core/rag/datasource/vdb/clickzetta/README.md
  17. 532
    280
      api/core/rag/datasource/vdb/clickzetta/clickzetta_vector.py
  18. 5
    0
      api/core/rag/datasource/vdb/tencent/tencent_vector.py
  19. 2
    17
      api/core/rag/extractor/word_extractor.py
  20. 3
    2
      api/core/tools/tool_engine.py
  21. 49
    1
      api/core/tools/utils/message_transformer.py
  22. 10
    0
      api/core/variables/segments.py
  23. 51
    3
      api/core/workflow/nodes/document_extractor/node.py
  24. 2
    3
      api/libs/rsa.py
  25. 3
    3
      api/schedule/clean_embedding_cache_task.py
  26. 3
    3
      api/schedule/clean_messages.py
  27. 5
    5
      api/schedule/clean_unused_datasets_task.py
  28. 9
    5
      api/services/conversation_service.py
  29. 2
    2
      api/services/workflow_draft_variable_service.py
  30. 14
    5
      api/tasks/clean_dataset_task.py
  31. 168
    0
      api/tests/integration_tests/controllers/console/app/test_description_validation.py
  32. 10
    23
      api/tests/integration_tests/vdb/clickzetta/test_clickzetta.py
  33. 11
    11
      api/tests/integration_tests/vdb/clickzetta/test_docker_integration.py
  34. 1252
    0
      api/tests/test_containers_integration_tests/services/test_annotation_service.py
  35. 487
    0
      api/tests/test_containers_integration_tests/services/test_api_based_extension_service.py
  36. 473
    0
      api/tests/test_containers_integration_tests/services/test_app_dsl_service.py
  37. 252
    0
      api/tests/unit_tests/controllers/console/app/test_description_validation.py
  38. 336
    0
      api/tests/unit_tests/controllers/service_api/app/test_file_preview.py
  39. 419
    0
      api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py
  40. 127
    0
      api/tests/unit_tests/services/test_conversation_service.py
  41. 97
    0
      web/__tests__/description-validation.test.tsx
  42. 2
    0
      web/app/(commonLayout)/layout.tsx
  43. 1
    1
      web/app/account/account-page/AvatarWithEdit.tsx
  44. 31
    23
      web/app/components/app-sidebar/app-info.tsx
  45. 1
    1
      web/app/components/app/app-access-control/access-control-dialog.tsx
  46. 2
    2
      web/app/components/app/app-access-control/add-member-or-group-pop.tsx
  47. 12
    14
      web/app/components/app/configuration/dataset-config/select-dataset/index.tsx
  48. 2
    2
      web/app/components/app/create-app-dialog/app-list/sidebar.tsx
  49. 5
    2
      web/app/components/app/create-app-modal/index.tsx
  50. 6
    3
      web/app/components/apps/app-card.tsx
  51. 2
    17
      web/app/components/apps/footer.tsx
  52. 0
    6
      web/app/components/apps/index.tsx
  53. 6
    0
      web/app/components/apps/list.tsx
  54. 1
    1
      web/app/components/base/app-icon-picker/ImageInput.tsx
  55. 2
    2
      web/app/components/base/block-input/index.tsx
  56. 1
    1
      web/app/components/base/button/index.tsx
  57. 1
    2
      web/app/components/base/date-and-time-picker/date-picker/index.tsx
  58. 46
    0
      web/app/components/base/date-and-time-picker/utils/dayjs.ts
  59. 5
    5
      web/app/components/base/dialog/index.tsx
  60. 1
    1
      web/app/components/base/features/new-feature-panel/dialog-wrapper.tsx
  61. 230
    4
      web/app/components/base/file-uploader/utils.spec.ts
  62. 20
    4
      web/app/components/base/file-uploader/utils.ts
  63. 3
    3
      web/app/components/base/fullscreen-modal/index.tsx
  64. 2
    2
      web/app/components/base/grid-mask/index.tsx
  65. 6
    3
      web/app/components/base/input/index.tsx
  66. 1
    1
      web/app/components/base/logo/logo-site.tsx
  67. 0
    1
      web/app/components/base/markdown-blocks/code-block.tsx
  68. 15
    2
      web/app/components/base/markdown-blocks/form.tsx
  69. 1
    11
      web/app/components/base/mermaid/index.tsx
  70. 4
    4
      web/app/components/base/modal/index.tsx
  71. 1
    1
      web/app/components/base/premium-badge/index.tsx
  72. 61
    0
      web/app/components/base/select/locale-signin.tsx
  73. 2
    2
      web/app/components/base/skeleton/index.tsx
  74. 3
    3
      web/app/components/base/switch/index.tsx
  75. 2
    2
      web/app/components/base/tab-slider-new/index.tsx
  76. 2
    2
      web/app/components/base/tag/index.tsx
  77. 2
    2
      web/app/components/billing/pricing/index.tsx
  78. 1
    1
      web/app/components/billing/pricing/self-hosted-plan-item.tsx
  79. 4
    4
      web/app/components/datasets/create/stepper/step.tsx
  80. 3
    3
      web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.tsx
  81. 1
    1
      web/app/components/datasets/documents/detail/embedding/index.tsx
  82. 1
    3
      web/app/components/datasets/external-api/external-api-modal/Form.tsx
  83. 1
    1
      web/app/components/datasets/formatted-text/flavours/edit-slice.tsx
  84. 4
    4
      web/app/components/datasets/formatted-text/flavours/shared.tsx
  85. 2
    2
      web/app/components/datasets/rename-modal/index.tsx
  86. 2
    2
      web/app/components/develop/code.tsx
  87. 3
    1
      web/app/components/develop/secret-key/secret-key-generate.tsx
  88. 3
    1
      web/app/components/develop/secret-key/secret-key-modal.tsx
  89. 79
    0
      web/app/components/develop/template/template.en.mdx
  90. 79
    0
      web/app/components/develop/template/template.ja.mdx
  91. 80
    0
      web/app/components/develop/template/template.zh.mdx
  92. 80
    1
      web/app/components/develop/template/template_advanced_chat.en.mdx
  93. 81
    1
      web/app/components/develop/template/template_advanced_chat.ja.mdx
  94. 81
    1
      web/app/components/develop/template/template_advanced_chat.zh.mdx
  95. 80
    1
      web/app/components/develop/template/template_chat.en.mdx
  96. 80
    1
      web/app/components/develop/template/template_chat.ja.mdx
  97. 81
    1
      web/app/components/develop/template/template_chat.zh.mdx
  98. 80
    0
      web/app/components/develop/template/template_workflow.en.mdx
  99. 80
    0
      web/app/components/develop/template/template_workflow.ja.mdx
  100. 0
    0
      web/app/components/develop/template/template_workflow.zh.mdx

+ 13
- 5
.github/workflows/translate-i18n-base-on-english.yml Прегледај датотеку

@@ -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'

+ 1
- 7
.gitignore Прегледај датотеку

@@ -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

+ 1
- 1
api/Dockerfile Прегледај датотеку

@@ -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

+ 7
- 7
api/commands.py Прегледај датотеку

@@ -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:

+ 9
- 9
api/configs/feature/__init__.py Прегледај датотеку

@@ -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",

+ 9
- 3
api/controllers/console/app/app.py Прегледај датотеку

@@ -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")

+ 2
- 2
api/controllers/console/datasets/datasets.py Прегледај датотеку

@@ -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="",

+ 1
- 1
api/controllers/service_api/__init__.py Прегледај датотеку

@@ -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

+ 12
- 0
api/controllers/service_api/app/error.py Прегледај датотеку

@@ -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

+ 186
- 0
api/controllers/service_api/app/file_preview.py Прегледај датотеку

@@ -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")

+ 2
- 2
api/controllers/service_api/dataset/dataset.py Прегледај датотеку

@@ -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="",

+ 4
- 3
api/controllers/web/app.py Прегледај датотеку

@@ -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")

+ 99
- 20
api/core/app/apps/advanced_chat/app_runner.py Прегледај датотеку

@@ -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

+ 5
- 0
api/core/app/task_pipeline/message_cycle_manager.py Прегледај датотеку

@@ -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:

+ 1
- 1
api/core/entities/provider_configuration.py Прегледај датотеку

@@ -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

+ 3
- 3
api/core/rag/datasource/vdb/clickzetta/README.md Прегледај датотеку

@@ -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)

+ 532
- 280
api/core/rag/datasource/vdb/clickzetta/clickzetta_vector.py
Разлика између датотеке није приказан због своје велике величине
Прегледај датотеку


+ 5
- 0
api/core/rag/datasource/vdb/tencent/tencent_vector.py Прегледај датотеку

@@ -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)

+ 2
- 17
api/core/rag/extractor/word_extractor.py Прегледај датотеку

@@ -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 = []


+ 3
- 2
api/core/tools/tool_engine.py Прегледај датотеку

@@ -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)

+ 49
- 1
api/core/tools/utils/message_transformer.py Прегледај датотеку

@@ -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


+ 10
- 0
api/core/variables/segments.py Прегледај датотеку

@@ -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)



+ 51
- 3
api/core/workflow/nodes/document_extractor/node.py Прегледај датотеку

@@ -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 _:

+ 2
- 3
api/libs/rsa.py Прегледај датотеку

@@ -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
- 3
api/schedule/clean_embedding_cache_task.py Прегледај датотеку

@@ -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
- 3
api/schedule/clean_messages.py Прегледај датотеку

@@ -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:

+ 5
- 5
api/schedule/clean_unused_datasets_task.py Прегледај датотеку

@@ -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:

+ 9
- 5
api/services/conversation_service.py Прегледај датотеку

@@ -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)

+ 2
- 2
api/services/workflow_draft_variable_service.py Прегледај датотеку

@@ -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.")

+ 14
- 5
api/tasks/clean_dataset_task.py Прегледај датотеку

@@ -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)

+ 168
- 0
api/tests/integration_tests/controllers/console/app/test_description_validation.py Прегледај датотеку

@@ -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)

+ 10
- 23
api/tests/integration_tests/vdb/clickzetta/test_clickzetta.py Прегледај датотеку

@@ -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"},
),
]


+ 11
- 11
api/tests/integration_tests/vdb/clickzetta/test_docker_integration.py Прегледај датотеку

@@ -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())

+ 1252
- 0
api/tests/test_containers_integration_tests/services/test_annotation_service.py
Разлика између датотеке није приказан због своје велике величине
Прегледај датотеку


+ 487
- 0
api/tests/test_containers_integration_tests/services/test_api_based_extension_service.py Прегледај датотеку

@@ -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)

+ 473
- 0
api/tests/test_containers_integration_tests/services/test_app_dsl_service.py Прегледај датотеку

@@ -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()

+ 252
- 0
api/tests/unit_tests/controllers/console/app/test_description_validation.py Прегледај датотеку

@@ -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)

+ 336
- 0
api/tests/unit_tests/controllers/service_api/app/test_file_preview.py Прегледај датотеку

@@ -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"},
)

+ 419
- 0
api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py Прегледај датотеку

@@ -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"

+ 127
- 0
api/tests/unit_tests/services/test_conversation_service.py Прегледај датотеку

@@ -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

+ 97
- 0
web/__tests__/description-validation.test.tsx Прегледај датотеку

@@ -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.',
)
}
})
})
})
})

+ 2
- 0
web/app/(commonLayout)/layout.tsx Прегледај датотеку

@@ -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>

+ 1
- 1
web/app/account/account-page/AvatarWithEdit.tsx Прегледај датотеку

@@ -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 />

+ 31
- 23
web/app/components/app-sidebar/app-info.tsx Прегледај датотеку

@@ -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>
)}

+ 1
- 1
web/app/components/app/app-access-control/access-control-dialog.tsx Прегледај датотеку

@@ -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">

+ 2
- 2
web/app/components/app/app-access-control/add-member-or-group-pop.tsx Прегледај датотеку

@@ -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>
}

+ 12
- 14
web/app/components/app/configuration/dataset-config/select-dataset/index.tsx Прегледај датотеку

@@ -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: [] }
},

+ 2
- 2
web/app/components/app/create-app-dialog/app-list/sidebar.tsx Прегледај датотеку

@@ -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 >
}


+ 5
- 2
web/app/components/app/create-app-modal/index.tsx Прегледај датотеку

@@ -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])

+ 6
- 3
web/app/components/apps/app-card.tsx Прегледај датотеку

@@ -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}

+ 2
- 17
web/app/components/apps/footer.tsx Прегледај датотеку

@@ -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'>

+ 0
- 6
web/app/components/apps/index.tsx Прегледај датотеку

@@ -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 >
)
}

+ 6
- 0
web/app/components/apps/list.tsx Прегледај датотеку

@@ -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 && (

+ 1
- 1
web/app/components/base/app-icon-picker/ImageInput.tsx Прегледај датотеку

@@ -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}

+ 2
- 2
web/app/components/base/block-input/index.tsx Прегледај датотеку

@@ -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 && (

+ 1
- 1
web/app/components/base/button/index.tsx Прегледај датотеку

@@ -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>
)
},

+ 1
- 2
web/app/components/base/date-and-time-picker/date-picker/index.tsx Прегледај датотеку

@@ -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'))

+ 46
- 0
web/app/components/base/date-and-time-picker/utils/dayjs.ts Прегледај датотеку

@@ -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')
}
}

+ 5
- 5
web/app/components/base/dialog/index.tsx Прегледај датотеку

@@ -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>

+ 1
- 1
web/app/components/base/features/new-feature-panel/dialog-wrapper.tsx Прегледај датотеку

@@ -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',

+ 230
- 4
web/app/components/base/file-uploader/utils.spec.ts Прегледај датотеку

@@ -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', () => {

+ 20
- 4
web/app/components/base/file-uploader/utils.ts Прегледај датотеку

@@ -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,
}

+ 3
- 3
web/app/components/base/fullscreen-modal/index.tsx Прегледај датотеку

@@ -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

+ 2
- 2
web/app/components/base/grid-mask/index.tsx Прегледај датотеку

@@ -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>
)

+ 6
- 3
web/app/components/base/input/index.tsx Прегледај датотеку

@@ -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

+ 1
- 1
web/app/components/base/logo/logo-site.tsx Прегледај датотеку

@@ -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'
/>
)

+ 0
- 1
web/app/components/base/markdown-blocks/code-block.tsx Прегледај датотеку

@@ -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

+ 15
- 2
web/app/components/base/markdown-blocks/form.tsx Прегледај датотеку

@@ -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
- 11
web/app/components/base/mermaid/index.tsx Прегледај датотеку

@@ -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>
)}


+ 4
- 4
web/app/components/base/modal/index.tsx Прегледај датотеку

@@ -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

+ 1
- 1
web/app/components/base/premium-badge/index.tsx Прегледај датотеку

@@ -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',
)}
/>

+ 61
- 0
web/app/components/base/select/locale-signin.tsx Прегледај датотеку

@@ -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>
)
}

+ 2
- 2
web/app/components/base/skeleton/index.tsx Прегледај датотеку

@@ -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

+ 3
- 3
web/app/components/base/switch/index.tsx Прегледај датотеку

@@ -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>

+ 2
- 2
web/app/components/base/tab-slider-new/index.tsx Прегледај датотеку

@@ -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}

+ 2
- 2
web/app/components/base/tag/index.tsx Прегледај датотеку

@@ -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>

+ 2
- 2
web/app/components/billing/pricing/index.tsx Прегледај датотеку

@@ -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>,
}]}

+ 1
- 1
web/app/components/billing/pricing/self-hosted-plan-item.tsx Прегледај датотеку

@@ -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',
},

+ 4
- 4
web/app/components/datasets/create/stepper/step.tsx Прегледај датотеку

@@ -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',

+ 3
- 3
web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.tsx Прегледај датотеку

@@ -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

+ 1
- 1
web/app/components/datasets/documents/detail/embedding/index.tsx Прегледај датотеку

@@ -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
- 3
web/app/components/datasets/external-api/external-api-modal/Form.tsx Прегледај датотеку

@@ -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 })
}

+ 1
- 1
web/app/components/datasets/formatted-text/flavours/edit-slice.tsx Прегледај датотеку

@@ -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)

+ 4
- 4
web/app/components/datasets/formatted-text/flavours/shared.tsx Прегледај датотеку

@@ -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 */}

+ 2
- 2
web/app/components/datasets/rename-modal/index.tsx Прегледај датотеку

@@ -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 || '' }

+ 2
- 2
web/app/components/develop/code.tsx Прегледај датотеку

@@ -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(() => {

+ 3
- 1
web/app/components/develop/secret-key/secret-key-generate.tsx Прегледај датотеку

@@ -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} />

+ 3
- 1
web/app/components/develop/secret-key/secret-key-modal.tsx Прегледај датотеку

@@ -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>}
{

+ 79
- 0
web/app/components/develop/template/template.en.mdx Прегледај датотеку

@@ -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'

+ 79
- 0
web/app/components/develop/template/template.ja.mdx Прегледај датотеку

@@ -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'

+ 80
- 0
web/app/components/develop/template/template.zh.mdx Прегледај датотеку

@@ -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'

+ 80
- 1
web/app/components/develop/template/template_advanced_chat.en.mdx Прегледај датотеку

@@ -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

+ 81
- 1
web/app/components/develop/template/template_advanced_chat.ja.mdx Прегледај датотеку

@@ -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

+ 81
- 1
web/app/components/develop/template/template_advanced_chat.zh.mdx Прегледај датотеку

@@ -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) 创建时间

+ 80
- 1
web/app/components/develop/template/template_chat.en.mdx Прегледај датотеку

@@ -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

+ 80
- 1
web/app/components/develop/template/template_chat.ja.mdx Прегледај датотеку

@@ -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があります

+ 81
- 1
web/app/components/develop/template/template_chat.zh.mdx Прегледај датотеку

@@ -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

+ 80
- 0
web/app/components/develop/template/template_workflow.en.mdx Прегледај датотеку

@@ -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'

+ 80
- 0
web/app/components/develop/template/template_workflow.ja.mdx Прегледај датотеку

@@ -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'

+ 0
- 0
web/app/components/develop/template/template_workflow.zh.mdx Прегледај датотеку


Неке датотеке нису приказане због велике количине промена

Loading…
Откажи
Сачувај