发布 v1.10 #5
@@ -6,7 +6,7 @@ import type {
|
|||||||
AgentValidationIssue,
|
AgentValidationIssue,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onActivated, onDeactivated, onMounted, ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
getAgentDetail,
|
getAgentDetail,
|
||||||
getAgentModels,
|
getAgentModels,
|
||||||
|
getMcpPage,
|
||||||
|
getMcpTools,
|
||||||
getPublishedKnowledgeList,
|
getPublishedKnowledgeList,
|
||||||
saveAgent,
|
saveAgent,
|
||||||
submitAgentOfflineApproval,
|
submitAgentOfflineApproval,
|
||||||
@@ -35,6 +37,7 @@ import AgentStudioCanvas from './components/agent-studio/AgentStudioCanvas.vue';
|
|||||||
import AgentCommandBar from './components/AgentCommandBar.vue';
|
import AgentCommandBar from './components/AgentCommandBar.vue';
|
||||||
import AgentInspectorPanel from './components/AgentInspectorPanel.vue';
|
import AgentInspectorPanel from './components/AgentInspectorPanel.vue';
|
||||||
import { useAgentDesignerState } from './composables/useAgentDesignerState';
|
import { useAgentDesignerState } from './composables/useAgentDesignerState';
|
||||||
|
import { createMcpToolLoader } from './mcpToolLoader';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -56,10 +59,13 @@ const {
|
|||||||
validate,
|
validate,
|
||||||
} = useAgentDesignerState();
|
} = useAgentDesignerState();
|
||||||
|
|
||||||
const pageLoading = ref(false);
|
const pageLoading = ref(true);
|
||||||
|
const contentReady = ref(false);
|
||||||
|
const canvasActive = ref(true);
|
||||||
const saveLoading = ref(false);
|
const saveLoading = ref(false);
|
||||||
const offlineLoading = ref(false);
|
const offlineLoading = ref(false);
|
||||||
const publishLoading = ref(false);
|
const publishLoading = ref(false);
|
||||||
|
const mcpToolsLoading = ref<Record<string, boolean>>({});
|
||||||
const issues = ref<AgentValidationIssue[]>([]);
|
const issues = ref<AgentValidationIssue[]>([]);
|
||||||
const categories = ref<AgentOption[]>([]);
|
const categories = ref<AgentOption[]>([]);
|
||||||
const models = ref<AgentOption[]>([]);
|
const models = ref<AgentOption[]>([]);
|
||||||
@@ -67,6 +73,10 @@ const knowledges = ref<AgentOption[]>([]);
|
|||||||
const workflows = ref<AgentOption[]>([]);
|
const workflows = ref<AgentOption[]>([]);
|
||||||
const pluginTools = ref<AgentOption[]>([]);
|
const pluginTools = ref<AgentOption[]>([]);
|
||||||
const mcps = ref<AgentOption[]>([]);
|
const mcps = ref<AgentOption[]>([]);
|
||||||
|
const fetchMcpToolResource = createMcpToolLoader(async (id) => {
|
||||||
|
const res = await getMcpTools(id);
|
||||||
|
return res.errorCode === 0 ? res.data : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
const isNew = computed(() => String(route.params.id || '') === 'new');
|
const isNew = computed(() => String(route.params.id || '') === 'new');
|
||||||
const publishText = computed(() => {
|
const publishText = computed(() => {
|
||||||
@@ -116,14 +126,23 @@ const offlineDisabled = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
pageLoading.value = true;
|
void loadDeferredOptions();
|
||||||
try {
|
try {
|
||||||
await Promise.all([loadOptions(), loadAgent()]);
|
await Promise.all([loadCriticalOptions(), loadAgent()]);
|
||||||
} finally {
|
} finally {
|
||||||
|
contentReady.value = true;
|
||||||
pageLoading.value = false;
|
pageLoading.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
canvasActive.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
canvasActive.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
async function loadAgent() {
|
async function loadAgent() {
|
||||||
if (isNew.value) {
|
if (isNew.value) {
|
||||||
reset();
|
reset();
|
||||||
@@ -191,13 +210,33 @@ function syncNavTitle(title: string, options: { force?: boolean } = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOptions() {
|
async function loadCriticalOptions() {
|
||||||
const [categoryRes, modelRes, knowledgeRes, workflowRes, pluginRes, mcpRes] =
|
const [categoryResult, modelResult] = await Promise.allSettled([
|
||||||
await Promise.all([
|
|
||||||
api.get('/api/v1/agentCategory/visibleList', {
|
api.get('/api/v1/agentCategory/visibleList', {
|
||||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
params: { sortKey: 'sortNo', sortType: 'asc' },
|
||||||
}),
|
}),
|
||||||
getAgentModels(),
|
getAgentModels(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (categoryResult.status === 'fulfilled') {
|
||||||
|
categories.value = (categoryResult.value.data || []).map((item: any) => ({
|
||||||
|
label: item.categoryName || item.name,
|
||||||
|
value: String(item.id),
|
||||||
|
raw: item,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (modelResult.status === 'fulfilled') {
|
||||||
|
models.value = (modelResult.value.data || []).map((item: any) => ({
|
||||||
|
label: item.title || item.name,
|
||||||
|
value: String(item.id),
|
||||||
|
raw: item,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDeferredOptions() {
|
||||||
|
const [knowledgeResult, workflowResult, pluginResult, mcpResult] =
|
||||||
|
await Promise.allSettled([
|
||||||
getPublishedKnowledgeList(),
|
getPublishedKnowledgeList(),
|
||||||
api.get('/api/v1/workflow/page', {
|
api.get('/api/v1/workflow/page', {
|
||||||
params: { pageNumber: 1, pageSize: 200 },
|
params: { pageNumber: 1, pageSize: 200 },
|
||||||
@@ -205,37 +244,37 @@ async function loadOptions() {
|
|||||||
api.get('/api/v1/plugin/pageByCategory', {
|
api.get('/api/v1/plugin/pageByCategory', {
|
||||||
params: { pageNumber: 1, pageSize: 200, category: 0 },
|
params: { pageNumber: 1, pageSize: 200, category: 0 },
|
||||||
}),
|
}),
|
||||||
api.get('/api/v1/mcp/pageTools', {
|
getMcpPage(),
|
||||||
params: { pageNumber: 1, pageSize: 200, status: 1 },
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
categories.value = (categoryRes.data || []).map((item: any) => ({
|
if (knowledgeResult.status === 'fulfilled') {
|
||||||
label: item.categoryName || item.name,
|
knowledges.value = (knowledgeResult.value.data || []).map((item: any) => ({
|
||||||
value: String(item.id),
|
|
||||||
raw: item,
|
|
||||||
}));
|
|
||||||
models.value = (modelRes.data || []).map((item: any) => ({
|
|
||||||
label: item.title || item.name,
|
|
||||||
value: String(item.id),
|
|
||||||
raw: item,
|
|
||||||
}));
|
|
||||||
knowledges.value = (knowledgeRes.data || []).map((item: any) => ({
|
|
||||||
label: item.title || item.name,
|
label: item.title || item.name,
|
||||||
value: String(item.id),
|
value: String(item.id),
|
||||||
raw: item,
|
raw: item,
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
if (workflowResult.status === 'fulfilled') {
|
||||||
workflows.value = (
|
workflows.value = (
|
||||||
(workflowRes.data?.records || workflowRes.data || []) as any[]
|
(workflowResult.value.data?.records ||
|
||||||
|
workflowResult.value.data ||
|
||||||
|
[]) as any[]
|
||||||
).map((item) => ({
|
).map((item) => ({
|
||||||
label: item.title || item.name,
|
label: item.title || item.name,
|
||||||
value: String(item.id),
|
value: String(item.id),
|
||||||
raw: item,
|
raw: item,
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
if (pluginResult.status === 'fulfilled') {
|
||||||
pluginTools.value = flattenPluginTools(
|
pluginTools.value = flattenPluginTools(
|
||||||
pluginRes.data?.records || pluginRes.data || [],
|
pluginResult.value.data?.records || pluginResult.value.data || [],
|
||||||
);
|
);
|
||||||
mcps.value = mapMcpOptions(mcpRes.data?.records || mcpRes.data || []);
|
}
|
||||||
|
if (mcpResult.status === 'fulfilled') {
|
||||||
|
mcps.value = mapMcpOptions(
|
||||||
|
mcpResult.value.data?.records || mcpResult.value.data || [],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenPluginTools(list: any[]): AgentOption[] {
|
function flattenPluginTools(list: any[]): AgentOption[] {
|
||||||
@@ -269,6 +308,56 @@ function mapMcpOptions(list: any[]): AgentOption[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMcpToolsForOption(id: number | string) {
|
||||||
|
const key = String(id || '').trim();
|
||||||
|
if (!key || mcpToolsLoading.value[key]) return;
|
||||||
|
|
||||||
|
mcpToolsLoading.value = {
|
||||||
|
...mcpToolsLoading.value,
|
||||||
|
[key]: true,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const resource = await fetchMcpToolResource(key);
|
||||||
|
if (!resource) return;
|
||||||
|
|
||||||
|
const currentOption = mcps.value.find((item) => String(item.value) === key);
|
||||||
|
const mergedResource = {
|
||||||
|
...currentOption?.raw,
|
||||||
|
...resource,
|
||||||
|
tools: Array.isArray(resource.tools) ? resource.tools : [],
|
||||||
|
};
|
||||||
|
if (currentOption) {
|
||||||
|
mcps.value = mcps.value.map((item) =>
|
||||||
|
String(item.value) === key
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
label:
|
||||||
|
resource.title || resource.name || currentOption.label || 'MCP',
|
||||||
|
raw: mergedResource,
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state.toolBindings.forEach((binding) => {
|
||||||
|
if (
|
||||||
|
String(binding.toolType || '').toUpperCase() === 'MCP' &&
|
||||||
|
String(binding.targetId || '') === key
|
||||||
|
) {
|
||||||
|
binding.resourceSummary = {
|
||||||
|
...binding.resourceSummary,
|
||||||
|
...mergedResource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载 MCP 工具失败', error);
|
||||||
|
} finally {
|
||||||
|
const nextLoading = { ...mcpToolsLoading.value };
|
||||||
|
delete nextLoading[key];
|
||||||
|
mcpToolsLoading.value = nextLoading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAdd(kind: AgentCapabilityKind) {
|
async function handleAdd(kind: AgentCapabilityKind) {
|
||||||
if (kind === 'knowledge') {
|
if (kind === 'knowledge') {
|
||||||
addKnowledgeNode();
|
addKnowledgeNode();
|
||||||
@@ -417,7 +506,9 @@ function handleCloseTryout() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-loading="pageLoading" class="agent-designer">
|
<div v-loading="pageLoading" class="agent-designer">
|
||||||
|
<template v-if="contentReady">
|
||||||
<AgentStudioCanvas
|
<AgentStudioCanvas
|
||||||
|
:active="canvasActive"
|
||||||
:state="state"
|
:state="state"
|
||||||
:knowledge-options="knowledges"
|
:knowledge-options="knowledges"
|
||||||
:mcp-options="mcps"
|
:mcp-options="mcps"
|
||||||
@@ -434,10 +525,12 @@ function handleCloseTryout() {
|
|||||||
:workflows="workflows"
|
:workflows="workflows"
|
||||||
:plugin-tools="pluginTools"
|
:plugin-tools="pluginTools"
|
||||||
:mcps="mcps"
|
:mcps="mcps"
|
||||||
|
:mcp-tools-loading="mcpToolsLoading"
|
||||||
:issues="issues"
|
:issues="issues"
|
||||||
@change="markDirty"
|
@change="markDirty"
|
||||||
@remove-capability="removeSelectedCapability"
|
@remove-capability="removeSelectedCapability"
|
||||||
@close-tryout="handleCloseTryout"
|
@close-tryout="handleCloseTryout"
|
||||||
|
@load-mcp-tools="loadMcpToolsForOption"
|
||||||
@select-issue="handleSelectIssue"
|
@select-issue="handleSelectIssue"
|
||||||
/>
|
/>
|
||||||
<AgentCommandBar
|
<AgentCommandBar
|
||||||
@@ -454,6 +547,7 @@ function handleCloseTryout() {
|
|||||||
@publish="handlePublish"
|
@publish="handlePublish"
|
||||||
@tryout="handleTryout"
|
@tryout="handleTryout"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -109,3 +109,13 @@ export function getPublishedKnowledgeList() {
|
|||||||
params: { publishedOnly: true },
|
params: { publishedOnly: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMcpPage() {
|
||||||
|
return api.get<RequestResult<any>>('/api/v1/mcp/page', {
|
||||||
|
params: { pageNumber: 1, pageSize: 200, status: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMcpTools(id: number | string) {
|
||||||
|
return api.post<RequestResult<any>>('/api/v1/mcp/getMcpTools', { id });
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,22 +5,27 @@ import type {
|
|||||||
AgentValidationIssue,
|
AgentValidationIssue,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import {
|
||||||
|
computed,
|
||||||
|
defineAsyncComponent,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
import { Close } from '@element-plus/icons-vue';
|
import { Close } from '@element-plus/icons-vue';
|
||||||
import { ElButton, ElTabPane, ElTabs } from 'element-plus';
|
import { ElButton, ElTabPane, ElTabs } from 'element-plus';
|
||||||
|
|
||||||
import AgentBaseForm from './AgentBaseForm.vue';
|
import AgentBaseForm from './AgentBaseForm.vue';
|
||||||
import AgentInteractionForm from './AgentInteractionForm.vue';
|
|
||||||
import AgentKnowledgeForm from './AgentKnowledgeForm.vue';
|
|
||||||
import AgentToolForm from './AgentToolForm.vue';
|
|
||||||
import AgentTryoutPanel from './AgentTryoutPanel.vue';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
categories: AgentOption[];
|
categories: AgentOption[];
|
||||||
issues: AgentValidationIssue[];
|
issues: AgentValidationIssue[];
|
||||||
knowledges: AgentOption[];
|
knowledges: AgentOption[];
|
||||||
mcps: AgentOption[];
|
mcps: AgentOption[];
|
||||||
|
mcpToolsLoading: Record<string, boolean>;
|
||||||
models: AgentOption[];
|
models: AgentOption[];
|
||||||
pluginTools: AgentOption[];
|
pluginTools: AgentOption[];
|
||||||
state: AgentDraftState;
|
state: AgentDraftState;
|
||||||
@@ -30,10 +35,24 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
change: [];
|
change: [];
|
||||||
closeTryout: [];
|
closeTryout: [];
|
||||||
|
loadMcpTools: [id: string];
|
||||||
removeCapability: [];
|
removeCapability: [];
|
||||||
selectIssue: [nodeId: string];
|
selectIssue: [nodeId: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const loadAgentInteractionForm = () => import('./AgentInteractionForm.vue');
|
||||||
|
const loadAgentKnowledgeForm = () => import('./AgentKnowledgeForm.vue');
|
||||||
|
const loadAgentToolForm = () => import('./AgentToolForm.vue');
|
||||||
|
const loadAgentTryoutPanel = () => import('./AgentTryoutPanel.vue');
|
||||||
|
const AgentInteractionForm = defineAsyncComponent(loadAgentInteractionForm);
|
||||||
|
const AgentKnowledgeForm = defineAsyncComponent(loadAgentKnowledgeForm);
|
||||||
|
const AgentToolForm = defineAsyncComponent(loadAgentToolForm);
|
||||||
|
const AgentTryoutPanel = defineAsyncComponent(loadAgentTryoutPanel);
|
||||||
|
|
||||||
|
interface AgentInteractionFormExpose {
|
||||||
|
focusField: (field: string) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
const selectedKnowledge = computed(() => {
|
const selectedKnowledge = computed(() => {
|
||||||
if (!props.state.selectedNodeId.startsWith('knowledge:')) return;
|
if (!props.state.selectedNodeId.startsWith('knowledge:')) return;
|
||||||
const localId = props.state.selectedNodeId.slice('knowledge:'.length);
|
const localId = props.state.selectedNodeId.slice('knowledge:'.length);
|
||||||
@@ -41,7 +60,7 @@ const selectedKnowledge = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const activeBaseTab = ref<'basic' | 'interaction'>('basic');
|
const activeBaseTab = ref<'basic' | 'interaction'>('basic');
|
||||||
const interactionForm = ref<InstanceType<typeof AgentInteractionForm>>();
|
const interactionForm = ref<AgentInteractionFormExpose>();
|
||||||
const selectedModel = computed(() =>
|
const selectedModel = computed(() =>
|
||||||
props.models.find((item) => item.value === String(props.state.agent.modelId)),
|
props.models.find((item) => item.value === String(props.state.agent.modelId)),
|
||||||
);
|
);
|
||||||
@@ -56,7 +75,7 @@ function isInteractionIssue(issue?: AgentValidationIssue) {
|
|||||||
async function focusIssue(issue: AgentValidationIssue) {
|
async function focusIssue(issue: AgentValidationIssue) {
|
||||||
if (issue.nodeId !== 'agent-base') return;
|
if (issue.nodeId !== 'agent-base') return;
|
||||||
activeBaseTab.value = isInteractionIssue(issue) ? 'interaction' : 'basic';
|
activeBaseTab.value = isInteractionIssue(issue) ? 'interaction' : 'basic';
|
||||||
if (isInteractionIssue(issue)) {
|
if (isInteractionIssue(issue) && issue.field) {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
await interactionForm.value?.focusField(issue.field);
|
await interactionForm.value?.focusField(issue.field);
|
||||||
}
|
}
|
||||||
@@ -94,6 +113,49 @@ const selectedToolOptions = computed(() => {
|
|||||||
if (selectedToolKind.value === 'mcp') return props.mcps;
|
if (selectedToolKind.value === 'mcp') return props.mcps;
|
||||||
return props.pluginTools;
|
return props.pluginTools;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() =>
|
||||||
|
selectedToolKind.value === 'mcp'
|
||||||
|
? String(selectedTool.value?.targetId || '')
|
||||||
|
: '',
|
||||||
|
(mcpId) => {
|
||||||
|
if (mcpId) {
|
||||||
|
emit('loadMcpTools', mcpId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
let prefetchHandle: number | undefined;
|
||||||
|
let prefetchTimer: number | undefined;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const prefetchHiddenPanels = () => {
|
||||||
|
void Promise.allSettled([
|
||||||
|
loadAgentInteractionForm(),
|
||||||
|
loadAgentKnowledgeForm(),
|
||||||
|
loadAgentToolForm(),
|
||||||
|
loadAgentTryoutPanel(),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
if (typeof window.requestIdleCallback === 'function') {
|
||||||
|
prefetchHandle = window.requestIdleCallback(prefetchHiddenPanels, {
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prefetchTimer = window.setTimeout(prefetchHiddenPanels, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (prefetchHandle !== undefined) {
|
||||||
|
window.cancelIdleCallback(prefetchHandle);
|
||||||
|
}
|
||||||
|
if (prefetchTimer !== undefined) {
|
||||||
|
window.clearTimeout(prefetchTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -162,6 +224,9 @@ const selectedToolOptions = computed(() => {
|
|||||||
v-else-if="selectedTool"
|
v-else-if="selectedTool"
|
||||||
:binding="selectedTool"
|
:binding="selectedTool"
|
||||||
:kind="selectedToolKind"
|
:kind="selectedToolKind"
|
||||||
|
:mcp-tools-loading="
|
||||||
|
Boolean(mcpToolsLoading[String(selectedTool.targetId || '')])
|
||||||
|
"
|
||||||
:options="selectedToolOptions"
|
:options="selectedToolOptions"
|
||||||
@change="emit('change')"
|
@change="emit('change')"
|
||||||
@remove="emit('removeCapability')"
|
@remove="emit('removeCapability')"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
binding: AgentToolBinding;
|
binding: AgentToolBinding;
|
||||||
kind: 'mcp' | 'plugin' | 'workflow';
|
kind: 'mcp' | 'plugin' | 'workflow';
|
||||||
|
mcpToolsLoading?: boolean;
|
||||||
options: AgentOption[];
|
options: AgentOption[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -129,7 +130,11 @@ function handleTargetChange(value: string) {
|
|||||||
/>
|
/>
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<div v-if="kind === 'mcp'" class="agent-form__mcp-tools">
|
<div
|
||||||
|
v-if="kind === 'mcp'"
|
||||||
|
v-loading="mcpToolsLoading"
|
||||||
|
class="agent-form__mcp-tools"
|
||||||
|
>
|
||||||
<div class="agent-form__mcp-tools-header">
|
<div class="agent-form__mcp-tools-header">
|
||||||
<span>工具列表</span>
|
<span>工具列表</span>
|
||||||
<span>{{ selectedMcpToolCount }} 个</span>
|
<span>{{ selectedMcpToolCount }} 个</span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {AgentDraftState, AgentOption} from '../../types';
|
import type { AgentDraftState, AgentOption } from '../../types';
|
||||||
import type {
|
import type {
|
||||||
AgentStudioCanvasSize,
|
AgentStudioCanvasSize,
|
||||||
AgentStudioConnectionView,
|
AgentStudioConnectionView,
|
||||||
@@ -10,36 +10,46 @@ import type {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
createApp,
|
|
||||||
h,
|
h,
|
||||||
|
nextTick,
|
||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
onMounted,
|
onMounted,
|
||||||
reactive,
|
|
||||||
ref,
|
ref,
|
||||||
|
render as renderVueVNode,
|
||||||
shallowRef,
|
shallowRef,
|
||||||
watch,
|
watch,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
|
||||||
import {usePreferences} from '@easyflow/preferences';
|
import { usePreferences } from '@easyflow/preferences';
|
||||||
|
|
||||||
import {Tinyflow} from '@tinyflow-ai/vue';
|
import { Tinyflow } from '@tinyflow-ai/vue';
|
||||||
|
|
||||||
import {useAgentStudioLayout} from '../../composables/agent-studio/useAgentStudioLayout';
|
import { useAgentStudioLayout } from '../../composables/agent-studio/useAgentStudioLayout';
|
||||||
import {useAgentStudioModel} from '../../composables/agent-studio/useAgentStudioModel';
|
import { useAgentStudioModel } from '../../composables/agent-studio/useAgentStudioModel';
|
||||||
import AgentStudioBaseNode from './AgentStudioBaseNode.vue';
|
import AgentStudioBaseNode from './AgentStudioBaseNode.vue';
|
||||||
import AgentStudioCapabilityNode from './AgentStudioCapabilityNode.vue';
|
import AgentStudioCapabilityNode from './AgentStudioCapabilityNode.vue';
|
||||||
import AgentStudioEdgeLayer from './AgentStudioEdgeLayer.vue';
|
import AgentStudioEdgeLayer from './AgentStudioEdgeLayer.vue';
|
||||||
|
|
||||||
import '@tinyflow-ai/vue/dist/index.css';
|
import '@tinyflow-ai/vue/dist/index.css';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
active?: boolean;
|
||||||
knowledgeOptions?: AgentOption[];
|
knowledgeOptions?: AgentOption[];
|
||||||
mcpOptions?: AgentOption[];
|
mcpOptions?: AgentOption[];
|
||||||
pluginOptions?: AgentOption[];
|
pluginOptions?: AgentOption[];
|
||||||
selectedNodeId: string;
|
selectedNodeId: string;
|
||||||
state: AgentDraftState;
|
state: AgentDraftState;
|
||||||
workflowOptions?: AgentOption[];
|
workflowOptions?: AgentOption[];
|
||||||
}>();
|
}>(),
|
||||||
|
{
|
||||||
|
active: true,
|
||||||
|
knowledgeOptions: () => [],
|
||||||
|
mcpOptions: () => [],
|
||||||
|
pluginOptions: () => [],
|
||||||
|
workflowOptions: () => [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [nodeId: string];
|
select: [nodeId: string];
|
||||||
@@ -61,25 +71,33 @@ const canvasModel = useAgentStudioModel(
|
|||||||
workflow: props.workflowOptions || [],
|
workflow: props.workflowOptions || [],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const liveNodes = ref<AgentStudioNodeView[]>([]);
|
const liveNodes = shallowRef<AgentStudioNodeView[]>([]);
|
||||||
const liveViewport = ref<AgentStudioViewport>({ x: 250, y: 100, zoom: 1 });
|
const liveViewport = shallowRef<AgentStudioViewport>({
|
||||||
const flowData = computed(() => {
|
x: 250,
|
||||||
|
y: 100,
|
||||||
|
zoom: 1,
|
||||||
|
});
|
||||||
|
function buildFlowData() {
|
||||||
const liveNodeMap = new Map(liveNodes.value.map((node) => [node.id, node]));
|
const liveNodeMap = new Map(liveNodes.value.map((node) => [node.id, node]));
|
||||||
return {
|
return {
|
||||||
edges: canvasModel.value.edges.map((edge) => ({ ...edge })),
|
edges: canvasModel.value.edges,
|
||||||
nodes: canvasModel.value.nodes.map((node) => {
|
nodes: canvasModel.value.nodes.map((node) => {
|
||||||
const liveNode = liveNodeMap.get(node.id);
|
const liveNode = liveNodeMap.get(node.id);
|
||||||
|
const position = liveNode?.position || node.position;
|
||||||
|
if (position === node.position) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
data: { ...node.data },
|
position,
|
||||||
position: { ...(liveNode?.position || node.position) },
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
viewport: layout.viewport ? { ...layout.viewport } : undefined,
|
viewport: layout.viewport ? { ...layout.viewport } : undefined,
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
const lastCaptureSignature = ref('');
|
let lastCapturedEdges: unknown;
|
||||||
const lastFlowDataSignature = ref('');
|
let lastCapturedNodes: unknown;
|
||||||
|
let lastCapturedViewport: unknown;
|
||||||
|
|
||||||
const customNodes = computed(() => ({
|
const customNodes = computed(() => ({
|
||||||
agentStudioBase: {
|
agentStudioBase: {
|
||||||
@@ -90,6 +108,7 @@ const customNodes = computed(() => ({
|
|||||||
onUpdate: (parent: HTMLElement, node: any) => {
|
onUpdate: (parent: HTMLElement, node: any) => {
|
||||||
renderVueNode(parent, AgentStudioBaseNode, node.data);
|
renderVueNode(parent, AgentStudioBaseNode, node.data);
|
||||||
},
|
},
|
||||||
|
onDestroy: unmountVueNode,
|
||||||
},
|
},
|
||||||
agentStudioCapability: {
|
agentStudioCapability: {
|
||||||
presentation: 'plain',
|
presentation: 'plain',
|
||||||
@@ -99,6 +118,7 @@ const customNodes = computed(() => ({
|
|||||||
onUpdate: (parent: HTMLElement, node: any) => {
|
onUpdate: (parent: HTMLElement, node: any) => {
|
||||||
renderVueNode(parent, AgentStudioCapabilityNode, node.data);
|
renderVueNode(parent, AgentStudioCapabilityNode, node.data);
|
||||||
},
|
},
|
||||||
|
onDestroy: unmountVueNode,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -116,7 +136,7 @@ const connections = computed<AgentStudioConnectionView[]>(() => {
|
|||||||
return {
|
return {
|
||||||
active: false,
|
active: false,
|
||||||
id: edge.id,
|
id: edge.id,
|
||||||
path: buildConnectionPath(source, target, liveViewport.value),
|
path: buildConnectionPath(source, target),
|
||||||
sourceId: edge.source,
|
sourceId: edge.source,
|
||||||
targetId: edge.target,
|
targetId: edge.target,
|
||||||
};
|
};
|
||||||
@@ -132,37 +152,28 @@ function renderVueNode(
|
|||||||
const current = (parent as any).__agentStudioNodeView as
|
const current = (parent as any).__agentStudioNodeView as
|
||||||
| undefined
|
| undefined
|
||||||
| {
|
| {
|
||||||
app: ReturnType<typeof createApp>;
|
|
||||||
component: any;
|
component: any;
|
||||||
data: AgentStudioNodeData;
|
source: AgentStudioNodeData;
|
||||||
signature: string;
|
|
||||||
};
|
};
|
||||||
const signature = JSON.stringify(data);
|
|
||||||
if (current && current.component === component) {
|
if (current && current.component === component) {
|
||||||
if (current.signature === signature) {
|
if (current.source === data) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Object.assign(current.data, data);
|
current.source = data;
|
||||||
current.signature = signature;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
current?.app.unmount();
|
renderVueVNode(h(component, { data }), parent);
|
||||||
parent.textContent = '';
|
|
||||||
|
|
||||||
const reactiveData = reactive({ ...data }) as AgentStudioNodeData;
|
|
||||||
const app = createApp({
|
|
||||||
render: () => h(component, { data: reactiveData }),
|
|
||||||
});
|
|
||||||
app.mount(parent);
|
|
||||||
(parent as any).__agentStudioNodeView = {
|
(parent as any).__agentStudioNodeView = {
|
||||||
app,
|
|
||||||
component,
|
component,
|
||||||
data: reactiveData,
|
source: data,
|
||||||
signature,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unmountVueNode(parent: HTMLElement) {
|
||||||
|
renderVueVNode(null, parent);
|
||||||
|
delete (parent as any).__agentStudioNodeView;
|
||||||
|
}
|
||||||
|
|
||||||
function getNodeSize(node: AgentStudioNodeView) {
|
function getNodeSize(node: AgentStudioNodeView) {
|
||||||
return {
|
return {
|
||||||
height: node.height || (node.data.kind === 'base' ? 104 : 78),
|
height: node.height || (node.data.kind === 'base' ? 104 : 78),
|
||||||
@@ -170,20 +181,9 @@ function getNodeSize(node: AgentStudioNodeView) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
function buildConnectionPath(
|
||||||
source: AgentStudioNodeView,
|
source: AgentStudioNodeView,
|
||||||
target: AgentStudioNodeView,
|
target: AgentStudioNodeView,
|
||||||
viewport: AgentStudioViewport,
|
|
||||||
) {
|
) {
|
||||||
const sourceSize = getNodeSize(source);
|
const sourceSize = getNodeSize(source);
|
||||||
const targetSize = getNodeSize(target);
|
const targetSize = getNodeSize(target);
|
||||||
@@ -204,80 +204,119 @@ function buildConnectionPath(
|
|||||||
x: target.position.x + (sourceOnLeft ? targetSize.width : 0),
|
x: target.position.x + (sourceOnLeft ? targetSize.width : 0),
|
||||||
y: targetCenter.y,
|
y: targetCenter.y,
|
||||||
};
|
};
|
||||||
const screenStart = toScreenPoint(start, viewport);
|
const midX = (start.x + end.x) / 2;
|
||||||
const screenEnd = toScreenPoint(end, viewport);
|
|
||||||
const midX = (screenStart.x + screenEnd.x) / 2;
|
|
||||||
return [
|
return [
|
||||||
`M ${screenStart.x} ${screenStart.y}`,
|
`M ${start.x} ${start.y}`,
|
||||||
`L ${midX} ${screenStart.y}`,
|
`L ${midX} ${start.y}`,
|
||||||
`L ${midX} ${screenEnd.y}`,
|
`L ${midX} ${end.y}`,
|
||||||
`L ${screenEnd.x} ${screenEnd.y}`,
|
`L ${end.x} ${end.y}`,
|
||||||
].join(' ');
|
].join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDataChange(data: any) {
|
function handleDataChange(data: any) {
|
||||||
const signature = buildFlowDataSignature(data);
|
if (!props.active) {
|
||||||
if (signature === lastCaptureSignature.value) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastCaptureSignature.value = signature;
|
const edges = Array.isArray(data?.edges) ? data.edges : undefined;
|
||||||
|
const nodes = Array.isArray(data?.nodes)
|
||||||
|
? (data.nodes as AgentStudioNodeView[])
|
||||||
|
: undefined;
|
||||||
|
const viewport = data?.viewport as AgentStudioViewport | undefined;
|
||||||
|
if (
|
||||||
|
edges === lastCapturedEdges &&
|
||||||
|
nodes === lastCapturedNodes &&
|
||||||
|
viewport === lastCapturedViewport
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastCapturedEdges = edges;
|
||||||
|
lastCapturedNodes = nodes;
|
||||||
|
lastCapturedViewport = viewport;
|
||||||
|
|
||||||
if (Array.isArray(data?.nodes)) {
|
if (nodes) {
|
||||||
liveNodes.value = data.nodes as AgentStudioNodeView[];
|
liveNodes.value = nodes;
|
||||||
}
|
}
|
||||||
if (data?.viewport) {
|
if (viewport) {
|
||||||
liveViewport.value = data.viewport as AgentStudioViewport;
|
liveViewport.value = viewport;
|
||||||
}
|
}
|
||||||
capture({
|
const selected = nodes?.find((node: any) => node.selected);
|
||||||
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) {
|
if (selected?.id && selected.id !== props.selectedNodeId) {
|
||||||
emit('select', selected.id);
|
emit('select', selected.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFlowDataSignature(data: any) {
|
function handleDataCommit(data: any) {
|
||||||
const nodes = Array.isArray(data?.nodes)
|
if (!props.active) {
|
||||||
? data.nodes.map((node: any) => ({
|
return;
|
||||||
data: node.data,
|
}
|
||||||
id: node.id,
|
capture({
|
||||||
position: node.position,
|
nodes: Array.isArray(data?.nodes)
|
||||||
selected: node.selected,
|
? (data.nodes as AgentStudioNodeView[])
|
||||||
type: node.type,
|
: undefined,
|
||||||
}))
|
viewport: data?.viewport as AgentStudioViewport | undefined,
|
||||||
: [];
|
|
||||||
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);
|
function shallowEqual(left: object, right: object) {
|
||||||
let resizeObserver: ResizeObserver | undefined;
|
if (left === right) {
|
||||||
|
return true;
|
||||||
function syncFlowData() {
|
|
||||||
const next = flowData.value;
|
|
||||||
const signature = buildFlowDataSignature(next);
|
|
||||||
if (signature === lastFlowDataSignature.value) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
lastFlowDataSignature.value = signature;
|
const leftRecord = left as Record<string, unknown>;
|
||||||
stableFlowData.value = next;
|
const rightRecord = right as Record<string, unknown>;
|
||||||
|
const leftKeys = Object.keys(leftRecord);
|
||||||
|
if (leftKeys.length !== Object.keys(rightRecord).length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return leftKeys.every((key) => Object.is(leftRecord[key], rightRecord[key]));
|
||||||
}
|
}
|
||||||
|
|
||||||
syncFlowData();
|
function canvasModelsEqual(
|
||||||
|
previous: typeof canvasModel.value,
|
||||||
|
next: typeof canvasModel.value,
|
||||||
|
comparePositions: boolean,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
previous.nodes.length !== next.nodes.length ||
|
||||||
|
previous.edges.length !== next.edges.length
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nodesEqual = previous.nodes.every((node, index) => {
|
||||||
|
const nextNode = next.nodes[index];
|
||||||
|
return (
|
||||||
|
node.id === nextNode?.id &&
|
||||||
|
node.type === nextNode.type &&
|
||||||
|
node.width === nextNode.width &&
|
||||||
|
node.height === nextNode.height &&
|
||||||
|
shallowEqual(node.data, nextNode.data) &&
|
||||||
|
(!comparePositions ||
|
||||||
|
(node.position.x === nextNode.position.x &&
|
||||||
|
node.position.y === nextNode.position.y))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!nodesEqual) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return previous.edges.every((edge, index) => {
|
||||||
|
const nextEdge = next.edges[index];
|
||||||
|
return nextEdge ? shallowEqual(edge, nextEdge) : false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
watch(flowData, syncFlowData);
|
const stableFlowData = shallowRef(buildFlowData());
|
||||||
|
let lastCanvasModel = canvasModel.value;
|
||||||
|
let resizeObserver: ResizeObserver | undefined;
|
||||||
|
|
||||||
|
function syncFlowData(nextModel: typeof canvasModel.value) {
|
||||||
|
const comparePositions = liveNodes.value.length === 0;
|
||||||
|
if (canvasModelsEqual(lastCanvasModel, nextModel, comparePositions)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastCanvasModel = nextModel;
|
||||||
|
stableFlowData.value = buildFlowData();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(canvasModel, syncFlowData);
|
||||||
|
|
||||||
function updateCanvasSize() {
|
function updateCanvasSize() {
|
||||||
const rect = canvasRef.value?.getBoundingClientRect();
|
const rect = canvasRef.value?.getBoundingClientRect();
|
||||||
@@ -295,16 +334,41 @@ function updateCanvasSize() {
|
|||||||
canvasSize.value = next;
|
canvasSize.value = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
function stopCanvasObservation() {
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCanvasObservation() {
|
||||||
|
stopCanvasObservation();
|
||||||
|
if (!props.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateCanvasSize();
|
updateCanvasSize();
|
||||||
if (typeof ResizeObserver === 'undefined' || !canvasRef.value) return;
|
if (typeof ResizeObserver === 'undefined' || !canvasRef.value) return;
|
||||||
resizeObserver = new ResizeObserver(updateCanvasSize);
|
resizeObserver = new ResizeObserver(updateCanvasSize);
|
||||||
resizeObserver.observe(canvasRef.value);
|
resizeObserver.observe(canvasRef.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startCanvasObservation();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
resizeObserver?.disconnect();
|
stopCanvasObservation();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
async (active) => {
|
||||||
|
if (!active) {
|
||||||
|
stopCanvasObservation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
startCanvasObservation();
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -329,8 +393,9 @@ onBeforeUnmount(() => {
|
|||||||
:elements-selectable="true"
|
:elements-selectable="true"
|
||||||
:drop-enabled="false"
|
:drop-enabled="false"
|
||||||
:on-data-change="handleDataChange"
|
:on-data-change="handleDataChange"
|
||||||
|
:on-data-commit="handleDataCommit"
|
||||||
/>
|
/>
|
||||||
<AgentStudioEdgeLayer :connections="connections" />
|
<AgentStudioEdgeLayer :connections="connections" :viewport="liveViewport" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {AgentStudioConnectionView} from './types';
|
import type { AgentStudioConnectionView, AgentStudioViewport } from './types';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
connections: AgentStudioConnectionView[];
|
connections: AgentStudioConnectionView[];
|
||||||
|
viewport: AgentStudioViewport;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<svg class="agent-studio-edge-layer" aria-hidden="true">
|
<svg class="agent-studio-edge-layer" aria-hidden="true">
|
||||||
|
<g
|
||||||
|
:transform="`translate(${viewport.x} ${viewport.y}) scale(${viewport.zoom})`"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
v-for="connection in connections"
|
v-for="connection in connections"
|
||||||
:key="connection.id"
|
:key="connection.id"
|
||||||
@@ -17,6 +21,7 @@ defineProps<{
|
|||||||
:data-source-id="connection.sourceId"
|
:data-source-id="connection.sourceId"
|
||||||
:data-target-id="connection.targetId"
|
:data-target-id="connection.targetId"
|
||||||
/>
|
/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -93,11 +93,12 @@ export function useAgentStudioLayout(state: AgentDraftState) {
|
|||||||
}) {
|
}) {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
if (Array.isArray(data.nodes)) {
|
if (Array.isArray(data.nodes)) {
|
||||||
|
let positionsChanged = false;
|
||||||
const activeIds = new Set(data.nodes.map((node) => node.id));
|
const activeIds = new Set(data.nodes.map((node) => node.id));
|
||||||
const nextPositions: AgentStudioLayoutSnapshot['nodePositions'] = {};
|
const nextPositions: AgentStudioLayoutSnapshot['nodePositions'] = {};
|
||||||
for (const [key, position] of Object.entries(snapshot.nodePositions)) {
|
for (const [key, position] of Object.entries(snapshot.nodePositions)) {
|
||||||
if (activeIds.has(key)) nextPositions[key] = position;
|
if (activeIds.has(key)) nextPositions[key] = position;
|
||||||
else changed = true;
|
else positionsChanged = true;
|
||||||
}
|
}
|
||||||
data.nodes.forEach((node) => {
|
data.nodes.forEach((node) => {
|
||||||
if (isFinitePosition(node.position)) {
|
if (isFinitePosition(node.position)) {
|
||||||
@@ -108,11 +109,14 @@ export function useAgentStudioLayout(state: AgentDraftState) {
|
|||||||
current.y !== node.position.y
|
current.y !== node.position.y
|
||||||
) {
|
) {
|
||||||
nextPositions[node.id] = { ...node.position };
|
nextPositions[node.id] = { ...node.position };
|
||||||
changed = true;
|
positionsChanged = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (positionsChanged) {
|
||||||
snapshot.nodePositions = nextPositions;
|
snapshot.nodePositions = nextPositions;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
isFiniteViewport(data.viewport) &&
|
isFiniteViewport(data.viewport) &&
|
||||||
|
|||||||
@@ -100,4 +100,45 @@ describe('useAgentStudioModel', () => {
|
|||||||
expect(mcpNode?.data.title).toBe('context7 · 2 个工具');
|
expect(mcpNode?.data.title).toBe('context7 · 2 个工具');
|
||||||
expect(mcpNode?.data.detail).toBe('context7 · 2 个工具');
|
expect(mcpNode?.data.detail).toBe('context7 · 2 个工具');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('单次模型计算只读取一次工具选项集合', () => {
|
||||||
|
let optionReads = 0;
|
||||||
|
const model = useAgentStudioModel(
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
name: '测试智能体',
|
||||||
|
},
|
||||||
|
dirty: false,
|
||||||
|
knowledgeBindings: [],
|
||||||
|
panelMode: 'capability',
|
||||||
|
selectedNodeId: 'tool:plugin-1',
|
||||||
|
toolBindings: [
|
||||||
|
{
|
||||||
|
localId: 'plugin-1',
|
||||||
|
targetId: '1001',
|
||||||
|
toolType: 'PLUGIN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
localId: 'workflow-1',
|
||||||
|
targetId: '2001',
|
||||||
|
toolType: 'WORKFLOW',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
() => 'tool:plugin-1',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
() => {
|
||||||
|
optionReads += 1;
|
||||||
|
return {
|
||||||
|
plugin: [{ label: '插件', value: '1001' }],
|
||||||
|
workflow: [{ label: '工作流', value: '2001' }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.value.nodes).toHaveLength(3);
|
||||||
|
expect(optionReads).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,13 +31,36 @@ function firstText(...values: unknown[]) {
|
|||||||
return matched ? String(matched).trim() : '';
|
return matched ? String(matched).trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createKnowledgeOptionLookup(options: AgentOption[]) {
|
||||||
|
const lookup = new Map<string, AgentOption>();
|
||||||
|
options.forEach((option) => {
|
||||||
|
const key = String(option.value);
|
||||||
|
if (!lookup.has(key)) {
|
||||||
|
lookup.set(key, option);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createToolOptionLookup(options: AgentOption[]) {
|
||||||
|
const lookup = new Map<string, AgentOption>();
|
||||||
|
options.forEach((option) => {
|
||||||
|
const raw = option.raw || {};
|
||||||
|
[option.value, raw.id, raw.mcpId].forEach((value) => {
|
||||||
|
const key = String(value || '');
|
||||||
|
if (key && !lookup.has(key)) {
|
||||||
|
lookup.set(key, option);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
function buildKnowledgeTitle(
|
function buildKnowledgeTitle(
|
||||||
binding: AgentKnowledgeBinding,
|
binding: AgentKnowledgeBinding,
|
||||||
options: AgentOption[],
|
optionLookup: Map<string, AgentOption>,
|
||||||
) {
|
) {
|
||||||
const matchedOption = options.find(
|
const matchedOption = optionLookup.get(String(binding.knowledgeId));
|
||||||
(item) => String(item.value) === String(binding.knowledgeId),
|
|
||||||
);
|
|
||||||
return firstText(
|
return firstText(
|
||||||
binding.resourceSummary?.title,
|
binding.resourceSummary?.title,
|
||||||
binding.resourceSummary?.name,
|
binding.resourceSummary?.name,
|
||||||
@@ -59,22 +82,18 @@ function buildKnowledgeTitle(
|
|||||||
|
|
||||||
function findMatchedToolOption(
|
function findMatchedToolOption(
|
||||||
binding: AgentToolBinding,
|
binding: AgentToolBinding,
|
||||||
options: AgentOption[],
|
optionLookup: Map<string, AgentOption>,
|
||||||
) {
|
) {
|
||||||
const targetId = String(binding.targetId || '');
|
const targetId = String(binding.targetId || '');
|
||||||
if (!targetId) return undefined;
|
if (!targetId) return undefined;
|
||||||
return options.find((item) => {
|
return optionLookup.get(targetId);
|
||||||
const raw = item.raw || {};
|
|
||||||
return (
|
|
||||||
String(item.value) === targetId ||
|
|
||||||
String(raw.id || '') === targetId ||
|
|
||||||
String(raw.mcpId || '') === targetId
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildToolTitle(binding: AgentToolBinding, options: AgentOption[] = []) {
|
function buildToolTitle(
|
||||||
const matchedOption = findMatchedToolOption(binding, options);
|
binding: AgentToolBinding,
|
||||||
|
optionLookup: Map<string, AgentOption>,
|
||||||
|
matchedOption = findMatchedToolOption(binding, optionLookup),
|
||||||
|
) {
|
||||||
return firstText(
|
return firstText(
|
||||||
binding.resourceSummary?.title,
|
binding.resourceSummary?.title,
|
||||||
binding.resourceSummary?.name,
|
binding.resourceSummary?.name,
|
||||||
@@ -99,11 +118,11 @@ function buildToolTitle(binding: AgentToolBinding, options: AgentOption[] = [])
|
|||||||
function buildToolDetail(
|
function buildToolDetail(
|
||||||
binding: AgentToolBinding,
|
binding: AgentToolBinding,
|
||||||
fallback: string,
|
fallback: string,
|
||||||
options: AgentOption[] = [],
|
optionLookup: Map<string, AgentOption>,
|
||||||
) {
|
) {
|
||||||
if (String(binding.toolType || '').toUpperCase() === 'MCP') {
|
if (String(binding.toolType || '').toUpperCase() === 'MCP') {
|
||||||
const matchedOption = findMatchedToolOption(binding, options);
|
const matchedOption = findMatchedToolOption(binding, optionLookup);
|
||||||
const resourceName = buildToolTitle(binding, options);
|
const resourceName = buildToolTitle(binding, optionLookup, matchedOption);
|
||||||
const tools =
|
const tools =
|
||||||
binding.resourceSummary?.tools ||
|
binding.resourceSummary?.tools ||
|
||||||
binding.resourceSnapshot?.tools ||
|
binding.resourceSnapshot?.tools ||
|
||||||
@@ -116,7 +135,7 @@ function buildToolDetail(
|
|||||||
return resourceName || fallback;
|
return resourceName || fallback;
|
||||||
}
|
}
|
||||||
const toolName = firstText(binding.toolName);
|
const toolName = firstText(binding.toolName);
|
||||||
const resourceName = buildToolTitle(binding, options);
|
const resourceName = buildToolTitle(binding, optionLookup);
|
||||||
if (toolName && resourceName && toolName !== resourceName) {
|
if (toolName && resourceName && toolName !== resourceName) {
|
||||||
return `${resourceName} / ${toolName}`;
|
return `${resourceName} / ${toolName}`;
|
||||||
}
|
}
|
||||||
@@ -214,6 +233,16 @@ export function useAgentStudioModel(
|
|||||||
const positionOf = (nodeId: string, fallback: { x: number; y: number }) =>
|
const positionOf = (nodeId: string, fallback: { x: number; y: number }) =>
|
||||||
layout?.nodePositions?.[nodeId] || fallback;
|
layout?.nodePositions?.[nodeId] || fallback;
|
||||||
const size = canvasSize?.();
|
const size = canvasSize?.();
|
||||||
|
const resolvedKnowledgeOptions = knowledgeOptions?.() || [];
|
||||||
|
const resolvedToolOptions = toolOptions?.() || {};
|
||||||
|
const knowledgeOptionLookup = createKnowledgeOptionLookup(
|
||||||
|
resolvedKnowledgeOptions,
|
||||||
|
);
|
||||||
|
const toolOptionLookups = {
|
||||||
|
mcp: createToolOptionLookup(resolvedToolOptions.mcp || []),
|
||||||
|
plugin: createToolOptionLookup(resolvedToolOptions.plugin || []),
|
||||||
|
workflow: createToolOptionLookup(resolvedToolOptions.workflow || []),
|
||||||
|
};
|
||||||
const occupiedPositions: Array<{ x: number; y: number }> = Object.values(
|
const occupiedPositions: Array<{ x: number; y: number }> = Object.values(
|
||||||
layout?.nodePositions || {},
|
layout?.nodePositions || {},
|
||||||
);
|
);
|
||||||
@@ -238,7 +267,7 @@ export function useAgentStudioModel(
|
|||||||
|
|
||||||
const knowledgeNodes = state.knowledgeBindings.map((binding, index) => {
|
const knowledgeNodes = state.knowledgeBindings.map((binding, index) => {
|
||||||
const nodeId = `knowledge:${binding.localId}`;
|
const nodeId = `knowledge:${binding.localId}`;
|
||||||
const title = buildKnowledgeTitle(binding, knowledgeOptions?.() || []);
|
const title = buildKnowledgeTitle(binding, knowledgeOptionLookup);
|
||||||
const position = resolveCapabilityNodePosition({
|
const position = resolveCapabilityNodePosition({
|
||||||
canvasSize: size,
|
canvasSize: size,
|
||||||
fallbackIndex: index,
|
fallbackIndex: index,
|
||||||
@@ -270,10 +299,10 @@ export function useAgentStudioModel(
|
|||||||
const isWorkflow = toolType === 'WORKFLOW';
|
const isWorkflow = toolType === 'WORKFLOW';
|
||||||
const isMcp = toolType === 'MCP';
|
const isMcp = toolType === 'MCP';
|
||||||
const matchedOptions = isWorkflow
|
const matchedOptions = isWorkflow
|
||||||
? toolOptions?.().workflow || []
|
? toolOptionLookups.workflow
|
||||||
: isMcp
|
: isMcp
|
||||||
? toolOptions?.().mcp || []
|
? toolOptionLookups.mcp
|
||||||
: toolOptions?.().plugin || [];
|
: toolOptionLookups.plugin;
|
||||||
const fallback = isWorkflow
|
const fallback = isWorkflow
|
||||||
? '待选择工作流'
|
? '待选择工作流'
|
||||||
: isMcp
|
: isMcp
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createMcpToolLoader } from './mcpToolLoader';
|
||||||
|
|
||||||
|
describe('createMcpToolLoader', () => {
|
||||||
|
it('复用同一 MCP 的并发请求和已完成结果', async () => {
|
||||||
|
const request = vi.fn(async (id: string) => ({ id, tools: ['search'] }));
|
||||||
|
const load = createMcpToolLoader(request);
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([load('mcp-1'), load('mcp-1')]);
|
||||||
|
const third = await load('mcp-1');
|
||||||
|
|
||||||
|
expect(request).toHaveBeenCalledTimes(1);
|
||||||
|
expect(first).toEqual({ id: 'mcp-1', tools: ['search'] });
|
||||||
|
expect(second).toBe(first);
|
||||||
|
expect(third).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('分别加载不同的 MCP', async () => {
|
||||||
|
const request = vi.fn(async (id: string) => ({ id }));
|
||||||
|
const load = createMcpToolLoader(request);
|
||||||
|
|
||||||
|
await Promise.all([load('mcp-1'), load('mcp-2')]);
|
||||||
|
|
||||||
|
expect(request).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('请求失败后允许重新加载', async () => {
|
||||||
|
const request = vi
|
||||||
|
.fn<(id: string) => Promise<undefined | { id: string }>>()
|
||||||
|
.mockRejectedValueOnce(new Error('network error'))
|
||||||
|
.mockResolvedValueOnce({ id: 'mcp-1' });
|
||||||
|
const load = createMcpToolLoader(request);
|
||||||
|
|
||||||
|
await expect(load('mcp-1')).rejects.toThrow('network error');
|
||||||
|
await expect(load('mcp-1')).resolves.toEqual({ id: 'mcp-1' });
|
||||||
|
expect(request).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
35
easyflow-ui-admin/app/src/views/ai/agents/mcpToolLoader.ts
Normal file
35
easyflow-ui-admin/app/src/views/ai/agents/mcpToolLoader.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
type McpToolRequester<T> = (id: string) => Promise<T | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建页面级 MCP 工具加载器,同一 MCP 在页面生命周期内只请求一次。
|
||||||
|
*/
|
||||||
|
export function createMcpToolLoader<T>(request: McpToolRequester<T>) {
|
||||||
|
const requests = new Map<string, Promise<T | undefined>>();
|
||||||
|
|
||||||
|
return function loadMcpTools(id: number | string) {
|
||||||
|
const key = String(id || '').trim();
|
||||||
|
if (!key) {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = requests.get(key);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = request(key).then(
|
||||||
|
(result) => {
|
||||||
|
if (result === undefined) {
|
||||||
|
requests.delete(key);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
requests.delete(key);
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
requests.set(key, pending);
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
|
onActivated,
|
||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
onDeactivated,
|
onDeactivated,
|
||||||
onMounted,
|
onMounted,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
ref,
|
ref,
|
||||||
|
shallowRef,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import {useRoute} from 'vue-router';
|
import {useRoute} from 'vue-router';
|
||||||
|
|
||||||
@@ -52,6 +54,8 @@ import {
|
|||||||
|
|
||||||
import '@tinyflow-ai/vue/dist/index.css';
|
import '@tinyflow-ai/vue/dist/index.css';
|
||||||
|
|
||||||
|
const WORKFLOW_VISIBLE_RENDER_NODE_THRESHOLD = 40;
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
shareMode?: boolean;
|
shareMode?: boolean;
|
||||||
@@ -67,6 +71,9 @@ onMounted(async () => {
|
|||||||
document.addEventListener('keydown', handleKeydown);
|
document.addEventListener('keydown', handleKeydown);
|
||||||
await initializeWorkflow();
|
await initializeWorkflow();
|
||||||
});
|
});
|
||||||
|
onActivated(() => {
|
||||||
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
});
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
captureCurrentWorkflowDraft();
|
captureCurrentWorkflowDraft();
|
||||||
});
|
});
|
||||||
@@ -77,6 +84,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
onDeactivated(() => {
|
onDeactivated(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown);
|
||||||
captureCurrentWorkflowDraft();
|
captureCurrentWorkflowDraft();
|
||||||
});
|
});
|
||||||
// variables
|
// variables
|
||||||
@@ -85,7 +93,12 @@ const workflowId = ref(route.query.id);
|
|||||||
const workflowInfo = ref<any>({});
|
const workflowInfo = ref<any>({});
|
||||||
const initializationError = ref(false);
|
const initializationError = ref(false);
|
||||||
const runParams = ref<any>(null);
|
const runParams = ref<any>(null);
|
||||||
const tinyFlowData = ref<any>(null);
|
const tinyFlowData = shallowRef<any>(null);
|
||||||
|
const onlyRenderVisibleWorkflowElements = computed(
|
||||||
|
() =>
|
||||||
|
(tinyFlowData.value?.nodes?.length || 0) >=
|
||||||
|
WORKFLOW_VISIBLE_RENDER_NODE_THRESHOLD,
|
||||||
|
);
|
||||||
const llmList = ref<any>([]);
|
const llmList = ref<any>([]);
|
||||||
const knowledgeList = ref<any>([]);
|
const knowledgeList = ref<any>([]);
|
||||||
const codeEngineList = ref<any[]>([
|
const codeEngineList = ref<any[]>([
|
||||||
@@ -476,7 +489,8 @@ function persistPendingWorkflowDraft() {
|
|||||||
draftWriteTimer = undefined;
|
draftWriteTimer = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = pendingDraftContent;
|
const content = normalizeWorkflowStartNodes(pendingDraftContent);
|
||||||
|
lastObservedWorkflowContent = content;
|
||||||
pendingDraftContent = null;
|
pendingDraftContent = null;
|
||||||
draftWriteTimer = undefined;
|
draftWriteTimer = undefined;
|
||||||
const persisted = writeWorkflowDraft({
|
const persisted = writeWorkflowDraft({
|
||||||
@@ -491,8 +505,8 @@ function persistPendingWorkflowDraft() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleWorkflowDraft(content: any) {
|
function scheduleWorkflowDraft(content: any) {
|
||||||
lastObservedWorkflowContent = normalizeWorkflowStartNodes(content);
|
lastObservedWorkflowContent = content;
|
||||||
pendingDraftContent = lastObservedWorkflowContent;
|
pendingDraftContent = content;
|
||||||
if (draftWriteTimer) {
|
if (draftWriteTimer) {
|
||||||
clearTimeout(draftWriteTimer);
|
clearTimeout(draftWriteTimer);
|
||||||
}
|
}
|
||||||
@@ -942,11 +956,13 @@ function onAsyncExecute(info: any) {
|
|||||||
ref="tinyflowRef"
|
ref="tinyflowRef"
|
||||||
v-else-if="showTinyFlow"
|
v-else-if="showTinyFlow"
|
||||||
class="tiny-flow-container"
|
class="tiny-flow-container"
|
||||||
:data="JSON.parse(JSON.stringify(tinyFlowData))"
|
:data="tinyFlowData"
|
||||||
|
:edge-animated="false"
|
||||||
|
:only-render-visible-elements="onlyRenderVisibleWorkflowElements"
|
||||||
:theme="isDark ? 'dark' : 'light'"
|
:theme="isDark ? 'dark' : 'light'"
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
:custom-nodes="customNode"
|
:custom-nodes="customNode"
|
||||||
:on-data-change="scheduleWorkflowDraft"
|
:on-data-commit="scheduleWorkflowDraft"
|
||||||
:on-node-execute="runIndependently"
|
:on-node-execute="runIndependently"
|
||||||
:on-run-test="runWorkflow"
|
:on-run-test="runWorkflow"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type {useSvelteFlow} from '@xyflow/svelte';
|
import type {useSvelteFlow} from '@xyflow/svelte';
|
||||||
import {componentName} from './consts';
|
import {componentName} from './consts';
|
||||||
import {store} from './store/stores.svelte';
|
import type {TinyflowStore} from './store/stores.svelte';
|
||||||
import type {TinyflowData, TinyflowOptions, TinyflowTheme} from './types';
|
import type {TinyflowData, TinyflowOptions, TinyflowTheme} from './types';
|
||||||
import {installTinyflowBrowserCompat} from './utils/compat';
|
import {installTinyflowBrowserCompat} from './utils/compat';
|
||||||
import {createTinyflowNodeNormalizer} from './utils/nodeInteraction';
|
import {createTinyflowNodeNormalizer} from './utils/nodeInteraction';
|
||||||
@@ -13,9 +13,13 @@ export class Tinyflow {
|
|||||||
private options!: TinyflowOptions;
|
private options!: TinyflowOptions;
|
||||||
private rootEl!: Element;
|
private rootEl!: Element;
|
||||||
private svelteFlowInstance!: FlowInstance;
|
private svelteFlowInstance!: FlowInstance;
|
||||||
|
private store!: TinyflowStore;
|
||||||
private tinyflowEl!: HTMLElement & {
|
private tinyflowEl!: HTMLElement & {
|
||||||
options: TinyflowOptions;
|
options: TinyflowOptions;
|
||||||
onInit: (svelteFlowInstance: FlowInstance) => void;
|
onInit: (
|
||||||
|
svelteFlowInstance: FlowInstance,
|
||||||
|
store: TinyflowStore,
|
||||||
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(options: TinyflowOptions) {
|
constructor(options: TinyflowOptions) {
|
||||||
@@ -72,7 +76,10 @@ export class Tinyflow {
|
|||||||
private _createTinyflowElement() {
|
private _createTinyflowElement() {
|
||||||
const tinyflowEl = document.createElement(componentName) as HTMLElement & {
|
const tinyflowEl = document.createElement(componentName) as HTMLElement & {
|
||||||
options: TinyflowOptions;
|
options: TinyflowOptions;
|
||||||
onInit: (svelteFlowInstance: FlowInstance) => void;
|
onInit: (
|
||||||
|
svelteFlowInstance: FlowInstance,
|
||||||
|
store: TinyflowStore,
|
||||||
|
) => void;
|
||||||
};
|
};
|
||||||
tinyflowEl.style.display = 'block';
|
tinyflowEl.style.display = 'block';
|
||||||
tinyflowEl.style.width = '100%';
|
tinyflowEl.style.width = '100%';
|
||||||
@@ -80,8 +87,12 @@ export class Tinyflow {
|
|||||||
this._applyThemeClass(tinyflowEl, this.options.theme);
|
this._applyThemeClass(tinyflowEl, this.options.theme);
|
||||||
|
|
||||||
tinyflowEl.options = this.options;
|
tinyflowEl.options = this.options;
|
||||||
tinyflowEl.onInit = (svelteFlowInstance: FlowInstance) => {
|
tinyflowEl.onInit = (
|
||||||
|
svelteFlowInstance: FlowInstance,
|
||||||
|
store: TinyflowStore,
|
||||||
|
) => {
|
||||||
this.svelteFlowInstance = svelteFlowInstance;
|
this.svelteFlowInstance = svelteFlowInstance;
|
||||||
|
this.store = store;
|
||||||
};
|
};
|
||||||
return tinyflowEl;
|
return tinyflowEl;
|
||||||
}
|
}
|
||||||
@@ -104,29 +115,29 @@ export class Tinyflow {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentViewport = flow.getViewport();
|
|
||||||
const currentNodes = flow.getNodes();
|
|
||||||
const normalizeNode = createTinyflowNodeNormalizer(this.options);
|
const normalizeNode = createTinyflowNodeNormalizer(this.options);
|
||||||
const currentNodePositions = new Map(
|
if (data.nodes) {
|
||||||
currentNodes.map((node) => [node.id, node.position]),
|
const currentNodePositions =
|
||||||
);
|
|
||||||
const nextNodes =
|
|
||||||
options?.preserveViewport === true
|
options?.preserveViewport === true
|
||||||
? (data.nodes || currentNodes).map((node) => {
|
? new Map(
|
||||||
const currentPosition = currentNodePositions.get(node.id);
|
flow.getNodes().map((node) => [node.id, node.position] as const),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const nextNodes = data.nodes.map((node) => {
|
||||||
|
const currentPosition = currentNodePositions?.get(node.id);
|
||||||
const nextNode = currentPosition
|
const nextNode = currentPosition
|
||||||
? { ...node, position: { ...currentPosition } }
|
? { ...node, position: { ...currentPosition } }
|
||||||
: node;
|
: node;
|
||||||
return normalizeNode(nextNode);
|
return normalizeNode(nextNode);
|
||||||
})
|
});
|
||||||
: (data.nodes || currentNodes).map((node) => normalizeNode(node));
|
this.store.setNodes(nextNodes);
|
||||||
store.setNodes(nextNodes);
|
}
|
||||||
store.setEdges(data.edges || flow.getEdges());
|
if (data.edges) {
|
||||||
|
this.store.setEdges(data.edges);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.viewport && options?.preserveViewport !== true) {
|
if (data.viewport && options?.preserveViewport !== true) {
|
||||||
flow.setViewport(data.viewport, { duration: 0 });
|
flow.setViewport(data.viewport, { duration: 0 });
|
||||||
} else {
|
|
||||||
flow.setViewport(currentViewport, { duration: 0 });
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {SvelteFlowProvider, type useSvelteFlow} from '@xyflow/svelte';
|
import {SvelteFlowProvider, type useSvelteFlow} from '@xyflow/svelte';
|
||||||
import TinyflowCore from './TinyflowCore.svelte';
|
import TinyflowCore from './TinyflowCore.svelte';
|
||||||
import {store} from '#store/stores.svelte';
|
import {
|
||||||
|
createTinyflowStoreContext,
|
||||||
|
type TinyflowStore,
|
||||||
|
} from '#store/stores.svelte';
|
||||||
import type {TinyflowData, TinyflowOptions} from '#types';
|
import type {TinyflowData, TinyflowOptions} from '#types';
|
||||||
import {setContext} from 'svelte';
|
import {setContext} from 'svelte';
|
||||||
import {createTinyflowNodeNormalizer} from '../utils/nodeInteraction';
|
import {createTinyflowNodeNormalizer} from '../utils/nodeInteraction';
|
||||||
|
|
||||||
const props = $props<{
|
const props = $props<{
|
||||||
options: TinyflowOptions,
|
options: TinyflowOptions,
|
||||||
onInit: (svelteFlow: ReturnType<typeof useSvelteFlow>) => void,
|
onInit: (
|
||||||
|
svelteFlow: ReturnType<typeof useSvelteFlow>,
|
||||||
|
store: TinyflowStore,
|
||||||
|
) => void,
|
||||||
}>();
|
}>();
|
||||||
|
const store = createTinyflowStoreContext();
|
||||||
|
|
||||||
const parseData = (source: TinyflowOptions['data']) => {
|
const parseData = (source: TinyflowOptions['data']) => {
|
||||||
let nextData = source;
|
let nextData = source;
|
||||||
@@ -61,5 +68,8 @@
|
|||||||
|
|
||||||
|
|
||||||
<SvelteFlowProvider>
|
<SvelteFlowProvider>
|
||||||
<TinyflowCore onInit={props.onInit} />
|
<TinyflowCore
|
||||||
|
onInit={(svelteFlow: ReturnType<typeof useSvelteFlow>) =>
|
||||||
|
props.onInit(svelteFlow, store)}
|
||||||
|
/>
|
||||||
</SvelteFlowProvider>
|
</SvelteFlowProvider>
|
||||||
|
|||||||
@@ -9,11 +9,12 @@
|
|||||||
type NodeTypes,
|
type NodeTypes,
|
||||||
Panel,
|
Panel,
|
||||||
SvelteFlow,
|
SvelteFlow,
|
||||||
useSvelteFlow
|
useSvelteFlow,
|
||||||
|
useUpdateNodeInternals
|
||||||
} from '@xyflow/svelte';
|
} from '@xyflow/svelte';
|
||||||
import '@xyflow/svelte/dist/style.css';
|
import '@xyflow/svelte/dist/style.css';
|
||||||
import '../styles/index.ts';
|
import '../styles/index.ts';
|
||||||
import {store} from '#store/stores.svelte';
|
import {useTinyflowStore} from '#store/stores.svelte';
|
||||||
import {nodeTypes} from './nodes';
|
import {nodeTypes} from './nodes';
|
||||||
import Toolbar from './Toolbar.svelte';
|
import Toolbar from './Toolbar.svelte';
|
||||||
import NodePicker from './core/NodePicker.svelte';
|
import NodePicker from './core/NodePicker.svelte';
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
import {useGetNodesFromSource} from '#components/utils/useGetNodesFromSource.svelte';
|
import {useGetNodesFromSource} from '#components/utils/useGetNodesFromSource.svelte';
|
||||||
import {useGetNodeRelativePosition} from '#components/utils/useGetNodeRelativePosition.svelte';
|
import {useGetNodeRelativePosition} from '#components/utils/useGetNodeRelativePosition.svelte';
|
||||||
import {useCopyPasteHandler} from '#components/utils/useCopyPasteHandler.svelte';
|
import {useCopyPasteHandler} from '#components/utils/useCopyPasteHandler.svelte';
|
||||||
import {onDestroy, onMount} from 'svelte';
|
import {onDestroy, onMount, setContext} from 'svelte';
|
||||||
import {isInEditableElement} from '#components/utils/isInEditableElement';
|
import {isInEditableElement} from '#components/utils/isInEditableElement';
|
||||||
import {getAvailableNodes, type NodePaletteItem} from './utils/nodePalette';
|
import {getAvailableNodes, type NodePaletteItem} from './utils/nodePalette';
|
||||||
import {
|
import {
|
||||||
@@ -42,9 +43,69 @@
|
|||||||
ensureStartNodeParameters,
|
ensureStartNodeParameters,
|
||||||
START_NODE_TYPE,
|
START_NODE_TYPE,
|
||||||
} from '../utils/workflowNodeFields';
|
} from '../utils/workflowNodeFields';
|
||||||
|
import type {TinyflowData, TinyflowDataCommitReason} from '../types';
|
||||||
|
import {
|
||||||
|
TINYFLOW_NODE_SIZE_OBSERVER,
|
||||||
|
type TinyflowNodeSizeObserver,
|
||||||
|
} from './utils/nodeSizeObserver';
|
||||||
|
|
||||||
const { onInit }: { onInit: any; [key: string]: any } = $props();
|
const { onInit }: { onInit: any; [key: string]: any } = $props();
|
||||||
|
const store = useTinyflowStore();
|
||||||
const svelteFlow = useSvelteFlow();
|
const svelteFlow = useSvelteFlow();
|
||||||
|
const updateNodeInternals = useUpdateNodeInternals();
|
||||||
|
const nodeSizeIds = new WeakMap<Element, string>();
|
||||||
|
const pendingNodeSizeIds = new Set<string>();
|
||||||
|
let nodeSizeObserver: ResizeObserver | null = null;
|
||||||
|
let nodeSizeFrame = 0;
|
||||||
|
|
||||||
|
function flushNodeSizeUpdates() {
|
||||||
|
nodeSizeFrame = 0;
|
||||||
|
if (!flowRootEl?.isConnected || pendingNodeSizeIds.size === 0) {
|
||||||
|
pendingNodeSizeIds.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nodeIds = Array.from(pendingNodeSizeIds);
|
||||||
|
pendingNodeSizeIds.clear();
|
||||||
|
updateNodeInternals(nodeIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNodeSizeUpdate(nodeId: string) {
|
||||||
|
if (!nodeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingNodeSizeIds.add(nodeId);
|
||||||
|
if (!nodeSizeFrame) {
|
||||||
|
nodeSizeFrame = requestAnimationFrame(flushNodeSizeUpdates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureNodeSizeObserver() {
|
||||||
|
if (nodeSizeObserver || typeof ResizeObserver === 'undefined') {
|
||||||
|
return nodeSizeObserver;
|
||||||
|
}
|
||||||
|
nodeSizeObserver = new ResizeObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const nodeId = nodeSizeIds.get(entry.target);
|
||||||
|
if (nodeId && entry.target.isConnected) {
|
||||||
|
scheduleNodeSizeUpdate(nodeId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return nodeSizeObserver;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedNodeSizeObserver: TinyflowNodeSizeObserver = {
|
||||||
|
observe(nodeId, element) {
|
||||||
|
nodeSizeIds.set(element, nodeId);
|
||||||
|
scheduleNodeSizeUpdate(nodeId);
|
||||||
|
ensureNodeSizeObserver()?.observe(element);
|
||||||
|
},
|
||||||
|
unobserve(element) {
|
||||||
|
nodeSizeObserver?.unobserve(element);
|
||||||
|
nodeSizeIds.delete(element);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setContext(TINYFLOW_NODE_SIZE_OBSERVER, sharedNodeSizeObserver);
|
||||||
|
|
||||||
let showEdgePanel = $state(false);
|
let showEdgePanel = $state(false);
|
||||||
let currentEdge = $state<Edge | null>(null);
|
let currentEdge = $state<Edge | null>(null);
|
||||||
@@ -71,6 +132,7 @@
|
|||||||
const nodesDraggable = options.nodesDraggable ?? !readonly;
|
const nodesDraggable = options.nodesDraggable ?? !readonly;
|
||||||
const nodesConnectable = options.nodesConnectable ?? !readonly;
|
const nodesConnectable = options.nodesConnectable ?? !readonly;
|
||||||
const elementsSelectable = options.elementsSelectable ?? !readonly;
|
const elementsSelectable = options.elementsSelectable ?? !readonly;
|
||||||
|
const onlyRenderVisibleElements = options.onlyRenderVisibleElements === true;
|
||||||
const dropEnabled = options.dropEnabled ?? !readonly;
|
const dropEnabled = options.dropEnabled ?? !readonly;
|
||||||
const connectionEnabled = nodesConnectable && !readonly;
|
const connectionEnabled = nodesConnectable && !readonly;
|
||||||
const availableNodes = getAvailableNodes(options);
|
const availableNodes = getAvailableNodes(options);
|
||||||
@@ -327,8 +389,11 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const vp = store.getViewport();
|
const vp = store.getViewport();
|
||||||
if (vp) {
|
if (vp) {
|
||||||
currentZoomPercent = Math.round(vp.zoom * 100);
|
const nextZoomPercent = Math.round(vp.zoom * 100);
|
||||||
zoomSelectValue = String(currentZoomPercent);
|
if (currentZoomPercent !== nextZoomPercent) {
|
||||||
|
currentZoomPercent = nextZoomPercent;
|
||||||
|
zoomSelectValue = String(nextZoomPercent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -680,6 +745,9 @@
|
|||||||
const { copyHandler, pasteHandler } = useCopyPasteHandler();
|
const { copyHandler, pasteHandler } = useCopyPasteHandler();
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!flowRootEl?.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (nodePickerVisible && e.key === 'Escape') {
|
if (nodePickerVisible && e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
closeNodePicker();
|
closeNodePicker();
|
||||||
@@ -709,6 +777,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleGlobalPaste = async (event: ClipboardEvent) => {
|
const handleGlobalPaste = async (event: ClipboardEvent) => {
|
||||||
|
if (!flowRootEl?.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 只在“非输入态”下处理流程图粘贴
|
// 只在“非输入态”下处理流程图粘贴
|
||||||
if (isInEditableElement()) {
|
if (isInEditableElement()) {
|
||||||
return;
|
return;
|
||||||
@@ -718,7 +789,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
function handleGlobalPointerDown(event: PointerEvent) {
|
function handleGlobalPointerDown(event: PointerEvent) {
|
||||||
if (readonly) {
|
if (readonly || !flowRootEl?.isConnected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!nodePickerVisible || !inlineNodePickerEl) {
|
if (!nodePickerVisible || !inlineNodePickerEl) {
|
||||||
@@ -764,15 +835,155 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedNodeTypes = {
|
||||||
|
...nodeTypes,
|
||||||
|
...customNodeTypes
|
||||||
|
} as NodeTypes;
|
||||||
|
const defaultEdgeOptions = {
|
||||||
|
type: 'flow',
|
||||||
|
markerEnd: {
|
||||||
|
type: MarkerType.ArrowClosed,
|
||||||
|
width: 20,
|
||||||
|
height: 20
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleEdgeClick(event: { edge: Edge }) {
|
||||||
|
if (readonly || hideEdgePanel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showEdgePanel = true;
|
||||||
|
currentEdge = event.edge;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCanvasClick(event: MouseEvent) {
|
||||||
|
if (readonly || hideEdgePanel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const element = event.target as HTMLElement;
|
||||||
|
if (element.classList.contains('svelte-flow__edge-interaction')
|
||||||
|
|| element.classList.contains('panel-content')
|
||||||
|
|| element.closest('.panel-content')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showEdgePanel = false;
|
||||||
|
currentEdge = null;
|
||||||
|
}
|
||||||
|
|
||||||
const onDataChange = options.onDataChange;
|
const onDataChange = options.onDataChange;
|
||||||
$effect(() => {
|
const onDataCommit = options.onDataCommit;
|
||||||
onDataChange?.({
|
let dataChangeFrame = 0;
|
||||||
|
let pendingDataChange: TinyflowData | null = null;
|
||||||
|
let dataCommitFrame = 0;
|
||||||
|
let pendingDataCommit: TinyflowData | null = null;
|
||||||
|
let pendingDataCommitReason: TinyflowDataCommitReason = 'change';
|
||||||
|
let nodeDragging = false;
|
||||||
|
let viewportMoving = false;
|
||||||
|
|
||||||
|
function currentData(): TinyflowData {
|
||||||
|
return {
|
||||||
nodes: store.getNodes(),
|
nodes: store.getNodes(),
|
||||||
edges: store.getEdges(),
|
edges: store.getEdges(),
|
||||||
viewport: store.getViewport()
|
viewport: store.getViewport()
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function interactionActive() {
|
||||||
|
return nodeDragging || viewportMoving;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushDataChange() {
|
||||||
|
dataChangeFrame = 0;
|
||||||
|
const data = pendingDataChange;
|
||||||
|
pendingDataChange = null;
|
||||||
|
if (data) {
|
||||||
|
onDataChange?.(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDataChange(data: TinyflowData) {
|
||||||
|
pendingDataChange = data;
|
||||||
|
if (!dataChangeFrame) {
|
||||||
|
dataChangeFrame = requestAnimationFrame(flushDataChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushDataCommit() {
|
||||||
|
dataCommitFrame = 0;
|
||||||
|
if (interactionActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = pendingDataCommit;
|
||||||
|
const reason = pendingDataCommitReason;
|
||||||
|
pendingDataCommit = null;
|
||||||
|
pendingDataCommitReason = 'change';
|
||||||
|
if (data) {
|
||||||
|
onDataCommit?.(data, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDataCommit(
|
||||||
|
data: TinyflowData,
|
||||||
|
reason: TinyflowDataCommitReason = 'change'
|
||||||
|
) {
|
||||||
|
pendingDataCommit = data;
|
||||||
|
if (reason !== 'change' || pendingDataCommitReason === 'change') {
|
||||||
|
pendingDataCommitReason = reason;
|
||||||
|
}
|
||||||
|
if (!interactionActive() && !dataCommitFrame) {
|
||||||
|
dataCommitFrame = requestAnimationFrame(flushDataCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNodeDragStart() {
|
||||||
|
nodeDragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNodeDragStop() {
|
||||||
|
nodeDragging = false;
|
||||||
|
scheduleDataCommit(currentData(), 'node-drag-stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleViewportMoveStart() {
|
||||||
|
viewportMoving = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleViewportMoveEnd() {
|
||||||
|
viewportMoving = false;
|
||||||
|
scheduleDataCommit(currentData(), 'viewport-move-end');
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const data = currentData();
|
||||||
|
if (onDataChange) {
|
||||||
|
scheduleDataChange(data);
|
||||||
|
}
|
||||||
|
if (onDataCommit) {
|
||||||
|
scheduleDataCommit(data);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (dataChangeFrame) {
|
||||||
|
cancelAnimationFrame(dataChangeFrame);
|
||||||
|
dataChangeFrame = 0;
|
||||||
|
}
|
||||||
|
if (dataCommitFrame) {
|
||||||
|
cancelAnimationFrame(dataCommitFrame);
|
||||||
|
dataCommitFrame = 0;
|
||||||
|
}
|
||||||
|
flushDataChange();
|
||||||
|
nodeDragging = false;
|
||||||
|
viewportMoving = false;
|
||||||
|
flushDataCommit();
|
||||||
|
if (nodeSizeFrame) {
|
||||||
|
cancelAnimationFrame(nodeSizeFrame);
|
||||||
|
nodeSizeFrame = 0;
|
||||||
|
}
|
||||||
|
pendingNodeSizeIds.clear();
|
||||||
|
nodeSizeObserver?.disconnect();
|
||||||
|
nodeSizeObserver = null;
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -782,7 +993,7 @@
|
|||||||
style="position: relative; height: 100%; width: 100%;overflow: hidden"
|
style="position: relative; height: 100%; width: 100%;overflow: hidden"
|
||||||
bind:this={flowRootEl}
|
bind:this={flowRootEl}
|
||||||
>
|
>
|
||||||
<SvelteFlow nodeTypes={{ ...nodeTypes, ...customNodeTypes}}
|
<SvelteFlow nodeTypes={resolvedNodeTypes}
|
||||||
edgeTypes={customEdgeTypes}
|
edgeTypes={customEdgeTypes}
|
||||||
bind:nodes={store.getNodes, store.setNodes}
|
bind:nodes={store.getNodes, store.setNodes}
|
||||||
bind:edges={store.getEdges, store.setEdges}
|
bind:edges={store.getEdges, store.setEdges}
|
||||||
@@ -790,47 +1001,27 @@
|
|||||||
nodesDraggable={nodesDraggable && !canvasLocked}
|
nodesDraggable={nodesDraggable && !canvasLocked}
|
||||||
nodesConnectable={nodesConnectable && !canvasLocked}
|
nodesConnectable={nodesConnectable && !canvasLocked}
|
||||||
elementsSelectable={elementsSelectable && !canvasLocked}
|
elementsSelectable={elementsSelectable && !canvasLocked}
|
||||||
|
{onlyRenderVisibleElements}
|
||||||
panOnDrag={readonly ? true : !canvasLocked}
|
panOnDrag={readonly ? true : !canvasLocked}
|
||||||
zoomOnScroll={readonly ? true : !canvasLocked}
|
zoomOnScroll={readonly ? true : !canvasLocked}
|
||||||
zoomOnDoubleClick={readonly ? true : !canvasLocked}
|
zoomOnDoubleClick={readonly ? true : !canvasLocked}
|
||||||
ondrop={dropEnabled ? onDrop : undefined}
|
ondrop={dropEnabled ? onDrop : undefined}
|
||||||
ondragover={dropEnabled ? onDragOver : undefined}
|
ondragover={dropEnabled ? onDragOver : undefined}
|
||||||
|
onnodedragstart={onDataCommit ? handleNodeDragStart : undefined}
|
||||||
|
onnodedragstop={onDataCommit ? handleNodeDragStop : undefined}
|
||||||
|
onmovestart={onDataCommit ? handleViewportMoveStart : undefined}
|
||||||
|
onmoveend={onDataCommit ? handleViewportMoveEnd : undefined}
|
||||||
isValidConnection={isValidConnection}
|
isValidConnection={isValidConnection}
|
||||||
onconnectend={connectionEnabled ? onconnectend : undefined}
|
onconnectend={connectionEnabled ? onconnectend : undefined}
|
||||||
onconnectstart={connectionEnabled ? onconnectstart : undefined}
|
onconnectstart={connectionEnabled ? onconnectstart : undefined}
|
||||||
onconnect={connectionEnabled ? onconnect : undefined}
|
onconnect={connectionEnabled ? onconnect : undefined}
|
||||||
connectionRadius={50}
|
connectionRadius={50}
|
||||||
connectionLineComponent={FlowConnectionLine}
|
connectionLineComponent={FlowConnectionLine}
|
||||||
onedgeclick={(e) => {
|
onedgeclick={handleEdgeClick}
|
||||||
if (readonly || hideEdgePanel) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showEdgePanel = true;
|
|
||||||
currentEdge = e.edge;
|
|
||||||
}}
|
|
||||||
onbeforeconnect={(edge: any) => normalizeEdgeBeforeConnect(edge)}
|
onbeforeconnect={(edge: any) => normalizeEdgeBeforeConnect(edge)}
|
||||||
ondelete={readonly ? undefined : onDelete}
|
ondelete={readonly ? undefined : onDelete}
|
||||||
onclick={(e) => {
|
onclick={handleCanvasClick}
|
||||||
if (readonly || hideEdgePanel) {
|
defaultEdgeOptions={defaultEdgeOptions}
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = e.target as HTMLElement;
|
|
||||||
if (el.classList.contains("svelte-flow__edge-interaction")
|
|
||||||
|| el.classList.contains('panel-content')
|
|
||||||
|| el.closest('.panel-content')){
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showEdgePanel = false;
|
|
||||||
currentEdge = null;
|
|
||||||
}}
|
|
||||||
defaultEdgeOptions={{
|
|
||||||
type: 'flow',
|
|
||||||
markerEnd: {
|
|
||||||
type: MarkerType.ArrowClosed,
|
|
||||||
width: 20,
|
|
||||||
height: 20
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Background />
|
<Background />
|
||||||
{#if !hideMiniMap}
|
{#if !hideMiniMap}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||||
import {contentTypes, startFormTypes} from '#consts';
|
import {contentTypes, startFormTypes} from '#consts';
|
||||||
import type {Parameter} from '#types';
|
import type {Parameter} from '#types';
|
||||||
import {store} from '#store/stores.svelte';
|
import {useTinyflowStore} from '#store/stores.svelte';
|
||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
import {
|
import {
|
||||||
renameStartFieldReferencesInNodes,
|
renameStartFieldReferencesInNodes,
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
parameter: Parameter,
|
parameter: Parameter,
|
||||||
index: number
|
index: number
|
||||||
} = $props();
|
} = $props();
|
||||||
|
const store = useTinyflowStore();
|
||||||
|
|
||||||
|
|
||||||
let currentNodeId = getCurrentNodeId();
|
let currentNodeId = getCurrentNodeId();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import {getOptions} from '../utils/NodeUtils';
|
import {getOptions} from '../utils/NodeUtils';
|
||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
import type {TinyflowNodeData} from '#types';
|
import type {TinyflowNodeData} from '#types';
|
||||||
|
import {useTinyflowNodeSizeObserver} from '../utils/nodeSizeObserver';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -82,36 +83,23 @@
|
|||||||
};
|
};
|
||||||
let currentNodeId = getCurrentNodeId();
|
let currentNodeId = getCurrentNodeId();
|
||||||
let wrapperElement: HTMLDivElement | null = null;
|
let wrapperElement: HTMLDivElement | null = null;
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
const nodeSizeObserver = useTinyflowNodeSizeObserver();
|
||||||
let resizeFrame = 0;
|
|
||||||
|
|
||||||
const scheduleUpdateNodeInternals = () => {
|
|
||||||
if (!id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (resizeFrame) {
|
|
||||||
cancelAnimationFrame(resizeFrame);
|
|
||||||
}
|
|
||||||
resizeFrame = requestAnimationFrame(() => {
|
|
||||||
updateNodeInternals(id);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
scheduleUpdateNodeInternals();
|
if (!wrapperElement) {
|
||||||
if (typeof ResizeObserver !== 'undefined' && wrapperElement) {
|
return;
|
||||||
resizeObserver = new ResizeObserver(() => {
|
}
|
||||||
scheduleUpdateNodeInternals();
|
if (nodeSizeObserver) {
|
||||||
});
|
nodeSizeObserver.observe(id, wrapperElement);
|
||||||
resizeObserver.observe(wrapperElement);
|
} else {
|
||||||
|
updateNodeInternals(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (resizeFrame) {
|
if (wrapperElement) {
|
||||||
cancelAnimationFrame(resizeFrame);
|
nodeSizeObserver?.unobserve(wrapperElement);
|
||||||
}
|
}
|
||||||
resizeObserver?.disconnect();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -16,10 +16,11 @@
|
|||||||
import {genShortId} from '../utils/IdGen';
|
import {genShortId} from '../utils/IdGen';
|
||||||
import {deepEqual} from '../utils/deepEqual';
|
import {deepEqual} from '../utils/deepEqual';
|
||||||
import {useRefOptions} from '#components/utils/useRefOptions.svelte';
|
import {useRefOptions} from '#components/utils/useRefOptions.svelte';
|
||||||
import {store} from '#store/stores.svelte';
|
import {useTinyflowStore} from '#store/stores.svelte';
|
||||||
import type {Parameter, SelectItem} from '#types';
|
import type {Parameter, SelectItem} from '#types';
|
||||||
|
|
||||||
const SHOW_EXPRESSION_ENTRY = false;
|
const SHOW_EXPRESSION_ENTRY = false;
|
||||||
|
const store = useTinyflowStore();
|
||||||
|
|
||||||
type ConditionOperator =
|
type ConditionOperator =
|
||||||
| 'eq'
|
| 'eq'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import OutputDefList from '../core/OutputDefList.svelte';
|
import OutputDefList from '../core/OutputDefList.svelte';
|
||||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||||
import type {TinyflowNodeData} from '#types';
|
import type {TinyflowNodeData} from '#types';
|
||||||
import {onMount} from 'svelte';
|
import {onDestroy, onMount} from 'svelte';
|
||||||
|
|
||||||
const props = $props<{
|
const props = $props<{
|
||||||
data: TinyflowNodeData,
|
data: TinyflowNodeData,
|
||||||
@@ -73,6 +73,10 @@
|
|||||||
customNode.render?.(externalElement, buildNode(), flowInstance);
|
customNode.render?.(externalElement, buildNode(), flowInstance);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
customNode.onDestroy?.(externalElement);
|
||||||
|
});
|
||||||
|
|
||||||
let container = $state<HTMLElement | null>(null);
|
let container = $state<HTMLElement | null>(null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// 注意:由于 $effect 的 state 自动追踪问题,需要 data.expand 方在 if 里的最前面
|
// 注意:由于 $effect 的 state 自动追踪问题,需要 data.expand 方在 if 里的最前面
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { getContext } from 'svelte';
|
||||||
|
|
||||||
|
export const TINYFLOW_NODE_SIZE_OBSERVER = Symbol(
|
||||||
|
'tinyflow_node_size_observer',
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TinyflowNodeSizeObserver = {
|
||||||
|
observe: (nodeId: string, element: Element) => void;
|
||||||
|
unobserve: (element: Element) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTinyflowNodeSizeObserver() {
|
||||||
|
return getContext<TinyflowNodeSizeObserver | undefined>(
|
||||||
|
TINYFLOW_NODE_SIZE_OBSERVER,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { genShortId } from './IdGen';
|
import { genShortId } from './IdGen';
|
||||||
import { store } from '#store/stores.svelte';
|
import { useTinyflowStore } from '#store/stores.svelte';
|
||||||
|
|
||||||
export const useCopyNode = () => {
|
export const useCopyNode = () => {
|
||||||
|
const store = useTinyflowStore();
|
||||||
const copyNode = (id: string) => {
|
const copyNode = (id: string) => {
|
||||||
const node = store.getNode(id);
|
const node = store.getNode(id);
|
||||||
if (node) {
|
if (node) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { store } from '#store/stores.svelte';
|
import { useTinyflowStore } from '#store/stores.svelte';
|
||||||
import { genShortId } from '#components/utils/IdGen';
|
import { genShortId } from '#components/utils/IdGen';
|
||||||
import { type Edge, type Node, useSvelteFlow } from '@xyflow/svelte';
|
import { type Edge, type Node, useSvelteFlow } from '@xyflow/svelte';
|
||||||
|
|
||||||
@@ -119,6 +119,7 @@ function rewriteRefsInData(obj: any, idMap: Map<string, string>): any {
|
|||||||
* 复制粘贴处理器 Hook
|
* 复制粘贴处理器 Hook
|
||||||
*/
|
*/
|
||||||
export const useCopyPasteHandler = () => {
|
export const useCopyPasteHandler = () => {
|
||||||
|
const store = useTinyflowStore();
|
||||||
const svelteFlow = useSvelteFlow();
|
const svelteFlow = useSvelteFlow();
|
||||||
|
|
||||||
const copyHandler = async (event: ClipboardEvent | KeyboardEvent) => {
|
const copyHandler = async (event: ClipboardEvent | KeyboardEvent) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { store } from '#store/stores.svelte';
|
import { useTinyflowStore } from '#store/stores.svelte';
|
||||||
|
|
||||||
export const useDeleteEdge = () => {
|
export const useDeleteEdge = () => {
|
||||||
|
const store = useTinyflowStore();
|
||||||
const deleteEdge = (id: string) => {
|
const deleteEdge = (id: string) => {
|
||||||
store.removeEdge(id);
|
store.removeEdge(id);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { store } from '#store/stores.svelte';
|
import { useTinyflowStore } from '#store/stores.svelte';
|
||||||
|
|
||||||
export const useDeleteNode = () => {
|
export const useDeleteNode = () => {
|
||||||
|
const store = useTinyflowStore();
|
||||||
const deleteNode = (id: string) => {
|
const deleteNode = (id: string) => {
|
||||||
store.removeNode(id);
|
store.removeNode(id);
|
||||||
store.updateEdges((edges) =>
|
store.updateEdges((edges) =>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { store } from '../../store/stores.svelte';
|
import { useTinyflowStore } from '../../store/stores.svelte';
|
||||||
|
|
||||||
export const useEnsureParentInNodesBefore = () => {
|
export const useEnsureParentInNodesBefore = () => {
|
||||||
|
const store = useTinyflowStore();
|
||||||
const ensureParentInNodesBefore = (
|
const ensureParentInNodesBefore = (
|
||||||
parentNodeId: string,
|
parentNodeId: string,
|
||||||
childNodeId: string,
|
childNodeId: string,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { store } from '#store/stores.svelte';
|
import { useTinyflowStore } from '#store/stores.svelte';
|
||||||
|
|
||||||
export const useGetEdgesBySource = () => {
|
export const useGetEdgesBySource = () => {
|
||||||
|
const store = useTinyflowStore();
|
||||||
const getEdgesBySource = (target: string) => {
|
const getEdgesBySource = (target: string) => {
|
||||||
const edges = store.getEdges();
|
const edges = store.getEdges();
|
||||||
return edges.filter((edge) => edge.source === target);
|
return edges.filter((edge) => edge.source === target);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { store } from '#store/stores.svelte';
|
import { useTinyflowStore } from '#store/stores.svelte';
|
||||||
|
|
||||||
export const useGetEdgesByTarget = () => {
|
export const useGetEdgesByTarget = () => {
|
||||||
|
const store = useTinyflowStore();
|
||||||
const getEdgesByTarget = (target: string) => {
|
const getEdgesByTarget = (target: string) => {
|
||||||
const edges = store.getEdges();
|
const edges = store.getEdges();
|
||||||
return edges.filter((edge) => edge.target === target);
|
return edges.filter((edge) => edge.target === target);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { store } from '#store/stores.svelte';
|
import { useTinyflowStore } from '#store/stores.svelte';
|
||||||
|
|
||||||
export const useGetNode = () => {
|
export const useGetNode = () => {
|
||||||
|
const store = useTinyflowStore();
|
||||||
const getNode = (id: string) => {
|
const getNode = (id: string) => {
|
||||||
return store.getNode(id);
|
return store.getNode(id);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { store } from '#store/stores.svelte';
|
import { useTinyflowStore } from '#store/stores.svelte';
|
||||||
|
|
||||||
export const useGetNodeRelativePosition = () => {
|
export const useGetNodeRelativePosition = () => {
|
||||||
|
const store = useTinyflowStore();
|
||||||
const getNodeRelativePosition = (parentNodeId: string) => {
|
const getNodeRelativePosition = (parentNodeId: string) => {
|
||||||
let node = store.getNode(parentNodeId);
|
let node = store.getNode(parentNodeId);
|
||||||
const position = { x: 0, y: 0 };
|
const position = { x: 0, y: 0 };
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { store } from '#store/stores.svelte';
|
import { useTinyflowStore } from '#store/stores.svelte';
|
||||||
import type { Edge, Node } from '@xyflow/svelte';
|
import type { Edge, Node } from '@xyflow/svelte';
|
||||||
|
|
||||||
export const useGetNodesFromSource = () => {
|
export const useGetNodesFromSource = () => {
|
||||||
|
const store = useTinyflowStore();
|
||||||
const getEdgesBySource = (target: string, edges: Edge[]) => {
|
const getEdgesBySource = (target: string, edges: Edge[]) => {
|
||||||
return edges.filter(
|
return edges.filter(
|
||||||
// 排除循环节点的子节点,否则在多层循环嵌套时不正确
|
// 排除循环节点的子节点,否则在多层循环嵌套时不正确
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { store } from '#store/stores.svelte';
|
import { useTinyflowStore } from '#store/stores.svelte';
|
||||||
|
|
||||||
export const useUpdateEdgeData = () => {
|
export const useUpdateEdgeData = () => {
|
||||||
|
const store = useTinyflowStore();
|
||||||
const updateEdgeData = (
|
const updateEdgeData = (
|
||||||
id: string,
|
id: string,
|
||||||
dataUpdate: any,
|
dataUpdate: any,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { type Edge, type Node, type Viewport } from '@xyflow/svelte';
|
import { type Edge, type Node, type Viewport } from '@xyflow/svelte';
|
||||||
|
import { getContext, setContext } from 'svelte';
|
||||||
import type { TinyflowNodeNormalizer } from '../utils/nodeInteraction';
|
import type { TinyflowNodeNormalizer } from '../utils/nodeInteraction';
|
||||||
|
|
||||||
const DEFAULT_VIEWPORT: Viewport = { x: 250, y: 100, zoom: 1 };
|
const DEFAULT_VIEWPORT: Viewport = { x: 250, y: 100, zoom: 1 };
|
||||||
|
const TINYFLOW_STORE_CONTEXT = Symbol('tinyflow_store');
|
||||||
|
|
||||||
const createStore = () => {
|
export const createStore = () => {
|
||||||
let nodesInternal = $state.raw([] as Node[]);
|
let nodesInternal = $state.raw([] as Node[]);
|
||||||
let edgesInternal = $state.raw([] as Edge[]);
|
let edgesInternal = $state.raw([] as Edge[]);
|
||||||
let viewport = $state.raw({ ...DEFAULT_VIEWPORT } as Viewport);
|
let viewport = $state.raw({ ...DEFAULT_VIEWPORT } as Viewport);
|
||||||
@@ -86,4 +88,18 @@ const createStore = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const store = createStore();
|
export type TinyflowStore = ReturnType<typeof createStore>;
|
||||||
|
|
||||||
|
export const createTinyflowStoreContext = () => {
|
||||||
|
const store = createStore();
|
||||||
|
setContext(TINYFLOW_STORE_CONTEXT, store);
|
||||||
|
return store;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTinyflowStore = () => {
|
||||||
|
const store = getContext<TinyflowStore | undefined>(TINYFLOW_STORE_CONTEXT);
|
||||||
|
if (!store) {
|
||||||
|
throw new Error('Tinyflow store context is not initialized');
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import './components/TinyflowComponent.svelte';
|
||||||
|
import { Tinyflow } from './Tinyflow';
|
||||||
|
|
||||||
|
const waitForRender = () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tinyflow store isolation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps simultaneous canvas data isolated', async () => {
|
||||||
|
const firstContainer = document.createElement('div');
|
||||||
|
const secondContainer = document.createElement('div');
|
||||||
|
document.body.append(firstContainer, secondContainer);
|
||||||
|
|
||||||
|
const first = new Tinyflow({
|
||||||
|
element: firstContainer,
|
||||||
|
data: {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'first-source', position: { x: 0, y: 0 }, data: {} },
|
||||||
|
{ id: 'first-target', position: { x: 300, y: 0 }, data: {} },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: 'first-edge',
|
||||||
|
source: 'first-source',
|
||||||
|
target: 'first-target',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const second = new Tinyflow({
|
||||||
|
element: secondContainer,
|
||||||
|
data: {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'second-source', position: { x: 0, y: 0 }, data: {} },
|
||||||
|
{ id: 'second-target', position: { x: 300, y: 0 }, data: {} },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: 'second-edge',
|
||||||
|
source: 'second-source',
|
||||||
|
target: 'second-target',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForRender();
|
||||||
|
|
||||||
|
expect(first.getData()?.nodes.map((node) => node.id)).toEqual([
|
||||||
|
'first-source',
|
||||||
|
'first-target',
|
||||||
|
]);
|
||||||
|
expect(second.getData()?.nodes.map((node) => node.id)).toEqual([
|
||||||
|
'second-source',
|
||||||
|
'second-target',
|
||||||
|
]);
|
||||||
|
|
||||||
|
first.updateData({
|
||||||
|
nodes: [
|
||||||
|
{ id: 'first-updated', position: { x: 100, y: 100 }, data: {} },
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
await waitForRender();
|
||||||
|
|
||||||
|
expect(first.getData()?.nodes.map((node) => node.id)).toEqual([
|
||||||
|
'first-updated',
|
||||||
|
]);
|
||||||
|
expect(first.getData()?.edges).toEqual([]);
|
||||||
|
expect(second.getData()?.nodes.map((node) => node.id)).toEqual([
|
||||||
|
'second-source',
|
||||||
|
'second-target',
|
||||||
|
]);
|
||||||
|
expect(second.getData()?.edges.map((edge) => edge.id)).toEqual([
|
||||||
|
'second-edge',
|
||||||
|
]);
|
||||||
|
|
||||||
|
first.destroy();
|
||||||
|
second.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,10 @@ import type {Node, useSvelteFlow} from '@xyflow/svelte';
|
|||||||
export type TinyflowData = Partial<
|
export type TinyflowData = Partial<
|
||||||
ReturnType<ReturnType<typeof useSvelteFlow>['toObject']>
|
ReturnType<ReturnType<typeof useSvelteFlow>['toObject']>
|
||||||
>;
|
>;
|
||||||
|
export type TinyflowDataCommitReason =
|
||||||
|
| 'change'
|
||||||
|
| 'node-drag-stop'
|
||||||
|
| 'viewport-move-end';
|
||||||
export type TinyflowNodeData = Record<string, any>;
|
export type TinyflowNodeData = Record<string, any>;
|
||||||
export type TinyflowTheme = 'light' | 'dark';
|
export type TinyflowTheme = 'light' | 'dark';
|
||||||
|
|
||||||
@@ -94,6 +98,7 @@ export type CustomNode = {
|
|||||||
flowInstance: ReturnType<typeof useSvelteFlow>,
|
flowInstance: ReturnType<typeof useSvelteFlow>,
|
||||||
) => void;
|
) => void;
|
||||||
onUpdate?: (parent: HTMLElement, node: Node) => void;
|
onUpdate?: (parent: HTMLElement, node: Node) => void;
|
||||||
|
onDestroy?: (parent: HTMLElement) => void;
|
||||||
forms?: CustomNodeForm[];
|
forms?: CustomNodeForm[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,6 +120,7 @@ export type TinyflowOptions = {
|
|||||||
nodesDraggable?: boolean;
|
nodesDraggable?: boolean;
|
||||||
nodesConnectable?: boolean;
|
nodesConnectable?: boolean;
|
||||||
elementsSelectable?: boolean;
|
elementsSelectable?: boolean;
|
||||||
|
onlyRenderVisibleElements?: boolean;
|
||||||
dropEnabled?: boolean;
|
dropEnabled?: boolean;
|
||||||
provider?: {
|
provider?: {
|
||||||
llm?: () => SelectItem[] | Promise<SelectItem[]>;
|
llm?: () => SelectItem[] | Promise<SelectItem[]>;
|
||||||
@@ -128,6 +134,10 @@ export type TinyflowOptions = {
|
|||||||
onRunTest?: () => void | Promise<void>;
|
onRunTest?: () => void | Promise<void>;
|
||||||
hiddenNodes?: string[] | (() => string[]);
|
hiddenNodes?: string[] | (() => string[]);
|
||||||
onDataChange?: (data: TinyflowData) => void;
|
onDataChange?: (data: TinyflowData) => void;
|
||||||
|
onDataCommit?: (
|
||||||
|
data: TinyflowData,
|
||||||
|
reason: TinyflowDataCommitReason,
|
||||||
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Parameter = {
|
export type Parameter = {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Node } from '@xyflow/svelte';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTinyflowNodeNormalizer,
|
||||||
|
DEFAULT_NODE_DRAG_HANDLE,
|
||||||
|
} from './nodeInteraction';
|
||||||
|
|
||||||
|
function createNode(patch: Partial<Node> = {}): Node {
|
||||||
|
return {
|
||||||
|
data: {},
|
||||||
|
id: 'node-1',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
type: 'customNode',
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createTinyflowNodeNormalizer', () => {
|
||||||
|
it('节点已经规范化时保留引用', () => {
|
||||||
|
const normalizer = createTinyflowNodeNormalizer();
|
||||||
|
const node = createNode({ dragHandle: DEFAULT_NODE_DRAG_HANDLE });
|
||||||
|
|
||||||
|
expect(normalizer(node)).toBe(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('需要补齐交互属性时采用写时复制且不修改输入', () => {
|
||||||
|
const normalizer = createTinyflowNodeNormalizer();
|
||||||
|
const node = createNode();
|
||||||
|
|
||||||
|
const normalized = normalizer(node);
|
||||||
|
|
||||||
|
expect(normalized).not.toBe(node);
|
||||||
|
expect(normalized.dragHandle).toBe(DEFAULT_NODE_DRAG_HANDLE);
|
||||||
|
expect(node.dragHandle).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('只读节点已经包含限制属性时保留引用', () => {
|
||||||
|
const normalizer = createTinyflowNodeNormalizer({ readonly: true } as any);
|
||||||
|
const node = createNode({
|
||||||
|
connectable: false,
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalizer(node)).toBe(node);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,33 +25,45 @@ export function createTinyflowNodeNormalizer(
|
|||||||
const elementsSelectable = options?.elementsSelectable ?? !readonly;
|
const elementsSelectable = options?.elementsSelectable ?? !readonly;
|
||||||
|
|
||||||
return (node) => {
|
return (node) => {
|
||||||
const nextNode = { ...node };
|
let nextNode = node;
|
||||||
|
const mutableNode = () => {
|
||||||
|
if (nextNode === node) {
|
||||||
|
nextNode = { ...node };
|
||||||
|
}
|
||||||
|
return nextNode;
|
||||||
|
};
|
||||||
|
|
||||||
if (nodesDraggable) {
|
if (nodesDraggable) {
|
||||||
if (nextNode.draggable === false) {
|
if (nextNode.draggable === false) {
|
||||||
delete nextNode.draggable;
|
delete mutableNode().draggable;
|
||||||
}
|
}
|
||||||
if (shouldUseDefaultDragHandle(nextNode, options?.customNodes)) {
|
if (shouldUseDefaultDragHandle(nextNode, options?.customNodes)) {
|
||||||
nextNode.dragHandle = DEFAULT_NODE_DRAG_HANDLE;
|
mutableNode().dragHandle = DEFAULT_NODE_DRAG_HANDLE;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
nextNode.draggable = false;
|
if (nextNode.draggable !== false) {
|
||||||
|
mutableNode().draggable = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nodesConnectable) {
|
if (nodesConnectable) {
|
||||||
if (nextNode.connectable === false) {
|
if (nextNode.connectable === false) {
|
||||||
delete nextNode.connectable;
|
delete mutableNode().connectable;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
nextNode.connectable = false;
|
if (nextNode.connectable !== false) {
|
||||||
|
mutableNode().connectable = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elementsSelectable) {
|
if (elementsSelectable) {
|
||||||
if (nextNode.selectable === false) {
|
if (nextNode.selectable === false) {
|
||||||
delete nextNode.selectable;
|
delete mutableNode().selectable;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
nextNode.selectable = false;
|
if (nextNode.selectable !== false) {
|
||||||
|
mutableNode().selectable = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextNode;
|
return nextNode;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {Tinyflow as TinyflowNative, TinyflowOptions} from '@tinyflow-ai/ui';
|
import type { TinyflowOptions } from '@tinyflow-ai/ui';
|
||||||
|
|
||||||
|
import { Tinyflow as TinyflowNative } from '@tinyflow-ai/ui';
|
||||||
import '@tinyflow-ai/ui/dist/index.css';
|
import '@tinyflow-ai/ui/dist/index.css';
|
||||||
import {
|
import {
|
||||||
getCurrentInstance,
|
getCurrentInstance,
|
||||||
@@ -15,8 +17,7 @@ import {
|
|||||||
watch,
|
watch,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
|
||||||
type TinyflowDataOption = Exclude<TinyflowOptions['data'], string | undefined>;
|
import { createTinyflowDataSync } from './dataSync';
|
||||||
type StructuredCloneFn = <T>(value: T) => T;
|
|
||||||
|
|
||||||
const props = defineProps<
|
const props = defineProps<
|
||||||
{
|
{
|
||||||
@@ -30,7 +31,7 @@ const attrs = useAttrs();
|
|||||||
const instance = getCurrentInstance();
|
const instance = getCurrentInstance();
|
||||||
let tinyflow: TinyflowNative | null = null;
|
let tinyflow: TinyflowNative | null = null;
|
||||||
let mountedDataReady = false;
|
let mountedDataReady = false;
|
||||||
let lastAppliedDataSignature = '';
|
const dataSync = createTinyflowDataSync();
|
||||||
const optionalBooleanOptionKeys = new Set([
|
const optionalBooleanOptionKeys = new Set([
|
||||||
'readonly',
|
'readonly',
|
||||||
'hideBottomDock',
|
'hideBottomDock',
|
||||||
@@ -45,6 +46,7 @@ const optionalBooleanOptionKeys = new Set([
|
|||||||
'nodesDraggable',
|
'nodesDraggable',
|
||||||
'nodesConnectable',
|
'nodesConnectable',
|
||||||
'elementsSelectable',
|
'elementsSelectable',
|
||||||
|
'onlyRenderVisibleElements',
|
||||||
'dropEnabled',
|
'dropEnabled',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -81,58 +83,6 @@ function normalizeProps(source: Record<string, unknown>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 安全深拷贝工具函数
|
|
||||||
function safeDeepClone<T>(obj: T): T {
|
|
||||||
if (obj === null || typeof obj !== 'object') return obj;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const clone = (globalThis as { structuredClone?: StructuredCloneFn })
|
|
||||||
.structuredClone;
|
|
||||||
if (clone) {
|
|
||||||
return clone(obj);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to JSON cloning for browsers without native structuredClone.
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(JSON.stringify(obj));
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
console.warn(
|
|
||||||
'Failed to clone object, returning original (may cause issues)',
|
|
||||||
obj,
|
|
||||||
);
|
|
||||||
return obj;
|
|
||||||
} catch {
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDataSignature(data: unknown) {
|
|
||||||
if (data == null || typeof data === 'string') {
|
|
||||||
return String(data ?? '');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(data);
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneDataIfChanged(data: TinyflowOptions['data']) {
|
|
||||||
if (data == null || typeof data === 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const signature = createDataSignature(data);
|
|
||||||
if (signature && signature === lastAppliedDataSignature) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
lastAppliedDataSignature = signature;
|
|
||||||
return safeDeepClone(data as TinyflowDataOption);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (divRef.value) {
|
if (divRef.value) {
|
||||||
// 净化 props.data,避免响应式对象或函数污染
|
// 净化 props.data,避免响应式对象或函数污染
|
||||||
@@ -141,8 +91,21 @@ onMounted(() => {
|
|||||||
...normalizeProps(props),
|
...normalizeProps(props),
|
||||||
} as any;
|
} as any;
|
||||||
if ('data' in cleanedProps && cleanedProps.data != null) {
|
if ('data' in cleanedProps && cleanedProps.data != null) {
|
||||||
cleanedProps.data = cloneDataIfChanged(cleanedProps.data);
|
cleanedProps.data = dataSync.cloneExternalDataIfChanged(cleanedProps.data);
|
||||||
}
|
}
|
||||||
|
(['onDataChange', 'onDataCommit'] as const).forEach((callbackKey) => {
|
||||||
|
const callback = cleanedProps[callbackKey];
|
||||||
|
if (typeof callback !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleanedProps[callbackKey] = (
|
||||||
|
data: TinyflowOptions['data'],
|
||||||
|
...args: unknown[]
|
||||||
|
) => {
|
||||||
|
dataSync.markNativeData(data);
|
||||||
|
callback(data, ...args);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
tinyflow = new TinyflowNative({
|
tinyflow = new TinyflowNative({
|
||||||
...cleanedProps,
|
...cleanedProps,
|
||||||
@@ -158,7 +121,7 @@ onUnmounted(() => {
|
|||||||
tinyflow = null;
|
tinyflow = null;
|
||||||
}
|
}
|
||||||
mountedDataReady = false;
|
mountedDataReady = false;
|
||||||
lastAppliedDataSignature = '';
|
dataSync.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -179,7 +142,7 @@ watch(
|
|||||||
data != null &&
|
data != null &&
|
||||||
typeof data !== 'string'
|
typeof data !== 'string'
|
||||||
) {
|
) {
|
||||||
const clonedData = cloneDataIfChanged(data);
|
const clonedData = dataSync.cloneExternalDataIfChanged(data);
|
||||||
if (clonedData) {
|
if (clonedData) {
|
||||||
tinyflow.updateData(clonedData, { preserveViewport: true });
|
tinyflow.updateData(clonedData, { preserveViewport: true });
|
||||||
}
|
}
|
||||||
@@ -229,7 +192,7 @@ const updateData = (
|
|||||||
options?: { preserveViewport?: boolean },
|
options?: { preserveViewport?: boolean },
|
||||||
) => {
|
) => {
|
||||||
if (tinyflow && data != null && typeof data !== 'string') {
|
if (tinyflow && data != null && typeof data !== 'string') {
|
||||||
const clonedData = cloneDataIfChanged(data);
|
const clonedData = dataSync.cloneExternalDataIfChanged(data);
|
||||||
if (!clonedData) {
|
if (!clonedData) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
76
easyflow-ui-admin/packages/tinyflow-vue/src/dataSync.test.ts
Normal file
76
easyflow-ui-admin/packages/tinyflow-vue/src/dataSync.test.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTinyflowDataSync } from './dataSync';
|
||||||
|
|
||||||
|
describe('createTinyflowDataSync', () => {
|
||||||
|
it('克隆真正的外部数据并跳过相同内容', () => {
|
||||||
|
const sync = createTinyflowDataSync();
|
||||||
|
const data = {
|
||||||
|
edges: [],
|
||||||
|
nodes: [{ data: { title: '开始' }, id: 'start', position: { x: 0, y: 0 } }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloned = sync.cloneExternalDataIfChanged(data);
|
||||||
|
|
||||||
|
expect(cloned).toEqual(data);
|
||||||
|
expect(cloned).not.toBe(data);
|
||||||
|
expect(sync.cloneExternalDataIfChanged(data)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('跳过原生画布回传后由父组件形成的属性回声', () => {
|
||||||
|
const sync = createTinyflowDataSync();
|
||||||
|
const emitted = {
|
||||||
|
edges: [],
|
||||||
|
nodes: [{ data: { title: '开始' }, id: 'start', position: { x: 12, y: 8 } }],
|
||||||
|
viewport: { x: 20, y: 30, zoom: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
sync.markNativeData(emitted);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sync.cloneExternalDataIfChanged(structuredClone(emitted)),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('原生回传后仍应用内容不同的外部更新', () => {
|
||||||
|
const sync = createTinyflowDataSync();
|
||||||
|
const emitted = {
|
||||||
|
edges: [],
|
||||||
|
nodes: [{ data: { title: '开始' }, id: 'start', position: { x: 12, y: 8 } }],
|
||||||
|
};
|
||||||
|
sync.markNativeData(emitted);
|
||||||
|
|
||||||
|
const external = {
|
||||||
|
...emitted,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
...emitted.nodes[0],
|
||||||
|
data: { title: '已更新' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(sync.cloneExternalDataIfChanged(external)).toEqual(external);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('画布变化后允许重新应用曾经加载过的外部版本', () => {
|
||||||
|
const sync = createTinyflowDataSync();
|
||||||
|
const initial = {
|
||||||
|
edges: [],
|
||||||
|
nodes: [{ data: { title: '初始' }, id: 'start', position: { x: 0, y: 0 } }],
|
||||||
|
};
|
||||||
|
expect(sync.cloneExternalDataIfChanged(initial)).toEqual(initial);
|
||||||
|
|
||||||
|
sync.markNativeData({
|
||||||
|
...initial,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
...initial.nodes[0],
|
||||||
|
position: { x: 120, y: 80 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sync.cloneExternalDataIfChanged(initial)).toEqual(initial);
|
||||||
|
});
|
||||||
|
});
|
||||||
86
easyflow-ui-admin/packages/tinyflow-vue/src/dataSync.ts
Normal file
86
easyflow-ui-admin/packages/tinyflow-vue/src/dataSync.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { TinyflowOptions } from '@tinyflow-ai/ui';
|
||||||
|
|
||||||
|
type TinyflowDataOption = Exclude<
|
||||||
|
TinyflowOptions['data'],
|
||||||
|
string | undefined
|
||||||
|
>;
|
||||||
|
type StructuredCloneFn = <T>(value: T) => T;
|
||||||
|
|
||||||
|
function createDataSignature(data: unknown) {
|
||||||
|
if (data == null || typeof data === 'string') {
|
||||||
|
return String(data ?? '');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(data);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDeepClone<T>(value: T): T {
|
||||||
|
if (value === null || typeof value !== 'object') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clone = (globalThis as { structuredClone?: StructuredCloneFn })
|
||||||
|
.structuredClone;
|
||||||
|
if (clone) {
|
||||||
|
return clone(value);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to JSON cloning for browsers without native structuredClone.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
console.warn(
|
||||||
|
'Failed to clone object, returning original (may cause issues)',
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 维护 Vue 属性数据与 Tinyflow 原生数据之间的同步边界。
|
||||||
|
*
|
||||||
|
* 原生画布回传的数据会被父组件再次作为属性传入。延迟到属性更新时再比较
|
||||||
|
* 两侧签名,可以阻止同一份画布状态被重新深拷贝并整图应用,同时避免在
|
||||||
|
* 每一帧提前序列化完整画布。
|
||||||
|
*/
|
||||||
|
export function createTinyflowDataSync() {
|
||||||
|
let lastAppliedDataSignature = '';
|
||||||
|
let lastNativeData: TinyflowOptions['data'] | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cloneExternalDataIfChanged(data: TinyflowOptions['data']) {
|
||||||
|
if (data == null || typeof data === 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const signature = createDataSignature(data);
|
||||||
|
if (lastNativeData != null) {
|
||||||
|
const nativeSignature = createDataSignature(lastNativeData);
|
||||||
|
lastNativeData = null;
|
||||||
|
if (signature && signature === nativeSignature) {
|
||||||
|
lastAppliedDataSignature = signature;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
lastAppliedDataSignature = '';
|
||||||
|
}
|
||||||
|
if (signature && signature === lastAppliedDataSignature) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
lastAppliedDataSignature = signature;
|
||||||
|
return safeDeepClone(data as TinyflowDataOption);
|
||||||
|
},
|
||||||
|
markNativeData(data: TinyflowOptions['data']) {
|
||||||
|
lastNativeData = data;
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
lastAppliedDataSignature = '';
|
||||||
|
lastNativeData = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user