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,193 @@
<script setup lang="ts">
/* eslint-disable vue/no-mutating-props */
import type {AgentInfo, AgentOption} from '../types';
import {InfoFilled} from '@element-plus/icons-vue';
import {
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
ElTooltip,
} from 'element-plus';
defineProps<{
agent: AgentInfo;
categories: AgentOption[];
models: AgentOption[];
}>();
const emit = defineEmits<{ change: [] }>();
</script>
<template>
<ElForm label-position="top" class="agent-form">
<ElFormItem label="名称" required>
<ElInput
v-model="agent.name"
maxlength="50"
show-word-limit
@input="emit('change')"
/>
</ElFormItem>
<ElFormItem label="描述">
<ElInput
v-model="agent.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
@input="emit('change')"
/>
</ElFormItem>
<ElFormItem label="头像">
<ElInput
v-model="agent.avatar"
placeholder="图片地址"
@input="emit('change')"
/>
</ElFormItem>
<ElFormItem label="分类">
<ElSelect
v-model="agent.categoryId"
clearable
filterable
@change="emit('change')"
>
<ElOption
v-for="item in categories"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="模型" required>
<ElSelect v-model="agent.modelId" filterable @change="emit('change')">
<ElOption
v-for="item in models"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="系统提示词">
<ElInput
v-model="agent.promptConfigJson!.systemPrompt"
type="textarea"
:rows="7"
@input="emit('change')"
/>
</ElFormItem>
<div class="agent-form__grid">
<ElFormItem>
<template #label>
<span class="agent-form__label">
消息数触发阈值
<ElTooltip
content="工作记忆里的消息条数达到该值时,会触发上下文压缩;用户消息和智能体消息都会计入。"
effect="light"
placement="top"
>
<ElIcon class="agent-form__info" aria-label="消息数触发阈值说明">
<InfoFilled />
</ElIcon>
</ElTooltip>
</span>
</template>
<ElInputNumber
v-model="agent.memoryConfigJson!.compressionParameter.msgThreshold"
:min="1"
:max="100"
controls-position="right"
@change="emit('change')"
/>
</ElFormItem>
<ElFormItem>
<template #label>
<span class="agent-form__label">
最近保留消息数
<ElTooltip
content="压缩上下文时,最近这 N 条消息会尽量保留原文;更早的消息会优先被压缩。"
effect="light"
placement="top"
>
<ElIcon class="agent-form__info" aria-label="最近保留消息数说明">
<InfoFilled />
</ElIcon>
</ElTooltip>
</span>
</template>
<ElInputNumber
v-model="agent.memoryConfigJson!.compressionParameter.lastKeep"
:min="0"
:max="100"
controls-position="right"
@change="emit('change')"
/>
</ElFormItem>
<ElFormItem>
<template #label>
<span class="agent-form__label">
最小压缩 Token 阈值
<ElTooltip
content="工作记忆的 Token 数达到该值时,会触发上下文压缩。"
effect="light"
placement="top"
>
<ElIcon
class="agent-form__info"
aria-label="最小压缩 Token 阈值说明"
>
<InfoFilled />
</ElIcon>
</ElTooltip>
</span>
</template>
<ElInputNumber
v-model="
agent.memoryConfigJson!.compressionParameter
.minCompressionTokenThreshold
"
:min="0"
:step="500"
controls-position="right"
@change="emit('change')"
/>
</ElFormItem>
</div>
</ElForm>
</template>
<style scoped>
.agent-form {
padding: 16px;
}
.agent-form__grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.agent-form__label {
display: inline-flex;
align-items: center;
gap: 4px;
}
.agent-form__info {
color: var(--el-text-color-secondary);
cursor: help;
font-size: 14px;
}
.agent-form :deep(.el-select),
.agent-form :deep(.el-input-number) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,334 @@
<script setup lang="ts">
import type {AgentCapabilityKind} from '../types';
import {onBeforeUnmount, onMounted, ref} from 'vue';
import {
Connection,
Files,
Loading,
Plus,
Promotion,
Share,
VideoPlay,
} from '@element-plus/icons-vue';
defineProps<{
publishDisabled?: boolean;
publishLoading?: boolean;
publishText: string;
saveLoading?: boolean;
tryoutDisabled?: boolean;
}>();
const emit = defineEmits<{
add: [kind: AgentCapabilityKind];
publish: [];
save: [];
tryout: [];
}>();
const capabilityOpen = ref(false);
const capabilityRef = ref<HTMLElement>();
const capabilityItems = [
{
kind: 'knowledge' as const,
title: '知识库',
desc: '检索企业知识',
icon: Files,
},
{
kind: 'workflow' as const,
title: '工作流',
desc: '调用已发布流程',
icon: Share,
},
{
kind: 'plugin' as const,
title: '插件',
desc: '执行工具能力',
icon: Connection,
},
];
function handleAdd(kind: AgentCapabilityKind) {
capabilityOpen.value = false;
emit('add', kind);
}
function handlePointerDown(event: PointerEvent) {
if (!capabilityOpen.value || !capabilityRef.value) return;
const target = event.target as Node | null;
if (target && !capabilityRef.value.contains(target)) {
capabilityOpen.value = false;
}
}
onMounted(() => {
window.addEventListener('pointerdown', handlePointerDown);
});
onBeforeUnmount(() => {
window.removeEventListener('pointerdown', handlePointerDown);
});
</script>
<template>
<div class="agent-command-bar">
<div class="agent-command-bar__group">
<div ref="capabilityRef" class="agent-command-bar__capability">
<div v-if="capabilityOpen" class="agent-command-bar__capability-panel">
<button
v-for="item in capabilityItems"
:key="item.kind"
class="agent-command-bar__capability-item"
type="button"
@click="handleAdd(item.kind)"
>
<span class="agent-command-bar__capability-icon">
<component :is="item.icon" />
</span>
<span class="agent-command-bar__capability-meta">
<span class="agent-command-bar__capability-title">
{{ item.title }}
</span>
<span class="agent-command-bar__capability-desc">
{{ item.desc }}
</span>
</span>
</button>
</div>
<button
class="agent-command-bar__button agent-command-bar__button--add"
type="button"
:aria-expanded="capabilityOpen"
@click="capabilityOpen = !capabilityOpen"
>
<Plus />
<span>{{ capabilityOpen ? '收起能力' : '增加能力' }}</span>
</button>
</div>
<div class="agent-command-bar__divider"></div>
<button
class="agent-command-bar__button agent-command-bar__button--primary"
type="button"
:disabled="saveLoading"
@click="emit('save')"
>
<Loading v-if="saveLoading" class="is-loading" />
<span>保存</span>
</button>
<div class="agent-command-bar__divider"></div>
<button
class="agent-command-bar__button agent-command-bar__button--ghost"
type="button"
:disabled="publishDisabled || publishLoading"
@click="emit('publish')"
>
<Loading v-if="publishLoading" class="is-loading" />
<Promotion v-else />
<span>{{ publishText }}</span>
</button>
</div>
<button
class="agent-command-bar__run"
type="button"
:disabled="tryoutDisabled"
@click="emit('tryout')"
>
<VideoPlay />
<span>试运行</span>
</button>
</div>
</template>
<style scoped>
.agent-command-bar {
position: absolute;
bottom: 16px;
left: 50%;
z-index: 30;
display: flex;
gap: 8px;
align-items: center;
transform: translateX(-50%);
}
.agent-command-bar__group {
display: inline-flex;
gap: 2px;
align-items: center;
height: 40px;
padding: 0 4px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 10px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.agent-command-bar__button,
.agent-command-bar__run {
all: unset;
box-sizing: border-box;
display: inline-flex;
flex-shrink: 0;
gap: 6px;
align-items: center;
justify-content: center;
height: 32px;
font-size: 14px;
font-weight: 600;
line-height: 1;
cursor: pointer;
border-radius: 8px;
transition:
background 0.15s,
color 0.15s,
filter 0.15s,
opacity 0.15s;
}
.agent-command-bar__button {
padding: 0 12px;
}
.agent-command-bar__capability {
position: relative;
display: inline-flex;
}
.agent-command-bar__capability-panel {
position: absolute;
bottom: calc(100% + 10px);
left: 0;
display: flex;
flex-direction: column;
gap: 4px;
width: 224px;
padding: 8px;
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color);
border-radius: 10px;
box-shadow: var(--el-box-shadow-light);
}
.agent-command-bar__capability-item {
all: unset;
box-sizing: border-box;
display: flex;
gap: 10px;
align-items: center;
padding: 8px;
cursor: pointer;
border-radius: 8px;
transition: background 0.15s;
}
.agent-command-bar__capability-item:hover {
background: var(--el-fill-color-light);
}
.agent-command-bar__capability-icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-radius: 8px;
}
.agent-command-bar__capability-meta {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.agent-command-bar__capability-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.agent-command-bar__capability-desc {
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.agent-command-bar__button--add {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.agent-command-bar__button--add:hover {
background: var(--el-color-primary-light-8);
}
.agent-command-bar__button--primary {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.agent-command-bar__button--ghost {
color: var(--el-text-color-secondary);
}
.agent-command-bar__button:hover:not(:disabled) {
color: var(--el-text-color-primary);
background: var(--el-fill-color-light);
}
.agent-command-bar__button--primary:hover:not(:disabled) {
color: var(--el-color-primary);
background: var(--el-color-primary-light-8);
}
.agent-command-bar__divider {
width: 1px;
height: 20px;
margin: 0 4px;
background: var(--el-border-color);
}
.agent-command-bar__run {
padding: 0 14px;
color: #fff;
background: #13b33f;
}
.agent-command-bar__run:hover:not(:disabled) {
filter: brightness(0.95);
}
.agent-command-bar__button:disabled,
.agent-command-bar__run:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.agent-command-bar svg {
width: 14px;
height: 14px;
}
.agent-command-bar .is-loading {
animation: agent-command-spin 1s linear infinite;
}
@keyframes agent-command-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
import type {AgentDraftState, AgentOption, AgentValidationIssue,} from '../types';
import {computed} from 'vue';
import {Close} from '@element-plus/icons-vue';
import {ElButton} from 'element-plus';
import AgentBaseForm from './AgentBaseForm.vue';
import AgentKnowledgeForm from './AgentKnowledgeForm.vue';
import AgentToolForm from './AgentToolForm.vue';
import AgentTryoutPanel from './AgentTryoutPanel.vue';
const props = defineProps<{
categories: AgentOption[];
issues: AgentValidationIssue[];
knowledges: AgentOption[];
models: AgentOption[];
pluginTools: AgentOption[];
state: AgentDraftState;
workflows: AgentOption[];
}>();
const emit = defineEmits<{
change: [];
closeTryout: [];
removeCapability: [];
selectIssue: [nodeId: string];
}>();
const selectedKnowledge = computed(() => {
if (!props.state.selectedNodeId.startsWith('knowledge:')) return;
const localId = props.state.selectedNodeId.slice('knowledge:'.length);
return props.state.knowledgeBindings.find((item) => item.localId === localId);
});
const selectedTool = computed(() => {
if (!props.state.selectedNodeId.startsWith('tool:')) return;
const localId = props.state.selectedNodeId.slice('tool:'.length);
return props.state.toolBindings.find((item) => item.localId === localId);
});
const selectedToolKind = computed(() =>
String(selectedTool.value?.toolType || '').toUpperCase() === 'WORKFLOW'
? 'workflow'
: 'plugin',
);
</script>
<template>
<aside class="agent-inspector">
<template v-if="state.panelMode === 'tryout'">
<AgentTryoutPanel
:agent="state.agent"
:tool-bindings="state.toolBindings"
:knowledge-bindings="state.knowledgeBindings"
@close="emit('closeTryout')"
/>
</template>
<template v-else>
<header class="agent-inspector__header">
<div>
<div class="agent-inspector__title">
{{ state.panelMode === 'base' ? '基座智能体' : '能力配置' }}
</div>
<div class="agent-inspector__subtitle">
{{ state.panelMode === 'base' ? '核心设定' : '绑定关系' }}
</div>
</div>
</header>
<div class="agent-inspector__body">
<div v-if="issues.length > 0" class="agent-inspector__issues">
<button
v-for="issue in issues"
:key="`${issue.nodeId}-${issue.field || issue.message}`"
class="agent-inspector__issue"
type="button"
@click="emit('selectIssue', issue.nodeId)"
>
{{ issue.message }}
</button>
</div>
<AgentBaseForm
v-if="state.panelMode === 'base'"
:agent="state.agent"
:categories="categories"
:models="models"
@change="emit('change')"
/>
<AgentKnowledgeForm
v-else-if="selectedKnowledge"
:binding="selectedKnowledge"
:knowledges="knowledges"
@change="emit('change')"
@remove="emit('removeCapability')"
/>
<AgentToolForm
v-else-if="selectedTool"
:binding="selectedTool"
:kind="selectedToolKind"
:options="selectedToolKind === 'workflow' ? workflows : pluginTools"
@change="emit('change')"
@remove="emit('removeCapability')"
/>
<div v-else class="agent-inspector__empty">
<ElButton :icon="Close" text @click="emit('closeTryout')">
返回基座
</ElButton>
</div>
</div>
</template>
</aside>
</template>
<style scoped>
.agent-inspector {
position: absolute;
top: 24px;
right: 24px;
bottom: 96px;
z-index: 20;
display: flex;
flex-direction: column;
width: min(420px, calc(100vw - 320px));
min-height: 0;
overflow: hidden;
background: color-mix(in srgb, var(--el-bg-color) 94%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
box-shadow: var(--el-box-shadow-light);
backdrop-filter: blur(16px);
}
.agent-inspector__header {
padding: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.agent-inspector__body {
flex: 1;
min-height: 0;
overflow: hidden auto;
overscroll-behavior: contain;
}
.agent-inspector__title {
font-size: 16px;
font-weight: 600;
}
.agent-inspector__subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.agent-inspector__issues {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 16px 0;
}
.agent-inspector__issue {
padding: 8px 10px;
font-size: 12px;
color: var(--el-color-danger);
text-align: left;
cursor: pointer;
background: var(--el-color-danger-light-9);
border: 0;
border-radius: 8px;
}
.agent-inspector__empty {
padding: 16px;
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
/* eslint-disable vue/no-mutating-props */
import type {AgentKnowledgeBinding, AgentOption} from '../types';
import {ElButton, ElForm, ElFormItem, ElInputNumber, ElOption, ElSelect,} from 'element-plus';
const props = defineProps<{
binding: AgentKnowledgeBinding;
knowledges: AgentOption[];
}>();
const retrievalModeOptions = [
{ label: '混合', value: 'HYBRID' },
{ label: '语义', value: 'VECTOR' },
{ label: '关键词', value: 'KEYWORD' },
];
const emit = defineEmits<{
change: [];
remove: [];
}>();
function handleKnowledgeChange(value: string) {
const option = props.knowledges.find(
(item) => String(item.value) === String(value),
);
props.binding.resourceSummary = {
...(option?.raw || {}),
label: option?.label,
name: option?.raw?.name || option?.label,
};
emit('change');
}
</script>
<template>
<ElForm label-position="top" class="agent-form">
<ElFormItem label="知识库" required>
<ElSelect
v-model="binding.knowledgeId"
filterable
placeholder="选择知识库"
@change="handleKnowledgeChange"
>
<ElOption
v-for="item in knowledges"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="检索方式">
<ElSelect
v-model="binding.retrievalMode"
placeholder="选择检索方式"
@change="emit('change')"
>
<ElOption
v-for="item in retrievalModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="召回数量">
<ElInputNumber
v-model="binding.optionsJson!.limit"
:min="1"
:max="20"
controls-position="right"
@change="emit('change')"
/>
</ElFormItem>
<ElFormItem label="相似度阈值">
<ElInputNumber
v-model="binding.optionsJson!.scoreThreshold"
:min="0"
:max="1"
:step="0.05"
controls-position="right"
@change="emit('change')"
/>
</ElFormItem>
<ElButton
class="agent-form__danger"
type="danger"
plain
@click="emit('remove')"
>
删除节点
</ElButton>
</ElForm>
</template>
<style scoped>
.agent-form {
padding: 16px;
}
.agent-form :deep(.el-select),
.agent-form :deep(.el-input-number) {
width: 100%;
}
.agent-form__danger {
width: 100%;
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
/* eslint-disable vue/no-mutating-props */
import type {AgentOption, AgentToolBinding} from '../types';
import {ElButton, ElForm, ElFormItem, ElInput, ElOption, ElSelect, ElSwitch,} from 'element-plus';
const props = defineProps<{
binding: AgentToolBinding;
kind: 'plugin' | 'workflow';
options: AgentOption[];
}>();
const emit = defineEmits<{
change: [];
remove: [];
}>();
const SAFE_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
function isSafeToolName(name?: string) {
return SAFE_TOOL_NAME_PATTERN.test(String(name || ''));
}
function resolveToolName(option?: AgentOption) {
const raw = option?.raw || {};
if (isSafeToolName(raw.englishName)) {
return String(raw.englishName);
}
if (isSafeToolName(raw.name)) {
return String(raw.name);
}
const prefix = props.kind === 'workflow' ? 'workflow' : 'plugin';
return `${prefix}_${option?.value || Date.now()}`;
}
function shouldSyncToolName() {
const name = String(props.binding.toolName || '');
if (!name) return true;
const prefix = props.kind === 'workflow' ? 'workflow_' : 'plugin_';
return name.startsWith(prefix);
}
function handleTargetChange(value: string) {
const option = props.options.find(
(item) => String(item.value) === String(value),
);
props.binding.resourceSummary = option?.raw || {};
if (shouldSyncToolName()) {
props.binding.toolName = resolveToolName(option);
}
emit('change');
}
</script>
<template>
<ElForm label-position="top" class="agent-form">
<ElFormItem :label="kind === 'workflow' ? '工作流' : '插件工具'" required>
<ElSelect
v-model="binding.targetId"
filterable
placeholder="选择资源"
@change="handleTargetChange"
>
<ElOption
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="工具名称" required>
<ElInput
v-model="binding.toolName"
placeholder="仅支持英文、数字、下划线或中划线"
@input="emit('change')"
/>
</ElFormItem>
<ElFormItem label="执行前确认">
<ElSwitch v-model="binding.hitlEnabled" @change="emit('change')" />
</ElFormItem>
<ElButton
class="agent-form__danger"
type="danger"
plain
@click="emit('remove')"
>
删除节点
</ElButton>
</ElForm>
</template>
<style scoped>
.agent-form {
padding: 16px;
}
.agent-form :deep(.el-select) {
width: 100%;
}
.agent-form__danger {
width: 100%;
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import type {ChatTimelineMessageItem, ChatTimelineToolApprovalPayload,} from '@easyflow/common-ui';
import {ChatTimeline} from '@easyflow/common-ui';
import type {AgentInfo, AgentKnowledgeBinding, AgentToolBinding,} from '../types';
import {onMounted, ref, watch} from 'vue';
import {BrushCleaning} from '@easyflow/icons';
import {ElButton, ElMessage} from 'element-plus';
import AiChatPanel from '#/components/ai-chat/AiChatPanel.vue';
import {approveAgentRun, rejectAgentRun} from '../api';
import {useAgentTryoutStream} from '../composables/useAgentTryoutStream';
const props = defineProps<{
agent: AgentInfo;
knowledgeBindings: AgentKnowledgeBinding[];
toolBindings: AgentToolBinding[];
}>();
const emit = defineEmits<{ close: [] }>();
const {
loading,
clearDraftSession,
markToolApproving,
markToolRejected,
copyMessageText,
selectVariant,
syncDraftContext,
timelineItems,
sendDraft,
stop,
} = useAgentTryoutStream();
const approvalLoading = ref(false);
function getDraftContext() {
return {
agent: props.agent,
toolBindings: props.toolBindings,
knowledgeBindings: props.knowledgeBindings,
};
}
function syncCurrentDraftContext(restore = false) {
syncDraftContext(getDraftContext(), restore);
}
onMounted(() => {
syncCurrentDraftContext(true);
});
watch(
() => [props.agent.id, props.agent.localId],
() => {
syncCurrentDraftContext(true);
},
);
watch(
() => [props.agent, props.knowledgeBindings, props.toolBindings],
() => {
syncCurrentDraftContext();
},
{ deep: true },
);
async function handleSend(prompt: string) {
await sendDraft({
...getDraftContext(),
prompt,
});
}
function canCopyMessage(item: ChatTimelineMessageItem) {
if (item.role === 'user') {
return Boolean(copyMessageText(item).trim());
}
return Boolean(
item.roundId && item.roundCompleted && copyMessageText(item).trim(),
);
}
function canRegenerateMessage(item: ChatTimelineMessageItem) {
return (
item.role === 'assistant' &&
Boolean(item.roundId) &&
Boolean(item.roundCompleted)
);
}
async function handleCopyMessage(item: ChatTimelineMessageItem) {
const text = copyMessageText(item).trim();
if (!text) {
return;
}
try {
await navigator.clipboard.writeText(text);
ElMessage.success('已复制');
} catch {
ElMessage.error('复制失败');
}
}
async function handleRegenerateMessage(item: ChatTimelineMessageItem) {
void item;
}
function handleSelectPreviousVariant(item: ChatTimelineMessageItem) {
selectVariant(item, 'previous');
}
function handleSelectNextVariant(item: ChatTimelineMessageItem) {
selectVariant(item, 'next');
}
async function handleClearSession() {
try {
await clearDraftSession();
ElMessage.success('已清理会话');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清理会话失败');
}
}
function handleStop() {
if (!loading.value) {
return;
}
stop();
}
async function handleApprove(payload: ChatTimelineToolApprovalPayload) {
approvalLoading.value = true;
markToolApproving(payload);
try {
const res = await approveAgentRun(payload.requestId, payload.resumeToken);
if (res.errorCode === 0) {
ElMessage.success('已批准');
}
} catch (error) {
markToolRejected({
...payload,
reason: error instanceof Error ? error.message : '批准失败',
});
throw error;
} finally {
approvalLoading.value = false;
}
}
async function handleReject(payload: ChatTimelineToolApprovalPayload) {
approvalLoading.value = true;
markToolRejected({
...payload,
reason: '用户拒绝执行',
});
try {
await rejectAgentRun(
payload.requestId,
payload.resumeToken,
'用户拒绝执行',
);
} finally {
approvalLoading.value = false;
}
}
</script>
<template>
<AiChatPanel
:title="agent.name || '草稿试运行'"
empty-text="输入问题试运行当前智能体"
closable
:messages="[]"
:loading="loading"
:approval-loading="approvalLoading"
@send="handleSend"
@stop="handleStop"
@approve="handleApprove"
@reject="handleReject"
@close="emit('close')"
>
<template #headerActions>
<ElButton
:icon="BrushCleaning"
circle
text
:disabled="approvalLoading"
aria-label="清理会话"
title="清理会话"
@click="handleClearSession"
/>
</template>
<ChatTimeline
:items="timelineItems"
empty-text="输入问题试运行当前智能体"
:approval-loading="approvalLoading"
:copyable="canCopyMessage"
:regenerable="canRegenerateMessage"
:regenerate-disabled="true"
@approve="handleApprove"
@copy-message="handleCopyMessage"
@regenerate-message="handleRegenerateMessage"
@reject="handleReject"
@select-next-variant="handleSelectNextVariant"
@select-previous-variant="handleSelectPreviousVariant"
/>
</AiChatPanel>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import type {AgentStudioNodeRenderProps} from './types';
import AgentStudioNode from './AgentStudioNode.vue';
defineProps<AgentStudioNodeRenderProps>();
</script>
<template>
<AgentStudioNode :data="data" />
</template>

View File

@@ -0,0 +1,380 @@
<script setup lang="ts">
import type {AgentDraftState, AgentOption} from '../../types';
import type {
AgentStudioCanvasSize,
AgentStudioConnectionView,
AgentStudioNodeData,
AgentStudioNodeView,
AgentStudioViewport,
} from './types';
import {
computed,
createApp,
h,
onBeforeUnmount,
onMounted,
reactive,
ref,
shallowRef,
watch,
} from 'vue';
import {usePreferences} from '@easyflow/preferences';
import {Tinyflow} from '@tinyflow-ai/vue';
import {useAgentStudioLayout} from '../../composables/agent-studio/useAgentStudioLayout';
import {useAgentStudioModel} from '../../composables/agent-studio/useAgentStudioModel';
import AgentStudioBaseNode from './AgentStudioBaseNode.vue';
import AgentStudioCapabilityNode from './AgentStudioCapabilityNode.vue';
import AgentStudioEdgeLayer from './AgentStudioEdgeLayer.vue';
import '@tinyflow-ai/vue/dist/index.css';
const props = defineProps<{
knowledgeOptions?: AgentOption[];
selectedNodeId: string;
state: AgentDraftState;
}>();
const emit = defineEmits<{
select: [nodeId: string];
}>();
const { isDark } = usePreferences();
const { capture, layout } = useAgentStudioLayout(props.state);
const canvasRef = ref<HTMLElement>();
const canvasSize = ref<AgentStudioCanvasSize>();
const canvasModel = useAgentStudioModel(
props.state,
() => props.selectedNodeId,
layout,
() => canvasSize.value,
() => props.knowledgeOptions || [],
);
const liveNodes = ref<AgentStudioNodeView[]>([]);
const liveViewport = ref<AgentStudioViewport>({ x: 250, y: 100, zoom: 1 });
const flowData = computed(() => {
const liveNodeMap = new Map(liveNodes.value.map((node) => [node.id, node]));
return {
edges: canvasModel.value.edges.map((edge) => ({ ...edge })),
nodes: canvasModel.value.nodes.map((node) => {
const liveNode = liveNodeMap.get(node.id);
return {
...node,
data: { ...node.data },
position: { ...(liveNode?.position || node.position) },
};
}),
viewport: layout.viewport ? { ...layout.viewport } : undefined,
};
});
const lastCaptureSignature = ref('');
const lastFlowDataSignature = ref('');
const customNodes = computed(() => ({
agentStudioBase: {
presentation: 'plain',
render: (parent: HTMLElement, node: any) => {
renderVueNode(parent, AgentStudioBaseNode, node.data);
},
onUpdate: (parent: HTMLElement, node: any) => {
renderVueNode(parent, AgentStudioBaseNode, node.data);
},
},
agentStudioCapability: {
presentation: 'plain',
render: (parent: HTMLElement, node: any) => {
renderVueNode(parent, AgentStudioCapabilityNode, node.data);
},
onUpdate: (parent: HTMLElement, node: any) => {
renderVueNode(parent, AgentStudioCapabilityNode, node.data);
},
},
}));
const connections = computed<AgentStudioConnectionView[]>(() => {
const nodes =
liveNodes.value.length > 0
? liveNodes.value
: (canvasModel.value.nodes as AgentStudioNodeView[]);
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
return canvasModel.value.edges
.map((edge) => {
const source = nodeMap.get(edge.source);
const target = nodeMap.get(edge.target);
if (!source || !target) return undefined;
return {
active: false,
id: edge.id,
path: buildConnectionPath(source, target, liveViewport.value),
sourceId: edge.source,
targetId: edge.target,
};
})
.filter(Boolean) as AgentStudioConnectionView[];
});
function renderVueNode(
parent: HTMLElement,
component: any,
data: AgentStudioNodeData,
) {
const current = (parent as any).__agentStudioNodeView as
| undefined
| {
app: ReturnType<typeof createApp>;
component: any;
data: AgentStudioNodeData;
signature: string;
};
const signature = JSON.stringify(data);
if (current && current.component === component) {
if (current.signature === signature) {
return;
}
Object.assign(current.data, data);
current.signature = signature;
return;
}
current?.app.unmount();
parent.textContent = '';
const reactiveData = reactive({ ...data }) as AgentStudioNodeData;
const app = createApp({
render: () => h(component, { data: reactiveData }),
});
app.mount(parent);
(parent as any).__agentStudioNodeView = {
app,
component,
data: reactiveData,
signature,
};
}
function getNodeSize(node: AgentStudioNodeView) {
return {
height: node.height || (node.data.kind === 'base' ? 104 : 78),
width: node.width || (node.data.kind === 'base' ? 268 : 212),
};
}
function toScreenPoint(
point: { x: number; y: number },
viewport: AgentStudioViewport,
) {
return {
x: point.x * viewport.zoom + viewport.x,
y: point.y * viewport.zoom + viewport.y,
};
}
function buildConnectionPath(
source: AgentStudioNodeView,
target: AgentStudioNodeView,
viewport: AgentStudioViewport,
) {
const sourceSize = getNodeSize(source);
const targetSize = getNodeSize(target);
const sourceCenter = {
x: source.position.x + sourceSize.width / 2,
y: source.position.y + sourceSize.height / 2,
};
const targetCenter = {
x: target.position.x + targetSize.width / 2,
y: target.position.y + targetSize.height / 2,
};
const sourceOnLeft = targetCenter.x < sourceCenter.x;
const start = {
x: source.position.x + (sourceOnLeft ? 0 : sourceSize.width),
y: sourceCenter.y,
};
const end = {
x: target.position.x + (sourceOnLeft ? targetSize.width : 0),
y: targetCenter.y,
};
const screenStart = toScreenPoint(start, viewport);
const screenEnd = toScreenPoint(end, viewport);
const midX = (screenStart.x + screenEnd.x) / 2;
return [
`M ${screenStart.x} ${screenStart.y}`,
`L ${midX} ${screenStart.y}`,
`L ${midX} ${screenEnd.y}`,
`L ${screenEnd.x} ${screenEnd.y}`,
].join(' ');
}
function handleDataChange(data: any) {
const signature = buildFlowDataSignature(data);
if (signature === lastCaptureSignature.value) {
return;
}
lastCaptureSignature.value = signature;
if (Array.isArray(data?.nodes)) {
liveNodes.value = data.nodes as AgentStudioNodeView[];
}
if (data?.viewport) {
liveViewport.value = data.viewport as AgentStudioViewport;
}
capture({
nodes: data?.nodes as AgentStudioNodeView[] | undefined,
viewport: data?.viewport as AgentStudioViewport | undefined,
});
const selected = data?.nodes?.find((node: any) => node.selected);
if (selected?.id && selected.id !== props.selectedNodeId) {
emit('select', selected.id);
}
}
function buildFlowDataSignature(data: any) {
const nodes = Array.isArray(data?.nodes)
? data.nodes.map((node: any) => ({
data: node.data,
id: node.id,
position: node.position,
selected: node.selected,
type: node.type,
}))
: [];
const edges = Array.isArray(data?.edges)
? data.edges.map((edge: any) => ({
id: edge.id,
source: edge.source,
target: edge.target,
}))
: [];
return JSON.stringify({
edges,
nodes,
viewport: data?.viewport,
});
}
const stableFlowData = shallowRef(flowData.value);
let resizeObserver: ResizeObserver | undefined;
function syncFlowData() {
const next = flowData.value;
const signature = buildFlowDataSignature(next);
if (signature === lastFlowDataSignature.value) {
return;
}
lastFlowDataSignature.value = signature;
stableFlowData.value = next;
}
syncFlowData();
watch(flowData, syncFlowData);
function updateCanvasSize() {
const rect = canvasRef.value?.getBoundingClientRect();
if (!rect) return;
const next = {
height: rect.height,
width: rect.width,
};
if (
canvasSize.value?.height === next.height &&
canvasSize.value.width === next.width
) {
return;
}
canvasSize.value = next;
}
onMounted(() => {
updateCanvasSize();
if (typeof ResizeObserver === 'undefined' || !canvasRef.value) return;
resizeObserver = new ResizeObserver(updateCanvasSize);
resizeObserver.observe(canvasRef.value);
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
});
</script>
<template>
<div ref="canvasRef" class="agent-studio-canvas">
<Tinyflow
class="agent-studio-canvas__flow"
:data="stableFlowData"
:theme="isDark ? 'dark' : 'light'"
:custom-nodes="customNodes"
:hide-bottom-dock="true"
:hide-edge-panel="true"
:hide-mini-map="true"
:hide-node-handles="true"
:hide-node-picker="true"
:hide-node-setting="true"
:hide-node-toolbar="true"
:hide-edge-markers="true"
:edge-animated="false"
:edge-interaction-width="0"
:nodes-draggable="true"
:nodes-connectable="false"
:elements-selectable="true"
:drop-enabled="false"
:on-data-change="handleDataChange"
/>
<AgentStudioEdgeLayer :connections="connections" />
</div>
</template>
<style scoped>
.agent-studio-canvas {
position: absolute;
inset: 0;
overflow: hidden;
}
.agent-studio-canvas__flow {
width: 100%;
height: 100%;
}
.agent-studio-canvas :deep(.tinyflow),
.agent-studio-canvas :deep(.agentsflow) {
width: 100%;
height: 100% !important;
}
.agent-studio-canvas :deep(.svelte-flow__node) {
z-index: 2 !important;
border: 0;
border-radius: 18px;
}
.agent-studio-canvas :deep(.svelte-flow__node::after) {
display: none;
}
.agent-studio-canvas :deep(.svelte-flow__node:hover),
.agent-studio-canvas :deep(.svelte-flow__node.selectable.selected) {
border: 0;
box-shadow: none;
}
.agent-studio-canvas :deep(.svelte-flow__handle),
.agent-studio-canvas :deep(.svelte-flow__minimap),
.agent-studio-canvas :deep(.tf-left-dock),
.agent-studio-canvas :deep(.tf-top-dock),
.agent-studio-canvas :deep(.tf-bottom-dock),
.agent-studio-canvas :deep(.tf-node-toolbar) {
display: none !important;
}
.agent-studio-canvas :deep(.tf-flow-line-path) {
stroke: color-mix(
in srgb,
var(--el-color-primary) 30%,
var(--el-border-color)
);
stroke-width: 1.5;
}
</style>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import type {AgentStudioNodeRenderProps} from './types';
import AgentStudioNode from './AgentStudioNode.vue';
defineProps<AgentStudioNodeRenderProps>();
</script>
<template>
<AgentStudioNode :data="data" />
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type {AgentStudioConnectionView} from './types';
defineProps<{
connections: AgentStudioConnectionView[];
}>();
</script>
<template>
<svg class="agent-studio-edge-layer" aria-hidden="true">
<path
v-for="connection in connections"
:key="connection.id"
class="agent-studio-edge-layer__path"
:class="{ 'is-active': connection.active }"
:d="connection.path"
:data-source-id="connection.sourceId"
:data-target-id="connection.targetId"
/>
</svg>
</template>
<style scoped>
.agent-studio-edge-layer {
position: absolute;
inset: 0;
z-index: 1;
overflow: visible;
pointer-events: none;
}
.agent-studio-edge-layer__path {
fill: none;
stroke: color-mix(
in srgb,
var(--el-color-primary) 30%,
var(--el-border-color)
);
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.agent-studio-edge-layer__path.is-active {
stroke: var(--el-color-primary);
stroke-width: 2.2;
}
</style>

View File

@@ -0,0 +1,194 @@
<script setup lang="ts">
import type {AgentStudioNodeData} from './types';
import {computed} from 'vue';
import {Connection, Cpu, Files, Share} from '@element-plus/icons-vue';
import {ElIcon} from 'element-plus';
const props = defineProps<{
data: AgentStudioNodeData;
}>();
const iconComponent = computed(() => {
const icons = {
base: Cpu,
knowledge: Files,
plugin: Connection,
workflow: Share,
};
return icons[props.data.iconKey];
});
</script>
<template>
<button
class="agent-studio-node"
:class="[
`agent-studio-node--${data.kind}`,
{ 'is-selected': data.selected },
]"
type="button"
:aria-pressed="data.selected"
>
<span class="agent-studio-node__icon">
<ElIcon><component :is="iconComponent" /></ElIcon>
</span>
<span class="agent-studio-node__content">
<span class="agent-studio-node__header">
<span class="agent-studio-node__title">{{ data.title }}</span>
<span v-if="data.badge" class="agent-studio-node__badge">
{{ data.badge }}
</span>
</span>
<span v-if="data.detail" class="agent-studio-node__detail">
{{ data.detail }}
</span>
</span>
</button>
</template>
<style scoped>
.agent-studio-node {
box-sizing: border-box;
display: flex;
gap: 12px;
align-items: center;
width: 212px;
padding: 13px 14px;
color: var(--el-text-color-primary);
text-align: left;
cursor: pointer;
background: color-mix(in srgb, var(--el-bg-color) 96%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: 16px;
box-shadow: 0 10px 28px rgb(15 23 42 / 8%);
transition:
background-color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.agent-studio-node:hover {
background: var(--el-bg-color);
border-color: color-mix(
in srgb,
var(--el-color-primary) 26%,
var(--el-border-color-lighter)
);
box-shadow: 0 14px 32px rgb(15 23 42 / 10%);
transform: translateY(-1px);
}
.agent-studio-node:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 3px;
}
.agent-studio-node.is-selected {
background: color-mix(
in srgb,
var(--el-color-primary-light-9) 54%,
var(--el-bg-color)
);
border-color: color-mix(
in srgb,
var(--el-color-primary) 42%,
var(--el-border-color)
);
box-shadow: 0 16px 36px rgb(11 111 211 / 14%);
}
.agent-studio-node--base {
width: 268px;
min-height: 104px;
padding: 18px;
border-radius: 18px;
}
.agent-studio-node--knowledge,
.agent-studio-node--workflow,
.agent-studio-node--plugin {
min-height: 78px;
}
.agent-studio-node__icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
font-size: 24px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-radius: 14px;
}
.agent-studio-node--base .agent-studio-node__icon {
width: 50px;
height: 50px;
font-size: 28px;
background: color-mix(
in srgb,
var(--el-color-primary-light-8) 74%,
var(--el-bg-color)
);
}
.agent-studio-node__content {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.agent-studio-node__header {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.agent-studio-node__title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 650;
line-height: 22px;
white-space: nowrap;
}
.agent-studio-node--base .agent-studio-node__title {
font-size: 18px;
line-height: 26px;
}
.agent-studio-node__badge {
flex: 0 0 auto;
padding: 2px 7px;
font-size: 11px;
line-height: 16px;
color: var(--el-color-primary);
background: color-mix(
in srgb,
var(--el-color-primary-light-9) 74%,
var(--el-bg-color)
);
border-radius: 999px;
}
.agent-studio-node__detail {
display: -webkit-box;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-regular);
-webkit-box-orient: vertical;
}
</style>

View File

@@ -0,0 +1,59 @@
import type {AgentCapabilityKind} from '../../types';
export type AgentStudioNodeKind = 'base' | AgentCapabilityKind;
export interface AgentStudioNodeData {
badge?: string;
detail?: string;
iconKey: AgentStudioNodeKind;
id: string;
kind: AgentStudioNodeKind;
selected: boolean;
title: string;
}
export interface AgentStudioNodeRenderProps {
data: AgentStudioNodeData;
}
export interface AgentStudioNodeView {
id: string;
position: {
x: number;
y: number;
};
height?: number;
type: string;
width?: number;
data: AgentStudioNodeData;
}
export interface AgentStudioEdgeView {
id: string;
source: string;
target: string;
}
export interface AgentStudioConnectionView {
active?: boolean;
id: string;
path: string;
sourceId: string;
targetId: string;
}
export interface AgentStudioViewport {
x: number;
y: number;
zoom: number;
}
export interface AgentStudioCanvasSize {
height: number;
width: number;
}
export interface AgentStudioLayoutSnapshot {
nodePositions: Record<string, { x: number; y: number }>;
viewport?: AgentStudioViewport;
}