feat: 先进智能体功能上线

- 基于 agent-runtime 打造,默认 ReAct agent
- 支持 agent 能力对接,已对接工作流、插件、知识库等 tool 能力
- 全新 agent 编排界面,支持可视化便捷配置 agent
- 全新 agent 聊天界面,支持快捷操作、额外知识库选择等
This commit is contained in:
2026-05-28 11:29:18 +08:00
parent 11e595b088
commit 1c205c3720
39 changed files with 3546 additions and 217 deletions

View File

@@ -0,0 +1,459 @@
<script setup lang="ts">
import {computed, ref, watch} from 'vue';
import {ArrowRight, Check, Close, Collection, Plus,} from '@element-plus/icons-vue';
import {ElPopover} from 'element-plus';
interface KnowledgeOption {
label: string;
value: string;
}
interface KnowledgeView {
id: string;
title: string;
}
const props = withDefaults(
defineProps<{
disabled?: boolean;
extraKnowledgeIds?: string[];
knowledgeOptions?: KnowledgeOption[];
loading?: boolean;
selectedKnowledges?: KnowledgeView[];
showTrigger?: boolean;
}>(),
{
disabled: false,
extraKnowledgeIds: () => [],
knowledgeOptions: () => [],
loading: false,
selectedKnowledges: () => [],
showTrigger: true,
},
);
const emit = defineEmits<{
'update:extraKnowledgeIds': [value: string[]];
}>();
const MAX_EXTRA_KNOWLEDGE_COUNT = 3;
const rootOpen = ref(false);
const knowledgeOpen = ref(false);
const selectedIdSet = computed(
() => new Set(props.extraKnowledgeIds.map(String)),
);
function setKnowledgeOpen(open: boolean) {
if (props.disabled) {
return;
}
knowledgeOpen.value = open;
}
watch(rootOpen, (open) => {
if (!open) {
knowledgeOpen.value = false;
}
});
function updateKnowledgeIds(value: string[]) {
emit('update:extraKnowledgeIds', value);
}
function isSelected(id: string) {
return selectedIdSet.value.has(String(id));
}
function isKnowledgeDisabled(id: string) {
return (
props.disabled ||
props.loading ||
(props.extraKnowledgeIds.length >= MAX_EXTRA_KNOWLEDGE_COUNT &&
!isSelected(id))
);
}
function toggleKnowledge(id: string) {
const normalizedId = String(id);
const nextIds = props.extraKnowledgeIds.map(String);
const currentIndex = nextIds.indexOf(normalizedId);
if (currentIndex !== -1) {
nextIds.splice(currentIndex, 1);
updateKnowledgeIds(nextIds);
return;
}
if (nextIds.length >= MAX_EXTRA_KNOWLEDGE_COUNT) {
return;
}
nextIds.push(normalizedId);
updateKnowledgeIds(nextIds);
}
function removeKnowledge(id: string) {
updateKnowledgeIds(
props.extraKnowledgeIds.filter((item) => String(item) !== String(id)),
);
}
</script>
<template>
<div
v-if="showTrigger || selectedKnowledges.length > 0"
class="chat-capability"
>
<div v-if="selectedKnowledges.length > 0" class="chat-capability__chips">
<span
v-for="knowledge in selectedKnowledges"
:key="knowledge.id"
class="chat-capability__chip"
>
<Collection class="chat-capability__chip-icon" />
<span class="chat-capability__chip-label">{{ knowledge.title }}</span>
<button
type="button"
class="chat-capability__chip-remove"
aria-label="移除知识库"
:disabled="disabled"
@click="removeKnowledge(knowledge.id)"
>
<Close />
</button>
</span>
</div>
<ElPopover
v-if="showTrigger"
v-model:visible="rootOpen"
placement="top-start"
:show-arrow="false"
:width="120"
:offset="8"
popper-class="chat-capability-popper"
trigger="click"
>
<template #reference>
<button
type="button"
class="chat-capability__trigger"
:class="{ 'is-open': rootOpen }"
:disabled="disabled"
aria-label="添加能力"
title="添加能力"
>
<Plus />
</button>
</template>
<div class="chat-capability__menu" @mouseleave="setKnowledgeOpen(false)">
<button
type="button"
class="chat-capability__menu-item"
:class="{ 'is-open': knowledgeOpen }"
:disabled="disabled"
@click="setKnowledgeOpen(true)"
@focus="setKnowledgeOpen(true)"
@mouseenter="setKnowledgeOpen(true)"
>
<Collection class="chat-capability__menu-icon" />
<span>知识库</span>
<ArrowRight class="chat-capability__menu-arrow" />
</button>
<div
v-if="knowledgeOpen"
class="chat-capability__knowledge-panel"
@mouseenter="setKnowledgeOpen(true)"
@mouseleave="setKnowledgeOpen(false)"
>
<div class="chat-capability__knowledge-head">
<span>选择知识库</span>
<span>最多 3 </span>
</div>
<div
v-if="knowledgeOptions.length > 0"
class="chat-capability__knowledge-list"
>
<button
v-for="item in knowledgeOptions"
:key="item.value"
type="button"
class="chat-capability__knowledge-item"
:class="{
'is-active': isSelected(item.value),
'is-disabled': isKnowledgeDisabled(item.value),
}"
:disabled="isKnowledgeDisabled(item.value)"
@click.stop="toggleKnowledge(item.value)"
>
<span>{{ item.label }}</span>
<Check
v-if="isSelected(item.value)"
class="chat-capability__knowledge-check"
/>
</button>
</div>
<div v-else class="chat-capability__empty">
{{ loading ? '加载中' : '暂无可选知识库' }}
</div>
</div>
</div>
</ElPopover>
</div>
</template>
<style scoped>
.chat-capability {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.chat-capability__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.chat-capability__chip {
display: inline-flex;
align-items: center;
max-width: 220px;
min-height: 28px;
gap: 6px;
padding: 0 8px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
border-radius: 999px;
}
.chat-capability__chip-icon {
width: 14px;
height: 14px;
flex: none;
}
.chat-capability__chip-label {
min-width: 0;
overflow: hidden;
font-size: 12px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-capability__chip-remove,
.chat-capability__trigger,
.chat-capability__menu-item,
.chat-capability__knowledge-item {
font: inherit;
}
.chat-capability__chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
color: inherit;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 999px;
}
.chat-capability__chip-remove:hover:not(:disabled),
.chat-capability__chip-remove:focus-visible {
background: var(--el-color-primary-light-8);
outline: none;
}
.chat-capability__chip-remove :deep(.el-icon),
.chat-capability__chip-remove svg {
width: 12px;
height: 12px;
}
.chat-capability__trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: 0;
border-radius: 999px;
transition:
background-color 0.16s ease,
color 0.16s ease;
}
.chat-capability__trigger:hover:not(:disabled),
.chat-capability__trigger:focus-visible,
.chat-capability__trigger.is-open {
color: var(--el-color-primary);
background: var(--el-fill-color-light);
outline: none;
}
.chat-capability__trigger:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.chat-capability__trigger svg {
width: 18px;
height: 18px;
}
.chat-capability__menu,
.chat-capability__knowledge-panel {
padding: 4px;
}
.chat-capability__menu {
position: relative;
}
.chat-capability__menu-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
width: 100%;
min-height: 36px;
padding: 0 8px;
color: var(--el-text-color-primary);
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 12px;
}
.chat-capability__menu-item:hover:not(:disabled),
.chat-capability__menu-item:focus-visible,
.chat-capability__menu-item.is-open {
background: var(--el-fill-color-light);
outline: none;
}
.chat-capability__menu-item:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.chat-capability__menu-icon,
.chat-capability__menu-arrow {
width: 16px;
height: 16px;
color: var(--el-text-color-secondary);
}
.chat-capability__knowledge-head {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 32px;
padding: 0 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.chat-capability__knowledge-panel {
position: absolute;
bottom: -2px;
left: calc(100% + 4px);
width: 296px;
background: var(--el-bg-color-overlay);
border-radius: 14px;
box-shadow: var(--el-box-shadow-light);
box-sizing: border-box;
z-index: 1;
}
.chat-capability__knowledge-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 260px;
overflow: auto;
}
.chat-capability__knowledge-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 36px;
gap: 12px;
padding: 0 10px;
color: var(--el-text-color-primary);
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 8px;
}
.chat-capability__knowledge-item span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-capability__knowledge-item:hover:not(:disabled),
.chat-capability__knowledge-item:focus-visible,
.chat-capability__knowledge-item.is-active {
background: var(--el-fill-color-light);
outline: none;
}
.chat-capability__knowledge-item.is-active {
color: var(--el-color-primary);
}
.chat-capability__knowledge-item:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.chat-capability__knowledge-check {
width: 15px;
height: 15px;
flex: none;
}
.chat-capability__empty {
padding: 16px 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
:global(.chat-capability-popper) {
width: 120px !important;
min-width: 120px !important;
overflow: visible !important;
padding: 4px !important;
border-radius: 12px !important;
box-sizing: border-box;
}
:global(.chat-capability-popper .chat-capability__menu) {
padding: 0;
}
:global(.chat-capability-popper.el-popper) {
transition:
opacity 0.08s ease,
transform 0.08s ease !important;
}
</style>

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
import type {ChatInputTriggerItem} from './input-triggers/types';
import {Collection} from '@element-plus/icons-vue';
defineProps<{
activeIndex?: number;
groupLabel?: string;
items?: ChatInputTriggerItem[];
keyword?: string;
}>();
const emit = defineEmits<{
select: [item: ChatInputTriggerItem, index: number];
setActive: [index: number];
}>();
</script>
<template>
<div class="chat-input-trigger-panel">
<div v-if="groupLabel" class="chat-input-trigger-panel__head">
{{ groupLabel }}
</div>
<div
v-if="items && items.length > 0"
class="chat-input-trigger-panel__list"
>
<button
v-for="(item, index) in items"
:key="item.id"
type="button"
class="chat-input-trigger-panel__item"
:class="{ 'is-active': index === activeIndex }"
:disabled="item.disabled"
@mouseenter="emit('setActive', index)"
@mousedown.prevent="emit('select', item, index)"
>
<component
:is="item.icon || Collection"
class="chat-input-trigger-panel__icon"
/>
<span class="chat-input-trigger-panel__content">
<span class="chat-input-trigger-panel__label">{{ item.label }}</span>
</span>
</button>
</div>
<div v-else class="chat-input-trigger-panel__empty">
{{ keyword ? '没有匹配项' : '暂无可选项' }}
</div>
</div>
</template>
<style scoped>
.chat-input-trigger-panel {
width: 100%;
padding: 8px;
background: var(--el-fill-color-extra-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 18px;
box-shadow: var(--el-box-shadow-light);
box-sizing: border-box;
}
.chat-input-trigger-panel__head {
min-height: 32px;
padding: 0 10px;
font-size: 13px;
font-weight: 500;
line-height: 32px;
color: var(--el-text-color-secondary);
}
.chat-input-trigger-panel__list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 240px;
overflow: auto;
}
.chat-input-trigger-panel__item {
display: flex;
align-items: center;
width: 100%;
min-height: 40px;
gap: 10px;
padding: 0 10px;
color: var(--el-text-color-primary);
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 12px;
}
.chat-input-trigger-panel__item:hover:not(:disabled),
.chat-input-trigger-panel__item.is-active {
background: var(--el-fill-color-light);
}
.chat-input-trigger-panel__item:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
.chat-input-trigger-panel__item:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.chat-input-trigger-panel__icon {
width: 18px;
height: 18px;
flex: none;
color: var(--el-color-primary);
}
.chat-input-trigger-panel__content {
display: inline-flex;
min-width: 0;
align-items: baseline;
}
.chat-input-trigger-panel__label {
min-width: 0;
overflow: hidden;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-input-trigger-panel__empty {
padding: 18px 10px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,23 @@
import type {Component} from 'vue';
export type ChatInputTriggerSymbol = '#' | '$' | '/' | '@';
export interface ChatInputTriggerItem {
description?: string;
disabled?: boolean;
icon?: Component;
id: string;
label: string;
}
export interface ChatInputTriggerGroup {
items: ChatInputTriggerItem[];
label: string;
symbol: ChatInputTriggerSymbol;
}
export interface ChatInputTriggerMatch {
keyword: string;
start: number;
symbol: ChatInputTriggerSymbol;
}

View File

@@ -0,0 +1,166 @@
import type {Ref} from 'vue';
import {computed, nextTick, ref} from 'vue';
import type {ChatInputTriggerGroup, ChatInputTriggerMatch, ChatInputTriggerSymbol,} from './types';
const TRIGGER_SYMBOLS = new Set<ChatInputTriggerSymbol>(['#', '$', '/', '@']);
const TRIGGER_BOUNDARY = /[\s,.!?;:()[\]{}]/;
export interface ChatInputTriggerOptions {
disabled: Ref<boolean>;
groups: Ref<ChatInputTriggerGroup[]>;
inputRef: Ref<undefined | unknown>;
text: Ref<string>;
}
/**
* 管理聊天输入框快捷触发状态。
*
* @param options 触发器依赖
* @return 输入触发状态与操作方法
*/
export function useChatInputTrigger(options: ChatInputTriggerOptions) {
const activeMatch = ref<ChatInputTriggerMatch>();
const activeIndex = ref(0);
const activeGroup = computed(() => {
if (!activeMatch.value) {
return undefined;
}
return options.groups.value.find(
(group) => group.symbol === activeMatch.value?.symbol,
);
});
const visibleItems = computed(() => {
const group = activeGroup.value;
if (!group) {
return [];
}
const keyword = activeMatch.value?.keyword.trim().toLowerCase() || '';
if (!keyword) {
return group.items;
}
return group.items.filter((item) => {
const label = item.label.toLowerCase();
const description = item.description?.toLowerCase() || '';
return label.includes(keyword) || description.includes(keyword);
});
});
const activePanel = computed(() => {
const group = activeGroup.value;
if (!activeMatch.value || !group) {
return undefined;
}
return {
groupLabel: group.label,
keyword: activeMatch.value.keyword,
symbol: activeMatch.value.symbol,
};
});
function getTextareaElement() {
const rawRef = options.inputRef.value as
| undefined
| { textarea?: HTMLTextAreaElement };
return rawRef?.textarea;
}
function close() {
activeMatch.value = undefined;
activeIndex.value = 0;
}
function sync() {
if (options.disabled.value) {
close();
return;
}
const textarea = getTextareaElement();
const cursor = textarea?.selectionStart ?? options.text.value.length;
const match = findTriggerMatch(options.text.value, cursor);
if (!match) {
close();
return;
}
activeMatch.value = match;
activeIndex.value = Math.min(
activeIndex.value,
Math.max(visibleItems.value.length - 1, 0),
);
}
async function replaceTriggerText(replacement = '') {
const match = activeMatch.value;
if (!match) {
return;
}
const textarea = getTextareaElement();
const cursor = textarea?.selectionStart ?? options.text.value.length;
const before = options.text.value.slice(0, match.start);
const after = options.text.value.slice(cursor);
const nextText = `${before}${replacement}${after}`;
const nextCursor = before.length + replacement.length;
options.text.value = nextText;
close();
await nextTick();
textarea?.focus();
textarea?.setSelectionRange(nextCursor, nextCursor);
}
function move(delta: number) {
const total = visibleItems.value.length;
if (!activeMatch.value || total === 0) {
return;
}
activeIndex.value = (activeIndex.value + delta + total) % total;
}
function setActiveIndex(index: number) {
if (index < 0 || index >= visibleItems.value.length) {
return;
}
activeIndex.value = index;
}
function findTriggerMatch(
text: string,
cursor: number,
): ChatInputTriggerMatch | undefined {
const beforeCursor = text.slice(0, cursor);
for (let index = beforeCursor.length - 1; index >= 0; index--) {
const char = beforeCursor[index];
if (!char) {
continue;
}
if (TRIGGER_BOUNDARY.test(char)) {
return undefined;
}
if (!TRIGGER_SYMBOLS.has(char as ChatInputTriggerSymbol)) {
continue;
}
const previous = index === 0 ? '' : beforeCursor[index - 1];
if (previous && !TRIGGER_BOUNDARY.test(previous)) {
return undefined;
}
return {
keyword: beforeCursor.slice(index + 1),
start: index,
symbol: char as ChatInputTriggerSymbol,
};
}
return undefined;
}
return {
activeIndex,
activePanel,
close,
move,
replaceTriggerText,
setActiveIndex,
sync,
visibleItems,
};
}