Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioModel.ts
陈子默 4e8640dcaf feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离

- 增加 Agent Skill 冻结绑定与运行时消费闭环

- 增加受控工作区、内置工具和私有 Artifact 生命周期
2026-08-19 22:13:41 +08:00

402 lines
12 KiB
TypeScript

import type {
AgentStudioCanvasSize,
AgentStudioEdgeView,
AgentStudioLayoutSnapshot,
AgentStudioNodeData,
AgentStudioNodeView,
} from '../../components/agent-studio/types';
import type {
AgentDraftState,
AgentKnowledgeBinding,
AgentOption,
AgentToolBinding,
} from '../../types';
import { computed } from 'vue';
const BASE_NODE_ID = 'agent-base';
const BASE_POSITION = { x: 430, y: 260 };
const CAPABILITY_GAP = 104;
const CAPABILITY_OFFSET_X = 360;
const CAPABILITY_START_Y = 148;
const CAPABILITY_NODE_HEIGHT = 78;
const CAPABILITY_NODE_WIDTH = 212;
const LEFT_SCREEN_PADDING = 48;
const TOP_SCREEN_PADDING = 96;
const INSPECTOR_RESERVED_WIDTH = 468;
const MIN_VISIBLE_WORKSPACE_WIDTH = 520;
function firstText(...values: unknown[]) {
const matched = values.find((value) => String(value || '').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(
binding: AgentKnowledgeBinding,
optionLookup: Map<string, AgentOption>,
) {
const matchedOption = optionLookup.get(String(binding.knowledgeId));
return firstText(
binding.resourceSummary?.title,
binding.resourceSummary?.name,
binding.resourceSummary?.label,
binding.resourceSummary?.displayName,
binding.resourceSnapshot?.title,
binding.resourceSnapshot?.name,
binding.resourceSnapshot?.label,
binding.resourceSnapshot?.displayName,
binding.title,
binding.name,
binding.knowledgeName,
binding.collectionName,
matchedOption?.label,
matchedOption?.raw?.title,
matchedOption?.raw?.name,
);
}
function findMatchedToolOption(
binding: AgentToolBinding,
optionLookup: Map<string, AgentOption>,
) {
const targetId = String(binding.targetId || '');
if (!targetId) return undefined;
return optionLookup.get(targetId);
}
function buildToolTitle(
binding: AgentToolBinding,
optionLookup: Map<string, AgentOption>,
matchedOption = findMatchedToolOption(binding, optionLookup),
) {
return firstText(
binding.resourceSummary?.title,
binding.resourceSummary?.name,
binding.resourceSummary?.label,
binding.resourceSummary?.displayName,
binding.resourceSummary?.mcpTitle,
binding.resourceSnapshot?.title,
binding.resourceSnapshot?.name,
binding.resourceSnapshot?.label,
binding.resourceSnapshot?.displayName,
binding.resourceSnapshot?.mcpTitle,
matchedOption?.label,
matchedOption?.raw?.title,
matchedOption?.raw?.name,
matchedOption?.raw?.label,
matchedOption?.raw?.displayName,
matchedOption?.raw?.mcpTitle,
binding.toolName,
);
}
function buildToolDetail(
binding: AgentToolBinding,
fallback: string,
optionLookup: Map<string, AgentOption>,
) {
if (String(binding.toolType || '').toUpperCase() === 'MCP') {
const matchedOption = findMatchedToolOption(binding, optionLookup);
const resourceName = buildToolTitle(binding, optionLookup, matchedOption);
const tools =
binding.resourceSummary?.tools ||
binding.resourceSnapshot?.tools ||
matchedOption?.raw?.tools ||
[];
const toolCount = Array.isArray(tools) ? tools.length : 0;
if (resourceName && toolCount > 0) {
return `${resourceName} · ${toolCount} 个工具`;
}
return resourceName || fallback;
}
const toolName = firstText(binding.toolName);
const resourceName = buildToolTitle(binding, optionLookup);
if (toolName && resourceName && toolName !== resourceName) {
return `${resourceName} / ${toolName}`;
}
return resourceName || toolName || fallback;
}
function resolveToolNodePresentation(toolType: string) {
if (toolType === 'WORKFLOW') {
return {
fallback: '待选择工作流',
kind: 'workflow' as const,
title: '工作流',
};
}
if (toolType === 'MCP') {
return {
fallback: '待选择 MCP',
kind: 'mcp' as const,
title: 'MCP',
};
}
return {
fallback: '待选择插件工具',
kind: 'plugin' as const,
title: '插件',
};
}
function toFlowPoint(
point: { x: number; y: number },
viewport: NonNullable<AgentStudioLayoutSnapshot['viewport']>,
) {
return {
x: (point.x - viewport.x) / viewport.zoom,
y: (point.y - viewport.y) / viewport.zoom,
};
}
function resolveVisibleLeftX(
layout?: AgentStudioLayoutSnapshot,
canvasSize?: AgentStudioCanvasSize,
) {
const viewport = layout?.viewport;
if (!viewport || !canvasSize?.width) {
return BASE_POSITION.x - CAPABILITY_OFFSET_X;
}
const availableWidth = Math.max(
MIN_VISIBLE_WORKSPACE_WIDTH,
canvasSize.width - INSPECTOR_RESERVED_WIDTH,
);
const screenX = Math.min(
LEFT_SCREEN_PADDING,
Math.max(24, availableWidth - CAPABILITY_NODE_WIDTH - LEFT_SCREEN_PADDING),
);
return toFlowPoint({ x: screenX, y: 0 }, viewport).x;
}
function resolveVisibleTopY(
layout?: AgentStudioLayoutSnapshot,
canvasSize?: AgentStudioCanvasSize,
) {
const viewport = layout?.viewport;
if (!viewport || !canvasSize?.height) {
return CAPABILITY_START_Y;
}
return toFlowPoint({ x: 0, y: TOP_SCREEN_PADDING }, viewport).y;
}
function hasVerticalOverlap(
position: { x: number; y: number },
occupied: Array<{ x: number; y: number }>,
) {
return occupied.some(
(item) =>
Math.abs(item.x - position.x) < CAPABILITY_NODE_WIDTH &&
Math.abs(item.y - position.y) < CAPABILITY_NODE_HEIGHT,
);
}
export function resolveCapabilityNodePosition(params: {
canvasSize?: AgentStudioCanvasSize;
fallbackIndex: number;
layout?: AgentStudioLayoutSnapshot;
nodeId: string;
occupiedPositions?: Array<{ x: number; y: number }>;
}) {
const persisted = params.layout?.nodePositions?.[params.nodeId];
if (persisted) return persisted;
const occupied = params.occupiedPositions || [];
const x = resolveVisibleLeftX(params.layout, params.canvasSize);
let y =
resolveVisibleTopY(params.layout, params.canvasSize) +
params.fallbackIndex * CAPABILITY_GAP;
while (hasVerticalOverlap({ x, y }, occupied)) {
y += CAPABILITY_GAP;
}
return { x, y };
}
export function useAgentStudioModel(
state: AgentDraftState,
selectedNodeId: () => string,
layout?: AgentStudioLayoutSnapshot,
canvasSize?: () => AgentStudioCanvasSize | undefined,
knowledgeOptions?: () => AgentOption[],
toolOptions?: () => {
mcp?: AgentOption[];
plugin?: AgentOption[];
workflow?: AgentOption[];
},
) {
return computed(() => {
const positionOf = (nodeId: string, fallback: { x: number; y: number }) =>
layout?.nodePositions?.[nodeId] || fallback;
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(
layout?.nodePositions || {},
);
const nodes: AgentStudioNodeView[] = [
{
id: BASE_NODE_ID,
type: 'agentStudioBase',
position: positionOf(BASE_NODE_ID, BASE_POSITION),
width: 268,
height: 104,
data: {
badge: '基座',
detail: firstText(state.agent.description, '等待配置核心提示词'),
iconKey: 'base',
id: BASE_NODE_ID,
kind: 'base',
selected: selectedNodeId() === BASE_NODE_ID,
title: firstText(state.agent.name, '未命名智能体'),
} satisfies AgentStudioNodeData,
},
];
const knowledgeNodes = state.knowledgeBindings.map((binding, index) => {
const nodeId = `knowledge:${binding.localId}`;
const title = buildKnowledgeTitle(binding, knowledgeOptionLookup);
const position = resolveCapabilityNodePosition({
canvasSize: size,
fallbackIndex: index,
layout,
nodeId,
occupiedPositions,
});
occupiedPositions.push(position);
return {
id: nodeId,
type: 'agentStudioCapability',
position,
width: CAPABILITY_NODE_WIDTH,
height: CAPABILITY_NODE_HEIGHT,
data: {
badge: '知识库',
detail: title || '待选择知识库',
iconKey: 'knowledge',
id: nodeId,
kind: 'knowledge',
selected: selectedNodeId() === nodeId,
title: title || '知识库',
} satisfies AgentStudioNodeData,
};
});
const toolNodes = state.toolBindings.map((binding, index) => {
const nodeId = `tool:${binding.localId}`;
const toolType = String(binding.toolType || '').toUpperCase();
const presentation = resolveToolNodePresentation(toolType);
const matchedOptions = toolOptionLookups[presentation.kind];
const detail = buildToolDetail(
binding,
presentation.fallback,
matchedOptions,
);
const position = resolveCapabilityNodePosition({
canvasSize: size,
fallbackIndex: state.knowledgeBindings.length + index,
layout,
nodeId,
occupiedPositions,
});
occupiedPositions.push(position);
return {
id: nodeId,
type: 'agentStudioCapability',
position,
width: CAPABILITY_NODE_WIDTH,
height: CAPABILITY_NODE_HEIGHT,
data: {
badge: presentation.title,
detail,
iconKey: presentation.kind,
id: nodeId,
kind: presentation.kind,
selected: selectedNodeId() === nodeId,
title: detail === presentation.fallback ? presentation.title : detail,
} satisfies AgentStudioNodeData,
};
});
const skillNodes: AgentStudioNodeView[] = [];
if (state.skillBindings.length > 0) {
const nodeId = 'skills';
const previewItems = state.skillBindings
.slice(0, 3)
.map((binding) =>
firstText(binding.resourceSummary?.displayName, '技能'),
);
const position = resolveCapabilityNodePosition({
canvasSize: size,
fallbackIndex:
state.knowledgeBindings.length + state.toolBindings.length,
layout,
nodeId,
occupiedPositions,
});
occupiedPositions.push(position);
skillNodes.push({
id: nodeId,
type: 'agentStudioCapability',
position,
width: 240,
height: 154,
data: {
detail: `${state.skillBindings.length} / 20`,
iconKey: 'skill',
id: nodeId,
kind: 'skill',
previewItems,
remainingCount: Math.max(0, state.skillBindings.length - 3),
selected: selectedNodeId() === nodeId,
title: '技能',
} satisfies AgentStudioNodeData,
});
}
const capabilityNodes = [...knowledgeNodes, ...toolNodes, ...skillNodes];
const edges: AgentStudioEdgeView[] = capabilityNodes.map((node) => ({
id: `edge:${node.id}`,
source: BASE_NODE_ID,
target: node.id,
}));
nodes.push(...capabilityNodes);
return { edges, nodes };
});
}