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,137 @@
"""对话历史持久化。
只依赖管线已经发给客户端的最终文本事件,不侵入 Pipecat。媒体历史以后写入
conversation_artifacts并把对象存储地址关联到会话或消息。
"""
import asyncio
from datetime import UTC, datetime
from uuid import uuid4
from db.models import ConversationMessage, ConversationSession
from db.session import SessionLocal
from loguru import logger
def _parse_timestamp(value: object) -> datetime:
if not isinstance(value, str) or not value:
return datetime.now(UTC)
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
except ValueError:
return datetime.now(UTC)
class ConversationRecorder:
"""按事件顺序写入一通会话;写库失败不应中断实时通话。"""
def __init__(self, session_id: str):
self.session_id = session_id
self._sequence = 0
self._lock = asyncio.Lock()
self._seen_events: set[str] = set()
@classmethod
async def start(
cls,
*,
assistant_id: str | None,
assistant_name: str,
channel: str,
runtime_mode: str,
) -> "ConversationRecorder | None":
session_id = f"conv_{uuid4().hex[:20]}"
try:
async with SessionLocal() as db:
db.add(
ConversationSession(
id=session_id,
assistant_id=assistant_id,
assistant_name=assistant_name,
channel=channel,
runtime_mode=runtime_mode,
status="active",
message_count=0,
extra={},
)
)
await db.commit()
return cls(session_id)
except Exception as exc:
logger.error(f"创建对话历史会话失败,不影响本次通话: {exc}")
return None
async def record_transport_message(self, message: object) -> None:
if not isinstance(message, dict):
return
event_type = message.get("type")
role = ""
content = ""
extra: dict = {}
timestamp = message.get("timestamp")
event_key = ""
if event_type == "transcript":
role = str(message.get("role") or "")
content = str(message.get("content") or "").strip()
event_key = f"transcript:{role}:{timestamp}:{content}"
elif event_type == "assistant-text-end":
role = "assistant"
content = str(message.get("content") or "").strip()
turn_id = str(message.get("turn_id") or "")
event_key = f"assistant:{turn_id}"
extra = {
"turn_id": turn_id,
"interrupted": bool(message.get("interrupted", False)),
}
else:
return
if role not in {"user", "assistant"} or not content or event_key in self._seen_events:
return
self._seen_events.add(event_key)
await self._append(role, content, timestamp, extra)
async def _append(
self,
role: str,
content: str,
timestamp: object,
extra: dict,
) -> None:
async with self._lock:
next_sequence = self._sequence + 1
try:
async with SessionLocal() as db:
db.add(
ConversationMessage(
id=f"msg_{uuid4().hex[:20]}",
session_id=self.session_id,
sequence=next_sequence,
role=role,
content_type="text",
content=content,
occurred_at=_parse_timestamp(timestamp),
extra=extra,
)
)
conversation = await db.get(ConversationSession, self.session_id)
if conversation:
conversation.message_count = next_sequence
await db.commit()
self._sequence = next_sequence
except Exception as exc:
logger.error(f"保存对话文本失败,不影响本次通话: {exc}")
async def finish(self, *, status: str = "completed") -> None:
try:
async with SessionLocal() as db:
conversation = await db.get(ConversationSession, self.session_id)
if conversation:
conversation.status = status
conversation.ended_at = datetime.now(UTC)
conversation.message_count = self._sequence
await db.commit()
except Exception as exc:
logger.error(f"结束对话历史会话失败: {exc}")

View File

@@ -17,6 +17,7 @@ from models import AssistantConfig
from openai import AsyncOpenAI
from PIL import Image
from services.brains import build_brain
from services.conversation_history import ConversationRecorder
from services.pipecat.service_factory import (
create_realtime_service,
create_stt,
@@ -298,6 +299,20 @@ class RealtimeTextInputProcessor(FrameProcessor):
)
class ConversationHistoryProcessor(FrameProcessor):
"""从最终客户端事件旁路保存历史,不改变 Pipecat 的上下文与帧语义。"""
def __init__(self, recorder: ConversationRecorder | None):
super().__init__()
self._recorder = recorder
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
await self.push_frame(frame, direction)
if self._recorder and isinstance(frame, OutputTransportMessageUrgentFrame):
await self._recorder.record_transport_message(frame.message)
class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
"""聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
@@ -363,6 +378,8 @@ async def run_pipeline(
cfg: AssistantConfig,
*,
vision_enabled: bool = False,
assistant_id: str | None = None,
channel: str = "webrtc",
) -> None:
"""在给定 transport 上构建并运行管线,直到连接结束。
@@ -388,7 +405,12 @@ async def run_pipeline(
if cfg.runtimeMode == "realtime":
if vision_enabled:
logger.warning("Realtime 模式暂未接入视频帧工具,本次仅启用语音通话")
await run_realtime_pipeline(transport, cfg)
await run_realtime_pipeline(
transport,
cfg,
assistant_id=assistant_id,
channel=channel,
)
return
stt = create_stt(cfg)
@@ -677,6 +699,12 @@ async def run_pipeline(
reason = str(call_end_state["reason"] or "completed")
await queue_call_end(reason)
recorder = await ConversationRecorder.start(
assistant_id=assistant_id,
assistant_name=cfg.name,
channel=channel,
runtime_mode=cfg.runtimeMode,
)
pipeline = Pipeline(
[
transport.input(),
@@ -691,6 +719,7 @@ async def run_pipeline(
assistant_aggregator,
tts,
EndCallAfterSpeech(),
ConversationHistoryProcessor(recorder),
transport.output(),
]
)
@@ -950,21 +979,42 @@ async def run_pipeline(
await worker.queue_frame(EndFrame())
runner = WorkerRunner(handle_sigint=False)
await runner.add_workers(worker)
await runner.run()
run_status = "completed"
try:
await runner.add_workers(worker)
await runner.run()
except Exception:
run_status = "failed"
raise
finally:
if recorder:
await recorder.finish(status=run_status)
logger.info("管线已结束")
async def run_realtime_pipeline(transport, cfg: AssistantConfig) -> None:
async def run_realtime_pipeline(
transport,
cfg: AssistantConfig,
*,
assistant_id: str | None = None,
channel: str = "webrtc",
) -> None:
"""Run a speech-to-speech model that owns ASR, reasoning, and synthesis."""
realtime = create_realtime_service(cfg)
text_input = RealtimeTextInputProcessor()
recorder = await ConversationRecorder.start(
assistant_id=assistant_id,
assistant_name=cfg.name,
channel=channel,
runtime_mode=cfg.runtimeMode,
)
pipeline = Pipeline(
[
transport.input(),
text_input,
realtime,
ConversationHistoryProcessor(recorder),
transport.output(),
]
)
@@ -1017,6 +1067,14 @@ async def run_realtime_pipeline(transport, cfg: AssistantConfig) -> None:
await worker.queue_frame(EndFrame())
runner = WorkerRunner(handle_sigint=False)
await runner.add_workers(worker)
await runner.run()
run_status = "completed"
try:
await runner.add_workers(worker)
await runner.run()
except Exception:
run_status = "failed"
raise
finally:
if recorder:
await recorder.finish(status=run_status)
logger.info("Realtime 管线已结束")