feat: improve chat feedback navigation

This commit is contained in:
2026-08-31 11:41:56 +08:00
parent 5740bd26e7
commit f4b2836c8a
11 changed files with 422 additions and 41 deletions

View File

@@ -29,6 +29,7 @@ from app.services.growth_profile_service import topic_dict, topic_summary_dict
from app.services.help_card_service import help_card_dict
from app.services.share_draft_service import share_draft_dict
from app.services.chat_export_service import ChatExportService
from app.services.chat_message_navigation_service import ChatMessageNavigationService
router = APIRouter()
@@ -119,6 +120,7 @@ def export_chats(
@router.get("/chat/{session_id}/messages")
def chat_messages(
session_id: int,
keyword: str = Query(default="", max_length=100),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
@@ -127,7 +129,10 @@ def chat_messages(
exists_session = db.scalar(select(ChatSession.id).where(ChatSession.id == session_id))
if exists_session is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
normalized_keyword = keyword.strip()
message_query = select(ChatMessage).where(ChatMessage.session_id == session_id)
if normalized_keyword:
message_query = message_query.where(ChatMessage.content.contains(normalized_keyword, autoescape=True))
total = db.scalar(select(func.count()).select_from(message_query.subquery())) or 0
messages = db.scalars(
message_query
@@ -211,30 +216,18 @@ def chat_detail(
)
if focused_message is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="触发消息不属于该会话或已不存在")
focused_position = db.scalar(
select(func.count()).select_from(ChatMessage).where(
ChatMessage.session_id == session_id,
or_(
ChatMessage.created_at < focused_message.created_at,
(
(ChatMessage.created_at == focused_message.created_at)
& (ChatMessage.id <= focused_message.id)
),
),
)
) or 1
messagePage = (focused_position - 1) // messagePageSize + 1
messagePage = ChatMessageNavigationService.page_for_message(
db,
session_id=session_id,
message=focused_message,
page_size=messagePageSize,
)
messages = db.scalars(
message_query
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
.offset((messagePage - 1) * messagePageSize)
.limit(messagePageSize)
).all()
ai_logs = db.scalars(
select(AiRequestLog)
.where(AiRequestLog.session_id == session_id)
.order_by(AiRequestLog.created_at.asc(), AiRequestLog.id.asc())
).all()
topics = _topic_rows(db, session_id)
topic_ids = [item["id"] for item in topics]
help_cards = []
@@ -264,7 +257,8 @@ def chat_detail(
page=messagePage,
page_size=messagePageSize,
),
"aiLogs": [_ai_log_dict(item, include_prompt=True) for item in ai_logs],
# Large prompts and retrieval chunks are loaded asynchronously by the drawer.
"aiLogs": [],
"topics": topics,
"helpCards": [help_card_dict(item) for item in help_cards],
"shareDrafts": [share_draft_dict(item) for item in share_drafts],

View File

@@ -1,7 +1,8 @@
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from datetime import date, timedelta
from io import BytesIO
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
@@ -18,13 +19,14 @@ from app.api.pagination import page_result
from app.core.database import get_db
from app.core.dependencies import get_current_admin, get_current_user
from app.core.responses import api_success
from app.core.time_utils import business_day_boundary, to_business_naive
from app.core.time_utils import business_day_boundary, to_business_naive, utc_now_naive
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession
from app.models.feedback import MessageFeedback
from app.models.user import User
from app.services.admin_service import OperationLogService
from app.services.admin_permission_service import require_permission
from app.services.chat_message_navigation_service import ChatMessageNavigationService
router = APIRouter()
@@ -96,7 +98,13 @@ def export_feedback(
@router.get("/admin/{feedback_id}")
def feedback_detail(feedback_id: int, db: Session = Depends(get_db), admin: Admin = Depends(get_current_admin)) -> dict:
def feedback_detail(
feedback_id: int,
messagePage: Annotated[int | None, Query(ge=1)] = None,
messagePageSize: Annotated[int, Query(ge=10, le=100)] = 20,
db: Session = Depends(get_db),
admin: Admin = Depends(get_current_admin),
) -> dict:
require_permission(admin, "feedback.detail")
row = db.execute(select(MessageFeedback, User, ChatMessage, ChatSession).join(User, User.id == MessageFeedback.user_id).join(ChatMessage, ChatMessage.id == MessageFeedback.message_id).join(ChatSession, ChatSession.id == MessageFeedback.session_id).where(MessageFeedback.id == feedback_id)).first()
if row is None:
@@ -105,10 +113,43 @@ def feedback_detail(feedback_id: int, db: Session = Depends(get_db), admin: Admi
if not feedback.is_read:
feedback.is_read = 1
feedback.read_by = admin.id
feedback.read_at = datetime.now(UTC).replace(tzinfo=None)
feedback.read_at = utc_now_naive()
db.commit()
messages = db.scalars(select(ChatMessage).where(ChatMessage.session_id == feedback.session_id, ChatMessage.id <= target.id).order_by(ChatMessage.id.asc()).limit(200)).all()
return api_success({**_summary(feedback, user, target), "sessionTitle": session.title, "messages": [{"id": m.id, "role": m.role, "content": m.content, "createdAt": m.created_at, "isTarget": m.id == target.id} for m in messages]})
message_query = select(ChatMessage).where(ChatMessage.session_id == feedback.session_id)
message_total = db.scalar(select(func.count()).select_from(message_query.subquery())) or 0
resolved_page = messagePage or ChatMessageNavigationService.page_for_message(
db,
session_id=feedback.session_id,
message=target,
page_size=messagePageSize,
)
messages = db.scalars(
message_query
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
.offset((resolved_page - 1) * messagePageSize)
.limit(messagePageSize)
).all()
serialized_messages = [
{
"id": message.id,
"role": message.role,
"content": message.content,
"createdAt": message.created_at,
"isTarget": message.id == target.id,
}
for message in messages
]
return api_success({
**_summary(feedback, user, target),
"sessionTitle": session.title,
"messages": serialized_messages,
"messagesPage": page_result(
serialized_messages,
total=message_total,
page=resolved_page,
page_size=messagePageSize,
),
})
@router.delete("/admin/{feedback_id}")

View File

@@ -41,6 +41,9 @@ class ChatSession(Base, TimestampMixin):
class ChatMessage(Base):
__tablename__ = "sys_chat_message"
__table_args__ = (
Index("ix_chat_message_session_created_id", "session_id", "created_at", "id"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(ForeignKey("sys_chat_session.id"), index=True, nullable=False)

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.models.chat import ChatMessage
class ChatMessageNavigationService:
"""Shared chronological paging rules for locating a message in a conversation."""
@staticmethod
def page_for_message(
db: Session,
*,
session_id: int,
message: ChatMessage,
page_size: int,
) -> int:
position = db.scalar(
select(func.count()).select_from(ChatMessage).where(
ChatMessage.session_id == session_id,
or_(
ChatMessage.created_at < message.created_at,
(
(ChatMessage.created_at == message.created_at)
& (ChatMessage.id <= message.id)
),
),
)
) or 1
return (position - 1) // page_size + 1