feat: improve chat feedback navigation
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }),
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }>;
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -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],
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user