253 lines
9.9 KiB
Python
253 lines
9.9 KiB
Python
"""聊天服务。"""
|
|
|
|
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)
|