perf: 优化智能体与工作流幕布渲染性能

- 分阶段加载智能体配置并按需缓存 MCP 工具

- 合并画布状态更新与节点尺寸监听,启用大图可视区域渲染和静态连线

- 隔离 Tinyflow Store 实例并补充数据同步与回归测试
This commit is contained in:
2026-07-27 18:27:01 +08:00
parent dc7e46260b
commit aedefe6b5e
39 changed files with 1349 additions and 408 deletions

View File

@@ -1,6 +1,6 @@
import type {useSvelteFlow} from '@xyflow/svelte';
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 {installTinyflowBrowserCompat} from './utils/compat';
import {createTinyflowNodeNormalizer} from './utils/nodeInteraction';
@@ -13,9 +13,13 @@ export class Tinyflow {
private options!: TinyflowOptions;
private rootEl!: Element;
private svelteFlowInstance!: FlowInstance;
private store!: TinyflowStore;
private tinyflowEl!: HTMLElement & {
options: TinyflowOptions;
onInit: (svelteFlowInstance: FlowInstance) => void;
onInit: (
svelteFlowInstance: FlowInstance,
store: TinyflowStore,
) => void;
};
constructor(options: TinyflowOptions) {
@@ -72,7 +76,10 @@ export class Tinyflow {
private _createTinyflowElement() {
const tinyflowEl = document.createElement(componentName) as HTMLElement & {
options: TinyflowOptions;
onInit: (svelteFlowInstance: FlowInstance) => void;
onInit: (
svelteFlowInstance: FlowInstance,
store: TinyflowStore,
) => void;
};
tinyflowEl.style.display = 'block';
tinyflowEl.style.width = '100%';
@@ -80,8 +87,12 @@ export class Tinyflow {
this._applyThemeClass(tinyflowEl, this.options.theme);
tinyflowEl.options = this.options;
tinyflowEl.onInit = (svelteFlowInstance: FlowInstance) => {
tinyflowEl.onInit = (
svelteFlowInstance: FlowInstance,
store: TinyflowStore,
) => {
this.svelteFlowInstance = svelteFlowInstance;
this.store = store;
};
return tinyflowEl;
}
@@ -104,29 +115,29 @@ export class Tinyflow {
return false;
}
const currentViewport = flow.getViewport();
const currentNodes = flow.getNodes();
const normalizeNode = createTinyflowNodeNormalizer(this.options);
const currentNodePositions = new Map(
currentNodes.map((node) => [node.id, node.position]),
);
const nextNodes =
options?.preserveViewport === true
? (data.nodes || currentNodes).map((node) => {
const currentPosition = currentNodePositions.get(node.id);
const nextNode = currentPosition
? { ...node, position: { ...currentPosition } }
: node;
return normalizeNode(nextNode);
})
: (data.nodes || currentNodes).map((node) => normalizeNode(node));
store.setNodes(nextNodes);
store.setEdges(data.edges || flow.getEdges());
if (data.nodes) {
const currentNodePositions =
options?.preserveViewport === true
? new Map(
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
? { ...node, position: { ...currentPosition } }
: node;
return normalizeNode(nextNode);
});
this.store.setNodes(nextNodes);
}
if (data.edges) {
this.store.setEdges(data.edges);
}
if (data.viewport && options?.preserveViewport !== true) {
flow.setViewport(data.viewport, { duration: 0 });
} else {
flow.setViewport(currentViewport, { duration: 0 });
}
return true;
}

View File

@@ -10,15 +10,22 @@
<script lang="ts">
import {SvelteFlowProvider, type useSvelteFlow} from '@xyflow/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 {setContext} from 'svelte';
import {createTinyflowNodeNormalizer} from '../utils/nodeInteraction';
const props = $props<{
options: TinyflowOptions,
onInit: (svelteFlow: ReturnType<typeof useSvelteFlow>) => void,
onInit: (
svelteFlow: ReturnType<typeof useSvelteFlow>,
store: TinyflowStore,
) => void,
}>();
const store = createTinyflowStoreContext();
const parseData = (source: TinyflowOptions['data']) => {
let nextData = source;
@@ -61,5 +68,8 @@
<SvelteFlowProvider>
<TinyflowCore onInit={props.onInit} />
<TinyflowCore
onInit={(svelteFlow: ReturnType<typeof useSvelteFlow>) =>
props.onInit(svelteFlow, store)}
/>
</SvelteFlowProvider>

View File

@@ -9,11 +9,12 @@
type NodeTypes,
Panel,
SvelteFlow,
useSvelteFlow
useSvelteFlow,
useUpdateNodeInternals
} from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import '../styles/index.ts';
import {store} from '#store/stores.svelte';
import {useTinyflowStore} from '#store/stores.svelte';
import {nodeTypes} from './nodes';
import Toolbar from './Toolbar.svelte';
import NodePicker from './core/NodePicker.svelte';
@@ -34,7 +35,7 @@
import {useGetNodesFromSource} from '#components/utils/useGetNodesFromSource.svelte';
import {useGetNodeRelativePosition} from '#components/utils/useGetNodeRelativePosition.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 {getAvailableNodes, type NodePaletteItem} from './utils/nodePalette';
import {
@@ -42,9 +43,69 @@
ensureStartNodeParameters,
START_NODE_TYPE,
} 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 store = useTinyflowStore();
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 currentEdge = $state<Edge | null>(null);
@@ -71,6 +132,7 @@
const nodesDraggable = options.nodesDraggable ?? !readonly;
const nodesConnectable = options.nodesConnectable ?? !readonly;
const elementsSelectable = options.elementsSelectable ?? !readonly;
const onlyRenderVisibleElements = options.onlyRenderVisibleElements === true;
const dropEnabled = options.dropEnabled ?? !readonly;
const connectionEnabled = nodesConnectable && !readonly;
const availableNodes = getAvailableNodes(options);
@@ -327,8 +389,11 @@
$effect(() => {
const vp = store.getViewport();
if (vp) {
currentZoomPercent = Math.round(vp.zoom * 100);
zoomSelectValue = String(currentZoomPercent);
const nextZoomPercent = Math.round(vp.zoom * 100);
if (currentZoomPercent !== nextZoomPercent) {
currentZoomPercent = nextZoomPercent;
zoomSelectValue = String(nextZoomPercent);
}
}
});
@@ -680,6 +745,9 @@
const { copyHandler, pasteHandler } = useCopyPasteHandler();
const handleKeyDown = (e: KeyboardEvent) => {
if (!flowRootEl?.isConnected) {
return;
}
if (nodePickerVisible && e.key === 'Escape') {
e.preventDefault();
closeNodePicker();
@@ -709,6 +777,9 @@
};
const handleGlobalPaste = async (event: ClipboardEvent) => {
if (!flowRootEl?.isConnected) {
return;
}
// 只在“非输入态”下处理流程图粘贴
if (isInEditableElement()) {
return;
@@ -718,7 +789,7 @@
};
function handleGlobalPointerDown(event: PointerEvent) {
if (readonly) {
if (readonly || !flowRootEl?.isConnected) {
return;
}
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;
$effect(() => {
onDataChange?.({
const onDataCommit = options.onDataCommit;
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(),
edges: store.getEdges(),
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>
@@ -782,7 +993,7 @@
style="position: relative; height: 100%; width: 100%;overflow: hidden"
bind:this={flowRootEl}
>
<SvelteFlow nodeTypes={{ ...nodeTypes, ...customNodeTypes}}
<SvelteFlow nodeTypes={resolvedNodeTypes}
edgeTypes={customEdgeTypes}
bind:nodes={store.getNodes, store.setNodes}
bind:edges={store.getEdges, store.setEdges}
@@ -790,47 +1001,27 @@
nodesDraggable={nodesDraggable && !canvasLocked}
nodesConnectable={nodesConnectable && !canvasLocked}
elementsSelectable={elementsSelectable && !canvasLocked}
{onlyRenderVisibleElements}
panOnDrag={readonly ? true : !canvasLocked}
zoomOnScroll={readonly ? true : !canvasLocked}
zoomOnDoubleClick={readonly ? true : !canvasLocked}
ondrop={dropEnabled ? onDrop : undefined}
ondragover={dropEnabled ? onDragOver : undefined}
onnodedragstart={onDataCommit ? handleNodeDragStart : undefined}
onnodedragstop={onDataCommit ? handleNodeDragStop : undefined}
onmovestart={onDataCommit ? handleViewportMoveStart : undefined}
onmoveend={onDataCommit ? handleViewportMoveEnd : undefined}
isValidConnection={isValidConnection}
onconnectend={connectionEnabled ? onconnectend : undefined}
onconnectstart={connectionEnabled ? onconnectstart : undefined}
onconnect={connectionEnabled ? onconnect : undefined}
connectionRadius={50}
connectionLineComponent={FlowConnectionLine}
onedgeclick={(e) => {
if (readonly || hideEdgePanel) {
return;
}
showEdgePanel = true;
currentEdge = e.edge;
}}
onedgeclick={handleEdgeClick}
onbeforeconnect={(edge: any) => normalizeEdgeBeforeConnect(edge)}
ondelete={readonly ? undefined : onDelete}
onclick={(e) => {
if (readonly || hideEdgePanel) {
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
}
}}
onclick={handleCanvasClick}
defaultEdgeOptions={defaultEdgeOptions}
>
<Background />
{#if !hideMiniMap}

View File

@@ -4,7 +4,7 @@
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
import {contentTypes, startFormTypes} from '#consts';
import type {Parameter} from '#types';
import {store} from '#store/stores.svelte';
import {useTinyflowStore} from '#store/stores.svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {
renameStartFieldReferencesInNodes,
@@ -18,6 +18,7 @@
parameter: Parameter,
index: number
} = $props();
const store = useTinyflowStore();
let currentNodeId = getCurrentNodeId();

View File

@@ -14,6 +14,7 @@
import {getOptions} from '../utils/NodeUtils';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import type {TinyflowNodeData} from '#types';
import {useTinyflowNodeSizeObserver} from '../utils/nodeSizeObserver';
const {
data,
@@ -82,36 +83,23 @@
};
let currentNodeId = getCurrentNodeId();
let wrapperElement: HTMLDivElement | null = null;
let resizeObserver: ResizeObserver | null = null;
let resizeFrame = 0;
const scheduleUpdateNodeInternals = () => {
if (!id) {
return;
}
if (resizeFrame) {
cancelAnimationFrame(resizeFrame);
}
resizeFrame = requestAnimationFrame(() => {
updateNodeInternals(id);
});
};
const nodeSizeObserver = useTinyflowNodeSizeObserver();
onMount(() => {
scheduleUpdateNodeInternals();
if (typeof ResizeObserver !== 'undefined' && wrapperElement) {
resizeObserver = new ResizeObserver(() => {
scheduleUpdateNodeInternals();
});
resizeObserver.observe(wrapperElement);
if (!wrapperElement) {
return;
}
if (nodeSizeObserver) {
nodeSizeObserver.observe(id, wrapperElement);
} else {
updateNodeInternals(id);
}
});
onDestroy(() => {
if (resizeFrame) {
cancelAnimationFrame(resizeFrame);
if (wrapperElement) {
nodeSizeObserver?.unobserve(wrapperElement);
}
resizeObserver?.disconnect();
});
</script>

View File

@@ -16,10 +16,11 @@
import {genShortId} from '../utils/IdGen';
import {deepEqual} from '../utils/deepEqual';
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';
const SHOW_EXPRESSION_ENTRY = false;
const store = useTinyflowStore();
type ConditionOperator =
| 'eq'

View File

@@ -11,7 +11,7 @@
import OutputDefList from '../core/OutputDefList.svelte';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
import type {TinyflowNodeData} from '#types';
import {onMount} from 'svelte';
import {onDestroy, onMount} from 'svelte';
const props = $props<{
data: TinyflowNodeData,
@@ -73,6 +73,10 @@
customNode.render?.(externalElement, buildNode(), flowInstance);
});
onDestroy(() => {
customNode.onDestroy?.(externalElement);
});
let container = $state<HTMLElement | null>(null);
$effect(() => {
// 注意:由于 $effect 的 state 自动追踪问题,需要 data.expand 方在 if 里的最前面

View File

@@ -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,
);
}

View File

@@ -1,7 +1,8 @@
import { genShortId } from './IdGen';
import { store } from '#store/stores.svelte';
import { useTinyflowStore } from '#store/stores.svelte';
export const useCopyNode = () => {
const store = useTinyflowStore();
const copyNode = (id: string) => {
const node = store.getNode(id);
if (node) {

View File

@@ -1,4 +1,4 @@
import { store } from '#store/stores.svelte';
import { useTinyflowStore } from '#store/stores.svelte';
import { genShortId } from '#components/utils/IdGen';
import { type Edge, type Node, useSvelteFlow } from '@xyflow/svelte';
@@ -119,6 +119,7 @@ function rewriteRefsInData(obj: any, idMap: Map<string, string>): any {
* 复制粘贴处理器 Hook
*/
export const useCopyPasteHandler = () => {
const store = useTinyflowStore();
const svelteFlow = useSvelteFlow();
const copyHandler = async (event: ClipboardEvent | KeyboardEvent) => {

View File

@@ -1,6 +1,7 @@
import { store } from '#store/stores.svelte';
import { useTinyflowStore } from '#store/stores.svelte';
export const useDeleteEdge = () => {
const store = useTinyflowStore();
const deleteEdge = (id: string) => {
store.removeEdge(id);
};

View File

@@ -1,6 +1,7 @@
import { store } from '#store/stores.svelte';
import { useTinyflowStore } from '#store/stores.svelte';
export const useDeleteNode = () => {
const store = useTinyflowStore();
const deleteNode = (id: string) => {
store.removeNode(id);
store.updateEdges((edges) =>

View File

@@ -1,6 +1,7 @@
import { store } from '../../store/stores.svelte';
import { useTinyflowStore } from '../../store/stores.svelte';
export const useEnsureParentInNodesBefore = () => {
const store = useTinyflowStore();
const ensureParentInNodesBefore = (
parentNodeId: string,
childNodeId: string,

View File

@@ -1,6 +1,7 @@
import { store } from '#store/stores.svelte';
import { useTinyflowStore } from '#store/stores.svelte';
export const useGetEdgesBySource = () => {
const store = useTinyflowStore();
const getEdgesBySource = (target: string) => {
const edges = store.getEdges();
return edges.filter((edge) => edge.source === target);

View File

@@ -1,6 +1,7 @@
import { store } from '#store/stores.svelte';
import { useTinyflowStore } from '#store/stores.svelte';
export const useGetEdgesByTarget = () => {
const store = useTinyflowStore();
const getEdgesByTarget = (target: string) => {
const edges = store.getEdges();
return edges.filter((edge) => edge.target === target);

View File

@@ -1,6 +1,7 @@
import { store } from '#store/stores.svelte';
import { useTinyflowStore } from '#store/stores.svelte';
export const useGetNode = () => {
const store = useTinyflowStore();
const getNode = (id: string) => {
return store.getNode(id);
};

View File

@@ -1,6 +1,7 @@
import { store } from '#store/stores.svelte';
import { useTinyflowStore } from '#store/stores.svelte';
export const useGetNodeRelativePosition = () => {
const store = useTinyflowStore();
const getNodeRelativePosition = (parentNodeId: string) => {
let node = store.getNode(parentNodeId);
const position = { x: 0, y: 0 };

View File

@@ -1,7 +1,8 @@
import { store } from '#store/stores.svelte';
import { useTinyflowStore } from '#store/stores.svelte';
import type { Edge, Node } from '@xyflow/svelte';
export const useGetNodesFromSource = () => {
const store = useTinyflowStore();
const getEdgesBySource = (target: string, edges: Edge[]) => {
return edges.filter(
// 排除循环节点的子节点,否则在多层循环嵌套时不正确

View File

@@ -1,6 +1,7 @@
import { store } from '#store/stores.svelte';
import { useTinyflowStore } from '#store/stores.svelte';
export const useUpdateEdgeData = () => {
const store = useTinyflowStore();
const updateEdgeData = (
id: string,
dataUpdate: any,

View File

@@ -1,9 +1,11 @@
import { type Edge, type Node, type Viewport } from '@xyflow/svelte';
import { getContext, setContext } from 'svelte';
import type { TinyflowNodeNormalizer } from '../utils/nodeInteraction';
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 edgesInternal = $state.raw([] as Edge[]);
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;
};

View File

@@ -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();
});
});

View File

@@ -4,6 +4,10 @@ import type {Node, useSvelteFlow} from '@xyflow/svelte';
export type TinyflowData = Partial<
ReturnType<ReturnType<typeof useSvelteFlow>['toObject']>
>;
export type TinyflowDataCommitReason =
| 'change'
| 'node-drag-stop'
| 'viewport-move-end';
export type TinyflowNodeData = Record<string, any>;
export type TinyflowTheme = 'light' | 'dark';
@@ -94,6 +98,7 @@ export type CustomNode = {
flowInstance: ReturnType<typeof useSvelteFlow>,
) => void;
onUpdate?: (parent: HTMLElement, node: Node) => void;
onDestroy?: (parent: HTMLElement) => void;
forms?: CustomNodeForm[];
};
@@ -115,6 +120,7 @@ export type TinyflowOptions = {
nodesDraggable?: boolean;
nodesConnectable?: boolean;
elementsSelectable?: boolean;
onlyRenderVisibleElements?: boolean;
dropEnabled?: boolean;
provider?: {
llm?: () => SelectItem[] | Promise<SelectItem[]>;
@@ -128,6 +134,10 @@ export type TinyflowOptions = {
onRunTest?: () => void | Promise<void>;
hiddenNodes?: string[] | (() => string[]);
onDataChange?: (data: TinyflowData) => void;
onDataCommit?: (
data: TinyflowData,
reason: TinyflowDataCommitReason,
) => void;
};
export type Parameter = {

View File

@@ -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);
});
});

View File

@@ -25,33 +25,45 @@ export function createTinyflowNodeNormalizer(
const elementsSelectable = options?.elementsSelectable ?? !readonly;
return (node) => {
const nextNode = { ...node };
let nextNode = node;
const mutableNode = () => {
if (nextNode === node) {
nextNode = { ...node };
}
return nextNode;
};
if (nodesDraggable) {
if (nextNode.draggable === false) {
delete nextNode.draggable;
delete mutableNode().draggable;
}
if (shouldUseDefaultDragHandle(nextNode, options?.customNodes)) {
nextNode.dragHandle = DEFAULT_NODE_DRAG_HANDLE;
mutableNode().dragHandle = DEFAULT_NODE_DRAG_HANDLE;
}
} else {
nextNode.draggable = false;
if (nextNode.draggable !== false) {
mutableNode().draggable = false;
}
}
if (nodesConnectable) {
if (nextNode.connectable === false) {
delete nextNode.connectable;
delete mutableNode().connectable;
}
} else {
nextNode.connectable = false;
if (nextNode.connectable !== false) {
mutableNode().connectable = false;
}
}
if (elementsSelectable) {
if (nextNode.selectable === false) {
delete nextNode.selectable;
delete mutableNode().selectable;
}
} else {
nextNode.selectable = false;
if (nextNode.selectable !== false) {
mutableNode().selectable = false;
}
}
return nextNode;

View File

@@ -3,7 +3,9 @@
</template>
<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 {
getCurrentInstance,
@@ -15,8 +17,7 @@ import {
watch,
} from 'vue';
type TinyflowDataOption = Exclude<TinyflowOptions['data'], string | undefined>;
type StructuredCloneFn = <T>(value: T) => T;
import { createTinyflowDataSync } from './dataSync';
const props = defineProps<
{
@@ -30,7 +31,7 @@ const attrs = useAttrs();
const instance = getCurrentInstance();
let tinyflow: TinyflowNative | null = null;
let mountedDataReady = false;
let lastAppliedDataSignature = '';
const dataSync = createTinyflowDataSync();
const optionalBooleanOptionKeys = new Set([
'readonly',
'hideBottomDock',
@@ -45,6 +46,7 @@ const optionalBooleanOptionKeys = new Set([
'nodesDraggable',
'nodesConnectable',
'elementsSelectable',
'onlyRenderVisibleElements',
'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(() => {
if (divRef.value) {
// 净化 props.data避免响应式对象或函数污染
@@ -141,8 +91,21 @@ onMounted(() => {
...normalizeProps(props),
} as any;
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({
...cleanedProps,
@@ -158,7 +121,7 @@ onUnmounted(() => {
tinyflow = null;
}
mountedDataReady = false;
lastAppliedDataSignature = '';
dataSync.reset();
});
watch(
@@ -179,7 +142,7 @@ watch(
data != null &&
typeof data !== 'string'
) {
const clonedData = cloneDataIfChanged(data);
const clonedData = dataSync.cloneExternalDataIfChanged(data);
if (clonedData) {
tinyflow.updateData(clonedData, { preserveViewport: true });
}
@@ -229,7 +192,7 @@ const updateData = (
options?: { preserveViewport?: boolean },
) => {
if (tinyflow && data != null && typeof data !== 'string') {
const clonedData = cloneDataIfChanged(data);
const clonedData = dataSync.cloneExternalDataIfChanged(data);
if (!clonedData) {
return true;
}

View 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);
});
});

View 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;
},
};
}