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 detail = ref<ChatDetail | null>(null);
const loading = ref(false); const loading = ref(false);
const messagesLoading = 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 exporting = ref(false);
const bodyRef = ref<HTMLElement | null>(null); const bodyRef = ref<HTMLElement | null>(null);
const pager = reactive({ page: 1, pageSize: 20, total: 0 }); const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const aiPager = reactive({ page: 1, pageSize: 20, total: 0 });
watch( watch(
() => [props.modelValue, props.sessionId, props.focusMessageId] as const, () => [props.modelValue, props.sessionId, props.focusMessageId] as const,
@@ -27,6 +33,10 @@ watch(
if (!open || !sessionId) return; if (!open || !sessionId) return;
detail.value = null; detail.value = null;
Object.assign(pager, { page: 1, total: 0 }); 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); await loadDetail(sessionId, 1, pager.pageSize);
}, },
); );
@@ -45,6 +55,7 @@ async function loadDetail(sessionId: number, page: number, pageSize: number) {
pageSize: messagePage?.pageSize ?? pageSize, pageSize: messagePage?.pageSize ?? pageSize,
total: messagePage?.total ?? detail.value.messages.length, total: messagePage?.total ?? detail.value.messages.length,
}); });
void loadAiLogs(sessionId, 1, aiPager.pageSize);
await scrollToFocusedMessage(); await scrollToFocusedMessage();
} catch (error) { } catch (error) {
ElMessage.error( ElMessage.error(
@@ -68,7 +79,11 @@ async function changeMessagePage(page: number, pageSize: number) {
if (!props.sessionId || !detail.value) return; if (!props.sessionId || !detail.value) return;
messagesLoading.value = true; messagesLoading.value = true;
try { 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 = {
...detail.value, ...detail.value,
messages: result.items, messages: result.items,
@@ -80,7 +95,7 @@ async function changeMessagePage(page: number, pageSize: number) {
total: result.total, total: result.total,
}); });
await nextTick(); await nextTick();
bodyRef.value?.scrollTo({ top: 0, behavior: "smooth" }); bodyRef.value?.querySelector(".conversation-title-row")?.scrollIntoView({ behavior: "smooth", block: "start" });
} catch (error) { } catch (error) {
ElMessage.error( ElMessage.error(
error instanceof Error ? error.message : "聊天消息加载失败", 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() { async function exportConversation() {
if (!props.sessionId) return; if (!props.sessionId) return;
exporting.value = true; exporting.value = true;
@@ -193,6 +277,11 @@ async function exportConversation() {
</section></el-collapse-item </section></el-collapse-item
></el-collapse ></el-collapse
> >
<el-empty
v-if="!aiLogsLoading && !detail.aiLogs.length"
description="该会话暂无关联 AI 请求"
:image-size="64"
/>
<el-empty <el-empty
v-else v-else
description="该会话暂无用户生成的求助卡" description="该会话暂无用户生成的求助卡"
@@ -223,7 +312,22 @@ async function exportConversation() {
description="该会话暂无用户生成的班级分享稿" description="该会话暂无用户生成的班级分享稿"
:image-size="64" :image-size="64"
/> />
<div class="conversation-title-row">
<h3 class="detail-title">完整对话</h3> <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 <AdminPagination
class="chat-detail-pagination top" class="chat-detail-pagination top"
:page="pager.page" :page="pager.page"
@@ -253,7 +357,7 @@ async function exportConversation() {
>触发人工关注的消息</el-tag >触发人工关注的消息</el-tag
><span>{{ formatDateTime(message.createdAt) }}</span> ><span>{{ formatDateTime(message.createdAt) }}</span>
</div> </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"> <div class="message-extra">
<span>状态{{ message.messageStatus }}</span <span>状态{{ message.messageStatus }}</span
><span v-if="message.tokenInput" ><span v-if="message.tokenInput"
@@ -275,6 +379,9 @@ async function exportConversation() {
/> />
<h3 class="detail-title">关联 AI 请求</h3> <h3 class="detail-title">关联 AI 请求</h3>
<el-collapse <el-collapse
v-model="activeAiLogIds"
v-loading="aiLogsLoading"
@change="handleAiLogChange"
><el-collapse-item ><el-collapse-item
v-for="log in detail.aiLogs" v-for="log in detail.aiLogs"
:key="log.id" :key="log.id"
@@ -287,8 +394,9 @@ async function exportConversation() {
><span>耗时{{ log.costMs || "-" }}ms</span ><span>耗时{{ log.costMs || "-" }}ms</span
><span>时间{{ formatDateTime(log.createdAt) }}</span> ><span>时间{{ formatDateTime(log.createdAt) }}</span>
</div> </div>
<p v-if="aiLogDetailsLoading.includes(log.id)" class="ai-log-loading">正在加载请求详情</p>
<section <section
v-if="log.retrievedChunks?.length" v-else-if="log.retrievedChunks?.length"
class="retrieval-chunks" class="retrieval-chunks"
> >
<article <article
@@ -317,7 +425,7 @@ async function exportConversation() {
description="本条请求未保存召回片段" description="本条请求未保存召回片段"
:image-size="64" :image-size="64"
/> />
<pre class="prompt-preview">{{ <pre v-if="!aiLogDetailsLoading.includes(log.id)" class="prompt-preview">{{
log.prompt || " Prompt 记录" log.prompt || " Prompt 记录"
}}</pre> }}</pre>
<p v-if="log.errorMessage" class="error-text"> <p v-if="log.errorMessage" class="error-text">
@@ -325,6 +433,13 @@ async function exportConversation() {
</p></el-collapse-item </p></el-collapse-item
></el-collapse ></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> </template>
<el-empty <el-empty
v-else-if="!loading" v-else-if="!loading"
@@ -342,10 +457,54 @@ async function exportConversation() {
margin-bottom: 14px; 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 { .conversation-message.focus-message {
border: 2px solid #e6a23c; border: 2px solid #e6a23c;
background: #fffaf0; background: #fffaf0;
box-shadow: 0 0 0 4px rgb(230 162 60 / 10%); 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 { .conversation-message.focus-message .message-meta {

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, ref } from "vue"; import { nextTick, onMounted, reactive, ref } from "vue";
import { api } from "../services/api"; import { api } from "../services/api";
import type { FeedbackDetail, FeedbackItem } from "../types/api"; import type { FeedbackDetail, FeedbackItem } from "../types/api";
@@ -19,6 +19,9 @@ const readStatus = ref("all");
const dateFrom = ref(""); const dateFrom = ref("");
const dateTo = ref(""); const dateTo = ref("");
const detail = ref<FeedbackDetail | null>(null); 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() { async function load() {
loading.value = true; loading.value = true;
@@ -78,8 +81,49 @@ async function exportExcel() {
} }
async function open(item: FeedbackItem) { async function open(item: FeedbackItem) {
detail.value = await api.feedbackDetail(item.id); detail.value = null;
item.isRead = true; 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) { 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-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> </el-table>
<AdminPagination v-if="total" :page="page" :page-size="pageSize" :total="total" @change="changePage" /> <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"> <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> <section class="feedback-summary"><span>用户反馈</span><strong>{{ detail.content }}</strong><small>{{ detail.userName }} · {{ formatDateTime(detail.createdAt) }}</small></section>
<h3>当时对话记录</h3> <h3>当时对话记录</h3>
<div class="feedback-history"><article v-for="message in detail.messages" :key="message.id" :class="[message.role, { target: message.isTarget }]"> <AdminPagination class="feedback-detail-pagination" :page="detailPager.page" :page-size="detailPager.pageSize" :total="detailPager.total" @change="changeDetailPage" />
<div><b>{{ message.role === 'user' ? '用户' : 'AI' }}</b><el-tag v-if="message.isTarget" type="warning">用户反馈的这条回答</el-tag></div><p>{{ message.content }}</p> <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> </article></div>
<AdminPagination class="feedback-detail-pagination" :page="detailPager.page" :page-size="detailPager.pageSize" :total="detailPager.total" @change="changeDetailPage" />
</div>
</template> </template>
<div v-else v-loading="detailLoading" class="feedback-detail-loading"></div>
</el-drawer> </el-drawer>
</section> </section>
</template> </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 { 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-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-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%; } } @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> </style>

View File

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

View File

@@ -924,4 +924,5 @@ export interface FeedbackItem {
export interface FeedbackDetail extends FeedbackItem { export interface FeedbackDetail extends FeedbackItem {
sessionTitle: string; sessionTitle: string;
messages: Array<{ id: number; role: "user" | "assistant"; content: string; createdAt: string; isTarget: boolean }>; 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.help_card_service import help_card_dict
from app.services.share_draft_service import share_draft_dict from app.services.share_draft_service import share_draft_dict
from app.services.chat_export_service import ChatExportService from app.services.chat_export_service import ChatExportService
from app.services.chat_message_navigation_service import ChatMessageNavigationService
router = APIRouter() router = APIRouter()
@@ -119,6 +120,7 @@ def export_chats(
@router.get("/chat/{session_id}/messages") @router.get("/chat/{session_id}/messages")
def chat_messages( def chat_messages(
session_id: int, session_id: int,
keyword: str = Query(default="", max_length=100),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100), pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -127,7 +129,10 @@ def chat_messages(
exists_session = db.scalar(select(ChatSession.id).where(ChatSession.id == session_id)) exists_session = db.scalar(select(ChatSession.id).where(ChatSession.id == session_id))
if exists_session is None: if exists_session is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
normalized_keyword = keyword.strip()
message_query = select(ChatMessage).where(ChatMessage.session_id == session_id) 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 total = db.scalar(select(func.count()).select_from(message_query.subquery())) or 0
messages = db.scalars( messages = db.scalars(
message_query message_query
@@ -211,30 +216,18 @@ def chat_detail(
) )
if focused_message is None: if focused_message is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="触发消息不属于该会话或已不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="触发消息不属于该会话或已不存在")
focused_position = db.scalar( messagePage = ChatMessageNavigationService.page_for_message(
select(func.count()).select_from(ChatMessage).where( db,
ChatMessage.session_id == session_id, session_id=session_id,
or_( message=focused_message,
ChatMessage.created_at < focused_message.created_at, page_size=messagePageSize,
(
(ChatMessage.created_at == focused_message.created_at)
& (ChatMessage.id <= focused_message.id)
),
),
) )
) or 1
messagePage = (focused_position - 1) // messagePageSize + 1
messages = db.scalars( messages = db.scalars(
message_query message_query
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc()) .order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
.offset((messagePage - 1) * messagePageSize) .offset((messagePage - 1) * messagePageSize)
.limit(messagePageSize) .limit(messagePageSize)
).all() ).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) topics = _topic_rows(db, session_id)
topic_ids = [item["id"] for item in topics] topic_ids = [item["id"] for item in topics]
help_cards = [] help_cards = []
@@ -264,7 +257,8 @@ def chat_detail(
page=messagePage, page=messagePage,
page_size=messagePageSize, 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, "topics": topics,
"helpCards": [help_card_dict(item) for item in help_cards], "helpCards": [help_card_dict(item) for item in help_cards],
"shareDrafts": [share_draft_dict(item) for item in share_drafts], "shareDrafts": [share_draft_dict(item) for item in share_drafts],

View File

@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, date, datetime, timedelta from datetime import date, timedelta
from io import BytesIO from io import BytesIO
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse 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.database import get_db
from app.core.dependencies import get_current_admin, get_current_user from app.core.dependencies import get_current_admin, get_current_user
from app.core.responses import api_success 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.admin import Admin
from app.models.chat import ChatMessage, ChatSession from app.models.chat import ChatMessage, ChatSession
from app.models.feedback import MessageFeedback from app.models.feedback import MessageFeedback
from app.models.user import User from app.models.user import User
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
from app.services.admin_permission_service import require_permission from app.services.admin_permission_service import require_permission
from app.services.chat_message_navigation_service import ChatMessageNavigationService
router = APIRouter() router = APIRouter()
@@ -96,7 +98,13 @@ def export_feedback(
@router.get("/admin/{feedback_id}") @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") 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() 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: 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: if not feedback.is_read:
feedback.is_read = 1 feedback.is_read = 1
feedback.read_by = admin.id feedback.read_by = admin.id
feedback.read_at = datetime.now(UTC).replace(tzinfo=None) feedback.read_at = utc_now_naive()
db.commit() 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() message_query = select(ChatMessage).where(ChatMessage.session_id == feedback.session_id)
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_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}") @router.delete("/admin/{feedback_id}")

View File

@@ -41,6 +41,9 @@ class ChatSession(Base, TimestampMixin):
class ChatMessage(Base): class ChatMessage(Base):
__tablename__ = "sys_chat_message" __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) 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) 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}") ChatMessage(id=index + 1, session_id=1, user_id=1, role="user", content=f"消息{index + 1}")
for index in range(25) for index in range(25)
]) ])
db.add(AiRequestLog(session_id=1, status="success", prompt="p" * 100_000, retrieved_chunks='[{"content":"large"}]'))
db.commit() db.commit()
response = chat_detail( response = chat_detail(
@@ -116,6 +117,7 @@ def test_chat_detail_messages_are_paginated():
assert data["messagesPage"]["page"] == 2 assert data["messagesPage"]["page"] == 2
assert len(data["messages"]) == 10 assert len(data["messages"]) == 10
assert data["messages"][0]["content"] == "消息11" assert data["messages"][0]["content"] == "消息11"
assert data["aiLogs"] == []
def test_chat_detail_focus_message_opens_its_page_in_chronological_order(): 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.add(AiRequestLog(session_id=1, status="success", prompt="p" * 5000, retrieved_chunks='[{"content":"large"}]'))
db.commit() 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"] data = response["data"]
assert data["total"] == 12 assert data["total"] == 12
@@ -173,6 +175,32 @@ def test_chat_messages_endpoint_returns_only_message_page():
assert "aiLogs" not in data 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(): def test_retrieval_and_attention_lists_are_paginated():
with _database() as db: with _database() as db:
for index in range(21): 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 "AI生成内容请结合实际情况核对后使用。" in sheet["H2"].value
assert sheet["I2"].value == created_at + timedelta(hours=8) assert sheet["I2"].value == created_at + timedelta(hours=8)
assert sheet.tables["FeedbackRecords"].ref == "A1:J2" 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"