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

@@ -0,0 +1,223 @@
<script setup lang="ts">
import type {
ChatTimeAssistantSegment,
ChatTimeTimelineItem,
} from '@easyflow/types';
import { computed, ref } from 'vue';
import { XMarkdown as ElXMarkdown } from 'vue-element-plus-x';
import { ChatThinkingBlock } from '@easyflow/common-ui';
import { IconifyIcon } from '@easyflow/icons';
import { CircleCheck } from '@element-plus/icons-vue';
import { ElIcon } from 'element-plus';
import ShowJson from '#/components/json/ShowJson.vue';
interface Props {
item: ChatTimeTimelineItem;
readonlyThinking?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
readonlyThinking: false,
});
const toolExpanded = ref(false);
const renderableSegments = computed(() => {
if (props.item.role !== 'assistant') {
return [] as ChatTimeAssistantSegment[];
}
if (props.item.segments.length > 0) {
return props.item.segments;
}
if (!props.item.content) {
return [] as ChatTimeAssistantSegment[];
}
return [
{
content: props.item.content,
id: `${props.item.id}-fallback-text`,
type: 'text' as const,
},
] satisfies ChatTimeAssistantSegment[];
});
function getToolName() {
if (props.item.role !== 'tool') {
return '';
}
return props.item.name || '工具调用';
}
const hasToolDetails = computed(() => {
return props.item.role === 'tool' && Boolean(props.item.arguments || props.item.result);
});
function toggleToolExpanded() {
if (!hasToolDetails.value) {
return;
}
toolExpanded.value = !toolExpanded.value;
}
</script>
<template>
<template v-if="item.role === 'assistant'">
<div class="flex flex-col gap-2">
<template v-for="segment in renderableSegments" :key="segment.id">
<ChatThinkingBlock
v-if="segment.type === 'thinking'"
v-model:expanded="segment.expanded"
:content="segment.content"
:readonly="readonlyThinking"
: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': toolExpanded }">
<button
type="button"
class="chat-tool-header"
:class="{ 'is-clickable': hasToolDetails }"
@click="toggleToolExpanded"
>
<div class="chat-tool-title">
<ElIcon size="14">
<IconifyIcon icon="svg:wrench" />
</ElIcon>
<span class="chat-tool-title-text">{{ getToolName() }}</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"
size="14"
class="chat-tool-arrow"
:class="{ 'is-expanded': toolExpanded }"
>
<IconifyIcon icon="ep:arrow-right" />
</ElIcon>
</div>
</button>
<div v-if="toolExpanded && hasToolDetails" 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>
<style scoped>
.chat-thinking-block-item {
margin-bottom: 0;
}
.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;
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-tool-meta {
display: inline-flex;
flex-shrink: 0;
gap: 6px;
align-items: center;
}
.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;
}
.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

@@ -5,8 +5,7 @@ import type {
} from 'vue-element-plus-x/types/BubbleList';
import type { TypewriterInstance } from 'vue-element-plus-x/types/Typewriter';
import type { ChatThinkingBlockStatus } from '@easyflow/common-ui';
import type { BotInfo, ChatMessage } from '@easyflow/types';
import type { BotInfo, ChatTimeTimelineItem } from '@easyflow/types';
import {
nextTick,
@@ -18,59 +17,36 @@ import {
} from 'vue';
import ElBubbleList from 'vue-element-plus-x/es/BubbleList/index.js';
import ElSender from 'vue-element-plus-x/es/Sender/index.js';
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
import { useRoute, useRouter } from 'vue-router';
import { ChatThinkingBlock } from '@easyflow/common-ui';
import { IconifyIcon } from '@easyflow/icons';
import { $t } from '@easyflow/locales';
import { useBotStore } from '@easyflow/stores';
import { cloneDeep, cn, uuid } from '@easyflow/utils';
import {
ChatTimeHistoryMapper,
ChatTimeTimelineBuilder,
cn,
uuid,
} from '@easyflow/utils';
import {
ArrowDownBold,
CircleCheck,
CopyDocument,
Paperclip,
RefreshRight,
} from '@element-plus/icons-vue';
import {
ElButton,
ElCollapse,
ElCollapseItem,
ElIcon,
ElMessage,
ElSpace,
} from 'element-plus';
import { ElButton, ElIcon, ElMessage, ElSpace } from 'element-plus';
import { tryit } from 'radash';
import { getMessageList, getPerQuestions } from '#/api';
import { api, sseClient } from '#/api/request';
import ChatTimeMessageContent from '#/components/chat/ChatTimeMessageContent.vue';
import SendEnableIcon from '#/components/icons/SendEnableIcon.vue';
import SendIcon from '#/components/icons/SendIcon.vue';
import ShowJson from '#/components/json/ShowJson.vue';
import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
import BotAvatar from '../botAvatar/botAvatar.vue';
import SendingIcon from '../icons/SendingIcon.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 = ChatMessage & {
chains?: (Think | Tool)[];
};
const props = defineProps<{
bot?: BotInfo;
conversationId?: string;
@@ -90,7 +66,7 @@ const route = useRoute();
const botId = ref<string>((route.params.id as string) || '');
const router = useRouter();
const bubbleItems = ref<BubbleListProps<MessageItem>['list']>([]);
const bubbleItems = ref<BubbleListProps<ChatTimeTimelineItem>['list']>([]);
const bubbleListRef = ref<BubbleListInstance>();
const messageContainerRef = ref<HTMLElement | null>(null);
const bubbleListScrollElement = ref<HTMLElement | null>(null);
@@ -153,14 +129,9 @@ watchEffect(async () => {
});
if (res?.errorCode === 0) {
bubbleItems.value = res.data.map((item) => ({
...item,
content:
item.role === 'assistant'
? item.content.replace(/^Final Answer:\s*/i, '')
: item.content,
placement: item.role === 'assistant' ? 'start' : 'end',
}));
bubbleItems.value = ChatTimeHistoryMapper.fromHistoryRecords(
res.data as any[],
);
}
} else {
bubbleItems.value = [];
@@ -207,31 +178,17 @@ const bindBubbleListScroll = () => {
}
updateBackToBottomButtonVisible();
};
const updateLastBubbleItem = (patch: Partial<MessageItem>) => {
const lastIndex = bubbleItems.value.length - 1;
if (lastIndex < 0) {
return;
}
bubbleItems.value[lastIndex] = {
...bubbleItems.value[lastIndex]!,
...patch,
};
};
const finalizeLastBubbleItem = () => {
updateLastBubbleItem({
loading: false,
typing: false,
});
const finalizeTimelineTail = () => {
ChatTimeTimelineBuilder.finalize(bubbleItems.value);
};
const stopSse = () => {
sseClient.abort();
sending.value = false;
finalizeLastBubbleItem();
finalizeTimelineTail();
};
const clearSenderFiles = () => {
files.value = [];
attachmentsRef.value?.clearFiles();
openCloseHeader();
};
const handleSubmit = async (refreshContent: string) => {
const attachments = attachmentsRef.value?.getFileList();
@@ -261,14 +218,11 @@ const handleSubmit = async (refreshContent: string) => {
sseClient.post('/api/v1/bot/chat', data, {
onMessage(message) {
const event = message.event;
const lastIndex = bubbleItems.value.length - 1;
const lastBubbleItem = bubbleItems.value[lastIndex];
// finish
if (event === 'done') {
sending.value = false;
finalizeLastBubbleItem();
stopThinking();
finalizeTimelineTail();
return;
}
if (!message.data) {
@@ -280,46 +234,30 @@ const handleSubmit = async (refreshContent: string) => {
sseData?.domain === 'SYSTEM' &&
sseData.payload?.code === 'SYSTEM_ERROR'
) {
const errorMessage = sseData.payload.message;
if (!lastBubbleItem) return;
bubbleItems.value[lastIndex] = {
...lastBubbleItem,
content: errorMessage,
loading: false,
typing: false,
};
ChatTimeTimelineBuilder.applySystemError(
bubbleItems.value,
sseData.payload.message,
Date.now(),
);
return;
}
if (lastIndex >= 0 && sseData?.domain === 'TOOL') {
const chains = cloneDeep(lastBubbleItem?.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,
if (sseData?.domain === 'TOOL') {
if (sseData?.type === 'TOOL_CALL') {
ChatTimeTimelineBuilder.upsertToolCall(bubbleItems.value, {
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,
};
ChatTimeTimelineBuilder.upsertToolResult(bubbleItems.value, {
created: Date.now(),
name: sseData?.payload?.name,
result: sseData?.payload?.result,
toolCallId: sseData?.payload?.tool_call_id,
});
}
bubbleItems.value[lastIndex]!.chains = chains;
stopThinking();
return;
}
@@ -327,38 +265,19 @@ const handleSubmit = async (refreshContent: string) => {
const delta = sseData.payload?.delta;
const role = sseData.payload?.role;
if (lastBubbleItem && delta) {
if (delta) {
if (sseData.type === 'THINKING') {
const chains = cloneDeep(lastBubbleItem?.chains ?? []);
const index = chains.findIndex(
(chain) => isThink(chain) && chain.thinkingStatus === 'thinking',
ChatTimeTimelineBuilder.appendThinkingDelta(
bubbleItems.value,
delta,
Date.now(),
);
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,
};
}
bubbleItems.value[lastIndex]!.chains = chains;
} else if (sseData.type === 'MESSAGE') {
bubbleItems.value[lastIndex] = {
...lastBubbleItem,
content: (lastBubbleItem.content + delta).replaceAll(
'```echartsoption',
'```echarts\noption',
),
loading: false,
typing: true,
};
stopThinking();
ChatTimeTimelineBuilder.appendMessageDelta(
bubbleItems.value,
delta,
Date.now(),
);
}
}
@@ -372,39 +291,16 @@ const handleSubmit = async (refreshContent: string) => {
},
onFinished() {
sending.value = false;
finalizeLastBubbleItem();
stopThinking();
finalizeTimelineTail();
},
onError(err) {
console.error(err);
sending.value = false;
finalizeLastBubbleItem();
finalizeTimelineTail();
},
});
};
const isTool = (item: Think | Tool) => {
return 'id' in item;
};
const isThink = (item: Think | Tool): item is Think => {
return !('id' in item);
};
const stopThinking = () => {
const lastIndex = bubbleItems.value.length - 1;
if (lastIndex >= 0 && bubbleItems.value[lastIndex]?.chains) {
const chains = cloneDeep(bubbleItems.value[lastIndex].chains);
for (const chain of chains) {
if (isThink(chain) && chain.thinkingStatus === 'thinking') {
chain.thinkingStatus = 'end';
}
}
bubbleItems.value[lastIndex].chains = chains;
}
};
const handleComplete = (_: TypewriterInstance, index: number) => {
if (
index === bubbleItems.value.length - 1 &&
@@ -421,27 +317,14 @@ const handleComplete = (_: TypewriterInstance, index: number) => {
};
const generateMockMessages = (refreshContent: string) => {
const userMessage: MessageItem = {
const userMessage: ChatTimeTimelineItem = {
role: 'user',
id: Date.now().toString(),
fileList: [],
content: refreshContent || senderValue.value,
created: Date.now(),
updateAt: Date.now(),
placement: 'end',
};
const assistantMessage: MessageItem = {
role: 'assistant',
id: Date.now().toString(),
content: '',
loading: true,
created: Date.now(),
updateAt: Date.now(),
placement: 'start',
};
return [userMessage, assistantMessage];
return [userMessage];
};
const handleCopy = (content: string) => {
@@ -462,23 +345,17 @@ const scrollToBottom = () => {
}
showBackToBottomButton.value = false;
};
const showHeaderFlog = ref(false);
function openCloseHeader() {
if (showHeaderFlog.value) {
senderRef.value?.closeHeader();
files.value = [];
} else {
senderRef.value?.openHeader();
}
showHeaderFlog.value = !showHeaderFlog.value;
}
const attachmentsRef = ref();
const files = ref<any[]>([]);
function handlePasteFile(_: any, fileList: FileList) {
showHeaderFlog.value = true;
senderRef.value?.openHeader();
files.value = [...fileList];
}
function triggerFileSelect() {
attachmentsRef.value?.triggerFileSelect?.();
}
function handleDeleteAllSenderFiles() {
files.value = [];
}
watch(
() => [localeConversationId.value, bubbleItems.value.length],
() => {
@@ -527,78 +404,30 @@ onBeforeUnmount(() => {
<span class="chat-bubble-item-time-style">
{{ new Date(item.created).toLocaleString() }}
</span>
<template v-if="item.chains">
<template
v-for="(chain, index) in item.chains"
:key="chain.id || index"
>
<ChatThinkingBlock
v-if="isThink(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>{{ $t('bot.Running') }}...</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>{{ $t('bot.Completed') }}</span>
</div>
</template>
</div>
</template>
<ShowJson :value="chain.result" />
</ElCollapseItem>
</ElCollapse>
</template>
</template>
</div>
</template>
<!-- 自定义头像 -->
<template #avatar="{ item }">
<BotAvatar
v-if="item.role === 'assistant'"
v-if="item.role !== 'user'"
:src="bot?.icon"
:size="40"
/>
</template>
<template #content="{ item }">
<ElXMarkdown :markdown="item.content" />
<ChatTimeMessageContent :item="item" />
</template>
<!-- 自定义底部 -->
<template #footer="{ item }">
<ElSpace :size="10">
<ElSpace>
<ElSpace v-if="item.role !== 'tool'" :size="10">
<ElSpace v-if="item.role === 'assistant'">
<span @click="handleRefresh()" style="cursor: pointer">
<ElIcon>
<RefreshRight />
</ElIcon>
</span>
</ElSpace>
<ElSpace>
<span @click="handleCopy(item.content)" style="cursor: pointer">
<ElIcon>
<CopyDocument />
@@ -643,6 +472,12 @@ onBeforeUnmount(() => {
</ElButton>
</div>
<!-- Sender -->
<ChatFileUploader
ref="attachmentsRef"
:external-files="files"
:max-size="10"
@delete-all="handleDeleteAllSenderFiles"
/>
<ElSender
ref="senderRef"
class="w-full"
@@ -654,18 +489,9 @@ onBeforeUnmount(() => {
@submit="handleSubmit"
@paste-file="handlePasteFile"
>
<!-- 自定义头部内容 -->
<template #header>
<ChatFileUploader
ref="attachmentsRef"
:external-files="files"
:max-size="10"
/>
</template>
<template #action-list>
<ElSpace>
<ElButton circle @click="openCloseHeader">
<ElButton circle @click="triggerFileSelect">
<ElIcon><Paperclip /></ElIcon>
</ElButton>
<!--<ElButton circle @click="uploadRef.triggerFileSelect()">
@@ -770,39 +596,4 @@ onBeforeUnmount(() => {
:deep(.el-bubble-end .el-bubble-header) {
width: fit-content;
}
.chat-thinking-block-item {
margin-bottom: 8px;
}
.chat-tool-panel {
margin-bottom: 8px;
}
:deep(.chat-tool-panel.el-collapse) {
background: hsl(var(--surface-panel) / 70%);
border: 1px solid hsl(var(--divider-faint) / 26%);
border-radius: 14px;
box-shadow: inset 0 1px 0 hsl(var(--glass-border) / 20%);
}
:deep(.chat-tool-panel .el-collapse-item) {
overflow: hidden;
border-radius: 14px;
}
:deep(.chat-tool-panel .el-collapse-item__wrap) {
background: transparent;
}
:deep(.chat-tool-panel .el-collapse-item__header) {
min-height: 44px;
padding-right: 14px;
background: transparent;
border-bottom-color: hsl(var(--divider-faint) / 16%);
}
:deep(.chat-tool-panel .el-collapse-item__content) {
padding-bottom: 0;
}
</style>