feat: 支持聊天多版本答案切换
- 为管理端、公共聊天和用户中心补充回答变体查询与切换能力 - 支持基于指定轮次重新生成并同步前后端多版本状态 - 保留 application.yml 与本地截图文件为未提交状态
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,11 @@ import type { ChatTimeTimelineItem } from '@easyflow/types';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { useEasyFlowDrawer } from '@easyflow/common-ui';
|
||||
import { ChatTimeHistoryMapper } from '@easyflow/utils';
|
||||
import {
|
||||
createChatVariantSwitchController,
|
||||
ChatTimeHistoryMapper,
|
||||
ChatTimeTimelineBuilder,
|
||||
} from '@easyflow/utils';
|
||||
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
import {
|
||||
@@ -53,6 +57,15 @@ const messagePage = ref({
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const variantSwitchStateVersion = ref(0);
|
||||
const variantSwitchController = createChatVariantSwitchController<any, ChatTimeTimelineItem>({
|
||||
mapRecords: (records) => ChatTimeHistoryMapper.fromHistoryRecords(records),
|
||||
onStateChange: () => {
|
||||
variantSwitchStateVersion.value += 1;
|
||||
},
|
||||
replaceRound: (items, roundId, nextItems) =>
|
||||
ChatTimeTimelineBuilder.replaceRoundMessages(items, roundId, nextItems),
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useEasyFlowDrawer({
|
||||
appendToMain: false,
|
||||
@@ -164,12 +177,88 @@ async function loadMessages(reset = false) {
|
||||
: loadedMessageRecordCount.value + (res.data?.records || []).length;
|
||||
messagePage.value.total = res.data?.total || 0;
|
||||
messagePage.value.pageNumber = nextPageNumber;
|
||||
prefetchVisibleVariants();
|
||||
}
|
||||
|
||||
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;
|
||||
await variantSwitchController.switchVariant({
|
||||
fetchVariants: () =>
|
||||
fetchRoundVariants(String(currentSession.value.id), String(item.roundId)),
|
||||
items: messageList.value,
|
||||
persistVariant: async () => {
|
||||
const [, res] = await tryit(api.post)(
|
||||
`/api/v1/chatHistory/sessions/${currentSession.value.id}/rounds/${item.roundId}/selectVariant`,
|
||||
{
|
||||
variantIndex,
|
||||
},
|
||||
);
|
||||
if (res?.errorCode !== 0 || !res?.data) {
|
||||
throw new Error(res?.message || '答案版本切换失败');
|
||||
}
|
||||
return res.data;
|
||||
},
|
||||
roundId: item.roundId,
|
||||
sessionId: currentSession.value.id,
|
||||
targetVariantIndex: variantIndex,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchRoundVariants(sessionId: string, roundId: string) {
|
||||
const [, res] = await tryit(api.get)(
|
||||
`/api/v1/chatHistory/sessions/${sessionId}/rounds/${roundId}/variants`,
|
||||
);
|
||||
if (res?.errorCode !== 0) {
|
||||
throw new Error(res?.message || '答案版本加载失败');
|
||||
}
|
||||
return res.data || [];
|
||||
}
|
||||
|
||||
function prefetchVisibleVariants() {
|
||||
const sessionId = String(currentSession.value?.id || '');
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
for (const item of messageList.value) {
|
||||
if (
|
||||
item.role === 'assistant' &&
|
||||
item.roundId &&
|
||||
Number(item.variantCount || 0) > 1
|
||||
) {
|
||||
variantSwitchController.prefetchVariants({
|
||||
fetchVariants: () => fetchRoundVariants(sessionId, String(item.roundId)),
|
||||
roundId: item.roundId,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function currentSwitchingRoundIds() {
|
||||
variantSwitchStateVersion.value;
|
||||
const sessionId = String(currentSession.value?.id || '');
|
||||
if (!sessionId) {
|
||||
return [];
|
||||
}
|
||||
return messageList.value
|
||||
.filter(
|
||||
(item) =>
|
||||
item.roundId && variantSwitchController.isSwitching(sessionId, item.roundId),
|
||||
)
|
||||
.map((item) => String(item.roundId));
|
||||
}
|
||||
|
||||
function changePage(pageNumber: number) {
|
||||
query.value.pageNumber = pageNumber;
|
||||
fetchSessions();
|
||||
@@ -421,6 +510,8 @@ function closeDetail() {
|
||||
:messages="messageList"
|
||||
:has-more="hasMoreMessages"
|
||||
:on-load-more="() => loadMessages(false)"
|
||||
:switching-round-ids="currentSwitchingRoundIds()"
|
||||
@select-variant="handleSelectVariant($event.item, $event.direction)"
|
||||
@close="closeDetail"
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
@@ -20,14 +20,15 @@ import { LOGIN_PATH } from '@easyflow/constants';
|
||||
import { IconifyIcon } from '@easyflow/icons';
|
||||
import { $t } from '@easyflow/locales';
|
||||
import { useAccessStore } from '@easyflow/stores';
|
||||
import { uuid } from '@easyflow/utils';
|
||||
import { createChatVariantSwitchController, uuid } from '@easyflow/utils';
|
||||
|
||||
import { useTitle } from '@vueuse/core';
|
||||
import { ElAvatar, ElButton, ElInput, ElSkeleton } from 'element-plus';
|
||||
import { ElAvatar, ElButton, ElInput, ElMessage, ElSkeleton } from 'element-plus';
|
||||
|
||||
import { refreshTokenApi } from '#/api/core';
|
||||
import { baseRequestClient, sseClient } from '#/api/request';
|
||||
import defaultBotAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
|
||||
import ChatMessageActionBar from '#/components/chat-workspace/ChatMessageActionBar.vue';
|
||||
|
||||
type MessageRole = 'assistant' | 'user';
|
||||
type ToolStatus = 'TOOL_CALL' | 'TOOL_RESULT';
|
||||
@@ -71,8 +72,26 @@ interface BubbleMessage {
|
||||
content: string;
|
||||
createdAt: number;
|
||||
loading?: boolean;
|
||||
messageKind?: string;
|
||||
roundId?: string;
|
||||
roundNo?: number;
|
||||
selectedVariantIndex?: number;
|
||||
switchable?: boolean;
|
||||
toolCalls?: ToolTraceItem[];
|
||||
segments?: AssistantSegment[];
|
||||
variantCount?: number;
|
||||
variantIndex?: number;
|
||||
}
|
||||
|
||||
interface SseRoundMeta {
|
||||
regenerate?: boolean;
|
||||
regenerateRoundId?: string;
|
||||
roundId?: string;
|
||||
roundNo?: number;
|
||||
selectedVariantIndex?: number;
|
||||
switchable?: boolean;
|
||||
variantCount?: number;
|
||||
variantIndex?: number;
|
||||
}
|
||||
|
||||
interface PublicChatApiResponse<T> {
|
||||
@@ -100,6 +119,13 @@ interface PublicChatMessageRecord {
|
||||
contentText?: string;
|
||||
contentPayload?: null | Record<string, any>;
|
||||
created?: number | string;
|
||||
messageKind?: string;
|
||||
roundId?: number | string;
|
||||
roundNo?: number;
|
||||
selectedVariantIndex?: number;
|
||||
switchable?: boolean;
|
||||
variantCount?: number;
|
||||
variantIndex?: number;
|
||||
}
|
||||
|
||||
interface PublicChatSessionRestoreResult {
|
||||
@@ -142,6 +168,34 @@ const dockHeight = ref(170);
|
||||
const collapsedToolIds = ref<Set<string>>(new Set());
|
||||
const requestAccessToken = ref('');
|
||||
const authenticatedAccess = ref(false);
|
||||
const variantSwitchStateVersion = ref(0);
|
||||
const variantSwitchController = createChatVariantSwitchController<
|
||||
PublicChatMessageRecord,
|
||||
BubbleMessage
|
||||
>({
|
||||
mapRecords: (records) => records.map((record) => buildBubbleMessageFromRecord(record)),
|
||||
onError: () => ElMessage.error('答案版本切换失败'),
|
||||
onStateChange: () => {
|
||||
variantSwitchStateVersion.value += 1;
|
||||
},
|
||||
replaceRound(items, roundId, nextItems) {
|
||||
const assistantMessage = nextItems.find((item) => item.role === 'assistant');
|
||||
if (!assistantMessage) {
|
||||
return;
|
||||
}
|
||||
const targetIndex = items.findIndex(
|
||||
(item) => item.role === 'assistant' && item.roundId === roundId,
|
||||
);
|
||||
if (targetIndex >= 0) {
|
||||
items.splice(targetIndex, 1, assistantMessage);
|
||||
} else {
|
||||
items.push(assistantMessage);
|
||||
}
|
||||
const nextCollapsed = new Set(collapsedToolIds.value);
|
||||
assistantMessage.toolCalls?.forEach((item) => nextCollapsed.add(item.id));
|
||||
collapsedToolIds.value = nextCollapsed;
|
||||
},
|
||||
});
|
||||
const botId = computed(() => String(route.params.botId || ''));
|
||||
const isEmbedMode = computed(() => String(route.query.embed || '') === '1');
|
||||
const presetQuestions = computed(() =>
|
||||
@@ -305,6 +359,83 @@ const normalizeTimestamp = (value: any) => {
|
||||
return Number.isNaN(parsed) ? Date.now() : parsed;
|
||||
};
|
||||
|
||||
const normalizeRoundId = (value: any) => {
|
||||
const normalized = String(value ?? '').trim();
|
||||
return normalized || undefined;
|
||||
};
|
||||
|
||||
const normalizePositiveInteger = (value: any) => {
|
||||
if (value == null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
};
|
||||
|
||||
const applyRoundMeta = (
|
||||
target: Partial<BubbleMessage>,
|
||||
source?: null | Partial<PublicChatMessageRecord> | SseRoundMeta,
|
||||
) => {
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
const roundId = normalizeRoundId(source.roundId);
|
||||
if (roundId) {
|
||||
target.roundId = roundId;
|
||||
}
|
||||
const roundNo = normalizePositiveInteger(source.roundNo);
|
||||
if (roundNo) {
|
||||
target.roundNo = roundNo;
|
||||
}
|
||||
const variantIndex = normalizePositiveInteger(source.variantIndex);
|
||||
if (variantIndex) {
|
||||
target.variantIndex = variantIndex;
|
||||
}
|
||||
const variantCount = normalizePositiveInteger(source.variantCount);
|
||||
if (variantCount) {
|
||||
target.variantCount = variantCount;
|
||||
}
|
||||
const selectedVariantIndex = normalizePositiveInteger(
|
||||
source.selectedVariantIndex,
|
||||
);
|
||||
if (selectedVariantIndex) {
|
||||
target.selectedVariantIndex = selectedVariantIndex;
|
||||
}
|
||||
if (typeof source.switchable === 'boolean') {
|
||||
target.switchable = source.switchable;
|
||||
}
|
||||
if ('messageKind' in source) {
|
||||
const messageKind = String(source.messageKind ?? '').trim();
|
||||
if (messageKind) {
|
||||
target.messageKind = messageKind;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const bindLatestPendingUserMessage = (meta?: SseRoundMeta) => {
|
||||
const roundId = normalizeRoundId(meta?.roundId);
|
||||
if (!roundId) {
|
||||
return;
|
||||
}
|
||||
for (let index = messages.value.length - 1; index >= 0; index -= 1) {
|
||||
const item = messages.value[index];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (item.role !== 'user') {
|
||||
continue;
|
||||
}
|
||||
if (item.roundId) {
|
||||
return;
|
||||
}
|
||||
applyRoundMeta(item, {
|
||||
roundId,
|
||||
roundNo: meta?.roundNo,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const restoreToolTraceFromChain = (chain: Record<string, any>) => {
|
||||
const toolId = String(chain.id || '').trim() || uuid();
|
||||
const status =
|
||||
@@ -394,34 +525,47 @@ const buildAssistantMessageFromRecord = (
|
||||
};
|
||||
};
|
||||
|
||||
const buildBubbleMessageFromRecord = (record: PublicChatMessageRecord) => {
|
||||
const role = String(record.senderRole || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (role === 'assistant') {
|
||||
const assistantMessage = buildAssistantMessageFromRecord(record);
|
||||
applyRoundMeta(assistantMessage, record);
|
||||
return assistantMessage;
|
||||
}
|
||||
const userMessage: BubbleMessage = {
|
||||
id: String(record.id || uuid()),
|
||||
role: 'user',
|
||||
content: String(record.contentText || ''),
|
||||
createdAt: normalizeTimestamp(record.created),
|
||||
loading: false,
|
||||
};
|
||||
applyRoundMeta(userMessage, record);
|
||||
return userMessage;
|
||||
};
|
||||
|
||||
const restoreMessagesFromRecords = (records: PublicChatMessageRecord[]) => {
|
||||
const restoredMessages: BubbleMessage[] = [];
|
||||
const nextCollapsedToolIds = new Set<string>();
|
||||
|
||||
for (const record of records) {
|
||||
const role = String(record.senderRole || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (role === 'assistant') {
|
||||
const assistantMessage = buildAssistantMessageFromRecord(record);
|
||||
const message = buildBubbleMessageFromRecord(record);
|
||||
if (message.role === 'assistant') {
|
||||
const assistantMessage = message;
|
||||
assistantMessage.toolCalls?.forEach((item) =>
|
||||
nextCollapsedToolIds.add(item.id),
|
||||
);
|
||||
restoredMessages.push(assistantMessage);
|
||||
continue;
|
||||
}
|
||||
restoredMessages.push({
|
||||
id: String(record.id || uuid()),
|
||||
role: 'user',
|
||||
content: String(record.contentText || ''),
|
||||
createdAt: normalizeTimestamp(record.created),
|
||||
loading: false,
|
||||
});
|
||||
restoredMessages.push(message);
|
||||
}
|
||||
|
||||
messages.value = restoredMessages;
|
||||
collapsedToolIds.value = nextCollapsedToolIds;
|
||||
shouldAutoScroll.value = true;
|
||||
prefetchVisibleVariants();
|
||||
};
|
||||
|
||||
const readUrlApiKey = () => {
|
||||
@@ -659,6 +803,47 @@ const appendMessage = (role: MessageRole, content = '', loading = false) => {
|
||||
scrollToBottom(true);
|
||||
};
|
||||
|
||||
const findMessageIndexByRoundId = (
|
||||
role: MessageRole,
|
||||
roundId?: string,
|
||||
) => {
|
||||
if (!roundId) {
|
||||
return -1;
|
||||
}
|
||||
for (let index = messages.value.length - 1; index >= 0; index -= 1) {
|
||||
const item = messages.value[index];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (item.role === role && item.roundId === roundId) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const resetAssistantMessageForRegenerate = (roundId: string) => {
|
||||
const targetIndex = findMessageIndexByRoundId('assistant', roundId);
|
||||
if (targetIndex < 0) {
|
||||
appendMessage('assistant', '', true);
|
||||
return;
|
||||
}
|
||||
const current = messages.value[targetIndex];
|
||||
if (!current) {
|
||||
appendMessage('assistant', '', true);
|
||||
return;
|
||||
}
|
||||
messages.value[targetIndex] = {
|
||||
...current,
|
||||
content: '',
|
||||
createdAt: Date.now(),
|
||||
loading: true,
|
||||
segments: [],
|
||||
toolCalls: [],
|
||||
};
|
||||
scrollToBottom(true);
|
||||
};
|
||||
|
||||
const updateLastAssistantMessage = (patch: Partial<BubbleMessage>) => {
|
||||
const lastIndex = messages.value.length - 1;
|
||||
if (lastIndex < 0) return;
|
||||
@@ -707,7 +892,7 @@ const getLastAssistantMessage = () => {
|
||||
return last;
|
||||
};
|
||||
|
||||
const appendAssistantTextDelta = (delta: string) => {
|
||||
const appendAssistantTextDelta = (delta: string, meta?: SseRoundMeta) => {
|
||||
if (!delta) return;
|
||||
const last = getLastAssistantMessage();
|
||||
if (!last) return;
|
||||
@@ -729,10 +914,11 @@ const appendAssistantTextDelta = (delta: string) => {
|
||||
content: `${last.content}${delta}`,
|
||||
segments,
|
||||
loading: true,
|
||||
...meta,
|
||||
});
|
||||
};
|
||||
|
||||
const appendAssistantThinkingDelta = (delta: string) => {
|
||||
const appendAssistantThinkingDelta = (delta: string, meta?: SseRoundMeta) => {
|
||||
if (!delta) return;
|
||||
const last = getLastAssistantMessage();
|
||||
if (!last) return;
|
||||
@@ -763,6 +949,7 @@ const appendAssistantThinkingDelta = (delta: string) => {
|
||||
updateLastAssistantMessage({
|
||||
segments,
|
||||
loading: true,
|
||||
...meta,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -868,6 +1055,12 @@ const upsertToolTrace = (patch: Partial<ToolTraceItem> & { id?: string }) => {
|
||||
toolCalls: nextToolCalls,
|
||||
segments,
|
||||
loading: true,
|
||||
roundId: last.roundId,
|
||||
roundNo: last.roundNo,
|
||||
selectedVariantIndex: last.selectedVariantIndex,
|
||||
switchable: last.switchable,
|
||||
variantCount: last.variantCount,
|
||||
variantIndex: last.variantIndex,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -910,6 +1103,7 @@ const extractToolMetaFromPayload = (payload: any) => {
|
||||
|
||||
const handleToolSseEvent = (sseData: any, normalizedType?: string) => {
|
||||
const payload = sseData?.payload || {};
|
||||
const streamMeta = normalizeSseRoundMeta(sseData?.meta);
|
||||
const inferred = extractToolMetaFromPayload(payload);
|
||||
const normalized = normalizedType || normalizeEventKey(sseData?.type);
|
||||
let status = normalized as ToolStatus;
|
||||
@@ -932,6 +1126,9 @@ const handleToolSseEvent = (sseData: any, normalizedType?: string) => {
|
||||
arguments: status === 'TOOL_CALL' ? inferred.arguments : undefined,
|
||||
result: status === 'TOOL_RESULT' ? inferred.result : undefined,
|
||||
});
|
||||
if (Object.keys(streamMeta).length > 0) {
|
||||
updateLastAssistantMessage(streamMeta);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNeedSaveToolMessage = (role: string, content: string) => {
|
||||
@@ -981,6 +1178,8 @@ const onSseMessage = (event: ServerSentEventMessage) => {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const streamMeta = normalizeSseRoundMeta(sseData?.meta);
|
||||
bindLatestPendingUserMessage(streamMeta);
|
||||
const eventDomain = normalizeEventKey(sseData?.domain);
|
||||
const eventType = normalizeEventKey(sseData?.type);
|
||||
if (eventDomain === 'SYSTEM' && sseData?.payload?.code === 'SYSTEM_ERROR') {
|
||||
@@ -989,6 +1188,7 @@ const onSseMessage = (event: ServerSentEventMessage) => {
|
||||
updateLastAssistantMessage({
|
||||
content: sseData.payload?.message || $t('bot.publicChatInitError'),
|
||||
loading: false,
|
||||
...streamMeta,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1027,7 +1227,7 @@ const onSseMessage = (event: ServerSentEventMessage) => {
|
||||
typeof deltaRaw === 'string'
|
||||
? deltaRaw
|
||||
: normalizeToolPayloadValue(deltaRaw);
|
||||
appendAssistantTextDelta(delta);
|
||||
appendAssistantTextDelta(delta, streamMeta);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1037,24 +1237,116 @@ const onSseMessage = (event: ServerSentEventMessage) => {
|
||||
typeof deltaRaw === 'string'
|
||||
? deltaRaw
|
||||
: normalizeToolPayloadValue(deltaRaw);
|
||||
appendAssistantThinkingDelta(delta);
|
||||
appendAssistantThinkingDelta(delta, streamMeta);
|
||||
return;
|
||||
}
|
||||
|
||||
handleToolSseEvent(sseData, eventType);
|
||||
};
|
||||
|
||||
const doSendMessage = () => {
|
||||
const prompt = senderValue.value.trim();
|
||||
const normalizeSseRoundMeta = (meta: any): SseRoundMeta => {
|
||||
if (!meta || typeof meta !== 'object') {
|
||||
return {};
|
||||
}
|
||||
const variantIndex = normalizePositiveInteger(meta.variantIndex);
|
||||
return {
|
||||
regenerate: Boolean(meta.regenerate),
|
||||
regenerateRoundId: meta.regenerateRoundId
|
||||
? String(meta.regenerateRoundId)
|
||||
: undefined,
|
||||
roundId: normalizeRoundId(meta.roundId),
|
||||
roundNo: normalizePositiveInteger(meta.roundNo),
|
||||
selectedVariantIndex:
|
||||
normalizePositiveInteger(meta.selectedVariantIndex) || variantIndex,
|
||||
switchable:
|
||||
typeof meta.switchable === 'boolean'
|
||||
? meta.switchable
|
||||
: variantIndex != null
|
||||
? true
|
||||
: undefined,
|
||||
variantCount: normalizePositiveInteger(meta.variantCount) || variantIndex,
|
||||
variantIndex,
|
||||
};
|
||||
};
|
||||
|
||||
const getLatestAssistantMessage = () => {
|
||||
for (let index = messages.value.length - 1; index >= 0; index -= 1) {
|
||||
const item = messages.value[index];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (item.role === 'assistant') {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveRoundUserPrompt = (roundId?: string) => {
|
||||
if (!roundId) {
|
||||
return '';
|
||||
}
|
||||
const matched = messages.value.find(
|
||||
(item) => item.role === 'user' && item.roundId === roundId,
|
||||
);
|
||||
return matched ? String(matched.content || '').trim() : '';
|
||||
};
|
||||
|
||||
const refreshConversationMessages = async () => {
|
||||
if (!conversationId.value) {
|
||||
return false;
|
||||
}
|
||||
const restoreResp = await getResponseBody<PublicChatSessionRestoreResult>(
|
||||
baseRequestClient.get('/api/v1/public-chat/session/restore', {
|
||||
params: {
|
||||
botId: botId.value,
|
||||
conversationId: conversationId.value,
|
||||
},
|
||||
headers: buildRequestHeaders(),
|
||||
}),
|
||||
);
|
||||
const restoreData = restoreResp.data;
|
||||
if (
|
||||
restoreResp.errorCode !== 0 ||
|
||||
!restoreData?.sessionExists ||
|
||||
!restoreData.conversationId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
conversationId.value = String(restoreData.conversationId);
|
||||
restoreMessagesFromRecords(restoreData.messages || []);
|
||||
upsertPublicChatContext({
|
||||
accessToken: requestAccessToken.value,
|
||||
authenticatedAccess: authenticatedAccess.value,
|
||||
conversationId: conversationId.value,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const doSendMessage = (options?: {
|
||||
prompt?: string;
|
||||
regenerateRoundId?: string;
|
||||
}) => {
|
||||
const prompt = String(options?.prompt ?? senderValue.value).trim();
|
||||
const regenerateRoundId = options?.regenerateRoundId
|
||||
? String(options.regenerateRoundId)
|
||||
: '';
|
||||
const isRegenerate = !!regenerateRoundId;
|
||||
if (!prompt || sending.value || blocked.value || !conversationId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
appendMessage('user', prompt);
|
||||
appendMessage('assistant', '', true);
|
||||
if (!isRegenerate) {
|
||||
appendMessage('user', prompt);
|
||||
appendMessage('assistant', '', true);
|
||||
} else {
|
||||
resetAssistantMessageForRegenerate(regenerateRoundId);
|
||||
}
|
||||
shouldAutoScroll.value = true;
|
||||
userStoppedStreaming.value = false;
|
||||
senderValue.value = '';
|
||||
if (!options?.prompt) {
|
||||
senderValue.value = '';
|
||||
}
|
||||
sending.value = true;
|
||||
|
||||
sseClient.post(
|
||||
@@ -1063,16 +1355,18 @@ const doSendMessage = () => {
|
||||
botId: botId.value,
|
||||
prompt,
|
||||
conversationId: conversationId.value,
|
||||
regenerateRoundId: regenerateRoundId || undefined,
|
||||
},
|
||||
{
|
||||
headers: buildRequestHeaders(),
|
||||
onMessage: onSseMessage,
|
||||
onFinished: () => {
|
||||
onFinished: async () => {
|
||||
sending.value = false;
|
||||
stopAssistantThinking();
|
||||
updateLastAssistantMessage({ loading: false });
|
||||
await refreshConversationMessages();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
onError: async (error: any) => {
|
||||
sending.value = false;
|
||||
stopAssistantThinking();
|
||||
const abortLike =
|
||||
@@ -1089,12 +1383,14 @@ const doSendMessage = () => {
|
||||
} else {
|
||||
updateLastAssistantMessage({ loading: false });
|
||||
}
|
||||
await refreshConversationMessages();
|
||||
return;
|
||||
}
|
||||
updateLastAssistantMessage({
|
||||
loading: false,
|
||||
content: $t('bot.publicChatInitError'),
|
||||
});
|
||||
await refreshConversationMessages();
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1159,10 +1455,10 @@ const createConversationId = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const restoreConversationMessages = async () => {
|
||||
const restoreConversationMessages = async (conversationIdValue?: string) => {
|
||||
const storedContext = readPublicChatContext(botId.value);
|
||||
const storedConversationId = String(
|
||||
storedContext?.conversationId || '',
|
||||
conversationIdValue || storedContext?.conversationId || '',
|
||||
).trim();
|
||||
if (!storedConversationId) {
|
||||
return false;
|
||||
@@ -1299,6 +1595,164 @@ onBeforeUnmount(() => {
|
||||
dockResizeObserver?.disconnect();
|
||||
dockResizeObserver = null;
|
||||
});
|
||||
|
||||
const canCopyMessage = (item: BubbleMessage) =>
|
||||
Boolean(String(item.content || '').trim());
|
||||
|
||||
const shouldShowVariantNavigator = (item: BubbleMessage) =>
|
||||
item.role === 'assistant' &&
|
||||
isFinalAssistantInRound(item) &&
|
||||
Number(item.variantCount || 0) > 1 &&
|
||||
Boolean(item.roundId);
|
||||
|
||||
const canSwitchVariant = (
|
||||
item: BubbleMessage,
|
||||
direction: 'next' | 'previous',
|
||||
) => {
|
||||
if (
|
||||
item.role !== 'assistant' ||
|
||||
!isFinalAssistantInRound(item) ||
|
||||
sending.value ||
|
||||
!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 isFinalAssistantInRound = (item: BubbleMessage) => {
|
||||
if (item.role !== 'assistant') {
|
||||
return false;
|
||||
}
|
||||
if (!item.roundId) {
|
||||
return true;
|
||||
}
|
||||
for (let index = messages.value.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = messages.value[index];
|
||||
if (
|
||||
candidate?.role === 'assistant' &&
|
||||
candidate.roundId === item.roundId &&
|
||||
String(candidate.variantIndex || '') === String(item.variantIndex || '')
|
||||
) {
|
||||
return candidate.id === item.id;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCopyMessage = async (item: BubbleMessage) => {
|
||||
if (!canCopyMessage(item)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(item.content || ''));
|
||||
ElMessage.success($t('bot.publicChatCopySuccess'));
|
||||
} catch {
|
||||
ElMessage.error($t('bot.publicChatCopyFail'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerateMessage = async (item: BubbleMessage) => {
|
||||
if (sending.value || item.role !== 'assistant') {
|
||||
return;
|
||||
}
|
||||
const roundId = String(item.roundId || '');
|
||||
const prompt = resolveRoundUserPrompt(roundId);
|
||||
if (!roundId || !prompt) {
|
||||
ElMessage.warning('当前回答暂不支持重新生成');
|
||||
return;
|
||||
}
|
||||
doSendMessage({
|
||||
prompt,
|
||||
regenerateRoundId: roundId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectVariant = async (
|
||||
item: BubbleMessage,
|
||||
direction: 'next' | 'previous',
|
||||
) => {
|
||||
if (!conversationId.value || !item.roundId) {
|
||||
return;
|
||||
}
|
||||
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
|
||||
const variantIndex = direction === 'previous' ? current - 1 : current + 1;
|
||||
await variantSwitchController.switchVariant({
|
||||
fetchVariants: () => fetchRoundVariants(conversationId.value, String(item.roundId)),
|
||||
items: messages.value,
|
||||
persistVariant: async () => {
|
||||
const res = await getResponseBody<PublicChatMessageRecord>(
|
||||
baseRequestClient.post(
|
||||
`/api/v1/public-chat/session/${conversationId.value}/rounds/${item.roundId}/selectVariant`,
|
||||
{
|
||||
variantIndex,
|
||||
},
|
||||
{
|
||||
params: {
|
||||
botId: botId.value,
|
||||
},
|
||||
headers: buildRequestHeaders(),
|
||||
},
|
||||
),
|
||||
);
|
||||
if (res.errorCode !== 0 || !res.data) {
|
||||
throw new Error(res.message || '答案版本切换失败');
|
||||
}
|
||||
return res.data;
|
||||
},
|
||||
roundId: item.roundId,
|
||||
sessionId: conversationId.value,
|
||||
targetVariantIndex: variantIndex,
|
||||
});
|
||||
};
|
||||
|
||||
async function fetchRoundVariants(sessionId: string, roundId: string) {
|
||||
const res = await getResponseBody<PublicChatMessageRecord[]>(
|
||||
baseRequestClient.get(
|
||||
`/api/v1/public-chat/session/${sessionId}/rounds/${roundId}/variants`,
|
||||
{
|
||||
params: {
|
||||
botId: botId.value,
|
||||
},
|
||||
headers: buildRequestHeaders(),
|
||||
},
|
||||
),
|
||||
);
|
||||
if (res.errorCode !== 0) {
|
||||
throw new Error(res.message || '答案版本加载失败');
|
||||
}
|
||||
return res.data || [];
|
||||
}
|
||||
|
||||
function isVariantSwitching(item: BubbleMessage) {
|
||||
variantSwitchStateVersion.value;
|
||||
return variantSwitchController.isSwitching(conversationId.value, item.roundId);
|
||||
}
|
||||
|
||||
function prefetchVisibleVariants() {
|
||||
if (!conversationId.value) {
|
||||
return;
|
||||
}
|
||||
for (const item of messages.value) {
|
||||
if (
|
||||
item.role === 'assistant' &&
|
||||
item.roundId &&
|
||||
Number(item.variantCount || 0) > 1
|
||||
) {
|
||||
variantSwitchController.prefetchVariants({
|
||||
fetchVariants: () => fetchRoundVariants(conversationId.value, String(item.roundId)),
|
||||
roundId: item.roundId,
|
||||
sessionId: conversationId.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1497,6 +1951,33 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<ChatMessageActionBar
|
||||
:align="item.role === 'user' ? 'end' : 'start'"
|
||||
:allow-copy="canCopyMessage(item)"
|
||||
:allow-regenerate="
|
||||
item.role === 'assistant' &&
|
||||
getLatestAssistantMessage()?.id === item.id &&
|
||||
Boolean(item.roundId)
|
||||
"
|
||||
:regenerate-disabled="sending"
|
||||
:show-variant-navigator="shouldShowVariantNavigator(item)"
|
||||
:variant-loading="isVariantSwitching(item)"
|
||||
:disabled-variant-next="!canSwitchVariant(item, 'next')"
|
||||
:disabled-variant-previous="
|
||||
!canSwitchVariant(item, 'previous')
|
||||
"
|
||||
:variant-current="
|
||||
Number(item.variantIndex || item.selectedVariantIndex || 1)
|
||||
"
|
||||
:variant-total="Number(item.variantCount || 1)"
|
||||
@copy="handleCopyMessage(item)"
|
||||
@regenerate="handleRegenerateMessage(item)"
|
||||
@select-next-variant="handleSelectVariant(item, 'next')"
|
||||
@select-previous-variant="
|
||||
handleSelectVariant(item, 'previous')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1555,7 +2036,7 @@ onBeforeUnmount(() => {
|
||||
:disabled="
|
||||
blocked || initializing || !!initError || !senderValue.trim()
|
||||
"
|
||||
@click="doSendMessage"
|
||||
@click="() => doSendMessage()"
|
||||
>
|
||||
<template #icon>
|
||||
<IconifyIcon icon="solar:plain-linear" />
|
||||
|
||||
Reference in New Issue
Block a user