feat: 全新智能体功能

- 基于先进智能体框架,增加智能体编排功能
- 增加智能体聊天,并对接持久化
This commit is contained in:
2026-05-25 11:42:48 +08:00
parent 6c3d98eaac
commit 72df00f25b
168 changed files with 22045 additions and 400 deletions

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import type {
ChatTimelineItem as ChatTimelineItemType,
ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload,
} from './types';
import {nextTick, onBeforeUnmount, ref, watch} from 'vue';
import ChatTimelineItem from './ChatTimelineItem.vue';
const props = defineProps<{
approvalLoading?: boolean;
copyable?: (item: ChatTimelineMessageItem) => boolean;
emptyText?: string;
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;
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,
index: number,
items: ChatTimelineItemType[],
) {
if (item.type !== 'message' || item.role !== 'assistant' || !item.roundId) {
return false;
}
for (let cursor = items.length - 1; cursor >= 0; cursor -= 1) {
const current = items[cursor];
if (
current &&
current.type === 'message' &&
current.role === 'assistant' &&
current.roundId === item.roundId
) {
return cursor === index;
}
}
return false;
}
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, index) in items"
:key="item.id"
:assistant-actions-visible="isAssistantActionAnchor(item, index, items)"
:item="item"
:approval-loading="approvalLoading"
: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>