feat: 全新智能体功能
- 基于先进智能体框架,增加智能体编排功能 - 增加智能体聊天,并对接持久化
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user