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,124 @@
<script setup lang="ts">
import type {AiChatMessage, AiToolApprovalPayload} from './types';
import {Close} from '@element-plus/icons-vue';
import {ElButton} from 'element-plus';
import AiConversation from './AiConversation.vue';
import AiPromptInput from './AiPromptInput.vue';
defineProps<{
approvalLoading?: boolean;
closable?: boolean;
emptyText?: string;
loading?: boolean;
messages: AiChatMessage[];
subtitle?: string;
title: string;
}>();
const emit = defineEmits<{
approve: [payload: AiToolApprovalPayload];
close: [];
reject: [payload: AiToolApprovalPayload];
send: [text: string];
stop: [];
}>();
defineSlots<{
default?: () => any;
headerActions?: () => any;
}>();
</script>
<template>
<section class="ai-chat-panel">
<header class="ai-chat-panel__header">
<div class="ai-chat-panel__title-wrap">
<div class="ai-chat-panel__title">{{ title }}</div>
<div v-if="subtitle" class="ai-chat-panel__subtitle">
{{ subtitle }}
</div>
</div>
<div class="ai-chat-panel__actions">
<slot name="headerActions"></slot>
<ElButton
v-if="closable"
:icon="Close"
circle
text
aria-label="关闭"
@click="emit('close')"
/>
</div>
</header>
<slot>
<AiConversation
:messages="messages"
:empty-text="emptyText"
:approval-loading="approvalLoading"
@approve="emit('approve', $event)"
@reject="emit('reject', $event)"
/>
</slot>
<AiPromptInput
:loading="loading"
@send="emit('send', $event)"
@stop="emit('stop')"
/>
</section>
</template>
<style scoped>
.ai-chat-panel {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.ai-chat-panel__header {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
min-height: 48px;
padding: 8px 14px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.ai-chat-panel__title-wrap {
flex: 1;
min-width: 0;
}
.ai-chat-panel__actions {
display: inline-flex;
flex: 0 0 auto;
gap: 2px;
align-items: center;
}
.ai-chat-panel__actions :deep(.el-button.is-circle) {
width: 32px;
height: 32px;
}
.ai-chat-panel__title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.ai-chat-panel__subtitle {
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import type {AiChatMessage, AiToolApprovalPayload} from './types';
import {ref, watch} from 'vue';
import {ChatDotRound} from '@element-plus/icons-vue';
import {ElEmpty} from 'element-plus';
import AiMessage from './AiMessage.vue';
import {useAiChatScroll} from './composables/useAiChatScroll';
const props = defineProps<{
approvalLoading?: boolean;
emptyText?: string;
messages: AiChatMessage[];
}>();
const emit = defineEmits<{
approve: [payload: AiToolApprovalPayload];
reject: [payload: AiToolApprovalPayload];
}>();
const containerRef = ref<HTMLElement>();
const { scrollToBottom } = useAiChatScroll(containerRef);
watch(
() => props.messages,
() => scrollToBottom(),
{ deep: true, immediate: true },
);
</script>
<template>
<div ref="containerRef" class="ai-conversation">
<ElEmpty
v-if="messages.length === 0"
:image-size="88"
:description="emptyText || '开始试用当前草稿'"
>
<template #image>
<div class="ai-conversation__empty-icon">
<ChatDotRound />
</div>
</template>
</ElEmpty>
<AiMessage
v-for="message in messages"
v-else
:key="message.id"
:message="message"
:approval-loading="approvalLoading"
@approve="emit('approve', $event)"
@reject="emit('reject', $event)"
/>
</div>
</template>
<style scoped>
.ai-conversation {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
padding: 16px;
overflow: auto;
}
.ai-conversation__empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
margin: 0 auto;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-radius: 8px;
}
.ai-conversation__empty-icon svg {
width: 32px;
height: 32px;
}
</style>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import {WarningFilled} from '@element-plus/icons-vue';
import {ElIcon} from 'element-plus';
defineProps<{ message: string }>();
</script>
<template>
<div class="ai-error-notice">
<ElIcon><WarningFilled /></ElIcon>
<span>{{ message }}</span>
</div>
</template>
<style scoped>
.ai-error-notice {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 12px;
font-size: 13px;
line-height: 20px;
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import type {AiKnowledgeHit} from './types';
defineProps<{ items: AiKnowledgeHit[] }>();
function resolveTitle(item: AiKnowledgeHit, index: number) {
return item.title || item.source || `知识片段 ${index + 1}`;
}
</script>
<template>
<div class="ai-knowledge-card">
<div class="ai-knowledge-card__title">知识检索</div>
<div
v-for="(item, index) in items"
:key="item.id || index"
class="ai-knowledge-card__item"
>
<div class="ai-knowledge-card__item-title">
{{ resolveTitle(item, index) }}
</div>
<div v-if="item.content" class="ai-knowledge-card__content">
{{ item.content }}
</div>
<div v-if="item.score" class="ai-knowledge-card__score">
相似度 {{ item.score }}
</div>
</div>
</div>
</template>
<style scoped>
.ai-knowledge-card {
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 8px;
}
.ai-knowledge-card__title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.ai-knowledge-card__item + .ai-knowledge-card__item {
padding-top: 8px;
margin-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
}
.ai-knowledge-card__item-title {
font-size: 13px;
font-weight: 500;
}
.ai-knowledge-card__content,
.ai-knowledge-card__score {
margin-top: 4px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import type {AiChatMessage, AiToolApprovalPayload} from './types';
import AiErrorNotice from './AiErrorNotice.vue';
import AiKnowledgeCard from './AiKnowledgeCard.vue';
import AiReasoningCard from './AiReasoningCard.vue';
import AiToolApprovalCard from './AiToolApprovalCard.vue';
import AiToolCallCard from './AiToolCallCard.vue';
defineProps<{
approvalLoading?: boolean;
message: AiChatMessage;
}>();
const emit = defineEmits<{
approve: [payload: AiToolApprovalPayload];
reject: [payload: AiToolApprovalPayload];
}>();
</script>
<template>
<div class="ai-message" :class="`ai-message--${message.role}`">
<div class="ai-message__bubble">
<template v-for="(part, index) in message.parts" :key="index">
<div v-if="part.type === 'text'" class="ai-message__text">
{{ part.text }}
</div>
<AiReasoningCard
v-else-if="part.type === 'reasoning'"
:text="part.text"
:collapsed="part.collapsed"
/>
<AiKnowledgeCard
v-else-if="part.type === 'knowledge'"
:items="part.items"
/>
<AiToolCallCard
v-else-if="part.type === 'tool_call'"
:tool-name="part.toolName"
:status="part.status"
:input="part.input"
:output="part.output"
/>
<AiToolApprovalCard
v-else-if="part.type === 'tool_approval'"
:request-id="part.requestId"
:resume-token="part.resumeToken"
:tool-name="part.toolName"
:tool-display-name="part.toolDisplayName"
:tool-call-id="part.toolCallId"
:tool-type="part.toolType"
:input="part.input"
:expires-at="part.expiresAt"
:metadata="part.metadata"
:loading="approvalLoading"
@approve="emit('approve', $event)"
@reject="emit('reject', $event)"
/>
<AiErrorNotice
v-else-if="part.type === 'error'"
:message="part.message"
/>
</template>
<span
v-if="message.status === 'streaming'"
class="ai-message__cursor"
></span>
</div>
</div>
</template>
<style scoped>
.ai-message {
display: flex;
width: 100%;
}
.ai-message--user {
justify-content: flex-end;
}
.ai-message--assistant,
.ai-message--system {
justify-content: flex-start;
}
.ai-message__bubble {
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(88%, 640px);
padding: 12px 14px;
color: var(--el-text-color-primary);
background: color-mix(in srgb, var(--el-bg-color) 88%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.ai-message--user .ai-message__bubble {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-7);
}
.ai-message__text {
font-size: 14px;
line-height: 22px;
white-space: pre-wrap;
}
.ai-message__cursor {
width: 6px;
height: 16px;
margin-left: 2px;
background: var(--el-color-primary);
border-radius: 2px;
animation: ai-chat-cursor 0.9s infinite;
}
@keyframes ai-chat-cursor {
50% {
opacity: 0.2;
}
}
</style>

View File

@@ -0,0 +1,33 @@
import {mount} from '@vue/test-utils';
import {describe, expect, it} from 'vitest';
import AiPromptInput from './AiPromptInput.vue';
describe('AiPromptInput', () => {
it('emits send when loading is false', async () => {
const wrapper = mount(AiPromptInput, {
props: {
loading: false,
},
});
await wrapper.find('textarea').setValue('你好');
await wrapper.find('[aria-label="发送"]').trigger('click');
expect(wrapper.emitted('send')?.[0]?.[0]).toBe('你好');
});
it('emits stop when loading is true', async () => {
const wrapper = mount(AiPromptInput, {
props: {
loading: true,
},
});
expect(wrapper.find('[aria-label="中止"]').exists()).toBe(true);
await wrapper.find('[aria-label="中止"]').trigger('click');
expect(wrapper.emitted('stop')).toBeTruthy();
});
});

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import {computed, ref} from 'vue';
import {Promotion} from '@element-plus/icons-vue';
import {ElButton, ElInput} from 'element-plus';
const props = defineProps<{
loading?: boolean;
placeholder?: string;
}>();
const emit = defineEmits<{
send: [text: string];
stop: [];
}>();
const text = ref('');
const canSend = computed(() => text.value.trim().length > 0 && !props.loading);
function send() {
const value = text.value.trim();
if (!value || props.loading) return;
emit('send', value);
text.value = '';
}
function stop() {
if (!props.loading) return;
emit('stop');
}
function handleKeydown(event: Event | KeyboardEvent) {
if (!(event instanceof KeyboardEvent)) return;
if (event.key !== 'Enter' || event.shiftKey) return;
event.preventDefault();
send();
}
</script>
<template>
<div class="ai-prompt-input">
<ElInput
v-model="text"
class="ai-prompt-input__textarea"
type="textarea"
resize="none"
:autosize="{ minRows: 1, maxRows: 5 }"
:disabled="loading"
:placeholder="placeholder || '输入消息'"
@keydown="handleKeydown"
/>
<ElButton
v-if="loading"
type="primary"
circle
aria-label="中止"
title="中止"
class="ai-prompt-input__action is-stop"
@click="stop"
/>
<ElButton
v-else
type="primary"
circle
:icon="Promotion"
:disabled="!canSend"
aria-label="发送"
class="ai-prompt-input__action"
@click="send"
/>
</div>
</template>
<style scoped>
.ai-prompt-input {
display: flex;
gap: 10px;
align-items: flex-end;
padding: 10px;
margin: 0 16px 16px;
background: color-mix(in srgb, var(--el-bg-color) 92%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
box-shadow: var(--el-box-shadow-lighter);
}
.ai-prompt-input__textarea {
flex: 1;
}
.ai-prompt-input__textarea :deep(.el-textarea__inner) {
min-height: 36px !important;
padding: 8px 10px;
line-height: 20px;
background: transparent;
border: none;
box-shadow: none;
}
.ai-prompt-input__action {
width: 36px;
height: 36px;
}
.ai-prompt-input__action.is-stop::before {
display: block;
width: 12px;
height: 12px;
content: '';
background: var(--el-color-white);
border-radius: 2px;
}
</style>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import {ref} from 'vue';
import {ArrowDown, ArrowRight} from '@element-plus/icons-vue';
import {ElButton, ElIcon} from 'element-plus';
const props = defineProps<{
collapsed?: boolean;
text: string;
}>();
const open = ref(!props.collapsed);
</script>
<template>
<div class="ai-reasoning-card">
<ElButton class="ai-reasoning-card__toggle" link @click="open = !open">
<ElIcon>
<ArrowDown v-if="open" />
<ArrowRight v-else />
</ElIcon>
<span>思考</span>
</ElButton>
<div v-if="open" class="ai-reasoning-card__body">{{ text }}</div>
</div>
</template>
<style scoped>
.ai-reasoning-card {
padding: 10px 12px;
color: var(--el-text-color-regular);
background: var(--el-fill-color-light);
border-radius: 8px;
}
.ai-reasoning-card__toggle {
height: 24px;
padding: 0;
color: var(--el-text-color-secondary);
}
.ai-reasoning-card__body {
margin-top: 8px;
font-size: 13px;
line-height: 20px;
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import type {AiToolApprovalPayload} from './types';
import {Check, Close, Key} from '@element-plus/icons-vue';
import {ElButton, ElIcon} from 'element-plus';
const props = defineProps<AiToolApprovalPayload & { loading?: boolean }>();
const emit = defineEmits<{
approve: [payload: AiToolApprovalPayload];
reject: [payload: AiToolApprovalPayload];
}>();
function payload(): AiToolApprovalPayload {
return {
requestId: props.requestId,
resumeToken: props.resumeToken,
toolName: props.toolName,
toolDisplayName: props.toolDisplayName,
toolCallId: props.toolCallId,
toolType: props.toolType,
input: props.input,
expiresAt: props.expiresAt,
metadata: props.metadata,
};
}
</script>
<template>
<div class="ai-tool-approval">
<div class="ai-tool-approval__head">
<ElIcon><Key /></ElIcon>
<div>
<div class="ai-tool-approval__title">
{{ toolDisplayName || toolName }}
</div>
<div class="ai-tool-approval__desc">需要确认后执行</div>
</div>
</div>
<pre v-if="input" class="ai-tool-approval__payload">{{ input }}</pre>
<div class="ai-tool-approval__actions">
<ElButton
:icon="Close"
:loading="loading"
@click="emit('reject', payload())"
>
拒绝
</ElButton>
<ElButton
type="primary"
:icon="Check"
:loading="loading"
@click="emit('approve', payload())"
>
批准
</ElButton>
</div>
</div>
</template>
<style scoped>
.ai-tool-approval {
padding: 12px;
background: var(--el-color-primary-light-9);
border-radius: 8px;
}
.ai-tool-approval__head {
display: flex;
gap: 8px;
align-items: center;
}
.ai-tool-approval__title {
font-size: 13px;
font-weight: 600;
}
.ai-tool-approval__desc {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.ai-tool-approval__payload {
max-height: 128px;
padding: 8px;
margin: 10px 0 0;
overflow: auto;
font-size: 12px;
line-height: 18px;
white-space: pre-wrap;
background: var(--el-bg-color);
border-radius: 6px;
}
.ai-tool-approval__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import {computed} from 'vue';
import {Tools} from '@element-plus/icons-vue';
import {ElIcon, ElTag} from 'element-plus';
const props = defineProps<{
input?: unknown;
output?: unknown;
status?: string;
toolName: string;
}>();
const statusText = computed(() => props.status || '执行中');
</script>
<template>
<div class="ai-tool-card">
<div class="ai-tool-card__head">
<div class="ai-tool-card__name">
<ElIcon><Tools /></ElIcon>
<span>{{ toolName }}</span>
</div>
<ElTag size="small" effect="plain">{{ statusText }}</ElTag>
</div>
<pre v-if="input" class="ai-tool-card__payload">{{ input }}</pre>
<pre v-if="output" class="ai-tool-card__payload">{{ output }}</pre>
</div>
</template>
<style scoped>
.ai-tool-card {
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 8px;
}
.ai-tool-card__head,
.ai-tool-card__name {
display: flex;
align-items: center;
}
.ai-tool-card__head {
justify-content: space-between;
}
.ai-tool-card__name {
gap: 6px;
min-width: 0;
font-size: 13px;
font-weight: 600;
}
.ai-tool-card__payload {
max-height: 128px;
padding: 8px;
margin: 10px 0 0;
overflow: auto;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
white-space: pre-wrap;
background: var(--el-bg-color);
border-radius: 6px;
}
</style>

View File

@@ -0,0 +1,13 @@
import type {Ref} from 'vue';
import {nextTick} from 'vue';
export function useAiChatScroll(containerRef: Ref<HTMLElement | undefined>) {
async function scrollToBottom() {
await nextTick();
const container = containerRef.value;
if (!container) return;
container.scrollTop = container.scrollHeight;
}
return { scrollToBottom };
}

View File

@@ -0,0 +1,45 @@
export type AiChatMessageRole = 'assistant' | 'system' | 'user';
export type AiChatMessageStatus = 'done' | 'error' | 'pending' | 'streaming';
export interface AiKnowledgeHit {
id?: string;
title?: string;
content?: string;
score?: number | string;
source?: string;
[key: string]: any;
}
export interface AiToolApprovalPayload {
requestId: string;
resumeToken: string;
toolName: string;
toolDisplayName?: string;
toolCallId?: string;
toolType?: string;
input?: unknown;
expiresAt?: string;
metadata?: unknown;
}
export type AiChatMessagePart =
| (AiToolApprovalPayload & { type: 'tool_approval' })
| { collapsed?: boolean; text: string; type: 'reasoning' }
| {
input?: unknown;
output?: unknown;
status?: string;
toolName: string;
type: 'tool_call';
}
| { items: AiKnowledgeHit[]; type: 'knowledge' }
| { message: string; type: 'error' }
| { text: string; type: 'text' };
export interface AiChatMessage {
id: string;
role: AiChatMessageRole;
status?: AiChatMessageStatus;
parts: AiChatMessagePart[];
createdAt?: number;
}

View File

@@ -1,16 +1,17 @@
<script setup lang="ts">
import type {
ChatTimeAssistantSegment,
ChatTimeAssistantTextSegment,
ChatTimeTimelineItem,
} from '@easyflow/types';
import { computed, ref } from 'vue';
import {computed, ref} from 'vue';
import { ChatThinkingBlock, ChatTimeMarkdown } from '@easyflow/common-ui';
import { IconifyIcon } from '@easyflow/icons';
import {ChatThinkingBlock, ChatTimeMarkdown} from '@easyflow/common-ui';
import {IconifyIcon} from '@easyflow/icons';
import { CircleCheck } from '@element-plus/icons-vue';
import { ElIcon } from 'element-plus';
import {CircleCheck} from '@element-plus/icons-vue';
import {ElIcon} from 'element-plus';
import ShowJson from '#/components/json/ShowJson.vue';
@@ -29,7 +30,22 @@ const renderableSegments = computed(() => {
return [] as ChatTimeAssistantSegment[];
}
if (props.item.segments.length > 0) {
return props.item.segments;
const textSegments = props.item.segments.filter(
(segment): segment is ChatTimeAssistantTextSegment =>
segment.type === 'text',
);
if (textSegments.length <= 1) {
return props.item.segments;
}
const mergedTextSegment: ChatTimeAssistantTextSegment = {
content: textSegments.map((segment) => segment.content).join(''),
id: `${props.item.id}-merged-text`,
type: 'text',
};
return [
...props.item.segments.filter((segment) => segment.type !== 'text'),
mergedTextSegment,
];
}
if (!props.item.content) {
return [] as ChatTimeAssistantSegment[];
@@ -74,7 +90,11 @@ function toggleToolExpanded() {
:status="segment.status"
class="chat-thinking-block-item"
/>
<ChatTimeMarkdown v-else :content="segment.content" />
<ChatTimeMarkdown
v-else
:content="segment.content"
:streaming="item.typing"
/>
</template>
</div>
</template>
@@ -124,7 +144,7 @@ function toggleToolExpanded() {
</div>
</template>
<ChatTimeMarkdown v-else :content="item.content" />
<ChatTimeMarkdown v-else :content="item.content" :streaming="item.typing" />
</template>
<style scoped>