support tomology detection

This commit is contained in:
锦恢 2025-07-02 21:37:33 +08:00
parent a735bbf023
commit 4a210eaa82
4 changed files with 214 additions and 37 deletions

View File

@ -12,8 +12,8 @@
<Diagram /> <Diagram />
</el-scrollbar> </el-scrollbar>
<transition name="main-fade" mode="out-in"> <transition name="main-fade" mode="out-in">
<div class="caption" v-show="context.caption.value"> <div class="caption" v-show="showCaption">
{{ context.caption }} {{ caption }}
</div> </div>
</transition> </transition>
</el-dialog> </el-dialog>
@ -23,13 +23,27 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { provide, ref } from 'vue'; import { nextTick, provide, ref } from 'vue';
import Diagram from './diagram.vue'; import Diagram from './diagram.vue';
const showDiagram = ref(true); const showDiagram = ref(true);
const caption = ref('');
const showCaption = ref(false);
const context = { const context = {
reset: () => {}, reset: () => {},
caption: ref('') setCaption: (text: string) => {
caption.value = text;
if (caption.value) {
nextTick(() => {
showCaption.value = true;
});
} else {
nextTick(() => {
showCaption.value = false;
});
}
}
}; };
provide('context', context); provide('context', context);

View File

@ -0,0 +1,141 @@
import type { ElkNode } from 'elkjs/lib/elk-api';
export interface Edge {
id: string;
sources: string[];
targets: string[];
section?: any; // { startPoint: { x, y }, endPoint: { x,
}
export type Node = ElkNode & {
[key: string]: any;
};
export interface DiagramState {
nodes: Node[];
edges: Edge[];
selectedNodeId: string | null;
[key: string]: any;
}
export interface CanConnectResult {
canConnect: boolean;
reason?: string;
}
/**
* @description
*/
export function invalidConnectionDetector(state: DiagramState, d: Node): CanConnectResult {
const from = state.selectedNodeId;
const to = d.id;
if (!from) {
return { canConnect: false, reason: '未选择起始节点' };
}
if (from === to) {
return { canConnect: false, reason: '不能连接到自身' };
}
// 建立邻接表
const adjacencyList: Record<string, Set<string>> = {};
state.edges.forEach(edge => {
const src = edge.sources[0];
const tgt = edge.targets[0];
if (!adjacencyList[src]) {
adjacencyList[src] = new Set();
}
adjacencyList[src].add(tgt);
});
// DFS 检测是否存在
function hasPath(current: string, target: string, visited: Set<string>): boolean {
if (current === target) return true;
visited.add(current);
const neighbors = adjacencyList[current] || new Set();
for (const neighbor of neighbors) {
if (!visited.has(neighbor)) {
if (hasPath(neighbor, target, visited)) {
return true;
}
}
}
return false;
}
if (hasPath(to, from, new Set())) {
return { canConnect: false, reason: '连接会形成环路' };
}
if (hasPath(from, to, new Set())) {
return { canConnect: false, reason: '这是一个重复的连接' };
}
return {
canConnect: true
}
}
// export async function generateAIMockData(params: any) {
// if (!currentTool.value?.inputSchema) return;
// aiMockLoading.value = true;
// try {
// const loop = new TaskLoop({ maxEpochs: 1 });
// const usePrompt = prompt || `please call the tool ${currentTool.value.name} to make some test`;
// const chatStorage = {
// messages: [],
// settings: {
// temperature: 0.6,
// systemPrompt: '',
// enableTools: [{
// name: currentTool.value.name,
// description: currentTool.value.description,
// inputSchema: currentTool.value.inputSchema,
// enabled: true
// }],
// enableWebSearch: false,
// contextLength: 5,
// enableXmlWrapper: enableXmlWrapper.value,
// parallelToolCalls: false
// }
// } as ChatStorage;
// loop.setMaxEpochs(1);
// let aiMockJson: any = undefined;
// loop.registerOnToolCall(toolCall => {
// console.log(toolCall);
// if (toolCall.function?.name === currentTool.value?.name) {
// try {
// const toolArgs = JSON.parse(toolCall.function?.arguments || '{}');
// aiMockJson = toolArgs;
// } catch (e) {
// ElMessage.error('AI 生成的 JSON 解析错误');
// }
// } else {
// ElMessage.error('AI 调用了未知的工具');
// }
// loop.abort();
// return toolCall;
// });
// loop.registerOnError(error => {
// ElMessage.error(error + '');
// });
// await loop.start(chatStorage, usePrompt);
// if (aiMockJson && typeof aiMockJson === 'object') {
// Object.keys(aiMockJson).forEach(key => {
// tabStorage.formData[key] = aiMockJson[key];
// });
// formRef.value?.clearValidate?.();
// }
// } finally {
// aiMockLoading.value = false;
// }
// };

View File

@ -7,17 +7,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, nextTick, reactive, inject } from 'vue'; import { ref, onMounted, nextTick, reactive, inject } from 'vue';
import * as d3 from 'd3'; import * as d3 from 'd3';
import ELK, { type ElkNode } from 'elkjs/lib/elk.bundled.js'; import ELK from 'elkjs/lib/elk.bundled.js';
import { mcpClientAdapter } from '@/views/connect/core'; import { mcpClientAdapter } from '@/views/connect/core';
import { invalidConnectionDetector, type Edge, type Node } from './diagram';
import { ElMessage } from 'element-plus';
const svgContainer = ref<HTMLDivElement | null>(null); const svgContainer = ref<HTMLDivElement | null>(null);
let prevNodes: any[] = []; let prevNodes: any[] = [];
let prevEdges: any[] = []; let prevEdges: any[] = [];
type Node = ElkNode & {
[key: string]: any;
};
const state = reactive({ const state = reactive({
nodes: [] as any[], nodes: [] as any[],
edges: [] as any[], edges: [] as any[],
@ -63,16 +61,11 @@ const recomputeLayout = async () => {
const drawDiagram = async () => { const drawDiagram = async () => {
const tools = await getAllTools(); const tools = await getAllTools();
state.nodes = tools.map((tool, i) => ({
id: tool.name,
width: 160,
height: 48,
labels: [{ text: tool.name || 'Tool' }]
}));
// //
const edges = []; const nodes = [] as Node[];
const edges = [] as Edge[];
for (let i = 0; i < tools.length - 1; ++ i) { for (let i = 0; i < tools.length - 1; ++ i) {
const prev = tools[i]; const prev = tools[i];
const next = tools[i + 1]; const next = tools[i + 1];
@ -83,7 +76,17 @@ const drawDiagram = async () => {
}) })
} }
for (const tool of tools) {
nodes.push({
id: tool.name,
width: 160,
height: 48,
labels: [{ text: tool.name || 'Tool' }]
});
}
state.edges = edges; state.edges = edges;
state.nodes = nodes;
// //
await recomputeLayout(); await recomputeLayout();
@ -96,7 +99,14 @@ function renderSvg() {
const prevNodeMap = new Map(prevNodes.map(n => [n.id, n])); const prevNodeMap = new Map(prevNodes.map(n => [n.id, n]));
const prevEdgeMap = new Map(prevEdges.map(e => [e.id, e])); const prevEdgeMap = new Map(prevEdges.map(e => [e.id, e]));
const width = Math.max(...state.nodes.map(n => (n.x || 0) + (n.width || 160)), 400) + 60; // xx
const xs = state.nodes.map(n => (n.x || 0));
const minX = Math.min(...xs);
const maxX = Math.max(...xs.map((x, i) => x + (state.nodes[i].width || 160)));
const contentWidth = maxX - minX;
const svgWidth = Math.max(contentWidth + 120, 400); // 120
const offsetX = (svgWidth - contentWidth) / 2 - minX;
const height = Math.max(...state.nodes.map(n => (n.y || 0) + (n.height || 48)), 300) + 60; const height = Math.max(...state.nodes.map(n => (n.y || 0) + (n.height || 48)), 300) + 60;
// svg // svg
@ -105,11 +115,11 @@ function renderSvg() {
svg = d3 svg = d3
.select(svgContainer.value) .select(svgContainer.value)
.append('svg') .append('svg')
.attr('width', width) .attr('width', svgWidth)
.attr('height', height) .attr('height', height)
.style('user-select', 'none') as any; .style('user-select', 'none') as any;
} else { } else {
svg.attr('width', width).attr('height', height); svg.attr('width', svgWidth).attr('height', height);
svg.selectAll('defs').remove(); svg.selectAll('defs').remove();
} }
@ -128,6 +138,16 @@ function renderSvg() {
.attr('d', 'M 0 0 L 8 4 L 0 8 z') .attr('d', 'M 0 0 L 8 4 L 0 8 z')
.attr('fill', 'var(--main-color)'); .attr('fill', 'var(--main-color)');
// 1. / main group
let mainGroup = svg.select('g.main-group');
if (mainGroup.empty()) {
mainGroup = svg.append('g').attr('class', 'main-group') as any;
}
mainGroup
.transition()
.duration(600)
.attr('transform', `translate(${offsetX}, 0)`);
// Draw edges with enter animation // Draw edges with enter animation
const allSections: { id: string, section: any }[] = []; const allSections: { id: string, section: any }[] = [];
(state.edges || []).forEach(edge => { (state.edges || []).forEach(edge => {
@ -140,7 +160,7 @@ function renderSvg() {
}); });
}); });
const edgeSelection = svg.selectAll<SVGLineElement, any>('.edge') const edgeSelection = mainGroup.selectAll<SVGLineElement, any>('.edge')
.data(allSections, d => d.id); .data(allSections, d => d.id);
edgeSelection.exit().remove(); edgeSelection.exit().remove();
@ -198,7 +218,7 @@ function renderSvg() {
.attr('opacity', 1); .attr('opacity', 1);
// --- --- // --- ---
const nodeGroup = svg.selectAll<SVGGElement, any>('.node') const nodeGroup = mainGroup.selectAll<SVGGElement, any>('.node')
.data(state.nodes, d => d.id); .data(state.nodes, d => d.id);
nodeGroup.exit().remove(); nodeGroup.exit().remove();
@ -219,16 +239,18 @@ function renderSvg() {
.on('mousedown', null) .on('mousedown', null)
.on('mouseup', function (event, d) { .on('mouseup', function (event, d) {
event.stopPropagation(); event.stopPropagation();
if (state.selectedNodeId && state.selectedNodeId !== d.id) { if (state.selectedNodeId) {
// 线
const exists = state.edges.some( const { canConnect, reason } = invalidConnectionDetector(state, d);
e =>
Array.isArray(e.sources) && console.log(reason);
Array.isArray(e.targets) &&
e.sources[0] === state.selectedNodeId &&
e.targets[0] === d.id if (reason) {
); ElMessage.warning(reason);
if (!exists) { }
if (canConnect) {
state.edges.push({ state.edges.push({
id: `e${state.selectedNodeId}_${d.id}_${Date.now()}`, id: `e${state.selectedNodeId}_${d.id}_${Date.now()}`,
sources: [state.selectedNodeId], sources: [state.selectedNodeId],
@ -241,12 +263,12 @@ function renderSvg() {
state.selectedNodeId = null; state.selectedNodeId = null;
renderSvg(); renderSvg();
} }
context.caption.value = ''; context.setCaption('');
} else { } else {
state.selectedNodeId = d.id; state.selectedNodeId = d.id;
renderSvg(); renderSvg();
context.caption.value = '选择另一个节点以构建顺序'; context.setCaption('选择另一个节点以定义测试拓扑');
} }
state.draggingNodeId = null; state.draggingNodeId = null;
}) })
@ -315,7 +337,7 @@ function renderSvg() {
.attr('stroke', 'var(--main-color)') .attr('stroke', 'var(--main-color)')
.attr('stroke-width', 4.5); .attr('stroke-width', 4.5);
context.caption.value = '点击边以删除'; context.setCaption('点击边以删除');
}) })
.on('mouseout', function () { .on('mouseout', function () {
@ -325,8 +347,7 @@ function renderSvg() {
.attr('stroke', 'var(--main-color)') .attr('stroke', 'var(--main-color)')
.attr('stroke-width', 2.5); .attr('stroke-width', 2.5);
context.caption.value = ''; context.setCaption('');
}) })
.on('click', function (event, d) { .on('click', function (event, d) {
// edge // edge

View File

@ -233,6 +233,7 @@ const generateAIMockData = async (prompt?: string) => {
aiMockLoading.value = false; aiMockLoading.value = false;
} }
}; };
const generateMockData = async () => { const generateMockData = async () => {
if (!currentTool.value?.inputSchema) return; if (!currentTool.value?.inputSchema) return;
mockLoading.value = true; mockLoading.value = true;