Add conversation history management and API endpoints

- Introduce new database models for conversation sessions, messages, and artifacts to support conversation history tracking.
- Implement API routes for listing conversations and retrieving detailed conversation data, enhancing user interaction with historical records.
- Add a conversation recorder service to persist conversation messages in real-time without disrupting ongoing calls.
- Update the frontend to display conversation history, including filtering and sorting options, improving user experience.
- Enhance the pipeline to integrate conversation history recording seamlessly during interactions.
This commit is contained in:
Xin Wang
2026-07-10 16:25:06 +08:00
parent 5705726cfb
commit 059ad8162e
13 changed files with 1096 additions and 43 deletions

View File

@@ -0,0 +1,117 @@
"""对话历史查询 API。"""
from db.models import ConversationMessage, ConversationSession
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException, Query
from schemas import (
ConversationDetailOut,
ConversationListOut,
ConversationMessageOut,
ConversationOut,
)
from services.auth import require_admin
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,
)
@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()
return ConversationDetailOut(
**_session_out(conversation).model_dump(),
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 {},
)
for message in messages
],
)

View File

@@ -120,7 +120,13 @@ async def _handle_offer(websocket, payload, peers):
video_in_enabled=vision_enabled,
)
asyncio.create_task(
run_pipeline(transport, cfg, vision_enabled=vision_enabled)
run_pipeline(
transport,
cfg,
vision_enabled=vision_enabled,
assistant_id=offer.assistant_id,
channel="webrtc",
)
)
answer = pc.get_answer()

View File

@@ -24,13 +24,16 @@ from starlette.websockets import WebSocketDisconnect
router = APIRouter()
async def _resolve_start_config(raw: str) -> AssistantConfig:
async def _resolve_start_config(raw: str) -> tuple[AssistantConfig, str | None]:
data = json.loads(raw)
if data.get("assistant_id"):
async with SessionLocal() as session:
return await resolve_runtime_config(session, data["assistant_id"])
return (
await resolve_runtime_config(session, data["assistant_id"]),
data["assistant_id"],
)
if data.get("inline_config"):
return AssistantConfig(**data["inline_config"])
return AssistantConfig(**data["inline_config"]), None
raise ValueError("启动参数缺少 assistant_id 或 inline_config")
@@ -43,10 +46,10 @@ async def voice_stream(websocket: WebSocket):
return
await websocket.accept()
try:
cfg = await _resolve_start_config(await websocket.receive_text())
cfg, assistant_id = await _resolve_start_config(await websocket.receive_text())
transport = build_ws_transport(websocket)
# 直接 await:管线持续读这条 WS 的音频帧,直到对端断开
await run_pipeline(transport, cfg)
await run_pipeline(transport, cfg, assistant_id=assistant_id, channel="websocket")
except WebSocketDisconnect:
logger.info("WS 音频流断开")
except Exception as e: