feat: 完成管理端聊天工作台收口

- 新增管理端聊天工作台与会话级额外知识库持久化

- 补齐发布态聊天、历史会话只读判断与答案版本切换

- 新增 chat_round 热数据与主线消息读取支撑
This commit is contained in:
2026-05-14 20:22:46 +08:00
parent 2ad8935a61
commit 47c2bad839
63 changed files with 8609 additions and 136 deletions

View File

@@ -197,6 +197,28 @@ export class SseClient {
return;
}
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('text/event-stream')) {
let errorMessage = '请求失败,请稍后再试';
try {
const body = await res.json();
errorMessage =
body?.error ?? body?.message ?? body?.data?.message ?? errorMessage;
} catch {
try {
const text = await res.text();
if (text.trim()) {
errorMessage = text.trim();
}
} catch {
// ignore body parse failures and keep fallback message
}
}
showErrorOnce(errorMessage);
options?.onError?.(new Error(errorMessage));
return;
}
// 在开始事件流之前检查是否还是同一个请求
if (this.currentRequestId !== currentRequestId) {
return;

View File

@@ -0,0 +1,147 @@
<script setup lang="ts">
import {
ArrowLeft,
ArrowRight,
Loading,
} from '@element-plus/icons-vue';
const props = withDefaults(
defineProps<{
current?: number;
disabled?: boolean;
disabledNext?: boolean;
disabledPrev?: boolean;
loading?: boolean;
total?: number;
}>(),
{
current: 1,
disabled: false,
disabledNext: false,
disabledPrev: false,
loading: false,
total: 1,
},
);
const emit = defineEmits<{
next: [];
previous: [];
}>();
function handlePrevious() {
if (props.disabled || props.disabledPrev || props.loading) {
return;
}
emit('previous');
}
function handleNext() {
if (props.disabled || props.disabledNext || props.loading) {
return;
}
emit('next');
}
</script>
<template>
<div
v-if="total > 1"
class="variant-nav"
:class="{ 'is-loading': loading }"
>
<button
type="button"
class="variant-nav__button"
:disabled="disabled || disabledPrev || loading"
aria-label="查看上一版答案"
title="上一版"
@click="handlePrevious"
>
<Loading v-if="loading" />
<ArrowLeft v-else />
</button>
<span class="variant-nav__label">{{ current }}/{{ total }}</span>
<button
type="button"
class="variant-nav__button"
:disabled="disabled || disabledNext || loading"
aria-label="查看下一版答案"
title="下一版"
@click="handleNext"
>
<Loading v-if="loading" />
<ArrowRight v-else />
</button>
</div>
</template>
<style scoped>
.variant-nav {
display: inline-flex;
gap: 2px;
align-items: center;
}
.variant-nav__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;
}
.variant-nav__button:hover:not(:disabled) {
color: hsl(var(--text-strong));
background: hsl(var(--foreground) / 0.05);
}
.variant-nav__button:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.32);
outline-offset: 2px;
}
.variant-nav__button:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.variant-nav__button :deep(svg) {
width: 14px;
height: 14px;
}
.variant-nav.is-loading .variant-nav__button :deep(svg) {
animation: variant-nav-spin 0.8s linear infinite;
}
.variant-nav__label {
min-width: 44px;
font-size: 11px;
font-weight: 600;
line-height: 1;
text-align: center;
color: hsl(var(--text-muted));
}
@keyframes variant-nav-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,429 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
ArrowDown,
Check,
Close,
Plus,
} from '@element-plus/icons-vue';
import { ElPopover } from 'element-plus';
interface KnowledgeOption {
label: string;
value: string;
}
interface KnowledgeView {
id: string;
title: string;
}
const MAX_EXTRA_KNOWLEDGE_COUNT = 3;
const knowledgePanelOpen = ref(false);
const props = withDefaults(
defineProps<{
boundKnowledges?: KnowledgeView[];
selectedExtraKnowledges?: KnowledgeView[];
extraKnowledgeIds?: string[];
extraKnowledgeOptions?: KnowledgeOption[];
disabled?: boolean;
knowledgeDisabled?: boolean;
loading?: boolean;
mode?: 'editable' | 'readonly';
}>(),
{
boundKnowledges: () => [],
selectedExtraKnowledges: () => [],
extraKnowledgeIds: () => [],
extraKnowledgeOptions: () => [],
disabled: false,
knowledgeDisabled: false,
loading: false,
mode: 'editable',
},
);
const emit = defineEmits<{
'update:extraKnowledgeIds': [value: string[]];
}>();
const selectedExtraIdSet = computed(() => new Set(props.extraKnowledgeIds));
const hasAnyKnowledge = computed(
() =>
props.boundKnowledges.length > 0 || props.selectedExtraKnowledges.length > 0,
);
function updateExtraKnowledges(value: string[]) {
emit('update:extraKnowledgeIds', value || []);
}
function toggleKnowledge(id: string) {
const normalizedId = String(id);
const nextIds = [...props.extraKnowledgeIds];
const currentIndex = nextIds.findIndex((item) => String(item) === normalizedId);
if (currentIndex >= 0) {
nextIds.splice(currentIndex, 1);
updateExtraKnowledges(nextIds);
return;
}
if (nextIds.length >= MAX_EXTRA_KNOWLEDGE_COUNT) {
return;
}
nextIds.push(normalizedId);
updateExtraKnowledges(nextIds);
}
function removeExtraKnowledge(id: string) {
updateExtraKnowledges(
props.extraKnowledgeIds.filter((item) => String(item) !== String(id)),
);
}
function isKnowledgeSelected(id: string) {
return selectedExtraIdSet.value.has(String(id));
}
function isKnowledgeDisabled(id: string) {
if (props.disabled || props.knowledgeDisabled || props.loading) {
return true;
}
return (
props.extraKnowledgeIds.length >= MAX_EXTRA_KNOWLEDGE_COUNT &&
!isKnowledgeSelected(id)
);
}
</script>
<template>
<div
v-if="mode === 'editable' || hasAnyKnowledge"
class="context-rail"
:class="{ 'is-readonly': mode === 'readonly' }"
>
<div class="context-rail__row">
<ElPopover
v-if="mode === 'editable'"
v-model:visible="knowledgePanelOpen"
placement="top-start"
:teleported="false"
popper-class="chat-knowledge-popper"
:show-arrow="false"
:width="356"
:offset="10"
trigger="click"
>
<template #reference>
<button
type="button"
class="context-rail__trigger"
:disabled="disabled || knowledgeDisabled || loading"
:class="{ 'is-open': knowledgePanelOpen }"
>
<Plus class="context-rail__trigger-icon" />
<span>{{ loading ? '加载知识库中' : '知识库' }}</span>
<ArrowDown class="context-rail__trigger-caret" />
</button>
</template>
<div class="context-rail__panel">
<div class="context-rail__panel-head">
<span class="context-rail__panel-title">选择知识库</span>
<span class="context-rail__panel-caption">最多 3 </span>
</div>
<div
v-if="extraKnowledgeOptions.length > 0"
class="context-rail__option-list"
>
<button
v-for="item in extraKnowledgeOptions"
:key="item.value"
type="button"
class="context-rail__option"
:class="{
'is-active': isKnowledgeSelected(item.value),
'is-disabled': isKnowledgeDisabled(item.value),
}"
:disabled="isKnowledgeDisabled(item.value)"
@click="toggleKnowledge(item.value)"
>
<span class="context-rail__option-label">
{{ item.label }}
</span>
<Check
v-if="isKnowledgeSelected(item.value)"
class="context-rail__option-check"
/>
</button>
</div>
<div v-else class="context-rail__empty">
暂无可选知识库
</div>
</div>
</ElPopover>
<div
v-if="hasAnyKnowledge"
class="context-rail__chips"
>
<span
v-for="knowledge in boundKnowledges"
:key="knowledge.id"
class="context-rail__chip is-bound"
>
{{ knowledge.title }}
</span>
<span
v-for="knowledge in selectedExtraKnowledges"
:key="knowledge.id"
class="context-rail__chip is-extra"
>
<span class="context-rail__chip-label">{{ knowledge.title }}</span>
<button
v-if="mode === 'editable'"
type="button"
class="context-rail__chip-remove"
aria-label="移除知识库"
@click="removeExtraKnowledge(knowledge.id)"
>
<Close />
</button>
</span>
</div>
</div>
</div>
</template>
<style scoped>
.context-rail {
min-width: 0;
}
.context-rail__row {
display: flex;
gap: 10px;
align-items: center;
min-width: 0;
flex-wrap: wrap;
}
.context-rail__trigger {
display: inline-flex;
gap: 8px;
align-items: center;
min-height: 32px;
padding: 0 12px;
font-size: 12px;
font-weight: 600;
color: hsl(var(--text-strong));
border: 1px solid hsl(var(--divider-faint) / 0.86);
border-radius: 999px;
background: hsl(var(--surface-panel));
transition:
border-color 0.18s ease,
background-color 0.18s ease,
color 0.18s ease;
}
.context-rail__trigger:hover:not(:disabled) {
border-color: hsl(var(--primary) / 0.22);
background: hsl(var(--surface-subtle));
}
.context-rail__trigger.is-open {
border-color: hsl(var(--primary) / 0.24);
background: hsl(var(--surface-subtle));
}
.context-rail__trigger:focus-visible,
.context-rail__option:focus-visible,
.context-rail__chip-remove:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.28);
outline-offset: 2px;
}
.context-rail__trigger:disabled {
cursor: not-allowed;
opacity: 0.68;
}
.context-rail__trigger-icon,
.context-rail__trigger-caret {
width: 14px;
height: 14px;
color: hsl(var(--text-muted));
}
.context-rail__chips {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
flex-wrap: wrap;
}
.context-rail__chip {
display: inline-flex;
gap: 6px;
align-items: center;
max-width: min(100%, 220px);
min-height: 32px;
padding: 0 12px;
overflow: hidden;
font-size: 12px;
font-weight: 500;
color: hsl(var(--text-muted));
border: 1px solid hsl(var(--divider-faint) / 0.84);
border-radius: 999px;
background: hsl(var(--surface-panel));
}
.context-rail__chip.is-extra {
color: hsl(var(--text-strong));
border-color: hsl(var(--primary) / 0.18);
background: hsl(var(--primary) / 0.06);
}
.context-rail__chip-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-rail__chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
color: hsl(var(--text-muted));
border: none;
border-radius: 999px;
background: transparent;
}
.context-rail__chip-remove:hover {
color: hsl(var(--text-strong));
background: hsl(var(--surface-panel) / 0.7);
}
.context-rail__chip-remove :deep(svg) {
width: 12px;
height: 12px;
}
.context-rail__panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.context-rail__panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.context-rail__panel-title {
font-size: 13px;
font-weight: 650;
color: hsl(var(--text-strong));
}
.context-rail__panel-caption {
font-size: 11px;
color: hsl(var(--text-muted));
}
.context-rail__option-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.context-rail__option {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 11px 12px;
text-align: left;
color: hsl(var(--text-strong));
border: 1px solid transparent;
border-radius: 12px;
background: hsl(var(--surface-subtle) / 0.82);
transition:
border-color 0.18s ease,
background-color 0.18s ease,
color 0.18s ease;
}
.context-rail__option:hover:not(:disabled) {
border-color: hsl(var(--primary) / 0.16);
background: hsl(var(--surface-panel));
}
.context-rail__option.is-active {
border-color: hsl(var(--primary) / 0.18);
background: hsl(var(--primary) / 0.07);
}
.context-rail__option.is-disabled {
cursor: not-allowed;
opacity: 0.58;
}
.context-rail__option-label {
overflow: hidden;
font-size: 12px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-rail__option-check {
flex: none;
width: 14px;
height: 14px;
color: hsl(var(--primary));
}
.context-rail__empty {
padding: 2px 0;
font-size: 12px;
text-align: left;
color: hsl(var(--text-muted));
}
:global(.chat-knowledge-popper.el-popper) {
padding: 12px;
border: 1px solid hsl(var(--divider-faint) / 0.88);
border-radius: 18px;
background:
linear-gradient(180deg, hsl(var(--surface-panel) / 0.98) 0%, hsl(var(--surface-subtle) / 0.96) 100%);
box-shadow: 0 18px 40px -32px hsl(var(--foreground) / 0.22);
backdrop-filter: blur(18px);
}
:global(.chat-knowledge-popper.el-popper .el-popper__arrow) {
display: none;
}
@media (max-width: 768px) {
.context-rail__row {
align-items: stretch;
}
.context-rail__trigger {
justify-content: center;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import {
CopyDocument,
RefreshRight,
} from '@element-plus/icons-vue';
import ChatAnswerVariantNavigator from './ChatAnswerVariantNavigator.vue';
const props = withDefaults(
defineProps<{
align?: 'end' | 'start';
allowCopy?: boolean;
allowRegenerate?: boolean;
disabledVariantNext?: boolean;
disabledVariantPrevious?: boolean;
regenerateDisabled?: boolean;
showVariantNavigator?: boolean;
variantLoading?: boolean;
variantCurrent?: number;
variantTotal?: number;
}>(),
{
align: 'start',
allowCopy: false,
allowRegenerate: false,
disabledVariantNext: false,
disabledVariantPrevious: false,
regenerateDisabled: false,
showVariantNavigator: false,
variantLoading: false,
variantCurrent: 1,
variantTotal: 1,
},
);
const emit = defineEmits<{
copy: [];
regenerate: [];
selectNextVariant: [];
selectPreviousVariant: [];
}>();
function handleCopy() {
emit('copy');
}
function handleRegenerate() {
if (props.regenerateDisabled) {
return;
}
emit('regenerate');
}
function handleSelectPreviousVariant() {
emit('selectPreviousVariant');
}
function handleSelectNextVariant() {
emit('selectNextVariant');
}
</script>
<template>
<div
v-if="allowCopy || allowRegenerate || showVariantNavigator"
class="message-actions"
:class="`is-${align}`"
>
<ChatAnswerVariantNavigator
v-if="showVariantNavigator"
:current="variantCurrent"
:total="variantTotal"
:disabled-prev="disabledVariantPrevious"
:disabled-next="disabledVariantNext"
:loading="variantLoading"
@previous="handleSelectPreviousVariant"
@next="handleSelectNextVariant"
/>
<button
v-if="allowCopy"
type="button"
class="message-actions__button"
aria-label="复制消息"
title="复制"
@click="handleCopy"
>
<CopyDocument />
</button>
<button
v-if="allowRegenerate"
type="button"
class="message-actions__button"
:disabled="regenerateDisabled"
aria-label="重新生成"
title="重新生成"
@click="handleRegenerate"
>
<RefreshRight />
</button>
</div>
</template>
<style scoped>
.message-actions {
display: inline-flex;
gap: 4px;
align-items: center;
margin-top: 6px;
}
.message-actions.is-end {
justify-content: flex-end;
}
.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;
}
.message-actions__button:hover:not(:disabled) {
color: hsl(var(--text-strong));
background: hsl(var(--foreground) / 0.05);
}
.message-actions__button:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.32);
outline-offset: 2px;
}
.message-actions__button:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.message-actions__button :deep(svg) {
width: 15px;
height: 15px;
}
</style>

View File

@@ -0,0 +1,199 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ArrowDown, Check } from '@element-plus/icons-vue';
import {
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
} from 'element-plus';
interface AssistantOption {
label: string;
value: string;
}
const props = withDefaults(
defineProps<{
assistantId?: string;
assistantName?: string;
assistantOptions?: AssistantOption[];
disabled?: boolean;
loading?: boolean;
placeholder?: string;
}>(),
{
assistantId: undefined,
assistantName: '',
assistantOptions: () => [],
disabled: false,
loading: false,
placeholder: '选择智能体',
},
);
const emit = defineEmits<{
'update:assistantId': [value?: string];
}>();
const currentLabel = computed(() => {
if (props.assistantName?.trim()) {
return props.assistantName.trim();
}
return (
props.assistantOptions.find((item) => item.value === props.assistantId)?.label ||
props.placeholder
);
});
function handleCommand(value: string) {
emit('update:assistantId', value || undefined);
}
</script>
<template>
<ElDropdown
class="assistant-inline-picker"
:disabled="disabled"
trigger="click"
@command="handleCommand"
>
<button
type="button"
class="assistant-inline-picker__trigger"
:class="{
'is-placeholder': !assistantId,
'is-disabled': disabled,
}"
>
<span class="assistant-inline-picker__label">
{{ loading ? '加载智能体中' : currentLabel }}
</span>
<ArrowDown class="assistant-inline-picker__arrow" />
</button>
<template #dropdown>
<ElDropdownMenu class="assistant-inline-picker__menu">
<ElDropdownItem
v-for="item in assistantOptions"
:key="item.value"
:command="item.value"
class="assistant-inline-picker__item"
>
<span class="assistant-inline-picker__item-label">
{{ item.label }}
</span>
<Check
v-if="item.value === assistantId"
class="assistant-inline-picker__item-check"
/>
</ElDropdownItem>
<ElDropdownItem
v-if="assistantOptions.length === 0"
disabled
class="assistant-inline-picker__item is-empty"
>
暂无可用智能体
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
<style scoped>
.assistant-inline-picker {
display: inline-flex;
vertical-align: middle;
}
.assistant-inline-picker__trigger {
display: inline-flex;
gap: 8px;
align-items: center;
max-width: min(100%, 360px);
min-height: 42px;
padding: 0 14px;
font-size: 22px;
font-weight: 680;
line-height: 1;
color: hsl(var(--text-strong));
border: 1px solid hsl(var(--divider-faint) / 0.82);
border-radius: 999px;
background: hsl(var(--surface-panel));
box-shadow: 0 12px 28px -24px hsl(var(--foreground) / 0.22);
transition:
border-color 0.18s ease,
background-color 0.18s ease,
color 0.18s ease;
}
.assistant-inline-picker__trigger:hover:not(.is-disabled) {
border-color: hsl(var(--primary) / 0.22);
background: hsl(var(--surface-subtle));
}
.assistant-inline-picker__trigger:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.28);
outline-offset: 2px;
}
.assistant-inline-picker__trigger.is-placeholder {
color: hsl(var(--text-muted));
}
.assistant-inline-picker__trigger.is-disabled {
cursor: default;
opacity: 0.72;
}
.assistant-inline-picker__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assistant-inline-picker__arrow {
flex: none;
width: 16px;
height: 16px;
color: hsl(var(--text-muted));
}
.assistant-inline-picker__menu :deep(.el-dropdown-menu__item) {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
min-width: 220px;
padding: 10px 14px;
font-size: 13px;
color: hsl(var(--text-strong));
}
.assistant-inline-picker__item-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assistant-inline-picker__item-check {
flex: none;
width: 14px;
height: 14px;
color: hsl(var(--primary));
}
.assistant-inline-picker__item.is-empty {
justify-content: center;
color: hsl(var(--text-muted));
}
@media (max-width: 768px) {
.assistant-inline-picker__trigger {
max-width: 100%;
min-height: 38px;
padding-inline: 12px;
font-size: 18px;
}
}
</style>

View File

@@ -22,6 +22,7 @@
"oauth": "OAuth"
},
"ai": {
"chat": "Chat",
"bots": "ChatAssistant",
"title": "AI",
"resources": "Resources",

View File

@@ -22,6 +22,7 @@
"oauth": "认证设置"
},
"ai": {
"chat": "聊天",
"bots": "聊天助手",
"title": "AI能力",
"resources": "素材库",

File diff suppressed because it is too large Load Diff

View File

@@ -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,

View File

@@ -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,64 @@ 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 (
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 +823,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, '')

View File

@@ -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,
};
}

View File

@@ -59,6 +59,9 @@ function convertRoutes(
const pageKey = normalizePath.endsWith('.vue')
? normalizePath
: `${normalizePath}.vue`;
if (pageKey === '/ai/chat/index.vue' && route.meta) {
route.meta.fullPathKey = false;
}
if (pageMap[pageKey]) {
route.component = pageMap[pageKey];
} else {

View File

@@ -1,4 +1,5 @@
export * from './chat-time';
export * from './chat-variant-switch';
export * from './find-menu-by-path';
export * from './generate-menus';
export * from './generate-routes-backend';