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

@@ -17,9 +17,15 @@ const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
const detail = ref<ChatDetail | null>(null);
const loading = ref(false);
const messagesLoading = ref(false);
const aiLogsLoading = ref(false);
const aiLogDetailsLoading = ref<number[]>([]);
const activeAiLogIds = ref<number[]>([]);
const messageKeyword = ref("");
const appliedMessageKeyword = ref("");
const exporting = ref(false);
const bodyRef = ref<HTMLElement | null>(null);
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const aiPager = reactive({ page: 1, pageSize: 20, total: 0 });
watch(
() => [props.modelValue, props.sessionId, props.focusMessageId] as const,
@@ -27,6 +33,10 @@ watch(
if (!open || !sessionId) return;
detail.value = null;
Object.assign(pager, { page: 1, total: 0 });
Object.assign(aiPager, { page: 1, total: 0 });
messageKeyword.value = "";
appliedMessageKeyword.value = "";
activeAiLogIds.value = [];
await loadDetail(sessionId, 1, pager.pageSize);
},
);
@@ -45,6 +55,7 @@ async function loadDetail(sessionId: number, page: number, pageSize: number) {
pageSize: messagePage?.pageSize ?? pageSize,
total: messagePage?.total ?? detail.value.messages.length,
});
void loadAiLogs(sessionId, 1, aiPager.pageSize);
await scrollToFocusedMessage();
} catch (error) {
ElMessage.error(
@@ -68,7 +79,11 @@ async function changeMessagePage(page: number, pageSize: number) {
if (!props.sessionId || !detail.value) return;
messagesLoading.value = true;
try {
const result = await api.chatMessages(props.sessionId, { page, pageSize });
const result = await api.chatMessages(props.sessionId, {
keyword: appliedMessageKeyword.value || undefined,
page,
pageSize,
});
detail.value = {
...detail.value,
messages: result.items,
@@ -80,7 +95,7 @@ async function changeMessagePage(page: number, pageSize: number) {
total: result.total,
});
await nextTick();
bodyRef.value?.scrollTo({ top: 0, behavior: "smooth" });
bodyRef.value?.querySelector(".conversation-title-row")?.scrollIntoView({ behavior: "smooth", block: "start" });
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "聊天消息加载失败",
@@ -90,6 +105,75 @@ async function changeMessagePage(page: number, pageSize: number) {
}
}
async function searchMessages() {
appliedMessageKeyword.value = messageKeyword.value.trim();
await changeMessagePage(1, pager.pageSize);
}
async function clearMessageSearch() {
messageKeyword.value = "";
if (!appliedMessageKeyword.value) return;
appliedMessageKeyword.value = "";
await changeMessagePage(1, pager.pageSize);
}
function messageParts(content: string) {
const keyword = appliedMessageKeyword.value;
if (!keyword) return [{ text: content, matched: false }];
const parts: Array<{ text: string; matched: boolean }> = [];
const lowerContent = content.toLocaleLowerCase();
const lowerKeyword = keyword.toLocaleLowerCase();
let cursor = 0;
let index = lowerContent.indexOf(lowerKeyword);
while (index >= 0) {
if (index > cursor) parts.push({ text: content.slice(cursor, index), matched: false });
parts.push({ text: content.slice(index, index + keyword.length), matched: true });
cursor = index + keyword.length;
index = lowerContent.indexOf(lowerKeyword, cursor);
}
if (cursor < content.length) parts.push({ text: content.slice(cursor), matched: false });
return parts.length ? parts : [{ text: content, matched: false }];
}
async function loadAiLogs(sessionId: number, page: number, pageSize: number) {
aiLogsLoading.value = true;
try {
const result = await api.aiLogs({ sessionId, page, pageSize });
if (!detail.value || props.sessionId !== sessionId) return;
detail.value.aiLogs = result.items;
Object.assign(aiPager, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
activeAiLogIds.value = [];
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "关联 AI 请求加载失败");
} finally {
aiLogsLoading.value = false;
}
}
async function handleAiLogChange(activeNames: string | number | Array<string | number>) {
const ids = (Array.isArray(activeNames) ? activeNames : [activeNames])
.map(Number)
.filter(Number.isFinite);
for (const id of ids) {
const log = detail.value?.aiLogs.find((item) => item.id === id);
if (!log || Object.prototype.hasOwnProperty.call(log, "prompt") || aiLogDetailsLoading.value.includes(id)) continue;
aiLogDetailsLoading.value = [...aiLogDetailsLoading.value, id];
try {
const fullLog = await api.aiLogDetail(id);
const index = detail.value?.aiLogs.findIndex((item) => item.id === id) ?? -1;
if (detail.value && index >= 0) detail.value.aiLogs[index] = fullLog;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "AI 请求详情加载失败");
} finally {
aiLogDetailsLoading.value = aiLogDetailsLoading.value.filter((item) => item !== id);
}
}
}
async function exportConversation() {
if (!props.sessionId) return;
exporting.value = true;
@@ -193,6 +277,11 @@ async function exportConversation() {
</section></el-collapse-item
></el-collapse
>
<el-empty
v-if="!aiLogsLoading && !detail.aiLogs.length"
description="该会话暂无关联 AI 请求"
:image-size="64"
/>
<el-empty
v-else
description="该会话暂无用户生成的求助卡"
@@ -223,7 +312,22 @@ async function exportConversation() {
description="该会话暂无用户生成的班级分享稿"
:image-size="64"
/>
<div class="conversation-title-row">
<h3 class="detail-title">完整对话</h3>
<span v-if="appliedMessageKeyword">找到 {{ pager.total }} 条匹配消息</span>
</div>
<div class="conversation-search">
<el-input
v-model="messageKeyword"
clearable
maxlength="100"
placeholder="输入关键字搜索当前会话"
@keyup.enter="searchMessages"
@clear="clearMessageSearch"
/>
<el-button type="primary" :loading="messagesLoading" @click="searchMessages">搜索</el-button>
<el-button v-if="appliedMessageKeyword" @click="clearMessageSearch">返回完整对话</el-button>
</div>
<AdminPagination
class="chat-detail-pagination top"
:page="pager.page"
@@ -253,7 +357,7 @@ async function exportConversation() {
>触发人工关注的消息</el-tag
><span>{{ formatDateTime(message.createdAt) }}</span>
</div>
<pre>{{ message.content }}</pre>
<pre><template v-for="(part, index) in messageParts(message.content)" :key="index"><mark v-if="part.matched">{{ part.text }}</mark><template v-else>{{ part.text }}</template></template></pre>
<div class="message-extra">
<span>状态{{ message.messageStatus }}</span
><span v-if="message.tokenInput"
@@ -275,6 +379,9 @@ async function exportConversation() {
/>
<h3 class="detail-title">关联 AI 请求</h3>
<el-collapse
v-model="activeAiLogIds"
v-loading="aiLogsLoading"
@change="handleAiLogChange"
><el-collapse-item
v-for="log in detail.aiLogs"
:key="log.id"
@@ -287,8 +394,9 @@ async function exportConversation() {
><span>耗时{{ log.costMs || "-" }}ms</span
><span>时间{{ formatDateTime(log.createdAt) }}</span>
</div>
<p v-if="aiLogDetailsLoading.includes(log.id)" class="ai-log-loading">正在加载请求详情</p>
<section
v-if="log.retrievedChunks?.length"
v-else-if="log.retrievedChunks?.length"
class="retrieval-chunks"
>
<article
@@ -317,7 +425,7 @@ async function exportConversation() {
description="本条请求未保存召回片段"
:image-size="64"
/>
<pre class="prompt-preview">{{
<pre v-if="!aiLogDetailsLoading.includes(log.id)" class="prompt-preview">{{
log.prompt || " Prompt 记录"
}}</pre>
<p v-if="log.errorMessage" class="error-text">
@@ -325,6 +433,13 @@ async function exportConversation() {
</p></el-collapse-item
></el-collapse
>
<AdminPagination
v-if="aiPager.total"
:page="aiPager.page"
:page-size="aiPager.pageSize"
:total="aiPager.total"
@change="(page, size) => sessionId && loadAiLogs(sessionId, page, size)"
/>
</template>
<el-empty
v-else-if="!loading"
@@ -342,10 +457,54 @@ async function exportConversation() {
margin-bottom: 14px;
}
.conversation-title-row,
.conversation-search {
display: flex;
align-items: center;
gap: 10px;
}
.conversation-title-row {
justify-content: space-between;
}
.conversation-title-row > span {
color: #7b8494;
font-size: 13px;
}
.conversation-search {
margin: 0 0 12px;
}
.conversation-search :deep(.el-input) {
max-width: 360px;
}
.conversation-message mark {
padding: 1px 3px;
border-radius: 4px;
background: #ffe58f;
color: inherit;
}
.ai-log-loading {
padding: 16px;
color: #7b8494;
text-align: center;
}
.conversation-message.focus-message {
border: 2px solid #e6a23c;
background: #fffaf0;
box-shadow: 0 0 0 4px rgb(230 162 60 / 10%);
animation: focus-message-pulse 1.2s ease-out;
}
@keyframes focus-message-pulse {
0% { box-shadow: 0 0 0 0 rgb(230 162 60 / 38%); }
65% { box-shadow: 0 0 0 12px rgb(230 162 60 / 8%); }
100% { box-shadow: 0 0 0 4px rgb(230 162 60 / 10%); }
}
.conversation-message.focus-message .message-meta {

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, ref } from "vue";
import { nextTick, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { FeedbackDetail, FeedbackItem } from "../types/api";
@@ -19,6 +19,9 @@ const readStatus = ref("all");
const dateFrom = ref("");
const dateTo = ref("");
const detail = ref<FeedbackDetail | null>(null);
const detailLoading = ref(false);
const detailBodyRef = ref<HTMLElement | null>(null);
const detailPager = reactive({ page: 1, pageSize: 20, total: 0 });
async function load() {
loading.value = true;
@@ -78,8 +81,49 @@ async function exportExcel() {
}
async function open(item: FeedbackItem) {
detail.value = await api.feedbackDetail(item.id);
item.isRead = true;
detail.value = null;
Object.assign(detailPager, { page: 1, total: 0 });
await loadDetail(item.id);
if (detail.value) item.isRead = true;
}
async function loadDetail(feedbackId: number, messagePage?: number, messagePageSize = detailPager.pageSize) {
detailLoading.value = true;
try {
detail.value = await api.feedbackDetail(feedbackId, {
messagePage,
messagePageSize,
});
Object.assign(detailPager, {
page: detail.value.messagesPage.page,
pageSize: detail.value.messagesPage.pageSize,
total: detail.value.messagesPage.total,
});
await scrollToTarget();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "反馈详情加载失败");
} finally {
detailLoading.value = false;
}
}
async function changeDetailPage(nextPage: number, nextPageSize: number) {
if (!detail.value) return;
await loadDetail(detail.value.id, nextPage, nextPageSize);
if (!detail.value.messages.some((message) => message.isTarget)) {
await nextTick();
detailBodyRef.value?.querySelector("article")?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
async function scrollToTarget() {
await nextTick();
const target = detailBodyRef.value?.querySelector<HTMLElement>("[data-feedback-target='true']");
target?.scrollIntoView({ behavior: "smooth", block: "center" });
}
function closeDetail() {
detail.value = null;
}
async function remove(item: FeedbackItem) {
@@ -124,14 +168,19 @@ onMounted(load);
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="canDetail" link type="primary" @click="open(row)">查看</el-button><el-button v-if="canDelete" link type="danger" @click="remove(row)">删除</el-button></template></el-table-column>
</el-table>
<AdminPagination v-if="total" :page="page" :page-size="pageSize" :total="total" @change="changePage" />
<el-drawer :model-value="Boolean(detail)" title="反馈详情" size="620px" @close="detail = null">
<el-drawer :model-value="Boolean(detail) || detailLoading" title="反馈详情" size="680px" @close="closeDetail">
<template v-if="detail">
<div ref="detailBodyRef" v-loading="detailLoading" class="feedback-detail-body">
<section class="feedback-summary"><span>用户反馈</span><strong>{{ detail.content }}</strong><small>{{ detail.userName }} · {{ formatDateTime(detail.createdAt) }}</small></section>
<h3>当时对话记录</h3>
<div class="feedback-history"><article v-for="message in detail.messages" :key="message.id" :class="[message.role, { target: message.isTarget }]">
<div><b>{{ message.role === 'user' ? '用户' : 'AI' }}</b><el-tag v-if="message.isTarget" type="warning">用户反馈的这条回答</el-tag></div><p>{{ message.content }}</p>
<AdminPagination class="feedback-detail-pagination" :page="detailPager.page" :page-size="detailPager.pageSize" :total="detailPager.total" @change="changeDetailPage" />
<div class="feedback-history"><article v-for="message in detail.messages" :key="message.id" :class="[message.role, { target: message.isTarget }]" :data-feedback-target="message.isTarget ? 'true' : undefined">
<div><b>{{ message.role === 'user' ? '用户' : 'AI' }}</b><el-tag v-if="message.isTarget" type="warning">用户反馈的这条回答</el-tag><time>{{ formatDateTime(message.createdAt) }}</time></div><p>{{ message.content }}</p>
</article></div>
<AdminPagination class="feedback-detail-pagination" :page="detailPager.page" :page-size="detailPager.pageSize" :total="detailPager.total" @change="changeDetailPage" />
</div>
</template>
<div v-else v-loading="detailLoading" class="feedback-detail-loading"></div>
</el-drawer>
</section>
</template>
@@ -142,6 +191,10 @@ onMounted(load);
.feedback-toolbar { align-items:center; margin-bottom:18px; padding:14px 16px; border:1px solid #e7ebf0; border-radius:12px; background:#fafbfc; }
.feedback-toolbar > :deep(.el-select) { width:140px; }
.feedback-summary { display:grid; gap:9px; padding:16px; border-radius:12px; background:#f7f9fc; }.feedback-summary span,.feedback-summary small { color:#87909f; }
.feedback-history { display:grid; gap:12px; }.feedback-history article { padding:14px 16px; border:1px solid #e6eaf0; border-radius:12px; }.feedback-history article.user { margin-left:42px; background:#f5f8ff; }.feedback-history article.assistant { margin-right:42px; }.feedback-history article.target { border:2px solid #f0a020; background:#fffaf0; }.feedback-history article div { display:flex; align-items:center; justify-content:space-between; }.feedback-history p { white-space:pre-wrap; line-height:1.65; margin:8px 0 0; }
.feedback-detail-body { min-height:260px; }
.feedback-detail-loading { min-height:260px; }
.feedback-detail-pagination { margin:12px 0; }
.feedback-history { display:grid; gap:12px; }.feedback-history article { padding:14px 16px; border:1px solid #e6eaf0; border-radius:12px; }.feedback-history article.user { margin-left:42px; background:#f5f8ff; }.feedback-history article.assistant { margin-right:42px; }.feedback-history article.target { border:2px solid #f0a020; background:#fffaf0; box-shadow:0 0 0 4px rgb(240 160 32 / 10%); animation:feedback-target-pulse 1.2s ease-out; }.feedback-history article div { display:flex; align-items:center; gap:10px; }.feedback-history article time { margin-left:auto; color:#87909f; font-size:12px; }.feedback-history p { white-space:pre-wrap; line-height:1.65; margin:8px 0 0; }
@keyframes feedback-target-pulse { 0% { box-shadow:0 0 0 0 rgb(240 160 32 / 38%); } 65% { box-shadow:0 0 0 12px rgb(240 160 32 / 8%); } 100% { box-shadow:0 0 0 4px rgb(240 160 32 / 10%); } }
@media (max-width: 900px) { .feedback-toolbar { align-items:stretch; }.feedback-toolbar .record-date-range { width:100%; }.feedback-toolbar .record-date-field { flex:1; width:auto; }.feedback-toolbar .record-filter-actions { width:100%; } }
</style>

View File

@@ -342,7 +342,7 @@ export const api = {
chats: (query: ChatRecordQuery = {}) => request<PageResult<ChatRecord>>(`/admin/chat/list${queryString(query)}`),
chatDetail: (sessionId: number, query: { messagePage?: number; messagePageSize?: number; focusMessageId?: number } = {}) =>
request<ChatDetail>(`/admin/chat/${sessionId}${queryString(query)}`),
chatMessages: (sessionId: number, query: { page?: number; pageSize?: number } = {}) =>
chatMessages: (sessionId: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<ChatMessageRecord>>(`/admin/chat/${sessionId}/messages${queryString(query)}`),
exportChatDetail: (sessionId: number, filename = `完整对话_会话${sessionId}.xlsx`) =>
download(`/admin/chat/${sessionId}/export`, filename),
@@ -380,7 +380,8 @@ export const api = {
feedbackList: (query: { readStatus?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number } = {}) => request<PageResult<import("../types/api").FeedbackItem>>(`/feedback/admin/list${queryString(query)}`),
exportFeedback: (query: { readStatus?: string; startDate: string; endDate: string }) =>
download(`/feedback/admin/export${queryString(query)}`, `反馈记录_${query.startDate}_${query.endDate}.xlsx`),
feedbackDetail: (id: number) => request<import("../types/api").FeedbackDetail>(`/feedback/admin/${id}`),
feedbackDetail: (id: number, query: { messagePage?: number; messagePageSize?: number } = {}) =>
request<import("../types/api").FeedbackDetail>(`/feedback/admin/${id}${queryString(query)}`),
deleteFeedback: (id: number) => request<null>(`/feedback/admin/${id}`, { method: "DELETE" }),
};

View File

@@ -924,4 +924,5 @@ export interface FeedbackItem {
export interface FeedbackDetail extends FeedbackItem {
sessionTitle: string;
messages: Array<{ id: number; role: "user" | "assistant"; content: string; createdAt: string; isTarget: boolean }>;
messagesPage: PageResult<{ id: number; role: "user" | "assistant"; content: string; createdAt: string; isTarget: boolean }>;
}

View File

@@ -0,0 +1,25 @@
"""add chat message navigation index
Revision ID: 0043_chat_msg_nav_index
Revises: 0042_record_export_permission
"""
from alembic import op
revision = "0043_chat_msg_nav_index"
down_revision = "0042_record_export_permission"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"ix_chat_message_session_created_id",
"sys_chat_message",
["session_id", "created_at", "id"],
)
def downgrade() -> None:
op.drop_index("ix_chat_message_session_created_id", table_name="sys_chat_message")

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)
),
),
messagePage = ChatMessageNavigationService.page_for_message(
db,
session_id=session_id,
message=focused_message,
page_size=messagePageSize,
)
) or 1
messagePage = (focused_position - 1) // messagePageSize + 1
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

View File

@@ -99,6 +99,7 @@ def test_chat_detail_messages_are_paginated():
ChatMessage(id=index + 1, session_id=1, user_id=1, role="user", content=f"消息{index + 1}")
for index in range(25)
])
db.add(AiRequestLog(session_id=1, status="success", prompt="p" * 100_000, retrieved_chunks='[{"content":"large"}]'))
db.commit()
response = chat_detail(
@@ -116,6 +117,7 @@ def test_chat_detail_messages_are_paginated():
assert data["messagesPage"]["page"] == 2
assert len(data["messages"]) == 10
assert data["messages"][0]["content"] == "消息11"
assert data["aiLogs"] == []
def test_chat_detail_focus_message_opens_its_page_in_chronological_order():
@@ -164,7 +166,7 @@ def test_chat_messages_endpoint_returns_only_message_page():
db.add(AiRequestLog(session_id=1, status="success", prompt="p" * 5000, retrieved_chunks='[{"content":"large"}]'))
db.commit()
response = chat_messages(1, page=2, pageSize=10, db=db, current_admin=object())
response = chat_messages(1, keyword="", page=2, pageSize=10, db=db, current_admin=object())
data = response["data"]
assert data["total"] == 12
@@ -173,6 +175,32 @@ def test_chat_messages_endpoint_returns_only_message_page():
assert "aiLogs" not in data
def test_chat_messages_keyword_search_is_scoped_and_paginated():
with _database() as db:
user = User(id=1, phone="13800000000", name="学员", daily_chat_limit=10)
session = ChatSession(id=1, user_id=1, title="搜索会话", message_count=24)
other_session = ChatSession(id=2, user_id=1, title="其他会话", message_count=1)
db.add_all([user, session, other_session])
db.add_all([
ChatMessage(
id=index,
session_id=1,
user_id=1,
role="user",
content=f"{index}条 关键字" if index % 2 == 0 else f"{index}条普通内容",
)
for index in range(1, 25)
])
db.add(ChatMessage(id=100, session_id=2, user_id=1, role="user", content="其他会话关键字"))
db.commit()
response = chat_messages(1, keyword="关键字", page=2, pageSize=10, db=db, current_admin=object())
data = response["data"]
assert data["total"] == 12
assert [item["id"] for item in data["items"]] == [22, 24]
def test_retrieval_and_attention_lists_are_paginated():
with _database() as db:
for index in range(21):

View File

@@ -55,3 +55,47 @@ def test_feedback_export_workbook_is_formatted_and_formula_safe() -> None:
assert "AI生成内容请结合实际情况核对后使用。" in sheet["H2"].value
assert sheet["I2"].value == created_at + timedelta(hours=8)
assert sheet.tables["FeedbackRecords"].ref == "A1:J2"
def test_feedback_detail_locates_target_beyond_first_two_hundred_messages() -> None:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
with Session(engine) as db:
user = User(id=1, phone="13800000000", name="测试用户")
admin = Admin(id=1, username="admin", password="hash", name="管理员", is_super_admin=1, must_change_password=0)
session = ChatSession(id=1, user_id=1, title="超长会话", message_count=250)
created_at = datetime(2026, 8, 29, 9, 0)
db.add_all([user, admin, session])
db.add_all([
ChatMessage(
id=index,
session_id=1,
user_id=1,
role="assistant" if index % 2 == 0 else "user",
content=f"消息{index}",
message_status="FINISHED",
created_at=created_at,
)
for index in range(1, 251)
])
db.commit()
feedback_id = create_feedback(
FeedbackCreate(messageId=230, content="这条回答有问题"),
db=db,
user=user,
)["data"]["id"]
detail = feedback_detail(
feedback_id,
messagePage=None,
messagePageSize=20,
db=db,
admin=admin,
)["data"]
assert detail["messagesPage"]["page"] == 12
assert detail["messagesPage"]["total"] == 250
assert [item["id"] for item in detail["messages"]] == list(range(221, 241))
target = next(item for item in detail["messages"] if item["isTarget"])
assert target["id"] == 230
assert target["createdAt"] == "2026-08-29T09:00:00.000Z"