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

@@ -2,9 +2,10 @@
import type { ChatTimeTimelineItem } from '@easyflow/types';
import { Close } from '@element-plus/icons-vue';
import { ElButton, ElEmpty, ElIcon, ElScrollbar } from 'element-plus';
import { ElButton, ElEmpty, ElIcon, ElMessage, ElScrollbar } from 'element-plus';
import ChatTimeMessageContent from '#/components/chat/ChatTimeMessageContent.vue';
import ChatMessageActionBar from '#/components/chat-workspace/ChatMessageActionBar.vue';
interface ChatHistoryDetailDrawerProps {
visible?: boolean;
@@ -13,6 +14,7 @@ interface ChatHistoryDetailDrawerProps {
messages?: ChatTimeTimelineItem[];
hasMore?: boolean;
onLoadMore?: (() => Promise<void> | void) | undefined;
switchingRoundIds?: string[];
}
const props = withDefaults(defineProps<ChatHistoryDetailDrawerProps>(), {
@@ -22,10 +24,17 @@ const props = withDefaults(defineProps<ChatHistoryDetailDrawerProps>(), {
messages: () => [],
hasMore: false,
onLoadMore: undefined,
switchingRoundIds: () => [],
});
const emit = defineEmits<{
close: [];
selectVariant: [
payload: {
direction: 'next' | 'previous';
item: ChatTimeTimelineItem;
},
];
}>();
function formatTime(value?: number | string) {
@@ -57,6 +66,77 @@ function resolveSenderName(item: any) {
async function handleLoadMore() {
await props.onLoadMore?.();
}
function shouldShowVariantNavigator(item: ChatTimeTimelineItem) {
return (
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) ||
!item.switchable ||
isVariantSwitching(item)
) {
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;
}
function canCopyMessage(item: ChatTimeTimelineItem) {
return item.role !== 'tool' && Boolean(String(item.content || '').trim());
}
async function handleCopyMessage(item: ChatTimeTimelineItem) {
if (!canCopyMessage(item)) {
return;
}
try {
await navigator.clipboard.writeText(String(item.content || ''));
ElMessage.success('已复制');
} catch {
ElMessage.error('复制失败');
}
}
</script>
<template>
@@ -147,6 +227,26 @@ async function handleLoadMore() {
>
<ChatTimeMessageContent :item="item" readonly-thinking />
</div>
<ChatMessageActionBar
:align="item.role === 'user' ? 'end' : 'start'"
:allow-copy="canCopyMessage(item)"
:show-variant-navigator="shouldShowVariantNavigator(item)"
:disabled-variant-next="!canSwitchVariant(item, 'next')"
:disabled-variant-previous="!canSwitchVariant(item, 'previous')"
:variant-loading="isVariantSwitching(item)"
:variant-current="
Number(item.variantIndex || item.selectedVariantIndex || 1)
"
:variant-total="Number(item.variantCount || 1)"
@copy="handleCopyMessage(item)"
@select-next-variant="
emit('selectVariant', { direction: 'next', item })
"
@select-previous-variant="
emit('selectVariant', { direction: 'previous', item })
"
/>
</article>
</div>

View File

@@ -8,6 +8,7 @@ import type { TypewriterInstance } from 'vue-element-plus-x/types/Typewriter';
import type { BotInfo, ChatTimeTimelineItem } from '@easyflow/types';
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
@@ -22,6 +23,7 @@ import { useRoute, useRouter } from 'vue-router';
import { $t } from '@easyflow/locales';
import { useBotStore } from '@easyflow/stores';
import {
createChatVariantSwitchController,
ChatTimeHistoryMapper,
ChatTimeTimelineBuilder,
cn,
@@ -30,9 +32,7 @@ import {
import {
ArrowDownBold,
CopyDocument,
Paperclip,
RefreshRight,
} from '@element-plus/icons-vue';
import { ElButton, ElIcon, ElMessage, ElSpace } from 'element-plus';
import { tryit } from 'radash';
@@ -40,6 +40,7 @@ import { tryit } from 'radash';
import { getMessageList, getPerQuestions } from '#/api';
import { api, sseClient } from '#/api/request';
import ChatTimeMessageContent from '#/components/chat/ChatTimeMessageContent.vue';
import ChatMessageActionBar from '#/components/chat-workspace/ChatMessageActionBar.vue';
import SendEnableIcon from '#/components/icons/SendEnableIcon.vue';
import SendIcon from '#/components/icons/SendIcon.vue';
import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
@@ -62,6 +63,19 @@ interface presetQuestionsType {
key: string;
description: string;
}
interface SendMessageOptions {
prompt?: string;
regenerateRoundId?: string;
}
interface SseRoundMeta {
roundId?: string;
roundNo?: number;
selectedVariantIndex?: number;
switchable?: boolean;
variantCount?: number;
variantIndex?: number;
}
const route = useRoute();
const botId = ref<string>((route.params.id as string) || '');
const router = useRouter();
@@ -74,7 +88,20 @@ const showBackToBottomButton = ref(false);
const senderRef = ref<InstanceType<typeof ElSender>>();
const senderValue = ref('');
const sending = ref(false);
const variantSwitchStateVersion = ref(0);
const BACK_TO_BOTTOM_THRESHOLD = 160;
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 latestAssistantMessage = computed(() => {
return [...bubbleItems.value].reverse().find((item) => item.role === 'assistant');
});
const getConversationId = async () => {
const res = await api.get('/api/v1/bot/generateConversationId');
return res.data;
@@ -132,6 +159,7 @@ watchEffect(async () => {
bubbleItems.value = ChatTimeHistoryMapper.fromHistoryRecords(
res.data as any[],
);
prefetchVisibleVariants();
}
} else {
bubbleItems.value = [];
@@ -180,6 +208,7 @@ const bindBubbleListScroll = () => {
};
const finalizeTimelineTail = () => {
ChatTimeTimelineBuilder.finalize(bubbleItems.value);
prefetchVisibleVariants();
};
const stopSse = () => {
sseClient.abort();
@@ -190,14 +219,25 @@ const clearSenderFiles = () => {
files.value = [];
attachmentsRef.value?.clearFiles();
};
const handleSubmit = async (refreshContent: string) => {
const handleSubmit = async (refreshContent: string, options: SendMessageOptions = {}) => {
const attachments = attachmentsRef.value?.getFileList();
const currentPrompt = refreshContent || senderValue.value.trim();
const currentPrompt = (options.prompt || refreshContent || senderValue.value).trim();
if (!currentPrompt) {
return;
}
const regenerateRoundId = options.regenerateRoundId
? String(options.regenerateRoundId)
: '';
const isRegenerate = !!regenerateRoundId;
sending.value = true;
lastUserMessage.value = currentPrompt;
if (!isRegenerate && latestAssistantMessage.value?.roundId) {
ChatTimeTimelineBuilder.setRoundSwitchable(
bubbleItems.value,
latestAssistantMessage.value.roundId,
false,
);
}
messages.value.push({
role: 'user',
content: currentPrompt,
@@ -209,12 +249,16 @@ const handleSubmit = async (refreshContent: string) => {
conversationId: localeConversationId.value,
messages: copyMessages,
attachments,
regenerateRoundId: regenerateRoundId || undefined,
};
clearSenderFiles();
messages.value.pop();
const mockMessages = generateMockMessages(refreshContent);
bubbleItems.value.push(...mockMessages);
senderRef.value?.clear();
if (!isRegenerate) {
const mockMessages = generateMockMessages(refreshContent || currentPrompt);
bubbleItems.value.push(...mockMessages);
senderRef.value?.clear();
}
let receivedAssistantPayload = false;
sseClient.post('/api/v1/bot/chat', data, {
onMessage(message) {
const event = message.event;
@@ -230,22 +274,33 @@ const handleSubmit = async (refreshContent: string) => {
}
// 处理系统错误
const sseData = JSON.parse(message.data);
const streamMeta = normalizeSseRoundMeta(sseData?.meta);
ChatTimeTimelineBuilder.bindLatestPendingUserMessage(
bubbleItems.value,
streamMeta,
);
if (
sseData?.domain === 'SYSTEM' &&
sseData.payload?.code === 'SYSTEM_ERROR'
) {
ChatTimeTimelineBuilder.applySystemError(
bubbleItems.value,
sseData.payload.message,
Date.now(),
);
if (isRegenerate && !receivedAssistantPayload) {
ElMessage.error(sseData.payload.message || '重新生成失败');
} else {
ChatTimeTimelineBuilder.applySystemError(
bubbleItems.value,
sseData.payload.message,
Date.now(),
);
}
return;
}
if (sseData?.domain === 'TOOL') {
receivedAssistantPayload = true;
if (sseData?.type === 'TOOL_CALL') {
ChatTimeTimelineBuilder.upsertToolCall(bubbleItems.value, {
created: Date.now(),
...streamMeta,
name: sseData?.payload?.name,
toolCallId: sseData?.payload?.tool_call_id,
value: sseData?.payload?.arguments,
@@ -253,6 +308,7 @@ const handleSubmit = async (refreshContent: string) => {
} else {
ChatTimeTimelineBuilder.upsertToolResult(bubbleItems.value, {
created: Date.now(),
...streamMeta,
name: sseData?.payload?.name,
result: sseData?.payload?.result,
toolCallId: sseData?.payload?.tool_call_id,
@@ -267,16 +323,20 @@ const handleSubmit = async (refreshContent: string) => {
if (delta) {
if (sseData.type === 'THINKING') {
receivedAssistantPayload = true;
ChatTimeTimelineBuilder.appendThinkingDelta(
bubbleItems.value,
delta,
Date.now(),
streamMeta,
);
} else if (sseData.type === 'MESSAGE') {
receivedAssistantPayload = true;
ChatTimeTimelineBuilder.appendMessageDelta(
bubbleItems.value,
delta,
Date.now(),
streamMeta,
);
}
}
@@ -335,8 +395,138 @@ const handleCopy = (content: string) => {
};
const handleRefresh = () => {
handleSubmit(lastUserMessage.value);
const roundId = String(latestAssistantMessage.value?.roundId || '');
if (!roundId) {
return;
}
handleSubmit('', {
prompt: resolveRoundPrompt(roundId),
regenerateRoundId: roundId,
});
};
const canRegenerateMessage = (item: ChatTimeTimelineItem) => {
return (
item.role === 'assistant' &&
item.id === latestAssistantMessage.value?.id &&
!!String(item.roundId || '').trim()
);
};
const shouldShowVariantNavigator = (item: ChatTimeTimelineItem) => {
return (
item.role === 'assistant' &&
isFinalAssistantInRound(item) &&
Number(item.variantCount || 0) > 1 &&
!!item.roundId
);
};
const canSwitchVariant = (
item: ChatTimeTimelineItem,
direction: 'next' | 'previous',
) => {
if (
item.role !== 'assistant' ||
!isFinalAssistantInRound(item) ||
!item.switchable ||
isVariantSwitching(item)
) {
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;
};
const handleSelectVariant = async (
item: ChatTimeTimelineItem,
direction: 'next' | 'previous',
) => {
if (!item.roundId || !localeConversationId.value) {
return;
}
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
const variantIndex = direction === 'previous' ? current - 1 : current + 1;
await variantSwitchController.switchVariant({
fetchVariants: () =>
fetchRoundVariants(String(localeConversationId.value), String(item.roundId)),
items: bubbleItems.value,
persistVariant: async () => {
const [, res] = await tryit(api.post)(
`/api/v1/chatWorkspace/sessions/${localeConversationId.value}/rounds/${item.roundId}/selectVariant`,
{
variantIndex,
},
);
if (res?.errorCode !== 0 || !res?.data) {
throw new Error(res?.message || '答案版本切换失败');
}
return res.data;
},
roundId: item.roundId,
sessionId: localeConversationId.value,
targetVariantIndex: variantIndex,
});
};
async function fetchRoundVariants(sessionId: string, roundId: string) {
const [, res] = await tryit(api.get)(
`/api/v1/chatWorkspace/sessions/${sessionId}/rounds/${roundId}/variants`,
);
if (res?.errorCode !== 0) {
throw new Error(res?.message || '答案版本加载失败');
}
return res.data || [];
}
function isVariantSwitching(item: ChatTimeTimelineItem) {
variantSwitchStateVersion.value;
return variantSwitchController.isSwitching(localeConversationId.value, item.roundId);
}
function isFinalAssistantInRound(item: ChatTimeTimelineItem) {
if (item.role !== 'assistant') {
return false;
}
if (!item.roundId) {
return true;
}
for (let index = bubbleItems.value.length - 1; index >= 0; index -= 1) {
const candidate = bubbleItems.value[index];
if (
candidate?.role === 'assistant' &&
candidate.roundId === item.roundId &&
String(candidate.variantIndex || '') === String(item.variantIndex || '')
) {
return candidate.id === item.id;
}
}
return true;
}
function prefetchVisibleVariants() {
if (!localeConversationId.value) {
return;
}
for (const item of bubbleItems.value) {
if (
item.role === 'assistant' &&
item.roundId &&
Number(item.variantCount || 0) > 1
) {
variantSwitchController.prefetchVariants({
fetchVariants: () =>
fetchRoundVariants(String(localeConversationId.value), String(item.roundId)),
roundId: item.roundId,
sessionId: localeConversationId.value,
});
}
}
}
const scrollToBottom = () => {
bubbleListRef.value?.scrollToBottom();
if (!bubbleListRef.value && bubbleListScrollElement.value) {
@@ -356,6 +546,35 @@ function triggerFileSelect() {
function handleDeleteAllSenderFiles() {
files.value = [];
}
function resolveRoundPrompt(roundId: string) {
const target = [...bubbleItems.value].reverse().find(
(item) => item.role === 'user' && item.roundId === roundId,
);
return String(target?.content || lastUserMessage.value || '').trim();
}
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,
};
}
watch(
() => [localeConversationId.value, bubbleItems.value.length],
() => {
@@ -419,22 +638,25 @@ onBeforeUnmount(() => {
</template>
<!-- 自定义底部 -->
<template #footer="{ item }">
<ElSpace v-if="item.role !== 'tool'" :size="10">
<ElSpace v-if="item.role === 'assistant'">
<span @click="handleRefresh()" style="cursor: pointer">
<ElIcon>
<RefreshRight />
</ElIcon>
</span>
</ElSpace>
<ElSpace>
<span @click="handleCopy(item.content)" style="cursor: pointer">
<ElIcon>
<CopyDocument />
</ElIcon>
</span>
</ElSpace>
</ElSpace>
<ChatMessageActionBar
v-if="item.role !== 'tool'"
:align="item.role === 'user' ? 'end' : 'start'"
:allow-copy="!!String(item.content || '').trim()"
:allow-regenerate="canRegenerateMessage(item)"
:disabled-variant-next="!canSwitchVariant(item, 'next')"
:disabled-variant-previous="!canSwitchVariant(item, 'previous')"
:regenerate-disabled="sending"
:show-variant-navigator="shouldShowVariantNavigator(item)"
:variant-loading="isVariantSwitching(item)"
:variant-current="
Number(item.variantIndex || item.selectedVariantIndex || 1)
"
:variant-total="Number(item.variantCount || 1)"
@copy="handleCopy(item.content)"
@regenerate="handleRefresh"
@select-next-variant="handleSelectVariant(item, 'next')"
@select-previous-variant="handleSelectVariant(item, 'previous')"
/>
</template>
</ElBubbleList>
<button