"""对话历史查询 API。""" import asyncio from db.models import ConversationArtifact, ConversationMessage, ConversationSession from db.session import get_session from fastapi import APIRouter, Depends, HTTPException, Query, Response from loguru import logger from schemas import ( ConversationArtifactOut, ConversationAnalysisFieldOut, ConversationAnalysisOut, ConversationDetailOut, ConversationListOut, ConversationMessageOut, ConversationOut, ) from services.auth import require_admin from services.object_storage import delete_object, get_object, key_from_storage_uri from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession router = APIRouter( prefix="/api/conversations", tags=["conversations"], dependencies=[Depends(require_admin)], ) def _session_out(row: ConversationSession) -> ConversationOut: return ConversationOut( id=row.id, assistant_id=row.assistant_id, assistant_name=row.assistant_name, channel=row.channel, runtime_mode=row.runtime_mode, status=row.status, message_count=row.message_count, started_at=row.started_at, ended_at=row.ended_at, ) def _analysis_out(row: ConversationSession) -> ConversationAnalysisOut: data = row.analysis_data or {} plan = data.get("plan") if isinstance(data.get("plan"), dict) else {} definitions = plan.get("fields") if isinstance(plan.get("fields"), list) else [] result = data.get("result") if isinstance(data.get("result"), dict) else {} fields = [] for definition in definitions: if not isinstance(definition, dict): continue name = str(definition.get("name") or "") if not name: continue fields.append( ConversationAnalysisFieldOut( name=name, type=str(definition.get("type") or "string"), value=result.get(name), ) ) return ConversationAnalysisOut( status=row.analysis_status or "none", fields=fields, error=row.analysis_error or "", completed_at=data.get("completedAt"), ) @router.get("", response_model=ConversationListOut) async def list_conversations( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), assistant_id: str | None = None, search: str | None = Query(None, max_length=128), channel: str | None = None, status: str | None = None, sort_order: str = Query("newest", pattern="^(newest|oldest)$"), session: AsyncSession = Depends(get_session), ): filters = [] if assistant_id: filters.append(ConversationSession.assistant_id == assistant_id) if search and search.strip(): keyword = f"%{search.strip()}%" filters.append( or_( ConversationSession.assistant_name.ilike(keyword), ConversationSession.id.ilike(keyword), ) ) if channel: filters.append(ConversationSession.channel == channel) if status: filters.append(ConversationSession.status == status) total = await session.scalar( select(func.count()).select_from(ConversationSession).where(*filters) ) rows = ( await session.execute( select(ConversationSession) .where(*filters) .order_by( ConversationSession.started_at.asc() if sort_order == "oldest" else ConversationSession.started_at.desc() ) .offset((page - 1) * page_size) .limit(page_size) ) ).scalars().all() return ConversationListOut( items=[_session_out(row) for row in rows], total=int(total or 0), page=page, page_size=page_size, ) @router.get("/{conversation_id}", response_model=ConversationDetailOut) async def get_conversation( conversation_id: str, session: AsyncSession = Depends(get_session), ): conversation = await session.get(ConversationSession, conversation_id) if not conversation: raise HTTPException(404, "对话记录不存在") messages = ( await session.execute( select(ConversationMessage) .where(ConversationMessage.session_id == conversation_id) .order_by(ConversationMessage.sequence) ) ).scalars().all() artifacts = ( await session.execute( select(ConversationArtifact) .where(ConversationArtifact.session_id == conversation_id) .order_by(ConversationArtifact.created_at) ) ).scalars().all() artifacts_by_message: dict[str, list[ConversationArtifact]] = {} for artifact in artifacts: if artifact.message_id: artifacts_by_message.setdefault(artifact.message_id, []).append(artifact) return ConversationDetailOut( **_session_out(conversation).model_dump(), extra=conversation.extra or {}, analysis=_analysis_out(conversation), messages=[ ConversationMessageOut( id=message.id, sequence=message.sequence, role=message.role, content_type=message.content_type, content=message.content, occurred_at=message.occurred_at, extra=message.extra or {}, artifacts=[ ConversationArtifactOut( id=artifact.id, kind=artifact.kind, content_url=( f"/api/conversations/{conversation_id}/artifacts/" f"{artifact.id}/content" ), mime_type=artifact.mime_type, size_bytes=artifact.size_bytes, duration_ms=artifact.duration_ms, extra=artifact.extra or {}, ) for artifact in artifacts_by_message.get(message.id, []) ], ) for message in messages ], ) @router.get("/{conversation_id}/artifacts/{artifact_id}/content") async def get_conversation_artifact( conversation_id: str, artifact_id: str, session: AsyncSession = Depends(get_session), ): artifact = await session.get(ConversationArtifact, artifact_id) if not artifact or artifact.session_id != conversation_id: raise HTTPException(404, "会话附件不存在") try: key = key_from_storage_uri(artifact.storage_uri) data = await asyncio.to_thread(get_object, key) except Exception as exc: logger.warning(f"读取会话附件失败: {artifact_id}: {exc}") raise HTTPException(404, "会话附件不可用") from exc return Response( content=data, media_type=artifact.mime_type or "application/octet-stream", headers={ "Cache-Control": "private, max-age=300", "Content-Disposition": f'inline; filename="{artifact.id}"', }, ) @router.delete("/{conversation_id}") async def delete_conversation( conversation_id: str, session: AsyncSession = Depends(get_session), ): conversation = await session.get(ConversationSession, conversation_id) if not conversation: raise HTTPException(404, "对话记录不存在") storage_uris = ( await session.execute( select(ConversationArtifact.storage_uri).where( ConversationArtifact.session_id == conversation_id ) ) ).scalars().all() await session.delete(conversation) await session.commit() for storage_uri in storage_uris: try: key = key_from_storage_uri(storage_uri) await asyncio.to_thread(delete_object, key) except Exception as exc: logger.warning(f"清理已删除会话的附件失败: {storage_uri}: {exc}") return {"ok": True}