feat: 重构聊天时间线与附件上传交互
- 管理端和用户中心统一切换到 chatTime 时间线模型,按真实 assistant/tool 时序渲染 - 收紧工具气泡与 JSON 展示样式,保留同气泡内的工具状态更新 - 回形针直接触发文件选择,附件列表上移到输入框上方并补充共享 helper 测试
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user