diff --git a/frontend/src/lib/components/flows/FlowAssetsHandler.svelte b/frontend/src/lib/components/flows/FlowAssetsHandler.svelte index c216022520..947eca1a67 100644 --- a/frontend/src/lib/components/flows/FlowAssetsHandler.svelte +++ b/frontend/src/lib/components/flows/FlowAssetsHandler.svelte @@ -117,10 +117,32 @@ }) } }) - // Prune all additionalAssetsMap entries from deleted modules + + // Ids the flow was loaded with. Only those modules can carry asset metadata predating + // the assets feature; anything appearing later was added in this session, so writing + // its assets adds nothing on top of a change the user has already made. The editor + // only mounts once the flow is loaded, so reading the prop here is the loaded state. + const loadedModuleIds = new Set(getAllModules(modules).map((m) => m.id)) + // Analyzing is a flow-wide action: offer it once, then apply that answer to every + // other module of the flow rather than asking again for each one. A prompt left + // unanswered stays 'offered' and keeps those modules untouched for the session. + let flowAnalysis: 'unoffered' | 'offered' | 'accepted' = 'unoffered' + // Last inference per module, keyed on everything inferAssets depends on. The watchers + // below are re-created whenever a module is added or removed and the selection watch + // fires on creation, so a miss here means a re-parse on every structural edit. + type InferredAssets = Extract>, { status: 'ok' }> + let analyzed: Record = {} + + // Prune per-module caches from deleted modules $effect(() => { - if (!flowGraphAssetsCtx) return const modulesSet = new Set(allModules.map((m) => m.id)) + for (const key of Object.keys(analyzed)) { + if (!modulesSet.has(key)) delete analyzed[key] + } + for (const key of [...loadedModuleIds]) { + if (!modulesSet.has(key)) loadedModuleIds.delete(key) + } + if (!flowGraphAssetsCtx) return for (const key of Object.keys(flowGraphAssetsCtx.val.additionalAssetsMap)) { if (!modulesSet.has(key)) { delete flowGraphAssetsCtx.val.additionalAssetsMap[key] @@ -129,6 +151,7 @@ }) function analyzeEntireFlow() { + flowAnalysis = 'accepted' for (const mod of allModules) { if (mod.value.type === 'rawscript') { parseAndUpdateRawScriptModule(mod.value, mod.id) @@ -136,27 +159,37 @@ } } - async function parseAndUpdateRawScriptModule( - v: RawScript, - modId: string, - isUserEdit: boolean = true - ) { - console.log('Parsing assets for RawScript module', modId) - let inferAssetsResult = await inferAssets(v.language, v.content) - if (inferAssetsResult.status === 'error') return - if (flowGraphAssetsCtx) flowGraphAssetsCtx.val.sqlQueries[modId] = inferAssetsResult.sql_queries - let newAssets = inferAssetsResult.assets as AssetWithAltAccessType[] + async function parseAndUpdateRawScriptModule(v: RawScript, modId: string, prompt = false) { + const key = JSON.stringify([v.language, v.content]) + let inferred = analyzed[modId]?.key === key ? analyzed[modId].result : undefined + if (!inferred) { + const inferAssetsResult = await inferAssets(v.language, v.content) + if (inferAssetsResult.status === 'error') return + inferred = inferAssetsResult + analyzed[modId] = { key, result: inferred } + } + // Copy before handing anything to the flow store: stored values become reactive + // proxies, and a later replay of this same inference would mutate the cache. + const { assets, sql_queries } = structuredClone(inferred) + if (flowGraphAssetsCtx) flowGraphAssetsCtx.val.sqlQueries[modId] = sql_queries + let newAssets = assets as AssetWithAltAccessType[] for (const asset of newAssets) { const old = v.assets?.find((a) => assetEq(a, asset)) if (old?.alt_access_type) asset.alt_access_type = old.alt_access_type } const normalizedAssets = newAssets.length > 0 ? newAssets : undefined if (!deepEqual(v.assets, normalizedAssets)) { - if (!isUserEdit && normalizedAssets && normalizedAssets.length > 0) { + if (prompt && flowAnalysis !== 'accepted' && normalizedAssets?.length) { + if (flowAnalysis === 'offered') return + flowAnalysis = 'offered' + // Long-lived because it is the only entry point to analyzeEntireFlow and it is + // offered once: a toast the user misses cannot be brought back without a reload. sendUserToast( 'Assets were detected in this step. Analyze entire flow for assets?', 'warning', - [{ label: 'Analyze entire flow', callback: () => analyzeEntireFlow() }] + [{ label: 'Analyze entire flow', callback: () => analyzeEntireFlow() }], + undefined, + 20000 ) } else { v.assets = normalizedAssets @@ -181,7 +214,11 @@ // Also recompute if the module is selected watch([() => selectedId === mod.id], () => { if (selectedId === mod.id) - parseAndUpdateRawScriptModule(modValue, mod.id, modValue.assets !== undefined) + parseAndUpdateRawScriptModule( + modValue, + mod.id, + modValue.assets === undefined && loadedModuleIds.has(mod.id) + ) }) } }