- 为管理端、公共聊天和用户中心补充回答变体查询与切换能力 - 支持基于指定轮次重新生成并同步前后端多版本状态 - 保留 application.yml 与本地截图文件为未提交状态
467 lines
14 KiB
Vue
467 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
||
|
||
import { computed, onMounted, ref, watch } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
|
||
import {
|
||
ChatTimeHistoryMapper,
|
||
ChatTimeTimelineBuilder,
|
||
createChatVariantSwitchController,
|
||
} from '@easyflow/utils';
|
||
import { Delete, Edit, Search } from '@element-plus/icons-vue';
|
||
import {
|
||
ElButton,
|
||
ElContainer,
|
||
ElDrawer,
|
||
ElEmpty,
|
||
ElHeader,
|
||
ElInput,
|
||
ElMain,
|
||
ElMessage,
|
||
ElMessageBox,
|
||
ElPagination,
|
||
ElSelect,
|
||
ElSpace,
|
||
ElTag,
|
||
} from 'element-plus';
|
||
import { tryit } from 'radash';
|
||
|
||
import { api } from '#/api/request';
|
||
import { ChatBubbleList } from '#/components/chat';
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
|
||
const assistantList = ref<any[]>([]);
|
||
const sessions = ref<any[]>([]);
|
||
const loading = ref(false);
|
||
const queryParams = ref({
|
||
assistantId: undefined as number | undefined,
|
||
keyword: '',
|
||
pageNumber: 1,
|
||
pageSize: 20,
|
||
});
|
||
|
||
const pageState = ref({
|
||
total: 0,
|
||
});
|
||
|
||
const drawerVisible = ref(false);
|
||
const drawerLoading = ref(false);
|
||
const currentSession = ref<any>();
|
||
const messageList = ref<ChatTimeTimelineItem[]>([]);
|
||
const variantSwitchStateVersion = ref(0);
|
||
const loadedMessageRecordCount = ref(0);
|
||
const messagePage = ref({
|
||
total: 0,
|
||
pageNumber: 1,
|
||
pageSize: 20,
|
||
});
|
||
const variantSwitchController = createChatVariantSwitchController<any, ChatTimeTimelineItem>({
|
||
mapRecords: (records) => ChatTimeHistoryMapper.fromHistoryRecords(records),
|
||
onError: () => ElMessage.error('答案版本切换失败'),
|
||
onStateChange: () => {
|
||
variantSwitchStateVersion.value += 1;
|
||
},
|
||
replaceRound(items, roundId, nextItems) {
|
||
ChatTimeTimelineBuilder.replaceRoundMessages(items, roundId, nextItems);
|
||
},
|
||
});
|
||
|
||
const filteredSessions = computed(() => {
|
||
const keyword = queryParams.value.keyword.trim().toLowerCase();
|
||
if (!keyword) {
|
||
return sessions.value;
|
||
}
|
||
return sessions.value.filter((item) => {
|
||
const title = String(item.title || '').toLowerCase();
|
||
const preview = String(item.lastMessagePreview || '').toLowerCase();
|
||
const assistantName = String(item.assistantName || '').toLowerCase();
|
||
return title.includes(keyword) || preview.includes(keyword) || assistantName.includes(keyword);
|
||
});
|
||
});
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([fetchAssistants(), fetchSessions()]);
|
||
const sessionId = route.query.sessionId ? String(route.query.sessionId) : '';
|
||
if (sessionId) {
|
||
await openSession(sessionId);
|
||
}
|
||
});
|
||
|
||
watch(
|
||
() => route.query.sessionId,
|
||
async (sessionId) => {
|
||
if (!sessionId) {
|
||
drawerVisible.value = false;
|
||
currentSession.value = undefined;
|
||
messageList.value = [];
|
||
return;
|
||
}
|
||
if (!currentSession.value || String(currentSession.value.id) !== String(sessionId)) {
|
||
await openSession(String(sessionId));
|
||
}
|
||
},
|
||
);
|
||
|
||
async function fetchAssistants() {
|
||
const [, res] = await tryit(api.get)('/userCenter/bot/list', {
|
||
params: { status: 1 },
|
||
});
|
||
if (res?.errorCode === 0) {
|
||
assistantList.value = (res.data || []).map((item: any) => ({
|
||
label: item.title,
|
||
value: item.id,
|
||
}));
|
||
}
|
||
}
|
||
|
||
async function fetchSessions() {
|
||
loading.value = true;
|
||
const [, res] = await tryit(api.get)('/userCenter/chatHistory/sessions', {
|
||
params: {
|
||
assistantId: queryParams.value.assistantId,
|
||
pageNumber: queryParams.value.pageNumber,
|
||
pageSize: queryParams.value.pageSize,
|
||
},
|
||
});
|
||
loading.value = false;
|
||
if (res?.errorCode === 0) {
|
||
sessions.value = res.data?.records || [];
|
||
pageState.value.total = res.data?.total || 0;
|
||
}
|
||
}
|
||
|
||
async function openSession(sessionId: string | number) {
|
||
drawerLoading.value = true;
|
||
const [, summaryRes] = await tryit(api.get)(`/userCenter/chatHistory/sessions/${sessionId}`);
|
||
if (summaryRes?.errorCode !== 0) {
|
||
drawerLoading.value = false;
|
||
return;
|
||
}
|
||
currentSession.value = summaryRes.data;
|
||
messageList.value = [];
|
||
loadedMessageRecordCount.value = 0;
|
||
messagePage.value = {
|
||
total: 0,
|
||
pageNumber: 1,
|
||
pageSize: 20,
|
||
};
|
||
drawerVisible.value = true;
|
||
await loadMessages(true);
|
||
drawerLoading.value = false;
|
||
if (String(route.query.sessionId || '') !== String(sessionId)) {
|
||
router.replace({
|
||
path: '/chatHistory',
|
||
query: { ...route.query, sessionId: String(sessionId) },
|
||
});
|
||
}
|
||
}
|
||
|
||
async function loadMessages(reset = false) {
|
||
if (!currentSession.value?.id) {
|
||
return;
|
||
}
|
||
const nextPageNumber = reset ? 1 : messagePage.value.pageNumber + 1;
|
||
const [, res] = await tryit(api.get)(
|
||
`/userCenter/chatHistory/sessions/${currentSession.value.id}/messages`,
|
||
{
|
||
params: {
|
||
pageNumber: nextPageNumber,
|
||
pageSize: messagePage.value.pageSize,
|
||
},
|
||
},
|
||
);
|
||
if (res?.errorCode !== 0) {
|
||
return;
|
||
}
|
||
const normalized = normalizeMessages(res.data?.records || []);
|
||
if (reset) {
|
||
messageList.value = normalized;
|
||
} else {
|
||
messageList.value = [...normalized, ...messageList.value];
|
||
}
|
||
prefetchVisibleVariants();
|
||
loadedMessageRecordCount.value = reset
|
||
? (res.data?.records || []).length
|
||
: loadedMessageRecordCount.value + (res.data?.records || []).length;
|
||
messagePage.value.total = res.data?.total || 0;
|
||
messagePage.value.pageNumber = nextPageNumber;
|
||
}
|
||
|
||
function normalizeMessages(records: any[]) {
|
||
return ChatTimeHistoryMapper.fromHistoryRecords([...records].reverse());
|
||
}
|
||
|
||
async function handleSelectVariant(
|
||
item: ChatTimeTimelineItem,
|
||
direction: 'next' | 'previous',
|
||
) {
|
||
if (!currentSession.value?.id || !item.roundId) {
|
||
return;
|
||
}
|
||
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
|
||
const variantIndex = direction === 'previous' ? current - 1 : current + 1;
|
||
const sessionId = String(currentSession.value.id);
|
||
await variantSwitchController.switchVariant({
|
||
fetchVariants: () => fetchRoundVariants(sessionId, item.roundId!),
|
||
items: messageList.value,
|
||
persistVariant: async () => {
|
||
const [, res] = await tryit(api.post)(
|
||
`/userCenter/chatHistory/sessions/${sessionId}/rounds/${item.roundId}/selectVariant`,
|
||
{
|
||
variantIndex,
|
||
},
|
||
);
|
||
if (res?.errorCode !== 0 || !res?.data) {
|
||
throw new Error(String((res as any)?.message || '答案版本切换失败'));
|
||
}
|
||
return res.data;
|
||
},
|
||
roundId: item.roundId,
|
||
sessionId,
|
||
targetVariantIndex: variantIndex,
|
||
});
|
||
}
|
||
|
||
async function fetchRoundVariants(sessionId: string, roundId: string | number) {
|
||
const [, res] = await tryit(api.get)(
|
||
`/userCenter/chatHistory/sessions/${sessionId}/rounds/${roundId}/variants`,
|
||
);
|
||
if (res?.errorCode !== 0) {
|
||
throw new Error(String((res as any)?.message || '答案版本加载失败'));
|
||
}
|
||
return res?.data || [];
|
||
}
|
||
|
||
function prefetchVisibleVariants() {
|
||
if (!currentSession.value?.id) {
|
||
return;
|
||
}
|
||
const sessionId = String(currentSession.value.id);
|
||
const prefetchedRoundIds = new Set<string>();
|
||
for (const item of messageList.value) {
|
||
if (
|
||
item.role !== 'assistant' ||
|
||
!item.roundId ||
|
||
Number(item.variantCount || 0) <= 1 ||
|
||
prefetchedRoundIds.has(String(item.roundId))
|
||
) {
|
||
continue;
|
||
}
|
||
prefetchedRoundIds.add(String(item.roundId));
|
||
variantSwitchController.prefetchVariants({
|
||
fetchVariants: () => fetchRoundVariants(sessionId, item.roundId!),
|
||
roundId: item.roundId,
|
||
sessionId,
|
||
});
|
||
}
|
||
}
|
||
|
||
function currentSwitchingRoundIds() {
|
||
void variantSwitchStateVersion.value;
|
||
if (!currentSession.value?.id) {
|
||
return [];
|
||
}
|
||
const sessionId = String(currentSession.value.id);
|
||
return Array.from(
|
||
new Set(
|
||
messageList.value
|
||
.filter(
|
||
(item) =>
|
||
item.roundId &&
|
||
variantSwitchController.isSwitching(sessionId, item.roundId),
|
||
)
|
||
.map((item) => String(item.roundId)),
|
||
),
|
||
);
|
||
}
|
||
|
||
function closeDrawer() {
|
||
drawerVisible.value = false;
|
||
currentSession.value = undefined;
|
||
messageList.value = [];
|
||
loadedMessageRecordCount.value = 0;
|
||
router.replace({ path: '/chatHistory', query: {} });
|
||
}
|
||
|
||
function changePage(pageNumber: number) {
|
||
queryParams.value.pageNumber = pageNumber;
|
||
fetchSessions();
|
||
}
|
||
|
||
async function renameSession(session: any) {
|
||
const [, promptRes] = await tryit(ElMessageBox.prompt)(
|
||
'请输入新的会话名称',
|
||
'重命名会话',
|
||
{
|
||
inputValue: session.title || '',
|
||
inputPlaceholder: '请输入会话名称',
|
||
confirmButtonText: '确认',
|
||
cancelButtonText: '取消',
|
||
},
|
||
);
|
||
const value = promptRes?.value?.trim();
|
||
if (!value) {
|
||
return;
|
||
}
|
||
const [, res] = await tryit(api.post)(`/userCenter/chatHistory/sessions/${session.id}/rename`, {
|
||
title: value,
|
||
});
|
||
if (res?.errorCode === 0) {
|
||
ElMessage.success('重命名成功');
|
||
if (currentSession.value?.id === session.id) {
|
||
currentSession.value.title = value;
|
||
}
|
||
await fetchSessions();
|
||
}
|
||
}
|
||
|
||
function deleteSession(session: any) {
|
||
ElMessageBox.confirm('删除后将不再出现在聊天历史中,是否继续?', '删除会话', {
|
||
type: 'warning',
|
||
confirmButtonText: '删除',
|
||
cancelButtonText: '取消',
|
||
}).then(async () => {
|
||
const [, res] = await tryit(api.post)(`/userCenter/chatHistory/sessions/${session.id}/delete`, {});
|
||
if (res?.errorCode === 0) {
|
||
ElMessage.success('删除成功');
|
||
if (currentSession.value?.id === session.id) {
|
||
closeDrawer();
|
||
}
|
||
await fetchSessions();
|
||
}
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function formatTime(value?: string) {
|
||
if (!value) {
|
||
return '';
|
||
}
|
||
const time = new Date(value);
|
||
if (Number.isNaN(time.getTime())) {
|
||
return value;
|
||
}
|
||
return `${time.getMonth() + 1}-${time.getDate()} ${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<ElContainer class="bg-background-deep h-full">
|
||
<ElHeader class="!h-auto !px-8 !pb-0 !pt-8">
|
||
<ElSpace direction="vertical" :size="20" alignment="flex-start" class="w-full">
|
||
<div>
|
||
<h1 class="text-2xl font-medium">聊天历史</h1>
|
||
<p class="text-foreground/60 mt-2 text-sm">
|
||
查看最近会话,并在右侧抽屉中回溯完整聊天内容。
|
||
</p>
|
||
</div>
|
||
<div class="flex w-full flex-wrap items-center gap-4">
|
||
<ElSelect
|
||
v-model="queryParams.assistantId"
|
||
clearable
|
||
placeholder="筛选聊天助理"
|
||
:options="assistantList"
|
||
class="!w-[220px]"
|
||
@change="fetchSessions"
|
||
/>
|
||
<ElInput
|
||
v-model="queryParams.keyword"
|
||
class="max-w-[320px]"
|
||
placeholder="搜索标题或最近消息"
|
||
:prefix-icon="Search"
|
||
/>
|
||
</div>
|
||
</ElSpace>
|
||
</ElHeader>
|
||
|
||
<ElMain class="!px-8 !pb-8">
|
||
<div class="bg-background border-border min-h-full rounded-2xl border p-5">
|
||
<div v-if="filteredSessions.length > 0" class="flex flex-col gap-3">
|
||
<button
|
||
v-for="item in filteredSessions"
|
||
:key="item.id"
|
||
class="border-border hover:border-primary/30 hover:bg-accent/40 flex w-full items-start justify-between gap-4 rounded-2xl border px-5 py-4 text-left transition-colors"
|
||
@click="openSession(item.id)"
|
||
>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-center gap-3">
|
||
<span class="truncate text-base font-medium">{{ item.title || '未命名会话' }}</span>
|
||
<ElTag size="small" effect="plain">{{ item.assistantName || '聊天助理' }}</ElTag>
|
||
</div>
|
||
<div class="text-foreground/65 mt-2 line-clamp-2 text-sm">
|
||
{{ item.lastMessagePreview || '暂无消息内容' }}
|
||
</div>
|
||
<div class="text-foreground/50 mt-3 flex flex-wrap items-center gap-4 text-xs">
|
||
<span>最近发送人:{{ item.lastSenderName || '未知' }}</span>
|
||
<span>消息数:{{ item.messageCount || 0 }}</span>
|
||
<span>活跃时间:{{ formatTime(item.lastMessageAt || item.accessAt) }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<ElButton link :icon="Edit" @click.stop="renameSession(item)">重命名</ElButton>
|
||
<ElButton link type="danger" :icon="Delete" @click.stop="deleteSession(item)">删除</ElButton>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<ElEmpty v-else description="暂无聊天历史" />
|
||
|
||
<div class="mt-6 flex justify-end" v-if="pageState.total > queryParams.pageSize">
|
||
<ElPagination
|
||
background
|
||
layout="prev, pager, next"
|
||
:current-page="queryParams.pageNumber"
|
||
:page-size="queryParams.pageSize"
|
||
:total="pageState.total"
|
||
@current-change="changePage"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</ElMain>
|
||
</ElContainer>
|
||
|
||
<ElDrawer
|
||
v-model="drawerVisible"
|
||
:title="currentSession?.title || '聊天详情'"
|
||
size="760px"
|
||
destroy-on-close
|
||
@close="closeDrawer"
|
||
>
|
||
<div v-loading="drawerLoading" class="flex h-full flex-col">
|
||
<div class="border-border mb-4 flex items-center justify-between rounded-2xl border px-4 py-3">
|
||
<div class="min-w-0">
|
||
<div class="truncate text-base font-medium">{{ currentSession?.title || '聊天详情' }}</div>
|
||
<div class="text-foreground/55 mt-1 text-sm">
|
||
{{ currentSession?.assistantName || '聊天助理' }}
|
||
</div>
|
||
</div>
|
||
<div class="text-foreground/50 text-xs">
|
||
{{ formatTime(currentSession?.lastMessageAt || currentSession?.accessAt) }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex-1 overflow-hidden">
|
||
<div class="mb-4 flex justify-center">
|
||
<ElButton
|
||
v-if="loadedMessageRecordCount < messagePage.total"
|
||
text
|
||
type="primary"
|
||
@click="loadMessages(false)"
|
||
>
|
||
加载更早消息
|
||
</ElButton>
|
||
</div>
|
||
<ChatBubbleList
|
||
:bot="{ icon: '', title: currentSession?.assistantName || '' }"
|
||
:messages="messageList"
|
||
allow-variant-switch
|
||
:switching-round-ids="currentSwitchingRoundIds()"
|
||
@select-next-variant="handleSelectVariant($event, 'next')"
|
||
@select-previous-variant="handleSelectVariant($event, 'previous')"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</ElDrawer>
|
||
</template>
|