"""聊天服务。""" from typing import TYPE_CHECKING if TYPE_CHECKING: from app.modules.safety.confirmation import ConfirmationStore from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.errors import AppError from app.db.session import AsyncSessionLocal from app.models.chat import ChatMessage, ChatSession from app.repositories import chat as chat_repo from app.repositories.json_utils import dumps, loads_list from app.schemas.chat import ( ChatMessageListResponse, ChatMessageResponse, ChatSessionListResponse, ChatSessionResponse, CreateSessionRequest, SelectVersionRequest, SendMessageRequest, SendMessageResponse, UpdateSessionRequest, ) from app.services.characters import get_active_character_model from app.services.serializers import chat_message_to_response, chat_session_to_response if TYPE_CHECKING: from app.application.agent.confirmation import ConfirmationRegistry from app.application.agent.python_skills import PythonSkillCatalog from app.application.agent.run_registry import AgentRunRegistry from app.application.tool_runtime.runtime import ToolRuntime from app.modules.runtime.events import EventStore async def create_session( payload: CreateSessionRequest, owner_scope: str = settings.default_owner_scope ) -> ChatSessionResponse: async with AsyncSessionLocal() as session: character = await get_active_character_model(session, owner_scope=owner_scope) character_id = payload.character_id or character.id if character_id != character.id: raise AppError("not_found", "人物不存在。") chat_session = ChatSession( character_id=character_id, title=payload.title, status="active", owner_scope=owner_scope, ) session.add(chat_session) await session.commit() await session.refresh(chat_session) return chat_session_to_response(chat_session) _PREVIEW_LEN = 80 def _truncate_preview(content: str | None) -> str | None: """截断消息内容为列表预览长度;空内容返回 None。""" if not content: return None text = content.strip() if len(text) <= _PREVIEW_LEN: return text return text[:_PREVIEW_LEN].rstrip() + "…" async def list_session_responses( *, owner_scope: str = settings.default_owner_scope, status: str | None = None, limit: int = 50, offset: int = 0, ) -> ChatSessionListResponse: async with AsyncSessionLocal() as session: character = await get_active_character_model(session, owner_scope=owner_scope) sessions = await chat_repo.list_sessions( session, character.id, status=status, owner_scope=owner_scope, limit=limit, offset=offset ) total = await chat_repo.count_sessions( session, character.id, status=status, owner_scope=owner_scope ) session_ids = [s.id for s in sessions] counts = await chat_repo.count_messages_by_sessions(session, session_ids) previews = await chat_repo.last_message_previews_by_sessions(session, session_ids) return ChatSessionListResponse( items=[ chat_session_to_response( s, message_count=counts.get(s.id, 0), last_message_preview=_truncate_preview(previews.get(s.id)), ) for s in sessions ], total=total, ) async def get_session_response( session_id: str, owner_scope: str = settings.default_owner_scope ) -> ChatSessionResponse: async with AsyncSessionLocal() as session: chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope) if chat_session is None or chat_session.status == "deleted": raise AppError("not_found", "会话不存在。") message_count = await chat_repo.count_messages(session, chat_session.id) recent = await chat_repo.list_recent_messages(session, chat_session.id, limit=1) preview = _truncate_preview(recent[0].content if recent else None) return chat_session_to_response( chat_session, message_count=message_count, last_message_preview=preview, ) _ALLOWED_SESSION_STATUSES = {"active", "archived"} async def update_session_response( session_id: str, payload: UpdateSessionRequest, owner_scope: str = settings.default_owner_scope ) -> ChatSessionResponse: """更新会话标题或状态(active/archived);归属校验同 get。 deleted 态由 DELETE 端点承担,此处禁止直接置为 deleted。 """ async with AsyncSessionLocal() as session: # owner_scope 校验已由 get_session 内化,无需重复查 character chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope) if chat_session is None or chat_session.status == "deleted": raise AppError("not_found", "会话不存在。") if payload.title is not None: chat_session.title = payload.title if payload.status is not None: if payload.status not in _ALLOWED_SESSION_STATUSES: raise AppError( "invalid_request", "会话状态仅可切换为 active 或 archived;删除请使用 DELETE。", ) chat_session.status = payload.status await session.commit() await session.refresh(chat_session) message_count = await chat_repo.count_messages(session, chat_session.id) return chat_session_to_response(chat_session, message_count=message_count) async def delete_session(session_id: str, owner_scope: str = settings.default_owner_scope) -> None: """软删除会话:session.status=deleted 并软删其全部消息。 Run/Event 为审计/日志型数据,保留不删;消息通过 deleted_at 软删, 与现有查询过滤保持一致。 """ async with AsyncSessionLocal() as session: # owner_scope 校验已由 get_session 内化,无需重复查 character chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope) if chat_session is None or chat_session.status == "deleted": raise AppError("not_found", "会话不存在。") chat_session.status = "deleted" await chat_repo.soft_delete_session_messages(session, session_id) await session.commit() async def list_message_responses( session_id: str, *, owner_scope: str = settings.default_owner_scope, limit: int = 100, offset: int = 0, ) -> ChatMessageListResponse: async with AsyncSessionLocal() as session: chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope) if chat_session is None or chat_session.status == "deleted": raise AppError("not_found", "会话不存在。") messages = await chat_repo.list_messages(session, session_id, limit=limit, offset=offset) total = await chat_repo.count_messages(session, session_id) return ChatMessageListResponse( items=[chat_message_to_response(message) for message in messages], total=total, ) async def recent_chat_messages( session: AsyncSession, session_id: str, *, limit: int = 12 ) -> list[ChatMessage]: """加载会话最近消息(供上下文组装)——接收外部 session,供 send_message usecase 复用。""" return await chat_repo.list_recent_messages(session, session_id, limit=limit) async def send_message( session: AsyncSession, session_id: str, payload: SendMessageRequest, *, owner_scope: str = settings.default_owner_scope, tool_runtime: "ToolRuntime | None" = None, confirmation_registry: "ConfirmationRegistry | None" = None, confirmation_store: "ConfirmationStore | None" = None, event_store: "EventStore | None" = None, agent_run_registry: "AgentRunRegistry | None" = None, python_skill_catalog: "PythonSkillCatalog | None" = None, ) -> SendMessageResponse: """发送消息——接收外部 session(由 run_idempotent 注入,保证幂等记录与发送同事务)。""" from app.application.chat.send_message import SendChatMessageUseCase return await SendChatMessageUseCase( session, owner_scope=owner_scope, tool_runtime=tool_runtime, confirmation_registry=confirmation_registry, confirmation_store=confirmation_store, event_store=event_store, agent_run_registry=agent_run_registry, python_skill_catalog=python_skill_catalog, ).execute(session_id, payload) async def select_message_version( session_id: str, message_id: str, payload: SelectVersionRequest, *, owner_scope: str = settings.default_owner_scope, ) -> ChatMessageResponse: """选择/激活消息的某个历史版本。""" async with AsyncSessionLocal() as session: # owner_scope 校验已由 get_session 内化 chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope) if chat_session is None or chat_session.status == "deleted": raise AppError("not_found", "会话不存在。") message = await chat_repo.get_message(session, message_id) if message is None or message.session_id != session_id: raise AppError("not_found", "消息不存在。") versions = loads_list(message.versions_json) if payload.version_idx < 0 or payload.version_idx >= len(versions): raise AppError( "invalid_request", f"版本索引 {payload.version_idx} 超出范围 (0-{len(versions) - 1})。", ) await chat_repo.set_message_active_version( session, message_id, payload.version_idx, dumps(versions), ) await session.commit() await session.refresh(message) return chat_message_to_response(message)