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>