feat: 支持聊天多版本答案切换

- 为管理端、公共聊天和用户中心补充回答变体查询与切换能力

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

- 保留 application.yml 与本地截图文件为未提交状态
This commit is contained in:
2026-05-14 21:23:20 +08:00
parent da58077d59
commit 1a6ea64e80
23 changed files with 2625 additions and 122 deletions

View File

@@ -3,26 +3,53 @@ import type { ChatTimeTimelineItem } from '@easyflow/types';
import { XMarkdown as ElXMarkdown } from 'vue-element-plus-x';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { ChatThinkingBlock } from '@easyflow/common-ui';
import { IconifyIcon } from '@easyflow/icons';
import { useUserStore } from '@easyflow/stores';
import { CircleCheck } from '@element-plus/icons-vue';
import { ElAvatar, ElIcon } from 'element-plus';
import {
ArrowLeft,
ArrowRight,
CircleCheck,
CopyDocument,
Loading,
RefreshRight,
} from '@element-plus/icons-vue';
import { ElAvatar, ElIcon, ElMessage } from 'element-plus';
import defaultAssistantAvatar from '#/assets/defaultAssistantAvatar.svg';
import defaultUserAvatar from '#/assets/defaultUserAvatar.png';
import ShowJson from '#/components/json/ShowJson.vue';
interface Props {
allowRegenerate?: boolean;
allowVariantSwitch?: boolean;
bot: any;
messages: ChatTimeTimelineItem[];
regenerateDisabled?: boolean;
switchingRoundIds?: string[];
variantSwitchDisabled?: boolean;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
allowRegenerate: false,
allowVariantSwitch: false,
regenerateDisabled: false,
switchingRoundIds: () => [],
variantSwitchDisabled: false,
});
const store = useUserStore();
const expandedToolState = ref<Record<string, boolean>>({});
const latestAssistantMessageId = computed(() => {
return [...props.messages].reverse().find((item) => item.role === 'assistant')?.id || '';
});
const emit = defineEmits<{
regenerate: [item: ChatTimeTimelineItem];
selectNextVariant: [item: ChatTimeTimelineItem];
selectPreviousVariant: [item: ChatTimeTimelineItem];
}>();
function getAssistantAvatar() {
return props.bot.icon || defaultAssistantAvatar;
@@ -59,6 +86,87 @@ function toggleToolExpanded(item: ChatTimeTimelineItem) {
[item.id]: !expandedToolState.value[item.id],
};
}
function canCopy(item: ChatTimeTimelineItem) {
return item.role !== 'tool' && Boolean(String(item.content || '').trim());
}
function canRegenerate(item: ChatTimeTimelineItem) {
return (
props.allowRegenerate &&
item.role === 'assistant' &&
item.id === latestAssistantMessageId.value
);
}
function shouldShowVariantNavigator(item: ChatTimeTimelineItem) {
return (
props.allowVariantSwitch &&
item.role === 'assistant' &&
isFinalAssistantInRound(item) &&
Number(item.variantCount || 0) > 1 &&
Boolean(item.roundId)
);
}
function canSwitchVariant(
item: ChatTimeTimelineItem,
direction: 'next' | 'previous',
) {
if (
item.role !== 'assistant' ||
!isFinalAssistantInRound(item) ||
props.variantSwitchDisabled ||
isVariantSwitching(item) ||
!item.switchable
) {
return false;
}
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
const total = Number(item.variantCount || 1);
if (direction === 'previous') {
return current > 1;
}
return current < total;
}
function isVariantSwitching(item: ChatTimeTimelineItem) {
return Boolean(
item.roundId && props.switchingRoundIds.includes(String(item.roundId)),
);
}
function isFinalAssistantInRound(item: ChatTimeTimelineItem) {
if (item.role !== 'assistant') {
return false;
}
if (!item.roundId) {
return true;
}
for (let index = props.messages.length - 1; index >= 0; index -= 1) {
const candidate = props.messages[index];
if (
candidate?.role === 'assistant' &&
candidate.roundId === item.roundId &&
String(candidate.variantIndex || '') === String(item.variantIndex || '')
) {
return candidate.id === item.id;
}
}
return true;
}
async function handleCopy(item: ChatTimeTimelineItem) {
if (!canCopy(item)) {
return;
}
try {
await navigator.clipboard.writeText(String(item.content || ''));
ElMessage.success('已复制');
} catch {
ElMessage.error('复制失败');
}
}
</script>
<template>
@@ -151,19 +259,65 @@ function toggleToolExpanded(item: ChatTimeTimelineItem) {
<ElXMarkdown v-else :markdown="item.content" />
</template>
<!-- 自定义底部 -->
<!--<template #footer="{ item }">
<div class="flex items-center">
<template v-if="item.role === 'assistant'">
<ElButton :icon="RefreshRight" link />
<ElButton :icon="CopyDocument" link />
</template>
<template v-else>
<ElButton :icon="CopyDocument" link />
<ElButton :icon="EditPen" link />
</template>
<template #footer="{ item }">
<div
v-if="canCopy(item) || canRegenerate(item) || shouldShowVariantNavigator(item)"
class="chat-message-actions"
:class="{ 'is-user': item.role === 'user' }"
>
<div
v-if="shouldShowVariantNavigator(item)"
class="chat-message-actions__variant"
>
<button
type="button"
class="chat-message-actions__button"
:disabled="!canSwitchVariant(item, 'previous')"
aria-label="查看上一版答案"
@click="emit('selectPreviousVariant', item)"
>
<Loading v-if="isVariantSwitching(item)" class="is-loading" />
<ArrowLeft v-else />
</button>
<span class="chat-message-actions__variant-label">
{{ Number(item.variantIndex || item.selectedVariantIndex || 1) }}/{{
Number(item.variantCount || 1)
}}
</span>
<button
type="button"
class="chat-message-actions__button"
:disabled="!canSwitchVariant(item, 'next')"
aria-label="查看下一版答案"
@click="emit('selectNextVariant', item)"
>
<Loading v-if="isVariantSwitching(item)" class="is-loading" />
<ArrowRight v-else />
</button>
</div>
<button
v-if="canRegenerate(item)"
type="button"
class="chat-message-actions__button"
:disabled="regenerateDisabled"
aria-label="重新生成"
@click="emit('regenerate', item)"
>
<RefreshRight />
</button>
<button
v-if="canCopy(item)"
type="button"
class="chat-message-actions__button"
aria-label="复制消息"
@click="handleCopy(item)"
>
<CopyDocument />
</button>
</div>
</template>-->
</template>
</ElBubbleList>
</template>
@@ -271,4 +425,76 @@ function toggleToolExpanded(item: ChatTimeTimelineItem) {
gap: 6px;
padding: 0 10px 10px;
}
.chat-message-actions {
display: inline-flex;
gap: 4px;
align-items: center;
margin-top: 6px;
}
.chat-message-actions.is-user {
justify-content: flex-end;
}
.chat-message-actions__variant {
display: inline-flex;
gap: 2px;
align-items: center;
}
.chat-message-actions__variant-label {
min-width: 42px;
font-size: 11px;
font-weight: 600;
text-align: center;
color: hsl(var(--text-muted));
}
.chat-message-actions__button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: hsl(var(--text-muted));
cursor: pointer;
border: none;
border-radius: 999px;
background: transparent;
transition:
color 0.18s ease,
background-color 0.18s ease;
}
.chat-message-actions__button:hover:not(:disabled) {
color: hsl(var(--text-strong));
background: hsl(var(--foreground) / 0.05);
}
.chat-message-actions__button:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.32);
outline-offset: 2px;
}
.chat-message-actions__button:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.chat-message-actions__button :deep(svg) {
width: 15px;
height: 15px;
}
.chat-message-actions__button .is-loading {
animation: chat-action-spin 0.8s linear infinite;
}
@keyframes chat-action-spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -21,30 +21,75 @@ const props = defineProps<Props>();
const senderValue = ref('');
const btnLoading = ref(false);
const getSessionList = inject<any>('getSessionList');
interface SendMessageOptions {
prompt?: string;
regenerateRoundId?: string;
}
interface SseRoundMeta {
roundId?: string;
roundNo?: number;
selectedVariantIndex?: number;
switchable?: boolean;
variantCount?: number;
variantIndex?: number;
}
defineExpose({
getConversationId: () => props.conversationId,
sendMessage,
});
const clearSenderFiles = () => {
files.value = [];
attachmentsRef.value?.clearFiles();
};
function sendMessage() {
if (getDisabled()) {
function sendMessage(options: SendMessageOptions = {}) {
const prompt = String(options.prompt || senderValue.value || '').trim();
const regenerateRoundId = options.regenerateRoundId
? String(options.regenerateRoundId)
: '';
const isRegenerate = !!regenerateRoundId;
if (!props.conversationId || !prompt) {
return;
}
const data = {
conversationId: props.conversationId,
prompt: senderValue.value,
prompt,
botId: props.bot.id,
attachments: attachmentsRef.value?.getFileList(),
regenerateRoundId: regenerateRoundId || undefined,
};
clearSenderFiles();
btnLoading.value = true;
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.appendUserMessage(messages, {
content: senderValue.value,
created: Date.now(),
id: uuid(),
let userMessageId = '';
if (!isRegenerate) {
props.mutateMessages((messages) => {
const latestAssistant = [...messages]
.reverse()
.find((item) => item.role === 'assistant');
if (latestAssistant?.roundId) {
ChatTimeTimelineBuilder.setRoundSwitchable(
messages,
latestAssistant.roundId,
false,
);
}
});
});
senderValue.value = '';
}
if (!isRegenerate) {
userMessageId = uuid();
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.appendUserMessage(messages, {
content: prompt,
created: Date.now(),
id: userMessageId,
});
});
senderValue.value = '';
}
let receivedAssistantPayload = false;
sseClient.post('/userCenter/bot/chat', data, {
onMessage(res) {
@@ -52,6 +97,10 @@ function sendMessage() {
return;
}
const sseData = JSON.parse(res.data);
const streamMeta = normalizeSseRoundMeta(sseData?.meta);
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.bindLatestPendingUserMessage(messages, streamMeta);
});
const delta = sseData.payload?.delta;
if (res.event === 'done') {
@@ -64,6 +113,10 @@ function sendMessage() {
sseData?.domain === 'SYSTEM' &&
sseData.payload?.code === 'SYSTEM_ERROR'
) {
if (isRegenerate && !receivedAssistantPayload) {
btnLoading.value = false;
return;
}
const errorMessage = sseData.payload.message;
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.applySystemError(messages, errorMessage);
@@ -72,10 +125,12 @@ function sendMessage() {
}
if (sseData?.domain === 'TOOL') {
receivedAssistantPayload = true;
props.mutateMessages((messages) => {
if (sseData?.type === 'TOOL_CALL') {
ChatTimeTimelineBuilder.upsertToolCall(messages, {
created: Date.now(),
...streamMeta,
name: sseData?.payload?.name,
toolCallId: sseData?.payload?.tool_call_id,
value: sseData?.payload?.arguments,
@@ -84,6 +139,7 @@ function sendMessage() {
}
ChatTimeTimelineBuilder.upsertToolResult(messages, {
created: Date.now(),
...streamMeta,
name: sseData?.payload?.name,
result: sseData?.payload?.result,
toolCallId: sseData?.payload?.tool_call_id,
@@ -93,18 +149,38 @@ function sendMessage() {
}
if (sseData.type === 'THINKING') {
receivedAssistantPayload = true;
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.appendThinkingDelta(messages, delta, Date.now());
ChatTimeTimelineBuilder.appendThinkingDelta(
messages,
delta,
Date.now(),
streamMeta,
);
});
} else if (sseData.type === 'MESSAGE') {
receivedAssistantPayload = true;
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.appendMessageDelta(messages, delta, Date.now());
ChatTimeTimelineBuilder.appendMessageDelta(
messages,
delta,
Date.now(),
streamMeta,
);
});
}
},
onError(err) {
console.error(err);
btnLoading.value = false;
if (!isRegenerate && !receivedAssistantPayload && userMessageId) {
props.mutateMessages((messages) => {
const index = messages.findIndex((item) => item.id === userMessageId);
if (index >= 0) {
messages.splice(index, 1);
}
});
}
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.finalize(messages);
});
@@ -139,6 +215,28 @@ function triggerFileSelect() {
function handleDeleteAllSenderFiles() {
files.value = [];
}
function normalizeSseRoundMeta(meta: any): SseRoundMeta {
if (!meta || typeof meta !== 'object') {
return {};
}
const variantIndex = meta.variantIndex ? Number(meta.variantIndex) : undefined;
return {
roundId: meta.roundId ? String(meta.roundId) : undefined,
roundNo: meta.roundNo ? Number(meta.roundNo) : undefined,
selectedVariantIndex: meta.selectedVariantIndex
? Number(meta.selectedVariantIndex)
: variantIndex,
switchable:
typeof meta.switchable === 'boolean'
? meta.switchable
: variantIndex != null
? true
: undefined,
variantCount: meta.variantCount ? Number(meta.variantCount) : variantIndex,
variantIndex,
};
}
</script>
<template>
@@ -155,7 +253,7 @@ function handleDeleteAllSenderFiles() {
clearable
allow-speech
placeholder="发送消息"
@keyup.enter="sendMessage"
@keyup.enter="() => sendMessage()"
@paste-file="handlePasteFile"
>
<template #action-list>
@@ -172,7 +270,7 @@ function handleDeleteAllSenderFiles() {
type="primary"
:icon="Promotion"
:disabled="getDisabled()"
@click="sendMessage"
@click="() => sendMessage()"
round
/>
</div>

View File

@@ -1,10 +1,15 @@
<script setup lang="ts">
import type { ChatTimeTimelineItem } from '@easyflow/types';
import { onMounted, ref } from 'vue';
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ArrowLeft, Minus, Plus } from '@element-plus/icons-vue';
import {
ChatTimeHistoryMapper,
ChatTimeTimelineBuilder,
createChatVariantSwitchController,
} from '@easyflow/utils';
import {
ElAside,
ElAvatar,
@@ -15,6 +20,7 @@ import {
ElMessage,
ElSpace,
} from 'element-plus';
import { tryit } from 'radash';
import { api } from '#/api/request';
import defaultBotAvatar from '#/assets/defaultBotAvatar.png';
@@ -26,12 +32,18 @@ onMounted(async () => {
getUserUsed();
getBotDetail();
});
onBeforeUnmount(() => {
if (prefetchTimer) {
clearTimeout(prefetchTimer);
}
});
const router = useRouter();
const route = useRoute();
const usedList = ref<any[]>([]);
const botInfo = ref<any>({});
const btnLoading = ref(false);
const conversationId = ref('');
const senderRef = ref<InstanceType<typeof ChatSender>>();
function getUserUsed() {
api.get('/userCenter/botRecentlyUsed/list').then((res) => {
usedList.value = res.data.map((item: any) => item.botId);
@@ -82,10 +94,138 @@ function removeBotFromRecentlyUsed(botId: any) {
});
}
const messageList = ref<ChatTimeTimelineItem[]>([]);
const variantSwitchStateVersion = ref(0);
let prefetchTimer: ReturnType<typeof setTimeout> | undefined;
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);
},
});
function mutateMessages(mutator: (messages: ChatTimeTimelineItem[]) => void) {
const next = [...messageList.value];
mutator(next);
messageList.value = next;
schedulePrefetchVisibleVariants();
}
async function handleRegenerate(item: ChatTimeTimelineItem) {
if (!item.roundId) {
return;
}
const prompt = resolveRoundPrompt(String(item.roundId));
if (!prompt) {
return;
}
senderRef.value?.sendMessage({
prompt,
regenerateRoundId: String(item.roundId),
});
}
async function handleSelectVariant(
item: ChatTimeTimelineItem,
direction: 'next' | 'previous',
) {
if (!item.roundId || !conversationId.value) {
return;
}
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
const variantIndex = direction === 'previous' ? current - 1 : current + 1;
const sessionId = conversationId.value;
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,
});
}
function resolveRoundPrompt(roundId: string) {
const target = [...messageList.value].reverse().find(
(item) => item.role === 'user' && item.roundId === roundId,
);
return String(target?.content || '').trim();
}
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 schedulePrefetchVisibleVariants() {
if (prefetchTimer) {
clearTimeout(prefetchTimer);
}
prefetchTimer = setTimeout(() => {
prefetchVisibleVariants();
}, 120);
}
function prefetchVisibleVariants() {
if (!conversationId.value) {
return;
}
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(conversationId.value, item.roundId!),
roundId: item.roundId,
sessionId: conversationId.value,
});
}
}
function currentSwitchingRoundIds() {
void variantSwitchStateVersion.value;
if (!conversationId.value) {
return [];
}
return Array.from(
new Set(
messageList.value
.filter(
(item) =>
item.roundId &&
variantSwitchController.isSwitching(
conversationId.value,
item.roundId,
),
)
.map((item) => String(item.roundId)),
),
);
}
</script>
@@ -114,8 +254,19 @@ function mutateMessages(mutator: (messages: ChatTimeTimelineItem[]) => void) {
{{ botInfo.description }}
</CardDescription>
</Card>
<ChatBubbleList v-else :bot="botInfo" :messages="messageList" />
<ChatBubbleList
v-else
:bot="botInfo"
:messages="messageList"
allow-regenerate
allow-variant-switch
:switching-round-ids="currentSwitchingRoundIds()"
@regenerate="handleRegenerate"
@select-next-variant="handleSelectVariant($event, 'next')"
@select-previous-variant="handleSelectVariant($event, 'previous')"
/>
<ChatSender
ref="senderRef"
class="absolute bottom-5 left-0 w-full"
:bot="botInfo"
:conversation-id="conversationId"

View File

@@ -1,12 +1,18 @@
<script setup lang="ts">
import type { ChatTimeTimelineItem } from '@easyflow/types';
import { onMounted, ref } from 'vue';
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { IconifyIcon } from '@easyflow/icons';
import { cn } from '@easyflow/utils';
import {
ChatTimeHistoryMapper,
ChatTimeTimelineBuilder,
cn,
createChatVariantSwitchController,
} from '@easyflow/utils';
import { ElAside, ElContainer, ElMain } from 'element-plus';
import { ElAside, ElContainer, ElMain, ElMessage } from 'element-plus';
import { tryit } from 'radash';
import { api } from '#/api/request';
import defaultAssistantAvatar from '#/assets/defaultAssistantAvatar.svg';
@@ -22,6 +28,11 @@ import { ChatBubbleList, ChatContainer, ChatSender } from '#/components/chat';
onMounted(() => {
getAssistantList();
});
onBeforeUnmount(() => {
if (prefetchTimer) {
clearTimeout(prefetchTimer);
}
});
const recentUsedAssistant = ref<any[]>([]);
const currentBot = ref<any>({});
const handleSelectAssistant = (bot: any) => {
@@ -37,18 +48,154 @@ function getAssistantList() {
});
}
const messageList = ref<ChatTimeTimelineItem[]>([]);
const senderRef = ref<InstanceType<typeof ChatSender>>();
const variantSwitchStateVersion = ref(0);
let prefetchTimer: ReturnType<typeof setTimeout> | undefined;
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);
},
});
function setMessageList(messages: ChatTimeTimelineItem[]) {
messageList.value = messages;
schedulePrefetchVisibleVariants();
}
function mutateMessages(mutator: (messages: ChatTimeTimelineItem[]) => void) {
const next = [...messageList.value];
mutator(next);
messageList.value = next;
schedulePrefetchVisibleVariants();
}
const isFold = ref(false);
const toggleFold = () => {
isFold.value = !isFold.value;
};
async function handleRegenerate(item: ChatTimeTimelineItem) {
if (!item.roundId) {
return;
}
const prompt = resolveRoundPrompt(String(item.roundId));
if (!prompt) {
return;
}
senderRef.value?.sendMessage({
prompt,
regenerateRoundId: String(item.roundId),
});
}
async function handleSelectVariant(
item: ChatTimeTimelineItem,
direction: 'next' | 'previous',
) {
if (!item.roundId) {
return;
}
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
const variantIndex = direction === 'previous' ? current - 1 : current + 1;
const conversationId = resolveConversationId();
if (!conversationId || variantIndex <= 0) {
return;
}
await variantSwitchController.switchVariant({
fetchVariants: () => fetchRoundVariants(conversationId, item.roundId!),
items: messageList.value,
persistVariant: async () => {
const [, res] = await tryit(api.post)(
`/userCenter/chatHistory/sessions/${conversationId}/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: conversationId,
targetVariantIndex: variantIndex,
});
}
function resolveConversationId() {
return String(senderRef.value?.getConversationId?.() || '');
}
function resolveRoundPrompt(roundId: string) {
const target = [...messageList.value].reverse().find(
(item) => item.role === 'user' && item.roundId === roundId,
);
return String(target?.content || '').trim();
}
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 schedulePrefetchVisibleVariants() {
if (prefetchTimer) {
clearTimeout(prefetchTimer);
}
prefetchTimer = setTimeout(() => {
prefetchVisibleVariants();
}, 120);
}
function prefetchVisibleVariants() {
const conversationId = resolveConversationId();
if (!conversationId) {
return;
}
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(conversationId, item.roundId!),
roundId: item.roundId,
sessionId: conversationId,
});
}
}
function currentSwitchingRoundIds() {
void variantSwitchStateVersion.value;
const conversationId = resolveConversationId();
if (!conversationId) {
return [];
}
return Array.from(
new Set(
messageList.value
.filter(
(item) =>
item.roundId &&
variantSwitchController.isSwitching(conversationId, item.roundId),
)
.map((item) => String(item.roundId)),
),
);
}
</script>
<template>
@@ -66,9 +213,19 @@ const toggleFold = () => {
>
<template #default="{ conversationId }">
<div class="flex h-full flex-col justify-between">
<ChatBubbleList :bot="currentBot" :messages="messageList" />
<ChatBubbleList
:bot="currentBot"
:messages="messageList"
allow-regenerate
allow-variant-switch
:switching-round-ids="currentSwitchingRoundIds()"
@regenerate="handleRegenerate"
@select-next-variant="handleSelectVariant($event, 'next')"
@select-previous-variant="handleSelectVariant($event, 'previous')"
/>
<div class="mx-auto w-full max-w-[1000px]">
<ChatSender
ref="senderRef"
:bot="currentBot"
:conversation-id="conversationId"
:mutate-messages="mutateMessages"

View File

@@ -4,7 +4,11 @@ import type { ChatTimeTimelineItem } from '@easyflow/types';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ChatTimeHistoryMapper } from '@easyflow/utils';
import {
ChatTimeHistoryMapper,
ChatTimeTimelineBuilder,
createChatVariantSwitchController,
} from '@easyflow/utils';
import { Delete, Edit, Search } from '@element-plus/icons-vue';
import {
ElButton,
@@ -47,12 +51,23 @@ 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();
@@ -167,6 +182,7 @@ async function loadMessages(reset = false) {
} else {
messageList.value = [...normalized, ...messageList.value];
}
prefetchVisibleVariants();
loadedMessageRecordCount.value = reset
? (res.data?.records || []).length
: loadedMessageRecordCount.value + (res.data?.records || []).length;
@@ -178,6 +194,90 @@ 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;
@@ -355,6 +455,10 @@ function formatTime(value?: string) {
<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>