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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,14 +2,31 @@ type ChatTimeTimelineRole = 'assistant' | 'tool' | 'user';
|
||||
type ChatTimeToolStatus = 'TOOL_CALL' | 'TOOL_RESULT';
|
||||
type ChatTimeThinkingStatus = 'end' | 'thinking';
|
||||
|
||||
interface ChatTimeRoundMeta {
|
||||
messageKind?: string;
|
||||
roundId?: number | string;
|
||||
roundNo?: number;
|
||||
selectedVariantIndex?: number;
|
||||
switchable?: boolean;
|
||||
variantCount?: number;
|
||||
variantIndex?: number;
|
||||
}
|
||||
|
||||
interface ChatTimeTimelineItemBase {
|
||||
created: number | string;
|
||||
id: string;
|
||||
loading?: boolean;
|
||||
messageKind?: string;
|
||||
placement: 'end' | 'start';
|
||||
roundId?: string;
|
||||
roundNo?: number;
|
||||
role: ChatTimeTimelineRole;
|
||||
selectedVariantIndex?: number;
|
||||
senderName?: string;
|
||||
switchable?: boolean;
|
||||
typing?: boolean;
|
||||
variantCount?: number;
|
||||
variantIndex?: number;
|
||||
}
|
||||
|
||||
interface ChatTimeAssistantThinkingSegment {
|
||||
@@ -66,14 +83,22 @@ interface ChatTimeHistoryRecord {
|
||||
loading?: boolean;
|
||||
placement?: 'end' | 'start';
|
||||
role?: string;
|
||||
roundId?: number | string;
|
||||
roundNo?: number;
|
||||
selectedVariantIndex?: number;
|
||||
senderName?: string;
|
||||
senderRole?: string;
|
||||
switchable?: boolean;
|
||||
typing?: boolean;
|
||||
variantCount?: number;
|
||||
variantIndex?: number;
|
||||
messageKind?: string;
|
||||
}
|
||||
|
||||
interface ChatTimeToolMutationPayload {
|
||||
interface ChatTimeToolMutationPayload extends ChatTimeRoundMeta {
|
||||
created?: number | string;
|
||||
name?: string;
|
||||
regenerate?: boolean;
|
||||
result?: any;
|
||||
toolCallId?: string;
|
||||
value?: any;
|
||||
@@ -85,6 +110,7 @@ export type {
|
||||
ChatTimeAssistantTextSegment,
|
||||
ChatTimeAssistantThinkingSegment,
|
||||
ChatTimeHistoryRecord,
|
||||
ChatTimeRoundMeta,
|
||||
ChatTimeThinkingStatus,
|
||||
ChatTimeTimelineItem,
|
||||
ChatTimeTimelineItemBase,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {
|
||||
ChatTimeAssistantItem,
|
||||
ChatTimeHistoryRecord,
|
||||
ChatTimeRoundMeta,
|
||||
ChatTimeThinkingStatus,
|
||||
ChatTimeTimelineItem,
|
||||
ChatTimeTimelineItemBase,
|
||||
ChatTimeToolItem,
|
||||
ChatTimeToolMutationPayload,
|
||||
ChatTimeToolStatus,
|
||||
@@ -28,17 +30,71 @@ class ChatTimeTimelineBuilder {
|
||||
content?: string;
|
||||
created?: number | string;
|
||||
id?: string;
|
||||
messageKind?: string;
|
||||
roundId?: number | string;
|
||||
roundNo?: number;
|
||||
senderName?: string;
|
||||
},
|
||||
) {
|
||||
items.push({
|
||||
const item: ChatTimeTimelineItem = {
|
||||
content: normalizePlainText(payload.content),
|
||||
created: normalizeTimestamp(payload.created),
|
||||
id: payload.id || uuid(),
|
||||
placement: 'end',
|
||||
role: 'user',
|
||||
senderName: payload.senderName,
|
||||
});
|
||||
};
|
||||
applyRoundMeta(item, payload);
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将最新一条待绑定的用户消息补齐到当前轮次。
|
||||
*/
|
||||
static bindLatestPendingUserMessage(
|
||||
items: ChatTimeTimelineItem[],
|
||||
meta?: ChatTimeRoundMeta,
|
||||
) {
|
||||
const roundId = normalizeRoundId(meta?.roundId);
|
||||
if (!roundId) {
|
||||
return;
|
||||
}
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (item.role !== 'user') {
|
||||
continue;
|
||||
}
|
||||
if (item.roundId) {
|
||||
return;
|
||||
}
|
||||
applyRoundMeta(item, {
|
||||
roundId,
|
||||
roundNo: meta?.roundNo,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新指定轮次的可切换状态。
|
||||
*/
|
||||
static setRoundSwitchable(
|
||||
items: ChatTimeTimelineItem[],
|
||||
roundId: number | string | undefined,
|
||||
switchable: boolean,
|
||||
) {
|
||||
const normalizedRoundId = normalizeRoundId(roundId);
|
||||
if (!normalizedRoundId) {
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
if (item.roundId === normalizedRoundId && item.role !== 'user') {
|
||||
item.switchable = switchable;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,12 +104,14 @@ class ChatTimeTimelineBuilder {
|
||||
items: ChatTimeTimelineItem[],
|
||||
delta?: string,
|
||||
created?: number | string,
|
||||
meta?: ChatTimeRoundMeta,
|
||||
) {
|
||||
const normalizedDelta = normalizePlainText(delta);
|
||||
if (!normalizedDelta) {
|
||||
return;
|
||||
}
|
||||
const assistant = ensureAssistantTail(items, created);
|
||||
prepareRoundVariant(items, meta);
|
||||
const assistant = ensureAssistantTail(items, created, meta);
|
||||
const tail = assistant.segments[assistant.segments.length - 1];
|
||||
if (tail?.type === 'thinking' && tail.status === 'thinking') {
|
||||
tail.content += normalizedDelta;
|
||||
@@ -77,12 +135,14 @@ class ChatTimeTimelineBuilder {
|
||||
items: ChatTimeTimelineItem[],
|
||||
delta?: string,
|
||||
created?: number | string,
|
||||
meta?: ChatTimeRoundMeta,
|
||||
) {
|
||||
const normalizedDelta = normalizeAssistantText(delta);
|
||||
if (!normalizedDelta) {
|
||||
return;
|
||||
}
|
||||
const assistant = ensureAssistantTail(items, created);
|
||||
prepareRoundVariant(items, meta);
|
||||
const assistant = ensureAssistantTail(items, created, meta);
|
||||
stopThinkingForAssistant(assistant);
|
||||
const tail = assistant.segments[assistant.segments.length - 1];
|
||||
if (tail?.type === 'text') {
|
||||
@@ -117,12 +177,14 @@ class ChatTimeTimelineBuilder {
|
||||
items: ChatTimeTimelineItem[],
|
||||
payload: ChatTimeToolMutationPayload,
|
||||
) {
|
||||
prepareRoundVariant(items, payload);
|
||||
this.stopThinking(items);
|
||||
const toolItem = ensureToolItem(
|
||||
items,
|
||||
payload.toolCallId,
|
||||
payload.created,
|
||||
payload.name,
|
||||
payload,
|
||||
);
|
||||
toolItem.arguments = normalizePayloadValue(payload.value);
|
||||
toolItem.content = '';
|
||||
@@ -136,11 +198,13 @@ class ChatTimeTimelineBuilder {
|
||||
items: ChatTimeTimelineItem[],
|
||||
payload: ChatTimeToolMutationPayload,
|
||||
) {
|
||||
prepareRoundVariant(items, payload);
|
||||
const toolItem = ensureToolItem(
|
||||
items,
|
||||
payload.toolCallId,
|
||||
payload.created,
|
||||
payload.name,
|
||||
payload,
|
||||
);
|
||||
toolItem.result = normalizePayloadValue(payload.result);
|
||||
toolItem.content = toolItem.result;
|
||||
@@ -178,7 +242,7 @@ class ChatTimeTimelineBuilder {
|
||||
* 结束当前轮的 assistant 状态。
|
||||
*/
|
||||
static finalize(items: ChatTimeTimelineItem[]) {
|
||||
const last = items[items.length - 1];
|
||||
const last = findLastAssistant(items);
|
||||
if (!isAssistantItem(last)) {
|
||||
return;
|
||||
}
|
||||
@@ -186,6 +250,26 @@ class ChatTimeTimelineBuilder {
|
||||
last.loading = false;
|
||||
last.typing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按轮次替换当前主线可见的 assistant/tool 片段。
|
||||
*/
|
||||
static replaceRoundMessages(
|
||||
items: ChatTimeTimelineItem[],
|
||||
roundId: number | string | undefined,
|
||||
nextMessages: ChatTimeTimelineItem[],
|
||||
) {
|
||||
const normalizedRoundId = normalizeRoundId(roundId);
|
||||
if (!normalizedRoundId) {
|
||||
return;
|
||||
}
|
||||
const range = resolveRoundReplaceRange(items, normalizedRoundId);
|
||||
if (range) {
|
||||
items.splice(range.start, range.deleteCount, ...nextMessages);
|
||||
return;
|
||||
}
|
||||
items.splice(resolveRoundInsertIndex(items, normalizedRoundId), 0, ...nextMessages);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,7 +280,9 @@ class ChatTimeHistoryMapper {
|
||||
* 从聊天历史记录恢复时间线。
|
||||
*/
|
||||
static fromHistoryRecords(records: ChatTimeHistoryRecord[]) {
|
||||
return records.flatMap((record) => this.fromHistoryRecord(record));
|
||||
return normalizeVisibleHistoryRecords(records).flatMap((record) =>
|
||||
this.fromHistoryRecord(record),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,8 +335,15 @@ class ChatTimeHistoryMapper {
|
||||
const assistant = createAssistantItem(record.created, {
|
||||
id: record.id == null ? undefined : String(record.id),
|
||||
loading: record.loading,
|
||||
messageKind: record.messageKind,
|
||||
roundId: normalizeRoundId(record.roundId),
|
||||
roundNo: record.roundNo,
|
||||
selectedVariantIndex: record.selectedVariantIndex,
|
||||
senderName: record.senderName,
|
||||
switchable: record.switchable,
|
||||
typing: record.typing,
|
||||
variantCount: record.variantCount,
|
||||
variantIndex: record.variantIndex,
|
||||
});
|
||||
const tools: ChatTimeTimelineItem[] = [];
|
||||
|
||||
@@ -267,7 +360,7 @@ class ChatTimeHistoryMapper {
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolItem = createToolItemFromChain(rawChain, record.created);
|
||||
const toolItem = createToolItemFromChain(rawChain, record.created, record);
|
||||
if (toolItem) {
|
||||
tools.push(toolItem);
|
||||
}
|
||||
@@ -316,6 +409,7 @@ class ChatTimeHistoryMapper {
|
||||
rawMessage,
|
||||
toolMetaMap,
|
||||
record.created,
|
||||
record,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -325,11 +419,84 @@ class ChatTimeHistoryMapper {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVisibleHistoryRecords(records: ChatTimeHistoryRecord[]) {
|
||||
const dedupedRecords = dedupeHistoryRecords(records);
|
||||
const userSelectedVariantByRound = new Map<string, number>();
|
||||
const assistantSelectedVariantByRound = new Map<string, number>();
|
||||
const fallbackVariantByRound = new Map<string, number>();
|
||||
for (const record of dedupedRecords) {
|
||||
const roundId = normalizeRoundId(record.roundId);
|
||||
if (!roundId) {
|
||||
continue;
|
||||
}
|
||||
const selectedVariantIndex = normalizePositiveInteger(
|
||||
record.selectedVariantIndex,
|
||||
);
|
||||
if (selectedVariantIndex) {
|
||||
if (isUserHistoryRecord(record)) {
|
||||
userSelectedVariantByRound.set(roundId, selectedVariantIndex);
|
||||
} else {
|
||||
assistantSelectedVariantByRound.set(roundId, selectedVariantIndex);
|
||||
}
|
||||
}
|
||||
const variantIndex = normalizePositiveInteger(record.variantIndex);
|
||||
if (!isUserHistoryRecord(record) && variantIndex) {
|
||||
fallbackVariantByRound.set(roundId, variantIndex);
|
||||
}
|
||||
}
|
||||
return dedupedRecords.filter((record) => {
|
||||
const roundId = normalizeRoundId(record.roundId);
|
||||
if (!roundId || isUserHistoryRecord(record)) {
|
||||
return true;
|
||||
}
|
||||
const variantIndex = normalizePositiveInteger(record.variantIndex);
|
||||
if (!variantIndex) {
|
||||
return true;
|
||||
}
|
||||
const selectedVariantIndex =
|
||||
userSelectedVariantByRound.get(roundId) ||
|
||||
assistantSelectedVariantByRound.get(roundId) ||
|
||||
fallbackVariantByRound.get(roundId);
|
||||
return !selectedVariantIndex || variantIndex === selectedVariantIndex;
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeHistoryRecords(records: ChatTimeHistoryRecord[]) {
|
||||
const seen = new Set<string>();
|
||||
const result: ChatTimeHistoryRecord[] = [];
|
||||
for (const record of records) {
|
||||
const key = resolveHistoryRecordKey(record);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(record);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveHistoryRecordKey(record: ChatTimeHistoryRecord) {
|
||||
if (record.id != null) {
|
||||
return `id:${String(record.id)}`;
|
||||
}
|
||||
return [
|
||||
'fallback',
|
||||
normalizeRoundId(record.roundId) || '',
|
||||
normalizeRole(record.senderRole || record.role),
|
||||
normalizePositiveInteger(record.variantIndex) || '',
|
||||
normalizePlainText(record.contentText || record.content),
|
||||
].join(':');
|
||||
}
|
||||
|
||||
function isUserHistoryRecord(record: ChatTimeHistoryRecord) {
|
||||
return normalizeRole(record.senderRole || record.role) === 'user';
|
||||
}
|
||||
|
||||
function createAssistantItem(
|
||||
created?: number | string,
|
||||
patch?: Partial<ChatTimeAssistantItem>,
|
||||
patch?: Omit<Partial<ChatTimeAssistantItem>, 'roundId'> & ChatTimeRoundMeta,
|
||||
): ChatTimeAssistantItem {
|
||||
return {
|
||||
const item: ChatTimeAssistantItem = {
|
||||
content: patch?.content || '',
|
||||
created: normalizeTimestamp(created),
|
||||
id: patch?.id || uuid(),
|
||||
@@ -340,6 +507,8 @@ function createAssistantItem(
|
||||
senderName: patch?.senderName,
|
||||
typing: patch?.typing,
|
||||
};
|
||||
applyRoundMeta(item, patch);
|
||||
return item;
|
||||
}
|
||||
|
||||
function createAssistantItemFromStructuredMessage(
|
||||
@@ -360,8 +529,15 @@ function createAssistantItemFromStructuredMessage(
|
||||
? undefined
|
||||
: `${String(record.id)}-assistant-${assistantIndex}`,
|
||||
loading: false,
|
||||
messageKind: record.messageKind,
|
||||
roundId: normalizeRoundId(record.roundId),
|
||||
roundNo: record.roundNo,
|
||||
selectedVariantIndex: record.selectedVariantIndex,
|
||||
senderName: record.senderName,
|
||||
switchable: record.switchable,
|
||||
typing: false,
|
||||
variantCount: record.variantCount,
|
||||
variantIndex: record.variantIndex,
|
||||
});
|
||||
if (reasoning) {
|
||||
assistant.segments.push({
|
||||
@@ -381,6 +557,7 @@ function createAssistantItemFromStructuredMessage(
|
||||
function createToolItemFromChain(
|
||||
rawChain: Record<string, any>,
|
||||
created?: number | string,
|
||||
record?: ChatTimeHistoryRecord,
|
||||
) {
|
||||
const toolCallId = normalizePlainText(rawChain.id);
|
||||
const name = normalizePlainText(rawChain.name);
|
||||
@@ -393,10 +570,17 @@ function createToolItemFromChain(
|
||||
arguments: status === 'TOOL_CALL' ? argumentsValue : undefined,
|
||||
created,
|
||||
id: toolCallId || uuid(),
|
||||
messageKind: record?.messageKind,
|
||||
name,
|
||||
roundId: record?.roundId,
|
||||
roundNo: record?.roundNo,
|
||||
result: status === 'TOOL_RESULT' ? argumentsValue : undefined,
|
||||
selectedVariantIndex: record?.selectedVariantIndex,
|
||||
status,
|
||||
switchable: record?.switchable,
|
||||
toolCallId,
|
||||
variantCount: record?.variantCount,
|
||||
variantIndex: record?.variantIndex,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -404,6 +588,7 @@ function createToolItemFromStructuredMessage(
|
||||
rawMessage: Record<string, any>,
|
||||
toolMetaMap: Map<string, ChatTimeToolMeta>,
|
||||
created?: number | string,
|
||||
record?: ChatTimeHistoryRecord,
|
||||
) {
|
||||
const toolCallId = normalizePlainText(
|
||||
rawMessage.toolCallId ?? rawMessage.tool_call_id,
|
||||
@@ -414,10 +599,17 @@ function createToolItemFromStructuredMessage(
|
||||
arguments: toolMeta?.arguments,
|
||||
created,
|
||||
id: toolCallId || uuid(),
|
||||
messageKind: record?.messageKind,
|
||||
name: toolMeta?.name,
|
||||
roundId: record?.roundId,
|
||||
roundNo: record?.roundNo,
|
||||
result,
|
||||
selectedVariantIndex: record?.selectedVariantIndex,
|
||||
status: 'TOOL_RESULT',
|
||||
switchable: record?.switchable,
|
||||
toolCallId,
|
||||
variantCount: record?.variantCount,
|
||||
variantIndex: record?.variantIndex,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -429,12 +621,19 @@ function createToolItemFromTopLevelRecord(record: ChatTimeHistoryRecord) {
|
||||
return createToolItem({
|
||||
created: record.created,
|
||||
id: record.id == null ? toolCallId || uuid() : String(record.id),
|
||||
messageKind: record.messageKind,
|
||||
name: normalizePlainText(payload.name),
|
||||
roundId: record.roundId,
|
||||
roundNo: record.roundNo,
|
||||
result: normalizePayloadValue(
|
||||
payload.content ?? payload.result ?? record.contentText ?? record.content,
|
||||
),
|
||||
selectedVariantIndex: record.selectedVariantIndex,
|
||||
status: 'TOOL_RESULT',
|
||||
switchable: record.switchable,
|
||||
toolCallId,
|
||||
variantCount: record.variantCount,
|
||||
variantIndex: record.variantIndex,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -442,12 +641,19 @@ function createToolItem(payload: {
|
||||
arguments?: string;
|
||||
created?: number | string;
|
||||
id?: string;
|
||||
messageKind?: string;
|
||||
name?: string;
|
||||
roundId?: number | string;
|
||||
roundNo?: number;
|
||||
result?: string;
|
||||
selectedVariantIndex?: number;
|
||||
status: ChatTimeToolStatus;
|
||||
switchable?: boolean;
|
||||
toolCallId?: string;
|
||||
variantCount?: number;
|
||||
variantIndex?: number;
|
||||
}): ChatTimeToolItem {
|
||||
return {
|
||||
const item: ChatTimeToolItem = {
|
||||
arguments: payload.arguments,
|
||||
content: payload.result || '',
|
||||
created: normalizeTimestamp(payload.created),
|
||||
@@ -459,10 +665,12 @@ function createToolItem(payload: {
|
||||
status: payload.status,
|
||||
toolCallId: payload.toolCallId || payload.id || uuid(),
|
||||
};
|
||||
applyRoundMeta(item, payload);
|
||||
return item;
|
||||
}
|
||||
|
||||
function createUserItem(record: ChatTimeHistoryRecord): ChatTimeTimelineItem {
|
||||
return {
|
||||
const item: ChatTimeTimelineItem = {
|
||||
content: normalizePlainText(record.contentText || record.content),
|
||||
created: normalizeTimestamp(record.created),
|
||||
id: record.id == null ? uuid() : String(record.id),
|
||||
@@ -472,6 +680,8 @@ function createUserItem(record: ChatTimeHistoryRecord): ChatTimeTimelineItem {
|
||||
senderName: record.senderName,
|
||||
typing: record.typing,
|
||||
};
|
||||
applyRoundMeta(item, record);
|
||||
return item;
|
||||
}
|
||||
|
||||
function appendAssistantText(item: ChatTimeAssistantItem, content: string) {
|
||||
@@ -507,14 +717,17 @@ function collectToolMeta(
|
||||
function ensureAssistantTail(
|
||||
items: ChatTimeTimelineItem[],
|
||||
created?: number | string,
|
||||
meta?: ChatTimeRoundMeta,
|
||||
) {
|
||||
const last = items[items.length - 1];
|
||||
if (isAssistantItem(last)) {
|
||||
if (isAssistantItem(last) && isSameRoundVariant(last, meta)) {
|
||||
applyRoundMeta(last, meta);
|
||||
return last;
|
||||
}
|
||||
const assistant = createAssistantItem(created, {
|
||||
loading: true,
|
||||
typing: true,
|
||||
...normalizeRoundMeta(meta),
|
||||
});
|
||||
items.push(assistant);
|
||||
return assistant;
|
||||
@@ -525,38 +738,67 @@ function ensureToolItem(
|
||||
toolCallId?: string,
|
||||
created?: number | string,
|
||||
name?: string,
|
||||
meta?: ChatTimeRoundMeta,
|
||||
) {
|
||||
const normalizedToolCallId = normalizePlainText(toolCallId);
|
||||
const found = findToolItem(items, normalizedToolCallId);
|
||||
const found = findToolItem(items, normalizedToolCallId, meta);
|
||||
if (found) {
|
||||
if (name) {
|
||||
found.name = name;
|
||||
}
|
||||
applyRoundMeta(found, meta);
|
||||
return found;
|
||||
}
|
||||
const toolItem = createToolItem({
|
||||
created,
|
||||
id: normalizedToolCallId || uuid(),
|
||||
messageKind: meta?.messageKind,
|
||||
name,
|
||||
roundId: meta?.roundId,
|
||||
roundNo: meta?.roundNo,
|
||||
selectedVariantIndex: meta?.selectedVariantIndex,
|
||||
status: 'TOOL_CALL',
|
||||
switchable: meta?.switchable,
|
||||
toolCallId: normalizedToolCallId,
|
||||
variantCount: meta?.variantCount,
|
||||
variantIndex: meta?.variantIndex,
|
||||
});
|
||||
items.push(toolItem);
|
||||
return toolItem;
|
||||
}
|
||||
|
||||
function findToolItem(items: ChatTimeTimelineItem[], toolCallId?: string) {
|
||||
function findToolItem(
|
||||
items: ChatTimeTimelineItem[],
|
||||
toolCallId?: string,
|
||||
meta?: ChatTimeRoundMeta,
|
||||
) {
|
||||
const normalizedRoundId = normalizeRoundId(meta?.roundId);
|
||||
const normalizedVariantIndex = normalizePositiveInteger(meta?.variantIndex);
|
||||
if (toolCallId) {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (isToolItem(item) && item.toolCallId === toolCallId) {
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isToolItem(item) &&
|
||||
item.toolCallId === toolCallId &&
|
||||
matchesRoundVariant(item, normalizedRoundId, normalizedVariantIndex)
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (isToolItem(item) && item.status === 'TOOL_CALL') {
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isToolItem(item) &&
|
||||
item.status === 'TOOL_CALL' &&
|
||||
matchesRoundVariant(item, normalizedRoundId, normalizedVariantIndex)
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -584,6 +826,182 @@ function isToolItem(item?: ChatTimeTimelineItem): item is ChatTimeToolItem {
|
||||
return item?.role === 'tool';
|
||||
}
|
||||
|
||||
function findLastAssistant(items: ChatTimeTimelineItem[]) {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (isAssistantItem(item)) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function prepareRoundVariant(
|
||||
items: ChatTimeTimelineItem[],
|
||||
meta?: ChatTimeRoundMeta,
|
||||
) {
|
||||
const normalizedRoundId = normalizeRoundId(meta?.roundId);
|
||||
const normalizedVariantIndex = normalizePositiveInteger(meta?.variantIndex);
|
||||
if (!normalizedRoundId || !normalizedVariantIndex) {
|
||||
return;
|
||||
}
|
||||
const assistant = items.find(
|
||||
(item) => item.role === 'assistant' && item.roundId === normalizedRoundId,
|
||||
);
|
||||
if (assistant?.variantIndex === normalizedVariantIndex) {
|
||||
return;
|
||||
}
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (item.roundId === normalizedRoundId && item.role !== 'user') {
|
||||
items.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRoundInsertIndex(
|
||||
items: ChatTimeTimelineItem[],
|
||||
roundId: string,
|
||||
) {
|
||||
const firstRoundItemIndex = items.findIndex(
|
||||
(item) => item.roundId === roundId && item.role !== 'user',
|
||||
);
|
||||
if (firstRoundItemIndex >= 0) {
|
||||
return firstRoundItemIndex;
|
||||
}
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (item.roundId === roundId && item.role === 'user') {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
return items.length;
|
||||
}
|
||||
|
||||
function resolveRoundReplaceRange(
|
||||
items: ChatTimeTimelineItem[],
|
||||
roundId: string,
|
||||
) {
|
||||
let start = -1;
|
||||
let end = -1;
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index];
|
||||
if (item?.roundId === roundId && item.role !== 'user') {
|
||||
if (start < 0) {
|
||||
start = index;
|
||||
}
|
||||
end = index;
|
||||
} else if (start >= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (start < 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
deleteCount: end - start + 1,
|
||||
start,
|
||||
};
|
||||
}
|
||||
|
||||
function matchesRoundVariant(
|
||||
item: ChatTimeTimelineItem,
|
||||
roundId?: string,
|
||||
variantIndex?: number,
|
||||
) {
|
||||
if (roundId && item.roundId !== roundId) {
|
||||
return false;
|
||||
}
|
||||
if (variantIndex && item.variantIndex && item.variantIndex !== variantIndex) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSameRoundVariant(
|
||||
item: ChatTimeTimelineItem,
|
||||
meta?: ChatTimeRoundMeta,
|
||||
) {
|
||||
const normalizedRoundId = normalizeRoundId(meta?.roundId);
|
||||
const normalizedVariantIndex = normalizePositiveInteger(meta?.variantIndex);
|
||||
if (!normalizedRoundId || !normalizedVariantIndex) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
item.roundId === normalizedRoundId &&
|
||||
normalizePositiveInteger(item.variantIndex) === normalizedVariantIndex
|
||||
);
|
||||
}
|
||||
|
||||
function applyRoundMeta(
|
||||
target: Partial<ChatTimeTimelineItemBase>,
|
||||
source?: ChatTimeRoundMeta | null,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
const messageKind = normalizePlainText(source.messageKind).trim();
|
||||
if (messageKind) {
|
||||
target.messageKind = messageKind;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRoundMeta(meta?: ChatTimeRoundMeta): ChatTimeRoundMeta {
|
||||
return {
|
||||
messageKind: meta?.messageKind,
|
||||
roundId: normalizeRoundId(meta?.roundId),
|
||||
roundNo: meta?.roundNo,
|
||||
selectedVariantIndex: meta?.selectedVariantIndex,
|
||||
switchable: meta?.switchable,
|
||||
variantCount: meta?.variantCount,
|
||||
variantIndex: meta?.variantIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRoundId(value: any) {
|
||||
const normalized = normalizePlainText(value).trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: any) {
|
||||
if (value == null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function normalizeAssistantText(value: any) {
|
||||
return normalizePlainText(value)
|
||||
.replace(/^Final Answer:\s*/i, '')
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
type VariantRecord = {
|
||||
selectedVariantIndex?: number | string;
|
||||
variantIndex?: number | string;
|
||||
};
|
||||
|
||||
interface ChatVariantSwitchControllerOptions<TRecord extends VariantRecord, TItem> {
|
||||
mapRecords: (records: TRecord[]) => TItem[];
|
||||
onError?: (error: unknown) => void;
|
||||
onStateChange?: () => void;
|
||||
replaceRound: (items: TItem[], roundId: string, nextItems: TItem[]) => void;
|
||||
}
|
||||
|
||||
interface EnsureVariantsOptions<TRecord extends VariantRecord> {
|
||||
fetchVariants: () => Promise<TRecord[]>;
|
||||
roundId: number | string;
|
||||
sessionId: number | string;
|
||||
}
|
||||
|
||||
interface SwitchVariantOptions<TRecord extends VariantRecord, TItem>
|
||||
extends EnsureVariantsOptions<TRecord> {
|
||||
items: TItem[];
|
||||
onLocalSwitch?: (record: TRecord) => void;
|
||||
persistVariant: () => Promise<TRecord | void>;
|
||||
targetVariantIndex: number;
|
||||
}
|
||||
|
||||
function variantCacheKey(sessionId: number | string, roundId: number | string) {
|
||||
return `${String(sessionId)}:${String(roundId)}`;
|
||||
}
|
||||
|
||||
function normalizeVariantIndex(value: unknown) {
|
||||
const parsed = Number.parseInt(String(value || ''), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function markVariantSelected<TRecord extends VariantRecord>(
|
||||
record: TRecord,
|
||||
selectedVariantIndex: number,
|
||||
): TRecord {
|
||||
return {
|
||||
...record,
|
||||
selectedVariantIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function syncCachedSelection<TRecord extends VariantRecord>(
|
||||
records: TRecord[],
|
||||
selectedVariantIndex: number,
|
||||
selectedRecord?: TRecord,
|
||||
) {
|
||||
return records.map((record) => {
|
||||
const isSelected =
|
||||
selectedRecord &&
|
||||
normalizeVariantIndex(record.variantIndex) ===
|
||||
normalizeVariantIndex(selectedRecord.variantIndex);
|
||||
return markVariantSelected(
|
||||
isSelected ? { ...record, ...selectedRecord } : record,
|
||||
selectedVariantIndex,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function createChatVariantSwitchController<
|
||||
TRecord extends VariantRecord,
|
||||
TItem,
|
||||
>(options: ChatVariantSwitchControllerOptions<TRecord, TItem>) {
|
||||
const cache = new Map<string, TRecord[]>();
|
||||
const fetchTasks = new Map<string, Promise<TRecord[]>>();
|
||||
const switchingKeys = new Set<string>();
|
||||
|
||||
function notifyStateChange() {
|
||||
options.onStateChange?.();
|
||||
}
|
||||
|
||||
async function ensureVariants(params: EnsureVariantsOptions<TRecord>) {
|
||||
const key = variantCacheKey(params.sessionId, params.roundId);
|
||||
const cached = cache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const existingTask = fetchTasks.get(key);
|
||||
if (existingTask) {
|
||||
return existingTask;
|
||||
}
|
||||
const task = params
|
||||
.fetchVariants()
|
||||
.then((records) => {
|
||||
cache.set(key, records);
|
||||
return records;
|
||||
})
|
||||
.finally(() => {
|
||||
fetchTasks.delete(key);
|
||||
});
|
||||
fetchTasks.set(key, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
function prefetchVariants(params: EnsureVariantsOptions<TRecord>) {
|
||||
void ensureVariants(params).catch(() => {
|
||||
// 预取失败不打断当前页面,用户点击时仍会再次拉取。
|
||||
});
|
||||
}
|
||||
|
||||
function hasCachedVariant(
|
||||
sessionId: number | string,
|
||||
roundId: number | string,
|
||||
variantIndex: number,
|
||||
) {
|
||||
const records = cache.get(variantCacheKey(sessionId, roundId));
|
||||
return Boolean(
|
||||
records?.some(
|
||||
(record) => normalizeVariantIndex(record.variantIndex) === variantIndex,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function isSwitching(sessionId?: number | string, roundId?: number | string) {
|
||||
if (!sessionId || !roundId) {
|
||||
return false;
|
||||
}
|
||||
return switchingKeys.has(variantCacheKey(sessionId, roundId));
|
||||
}
|
||||
|
||||
async function switchVariant(params: SwitchVariantOptions<TRecord, TItem>) {
|
||||
const key = variantCacheKey(params.sessionId, params.roundId);
|
||||
if (switchingKeys.has(key)) {
|
||||
return null;
|
||||
}
|
||||
switchingKeys.add(key);
|
||||
notifyStateChange();
|
||||
const snapshot = [...params.items];
|
||||
try {
|
||||
const records = await ensureVariants(params);
|
||||
const target = records.find(
|
||||
(record) =>
|
||||
normalizeVariantIndex(record.variantIndex) === params.targetVariantIndex,
|
||||
);
|
||||
if (!target) {
|
||||
throw new Error('目标答案版本不存在');
|
||||
}
|
||||
const localTarget = markVariantSelected(target, params.targetVariantIndex);
|
||||
const nextItems = options.mapRecords([localTarget]);
|
||||
if (nextItems.length === 0) {
|
||||
throw new Error('目标答案版本渲染失败');
|
||||
}
|
||||
options.replaceRound(
|
||||
params.items,
|
||||
String(params.roundId),
|
||||
nextItems,
|
||||
);
|
||||
params.onLocalSwitch?.(localTarget);
|
||||
const persistedRecord = await params.persistVariant();
|
||||
const selectedRecord = markVariantSelected(
|
||||
persistedRecord || localTarget,
|
||||
params.targetVariantIndex,
|
||||
);
|
||||
cache.set(
|
||||
key,
|
||||
syncCachedSelection(records, params.targetVariantIndex, selectedRecord),
|
||||
);
|
||||
return selectedRecord;
|
||||
} catch (error) {
|
||||
params.items.splice(0, params.items.length, ...snapshot);
|
||||
options.onError?.(error);
|
||||
return null;
|
||||
} finally {
|
||||
switchingKeys.delete(key);
|
||||
notifyStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
function cacheVariants(
|
||||
sessionId: number | string,
|
||||
roundId: number | string,
|
||||
records: TRecord[],
|
||||
) {
|
||||
cache.set(variantCacheKey(sessionId, roundId), records);
|
||||
}
|
||||
|
||||
return {
|
||||
cacheVariants,
|
||||
hasCachedVariant,
|
||||
isSwitching,
|
||||
prefetchVariants,
|
||||
switchVariant,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './chat-time';
|
||||
export * from './chat-variant-switch';
|
||||
export * from './clipboard';
|
||||
export * from './find-menu-by-path';
|
||||
export * from './generate-menus';
|
||||
|
||||
Reference in New Issue
Block a user