perf: 优化并重构工作流幕布UI表现

This commit is contained in:
2026-03-05 21:40:05 +08:00
parent 03c5f2cd2e
commit 265bb79ba3
12 changed files with 1384 additions and 273 deletions

View File

@@ -6,7 +6,7 @@ import {usePreferences} from '@easyflow/preferences';
import {getOptions, sortNodes} from '@easyflow/utils'; import {getOptions, sortNodes} from '@easyflow/utils';
import {getIconByValue} from '#/views/ai/model/modelUtils/defaultIcon'; import {getIconByValue} from '#/views/ai/model/modelUtils/defaultIcon';
import {ArrowLeft, CircleCheck, Close, Position} from '@element-plus/icons-vue'; import {ArrowLeft, CircleCheck, Close} from '@element-plus/icons-vue';
import {Tinyflow} from '@tinyflow-ai/vue'; import {Tinyflow} from '@tinyflow-ai/vue';
import {ElButton, ElDrawer, ElMessage, ElSkeleton} from 'element-plus'; import {ElButton, ElDrawer, ElMessage, ElSkeleton} from 'element-plus';
@@ -512,7 +512,7 @@ function onAsyncExecute(info: any) {
</span> </span>
</ElButton> </ElButton>
</div> </div>
<div> <div class="workflow-head-actions">
<ElButton <ElButton
:loading="checkLoading" :loading="checkLoading"
:disabled="saveLoading" :disabled="saveLoading"
@@ -521,13 +521,6 @@ function onAsyncExecute(info: any) {
> >
{{ $t('aiWorkflow.check') }} {{ $t('aiWorkflow.check') }}
</ElButton> </ElButton>
<ElButton
:disabled="saveLoading || checkLoading"
:icon="Position"
@click="runWorkflow"
>
{{ $t('button.runTest') }}
</ElButton>
<ElButton <ElButton
type="primary" type="primary"
:disabled="saveLoading || checkLoading" :disabled="saveLoading || checkLoading"
@@ -546,6 +539,7 @@ function onAsyncExecute(info: any) {
:provider="provider" :provider="provider"
:custom-nodes="customNode" :custom-nodes="customNode"
:on-node-execute="runIndependently" :on-node-execute="runIndependently"
:on-run-test="runWorkflow"
/> />
<transition name="checklist-slide"> <transition name="checklist-slide">
<div v-if="checkIssuesVisible" class="checklist-panel"> <div v-if="checkIssuesVisible" class="checklist-panel">
@@ -598,20 +592,30 @@ function onAsyncExecute(info: any) {
</template> </template>
<style scoped> <style scoped>
:deep(.tf-toolbar-container-body) {
height: calc(100vh - 365px) !important;
overflow-y: auto;
}
:deep(.agentsflow) { :deep(.agentsflow) {
height: calc(100vh - 130px) !important; height: calc(100vh - 130px) !important;
} }
:deep(.tf-bottom-dock) {
left: 50% !important;
bottom: 16px !important;
transform: translateX(-50%) !important;
z-index: 46 !important;
}
.head-div { .head-div {
--workflow-bottom-dock-height: 56px;
--workflow-checklist-offset: calc(var(--workflow-bottom-dock-height) + 24px);
position: relative; position: relative;
background-color: var(--el-bg-color); background-color: var(--el-bg-color);
} }
.workflow-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.tiny-flow-container { .tiny-flow-container {
width: 100%; width: 100%;
height: calc(100vh - 150px); height: calc(100vh - 150px);
@@ -625,8 +629,8 @@ function onAsyncExecute(info: any) {
position: absolute; position: absolute;
left: 20px; left: 20px;
right: 20px; right: 20px;
bottom: 16px; bottom: var(--workflow-checklist-offset);
z-index: 40; z-index: 60;
max-height: min(320px, 42vh); max-height: min(320px, 42vh);
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -5,6 +5,15 @@ import { lazyImport, VxeResolver } from 'vite-plugin-lazy-import';
async function viteVxeTableImportsPlugin(): Promise<PluginOption> { async function viteVxeTableImportsPlugin(): Promise<PluginOption> {
return [ return [
lazyImport({ lazyImport({
// 仅处理源码,避免扫描工作区包的 dist 产物导致解析异常。
include: [
'**/src/**/*.vue',
'**/src/**/*.ts',
'**/src/**/*.js',
'**/src/**/*.tsx',
'**/src/**/*.jsx',
],
exclude: ['**/node_modules/**', '**/dist/**'],
resolvers: [ resolvers: [
VxeResolver({ VxeResolver({
libraryName: 'vxe-table', libraryName: 'vxe-table',

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { import {
Background, Background,
Controls,
type Edge, type Edge,
type EdgeTypes,
MarkerType, MarkerType,
MiniMap, MiniMap,
type Node, type Node,
@@ -16,6 +16,11 @@
import {store} from '#store/stores.svelte'; import {store} 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 FlowEdge from './core/FlowEdge.svelte';
import FlowLinePath from './core/FlowLinePath.svelte';
import FlowMarkerDefs from './core/FlowMarkerDefs.svelte';
import FlowConnectionLine from './core/FlowConnectionLine.svelte';
import {genShortId} from './utils/IdGen'; import {genShortId} from './utils/IdGen';
import {useGetNode} from './utils/useGetNode.svelte'; import {useGetNode} from './utils/useGetNode.svelte';
import {useEnsureParentInNodesBefore} from './utils/useEnsureParentInNodesBefore.svelte'; import {useEnsureParentInNodesBefore} from './utils/useEnsureParentInNodesBefore.svelte';
@@ -31,6 +36,7 @@
import {useCopyPasteHandler} from '#components/utils/useCopyPasteHandler.svelte'; import {useCopyPasteHandler} from '#components/utils/useCopyPasteHandler.svelte';
import {onDestroy, onMount} from 'svelte'; import {onDestroy, onMount} from 'svelte';
import {isInEditableElement} from '#components/utils/isInEditableElement'; import {isInEditableElement} from '#components/utils/isInEditableElement';
import {getAvailableNodes, type NodePaletteItem} from './utils/nodePalette';
const { onInit }: { onInit: any; [key: string]: any } = $props(); const { onInit }: { onInit: any; [key: string]: any } = $props();
const svelteFlow = useSvelteFlow(); const svelteFlow = useSvelteFlow();
@@ -39,10 +45,259 @@
let showEdgePanel = $state(false); let showEdgePanel = $state(false);
let currentEdge = $state<Edge | null>(null); let currentEdge = $state<Edge | null>(null);
let nodePickerVisible = $state(false);
let pendingConnection = $state<null | {
source: string;
sourceHandle?: string;
startClientX: number;
startClientY: number;
clientX: number;
clientY: number;
}>(null);
let flowRootEl = $state<HTMLDivElement | null>(null);
let inlineNodePickerEl = $state<HTMLDivElement | null>(null);
let connectStartPoint = $state<{ x: number; y: number } | null>(null);
let canvasLocked = $state(false);
const asString = (value: unknown) => (value == null ? '' : String(value)); const asString = (value: unknown) => (value == null ? '' : String(value));
const options = getOptions();
const availableNodes = getAvailableNodes(options);
const onRunTest = options.onRunTest;
const { updateEdgeData } = useUpdateEdgeData(); const { updateEdgeData } = useUpdateEdgeData();
function getEventClientPosition(event: any) {
if (typeof event?.clientX === 'number' && typeof event?.clientY === 'number') {
return { x: event.clientX, y: event.clientY };
}
const touch = event?.changedTouches?.[0] || event?.touches?.[0];
if (touch && typeof touch.clientX === 'number' && typeof touch.clientY === 'number') {
return { x: touch.clientX, y: touch.clientY };
}
if (flowRootEl) {
const rect = flowRootEl.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
}
function ensureEdgeVisualDefaults(edge: any) {
return {
...edge,
type: edge.type || 'flow',
markerEnd: edge.markerEnd || {
type: MarkerType.ArrowClosed,
width: 20,
height: 20
}
};
}
function normalizeEdgeBeforeConnect(edge: any) {
const sourceNode = edge.source ? getNode(edge.source) : null;
const sourceHandle = edge.sourceHandle || '';
const isConditionBranchEdge = sourceNode?.type === 'conditionNode'
&& typeof sourceHandle === 'string'
&& sourceHandle.startsWith('branch_');
if (isConditionBranchEdge) {
const branchId = sourceHandle.slice(7);
const branches = (sourceNode?.data?.branches || []) as Array<any>;
const branch = branches.find((item) => item?.id === branchId);
const branchLabel = branch?.label || '条件分支';
return ensureEdgeVisualDefaults({
...edge,
id: genShortId(),
data: {
...((edge as any).data || {}),
managedByConditionNode: true,
branchId,
branchLabel,
condition: `matchedBranchId === '${branchId}'`
}
});
}
return ensureEdgeVisualDefaults({
...edge,
id: edge.id || genShortId()
});
}
function addNodeByPalette(
nodeItem: NodePaletteItem,
position: { x: number; y: number },
connection?: { source: string; sourceHandle?: string }
) {
const sourceNode = connection?.source ? getNode(connection.source) : undefined;
const newNode = {
id: `node_${genShortId()}`,
position: { ...position },
type: nodeItem.type,
data: {
title: nodeItem.title,
description: nodeItem.description,
...(nodeItem.extra || {})
}
} as Node;
if (sourceNode) {
if (connection?.sourceHandle === 'loop_handle') {
newNode.parentId = sourceNode.id;
} else if (sourceNode.parentId) {
newNode.parentId = sourceNode.parentId;
}
if (newNode.parentId) {
const { x, y } = getNodeRelativePosition(newNode.parentId);
newNode.position = {
x: position.x - x,
y: position.y - y
};
}
}
store.addNode(newNode);
store.selectNodeOnly(newNode.id);
if (newNode.parentId) {
ensureParentInNodesBefore(newNode.parentId, newNode.id);
}
if (sourceNode && connection?.source) {
const edge = normalizeEdgeBeforeConnect({
source: connection.source,
sourceHandle: connection.sourceHandle || undefined,
target: newNode.id
});
store.addEdge(edge as Edge);
}
}
function closeNodePicker() {
nodePickerVisible = false;
pendingConnection = null;
connectStartPoint = null;
}
function handleToolbarSelectNode(nodeItem: NodePaletteItem) {
const rect = flowRootEl?.getBoundingClientRect();
const clientX = rect ? rect.left + rect.width / 2 : window.innerWidth / 2;
const clientY = rect ? rect.top + rect.height / 2 - 80 : window.innerHeight / 2;
const position = svelteFlow.screenToFlowPosition({ x: clientX, y: clientY });
addNodeByPalette(nodeItem, position);
}
function handlePickerSelectNode(nodeItem: NodePaletteItem) {
if (!pendingConnection) {
handleToolbarSelectNode(nodeItem);
closeNodePicker();
return;
}
const position = svelteFlow.screenToFlowPosition({
x: pendingConnection.clientX,
y: pendingConnection.clientY
});
addNodeByPalette(nodeItem, position, {
source: pendingConnection.source,
sourceHandle: pendingConnection.sourceHandle
});
closeNodePicker();
}
const inlineNodePickerStyle = $derived.by(() => {
if (!nodePickerVisible || !pendingConnection || !flowRootEl) {
return '';
}
const rect = flowRootEl.getBoundingClientRect();
const margin = 12;
const width = Math.min(480, Math.max(300, rect.width - margin * 2));
const maxHeight = Math.min(380, Math.max(240, rect.height - margin * 2));
const anchorX = pendingConnection.clientX - rect.left;
const anchorY = pendingConnection.clientY - rect.top;
let left = anchorX + 14;
if (left + width > rect.width - margin) {
left = rect.width - width - margin;
}
if (left < margin) {
left = margin;
}
let top = anchorY - 24;
if (top + maxHeight > rect.height - margin) {
top = rect.height - maxHeight - margin;
}
if (top < margin) {
top = margin;
}
return `left:${left}px;top:${top}px;width:${width}px;max-height:${maxHeight}px;`;
});
const pendingConnectionLine = $derived.by(() => {
if (!nodePickerVisible || !pendingConnection || !flowRootEl) {
return null;
}
const rect = flowRootEl.getBoundingClientRect();
const x1 = pendingConnection.startClientX - rect.left;
const y1 = pendingConnection.startClientY - rect.top;
const x2 = pendingConnection.clientX - rect.left;
const y2 = pendingConnection.clientY - rect.top;
const deltaX = x2 - x1;
const c1x = x1 + Math.max(26, deltaX * 0.35);
const c2x = x2 - Math.max(26, deltaX * 0.35);
return {
x1,
y1,
x2,
y2,
path: `M ${x1} ${y1} C ${c1x} ${y1}, ${c2x} ${y2}, ${x2} ${y2}`
};
});
// 支持的缩放百分比选项
const zoomOptions = [25, 50, 75, 100, 125, 150, 200];
let currentZoomPercent = $state(100);
let zoomSelectValue = $state('100');
// 监听 viewport 变化同步缩放显示值
$effect(() => {
const vp = store.getViewport();
if (vp) {
currentZoomPercent = Math.round(vp.zoom * 100);
zoomSelectValue = String(currentZoomPercent);
}
});
function handleZoomSelect(event: Event) {
const value = parseInt((event.target as HTMLSelectElement).value, 10);
if (!isNaN(value)) {
zoomSelectValue = String(value);
const zoom = value / 100;
const viewport = store.getViewport() || { x: 0, y: 0, zoom: 1 };
if (typeof (svelteFlow as any).setViewport === 'function') {
(svelteFlow as any).setViewport(
{
x: viewport.x,
y: viewport.y,
zoom
},
{ duration: 180 }
);
} else {
svelteFlow.zoomTo(zoom, { duration: 180 });
}
}
}
function fitViewCanvas() {
svelteFlow.fitView({ duration: 220, padding: 0.2 });
}
function toggleCanvasLock() {
canvasLocked = !canvasLocked;
}
const onDragOver = (event: DragEvent) => { const onDragOver = (event: DragEvent) => {
event.preventDefault(); event.preventDefault();
if (event.dataTransfer) { if (event.dataTransfer) {
@@ -110,10 +365,28 @@
const { getNodesFromSource } = useGetNodesFromSource(); const { getNodesFromSource } = useGetNodesFromSource();
const { getNodeRelativePosition } = useGetNodeRelativePosition(); const { getNodeRelativePosition } = useGetNodeRelativePosition();
const { ensureParentInNodesBefore } = useEnsureParentInNodesBefore(); const { ensureParentInNodesBefore } = useEnsureParentInNodesBefore();
const onconnectend = (_: any, state: any) => { const onconnectend = (event: any, state: any) => {
if (!state.isValid) { if (!state.isValid) {
if (state.toNode) {
return; return;
} }
const fromNode = state.fromNode as Node | null;
if (fromNode) {
const fromHandle = state.fromHandle as { id?: string } | null;
const point = getEventClientPosition(event);
pendingConnection = {
source: fromNode.id,
sourceHandle: fromHandle?.id,
startClientX: connectStartPoint?.x ?? point.x,
startClientY: connectStartPoint?.y ?? point.y,
clientX: point.x,
clientY: point.y
};
nodePickerVisible = true;
}
return;
}
closeNodePicker();
const toNode = state.toNode as Node; const toNode = state.toNode as Node;
if (toNode.parentId) { if (toNode.parentId) {
@@ -244,7 +517,8 @@
const onconnectstart = (event: any, node: any) => { const onconnectstart = (event: any, node: any) => {
// console.log('onconnectstart: ', event, node); const point = getEventClientPosition(event);
connectStartPoint = { x: point.x, y: point.y };
}; };
@@ -255,6 +529,11 @@
const { copyHandler, pasteHandler } = useCopyPasteHandler(); const { copyHandler, pasteHandler } = useCopyPasteHandler();
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (nodePickerVisible && e.key === 'Escape') {
e.preventDefault();
closeNodePicker();
return;
}
if (isInEditableElement()) { if (isInEditableElement()) {
return; return;
@@ -287,28 +566,45 @@
pasteHandler(event); pasteHandler(event);
}; };
function handleGlobalPointerDown(event: PointerEvent) {
if (!nodePickerVisible || !inlineNodePickerEl) {
return;
}
const target = event.target as HTMLElement | null;
if (target && inlineNodePickerEl.contains(target)) {
return;
}
closeNodePicker();
}
onMount(() => { onMount(() => {
store.updateEdges((edges) => edges.map((edge) => ensureEdgeVisualDefaults(edge)));
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
window.addEventListener('paste', handleGlobalPaste); window.addEventListener('paste', handleGlobalPaste);
window.addEventListener('pointerdown', handleGlobalPointerDown);
}); });
onDestroy(() => { onDestroy(() => {
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('paste', handleGlobalPaste); window.removeEventListener('paste', handleGlobalPaste);
window.removeEventListener('pointerdown', handleGlobalPointerDown);
}); });
const customNodeTypes = { const customNodeTypes = {
// ...nodeTypes // ...nodeTypes
} as NodeTypes; } as NodeTypes;
const customEdgeTypes = {
flow: FlowEdge
} as EdgeTypes;
const customNodes = getOptions().customNodes; const customNodes = options.customNodes;
if (customNodes) { if (customNodes) {
for (let key of Object.keys(customNodes)) { for (let key of Object.keys(customNodes)) {
customNodeTypes[key] = CustomNode as any; customNodeTypes[key] = CustomNode as any;
} }
} }
const onDataChange = getOptions().onDataChange; const onDataChange = options.onDataChange;
$effect(() => { $effect(() => {
onDataChange?.({ onDataChange?.({
nodes: store.getNodes(), nodes: store.getNodes(),
@@ -321,11 +617,22 @@
</script> </script>
<div style="position: relative; height: 100%; width: 100%;overflow: hidden"> <div
class:tf-canvas-locked={canvasLocked}
style="position: relative; height: 100%; width: 100%;overflow: hidden"
bind:this={flowRootEl}
>
<SvelteFlow nodeTypes={{ ...nodeTypes, ...customNodeTypes}} <SvelteFlow nodeTypes={{ ...nodeTypes, ...customNodeTypes}}
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}
bind:viewport={store.getViewport, store.setViewport} bind:viewport={store.getViewport, store.setViewport}
nodesDraggable={!canvasLocked}
nodesConnectable={!canvasLocked}
elementsSelectable={!canvasLocked}
panOnDrag={!canvasLocked}
zoomOnScroll={!canvasLocked}
zoomOnDoubleClick={!canvasLocked}
ondrop={onDrop} ondrop={onDrop}
ondragover={onDragOver} ondragover={onDragOver}
isValidConnection={isValidConnection} isValidConnection={isValidConnection}
@@ -333,40 +640,12 @@
onconnectstart={onconnectstart} onconnectstart={onconnectstart}
onconnect={onconnect} onconnect={onconnect}
connectionRadius={50} connectionRadius={50}
connectionLineComponent={FlowConnectionLine}
onedgeclick={(e) => { onedgeclick={(e) => {
showEdgePanel = true; showEdgePanel = true;
currentEdge = e.edge; currentEdge = e.edge;
}} }}
onbeforeconnect={(edge: any) => { onbeforeconnect={(edge: any) => normalizeEdgeBeforeConnect(edge)}
const sourceNode = edge.source ? getNode(edge.source) : null;
const sourceHandle = edge.sourceHandle || '';
const isConditionBranchEdge = sourceNode?.type === 'conditionNode'
&& typeof sourceHandle === 'string'
&& sourceHandle.startsWith('branch_');
if (isConditionBranchEdge) {
const branchId = sourceHandle.slice(7);
const branches = (sourceNode?.data?.branches || []) as Array<any>;
const branch = branches.find((item) => item?.id === branchId);
const branchLabel = branch?.label || '条件分支';
return {
...edge,
id: genShortId(),
data: {
...((edge as any).data || {}),
managedByConditionNode: true,
branchId,
branchLabel,
condition: `matchedBranchId === '${branchId}'`
}
};
}
return {
...edge,
id:genShortId(),
}
}}
ondelete={onDelete} ondelete={onDelete}
onclick={(e) => { onclick={(e) => {
const el = e.target as HTMLElement; const el = e.target as HTMLElement;
@@ -379,18 +658,15 @@
currentEdge = null; currentEdge = null;
}} }}
defaultEdgeOptions={{ defaultEdgeOptions={{
// animated: true, type: 'flow',
// label: 'edge label',
markerEnd: { markerEnd: {
type: MarkerType.ArrowClosed, type: MarkerType.ArrowClosed,
// color: 'red',
width: 20, width: 20,
height: 20 height: 20
} }
}} }}
> >
<Background /> <Background />
<Controls />
<MiniMap /> <MiniMap />
{#if showEdgePanel} {#if showEdgePanel}
@@ -455,7 +731,93 @@
</Panel> </Panel>
{/if} {/if}
</SvelteFlow> </SvelteFlow>
<Toolbar /> {#if nodePickerVisible}
{#if pendingConnectionLine}
<svg class="node-picker-connection-line" width="100%" height="100%">
<FlowMarkerDefs id="tf-flow-inline-arrow-closed" />
<FlowLinePath
path={pendingConnectionLine.path}
markerEnd="url(#tf-flow-inline-arrow-closed)"
animated={false}
/>
</svg>
{/if}
<div
class="node-picker-inline"
style={inlineNodePickerStyle}
bind:this={inlineNodePickerEl}
>
<NodePicker nodes={availableNodes} onSelect={handlePickerSelectNode} />
</div>
{/if}
<div class="tf-bottom-dock">
<div class="tf-unified-bar">
<!-- 缩放百分比选择器 -->
<select
class="tf-zoom-select tf-zoom-select-simple"
value={zoomSelectValue}
onchange={handleZoomSelect}
aria-label="缩放比例"
>
{#each zoomOptions as opt}
<option value={String(opt)}>{opt}%</option>
{/each}
{#if !zoomOptions.includes(currentZoomPercent)}
<option value={String(currentZoomPercent)} selected>{currentZoomPercent}%</option>
{/if}
</select>
<!-- 适配视图按钮 -->
<button
class="tf-bar-btn"
onclick={fitViewCanvas}
aria-label="适配视图"
title="适配视图"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 7H10V5H5V10H7V7ZM14 5V7H17V10H19V5H14ZM17 17H14V19H19V14H17V17ZM7 14H5V19H10V17H7V14Z"></path>
</svg>
</button>
<!-- 锁定/解锁按钮 -->
<button
class="tf-bar-btn {canvasLocked ? 'tf-bar-btn-active' : ''}"
onclick={toggleCanvasLock}
aria-label={canvasLocked ? '解锁画布' : '锁定画布'}
title={canvasLocked ? '解锁画布' : '锁定画布'}
>
{#if canvasLocked}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 10V8C6 4.68629 8.68629 2 12 2C15.3137 2 18 4.68629 18 8V10H19C19.5523 10 20 10.4477 20 11V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V11C4 10.4477 4.44772 10 5 10H6ZM8 10H16V8C16 5.79086 14.2091 4 12 4C9.79086 4 8 5.79086 8 8V10Z"></path>
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 10V8C7 5.23858 9.23858 3 12 3C14.7614 3 17 5.23858 17 8V10H20V22H4V10H7ZM9 10H15V8C15 6.34315 13.6569 5 12 5C10.3431 5 9 6.34315 9 8V10Z"></path>
</svg>
{/if}
</button>
<!-- 分割线 -->
<div class="tf-bar-divider"></div>
<!-- 增加节点按钮(由 Toolbar 渲染) -->
<Toolbar nodes={availableNodes} onSelectNode={handleToolbarSelectNode} />
</div>
{#if onRunTest}
<button
class="tf-bar-run-btn"
onclick={() => onRunTest?.()}
aria-label="试运行"
title="试运行"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5V19L19 12L8 5Z"></path>
</svg>
试运行
</button>
{/if}
</div>
</div> </div>
<style> <style>
@@ -497,4 +859,31 @@
font-size: 12px; font-size: 12px;
color: var(--tf-text-muted); color: var(--tf-text-muted);
} }
.node-picker-inline {
position: absolute;
z-index: 270;
min-width: 300px;
border: 1px solid var(--tf-border-color);
border-radius: 12px;
background: var(--tf-bg-surface);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.2);
padding: 8px;
overflow: hidden;
}
.node-picker-connection-line {
position: absolute;
inset: 0;
z-index: 268;
pointer-events: none;
}
:global(.tf-canvas-locked .svelte-flow__pane),
:global(.tf-canvas-locked .svelte-flow__nodes),
:global(.tf-canvas-locked .svelte-flow__edges),
:global(.tf-canvas-locked .svelte-flow__connectionline),
:global(.tf-canvas-locked .svelte-flow__selection) {
pointer-events: none !important;
}
</style> </style>

View File

@@ -1,183 +1,58 @@
<script lang="ts"> <script lang="ts">
import {Button, Tabs} from './base/index'; import {onDestroy, onMount} from 'svelte';
import DraggableButton from './core/DraggableButton.svelte';
import {getOptions} from './utils/NodeUtils';
let showType = $state('base'); import NodePicker from './core/NodePicker.svelte';
let containerShowClass = $state('show'); import type {NodePaletteItem} from './utils/nodePalette';
const baseNodes = [ const { nodes = [], onSelectNode }: {
{ nodes: NodePaletteItem[];
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12C15 13.6569 13.6569 15 12 15Z"></path></svg>', onSelectNode?: (node: NodePaletteItem) => void;
title: '开始节点', } = $props();
type: 'startNode',
sortNo: 100,
description: '开始定义输入参数'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path></svg>', let panelVisible = $state(false);
title: '循环', let toolbarElement: HTMLDivElement | null = null;
type: 'loopNode',
sortNo: 200, function togglePanel() {
description: '用于循环执行任务' panelVisible = !panelVisible;
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4H10V10H4V4ZM14 4H20V10H14V4ZM4 14H10V20H4V14ZM14 14H20V20H14V14ZM10 7H14V9H10V7ZM7 10H9V14H7V10ZM15 10H17V14H15V10Z"></path></svg>',
title: '条件判断',
type: 'conditionNode',
sortNo: 250,
description: '根据参数值分流到不同分支'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.7134 7.12811L20.4668 7.69379C20.2864 8.10792 19.7136 8.10792 19.5331 7.69379L19.2866 7.12811C18.8471 6.11947 18.0555 5.31641 17.0677 4.87708L16.308 4.53922C15.8973 4.35653 15.8973 3.75881 16.308 3.57612L17.0252 3.25714C18.0384 2.80651 18.8442 1.97373 19.2761 0.930828L19.5293 0.319534C19.7058 -0.106511 20.2942 -0.106511 20.4706 0.319534L20.7238 0.930828C21.1558 1.97373 21.9616 2.80651 22.9748 3.25714L23.6919 3.57612C24.1027 3.75881 24.1027 4.35653 23.6919 4.53922L22.9323 4.87708C21.9445 5.31641 21.1529 6.11947 20.7134 7.12811ZM9 2C13.0675 2 16.426 5.03562 16.9337 8.96494L19.1842 12.5037C19.3324 12.7367 19.3025 13.0847 18.9593 13.2317L17 14.071V17C17 18.1046 16.1046 19 15 19H13.001L13 22H4L4.00025 18.3061C4.00033 17.1252 3.56351 16.0087 2.7555 15.0011C1.65707 13.6313 1 11.8924 1 10C1 5.58172 4.58172 2 9 2ZM9 4C5.68629 4 3 6.68629 3 10C3 11.3849 3.46818 12.6929 4.31578 13.7499C5.40965 15.114 6.00036 16.6672 6.00025 18.3063L6.00013 20H11.0007L11.0017 17H15V12.7519L16.5497 12.0881L15.0072 9.66262L14.9501 9.22118C14.5665 6.25141 12.0243 4 9 4ZM19.4893 16.9929L21.1535 18.1024C22.32 16.3562 23 14.2576 23 12.0001C23 11.317 22.9378 10.6486 22.8186 10L20.8756 10.5C20.9574 10.9878 21 11.489 21 12.0001C21 13.8471 20.4436 15.5642 19.4893 16.9929Z"></path></svg>',
title: '大模型',
type: 'llmNode',
sortNo: 300,
description: '使用大模型处理问题'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 5C13.567 5 12 6.567 12 8.5C12 10.433 13.567 12 15.5 12C17.433 12 19 10.433 19 8.5C19 6.567 17.433 5 15.5 5ZM10 8.5C10 5.46243 12.4624 3 15.5 3C18.5376 3 21 5.46243 21 8.5C21 9.6575 20.6424 10.7315 20.0317 11.6175L22.7071 14.2929L21.2929 15.7071L18.6175 13.0317C17.7315 13.6424 16.6575 14 15.5 14C12.4624 14 10 11.5376 10 8.5ZM3 4H8V6H3V4ZM3 11H8V13H3V11ZM21 18V20H3V18H21Z"></path></svg>',
title: '知识库',
type: 'knowledgeNode',
sortNo: 400,
description: '通过知识库获取内容'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z"></path></svg>',
title: '搜索引擎',
type: 'searchEngineNode',
sortNo: 500,
description: '通过搜索引擎搜索内容'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6.23509 6.45329C4.85101 7.89148 4 9.84636 4 12C4 16.4183 7.58172 20 12 20C13.0808 20 14.1116 19.7857 15.0521 19.3972C15.1671 18.6467 14.9148 17.9266 14.8116 17.6746C14.582 17.115 13.8241 16.1582 12.5589 14.8308C12.2212 14.4758 12.2429 14.2035 12.3636 13.3943L12.3775 13.3029C12.4595 12.7486 12.5971 12.4209 14.4622 12.1248C15.4097 11.9746 15.6589 12.3533 16.0043 12.8777C16.0425 12.9358 16.0807 12.9928 16.1198 13.0499C16.4479 13.5297 16.691 13.6394 17.0582 13.8064C17.2227 13.881 17.428 13.9751 17.7031 14.1314C18.3551 14.504 18.3551 14.9247 18.3551 15.8472V15.9518C18.3551 16.3434 18.3168 16.6872 18.2566 16.9859C19.3478 15.6185 20 13.8854 20 12C20 8.70089 18.003 5.8682 15.1519 4.64482C14.5987 5.01813 13.8398 5.54726 13.575 5.91C13.4396 6.09538 13.2482 7.04166 12.6257 7.11976C12.4626 7.14023 12.2438 7.12589 12.012 7.11097C11.3905 7.07058 10.5402 7.01606 10.268 7.75495C10.0952 8.2232 10.0648 9.49445 10.6239 10.1543C10.7134 10.2597 10.7307 10.4547 10.6699 10.6735C10.59 10.9608 10.4286 11.1356 10.3783 11.1717C10.2819 11.1163 10.0896 10.8931 9.95938 10.7412C9.64554 10.3765 9.25405 9.92233 8.74797 9.78176C8.56395 9.73083 8.36166 9.68867 8.16548 9.64736C7.6164 9.53227 6.99443 9.40134 6.84992 9.09302C6.74442 8.8672 6.74488 8.55621 6.74529 8.22764C6.74529 7.8112 6.74529 7.34029 6.54129 6.88256C6.46246 6.70541 6.35689 6.56446 6.23509 6.45329ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22Z"></path></svg>',
title: 'Http 请求',
type: 'httpNode',
sortNo: 600,
description: '通过 HTTP 请求获取数据'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M23 12L15.9289 19.0711L14.5147 17.6569L20.1716 12L14.5147 6.34317L15.9289 4.92896L23 12ZM3.82843 12L9.48528 17.6569L8.07107 19.0711L1 12L8.07107 4.92896L9.48528 6.34317L3.82843 12Z"></path></svg>',
title: '动态代码',
type: 'codeNode',
sortNo: 700,
description: '动态执行代码'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M2 4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4ZM4 5V19H20V5H4ZM7 8H17V11H15V10H13V14H14.5V16H9.5V14H11V10H9V11H7V8Z"></path></svg>',
title: '内容模板',
type: 'templateNode',
sortNo: 800,
description: '通过模板引擎生成内容'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M15.3873 13.4975L17.9403 20.5117L13.2418 22.2218L10.6889 15.2076L6.79004 17.6529L8.4086 1.63318L19.9457 12.8646L15.3873 13.4975ZM15.3768 19.3163L12.6618 11.8568L15.6212 11.4459L9.98201 5.9561L9.19088 13.7863L11.7221 12.1988L14.4371 19.6583L15.3768 19.3163Z"></path></svg>',
title: '用户确认',
type: 'confirmNode',
sortNo: 900,
description: '确认继续或选择内容'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6 5.1438V16.0002H18.3391L6 5.1438ZM4 2.932C4 2.07155 5.01456 1.61285 5.66056 2.18123L21.6501 16.2494C22.3423 16.8584 21.9116 18.0002 20.9896 18.0002H6V22H4V2.932Z"></path></svg>',
title: '结束节点',
type: 'endNode',
sortNo: 1000,
description: '结束定义输出参数'
} }
];
const tableItems = [ function handleSelect(node: NodePaletteItem) {
{ onSelectNode?.(node);
label: '基础节点', panelVisible = false;
value: 'base'
},
{
label: '业务工具',
value: 'tools'
} }
];
const customNodes = [] as any[]; function handlePointerDown(event: PointerEvent) {
const options = getOptions(); if (!panelVisible || !toolbarElement) {
const userCustomNodes = options.customNodes; return;
if (userCustomNodes) { }
const keys = Object.keys(userCustomNodes).sort((a, b) => { const target = event.target as Node | null;
return (userCustomNodes[a].sortNo || 0) - (userCustomNodes[b].sortNo || 0); if (target && !toolbarElement.contains(target)) {
panelVisible = false;
}
}
onMount(() => {
window.addEventListener('pointerdown', handlePointerDown);
}); });
for (let key of keys) { onDestroy(() => {
if (userCustomNodes[key].group === 'base') { window.removeEventListener('pointerdown', handlePointerDown);
baseNodes.push({
type: key,
...userCustomNodes[key]
} as any);
} else {
customNodes.push({
icon: userCustomNodes[key].icon,
title: userCustomNodes[key].title,
type: key
}); });
}
}
baseNodes.sort((a, b) => {
return (a.sortNo || 0) - (b.sortNo || 0);
});
}
if (options.hiddenNodes) {
const hiddenNodes = typeof options.hiddenNodes === 'function' ? options.hiddenNodes() : options.hiddenNodes;
if (Array.isArray(hiddenNodes)) {
for (let hiddenNode of hiddenNodes) {
for (let i = 0; i < baseNodes.length; i++) {
if (baseNodes[i].type === hiddenNode) {
baseNodes.splice(i, 1);
break;
}
}
}
}
}
</script> </script>
<div class="tf-toolbar {containerShowClass}"> <div class="tf-toolbar" bind:this={toolbarElement}>
<div class="tf-toolbar-container "> {#if panelVisible}
<div class="tf-toolbar-container-header"> <div class="tf-toolbar-panel">
<Tabs style="width: 100%" items={tableItems} onChange={(item)=>{ <NodePicker nodes={nodes} onSelect={handleSelect} />
showType = item.value.toString();
}}
/>
</div> </div>
<div class="tf-toolbar-container-body">
<div class="tf-toolbar-container-base" style="display: {showType === 'base' ? 'flex' : 'none'}">
{#each baseNodes as node}
<DraggableButton {...node} />
{/each}
</div>
<div class="tf-toolbar-container-tools" style="display: {showType !== 'base' ? 'flex' : 'none'}">
{#each customNodes as node}
<DraggableButton {...node} />
{/each}
</div>
</div>
</div>
<Button onclick={()=>{
containerShowClass = containerShowClass ? '' :'show';
}}>
{#if containerShowClass === 'show'}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
d="M4.83582 12L11.0429 18.2071L12.4571 16.7929L7.66424 12L12.4571 7.20712L11.0429 5.79291L4.83582 12ZM10.4857 12L16.6928 18.2071L18.107 16.7929L13.3141 12L18.107 7.20712L16.6928 5.79291L10.4857 12Z"></path>
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
d="M19.1642 12L12.9571 5.79291L11.5429 7.20712L16.3358 12L11.5429 16.7929L12.9571 18.2071L19.1642 12ZM13.5143 12L7.30722 5.79291L5.89301 7.20712L10.6859 12L5.89301 16.7929L7.30722 18.2071L13.5143 12Z"></path>
</svg>
{/if} {/if}
</Button>
<button type="button" class="tf-toolbar-trigger tf-bar-add-btn" onclick={togglePanel}>
<span class="tf-toolbar-trigger-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M11 11V6H13V11H18V13H13V18H11V13H6V11H11Z"></path>
</svg>
</span>
{panelVisible ? '收起节点' : '增加节点'}
</button>
</div> </div>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import {getBezierPath, useConnection} from '@xyflow/svelte';
import FlowLinePath from './FlowLinePath.svelte';
import FlowMarkerDefs from './FlowMarkerDefs.svelte';
const connection = useConnection();
const markerId = 'tf-flow-connection-arrow-closed';
const path = $derived.by(() => {
const current = connection.current;
if (!current.inProgress) {
return '';
}
const [linePath] = getBezierPath({
sourceX: current.from.x,
sourceY: current.from.y,
sourcePosition: current.fromPosition,
targetX: current.to.x,
targetY: current.to.y,
targetPosition: current.toPosition
});
return linePath;
});
</script>
{#if connection.current.inProgress && path}
<FlowMarkerDefs id={markerId} />
<FlowLinePath
class="svelte-flow__connection-path"
{path}
markerEnd={`url(#${markerId})`}
animated={false}
/>
{/if}

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import {EdgeLabel, type EdgeProps, getBezierPath} from '@xyflow/svelte';
import FlowLinePath from './FlowLinePath.svelte';
let {
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
markerStart,
markerEnd,
interactionWidth = 20,
label,
labelStyle
}: EdgeProps = $props();
const bezierPathResult = $derived.by(() =>
getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition
})
);
const path = $derived(bezierPathResult[0]);
const labelX = $derived(bezierPathResult[1]);
const labelY = $derived(bezierPathResult[2]);
</script>
<FlowLinePath path={path} {markerStart} {markerEnd} animated={true} />
{#if interactionWidth > 0}
<path
d={path}
stroke-opacity={0}
stroke-width={interactionWidth}
fill="none"
class="svelte-flow__edge-interaction"
></path>
{/if}
{#if label}
<EdgeLabel x={labelX} y={labelY} style={labelStyle} selectEdgeOnClick>
{label}
</EdgeLabel>
{/if}

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type {ClassValue} from 'svelte/elements';
let {
path = '',
markerStart,
markerEnd,
animated = true,
class: className = ''
}: {
path?: string;
markerStart?: string;
markerEnd?: string;
animated?: boolean;
class?: ClassValue;
} = $props();
</script>
<path
d={path}
fill="none"
marker-start={markerStart}
marker-end={markerEnd}
class={[
'svelte-flow__edge-path',
'tf-flow-line-path',
animated && 'tf-flow-line-path--animated',
className
]}
></path>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
let {
id,
markerWidth = 20,
markerHeight = 20,
markerUnits = 'strokeWidth'
}: {
id: string;
markerWidth?: number;
markerHeight?: number;
markerUnits?: 'strokeWidth' | 'userSpaceOnUse';
} = $props();
</script>
<defs>
<marker
class="svelte-flow__arrowhead"
{id}
markerWidth={`${markerWidth}`}
markerHeight={`${markerHeight}`}
viewBox="-10 -10 20 20"
orient="auto-start-reverse"
{markerUnits}
refX="0"
refY="0"
>
<polyline
class="arrowclosed tf-flow-line-arrow-closed"
points="-5,-4 0,0 -5,4 -5,-4"
></polyline>
</marker>
</defs>
<style>
.tf-flow-line-arrow-closed {
stroke: var(--xy-edge-stroke);
fill: var(--xy-edge-stroke);
stroke-linecap: round;
stroke-linejoin: round;
}
</style>

View File

@@ -0,0 +1,345 @@
<script lang="ts">
import type {NodePaletteItem} from '../utils/nodePalette';
const { nodes = [], onSelect, placeholder = '搜索节点、插件、工作流' }: {
nodes: NodePaletteItem[];
onSelect?: (node: NodePaletteItem) => void;
placeholder?: string;
} = $props();
let keyword = $state('');
type HighlightPart = { text: string; hit: boolean };
const PINYIN_CHAR_MAP: Record<string, string> = {
: 'da', : 'mo', : 'xing',
: 'kai', : 'shi', : 'jie', : 'dian',
: 'xun', : 'huan',
: 'tiao', : 'jian', : 'pan', : 'duan',
: 'zhi', : 'shi', : 'ku',
: 'sou', : 'suo', : 'yin', : 'qing',
: 'qing', : 'qiu',
: 'dong', : 'tai', : 'dai', : 'ma',
: 'nei', : 'rong', : 'ban',
: 'yong', : 'hu', : 'que', : 'ren',
: 'jie', : 'shu',
: 'wen', : 'dang', : 'ti', : 'qu',
: 'sheng', : 'cheng',
: 'cha', : 'xun', : 'shu', : 'ju',
: 'cha', : 'zi',
: 'liu', : 'cheng',
: 'su', : 'cai', : 'tong', : 'bu',
: 'bao', : 'cun',
: 'ji', : 'chu',
: 'kuo', : 'zhan', : 'gong', : 'ju',
: 'yu', : 'shu', : 'ru',
: 'qi', : 'ta'
};
const categoryOrder = ['模型与流程', '逻辑', '输入输出', '数据与集成', '基础节点', '扩展工具', '其他'];
const normalizedKeyword = $derived(keyword.trim().toLowerCase());
const compactKeyword = $derived(normalizeForMatch(keyword));
function normalizeForMatch(text: string) {
return text.toLowerCase().replace(/\s+/g, '');
}
function toPinyinToken(text: string) {
let full = '';
let initials = '';
for (const char of text) {
const py = PINYIN_CHAR_MAP[char];
if (py) {
full += py;
initials += py[0];
continue;
}
const code = char.codePointAt(0) || 0;
const isAsciiDigit = code >= 48 && code <= 57;
const isAsciiUpper = code >= 65 && code <= 90;
const isAsciiLower = code >= 97 && code <= 122;
if (isAsciiDigit || isAsciiUpper || isAsciiLower) {
const lowerChar = char.toLowerCase();
full += lowerChar;
initials += lowerChar;
}
}
return { full, initials };
}
function isNodeMatched(item: NodePaletteItem) {
if (!compactKeyword) {
return true;
}
const directHaystack = normalizeForMatch(`${item.title} ${item.type} ${item.description || ''}`);
if (directHaystack.includes(compactKeyword)) {
return true;
}
const pinyinToken = toPinyinToken(item.title);
return pinyinToken.full.includes(compactKeyword) || pinyinToken.initials.includes(compactKeyword);
}
function getHighlightParts(title: string): HighlightPart[] {
if (!normalizedKeyword) {
return [{ text: title, hit: false }];
}
const source = title.toLowerCase();
const index = source.indexOf(normalizedKeyword);
if (index < 0) {
return [{ text: title, hit: false }];
}
const head = title.slice(0, index);
const middle = title.slice(index, index + normalizedKeyword.length);
const tail = title.slice(index + normalizedKeyword.length);
const parts: HighlightPart[] = [];
if (head) {
parts.push({ text: head, hit: false });
}
if (middle) {
parts.push({ text: middle, hit: true });
}
if (tail) {
parts.push({ text: tail, hit: false });
}
return parts;
}
const filteredNodes = $derived.by(() => {
if (!compactKeyword) {
return nodes;
}
return nodes.filter((item) => isNodeMatched(item));
});
const groupedNodes = $derived.by(() => {
const map = new Map<string, NodePaletteItem[]>();
for (const item of filteredNodes) {
const category = item.category || '其他';
if (!map.has(category)) {
map.set(category, []);
}
map.get(category)?.push(item);
}
return [...map.entries()].sort((a, b) => {
const ai = categoryOrder.indexOf(a[0]);
const bi = categoryOrder.indexOf(b[0]);
if (ai === -1 && bi === -1) {
return a[0].localeCompare(b[0]);
}
if (ai === -1) {
return 1;
}
if (bi === -1) {
return -1;
}
return ai - bi;
});
});
function onDragStart(event: DragEvent, node: NodePaletteItem) {
if (!event.dataTransfer) {
return;
}
const payload = {
type: node.type,
data: {
title: node.title,
description: node.description,
...(node.extra || {})
}
};
event.dataTransfer.setData('application/tinyflow', JSON.stringify(payload));
event.dataTransfer.effectAllowed = 'move';
}
function handleSelect(node: NodePaletteItem) {
onSelect?.(node);
}
</script>
<div class="tf-node-picker">
<div class="tf-node-picker-search">
<span class="tf-node-picker-search-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z"></path>
</svg>
</span>
<input
type="text"
class="tf-node-picker-search-input"
bind:value={keyword}
placeholder={placeholder}
/>
</div>
<div class="tf-node-picker-content">
{#if groupedNodes.length === 0}
<div class="tf-node-picker-empty">暂无可用节点</div>
{:else}
{#each groupedNodes as [category, categoryNodes]}
<div class="tf-node-picker-group">
<div class="tf-node-picker-group-title">{category}</div>
<div class="tf-node-picker-grid">
{#each categoryNodes as node}
<button
type="button"
class="tf-node-picker-item nopan nodrag"
draggable={true}
ondragstart={(event) => onDragStart(event, node)}
onclick={() => handleSelect(node)}
>
<span class="tf-node-picker-item-icon">
{#if node.icon}
{@html node.icon}
{:else}
<span class="tf-node-picker-item-icon-fallback">N</span>
{/if}
</span>
<span class="tf-node-picker-item-title">
{#each getHighlightParts(node.title) as part}
<span class:tf-node-picker-item-title-hit={part.hit}>{part.text}</span>
{/each}
</span>
</button>
{/each}
</div>
</div>
{/each}
{/if}
</div>
</div>
<style lang="less">
.tf-node-picker {
display: flex;
flex-direction: column;
gap: 10px;
}
.tf-node-picker-search {
display: flex;
align-items: center;
border: 1px solid var(--tf-border-color);
border-radius: 10px;
background: var(--tf-bg-surface);
padding: 0 10px;
height: 40px;
}
.tf-node-picker-search-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: var(--tf-text-muted);
svg {
width: 16px;
height: 16px;
fill: currentColor;
}
}
.tf-node-picker-search-input {
flex: 1;
margin-left: 8px;
border: none;
background: transparent;
outline: none;
color: var(--tf-text-primary);
font-size: 14px;
}
.tf-node-picker-content {
max-height: min(300px, 38vh);
overflow: auto;
padding-right: 2px;
}
.tf-node-picker-empty {
padding: 18px 4px;
font-size: 14px;
color: var(--tf-text-muted);
}
.tf-node-picker-group {
margin-bottom: 14px;
}
.tf-node-picker-group-title {
font-size: 13px;
color: var(--tf-text-secondary);
font-weight: 600;
margin-bottom: 8px;
}
.tf-node-picker-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
}
.tf-node-picker-item {
border: none;
border-radius: 10px;
background: transparent;
min-height: 34px;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
max-width: none;
padding: 5px 8px;
text-align: left;
cursor: pointer;
color: var(--tf-text-primary);
}
.tf-node-picker-item:hover {
background: var(--tf-bg-hover);
}
.tf-node-picker-item:active {
transform: translateY(1px);
}
.tf-node-picker-item-icon {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--tf-primary-color);
}
.tf-node-picker-item-icon :global(svg) {
width: 20px;
height: 20px;
fill: currentColor;
}
.tf-node-picker-item-icon-fallback {
width: 20px;
height: 20px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--tf-bg-hover);
color: var(--tf-primary-color);
font-size: 12px;
font-weight: 700;
}
.tf-node-picker-item-title {
font-size: 13px;
color: var(--tf-text-primary);
line-height: 1.2;
}
.tf-node-picker-item-title-hit {
color: var(--tf-primary-color);
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,136 @@
import type {Node} from '@xyflow/svelte';
import type {TinyflowOptions} from '#types';
export type NodePaletteItem = {
icon?: string;
title: string;
type: string;
sortNo?: number;
description?: string;
category: string;
extra?: Partial<Node['data']>;
};
const BUILT_IN_NODES: NodePaletteItem[] = [
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12C15 13.6569 13.6569 15 12 15Z"></path></svg>',
title: '开始节点',
type: 'startNode',
sortNo: 100,
description: '开始定义输入参数',
category: '输入输出'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path></svg>',
title: '循环',
type: 'loopNode',
sortNo: 200,
description: '用于循环执行任务',
category: '逻辑'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4H10V10H4V4ZM14 4H20V10H14V4ZM4 14H10V20H4V14ZM14 14H20V20H14V14ZM10 7H14V9H10V7ZM7 10H9V14H7V10ZM15 10H17V14H15V10Z"></path></svg>',
title: '条件判断',
type: 'conditionNode',
sortNo: 250,
description: '根据参数值分流到不同分支',
category: '逻辑'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.7134 7.12811L20.4668 7.69379C20.2864 8.10792 19.7136 8.10792 19.5331 7.69379L19.2866 7.12811C18.8471 6.11947 18.0555 5.31641 17.0677 4.87708L16.308 4.53922C15.8973 4.35653 15.8973 3.75881 16.308 3.57612L17.0252 3.25714C18.0384 2.80651 18.8442 1.97373 19.2761 0.930828L19.5293 0.319534C19.7058 -0.106511 20.2942 -0.106511 20.4706 0.319534L20.7238 0.930828C21.1558 1.97373 21.9616 2.80651 22.9748 3.25714L23.6919 3.57612C24.1027 3.75881 24.1027 4.35653 23.6919 4.53922L22.9323 4.87708C21.9445 5.31641 21.1529 6.11947 20.7134 7.12811ZM9 2C13.0675 2 16.426 5.03562 16.9337 8.96494L19.1842 12.5037C19.3324 12.7367 19.3025 13.0847 18.9593 13.2317L17 14.071V17C17 18.1046 16.1046 19 15 19H13.001L13 22H4L4.00025 18.3061C4.00033 17.1252 3.56351 16.0087 2.7555 15.0011C1.65707 13.6313 1 11.8924 1 10C1 5.58172 4.58172 2 9 2ZM9 4C5.68629 4 3 6.68629 3 10C3 11.3849 3.46818 12.6929 4.31578 13.7499C5.40965 15.114 6.00036 16.6672 6.00025 18.3063L6.00013 20H11.0007L11.0017 17H15V12.7519L16.5497 12.0881L15.0072 9.66262L14.9501 9.22118C14.5665 6.25141 12.0243 4 9 4ZM19.4893 16.9929L21.1535 18.1024C22.32 16.3562 23 14.2576 23 12.0001C23 11.317 22.9378 10.6486 22.8186 10L20.8756 10.5C20.9574 10.9878 21 11.489 21 12.0001C21 13.8471 20.4436 15.5642 19.4893 16.9929Z"></path></svg>',
title: '大模型',
type: 'llmNode',
sortNo: 300,
description: '使用大模型处理问题',
category: '模型与流程'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 5C13.567 5 12 6.567 12 8.5C12 10.433 13.567 12 15.5 12C17.433 12 19 10.433 19 8.5C19 6.567 17.433 5 15.5 5ZM10 8.5C10 5.46243 12.4624 3 15.5 3C18.5376 3 21 5.46243 21 8.5C21 9.6575 20.6424 10.7315 20.0317 11.6175L22.7071 14.2929L21.2929 15.7071L18.6175 13.0317C17.7315 13.6424 16.6575 14 15.5 14C12.4624 14 10 11.5376 10 8.5ZM3 4H8V6H3V4ZM3 11H8V13H3V11ZM21 18V20H3V18H21Z"></path></svg>',
title: '知识库',
type: 'knowledgeNode',
sortNo: 400,
description: '通过知识库获取内容',
category: '数据与集成'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z"></path></svg>',
title: '搜索引擎',
type: 'searchEngineNode',
sortNo: 500,
description: '通过搜索引擎搜索内容',
category: '模型与流程'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6.23509 6.45329C4.85101 7.89148 4 9.84636 4 12C4 16.4183 7.58172 20 12 20C13.0808 20 14.1116 19.7857 15.0521 19.3972C15.1671 18.6467 14.9148 17.9266 14.8116 17.6746C14.582 17.115 13.8241 16.1582 12.5589 14.8308C12.2212 14.4758 12.2429 14.2035 12.3636 13.3943L12.3775 13.3029C12.4595 12.7486 12.5971 12.4209 14.4622 12.1248C15.4097 11.9746 15.6589 12.3533 16.0043 12.8777C16.0425 12.9358 16.0807 12.9928 16.1198 13.0499C16.4479 13.5297 16.691 13.6394 17.0582 13.8064C17.2227 13.881 17.428 13.9751 17.7031 14.1314C18.3551 14.504 18.3551 14.9247 18.3551 15.8472V15.9518C18.3551 16.3434 18.3168 16.6872 18.2566 16.9859C19.3478 15.6185 20 13.8854 20 12C20 8.70089 18.003 5.8682 15.1519 4.64482C14.5987 5.01813 13.8398 5.54726 13.575 5.91C13.4396 6.09538 13.2482 7.04166 12.6257 7.11976C12.4626 7.14023 12.2438 7.12589 12.012 7.11097C11.3905 7.07058 10.5402 7.01606 10.268 7.75495C10.0952 8.2232 10.0648 9.49445 10.6239 10.1543C10.7134 10.2597 10.7307 10.4547 10.6699 10.6735C10.59 10.9608 10.4286 11.1356 10.3783 11.1717C10.2819 11.1163 10.0896 10.8931 9.95938 10.7412C9.64554 10.3765 9.25405 9.92233 8.74797 9.78176C8.56395 9.73083 8.36166 9.68867 8.16548 9.64736C7.6164 9.53227 6.99443 9.40134 6.84992 9.09302C6.74442 8.8672 6.74488 8.55621 6.74529 8.22764C6.74529 7.8112 6.74529 7.34029 6.54129 6.88256C6.46246 6.70541 6.35689 6.56446 6.23509 6.45329ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22Z"></path></svg>',
title: 'Http 请求',
type: 'httpNode',
sortNo: 600,
description: '通过 HTTP 请求获取数据',
category: '数据与集成'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M23 12L15.9289 19.0711L14.5147 17.6569L20.1716 12L14.5147 6.34317L15.9289 4.92896L23 12ZM3.82843 12L9.48528 17.6569L8.07107 19.0711L1 12L8.07107 4.92896L9.48528 6.34317L3.82843 12Z"></path></svg>',
title: '动态代码',
type: 'codeNode',
sortNo: 700,
description: '动态执行代码',
category: '逻辑'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M2 4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4ZM4 5V19H20V5H4ZM7 8H17V11H15V10H13V14H14.5V16H9.5V14H11V10H9V11H7V8Z"></path></svg>',
title: '内容模板',
type: 'templateNode',
sortNo: 800,
description: '通过模板引擎生成内容',
category: '逻辑'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M15.3873 13.4975L17.9403 20.5117L13.2418 22.2218L10.6889 15.2076L6.79004 17.6529L8.4086 1.63318L19.9457 12.8646L15.3873 13.4975ZM15.3768 19.3163L12.6618 11.8568L15.6212 11.4459L9.98201 5.9561L9.19088 13.7863L11.7221 12.1988L14.4371 19.6583L15.3768 19.3163Z"></path></svg>',
title: '用户确认',
type: 'confirmNode',
sortNo: 900,
description: '确认继续或选择内容',
category: '输入输出'
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6 5.1438V16.0002H18.3391L6 5.1438ZM4 2.932C4 2.07155 5.01456 1.61285 5.66056 2.18123L21.6501 16.2494C22.3423 16.8584 21.9116 18.0002 20.9896 18.0002H6V22H4V2.932Z"></path></svg>',
title: '结束节点',
type: 'endNode',
sortNo: 1000,
description: '结束定义输出参数',
category: '输入输出'
}
];
export function getAvailableNodes(options?: TinyflowOptions) {
const nodes: NodePaletteItem[] = [...BUILT_IN_NODES];
const customNodes = options?.customNodes;
if (customNodes) {
const keys = Object.keys(customNodes).sort((a, b) => {
return (customNodes[a].sortNo || 0) - (customNodes[b].sortNo || 0);
});
for (let key of keys) {
const item = customNodes[key];
nodes.push({
icon: item.icon,
title: item.title,
type: key,
sortNo: item.sortNo,
description: item.description,
category: item.group === 'tools' ? '扩展工具' : '基础节点'
});
}
}
const hiddenNodes = typeof options?.hiddenNodes === 'function'
? options?.hiddenNodes()
: options?.hiddenNodes;
const hiddenSet = new Set(Array.isArray(hiddenNodes) ? hiddenNodes : []);
const filtered = nodes.filter((node) => !hiddenSet.has(node.type));
filtered.sort((a, b) => (a.sortNo || 0) - (b.sortNo || 0));
return filtered;
}

View File

@@ -1,4 +1,3 @@
.svelte-flow__nodes { .svelte-flow__nodes {
.svelte-flow__node { .svelte-flow__node {
@@ -84,6 +83,25 @@
} }
} }
.tf-flow-line-path {
stroke: var(--xy-edge-stroke);
stroke-width: var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));
}
.tf-flow-line-path--animated {
stroke-dasharray: 8 6;
animation: tf-edge-flow 1.2s linear infinite;
}
@keyframes tf-edge-flow {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -14;
}
}
.tf-node-wrapper { .tf-node-wrapper {
border-radius: 5px; border-radius: 5px;
@@ -99,62 +117,240 @@
display: none; display: none;
} }
.tf-toolbar { .tf-bottom-dock {
z-index: 200; z-index: 200;
position: absolute; position: absolute;
top: 10px; left: 50%;
left: 10px; bottom: var(--tf-toolbar-bottom, 20px);
transform: translateX(-50%);
display: flex; display: flex;
gap: 5px; align-items: center;
transition: transform 0.5s ease, opacity 0.5s ease; gap: 8px;
transform: translateX(calc(-100% + 20px)); /* 完全移出视口 */
&.show {
transform: translateX(0);
} }
&-container { // 单行统一工具栏
background: var(--tf-bg-surface); .tf-unified-bar {
border: 1px solid var(--tf-border-color); display: inline-flex;
border-radius: 5px; align-items: center;
box-shadow: var(--tf-shadow-soft);
padding: 10px;
width: fit-content;
&-header {
display: flex;
}
&-body {
display: flex;
margin-top: 20px;
.tf-toolbar-container-base, .tf-toolbar-container-tools {
display: flex;
flex-direction: column;
gap: 4px;
flex-grow: 1;
.tf-btn {
border: none;
width: 100%;
justify-content: flex-start;
height: 40px; height: 40px;
gap: 10px; border: 1px solid var(--tf-border-color);
cursor: grabbing; border-radius: 10px;
border-radius: 5px; background: var(--tf-bg-surface);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
padding: 0 4px;
gap: 2px;
> * {
flex-shrink: 0;
}
}
// 分割线
.tf-bar-divider {
width: 1px;
height: 20px;
background: var(--tf-border-color);
margin: 0 4px;
flex-shrink: 0;
}
// 图标按钮
.tf-bar-btn {
all: unset;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 7px;
color: var(--tf-text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
svg { svg {
width: 20px; width: 17px;
height: 20px; height: 17px;
fill: var(--tf-primary-color); fill: currentColor;
pointer-events: none;
} }
&:hover {
background: var(--tf-bg-hover);
color: var(--tf-text-primary);
}
&.tf-bar-btn-active {
color: var(--tf-primary-color);
background: var(--tf-bg-hover);
}
}
.tf-bar-run-btn {
all: unset;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 32px;
padding: 0 14px;
border-radius: 8px;
background: #13b33f;
color: #fff;
font-size: 14px;
font-weight: 600;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
transition: filter 0.15s;
svg {
width: 14px;
height: 14px;
fill: currentColor;
pointer-events: none;
}
&:hover {
filter: brightness(0.95);
}
}
.tf-bar-add-btn {
all: unset;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 32px;
padding: 0 12px;
border-radius: 8px;
background: #e9edff;
color: var(--tf-primary-color);
font-size: 13px;
font-weight: 600;
line-height: 1;
cursor: pointer;
transition: filter 0.15s, background 0.15s;
&:hover {
filter: brightness(0.98);
background: #dde5ff;
}
}
// 百分比缩放选择器单元
.tf-zoom-select-wrap {
position: relative;
display: inline-flex;
align-items: center;
padding: 0 6px 0 8px;
gap: 4px;
height: 32px;
border-radius: 7px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: var(--tf-bg-hover);
}
.tf-zoom-icon {
width: 14px;
height: 14px;
fill: var(--tf-text-secondary);
pointer-events: none;
flex-shrink: 0;
}
.tf-zoom-select {
appearance: none;
-webkit-appearance: none;
border: none;
background: transparent;
font-size: 13px;
font-weight: 500;
color: var(--tf-text-primary);
cursor: pointer;
outline: none;
padding: 0;
min-width: 48px;
text-align: center;
line-height: 1;
}
.tf-zoom-chevron {
width: 14px;
height: 14px;
fill: var(--tf-text-secondary);
pointer-events: none;
flex-shrink: 0;
}
}
.tf-zoom-select-simple {
appearance: none;
-webkit-appearance: none;
border: none;
outline: none;
background: transparent;
height: 32px;
min-width: 64px;
padding: 0 8px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
line-height: 1;
color: var(--tf-text-primary);
cursor: pointer;
text-align: center;
&:hover { &:hover {
background: var(--tf-bg-hover); background: var(--tf-bg-hover);
} }
} }
}
.tf-toolbar {
position: relative;
display: inline-flex;
align-items: center;
&-panel {
width: min(520px, calc(100vw - 40px));
max-height: min(390px, calc(100vh - 220px));
border: 1px solid var(--tf-border-color);
border-radius: 12px;
background: var(--tf-bg-surface);
box-shadow: var(--tf-shadow-soft);
padding: 8px;
display: flex;
flex-direction: column;
gap: 10px;
position: absolute;
left: 50%;
bottom: calc(100% + 8px);
transform: translateX(-50%);
z-index: 260;
overflow: hidden;
}
&-trigger {
border: none;
}
&-trigger-icon {
display: inline-flex;
align-items: center;
justify-content: center;
svg {
width: 16px;
height: 16px;
fill: currentColor;
} }
} }
} }

View File

@@ -94,6 +94,7 @@ export type TinyflowOptions = {
//type : node //type : node
customNodes?: Record<string, CustomNode>; customNodes?: Record<string, CustomNode>;
onNodeExecute?: (node: Node) => void; onNodeExecute?: (node: Node) => void;
onRunTest?: () => void | Promise<void>;
hiddenNodes?: string[] | (() => string[]); hiddenNodes?: string[] | (() => string[]);
onDataChange?: (data: TinyflowData) => void; onDataChange?: (data: TinyflowData) => void;
}; };