From 386cebf34239afa94f189e07b8cada55127f4b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Fri, 4 Sep 2026 14:56:20 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E5=87=8F=E5=B0=91=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E8=AE=BE=E8=AE=A1=E5=99=A8=E9=87=8D=E5=A4=8D=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E4=B8=8E=E5=BA=8F=E5=88=97=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 缓存节点汇聚模式拓扑分析结果 - 草稿保存复用内容序列化结果 --- .../__tests__/workflowDraftCache.test.ts | 17 +++++ .../views/ai/workflow/workflowDraftCache.ts | 31 +++++--- .../src/components/core/NodeWrapper.svelte | 8 +-- .../src/components/utils/joinMode.test.ts | 62 ++++++++++++++++ .../src/components/utils/joinMode.ts | 71 +++++++++++++++++++ 5 files changed, 175 insertions(+), 14 deletions(-) diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/__tests__/workflowDraftCache.test.ts b/easyflow-ui-admin/app/src/views/ai/workflow/__tests__/workflowDraftCache.test.ts index 46421ccd..1adafbf8 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/__tests__/workflowDraftCache.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/workflow/__tests__/workflowDraftCache.test.ts @@ -114,6 +114,23 @@ describe('workflowDraftCache', () => { ).toBe(false); }); + it('写入草稿时只序列化一次工作流内容', () => { + const toJSON = vi.fn(() => draftContent); + + expect( + writeWorkflowDraft({ + baseContentSignature: createWorkflowContentSignature(serverContent), + content: {toJSON}, + workflowId, + }), + ).toBe(true); + + expect(toJSON).toHaveBeenCalledTimes(1); + expect(readWorkflowDraft(workflowId, serverContent)?.content).toEqual( + draftContent, + ); + }); + it('忽略结构异常的缓存内容', () => { sessionStorage.setItem( `easyflow:workflow-draft:${workflowId}`, diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/workflowDraftCache.ts b/easyflow-ui-admin/app/src/views/ai/workflow/workflowDraftCache.ts index 539c0e86..7336e53f 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/workflowDraftCache.ts +++ b/easyflow-ui-admin/app/src/views/ai/workflow/workflowDraftCache.ts @@ -63,6 +63,25 @@ export function createWorkflowContentSignature(content: unknown) { } } +function serializeWorkflowDraft( + options: WriteWorkflowDraftOptions, + contentSignature: string, +) { + return [ + '{"baseContentSignature":', + JSON.stringify(options.baseContentSignature), + ',"content":', + contentSignature, + ',"updatedAt":', + String(Date.now()), + ',"version":', + String(WORKFLOW_DRAFT_VERSION), + ',"workflowId":', + JSON.stringify(String(options.workflowId)), + '}', + ].join(''); +} + export function readWorkflowDraft( workflowId: unknown, serverContent: unknown, @@ -124,15 +143,11 @@ export function writeWorkflowDraft( return true; } - const snapshot: WorkflowDraftSnapshot = { - baseContentSignature: options.baseContentSignature, - content: options.content, - updatedAt: Date.now(), - version: WORKFLOW_DRAFT_VERSION, - workflowId: String(options.workflowId), - }; try { - storage.setItem(storageKey, JSON.stringify(snapshot)); + storage.setItem( + storageKey, + serializeWorkflowDraft(options, contentSignature), + ); return true; } catch { return false; diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/NodeWrapper.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/NodeWrapper.svelte index 657631e6..054bdada 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/NodeWrapper.svelte +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/NodeWrapper.svelte @@ -18,7 +18,7 @@ import {useTinyflowStore} from '#store/stores.svelte'; import NodeJoinModeSetting from './NodeJoinModeSetting.svelte'; import { - analyzeJoinMode, + getCachedJoinModeAnalysis, getJoinModeBadge, type JoinMode, } from '../utils/joinMode'; @@ -64,11 +64,7 @@ const store = useTinyflowStore(); const updateNodeInternals = useUpdateNodeInternals(); - const joinModeAnalysis = $derived.by(() => analyzeJoinMode( - store.getNodes(), - store.getEdges(), - id, - )); + const joinModeAnalysis = $derived.by(() => getCachedJoinModeAnalysis(store, id)); const joinModeBadge = $derived(getJoinModeBadge(data.joinMode)); const items = $derived.by(() => { diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/joinMode.test.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/joinMode.test.ts index 97325a27..be08f1e2 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/joinMode.test.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/joinMode.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { analyzeJoinMode, + getCachedJoinModeAnalysis, getJoinModeBadge, parseJoinMode, } from './joinMode'; @@ -154,4 +155,65 @@ describe('join mode graph analysis', () => { expect(analysis.invalidMode).toBe(true); expect(analysis.mode).toBeNull(); }); + + it('reuses analysis when ordinary node content changes', () => { + let nodes = [ + node('start', 'startNode'), + node('join', 'codeNode', {joinMode: 'any', title: 'before'}), + ]; + const edges = [edge('start-join', 'start', 'join')]; + const store = { + getNodes: () => nodes, + getEdges: () => edges, + }; + + const first = getCachedJoinModeAnalysis(store, 'join'); + nodes = [ + node('start', 'startNode'), + node('join', 'codeNode', {joinMode: 'any', title: 'after'}), + ]; + + expect(getCachedJoinModeAnalysis(store, 'join')).toBe(first); + }); + + it('invalidates cached analysis when topology rules change', () => { + let nodes = [ + node('start', 'startNode'), + node('join', 'codeNode', {joinMode: 'any'}), + ]; + const edges = [edge('start-join', 'start', 'join')]; + const store = { + getNodes: () => nodes, + getEdges: () => edges, + }; + + const first = getCachedJoinModeAnalysis(store, 'join'); + nodes = [ + node('start', 'startNode'), + node('join', 'codeNode', {joinMode: 'all'}), + ]; + const second = getCachedJoinModeAnalysis(store, 'join'); + + expect(second).not.toBe(first); + expect(second.mode).toBe('all'); + }); + + it.each([null, ''])( + 'invalidates the default cache when joinMode becomes %j', + (joinMode) => { + let nodes = [node('join')]; + const store = { + getNodes: () => nodes, + getEdges: () => [] as Edge[], + }; + + const first = getCachedJoinModeAnalysis(store, 'join'); + nodes = [node('join', 'codeNode', {joinMode})]; + const second = getCachedJoinModeAnalysis(store, 'join'); + + expect(second).not.toBe(first); + expect(second.mode).toBeNull(); + expect(second.invalidMode).toBe(true); + }, + ); }); diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/joinMode.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/joinMode.ts index f7638eda..5f53f124 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/joinMode.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/joinMode.ts @@ -13,6 +13,20 @@ export type JoinModeAnalysis = { allDisabledReason: string; }; +type JoinModeStore = { + getEdges: () => Edge[]; + getNodes: () => Node[]; +}; + +type JoinModeCacheEntry = { + analyses: Map; + edges: Edge[]; + nodes: Node[]; + topologySignature: string; +}; + +const joinModeAnalysisCache = new WeakMap(); + const text = (value: unknown) => (value == null ? '' : String(value).trim()); export function parseJoinMode(value: unknown): JoinMode | null { @@ -109,6 +123,63 @@ export function analyzeJoinMode( }; } +/** + * Reuses join-mode analysis while node edits leave the graph topology intact. + * XYFlow replaces the nodes/edges arrays on graph changes, so the signature is + * calculated once per store update instead of once per rendered node. + */ +export function getCachedJoinModeAnalysis( + store: JoinModeStore, + nodeId: string, +): JoinModeAnalysis { + const nodes = store.getNodes(); + const edges = store.getEdges(); + let entry = joinModeAnalysisCache.get(store as object); + + if (entry?.nodes !== nodes || entry.edges !== edges) { + const topologySignature = createTopologySignature(nodes, edges); + entry = entry?.topologySignature === topologySignature + ? {...entry, nodes, edges} + : {nodes, edges, topologySignature, analyses: new Map()}; + joinModeAnalysisCache.set(store as object, entry); + } + + const cached = entry.analyses.get(nodeId); + if (cached) return cached; + + const analysis = analyzeJoinMode(nodes, edges, nodeId); + entry.analyses.set(nodeId, analysis); + return analysis; +} + +function createTopologySignature(nodes: Node[], edges: Edge[]) { + return JSON.stringify([ + nodes.map((node) => [ + node.id, + node.type, + node.parentId, + joinModeSignature(node), + text(node.data?.condition), + ]), + edges.map((edge) => [ + edge.source, + edge.target, + text(edge.data?.condition), + ]), + ]); +} + +function joinModeSignature(node: Node) { + const hasJoinMode = Boolean(node.data) + && Object.prototype.hasOwnProperty.call(node.data, 'joinMode'); + const value = node.data?.joinMode; + return [ + hasJoinMode, + value === undefined ? 'undefined' : value === null ? 'null' : typeof value, + text(value).toLowerCase(), + ]; +} + function findGuaranteedNodes(nodes: Node[], edges: Edge[]) { const guaranteed = new Set( nodes