feat: 重构聊天时间线与附件上传交互

- 管理端和用户中心统一切换到 chatTime 时间线模型,按真实 assistant/tool 时序渲染

- 收紧工具气泡与 JSON 展示样式,保留同气泡内的工具状态更新

- 回形针直接触发文件选择,附件列表上移到输入框上方并补充共享 helper 测试
This commit is contained in:
2026-05-11 21:25:21 +08:00
parent e27834ee0c
commit 4a15124183
27 changed files with 2527 additions and 751 deletions

View File

@@ -1,10 +1,16 @@
<script setup lang="ts">
import type { ChatTimeTimelineItem } from '@easyflow/types';
import { XMarkdown as ElXMarkdown } from 'vue-element-plus-x';
import { 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, ElCollapse, ElCollapseItem, ElIcon } from 'element-plus';
import { ElAvatar, ElIcon } from 'element-plus';
import defaultAssistantAvatar from '#/assets/defaultAssistantAvatar.svg';
import defaultUserAvatar from '#/assets/defaultUserAvatar.png';
@@ -12,10 +18,11 @@ import ShowJson from '#/components/json/ShowJson.vue';
interface Props {
bot: any;
messages: any[];
messages: ChatTimeTimelineItem[];
}
const props = defineProps<Props>();
const store = useUserStore();
const expandedToolState = ref<Record<string, boolean>>({});
function getAssistantAvatar() {
return props.bot.icon || defaultAssistantAvatar;
@@ -23,6 +30,35 @@ function getAssistantAvatar() {
function getUserAvatar() {
return store.userInfo?.avatar || defaultUserAvatar;
}
function formatTime(value: number | string) {
if (typeof value === 'number') {
return new Date(value).toLocaleString();
}
return value || '';
}
function getToolName(item: ChatTimeTimelineItem) {
return item.role === 'tool' && item.name ? item.name : '工具调用';
}
function hasToolDetails(item: ChatTimeTimelineItem) {
return item.role === 'tool' && Boolean(item.arguments || item.result);
}
function isToolExpanded(item: ChatTimeTimelineItem) {
return item.role === 'tool' && Boolean(expandedToolState.value[item.id]);
}
function toggleToolExpanded(item: ChatTimeTimelineItem) {
if (item.role !== 'tool' || !hasToolDetails(item)) {
return;
}
expandedToolState.value = {
...expandedToolState.value,
[item.id]: !expandedToolState.value[item.id],
};
}
</script>
<template>
@@ -30,9 +66,7 @@ function getUserAvatar() {
<!-- 自定义头像 -->
<template #avatar="{ item }">
<ElAvatar
:src="
item.role === 'assistant' ? getAssistantAvatar() : getUserAvatar()
"
:src="item.role === 'user' ? getUserAvatar() : getAssistantAvatar()"
:size="40"
/>
</template>
@@ -41,63 +75,80 @@ function getUserAvatar() {
<template #header="{ item }">
<div class="flex flex-col">
<span class="text-foreground/50 text-xs">
{{ item.created }}
{{ formatTime(item.created) }}
</span>
<template v-if="item.chains">
<template
v-for="(chain, index) in item.chains"
:key="chain.id || index"
>
<ChatThinkingBlock
v-if="!('id' in chain)"
v-model:expanded="chain.thinkingExpanded"
:content="chain.reasoning_content"
:status="chain.thinkingStatus"
class="chat-thinking-block-item"
/>
<ElCollapse v-else class="chat-tool-panel">
<ElCollapseItem :title="chain.name" :name="chain.id">
<template #title>
<div class="flex items-center gap-2 pl-5">
<ElIcon size="16">
<IconifyIcon icon="svg:wrench" />
</ElIcon>
<span>{{ chain.name }}</span>
<template v-if="chain.status === 'TOOL_CALL'">
<div
class="bg-secondary flex items-center gap-1 rounded-full px-2 py-0.5 leading-none"
>
<ElIcon size="16">
<IconifyIcon icon="mdi:clock-time-five-outline" />
</ElIcon>
<span>工具调用中...</span>
</div>
</template>
<template v-else>
<div
class="bg-secondary flex items-center gap-1 rounded-full px-2 py-0.5 leading-none"
>
<ElIcon size="16" color="var(--el-color-success)">
<CircleCheck />
</ElIcon>
<span>调用成功</span>
</div>
</template>
</div>
</template>
<ShowJson :value="chain.result" />
</ElCollapseItem>
</ElCollapse>
</template>
</template>
</div>
</template>
<!-- 自定义气泡内容 -->
<template #content="{ item }">
<ElXMarkdown :markdown="item.content" />
<template v-if="item.role === 'assistant'">
<div class="flex flex-col gap-2">
<template v-for="segment in item.segments" :key="segment.id">
<ChatThinkingBlock
v-if="segment.type === 'thinking'"
v-model:expanded="segment.expanded"
:content="segment.content"
:status="segment.status"
class="chat-thinking-block-item"
/>
<ElXMarkdown v-else :markdown="segment.content" />
</template>
</div>
</template>
<template v-else-if="item.role === 'tool'">
<div class="chat-tool-panel" :class="{ 'is-expanded': isToolExpanded(item) }">
<button
type="button"
class="chat-tool-header"
:class="{ 'is-clickable': hasToolDetails(item) }"
@click="toggleToolExpanded(item)"
>
<div class="chat-tool-title">
<ElIcon size="14">
<IconifyIcon icon="svg:wrench" />
</ElIcon>
<span class="chat-tool-title-text">{{ getToolName(item) }}</span>
</div>
<div class="chat-tool-meta">
<span
class="chat-tool-status"
:class="item.status === 'TOOL_CALL' ? 'is-calling' : 'is-done'"
>
<ElIcon size="13">
<IconifyIcon
v-if="item.status === 'TOOL_CALL'"
icon="mdi:clock-time-five-outline"
/>
<CircleCheck v-else />
</ElIcon>
<span>{{ item.status === 'TOOL_CALL' ? '调用中' : '已完成' }}</span>
</span>
<ElIcon
v-if="hasToolDetails(item)"
size="14"
class="chat-tool-arrow"
:class="{ 'is-expanded': isToolExpanded(item) }"
>
<IconifyIcon icon="ep:arrow-right" />
</ElIcon>
</div>
</button>
<div v-if="isToolExpanded(item) && hasToolDetails(item)" class="chat-tool-body">
<ShowJson
v-if="item.arguments"
:value="item.arguments"
plain
/>
<ShowJson
v-if="item.result"
:value="item.result"
plain
/>
</div>
</div>
</template>
<ElXMarkdown v-else :markdown="item.content" />
</template>
<!-- 自定义底部 -->
@@ -130,29 +181,94 @@ function getUserAvatar() {
}
.chat-thinking-block-item {
margin-bottom: 8px;
margin-bottom: 0;
}
:deep(.chat-tool-panel.el-collapse) {
.chat-tool-panel {
min-width: 0;
background: hsl(var(--surface-panel) / 0.98);
border: 1px solid hsl(var(--divider-faint) / 0.16);
border-radius: 10px;
box-shadow: none;
}
.chat-tool-header {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 34px;
padding: 0 10px;
text-align: left;
background: transparent;
border: none;
}
.chat-tool-header.is-clickable {
cursor: pointer;
}
.chat-tool-title {
display: inline-flex;
gap: 6px;
align-items: center;
min-width: 0;
color: hsl(var(--text-strong));
}
.chat-tool-title-text {
overflow: hidden;
border: 1px solid hsl(var(--divider-faint) / 0.26);
border-radius: 14px;
background: hsl(var(--surface-panel) / 0.7);
box-shadow: inset 0 1px 0 hsl(var(--glass-border) / 0.2);
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.chat-tool-panel .el-collapse-item__wrap) {
background: transparent;
.chat-tool-meta {
display: inline-flex;
flex-shrink: 0;
gap: 6px;
align-items: center;
}
:deep(.chat-tool-panel .el-collapse-item__header) {
min-height: 44px;
padding-right: 14px;
background: transparent;
border-bottom-color: hsl(var(--divider-faint) / 0.16);
.chat-tool-status {
display: inline-flex;
flex-shrink: 0;
gap: 4px;
align-items: center;
padding: 1px 8px;
font-size: 11px;
line-height: 1.4;
border: 1px solid transparent;
border-radius: 999px;
}
:deep(.chat-tool-panel .el-collapse-item__content) {
padding-bottom: 0;
.chat-tool-status.is-calling {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.08);
border-color: hsl(var(--primary) / 0.12);
}
.chat-tool-status.is-done {
color: hsl(var(--success));
background: hsl(var(--success) / 0.08);
border-color: hsl(var(--success) / 0.12);
}
.chat-tool-arrow {
color: hsl(var(--text-muted));
transition: transform 0.15s ease;
}
.chat-tool-arrow.is-expanded {
transform: rotate(90deg);
}
.chat-tool-body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 10px 10px;
}
</style>

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import type { ChatTimeTimelineItem } from '@easyflow/types';
import { nextTick, provide, ref, watch } from 'vue';
import { IconifyIcon } from '@easyflow/icons';
import { cn } from '@easyflow/utils';
import { ChatTimeHistoryMapper, cn } from '@easyflow/utils';
import { Delete, Edit, MoreFilled } from '@element-plus/icons-vue';
import {
@@ -29,7 +31,7 @@ import { $t } from '#/locales';
interface Props {
bot: any;
isFold: boolean;
onMessageList?: (list: any[]) => void;
onMessageList?: (list: ChatTimeTimelineItem[]) => void;
toggleFold: () => void;
}
const props = defineProps<Props>();
@@ -101,19 +103,7 @@ function getMessageList() {
if (res.errorCode === 0) {
const records = Array.isArray(res.data?.records) ? [...res.data.records] : [];
props.onMessageList?.(
records.reverse().map((item: any) => ({
key: String(item.id),
role: item.senderRole === 'assistant' ? 'assistant' : 'user',
content:
item.senderRole === 'assistant'
? String(item.contentText || '').replace(/^Final Answer:\s*/i, '')
: item.contentText,
placement: item.senderRole === 'assistant' ? 'start' : 'end',
created: item.created,
chains: Array.isArray(item.contentPayload?.chains)
? item.contentPayload.chains
: undefined,
})),
ChatTimeHistoryMapper.fromHistoryRecords(records.reverse()),
);
}
});

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import type { ChatThinkingBlockStatus } from '@easyflow/common-ui';
import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
import type { ChatTimeTimelineItem } from '@easyflow/types';
import { inject, ref } from 'vue';
import { cloneDeep, uuid } from '@easyflow/utils';
import { ChatTimeTimelineBuilder, uuid } from '@easyflow/utils';
import { Paperclip, Promotion } from '@element-plus/icons-vue';
import { ElButton, ElIcon } from 'element-plus';
@@ -12,33 +11,10 @@ import { ElButton, ElIcon } from 'element-plus';
import { sseClient } from '#/api/request';
import SendingIcon from '#/components/icons/SendingIcon.vue';
import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
// import PaperclipIcon from '#/components/icons/PaperclipIcon.vue';
type Think = {
reasoning_content?: string;
thinkingExpanded?: boolean;
thinkingStatus?: ChatThinkingBlockStatus;
};
type Tool = {
id: string;
name: string;
result: string;
status: 'TOOL_CALL' | 'TOOL_RESULT';
};
type MessageItem = BubbleProps & {
chains?: (Think | Tool)[];
key: string;
role: 'assistant' | 'user';
};
interface Props {
conversationId: string | undefined;
bot: any;
addMessage: (message: MessageItem) => void;
updateLastMessage: (item: any) => void;
stopThinking: () => void;
mutateMessages: (mutator: (messages: ChatTimeTimelineItem[]) => void) => void;
}
const props = defineProps<Props>();
@@ -48,7 +24,6 @@ const getSessionList = inject<any>('getSessionList');
const clearSenderFiles = () => {
files.value = [];
attachmentsRef.value?.clearFiles();
openCloseHeader();
};
function sendMessage() {
if (getDisabled()) {
@@ -62,25 +37,15 @@ function sendMessage() {
};
clearSenderFiles();
btnLoading.value = true;
props.addMessage({
key: uuid(),
role: 'user',
placement: 'end',
content: senderValue.value,
typing: true,
});
props.addMessage({
key: uuid(),
role: 'assistant',
placement: 'start',
content: '',
loading: true,
typing: true,
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.appendUserMessage(messages, {
content: senderValue.value,
created: Date.now(),
id: uuid(),
});
});
senderValue.value = '';
let content = '';
sseClient.post('/userCenter/bot/chat', data, {
onMessage(res) {
if (!res.data) {
@@ -100,127 +65,90 @@ function sendMessage() {
sseData.payload?.code === 'SYSTEM_ERROR'
) {
const errorMessage = sseData.payload.message;
props.updateLastMessage({
content: errorMessage,
loading: false,
typing: false,
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.applySystemError(messages, errorMessage);
});
return;
}
if (sseData?.domain === 'TOOL') {
props.updateLastMessage((message: MessageItem) => {
const chains = cloneDeep(message.chains ?? []);
const index = chains.findIndex(
(chain) =>
isTool(chain) && chain.id === sseData?.payload?.tool_call_id,
);
if (index === -1) {
chains.push({
id: sseData?.payload?.tool_call_id,
props.mutateMessages((messages) => {
if (sseData?.type === 'TOOL_CALL') {
ChatTimeTimelineBuilder.upsertToolCall(messages, {
created: Date.now(),
name: sseData?.payload?.name,
status: sseData?.type,
result:
sseData?.type === 'TOOL_CALL'
? sseData?.payload?.arguments
: sseData?.payload?.result,
toolCallId: sseData?.payload?.tool_call_id,
value: sseData?.payload?.arguments,
});
} else {
chains[index] = {
...chains[index]!,
status: sseData?.type,
result:
sseData?.type === 'TOOL_CALL'
? sseData?.payload?.arguments
: sseData?.payload?.result,
};
return;
}
return { chains };
ChatTimeTimelineBuilder.upsertToolResult(messages, {
created: Date.now(),
name: sseData?.payload?.name,
result: sseData?.payload?.result,
toolCallId: sseData?.payload?.tool_call_id,
});
});
props.stopThinking();
return;
}
if (sseData.type === 'THINKING') {
props.updateLastMessage((message: MessageItem) => {
const chains = cloneDeep(message.chains ?? []);
const index = chains.findIndex(
(chain) => isThink(chain) && chain.thinkingStatus === 'thinking',
);
if (index === -1) {
chains.push({
thinkingStatus: 'thinking',
thinkingExpanded: false,
reasoning_content: delta,
});
} else {
const think = chains[index]! as Think;
chains[index] = {
...think,
reasoning_content: think.reasoning_content + delta,
};
}
return { chains };
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.appendThinkingDelta(messages, delta, Date.now());
});
} else if (sseData.type === 'MESSAGE') {
props.updateLastMessage({
thinkingStatus: 'end',
loading: false,
content: (content += delta),
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.appendMessageDelta(messages, delta, Date.now());
});
props.stopThinking();
}
},
onError(err) {
console.error(err);
btnLoading.value = false;
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.finalize(messages);
});
},
onFinished() {
senderValue.value = '';
btnLoading.value = false;
props.updateLastMessage({ loading: false });
props.stopThinking();
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.finalize(messages);
});
},
});
}
const isTool = (item: Think | Tool) => {
return 'id' in item;
};
const isThink = (item: Think | Tool): item is Think => {
return !('id' in item);
};
function getDisabled() {
return !senderValue.value || !props.conversationId;
}
const stopSse = () => {
sseClient.abort();
btnLoading.value = false;
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.finalize(messages);
});
};
const showHeaderFlog = ref(false);
const attachmentsRef = ref();
const senderRef = ref();
const files = ref<any[]>([]);
function handlePasteFile(_: any, fileList: FileList) {
showHeaderFlog.value = true;
senderRef.value?.openHeader();
files.value = [...fileList];
}
function openCloseHeader() {
if (showHeaderFlog.value) {
senderRef.value?.closeHeader();
files.value = [];
} else {
senderRef.value?.openHeader();
}
showHeaderFlog.value = !showHeaderFlog.value;
function triggerFileSelect() {
attachmentsRef.value?.triggerFileSelect?.();
}
function handleDeleteAllSenderFiles() {
files.value = [];
}
</script>
<template>
<ChatFileUploader
ref="attachmentsRef"
:external-files="files"
@delete-all="handleDeleteAllSenderFiles"
:max-size="10"
/>
<ElSender
ref="senderRef"
v-model="senderValue"
variant="updown"
:auto-size="{ minRows: 2, maxRows: 5 }"
@@ -230,21 +158,9 @@ function openCloseHeader() {
@keyup.enter="sendMessage"
@paste-file="handlePasteFile"
>
<!-- 自定义 prefix 前缀 -->
<!-- <template #prefix>
</template> -->
<!-- 自定义头部内容 -->
<template #header>
<ChatFileUploader
ref="attachmentsRef"
:external-files="files"
@delete-all="openCloseHeader"
:max-size="10"
/>
</template>
<template #action-list>
<div class="flex items-center gap-2">
<ElButton circle @click="openCloseHeader">
<ElButton circle @click="triggerFileSelect">
<ElIcon><Paperclip /></ElIcon>
</ElButton>
<!-- <ElButton :icon="PaperclipIcon" link /> -->

View File

@@ -8,11 +8,15 @@ import { JsonViewer } from 'vue3-json-viewer';
import 'vue3-json-viewer/dist/vue3-json-viewer.css';
defineProps({
value: {
required: true,
withDefaults(
defineProps<{
value: unknown;
plain?: boolean;
}>(),
{
plain: false,
},
});
);
const emptyImageUrl = `${import.meta.env.BASE_URL || '/'}empty.png`;
const themeMode = ref(preferences.theme.mode);
@@ -25,7 +29,7 @@ watch(
</script>
<template>
<div class="res-container">
<div class="res-container" :class="{ 'is-plain': plain }">
<JsonViewer v-if="value" :value="value" copyable :theme="themeMode" />
<ElEmpty :image="emptyImageUrl" v-else />
</div>
@@ -37,4 +41,11 @@ watch(
border-radius: var(--el-border-radius-base);
padding: 10px;
}
.res-container.is-plain {
padding: 0;
background: transparent;
border: none;
border-radius: 0;
}
</style>

View File

@@ -33,6 +33,7 @@ type SelfFilesCardProps = {
} & FilesCardProps;
const files = ref<SelfFilesCardProps[]>([]);
const fileInputRef = ref<HTMLInputElement>();
/**
* 上传前校验
*/
@@ -45,30 +46,11 @@ function handleBeforeUpload(file: File) {
return true;
}
/**
* 拖拽上传处理
*/
async function handleUploadDrop(dropFiles: File[]) {
if (dropFiles?.length) {
if (dropFiles[0]?.type === '') {
ElMessage.error('禁止上传文件夹!');
return false;
}
for (const file of dropFiles) {
if (handleBeforeUpload(file)) {
await handleHttpRequest({ file });
}
}
}
}
/**
* 自定义上传请求
*/
async function handleHttpRequest(options: { file: File }) {
const { file } = options;
const formData = new FormData();
formData.append('file', file);
try {
const res = await api.upload(props.action, { file }, {});
@@ -104,6 +86,18 @@ async function uploadExternalFiles(fileList: File[]) {
}
}
/**
* 处理原生文件选择
*/
async function handleFileInputChange(event: Event) {
const target = event.target as HTMLInputElement;
const selectedFiles = Array.from(target.files || []);
if (selectedFiles.length > 0) {
await uploadExternalFiles(selectedFiles);
}
target.value = '';
}
/**
* 组件内部完成删除逻辑
*/
@@ -122,6 +116,13 @@ function getFileList(): string[] {
return files.value.map((file) => file.url);
}
/**
* 触发原生文件选择
*/
function triggerFileSelect() {
fileInputRef.value?.click();
}
watch(
() => props.externalFiles,
async (newFiles) => {
@@ -135,6 +136,7 @@ watch(
defineExpose({
getFileList,
triggerFileSelect,
clearFiles() {
files.value = [];
},
@@ -142,17 +144,20 @@ defineExpose({
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 12px">
<div v-if="files.length > 0" style="display: flex; flex-direction: column; gap: 12px">
<Attachments
:http-request="handleHttpRequest"
:items="files"
drag
:before-upload="handleBeforeUpload"
:hide-upload="false"
@upload-drop="handleUploadDrop"
:hide-upload="true"
@delete-card="handleDeleteCard"
/>
</div>
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="handleFileInputChange"
/>
</template>
<style scoped lang="less"></style>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { ChatTimeTimelineItem } from '@easyflow/types';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { cloneDeep } from '@easyflow/utils';
import { ArrowLeft, Minus, Plus } from '@element-plus/icons-vue';
import {
ElAside,
@@ -81,40 +81,12 @@ function removeBotFromRecentlyUsed(botId: any) {
}
});
}
const messageList = ref<any>([]);
function addMessage(message: any) {
messageList.value.push(message);
const messageList = ref<ChatTimeTimelineItem[]>([]);
function mutateMessages(mutator: (messages: ChatTimeTimelineItem[]) => void) {
const next = [...messageList.value];
mutator(next);
messageList.value = next;
}
function updateLastMessage(item: any) {
const lastIndex = messageList.value.length - 1;
let message = item;
if (typeof item === 'function') {
message = item(messageList.value[lastIndex]);
}
if (lastIndex >= 0) {
messageList.value[lastIndex] = {
...messageList.value[lastIndex],
...message,
};
}
}
const stopThinking = () => {
const lastIndex = messageList.value.length - 1;
if (lastIndex >= 0 && messageList.value[lastIndex]?.chains) {
const chains = cloneDeep(messageList.value[lastIndex].chains);
for (const chain of chains) {
if (!('id' in chain) && chain.thinkingStatus === 'thinking') {
chain.thinkingStatus = 'end';
}
}
messageList.value[lastIndex].chains = chains;
}
};
</script>
<template>
@@ -145,11 +117,9 @@ const stopThinking = () => {
<ChatBubbleList v-else :bot="botInfo" :messages="messageList" />
<ChatSender
class="absolute bottom-5 left-0 w-full"
:add-message="addMessage"
:update-last-message="updateLastMessage"
:stop-thinking="stopThinking"
:bot="botInfo"
:conversation-id="conversationId"
:mutate-messages="mutateMessages"
/>
</div>
</ElMain>

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import type { ChatTimeTimelineItem } from '@easyflow/types';
import { onMounted, ref } from 'vue';
import { IconifyIcon } from '@easyflow/icons';
import { cloneDeep, cn } from '@easyflow/utils';
import { cn } from '@easyflow/utils';
import { ElAside, ElContainer, ElMain } from 'element-plus';
@@ -34,43 +36,15 @@ function getAssistantList() {
}
});
}
const messageList = ref<any>([]);
function addMessage(message: any) {
messageList.value.push(message);
}
function updateLastMessage(item: any) {
const lastIndex = messageList.value.length - 1;
let message = item;
if (typeof item === 'function') {
message = item(messageList.value[lastIndex]);
}
if (lastIndex >= 0) {
messageList.value[lastIndex] = {
...messageList.value[lastIndex],
...message,
};
}
}
const stopThinking = () => {
const lastIndex = messageList.value.length - 1;
if (lastIndex >= 0 && messageList.value[lastIndex]?.chains) {
const chains = cloneDeep(messageList.value[lastIndex].chains);
for (const chain of chains) {
if (!('id' in chain) && chain.thinkingStatus === 'thinking') {
chain.thinkingStatus = 'end';
}
}
messageList.value[lastIndex].chains = chains;
}
};
function setMessageList(messages: any) {
const messageList = ref<ChatTimeTimelineItem[]>([]);
function setMessageList(messages: ChatTimeTimelineItem[]) {
messageList.value = messages;
}
function mutateMessages(mutator: (messages: ChatTimeTimelineItem[]) => void) {
const next = [...messageList.value];
mutator(next);
messageList.value = next;
}
const isFold = ref(false);
const toggleFold = () => {
isFold.value = !isFold.value;
@@ -95,11 +69,9 @@ const toggleFold = () => {
<ChatBubbleList :bot="currentBot" :messages="messageList" />
<div class="mx-auto w-full max-w-[1000px]">
<ChatSender
:add-message="addMessage"
:update-last-message="updateLastMessage"
:stop-thinking="stopThinking"
:bot="currentBot"
:conversation-id="conversationId"
:mutate-messages="mutateMessages"
/>
</div>
</div>

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
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 { Delete, Edit, Search } from '@element-plus/icons-vue';
import {
ElButton,
@@ -43,7 +46,8 @@ const pageState = ref({
const drawerVisible = ref(false);
const drawerLoading = ref(false);
const currentSession = ref<any>();
const messageList = ref<any[]>([]);
const messageList = ref<ChatTimeTimelineItem[]>([]);
const loadedMessageRecordCount = ref(0);
const messagePage = ref({
total: 0,
pageNumber: 1,
@@ -123,6 +127,7 @@ async function openSession(sessionId: string | number) {
}
currentSession.value = summaryRes.data;
messageList.value = [];
loadedMessageRecordCount.value = 0;
messagePage.value = {
total: 0,
pageNumber: 1,
@@ -162,32 +167,22 @@ async function loadMessages(reset = false) {
} else {
messageList.value = [...normalized, ...messageList.value];
}
loadedMessageRecordCount.value = reset
? (res.data?.records || []).length
: loadedMessageRecordCount.value + (res.data?.records || []).length;
messagePage.value.total = res.data?.total || 0;
messagePage.value.pageNumber = nextPageNumber;
}
function normalizeMessages(records: any[]) {
return [...records]
.reverse()
.map((item: any) => ({
key: String(item.id),
role: item.senderRole === 'assistant' ? 'assistant' : 'user',
content:
item.senderRole === 'assistant'
? String(item.contentText || '').replace(/^Final Answer:\s*/i, '')
: item.contentText,
placement: item.senderRole === 'assistant' ? 'start' : 'end',
created: item.created,
chains: Array.isArray(item.contentPayload?.chains)
? item.contentPayload.chains
: undefined,
}));
return ChatTimeHistoryMapper.fromHistoryRecords([...records].reverse());
}
function closeDrawer() {
drawerVisible.value = false;
currentSession.value = undefined;
messageList.value = [];
loadedMessageRecordCount.value = 0;
router.replace({ path: '/chatHistory', query: {} });
}
@@ -349,7 +344,7 @@ function formatTime(value?: string) {
<div class="flex-1 overflow-hidden">
<div class="mb-4 flex justify-center">
<ElButton
v-if="messageList.length < messagePage.total"
v-if="loadedMessageRecordCount < messagePage.total"
text
type="primary"
@click="loadMessages(false)"