feat:新增对话历史列表
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
"""会话创建与历史查询接口"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
|
||||
from app.common.response import ApiResponse, success_response
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.exceptions import BizException
|
||||
from app.schemas.conversation import ConversationCreated, ConversationDetail
|
||||
from app.schemas.conversation import (
|
||||
ConversationCreated,
|
||||
ConversationDetail,
|
||||
ConversationList,
|
||||
)
|
||||
from app.schemas.user import CurrentUser
|
||||
|
||||
router = APIRouter(prefix="/conversations", tags=["Conversation"])
|
||||
@@ -21,6 +27,19 @@ async def create_conversation(
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_conversations(
|
||||
request: Request,
|
||||
user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> ApiResponse[ConversationList]:
|
||||
store = request.app.state.conversation_store
|
||||
return success_response(
|
||||
ConversationList(conversations=await store.list_by_user(user.id, limit, offset))
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{conversation_id}")
|
||||
async def get_conversation(
|
||||
conversation_id: str,
|
||||
|
||||
@@ -25,3 +25,15 @@ class ConversationCreated(BaseModel):
|
||||
class ConversationDetail(BaseModel):
|
||||
conversation_id: str
|
||||
turns: list[Turn]
|
||||
|
||||
|
||||
class ConversationSummary(BaseModel):
|
||||
"""历史会话列表项,只包含已产生对话的会话"""
|
||||
|
||||
conversation_id: str
|
||||
title: str # 取自首轮用户输入,过长时截断
|
||||
updated_at: datetime # 最近一轮对话的创建时间
|
||||
|
||||
|
||||
class ConversationList(BaseModel):
|
||||
conversations: list[ConversationSummary]
|
||||
|
||||
@@ -5,7 +5,17 @@ from typing import Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from app.config import settings
|
||||
from app.schemas.conversation import Turn
|
||||
from app.schemas.conversation import ConversationSummary, Turn
|
||||
|
||||
TITLE_MAX_LENGTH = 30
|
||||
|
||||
|
||||
def make_title(prompt: str) -> str:
|
||||
"""会话标题取首轮用户输入,合并空白后截断"""
|
||||
title = " ".join(prompt.split())
|
||||
if len(title) <= TITLE_MAX_LENGTH:
|
||||
return title
|
||||
return title[:TITLE_MAX_LENGTH] + "…"
|
||||
|
||||
|
||||
class ConversationStore(Protocol):
|
||||
@@ -23,6 +33,12 @@ class ConversationStore(Protocol):
|
||||
|
||||
async def save_turn(self, turn: Turn) -> None: ...
|
||||
|
||||
async def list_by_user(
|
||||
self, user_id: int, limit: int, offset: int
|
||||
) -> list[ConversationSummary]:
|
||||
"""该用户已产生对话的会话,按最近更新时间倒序;没有对话的空会话不返回"""
|
||||
...
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
@@ -77,3 +93,26 @@ class InMemoryConversationStore:
|
||||
if existing is not None:
|
||||
turn = turn.model_copy(update={"created_at": existing.created_at})
|
||||
self._turns[turn.conversation_id][turn.thread_id] = turn
|
||||
|
||||
async def list_by_user(
|
||||
self, user_id: int, limit: int, offset: int
|
||||
) -> list[ConversationSummary]:
|
||||
summaries = []
|
||||
for conversation_id, owner in self._owner.items():
|
||||
turns = sorted(
|
||||
self._turns.get(conversation_id, {}).values(),
|
||||
key=lambda turn: turn.created_at,
|
||||
)
|
||||
if owner != user_id or not turns:
|
||||
continue
|
||||
summaries.append(
|
||||
ConversationSummary(
|
||||
conversation_id=conversation_id,
|
||||
title=make_title(turns[0].prompt),
|
||||
updated_at=turns[-1].created_at,
|
||||
)
|
||||
)
|
||||
summaries.sort(
|
||||
key=lambda item: (item.updated_at, item.conversation_id), reverse=True
|
||||
)
|
||||
return summaries[offset : offset + limit]
|
||||
|
||||
@@ -8,7 +8,8 @@ from pymongo import ASCENDING, DESCENDING
|
||||
from pymongo.asynchronous.database import AsyncDatabase
|
||||
|
||||
from app.config import settings
|
||||
from app.schemas.conversation import Turn
|
||||
from app.schemas.conversation import ConversationSummary, Turn
|
||||
from app.services.conversation import make_title
|
||||
|
||||
# 单次清理的空会话上限,避免积压时一次请求扫描过多
|
||||
PURGE_BATCH = 200
|
||||
@@ -119,3 +120,42 @@ class MongoConversationStore:
|
||||
{"$set": data, "$setOnInsert": {"created_at": created_at}},
|
||||
upsert=True,
|
||||
)
|
||||
|
||||
async def list_by_user(
|
||||
self, user_id: int, limit: int, offset: int
|
||||
) -> list[ConversationSummary]:
|
||||
# $unwind 会丢弃没有任何轮次的空会话
|
||||
pipeline = [
|
||||
{"$match": {"user_id": user_id}},
|
||||
{
|
||||
"$lookup": {
|
||||
"from": self._turns.name,
|
||||
"localField": "_id",
|
||||
"foreignField": "conversation_id",
|
||||
"pipeline": [
|
||||
{"$sort": {"created_at": 1, "_id": 1}},
|
||||
{
|
||||
"$group": {
|
||||
"_id": None,
|
||||
"first_prompt": {"$first": "$prompt"},
|
||||
"updated_at": {"$last": "$created_at"},
|
||||
}
|
||||
},
|
||||
],
|
||||
"as": "stats",
|
||||
}
|
||||
},
|
||||
{"$unwind": "$stats"},
|
||||
{"$sort": {"stats.updated_at": -1, "_id": -1}},
|
||||
{"$skip": offset},
|
||||
{"$limit": limit},
|
||||
]
|
||||
cursor = await self._conversations.aggregate(pipeline)
|
||||
return [
|
||||
ConversationSummary(
|
||||
conversation_id=item["_id"],
|
||||
title=make_title(item["stats"]["first_prompt"]),
|
||||
updated_at=item["stats"]["updated_at"],
|
||||
)
|
||||
for item in await cursor.to_list()
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user