Files
EasyFlow/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue
陈子默 d45c67a317 feat: 支持智能体文档附件与轻量读取
- 建立文档上传、异步读取、对象存储、补偿与聊天绑定闭环

- 按智能体 20K 上下文预算选择文档片段并保留稳定引用

- 统一聊天文件卡片、类型图标、草稿恢复与可靠下载
2026-07-29 01:32:19 +08:00

213 lines
5.6 KiB
Vue

<script setup lang="ts">
import type {
ChatDocumentLoader,
ChatImageLoader,
ChatTimelineItem as ChatTimelineItemType,
ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload,
} from './types';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import ChatTimelineItem from './ChatTimelineItem.vue';
const props = defineProps<{
approvalLoading?: boolean;
copyable?: (item: ChatTimelineMessageItem) => boolean;
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
documentLoader?: ChatDocumentLoader;
emptyText?: string;
imageLoader?: ChatImageLoader;
items: ChatTimelineItemType[];
regenerable?: (item: ChatTimelineMessageItem) => boolean;
regenerateDisabled?: boolean;
variantLoading?: (item: ChatTimelineMessageItem) => boolean;
}>();
const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload];
copyMessage: [item: ChatTimelineMessageItem];
regenerateMessage: [item: ChatTimelineMessageItem];
reject: [payload: ChatTimelineToolApprovalPayload];
selectNextVariant: [item: ChatTimelineMessageItem];
selectPreviousVariant: [item: ChatTimelineMessageItem];
}>();
const containerRef = ref<HTMLElement>();
const isPinnedToBottom = ref(true);
const suppressNextAutoScroll = ref(false);
let preservedScrollTop: number | undefined;
const bottomThreshold = 24;
let scrollFrame = 0;
const assistantActionAnchorIds = computed(() => {
const latestAssistantByRound = new Map<string, string>();
for (const item of props.items) {
if (item.type === 'message' && item.role === 'assistant' && item.roundId) {
latestAssistantByRound.set(item.roundId, item.id);
}
}
return new Set(latestAssistantByRound.values());
});
function isNearBottom(container: HTMLElement) {
return (
container.scrollHeight - container.scrollTop - container.clientHeight <=
bottomThreshold
);
}
function updatePinnedState() {
const container = containerRef.value;
if (!container) {
return;
}
isPinnedToBottom.value = isNearBottom(container);
}
function scrollToBottom() {
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
}
scrollFrame = requestAnimationFrame(() => {
const container = containerRef.value;
if (!container) {
return;
}
container.scrollTop = container.scrollHeight;
updatePinnedState();
});
}
function handleTimelineScroll() {
updatePinnedState();
}
function handleThinkingToggle() {
preservedScrollTop = containerRef.value?.scrollTop;
suppressNextAutoScroll.value = true;
}
function canCopyMessage(item: ChatTimelineItemType) {
return item.type === 'message' && (props.copyable?.(item) ?? false);
}
function canRegenerateMessage(item: ChatTimelineItemType) {
return item.type === 'message' && (props.regenerable?.(item) ?? false);
}
function isAssistantActionAnchor(item: ChatTimelineItemType) {
return (
item.type === 'message' &&
item.role === 'assistant' &&
Boolean(item.roundId) &&
assistantActionAnchorIds.value.has(item.id)
);
}
function isVariantLoading(item: ChatTimelineItemType) {
return item.type === 'message' && (props.variantLoading?.(item) ?? false);
}
onBeforeUnmount(() => {
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
}
});
watch(
() => props.items,
async () => {
if (suppressNextAutoScroll.value) {
suppressNextAutoScroll.value = false;
await nextTick();
if (preservedScrollTop !== undefined && containerRef.value) {
containerRef.value.scrollTop = preservedScrollTop;
}
preservedScrollTop = undefined;
updatePinnedState();
return;
}
if (isPinnedToBottom.value) {
scrollToBottom();
}
},
{ deep: true, immediate: true },
);
</script>
<template>
<div
ref="containerRef"
class="chat-timeline"
@scroll.passive="handleTimelineScroll"
>
<div v-if="items.length === 0" class="chat-timeline__empty">
<div class="chat-timeline__empty-icon" aria-hidden="true"></div>
<div class="chat-timeline__empty-text">
{{ emptyText || '开始对话' }}
</div>
</div>
<template v-else>
<ChatTimelineItem
v-for="item in items"
:key="item.id"
:assistant-actions-visible="isAssistantActionAnchor(item)"
:item="item"
:document-loader="documentLoader"
:image-loader="imageLoader"
:approval-loading="approvalLoading"
:copy-action="copyAction"
:copyable="canCopyMessage(item)"
:regenerable="canRegenerateMessage(item)"
:regenerate-disabled="regenerateDisabled"
:variant-loading="isVariantLoading(item)"
@approve="emit('approve', $event)"
@copy-message="emit('copyMessage', $event)"
@regenerate-message="emit('regenerateMessage', $event)"
@reject="emit('reject', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
@thinking-toggle="handleThinkingToggle"
/>
</template>
</div>
</template>
<style scoped>
.chat-timeline {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
padding: 16px;
overflow: auto;
}
.chat-timeline__empty-icon {
width: 72px;
height: 72px;
margin: 0 auto;
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
border-radius: 8px;
}
.chat-timeline__empty {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
min-height: 180px;
color: var(--el-text-color-secondary);
}
.chat-timeline__empty-text {
font-size: 13px;
line-height: 20px;
}
</style>