Files
EasyFlow/easyflow-ui-usercenter/app/src/views/chatHistory/index.vue
陈子默 1a6ea64e80 feat: 支持聊天多版本答案切换
- 为管理端、公共聊天和用户中心补充回答变体查询与切换能力

- 支持基于指定轮次重新生成并同步前后端多版本状态

- 保留 application.yml 与本地截图文件为未提交状态
2026-05-14 21:23:20 +08:00

467 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>