Compare commits

...

10 Commits

Author SHA1 Message Date
JzoNg
66d67185a0 fix: array number validation in conversation variable 2025-08-19 14:53:30 +08:00
KVOJJJin
5f0b52c017 Fix number input in tool configure form of agent node tool item (#24154) 2025-08-19 14:26:09 +08:00
Stream
c2606f9062 fix: correct behaviour of code fix (#24152)
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-08-19 14:18:49 +08:00
Asuka Minato
70da81d0e5 try ast-grep (#24149) 2025-08-19 13:41:52 +08:00
9527MrLi
75199442c1 feat: Implements periodic deletion of workflow run logs that exceed t… (#23881)
Co-authored-by: shiyun.li973792 <shiyun.li@seres.cn>
Co-authored-by: 1wangshu <suewangswu@gmail.com>
Co-authored-by: Blackoutta <hyytez@gmail.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2025-08-19 09:47:34 +08:00
NeatGuyCoding
60cc82aff1 feat: add testcontainers based tests for feature service (#24026) 2025-08-19 09:32:47 +08:00
Asuka Minato
ebd2c8236d an example of suppress (#24136) 2025-08-19 00:21:26 +08:00
github-actions[bot]
a2537ba4fd chore: translate i18n files (#24131)
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2025-08-18 23:46:23 +08:00
Guangdong Liu
a3a041ef6f feat: add delete avatar functionality with confirmation modal (#24127)
Co-authored-by: crazywoola <427733928@qq.com>
2025-08-18 21:35:20 +08:00
Zhehao Peng
c0702aacac Use typing.Literal to replace str places (#24099)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-08-18 21:34:13 +08:00
51 changed files with 2165 additions and 58 deletions

View File

@@ -23,6 +23,9 @@ jobs:
uv run ruff check --fix-only .
# Format code
uv run ruff format .
- name: ast-grep
run: |
uvx --from ast-grep-cli sg --pattern 'db.session.query($WHATEVER).filter($HERE)' --rewrite 'db.session.query($WHATEVER).where($HERE)' -l py --update-all
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27

View File

@@ -478,6 +478,13 @@ API_WORKFLOW_NODE_EXECUTION_REPOSITORY=repositories.sqlalchemy_api_workflow_node
# API workflow run repository implementation
API_WORKFLOW_RUN_REPOSITORY=repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository
# Workflow log cleanup configuration
# Enable automatic cleanup of workflow run logs to manage database size
WORKFLOW_LOG_CLEANUP_ENABLED=true
# Number of days to retain workflow run logs (default: 30 days)
WORKFLOW_LOG_RETENTION_DAYS=30
# Batch size for workflow log cleanup operations (default: 100)
WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100
# App configuration
APP_MAX_EXECUTION_TIME=1200

View File

@@ -968,6 +968,14 @@ class AccountConfig(BaseSettings):
)
class WorkflowLogConfig(BaseSettings):
WORKFLOW_LOG_CLEANUP_ENABLED: bool = Field(default=True, description="Enable workflow run log cleanup")
WORKFLOW_LOG_RETENTION_DAYS: int = Field(default=30, description="Retention days for workflow run logs")
WORKFLOW_LOG_CLEANUP_BATCH_SIZE: int = Field(
default=100, description="Batch size for workflow run log cleanup operations"
)
class FeatureConfig(
# place the configs in alphabet order
AppExecutionConfig,
@@ -1003,5 +1011,6 @@ class FeatureConfig(
HostedServiceConfig,
CeleryBeatConfig,
CeleryScheduleTasksConfig,
WorkflowLogConfig,
):
pass

View File

@@ -1,3 +1,4 @@
import contextlib
import mimetypes
import os
import platform
@@ -65,10 +66,8 @@ def guess_file_info_from_response(response: httpx.Response):
# Use python-magic to guess MIME type if still unknown or generic
if mimetype == "application/octet-stream" and magic is not None:
try:
with contextlib.suppress(magic.MagicException):
mimetype = magic.from_buffer(response.content[:1024], mime=True)
except magic.MagicException:
pass
extension = os.path.splitext(filename)[1]

View File

@@ -1,3 +1,5 @@
from typing import Literal
from flask import request
from flask_login import current_user
from flask_restful import Resource, marshal, marshal_with, reqparse
@@ -24,7 +26,7 @@ class AnnotationReplyActionApi(Resource):
@login_required
@account_initialization_required
@cloud_edition_billing_resource_check("annotation")
def post(self, app_id, action):
def post(self, app_id, action: Literal["enable", "disable"]):
if not current_user.is_editor:
raise Forbidden()
@@ -38,8 +40,6 @@ class AnnotationReplyActionApi(Resource):
result = AppAnnotationService.enable_app_annotation(args, app_id)
elif action == "disable":
result = AppAnnotationService.disable_app_annotation(app_id)
else:
raise ValueError("Unsupported annotation reply action")
return result, 200

View File

@@ -12,7 +12,6 @@ from controllers.console.app.error import (
)
from controllers.console.wraps import account_initialization_required, setup_required
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from core.helper.code_executor.code_node_provider import CodeNodeProvider
from core.helper.code_executor.javascript.javascript_code_provider import JavascriptCodeProvider
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
from core.llm_generator.llm_generator import LLMGenerator
@@ -126,18 +125,20 @@ class InstructionGenerateApi(Resource):
parser.add_argument("model_config", type=dict, required=True, nullable=False, location="json")
parser.add_argument("ideal_output", type=str, required=False, default="", location="json")
args = parser.parse_args()
providers: list[type[CodeNodeProvider]] = [Python3CodeProvider, JavascriptCodeProvider]
code_provider: type[CodeNodeProvider] | None = next(
(p for p in providers if p.is_accept_language(args["language"])), None
code_template = (
Python3CodeProvider.get_default_code()
if args["language"] == "python"
else (JavascriptCodeProvider.get_default_code())
if args["language"] == "javascript"
else ""
)
code_template = code_provider.get_default_code() if code_provider else ""
try:
# Generate from nothing for a workflow node
if (args["current"] == code_template or args["current"] == "") and args["node_id"] != "":
from models import App, db
from services.workflow_service import WorkflowService
app = db.session.query(App).filter(App.id == args["flow_id"]).first()
app = db.session.query(App).where(App.id == args["flow_id"]).first()
if not app:
return {"error": f"app {args['flow_id']} not found"}, 400
workflow = WorkflowService().get_draft_workflow(app_model=app)

View File

@@ -1,6 +1,6 @@
import logging
from argparse import ArgumentTypeError
from typing import cast
from typing import Literal, cast
from flask import request
from flask_login import current_user
@@ -758,7 +758,7 @@ class DocumentProcessingApi(DocumentResource):
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
def patch(self, dataset_id, document_id, action):
def patch(self, dataset_id, document_id, action: Literal["pause", "resume"]):
dataset_id = str(dataset_id)
document_id = str(document_id)
document = self.get_document(dataset_id, document_id)
@@ -784,8 +784,6 @@ class DocumentProcessingApi(DocumentResource):
document.paused_at = None
document.is_paused = False
db.session.commit()
else:
raise InvalidActionError()
return {"result": "success"}, 200
@@ -840,7 +838,7 @@ class DocumentStatusApi(DocumentResource):
@account_initialization_required
@cloud_edition_billing_resource_check("vector_space")
@cloud_edition_billing_rate_limit_check("knowledge")
def patch(self, dataset_id, action):
def patch(self, dataset_id, action: Literal["enable", "disable", "archive", "un_archive"]):
dataset_id = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id)
if dataset is None:

View File

@@ -1,3 +1,5 @@
from typing import Literal
from flask_login import current_user
from flask_restful import Resource, marshal_with, reqparse
from werkzeug.exceptions import NotFound
@@ -100,7 +102,7 @@ class DatasetMetadataBuiltInFieldActionApi(Resource):
@login_required
@account_initialization_required
@enterprise_license_required
def post(self, dataset_id, action):
def post(self, dataset_id, action: Literal["enable", "disable"]):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:

View File

@@ -39,7 +39,7 @@ class UploadFileApi(Resource):
data_source_info = document.data_source_info_dict
if data_source_info and "upload_file_id" in data_source_info:
file_id = data_source_info["upload_file_id"]
upload_file = db.session.query(UploadFile).filter(UploadFile.id == file_id).first()
upload_file = db.session.query(UploadFile).where(UploadFile.id == file_id).first()
if not upload_file:
raise NotFound("UploadFile not found.")
else:

View File

@@ -1,3 +1,5 @@
from typing import Literal
from flask import request
from flask_restful import Resource, marshal, marshal_with, reqparse
from werkzeug.exceptions import Forbidden
@@ -15,7 +17,7 @@ from services.annotation_service import AppAnnotationService
class AnnotationReplyActionApi(Resource):
@validate_app_token
def post(self, app_model: App, action):
def post(self, app_model: App, action: Literal["enable", "disable"]):
parser = reqparse.RequestParser()
parser.add_argument("score_threshold", required=True, type=float, location="json")
parser.add_argument("embedding_provider_name", required=True, type=str, location="json")
@@ -25,8 +27,6 @@ class AnnotationReplyActionApi(Resource):
result = AppAnnotationService.enable_app_annotation(args, app_model.id)
elif action == "disable":
result = AppAnnotationService.disable_app_annotation(app_model.id)
else:
raise ValueError("Unsupported annotation reply action")
return result, 200

View File

@@ -1,3 +1,5 @@
from typing import Literal
from flask import request
from flask_restful import marshal, marshal_with, reqparse
from werkzeug.exceptions import Forbidden, NotFound
@@ -358,14 +360,14 @@ class DatasetApi(DatasetApiResource):
class DocumentStatusApi(DatasetApiResource):
"""Resource for batch document status operations."""
def patch(self, tenant_id, dataset_id, action):
def patch(self, tenant_id, dataset_id, action: Literal["enable", "disable", "archive", "un_archive"]):
"""
Batch update document status.
Args:
tenant_id: tenant id
dataset_id: dataset id
action: action to perform (enable, disable, archive, un_archive)
action: action to perform (Literal["enable", "disable", "archive", "un_archive"])
Returns:
dict: A dictionary with a key 'result' and a value 'success'

View File

@@ -1,3 +1,5 @@
from typing import Literal
from flask_login import current_user # type: ignore
from flask_restful import marshal, reqparse
from werkzeug.exceptions import NotFound
@@ -77,7 +79,7 @@ class DatasetMetadataBuiltInFieldServiceApi(DatasetApiResource):
class DatasetMetadataBuiltInFieldActionServiceApi(DatasetApiResource):
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id, action):
def post(self, tenant_id, dataset_id, action: Literal["enable", "disable"]):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:

View File

@@ -181,7 +181,7 @@ class MessageCycleManager:
:param message_id: message id
:return:
"""
message_file = db.session.query(MessageFile).filter(MessageFile.id == message_id).first()
message_file = db.session.query(MessageFile).where(MessageFile.id == message_id).first()
event_type = StreamEvent.MESSAGE_FILE if message_file else StreamEvent.MESSAGE
return MessageStreamResponse(

View File

@@ -399,9 +399,9 @@ class LLMGenerator:
def instruction_modify_legacy(
tenant_id: str, flow_id: str, current: str, instruction: str, model_config: dict, ideal_output: str | None
) -> dict:
app: App | None = db.session.query(App).filter(App.id == flow_id).first()
app: App | None = db.session.query(App).where(App.id == flow_id).first()
last_run: Message | None = (
db.session.query(Message).filter(Message.app_id == flow_id).order_by(Message.created_at.desc()).first()
db.session.query(Message).where(Message.app_id == flow_id).order_by(Message.created_at.desc()).first()
)
if not last_run:
return LLMGenerator.__instruction_modify_common(
@@ -442,7 +442,7 @@ class LLMGenerator:
) -> dict:
from services.workflow_service import WorkflowService
app: App | None = db.session.query(App).filter(App.id == flow_id).first()
app: App | None = db.session.query(App).where(App.id == flow_id).first()
if not app:
raise ValueError("App not found.")
workflow = WorkflowService().get_draft_workflow(app_model=app)

View File

@@ -414,7 +414,7 @@ When you are modifying the code, you should remember:
- Get inputs from the parameters of the function and have explicit type annotations.
- Write proper imports at the top of the code.
- Use return statement to return the result.
- You should return a `dict`.
- You should return a `dict`. If you need to return a `result: str`, you should `return {"result": result}`.
Your output must strictly follow the schema format, do not output any content outside of the JSON body.
""" # noqa: E501

View File

@@ -151,7 +151,13 @@ def init_app(app: DifyApp) -> Celery:
"task": "schedule.check_upgradable_plugin_task.check_upgradable_plugin_task",
"schedule": crontab(minute="*/15"),
}
if dify_config.WORKFLOW_LOG_CLEANUP_ENABLED:
# 2:00 AM every day
imports.append("schedule.clean_workflow_runlogs_precise")
beat_schedule["clean_workflow_runlogs_precise"] = {
"task": "schedule.clean_workflow_runlogs_precise.clean_workflow_runlogs_precise",
"schedule": crontab(minute="0", hour="2"),
}
celery_app.conf.update(beat_schedule=beat_schedule, imports=imports)
return celery_app

View File

@@ -0,0 +1,155 @@
import datetime
import logging
import time
import click
import app
from configs import dify_config
from extensions.ext_database import db
from models.model import (
AppAnnotationHitHistory,
Conversation,
Message,
MessageAgentThought,
MessageAnnotation,
MessageChain,
MessageFeedback,
MessageFile,
)
from models.workflow import ConversationVariable, WorkflowAppLog, WorkflowNodeExecutionModel, WorkflowRun
_logger = logging.getLogger(__name__)
MAX_RETRIES = 3
BATCH_SIZE = dify_config.WORKFLOW_LOG_CLEANUP_BATCH_SIZE
@app.celery.task(queue="dataset")
def clean_workflow_runlogs_precise():
"""Clean expired workflow run logs with retry mechanism and complete message cascade"""
click.echo(click.style("Start clean workflow run logs (precise mode with complete cascade).", fg="green"))
start_at = time.perf_counter()
retention_days = dify_config.WORKFLOW_LOG_RETENTION_DAYS
cutoff_date = datetime.datetime.now() - datetime.timedelta(days=retention_days)
try:
total_workflow_runs = db.session.query(WorkflowRun).where(WorkflowRun.created_at < cutoff_date).count()
if total_workflow_runs == 0:
_logger.info("No expired workflow run logs found")
return
_logger.info("Found %s expired workflow run logs to clean", total_workflow_runs)
total_deleted = 0
failed_batches = 0
batch_count = 0
while True:
workflow_runs = (
db.session.query(WorkflowRun.id).where(WorkflowRun.created_at < cutoff_date).limit(BATCH_SIZE).all()
)
if not workflow_runs:
break
workflow_run_ids = [run.id for run in workflow_runs]
batch_count += 1
success = _delete_batch_with_retry(workflow_run_ids, failed_batches)
if success:
total_deleted += len(workflow_run_ids)
failed_batches = 0
else:
failed_batches += 1
if failed_batches >= MAX_RETRIES:
_logger.error("Failed to delete batch after %s retries, aborting cleanup for today", MAX_RETRIES)
break
else:
# Calculate incremental delay times: 5, 10, 15 minutes
retry_delay_minutes = failed_batches * 5
_logger.warning("Batch deletion failed, retrying in %s minutes...", retry_delay_minutes)
time.sleep(retry_delay_minutes * 60)
continue
_logger.info("Cleanup completed: %s expired workflow run logs deleted", total_deleted)
except Exception as e:
db.session.rollback()
_logger.exception("Unexpected error in workflow log cleanup")
raise
end_at = time.perf_counter()
execution_time = end_at - start_at
click.echo(click.style(f"Cleaned workflow run logs from db success latency: {execution_time:.2f}s", fg="green"))
def _delete_batch_with_retry(workflow_run_ids: list[str], attempt_count: int) -> bool:
"""Delete a single batch with a retry mechanism and complete cascading deletion"""
try:
with db.session.begin_nested():
message_data = (
db.session.query(Message.id, Message.conversation_id)
.filter(Message.workflow_run_id.in_(workflow_run_ids))
.all()
)
message_id_list = [msg.id for msg in message_data]
conversation_id_list = list({msg.conversation_id for msg in message_data if msg.conversation_id})
if message_id_list:
db.session.query(AppAnnotationHitHistory).where(
AppAnnotationHitHistory.message_id.in_(message_id_list)
).delete(synchronize_session=False)
db.session.query(MessageAgentThought).where(MessageAgentThought.message_id.in_(message_id_list)).delete(
synchronize_session=False
)
db.session.query(MessageChain).where(MessageChain.message_id.in_(message_id_list)).delete(
synchronize_session=False
)
db.session.query(MessageFile).where(MessageFile.message_id.in_(message_id_list)).delete(
synchronize_session=False
)
db.session.query(MessageAnnotation).where(MessageAnnotation.message_id.in_(message_id_list)).delete(
synchronize_session=False
)
db.session.query(MessageFeedback).where(MessageFeedback.message_id.in_(message_id_list)).delete(
synchronize_session=False
)
db.session.query(Message).where(Message.workflow_run_id.in_(workflow_run_ids)).delete(
synchronize_session=False
)
db.session.query(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(workflow_run_ids)).delete(
synchronize_session=False
)
db.session.query(WorkflowNodeExecutionModel).where(
WorkflowNodeExecutionModel.workflow_run_id.in_(workflow_run_ids)
).delete(synchronize_session=False)
if conversation_id_list:
db.session.query(ConversationVariable).where(
ConversationVariable.conversation_id.in_(conversation_id_list)
).delete(synchronize_session=False)
db.session.query(Conversation).where(Conversation.id.in_(conversation_id_list)).delete(
synchronize_session=False
)
db.session.query(WorkflowRun).where(WorkflowRun.id.in_(workflow_run_ids)).delete(synchronize_session=False)
db.session.commit()
return True
except Exception as e:
db.session.rollback()
_logger.exception("Batch deletion failed (attempt %s)", attempt_count + 1)
return False

View File

@@ -293,7 +293,7 @@ class AppAnnotationService:
annotation_ids_to_delete = [annotation.id for annotation, _ in annotations_to_delete]
# Step 2: Bulk delete hit histories in a single query
db.session.query(AppAnnotationHitHistory).filter(
db.session.query(AppAnnotationHitHistory).where(
AppAnnotationHitHistory.annotation_id.in_(annotation_ids_to_delete)
).delete(synchronize_session=False)
@@ -307,7 +307,7 @@ class AppAnnotationService:
# Step 4: Bulk delete annotations in a single query
deleted_count = (
db.session.query(MessageAnnotation)
.filter(MessageAnnotation.id.in_(annotation_ids_to_delete))
.where(MessageAnnotation.id.in_(annotation_ids_to_delete))
.delete(synchronize_session=False)
)
@@ -505,9 +505,9 @@ class AppAnnotationService:
db.session.query(AppAnnotationSetting).where(AppAnnotationSetting.app_id == app_id).first()
)
annotations_query = db.session.query(MessageAnnotation).filter(MessageAnnotation.app_id == app_id)
annotations_query = db.session.query(MessageAnnotation).where(MessageAnnotation.app_id == app_id)
for annotation in annotations_query.yield_per(100):
annotation_hit_histories_query = db.session.query(AppAnnotationHitHistory).filter(
annotation_hit_histories_query = db.session.query(AppAnnotationHitHistory).where(
AppAnnotationHitHistory.annotation_id == annotation.id
)
for annotation_hit_history in annotation_hit_histories_query.yield_per(100):

View File

@@ -6,7 +6,7 @@ import secrets
import time
import uuid
from collections import Counter
from typing import Any, Optional
from typing import Any, Literal, Optional
from flask_login import current_user
from sqlalchemy import func, select
@@ -51,7 +51,7 @@ from services.entities.knowledge_entities.knowledge_entities import (
RetrievalModel,
SegmentUpdateArgs,
)
from services.errors.account import InvalidActionError, NoPermissionError
from services.errors.account import NoPermissionError
from services.errors.chunk import ChildChunkDeleteIndexError, ChildChunkIndexingError
from services.errors.dataset import DatasetNameDuplicateError
from services.errors.document import DocumentIndexingError
@@ -1800,14 +1800,16 @@ class DocumentService:
raise ValueError("Process rule segmentation max_tokens is invalid")
@staticmethod
def batch_update_document_status(dataset: Dataset, document_ids: list[str], action: str, user):
def batch_update_document_status(
dataset: Dataset, document_ids: list[str], action: Literal["enable", "disable", "archive", "un_archive"], user
):
"""
Batch update document status.
Args:
dataset (Dataset): The dataset object
document_ids (list[str]): List of document IDs to update
action (str): Action to perform (enable, disable, archive, un_archive)
action (Literal["enable", "disable", "archive", "un_archive"]): Action to perform
user: Current user performing the action
Raises:
@@ -1890,9 +1892,10 @@ class DocumentService:
raise propagation_error
@staticmethod
def _prepare_document_status_update(document, action: str, user):
"""
Prepare document status update information.
def _prepare_document_status_update(
document: Document, action: Literal["enable", "disable", "archive", "un_archive"], user
):
"""Prepare document status update information.
Args:
document: Document object to update
@@ -2355,7 +2358,9 @@ class SegmentService:
db.session.commit()
@classmethod
def update_segments_status(cls, segment_ids: list, action: str, dataset: Dataset, document: Document):
def update_segments_status(
cls, segment_ids: list, action: Literal["enable", "disable"], dataset: Dataset, document: Document
):
# Check if segment_ids is not empty to avoid WHERE false condition
if not segment_ids or len(segment_ids) == 0:
return
@@ -2413,8 +2418,6 @@ class SegmentService:
db.session.commit()
disable_segments_from_index_task.delay(real_deal_segment_ids, dataset.id, document.id)
else:
raise InvalidActionError()
@classmethod
def create_child_chunk(

View File

@@ -1,5 +1,6 @@
import logging
import time
from typing import Literal
import click
from celery import shared_task # type: ignore
@@ -13,7 +14,7 @@ from models.dataset import Document as DatasetDocument
@shared_task(queue="dataset")
def deal_dataset_vector_index_task(dataset_id: str, action: str):
def deal_dataset_vector_index_task(dataset_id: str, action: Literal["remove", "add", "update"]):
"""
Async deal dataset from index
:param dataset_id: dataset_id

View File

@@ -471,7 +471,7 @@ class TestAnnotationService:
# Verify annotation was deleted
from extensions.ext_database import db
deleted_annotation = db.session.query(MessageAnnotation).filter(MessageAnnotation.id == annotation_id).first()
deleted_annotation = db.session.query(MessageAnnotation).where(MessageAnnotation.id == annotation_id).first()
assert deleted_annotation is None
# Verify delete_annotation_index_task was called (when annotation setting exists)
@@ -1175,7 +1175,7 @@ class TestAnnotationService:
AppAnnotationService.delete_app_annotation(app.id, annotation_id)
# Verify annotation was deleted
deleted_annotation = db.session.query(MessageAnnotation).filter(MessageAnnotation.id == annotation_id).first()
deleted_annotation = db.session.query(MessageAnnotation).where(MessageAnnotation.id == annotation_id).first()
assert deleted_annotation is None
# Verify delete_annotation_index_task was called

View File

@@ -234,7 +234,7 @@ class TestAPIBasedExtensionService:
# Verify extension was deleted
from extensions.ext_database import db
deleted_extension = db.session.query(APIBasedExtension).filter(APIBasedExtension.id == extension_id).first()
deleted_extension = db.session.query(APIBasedExtension).where(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):

File diff suppressed because it is too large Load Diff

View File

@@ -484,7 +484,7 @@ class TestMessageService:
# Verify feedback was deleted
from extensions.ext_database import db
deleted_feedback = db.session.query(MessageFeedback).filter(MessageFeedback.id == feedback.id).first()
deleted_feedback = db.session.query(MessageFeedback).where(MessageFeedback.id == feedback.id).first()
assert deleted_feedback is None
def test_create_feedback_no_rating_when_not_exists(

View File

@@ -469,6 +469,6 @@ class TestModelLoadBalancingService:
# Verify inherit config was created in database
inherit_configs = (
db.session.query(LoadBalancingModelConfig).filter(LoadBalancingModelConfig.name == "__inherit__").all()
db.session.query(LoadBalancingModelConfig).where(LoadBalancingModelConfig.name == "__inherit__").all()
)
assert len(inherit_configs) == 1

View File

@@ -887,6 +887,14 @@ API_WORKFLOW_RUN_REPOSITORY=repositories.sqlalchemy_api_workflow_run_repository.
# API workflow node execution repository implementation
API_WORKFLOW_NODE_EXECUTION_REPOSITORY=repositories.sqlalchemy_api_workflow_node_execution_repository.DifyAPISQLAlchemyWorkflowNodeExecutionRepository
# Workflow log cleanup configuration
# Enable automatic cleanup of workflow run logs to manage database size
WORKFLOW_LOG_CLEANUP_ENABLED=false
# Number of days to retain workflow run logs (default: 30 days)
WORKFLOW_LOG_RETENTION_DAYS=30
# Batch size for workflow log cleanup operations (default: 100)
WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100
# HTTP request node in workflow configuration
HTTP_REQUEST_NODE_MAX_BINARY_SIZE=10485760
HTTP_REQUEST_NODE_MAX_TEXT_SIZE=1048576

View File

@@ -396,6 +396,9 @@ x-shared-env: &shared-api-worker-env
CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY: ${CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY:-core.repositories.sqlalchemy_workflow_node_execution_repository.SQLAlchemyWorkflowNodeExecutionRepository}
API_WORKFLOW_RUN_REPOSITORY: ${API_WORKFLOW_RUN_REPOSITORY:-repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository}
API_WORKFLOW_NODE_EXECUTION_REPOSITORY: ${API_WORKFLOW_NODE_EXECUTION_REPOSITORY:-repositories.sqlalchemy_api_workflow_node_execution_repository.DifyAPISQLAlchemyWorkflowNodeExecutionRepository}
WORKFLOW_LOG_CLEANUP_ENABLED: ${WORKFLOW_LOG_CLEANUP_ENABLED:-false}
WORKFLOW_LOG_RETENTION_DAYS: ${WORKFLOW_LOG_RETENTION_DAYS:-30}
WORKFLOW_LOG_CLEANUP_BATCH_SIZE: ${WORKFLOW_LOG_CLEANUP_BATCH_SIZE:-100}
HTTP_REQUEST_NODE_MAX_BINARY_SIZE: ${HTTP_REQUEST_NODE_MAX_BINARY_SIZE:-10485760}
HTTP_REQUEST_NODE_MAX_TEXT_SIZE: ${HTTP_REQUEST_NODE_MAX_TEXT_SIZE:-1048576}
HTTP_REQUEST_NODE_SSL_VERIFY: ${HTTP_REQUEST_NODE_SSL_VERIFY:-True}

View File

@@ -4,7 +4,7 @@ import type { Area } from 'react-easy-crop'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { RiPencilLine } from '@remixicon/react'
import { RiDeleteBin5Line, RiPencilLine } from '@remixicon/react'
import { updateUserProfile } from '@/service/common'
import { ToastContext } from '@/app/components/base/toast'
import ImageInput, { type OnImageInput } from '@/app/components/base/app-icon-picker/ImageInput'
@@ -27,6 +27,8 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
const [inputImageInfo, setInputImageInfo] = useState<InputImageInfo>()
const [isShowAvatarPicker, setIsShowAvatarPicker] = useState(false)
const [uploading, setUploading] = useState(false)
const [isShowDeleteConfirm, setIsShowDeleteConfirm] = useState(false)
const [hoverArea, setHoverArea] = useState<string>('left')
const handleImageInput: OnImageInput = useCallback(async (isCropped: boolean, fileOrTempUrl: string | File, croppedAreaPixels?: Area, fileName?: string) => {
setInputImageInfo(
@@ -48,6 +50,18 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
}
}, [notify, onSave, t])
const handleDeleteAvatar = useCallback(async () => {
try {
await updateUserProfile({ url: 'account/avatar', body: { avatar: '' } })
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
setIsShowDeleteConfirm(false)
onSave?.()
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
}
}, [notify, onSave, t])
const { handleLocalFileUpload } = useLocalFileUploader({
limit: 3,
disabled: false,
@@ -86,12 +100,21 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
<div className="group relative">
<Avatar {...props} />
<div
onClick={() => { setIsShowAvatarPicker(true) }}
className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => hoverArea === 'right' ? setIsShowDeleteConfirm(true) : setIsShowAvatarPicker(true)}
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const isRight = x > rect.width / 2
setHoverArea(isRight ? 'right' : 'left')
}}
>
<span className="text-xs text-white">
{hoverArea === 'right' ? <span className="text-xs text-white">
<RiDeleteBin5Line />
</span> : <span className="text-xs text-white">
<RiPencilLine />
</span>
</span>}
</div>
</div>
</div>
@@ -115,6 +138,26 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
</Button>
</div>
</Modal>
<Modal
closable
className="!w-[362px] !p-6"
isShow={isShowDeleteConfirm}
onClose={() => setIsShowDeleteConfirm(false)}
>
<div className="title-2xl-semi-bold mb-3 text-text-primary">{t('common.avatar.deleteTitle')}</div>
<p className="mb-8 text-text-secondary">{t('common.avatar.deleteDescription')}</p>
<div className="flex w-full items-center justify-center gap-2">
<Button className="w-full" onClick={() => setIsShowDeleteConfirm(false)}>
{t('common.operation.cancel')}
</Button>
<Button variant="warning" className="w-full" onClick={handleDeleteAvatar}>
{t('common.operation.delete')}
</Button>
</div>
</Modal>
</>
)
}

View File

@@ -142,7 +142,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
ideal_output: ideaOutput,
language: languageMap[codeLanguages] || 'javascript',
})
if(!currentCode)
if((res as any).code) // not current or current is the same as the template would return a code field
res.modified = (res as any).code
if (error) {

View File

@@ -259,7 +259,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
className='h-8 grow'
type='number'
value={varInput?.value || ''}
onChange={handleValueChange(variable, type)}
onChange={e => handleValueChange(variable, type)(e.target.value)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
)}

View File

@@ -127,7 +127,7 @@ const ChatVariableModal = ({
case ChatVarType.ArrayString:
case ChatVarType.ArrayNumber:
case ChatVarType.ArrayObject:
return value?.filter(Boolean) || []
return value?.filter((item: any) => item !== null && item !== undefined && item !== '') || []
}
}

View File

@@ -715,6 +715,10 @@ const translation = {
supportedFormats: 'Unterstützt PNG, JPG, JPEG, WEBP und GIF',
},
you: 'Du',
avatar: {
deleteTitle: 'Avatar entfernen',
deleteDescription: 'Bist du sicher, dass du dein Profilbild entfernen möchtest? Dein Konto wird das standardmäßige Anfangs-Avatar verwenden.',
},
}
export default translation

View File

@@ -709,6 +709,10 @@ const translation = {
pagination: {
perPage: 'Items per page',
},
avatar: {
deleteTitle: 'Remove Avatar',
deleteDescription: 'Are you sure you want to remove your profile picture? Your account will use the default initial avatar.',
},
imageInput: {
dropImageHere: 'Drop your image here, or',
browse: 'browse',

View File

@@ -715,6 +715,10 @@ const translation = {
dropImageHere: 'Deja tu imagen aquí, o',
},
you: 'Tú',
avatar: {
deleteTitle: 'Eliminar Avatar',
deleteDescription: '¿Estás seguro de que deseas eliminar tu foto de perfil? Tu cuenta usará el avatar inicial predeterminado.',
},
}
export default translation

View File

@@ -716,6 +716,10 @@ const translation = {
browse: 'مرورگر',
},
you: 'تو',
avatar: {
deleteTitle: 'حذف آواتار',
deleteDescription: 'آیا مطمئن هستید که می‌خواهید تصویر پروفایل خود را حذف کنید؟ حساب شما از آواتار اولیه پیش‌فرض استفاده خواهد کرد.',
},
}
export default translation

View File

@@ -716,6 +716,10 @@ const translation = {
supportedFormats: 'Prend en charge PNG, JPG, JPEG, WEBP et GIF',
},
you: 'Vous',
avatar: {
deleteTitle: 'Supprimer l\'avatar',
deleteDescription: 'Êtes-vous sûr de vouloir supprimer votre photo de profil ? Votre compte utilisera l\'avatar par défaut.',
},
}
export default translation

View File

@@ -738,6 +738,10 @@ const translation = {
dropImageHere: 'अपनी छवि यहाँ छोड़ें, या',
},
you: 'आप',
avatar: {
deleteTitle: 'अवतार हटाएँ',
deleteDescription: 'क्या आप सुनिश्चित हैं कि आप अपनी प्रोफ़ाइल तस्वीर को हटाना चाहते हैं? आपका खाता डिफ़ॉल्ट प्रारंभिक अवतार का उपयोग करेगा।',
},
}
export default translation

View File

@@ -747,6 +747,10 @@ const translation = {
dropImageHere: 'Trascina la tua immagine qui, oppure',
},
you: 'Tu',
avatar: {
deleteTitle: 'Rimuovi avatar',
deleteDescription: 'Sei sicuro di voler rimuovere la tua immagine del profilo? Il tuo account utilizzerà l\'avatar iniziale predefinito.',
},
}
export default translation

View File

@@ -715,6 +715,10 @@ const translation = {
supportedFormats: 'PNG、JPG、JPEG、WEBP、および GIF をサポートしています。',
dropImageHere: 'ここに画像をドロップするか、',
},
avatar: {
deleteTitle: 'アバターを削除する',
deleteDescription: '本当にプロフィール写真を削除してもよろしいですか?あなたのアカウントはデフォルトの初期アバターを使用します。',
},
}
export default translation

View File

@@ -711,6 +711,10 @@ const translation = {
dropImageHere: '여기에 이미지를 드롭하거나',
},
you: '너',
avatar: {
deleteTitle: '아바타 제거하기',
deleteDescription: '프로필 사진을 제거하시겠습니까? 귀하의 계정은 기본 초기 아바타를 사용하게 됩니다.',
},
}
export default translation

View File

@@ -734,6 +734,10 @@ const translation = {
supportedFormats: 'Obsługuje PNG, JPG, JPEG, WEBP i GIF',
},
you: 'Ty',
avatar: {
deleteTitle: 'Usuń awatar',
deleteDescription: 'Czy na pewno chcesz usunąć swoje zdjęcie profilowe? Twoje konto będzie używać domyślnego, początkowego awatara.',
},
}
export default translation

View File

@@ -716,6 +716,10 @@ const translation = {
browse: 'navegar',
},
you: 'Você',
avatar: {
deleteTitle: 'Remover Avatar',
deleteDescription: 'Você tem certeza de que deseja remover sua foto de perfil? Sua conta usará o avatar padrão inicial.',
},
}
export default translation

View File

@@ -716,6 +716,10 @@ const translation = {
dropImageHere: 'Trageți imaginea aici sau',
},
you: 'Tu',
avatar: {
deleteDescription: 'Ești sigur că vrei să îți ștergi fotografia de profil? Contul tău va folosi avatarul inițial implicit.',
deleteTitle: 'Îndepărtează avatarul',
},
}
export default translation

View File

@@ -716,6 +716,10 @@ const translation = {
supportedFormats: 'Поддерживает PNG, JPG, JPEG, WEBP и GIF',
},
you: 'Ты',
avatar: {
deleteTitle: 'Удалить аватар',
deleteDescription: 'Вы уверены, что хотите удалить свою фотографию профиля? Ваш аккаунт будет использовать стандартный аватар.',
},
}
export default translation

View File

@@ -914,6 +914,10 @@ const translation = {
dropImageHere: 'Tukaj spustite svojo sliko ali',
},
you: 'Ti',
avatar: {
deleteTitle: 'Odstrani avatar',
deleteDescription: 'Ali ste prepričani, da želite odstraniti svojo profilno sliko? Vaš račun bo uporabljal privzeti začetni avatar.',
},
}
export default translation

View File

@@ -711,6 +711,10 @@ const translation = {
supportedFormats: 'รองรับ PNG, JPG, JPEG, WEBP และ GIF',
},
you: 'คุณ',
avatar: {
deleteTitle: 'ลบอวตาร',
deleteDescription: 'คุณแน่ใจหรือไม่ว่าต้องการลบรูปโปรไฟล์ของคุณ? บัญชีของคุณจะใช้รูปโปรไฟล์เริ่มต้นตามค่าเริ่มต้น.',
},
}
export default translation

View File

@@ -716,6 +716,10 @@ const translation = {
browse: 'tarayıcı',
},
you: 'Sen',
avatar: {
deleteTitle: 'Avatarı kaldır',
deleteDescription: 'Profil resminizi kaldırmak istediğinize emin misiniz? Hesabınız varsayılan başlangıç avatarını kullanacaktır.',
},
}
export default translation

View File

@@ -717,6 +717,10 @@ const translation = {
dropImageHere: 'Перетягніть зображення сюди або',
},
you: 'Ти',
avatar: {
deleteTitle: 'Видалити аватар',
deleteDescription: 'Ви впевнені, що хочете видалити своє фото профілю? Ваш обліковий запис використовуватиме стандартний початковий аватар.',
},
}
export default translation

View File

@@ -716,6 +716,10 @@ const translation = {
browse: 'duyệt',
},
you: 'Bạn',
avatar: {
deleteTitle: 'Xóa Ảnh Đại Diện',
deleteDescription: 'Bạn có chắc chắn muốn xóa ảnh đại diện của mình không? Tài khoản của bạn sẽ sử dụng avatar mặc định.',
},
}
export default translation

View File

@@ -709,6 +709,10 @@ const translation = {
pagination: {
perPage: '每页显示',
},
avatar: {
deleteTitle: '删除头像',
deleteDescription: '确定要删除你的个人头像吗?你的账号将使用默认的首字母头像。',
},
imageInput: {
dropImageHere: '将图片拖放到此处,或',
browse: '浏览',

View File

@@ -715,6 +715,10 @@ const translation = {
dropImageHere: '將您的圖片放在這裡,或',
},
you: '你',
avatar: {
deleteTitle: '移除頭像',
deleteDescription: '您確定要刪除您的個人資料照片嗎?您的帳戶將使用默認的初始頭像。',
},
}
export default translation