feat: 支持聊天多版本答案切换
- 为管理端、公共聊天和用户中心补充回答变体查询与切换能力 - 支持基于指定轮次重新生成并同步前后端多版本状态 - 保留 application.yml 与本地截图文件为未提交状态
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user