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:
@@ -1 +1,2 @@
|
|||||||
你编写具有可维护性和高可读性的代码,尽量不去对pipecat框架本身修改
|
你编写具有可维护性、高可读性、模块化的的代码,尽量不去对pipecat框架本身修改
|
||||||
|
以MVP构建为目标
|
||||||
@@ -23,6 +23,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from routes import (
|
from routes import (
|
||||||
assistants,
|
assistants,
|
||||||
auth,
|
auth,
|
||||||
|
conversations,
|
||||||
health,
|
health,
|
||||||
knowledge_bases,
|
knowledge_bases,
|
||||||
model_registry,
|
model_registry,
|
||||||
@@ -52,6 +53,7 @@ app.add_middleware(
|
|||||||
|
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
|
app.include_router(conversations.router)
|
||||||
app.include_router(assistants.router)
|
app.include_router(assistants.router)
|
||||||
app.include_router(knowledge_bases.router)
|
app.include_router(knowledge_bases.router)
|
||||||
app.include_router(model_registry.router)
|
app.include_router(model_registry.router)
|
||||||
|
|||||||
@@ -9,7 +9,17 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, func
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
@@ -187,3 +197,83 @@ class AssistantToolBinding(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now()
|
DateTime(timezone=True), server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationSession(Base):
|
||||||
|
"""一次完整连接对应的对话会话;媒体文件以后作为 artifact 关联进来。"""
|
||||||
|
|
||||||
|
__tablename__ = "conversation_sessions"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
assistant_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(40),
|
||||||
|
ForeignKey("assistants.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
assistant_name: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
channel: Mapped[str] = mapped_column(String(24), index=True)
|
||||||
|
runtime_mode: Mapped[str] = mapped_column(String(16), default="pipeline")
|
||||||
|
status: Mapped[str] = mapped_column(String(16), index=True, default="active")
|
||||||
|
message_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), index=True
|
||||||
|
)
|
||||||
|
ended_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
extra: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationMessage(Base):
|
||||||
|
"""会话中的有序消息;当前只写 text,结构预留其他内容类型。"""
|
||||||
|
|
||||||
|
__tablename__ = "conversation_messages"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"session_id", "sequence", name="uq_conversation_message_sequence"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
session_id: Mapped[str] = mapped_column(
|
||||||
|
String(40),
|
||||||
|
ForeignKey("conversation_sessions.id", ondelete="CASCADE"),
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
sequence: Mapped[int] = mapped_column(Integer)
|
||||||
|
role: Mapped[str] = mapped_column(String(16))
|
||||||
|
content_type: Mapped[str] = mapped_column(String(16), default="text")
|
||||||
|
content: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
occurred_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
extra: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationArtifact(Base):
|
||||||
|
"""未来的音频、视频、图片等大对象索引;二进制本体不进入数据库。"""
|
||||||
|
|
||||||
|
__tablename__ = "conversation_artifacts"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
session_id: Mapped[str] = mapped_column(
|
||||||
|
String(40),
|
||||||
|
ForeignKey("conversation_sessions.id", ondelete="CASCADE"),
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
message_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(40),
|
||||||
|
ForeignKey("conversation_messages.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
kind: Mapped[str] = mapped_column(String(16), index=True)
|
||||||
|
storage_uri: Mapped[str] = mapped_column(String(1024))
|
||||||
|
mime_type: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
extra: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""add conversation history
|
||||||
|
|
||||||
|
Revision ID: 20260710_0003
|
||||||
|
Revises: 20260710_0002
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260710_0003"
|
||||||
|
down_revision: str | Sequence[str] | None = "20260710_0002"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"conversation_sessions",
|
||||||
|
sa.Column("id", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("assistant_id", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("assistant_name", sa.String(length=128), server_default="", nullable=False),
|
||||||
|
sa.Column("channel", sa.String(length=24), nullable=False),
|
||||||
|
sa.Column("runtime_mode", sa.String(length=16), server_default="pipeline", nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=16), server_default="active", nullable=False),
|
||||||
|
sa.Column("message_count", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("extra", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["assistant_id"], ["assistants.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_conversation_sessions_assistant_id", "conversation_sessions", ["assistant_id"])
|
||||||
|
op.create_index("ix_conversation_sessions_channel", "conversation_sessions", ["channel"])
|
||||||
|
op.create_index("ix_conversation_sessions_status", "conversation_sessions", ["status"])
|
||||||
|
op.create_index("ix_conversation_sessions_started_at", "conversation_sessions", ["started_at"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"conversation_messages",
|
||||||
|
sa.Column("id", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("session_id", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("role", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("content_type", sa.String(length=16), server_default="text", nullable=False),
|
||||||
|
sa.Column("content", sa.Text(), server_default="", nullable=False),
|
||||||
|
sa.Column("occurred_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("extra", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["session_id"], ["conversation_sessions.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("session_id", "sequence", name="uq_conversation_message_sequence"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_conversation_messages_session_id", "conversation_messages", ["session_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"conversation_artifacts",
|
||||||
|
sa.Column("id", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("session_id", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("message_id", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("kind", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("storage_uri", sa.String(length=1024), nullable=False),
|
||||||
|
sa.Column("mime_type", sa.String(length=128), server_default="", nullable=False),
|
||||||
|
sa.Column("size_bytes", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("duration_ms", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("extra", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["message_id"], ["conversation_messages.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["session_id"], ["conversation_sessions.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_conversation_artifacts_session_id", "conversation_artifacts", ["session_id"])
|
||||||
|
op.create_index("ix_conversation_artifacts_message_id", "conversation_artifacts", ["message_id"])
|
||||||
|
op.create_index("ix_conversation_artifacts_kind", "conversation_artifacts", ["kind"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("conversation_artifacts")
|
||||||
|
op.drop_table("conversation_messages")
|
||||||
|
op.drop_table("conversation_sessions")
|
||||||
117
backend/routes/conversations.py
Normal file
117
backend/routes/conversations.py
Normal 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
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -120,7 +120,13 @@ async def _handle_offer(websocket, payload, peers):
|
|||||||
video_in_enabled=vision_enabled,
|
video_in_enabled=vision_enabled,
|
||||||
)
|
)
|
||||||
asyncio.create_task(
|
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()
|
answer = pc.get_answer()
|
||||||
|
|||||||
@@ -24,13 +24,16 @@ from starlette.websockets import WebSocketDisconnect
|
|||||||
router = APIRouter()
|
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)
|
data = json.loads(raw)
|
||||||
if data.get("assistant_id"):
|
if data.get("assistant_id"):
|
||||||
async with SessionLocal() as session:
|
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"):
|
if data.get("inline_config"):
|
||||||
return AssistantConfig(**data["inline_config"])
|
return AssistantConfig(**data["inline_config"]), None
|
||||||
raise ValueError("启动参数缺少 assistant_id 或 inline_config")
|
raise ValueError("启动参数缺少 assistant_id 或 inline_config")
|
||||||
|
|
||||||
|
|
||||||
@@ -43,10 +46,10 @@ async def voice_stream(websocket: WebSocket):
|
|||||||
return
|
return
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
try:
|
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)
|
transport = build_ws_transport(websocket)
|
||||||
# 直接 await:管线持续读这条 WS 的音频帧,直到对端断开
|
# 直接 await:管线持续读这条 WS 的音频帧,直到对端断开
|
||||||
await run_pipeline(transport, cfg)
|
await run_pipeline(transport, cfg, assistant_id=assistant_id, channel="websocket")
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
logger.info("WS 音频流断开")
|
logger.info("WS 音频流断开")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ JSON 用 camelCase(modelId/interfaceType/apiUrl/apiKey),Python 内部用 snake_c
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from typing import Annotated, Any, Literal, Union
|
from typing import Annotated, Any, Literal, Union
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
@@ -199,3 +200,37 @@ class ModelResourceTestResult(CamelModel):
|
|||||||
latency_ms: int | None = None
|
latency_ms: int | None = None
|
||||||
message: str
|
message: str
|
||||||
detail: str = ""
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 对话历史 ----------
|
||||||
|
class ConversationMessageOut(CamelModel):
|
||||||
|
id: str
|
||||||
|
sequence: int
|
||||||
|
role: Literal["user", "assistant"]
|
||||||
|
content_type: str
|
||||||
|
content: str
|
||||||
|
occurred_at: datetime
|
||||||
|
extra: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationOut(CamelModel):
|
||||||
|
id: str
|
||||||
|
assistant_id: str | None
|
||||||
|
assistant_name: str
|
||||||
|
channel: str
|
||||||
|
runtime_mode: str
|
||||||
|
status: str
|
||||||
|
message_count: int
|
||||||
|
started_at: datetime
|
||||||
|
ended_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationDetailOut(ConversationOut):
|
||||||
|
messages: list[ConversationMessageOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationListOut(CamelModel):
|
||||||
|
items: list[ConversationOut]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|||||||
137
backend/services/conversation_history.py
Normal file
137
backend/services/conversation_history.py
Normal 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}")
|
||||||
@@ -17,6 +17,7 @@ from models import AssistantConfig
|
|||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from services.brains import build_brain
|
from services.brains import build_brain
|
||||||
|
from services.conversation_history import ConversationRecorder
|
||||||
from services.pipecat.service_factory import (
|
from services.pipecat.service_factory import (
|
||||||
create_realtime_service,
|
create_realtime_service,
|
||||||
create_stt,
|
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):
|
class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
|
||||||
"""聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
|
"""聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
|
||||||
|
|
||||||
@@ -363,6 +378,8 @@ async def run_pipeline(
|
|||||||
cfg: AssistantConfig,
|
cfg: AssistantConfig,
|
||||||
*,
|
*,
|
||||||
vision_enabled: bool = False,
|
vision_enabled: bool = False,
|
||||||
|
assistant_id: str | None = None,
|
||||||
|
channel: str = "webrtc",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""在给定 transport 上构建并运行管线,直到连接结束。
|
"""在给定 transport 上构建并运行管线,直到连接结束。
|
||||||
|
|
||||||
@@ -388,7 +405,12 @@ async def run_pipeline(
|
|||||||
if cfg.runtimeMode == "realtime":
|
if cfg.runtimeMode == "realtime":
|
||||||
if vision_enabled:
|
if vision_enabled:
|
||||||
logger.warning("Realtime 模式暂未接入视频帧工具,本次仅启用语音通话")
|
logger.warning("Realtime 模式暂未接入视频帧工具,本次仅启用语音通话")
|
||||||
await run_realtime_pipeline(transport, cfg)
|
await run_realtime_pipeline(
|
||||||
|
transport,
|
||||||
|
cfg,
|
||||||
|
assistant_id=assistant_id,
|
||||||
|
channel=channel,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
stt = create_stt(cfg)
|
stt = create_stt(cfg)
|
||||||
@@ -677,6 +699,12 @@ async def run_pipeline(
|
|||||||
reason = str(call_end_state["reason"] or "completed")
|
reason = str(call_end_state["reason"] or "completed")
|
||||||
await queue_call_end(reason)
|
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(
|
pipeline = Pipeline(
|
||||||
[
|
[
|
||||||
transport.input(),
|
transport.input(),
|
||||||
@@ -691,6 +719,7 @@ async def run_pipeline(
|
|||||||
assistant_aggregator,
|
assistant_aggregator,
|
||||||
tts,
|
tts,
|
||||||
EndCallAfterSpeech(),
|
EndCallAfterSpeech(),
|
||||||
|
ConversationHistoryProcessor(recorder),
|
||||||
transport.output(),
|
transport.output(),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -950,21 +979,42 @@ async def run_pipeline(
|
|||||||
await worker.queue_frame(EndFrame())
|
await worker.queue_frame(EndFrame())
|
||||||
|
|
||||||
runner = WorkerRunner(handle_sigint=False)
|
runner = WorkerRunner(handle_sigint=False)
|
||||||
await runner.add_workers(worker)
|
run_status = "completed"
|
||||||
await runner.run()
|
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("管线已结束")
|
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."""
|
"""Run a speech-to-speech model that owns ASR, reasoning, and synthesis."""
|
||||||
realtime = create_realtime_service(cfg)
|
realtime = create_realtime_service(cfg)
|
||||||
text_input = RealtimeTextInputProcessor()
|
text_input = RealtimeTextInputProcessor()
|
||||||
|
|
||||||
|
recorder = await ConversationRecorder.start(
|
||||||
|
assistant_id=assistant_id,
|
||||||
|
assistant_name=cfg.name,
|
||||||
|
channel=channel,
|
||||||
|
runtime_mode=cfg.runtimeMode,
|
||||||
|
)
|
||||||
pipeline = Pipeline(
|
pipeline = Pipeline(
|
||||||
[
|
[
|
||||||
transport.input(),
|
transport.input(),
|
||||||
text_input,
|
text_input,
|
||||||
realtime,
|
realtime,
|
||||||
|
ConversationHistoryProcessor(recorder),
|
||||||
transport.output(),
|
transport.output(),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -1017,6 +1067,14 @@ async def run_realtime_pipeline(transport, cfg: AssistantConfig) -> None:
|
|||||||
await worker.queue_frame(EndFrame())
|
await worker.queue_frame(EndFrame())
|
||||||
|
|
||||||
runner = WorkerRunner(handle_sigint=False)
|
runner = WorkerRunner(handle_sigint=False)
|
||||||
await runner.add_workers(worker)
|
run_status = "completed"
|
||||||
await runner.run()
|
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 管线已结束")
|
logger.info("Realtime 管线已结束")
|
||||||
|
|||||||
@@ -1,10 +1,442 @@
|
|||||||
import { PlaceholderPage } from "./PlaceholderPage";
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Eye,
|
||||||
|
Loader2,
|
||||||
|
MessageSquareText,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DataList, type DataListColumn } from "@/components/ui/data-list";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { FilterPills } from "@/components/ui/filter-pills";
|
||||||
|
import { ListToolbar } from "@/components/ui/list-toolbar";
|
||||||
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
|
import { SearchInput } from "@/components/ui/search-input";
|
||||||
|
import {
|
||||||
|
conversationsApi,
|
||||||
|
type Conversation,
|
||||||
|
type ConversationDetail,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const channelFilters = ["全部", "WebRTC", "WebSocket"] as const;
|
||||||
|
type ChannelFilter = (typeof channelFilters)[number];
|
||||||
|
type SortOrder = "newest" | "oldest";
|
||||||
|
|
||||||
|
function formatDate(value?: string | null): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
return date.toLocaleString("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationLabel(row: Conversation): string {
|
||||||
|
if (!row.endedAt) return "进行中";
|
||||||
|
const seconds = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round(
|
||||||
|
(new Date(row.endedAt).getTime() - new Date(row.startedAt).getTime()) /
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
return minutes ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelLabel(channel: string): string {
|
||||||
|
if (channel === "webrtc") return "WebRTC";
|
||||||
|
if (channel === "websocket") return "WebSocket";
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelValue(filter: ChannelFilter): string | undefined {
|
||||||
|
if (filter === "WebRTC") return "webrtc";
|
||||||
|
if (filter === "WebSocket") return "websocket";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
if (status === "active") return "进行中";
|
||||||
|
if (status === "failed") return "失败";
|
||||||
|
return "已结束";
|
||||||
|
}
|
||||||
|
|
||||||
export function HistoryPage() {
|
export function HistoryPage() {
|
||||||
|
const [rows, setRows] = useState<Conversation[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [filter, setFilter] = useState<ChannelFilter>("全部");
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>("newest");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState("");
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [detail, setDetail] = useState<ConversationDetail | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [detailError, setDetailError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setLoadError("");
|
||||||
|
try {
|
||||||
|
const result = await conversationsApi.list({
|
||||||
|
page: currentPage,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
search: search.trim() || undefined,
|
||||||
|
channel: channelValue(filter),
|
||||||
|
sortOrder,
|
||||||
|
});
|
||||||
|
setRows(result.items);
|
||||||
|
setTotal(result.total);
|
||||||
|
} catch (error) {
|
||||||
|
setLoadError(error instanceof Error ? error.message : "加载历史记录失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentPage, filter, search, sortOrder]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initial and query-driven external API synchronization.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const openDetail = useCallback(async (conversation: Conversation) => {
|
||||||
|
setDialogOpen(true);
|
||||||
|
setDetail(null);
|
||||||
|
setDetailError("");
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
setDetail(await conversationsApi.get(conversation.id));
|
||||||
|
} catch (error) {
|
||||||
|
setDetailError(
|
||||||
|
error instanceof Error ? error.message : "对话内容加载失败",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function changeSearch(value: string) {
|
||||||
|
setSearch(value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeFilter(value: ChannelFilter) {
|
||||||
|
setFilter(value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSortOrder() {
|
||||||
|
setSortOrder((value) => (value === "newest" ? "oldest" : "newest"));
|
||||||
|
setCurrentPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = useMemo<DataListColumn<Conversation>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: "assistant",
|
||||||
|
header: "助手名称",
|
||||||
|
width: "md:w-[360px]",
|
||||||
|
cell: (row) => (
|
||||||
|
<>
|
||||||
|
<div className="truncate font-medium text-foreground">
|
||||||
|
{row.assistantName || "调试会话"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate font-mono text-xs text-muted-soft">
|
||||||
|
{row.id}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "channel",
|
||||||
|
header: "接入通道",
|
||||||
|
width: "md:w-[150px]",
|
||||||
|
cell: (row) => (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="h-6 bg-surface-strong px-3 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{channelLabel(row.channel)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "状态",
|
||||||
|
width: "md:w-[120px]",
|
||||||
|
cell: (row) => (
|
||||||
|
<Badge
|
||||||
|
variant={row.status === "failed" ? "destructive" : "outline"}
|
||||||
|
className="h-6 px-3"
|
||||||
|
>
|
||||||
|
{statusLabel(row.status)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "messages",
|
||||||
|
header: "消息数",
|
||||||
|
width: "md:w-[100px]",
|
||||||
|
cellClassName: "tabular-nums text-muted-foreground",
|
||||||
|
cell: (row) => `${row.messageCount} 条`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "duration",
|
||||||
|
header: "对话时长",
|
||||||
|
width: "md:w-[130px]",
|
||||||
|
cellClassName: "tabular-nums text-muted-foreground",
|
||||||
|
cell: durationLabel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "startedAt",
|
||||||
|
width: "md:w-[190px]",
|
||||||
|
header: (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleSortOrder();
|
||||||
|
}}
|
||||||
|
className="caption-label -mx-2 inline-flex items-center gap-1 rounded-md px-2 py-1 text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||||
|
aria-label={
|
||||||
|
sortOrder === "newest"
|
||||||
|
? "当前按最近开始排序,点击切换为最早开始"
|
||||||
|
: "当前按最早开始排序,点击切换为最近开始"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
开始时间
|
||||||
|
{sortOrder === "newest" ? (
|
||||||
|
<ChevronDown size={13} />
|
||||||
|
) : (
|
||||||
|
<ChevronUp size={13} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
cellClassName:
|
||||||
|
"whitespace-nowrap tabular-nums text-muted-foreground",
|
||||||
|
cell: (row) => formatDate(row.startedAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "操作",
|
||||||
|
align: "right",
|
||||||
|
cellClassName: "flex justify-end",
|
||||||
|
cell: (row) => (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void openDetail(row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[openDetail, sortOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
const safeCurrentPage = Math.min(currentPage, totalPages);
|
||||||
|
const pageStart = (safeCurrentPage - 1) * PAGE_SIZE;
|
||||||
|
const pageEnd = Math.min(pageStart + rows.length, total);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlaceholderPage
|
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
|
||||||
title="历史记录"
|
<PageHeader
|
||||||
description="查看助手的对话历史、运行日志与调用明细。"
|
title="历史记录"
|
||||||
/>
|
description="查看每次语音或文字会话中最终确认的用户转写和助手回复。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
|
||||||
|
<ListToolbar
|
||||||
|
filters={
|
||||||
|
<FilterPills
|
||||||
|
options={channelFilters}
|
||||||
|
value={filter}
|
||||||
|
onChange={changeFilter}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
search={
|
||||||
|
<SearchInput
|
||||||
|
value={search}
|
||||||
|
onChange={changeSearch}
|
||||||
|
placeholder="搜索助手名称或会话 ID"
|
||||||
|
className="lg:w-[320px]"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataList
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
loading={loading}
|
||||||
|
error={loadError || null}
|
||||||
|
onRetry={() => void load()}
|
||||||
|
onRowClick={(row) => void openDetail(row)}
|
||||||
|
empty={{
|
||||||
|
title:
|
||||||
|
total === 0 && !search && filter === "全部"
|
||||||
|
? "暂无对话记录"
|
||||||
|
: "未找到匹配的对话记录",
|
||||||
|
description:
|
||||||
|
total === 0 && !search && filter === "全部"
|
||||||
|
? "完成一次助手通话后,文本历史会显示在这里。"
|
||||||
|
: "请调整关键词或筛选条件后再试。",
|
||||||
|
}}
|
||||||
|
pagination={{
|
||||||
|
page: safeCurrentPage,
|
||||||
|
totalPages,
|
||||||
|
onPageChange: setCurrentPage,
|
||||||
|
summary:
|
||||||
|
total === 0
|
||||||
|
? "没有数据"
|
||||||
|
: `显示 ${pageStart + 1}-${pageEnd} / 共 ${total} 次会话`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-h-[calc(100vh-3rem)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<MessageSquareText size={18} />
|
||||||
|
{detail?.assistantName || "对话详情"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{detail
|
||||||
|
? `${formatDate(detail.startedAt)} · ${channelLabel(detail.channel)} · ${detail.messageCount} 条消息`
|
||||||
|
: "查看本次会话中最终确认的文本消息。"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 overflow-y-auto">
|
||||||
|
{detailLoading && (
|
||||||
|
<div className="flex min-h-80 items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
正在加载对话内容
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!detailLoading && detailError && (
|
||||||
|
<div className="flex min-h-80 items-center justify-center text-destructive">
|
||||||
|
{detailError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<section className="rounded-xl border border-hairline bg-surface-strong/20">
|
||||||
|
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
|
||||||
|
会话信息
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 p-4 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Metadata label="接入通道" value={channelLabel(detail.channel)} />
|
||||||
|
<Metadata label="运行模式" value={detail.runtimeMode} />
|
||||||
|
<Metadata label="对话时长" value={durationLabel(detail)} />
|
||||||
|
<Metadata label="状态" value={statusLabel(detail.status)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-hairline bg-surface-strong/20">
|
||||||
|
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
|
||||||
|
对话内容
|
||||||
|
</div>
|
||||||
|
<div className="space-y-5 p-4 sm:p-5">
|
||||||
|
{detail.messages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
message.role === "user"
|
||||||
|
? "justify-end"
|
||||||
|
: "justify-start",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="max-w-[85%]">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mb-1.5 text-xs text-muted-soft",
|
||||||
|
message.role === "user" && "text-right",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{message.role === "user" ? "用户" : "助手"} ·{" "}
|
||||||
|
{formatDate(message.occurredAt)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"whitespace-pre-wrap rounded-2xl px-4 py-3 text-left leading-6",
|
||||||
|
message.role === "user"
|
||||||
|
? "rounded-br-md bg-primary text-primary-foreground"
|
||||||
|
: "rounded-bl-md border border-hairline bg-background text-foreground shadow-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
{message.extra.interrupted && (
|
||||||
|
<div className="mt-1.5 text-xs text-muted-foreground">
|
||||||
|
回复在生成中被打断
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{detail.messages.length === 0 && (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
本次会话没有产生文本消息
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">关闭</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metadata({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs text-muted-soft">{label}</div>
|
||||||
|
<div className="mt-1 truncate font-medium text-foreground">{value}</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ export function useVoicePreview(
|
|||||||
const dataChannelRef = useRef<RTCDataChannel | null>(null);
|
const dataChannelRef = useRef<RTCDataChannel | null>(null);
|
||||||
const localStreamRef = useRef<MediaStream | null>(null);
|
const localStreamRef = useRef<MediaStream | null>(null);
|
||||||
const startingRef = useRef(false);
|
const startingRef = useRef(false);
|
||||||
|
const negotiationInProgressRef = useRef(false);
|
||||||
const messageSeqRef = useRef(0);
|
const messageSeqRef = useRef(0);
|
||||||
const networkStatsRef = useRef<NetworkStatsSample | null>(null);
|
const networkStatsRef = useRef<NetworkStatsSample | null>(null);
|
||||||
const videoBitrateRef = useRef(INITIAL_VIDEO_BITRATE);
|
const videoBitrateRef = useRef(INITIAL_VIDEO_BITRATE);
|
||||||
@@ -341,6 +342,7 @@ export function useVoicePreview(
|
|||||||
localStreamRef.current = null;
|
localStreamRef.current = null;
|
||||||
if (audioRef.current) audioRef.current.srcObject = null;
|
if (audioRef.current) audioRef.current.srcObject = null;
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
|
negotiationInProgressRef.current = false;
|
||||||
onNodeActiveRef.current?.(null);
|
onNodeActiveRef.current?.(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -436,10 +438,18 @@ export function useVoicePreview(
|
|||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
if (msg.type === "answer") {
|
if (msg.type === "answer") {
|
||||||
await pcRef.current?.setRemoteDescription({
|
const currentPc = pcRef.current;
|
||||||
type: "answer",
|
if (!currentPc) return;
|
||||||
sdp: msg.payload.sdp,
|
try {
|
||||||
});
|
await currentPc.setRemoteDescription({
|
||||||
|
type: "answer",
|
||||||
|
sdp: msg.payload.sdp,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (pcRef.current === currentPc) {
|
||||||
|
negotiationInProgressRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (msg.type === "ice-candidate" && msg.payload?.candidate) {
|
} else if (msg.type === "ice-candidate" && msg.payload?.candidate) {
|
||||||
// 后端当前不主动 trickle,留兼容
|
// 后端当前不主动 trickle,留兼容
|
||||||
try {
|
try {
|
||||||
@@ -478,6 +488,42 @@ export function useVoicePreview(
|
|||||||
const pc = new RTCPeerConnection({ iceServers });
|
const pc = new RTCPeerConnection({ iceServers });
|
||||||
pcRef.current = pc;
|
pcRef.current = pc;
|
||||||
|
|
||||||
|
const sendOffer = async () => {
|
||||||
|
if (
|
||||||
|
pcRef.current !== pc ||
|
||||||
|
ws.readyState !== WebSocket.OPEN ||
|
||||||
|
pc.signalingState !== "stable" ||
|
||||||
|
negotiationInProgressRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
negotiationInProgressRef.current = true;
|
||||||
|
try {
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
const localDescription = pc.localDescription;
|
||||||
|
if (!localDescription?.sdp) {
|
||||||
|
throw new Error("浏览器无法创建 WebRTC offer。");
|
||||||
|
}
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "offer",
|
||||||
|
payload: {
|
||||||
|
pc_id: pcId,
|
||||||
|
sdp: localDescription.sdp,
|
||||||
|
type: localDescription.type,
|
||||||
|
assistant_id: assistantId,
|
||||||
|
vision_enabled: Boolean(options.visionEnabled),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (offerError) {
|
||||||
|
negotiationInProgressRef.current = false;
|
||||||
|
throw offerError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
pc.onicecandidate = (e) => {
|
pc.onicecandidate = (e) => {
|
||||||
if (ws.readyState !== WebSocket.OPEN) return;
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
ws.send(
|
ws.send(
|
||||||
@@ -503,10 +549,15 @@ export function useVoicePreview(
|
|||||||
channel.onopen = () => {
|
channel.onopen = () => {
|
||||||
channel.send(JSON.stringify({ type: "client-ready" }));
|
channel.send(JSON.stringify({ type: "client-ready" }));
|
||||||
};
|
};
|
||||||
channel.onmessage = (event) => {
|
channel.onmessage = async (event) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
if (
|
if (
|
||||||
|
msg?.type === "signalling" &&
|
||||||
|
msg?.message?.type === "renegotiate"
|
||||||
|
) {
|
||||||
|
await sendOffer();
|
||||||
|
} else if (
|
||||||
msg?.type === "assistant-text-start" &&
|
msg?.type === "assistant-text-start" &&
|
||||||
typeof msg.turn_id === "string"
|
typeof msg.turn_id === "string"
|
||||||
) {
|
) {
|
||||||
@@ -631,25 +682,8 @@ export function useVoicePreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) 生成 offer 并发给后端(assistant_id 在 payload 顶层)
|
// 4) 生成 offer 并发给后端;同一方法也响应 SmallWebRTC 的重新协商请求。
|
||||||
const offer = await pc.createOffer();
|
await sendOffer();
|
||||||
await pc.setLocalDescription(offer);
|
|
||||||
const localDescription = pc.localDescription;
|
|
||||||
if (!localDescription?.sdp) {
|
|
||||||
throw new Error("浏览器无法创建 WebRTC offer。");
|
|
||||||
}
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "offer",
|
|
||||||
payload: {
|
|
||||||
pc_id: pcId,
|
|
||||||
sdp: localDescription.sdp,
|
|
||||||
type: localDescription.type,
|
|
||||||
assistant_id: assistantId,
|
|
||||||
vision_enabled: Boolean(options.visionEnabled),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (connectionError) {
|
} catch (connectionError) {
|
||||||
fail(errorMessage(connectionError, "无法连接语音服务。"));
|
fail(errorMessage(connectionError, "无法连接语音服务。"));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -194,6 +194,63 @@ export const assistantsApi = {
|
|||||||
request<{ ok: boolean }>(`/api/assistants/${id}`, { method: "DELETE" }),
|
request<{ ok: boolean }>(`/api/assistants/${id}`, { method: "DELETE" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------- 对话历史 ----------
|
||||||
|
export type Conversation = {
|
||||||
|
id: string;
|
||||||
|
assistantId: string | null;
|
||||||
|
assistantName: string;
|
||||||
|
channel: string;
|
||||||
|
runtimeMode: string;
|
||||||
|
status: string;
|
||||||
|
messageCount: number;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConversationMessage = {
|
||||||
|
id: string;
|
||||||
|
sequence: number;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
contentType: string;
|
||||||
|
content: string;
|
||||||
|
occurredAt: string;
|
||||||
|
extra: { interrupted?: boolean; turn_id?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConversationDetail = Conversation & {
|
||||||
|
messages: ConversationMessage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConversationList = {
|
||||||
|
items: Conversation[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const conversationsApi = {
|
||||||
|
list: (params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
search?: string;
|
||||||
|
channel?: string;
|
||||||
|
status?: string;
|
||||||
|
sortOrder?: "newest" | "oldest";
|
||||||
|
} = {}) => {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
page: String(params.page ?? 1),
|
||||||
|
page_size: String(params.pageSize ?? 20),
|
||||||
|
sort_order: params.sortOrder ?? "newest",
|
||||||
|
});
|
||||||
|
if (params.search) query.set("search", params.search);
|
||||||
|
if (params.channel) query.set("channel", params.channel);
|
||||||
|
if (params.status) query.set("status", params.status);
|
||||||
|
return request<ConversationList>(`/api/conversations?${query}`);
|
||||||
|
},
|
||||||
|
get: (id: string) =>
|
||||||
|
request<ConversationDetail>(`/api/conversations/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
// ---------- 工具 ----------
|
// ---------- 工具 ----------
|
||||||
export type ToolStatus = "active" | "archived" | "draft";
|
export type ToolStatus = "active" | "archived" | "draft";
|
||||||
export type ToolParameter = {
|
export type ToolParameter = {
|
||||||
|
|||||||
Reference in New Issue
Block a user