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 configs import dify_config 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.agent import AgentWorkspaceOwnerType from models.model import App, Conversation, EndUser, Message from services.agent.workspace_service import AgentWorkspaceNotFoundError, AgentWorkspaceService, WorkspaceOwnerScope from services.errors.conversation import ( ConversationNotExistsError, ConversationVariableNotExistsError, ConversationVariableTypeMismatchError, LastConversationNotExistsError, ) from services.errors.message import MessageNotExistsError from tasks.collect_agent_resources_task import enqueue_agent_resource_collection 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. Conversation deletion is the product lifecycle boundary for its Workspace. Physical collection happens only after the retire commit. Raises: ConversationNotExistsError: When the conversation is not visible to the current user. """ conversation = cls.get_conversation(app_model, conversation_id, user, session=session) binding_id = conversation.agent_workspace_binding_id retired_binding_id: str | None = None if binding_id is not None: owner_scope = WorkspaceOwnerScope( tenant_id=app_model.tenant_id, app_id=app_model.id, owner_type=AgentWorkspaceOwnerType.CONVERSATION, owner_id=conversation.id, ) binding = AgentWorkspaceService.get_active_binding( session=session, tenant_id=app_model.tenant_id, binding_id=binding_id, expected_owner_scope=owner_scope, ) if binding is None: raise AgentWorkspaceNotFoundError("Conversation participant Binding is unavailable") try: logger.info( "Initiating conversation deletion for app_name %s, conversation_id: %s", app_model.name, conversation_id, ) if binding_id is not None: retired_binding_id = AgentWorkspaceService.retire_binding( session=session, tenant_id=app_model.tenant_id, binding_id=binding_id, ) if retired_binding_id is None: raise AgentWorkspaceNotFoundError("Conversation participant Binding is unavailable") session.delete(conversation) session.commit() except Exception: session.rollback() raise if retired_binding_id is not None: enqueue_agent_resource_collection( tenant_id=app_model.tenant_id, binding_ids=(retired_binding_id,), ) delete_conversation_related_data.delay(conversation.id) @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(), }