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>