import contextlib import logging from collections.abc import Callable, Sequence from typing import Any from sqlalchemy import asc, desc, func, or_, select from sqlalchemy.orm import Session from clients.agent_backend import AgentBackendSessionCleanupPayload from configs import dify_config from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore from core.app.entities.app_invoke_entities import InvokeFrom from core.llm_generator.llm_generator import LLMGenerator from factories import variable_factory from graphon.variables.types import SegmentType from libs.datetime_utils import naive_utc_now from libs.infinite_scroll_pagination import InfiniteScrollPagination from models import Account, ConversationVariable from models.model import App, Conversation, EndUser, Message from services.errors.conversation import ( ConversationNotExistsError, ConversationVariableNotExistsError, ConversationVariableTypeMismatchError, LastConversationNotExistsError, ) from services.errors.message import MessageNotExistsError from tasks.agent_backend_session_cleanup_task import cleanup_conversation_agent_runtime_session from tasks.delete_conversation_task import delete_conversation_related_data logger = logging.getLogger(__name__) class ConversationService: @classmethod def pagination_by_last_id( cls, *, session: Session, app_model: App, user: Account | EndUser | None, last_id: str | None, limit: int, invoke_from: InvokeFrom, include_ids: Sequence[str] | None = None, exclude_ids: Sequence[str] | None = None, sort_by: str = "-updated_at", ) -> InfiniteScrollPagination: if not user: return InfiniteScrollPagination(data=[], limit=limit, has_more=False) stmt = select(Conversation).where( Conversation.is_deleted == False, Conversation.app_id == app_model.id, Conversation.from_source == ("api" if isinstance(user, EndUser) else "console"), Conversation.from_end_user_id == (user.id if isinstance(user, EndUser) else None), Conversation.from_account_id == (user.id if isinstance(user, Account) else None), or_(Conversation.invoke_from.is_(None), Conversation.invoke_from == invoke_from.value), ) # 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 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) if last_id: last_conversation = session.scalar(stmt.where(Conversation.id == last_id)) if not last_conversation: raise LastConversationNotExistsError() # build filters based on sorting filter_condition = cls._build_filter_condition( sort_field=sort_field, sort_direction=sort_direction, reference_conversation=last_conversation, ) stmt = stmt.where(filter_condition) query_stmt = stmt.order_by(sort_direction(getattr(Conversation, sort_field))).limit(limit) conversations = session.scalars(query_stmt).all() has_more = False if len(conversations) == limit: current_page_last_conversation = conversations[-1] rest_filter_condition = cls._build_filter_condition( sort_field=sort_field, sort_direction=sort_direction, reference_conversation=current_page_last_conversation, ) count_stmt = select(func.count()).select_from(stmt.where(rest_filter_condition).subquery()) rest_count = session.scalar(count_stmt) or 0 if rest_count > 0: has_more = True return InfiniteScrollPagination(data=conversations, limit=limit, has_more=has_more) @classmethod def _get_sort_params(cls, sort_by: str): if sort_by.startswith("-"): return sort_by[1:], desc return sort_by, asc @classmethod def _build_filter_condition(cls, sort_field: str, sort_direction: Callable, reference_conversation: Conversation): field_value = getattr(reference_conversation, sort_field) if sort_direction is desc: return getattr(Conversation, sort_field) < field_value return getattr(Conversation, sort_field) > field_value @classmethod def rename( cls, app_model: App, conversation_id: str, user: Account | EndUser | None, name: str | None, auto_generate: bool, *, session: Session, ): conversation = cls.get_conversation(app_model, conversation_id, user, session=session) if auto_generate: return cls.auto_generate_name(app_model, conversation, session=session) else: if name is None: raise ValueError("name is required when auto_generate is false") conversation.name = name conversation.updated_at = naive_utc_now() session.commit() return conversation @classmethod def auto_generate_name(cls, app_model: App, conversation: Conversation, *, session: Session): # get conversation first message message = session.scalar( select(Message) .where(Message.app_id == app_model.id, Message.conversation_id == conversation.id) .order_by(Message.created_at.asc()) .limit(1) ) if not message: raise MessageNotExistsError() # generate conversation name with contextlib.suppress(Exception): name = LLMGenerator.generate_conversation_name( app_model.tenant_id, message.query, conversation.id, app_model.id ) conversation.name = name session.commit() return conversation @classmethod def get_conversation( cls, app_model: App, conversation_id: str, user: Account | EndUser | None, *, session: Session ): conversation = session.scalar( select(Conversation) .where( Conversation.id == conversation_id, Conversation.app_id == app_model.id, Conversation.from_source == ("api" if isinstance(user, EndUser) else "console"), Conversation.from_end_user_id == (user.id if isinstance(user, EndUser) else None), Conversation.from_account_id == (user.id if isinstance(user, Account) else None), Conversation.is_deleted == False, ) .limit(1) ) if not conversation: raise ConversationNotExistsError() return conversation @classmethod def delete(cls, app_model: App, conversation_id: str, user: Account | EndUser | None, *, session: Session): """ Delete a conversation only if it belongs to the given user and app context. Before removing the conversation row, this best-effort lifecycle path enumerates any ACTIVE conversation-owned Agent backend runtime sessions, enqueues asynchronous backend cleanup for rows with persisted runtime layer specs, and then retires the local session rows even if enqueueing fails. Conversation deletion and related-data cleanup scheduling still proceed when that lifecycle bookkeeping only partially succeeds. Raises: ConversationNotExistsError: When the conversation is not visible to the current user. """ conversation = cls.get_conversation(app_model, conversation_id, user, session=session) session_store = AgentAppRuntimeSessionStore() stored_sessions = session_store.list_active_sessions_for_conversation( tenant_id=app_model.tenant_id, app_id=app_model.id, conversation_id=conversation.id, ) try: logger.info( "Initiating conversation deletion for app_name %s, conversation_id: %s", app_model.name, conversation_id, ) for stored_session in stored_sessions: try: if stored_session.runtime_layer_specs: payload = AgentBackendSessionCleanupPayload( session_snapshot=stored_session.session_snapshot, runtime_layer_specs=stored_session.runtime_layer_specs, idempotency_key=( f"{stored_session.scope.tenant_id}:{stored_session.scope.app_id}:" f"{stored_session.scope.conversation_id}:agent-runtime-session-cleanup:" f"{stored_session.scope.agent_id}:" f"{stored_session.scope.agent_config_snapshot_id or 'no-config'}:" f"{stored_session.backend_run_id or 'no-run'}" ), metadata={ "tenant_id": stored_session.scope.tenant_id, "app_id": stored_session.scope.app_id, "conversation_id": stored_session.scope.conversation_id, "agent_id": stored_session.scope.agent_id, "agent_config_snapshot_id": stored_session.scope.agent_config_snapshot_id, "previous_agent_backend_run_id": stored_session.backend_run_id, }, ) cleanup_conversation_agent_runtime_session.delay(payload.model_dump(mode="json")) except Exception: logger.warning( "Failed to enqueue Agent backend cleanup for conversation deletion: " "tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s", stored_session.scope.tenant_id, stored_session.scope.app_id, stored_session.scope.conversation_id, stored_session.scope.agent_id, stored_session.backend_run_id, exc_info=True, ) finally: try: session_store.mark_cleaned( scope=stored_session.scope, backend_run_id=stored_session.backend_run_id, ) except Exception: logger.warning( "Failed to retire Agent App runtime session for conversation deletion: " "tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s", stored_session.scope.tenant_id, stored_session.scope.app_id, stored_session.scope.conversation_id, stored_session.scope.agent_id, stored_session.backend_run_id, exc_info=True, ) session.delete(conversation) session.commit() delete_conversation_related_data.delay(conversation.id) except Exception: session.rollback() raise @classmethod def get_conversational_variable( cls, app_model: App, conversation_id: str, user: Account | EndUser | None, limit: int, last_id: str | None, variable_name: str | None = None, *, session: Session, ) -> InfiniteScrollPagination: conversation = cls.get_conversation(app_model, conversation_id, user, session=session) stmt = ( select(ConversationVariable) .where(ConversationVariable.app_id == app_model.id) .where(ConversationVariable.conversation_id == conversation.id) .order_by(ConversationVariable.created_at) ) # Apply variable_name filter if provided if variable_name: # Filter using JSON extraction to match variable names case-insensitively from libs.helper import escape_like_pattern escaped_variable_name = escape_like_pattern(variable_name) # Filter using JSON extraction to match variable names case-insensitively if dify_config.DB_TYPE in ["mysql", "oceanbase", "seekdb"]: stmt = stmt.where( func.json_extract(ConversationVariable.data, "$.name").ilike( f"%{escaped_variable_name}%", escape="\\" ) ) elif dify_config.DB_TYPE == "postgresql": stmt = stmt.where( func.json_extract_path_text(ConversationVariable.data, "name").ilike( f"%{escaped_variable_name}%", escape="\\" ) ) if last_id: last_variable = session.scalar(stmt.where(ConversationVariable.id == last_id)) if not last_variable: raise ConversationVariableNotExistsError() # Filter for variables created after the last_id stmt = stmt.where(ConversationVariable.created_at > last_variable.created_at) # Apply limit to query: fetch one extra row to determine has_more query_stmt = stmt.limit(limit + 1) rows = session.scalars(query_stmt).all() has_more = False if len(rows) > limit: has_more = True rows = rows[:limit] # Remove the extra item variables = [ { "created_at": row.created_at, "updated_at": row.updated_at, **row.to_variable().model_dump(), } for row in rows ] return InfiniteScrollPagination(variables, limit, has_more) @classmethod def update_conversation_variable( cls, app_model: App, conversation_id: str, variable_id: str, user: Account | EndUser | None, new_value: Any, *, session: Session, ): """ Update a conversation variable's value. Args: app_model: The app model conversation_id: The conversation ID variable_id: The variable ID to update user: The user (Account or EndUser) new_value: The new value for the variable Returns: Dictionary containing the updated variable information Raises: ConversationNotExistsError: If the conversation doesn't exist ConversationVariableNotExistsError: If the variable doesn't exist ConversationVariableTypeMismatchError: If the new value type doesn't match the variable's expected type """ # Verify conversation exists and user has access conversation = cls.get_conversation(app_model, conversation_id, user, session=session) # Get the existing conversation variable stmt = ( select(ConversationVariable) .where(ConversationVariable.app_id == app_model.id) .where(ConversationVariable.conversation_id == conversation.id) .where(ConversationVariable.id == variable_id) ) existing_variable = session.scalar(stmt) if not existing_variable: raise ConversationVariableNotExistsError() # Convert existing variable to Variable object current_variable = existing_variable.to_variable() # Validate that the new value type matches the expected variable type expected_type = SegmentType(current_variable.value_type) # There is showing number in web ui but int in db if expected_type == SegmentType.INTEGER: expected_type = SegmentType.NUMBER if not expected_type.is_valid(new_value): inferred_type = SegmentType.infer_segment_type(new_value) raise ConversationVariableTypeMismatchError( f"Type mismatch: variable '{current_variable.name}' expects {expected_type.value}, " f"but got {inferred_type.value if inferred_type else 'unknown'} type" ) # Create updated variable with new value only, preserving everything else updated_variable_dict = { "id": current_variable.id, "name": current_variable.name, "description": current_variable.description, "value_type": current_variable.value_type, "value": new_value, "selector": current_variable.selector, } updated_variable = variable_factory.build_conversation_variable_from_mapping(updated_variable_dict) existing_variable.data = updated_variable.model_dump_json() session.commit() return { "created_at": existing_variable.created_at, "updated_at": naive_utc_now(), # Update timestamp **updated_variable.model_dump(), }