feat: 全新智能体功能
- 基于先进智能体框架,增加智能体编排功能 - 增加智能体聊天,并对接持久化
This commit is contained in:
124
easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue
Normal file
124
easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
125
easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue
Normal file
125
easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue
Normal 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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
113
easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.vue
Normal file
113
easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
45
easyflow-ui-admin/app/src/components/ai-chat/types.ts
Normal file
45
easyflow-ui-admin/app/src/components/ai-chat/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user