213 lines
5.6 KiB
Vue
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>
|