add linter and packed output
This commit is contained in:
@@ -315,6 +315,16 @@ def create_app(
|
|||||||
) if input_path.exists() else []
|
) if input_path.exists() else []
|
||||||
return web.Response(text=_dumps(files), content_type="application/json")
|
return web.Response(text=_dumps(files), content_type="application/json")
|
||||||
|
|
||||||
|
async def get_file_content(request: web.Request) -> web.Response:
|
||||||
|
session_id = require_session_id(request)
|
||||||
|
path_value = request.query.get("path", "")
|
||||||
|
if not path_value:
|
||||||
|
raise web.HTTPBadRequest(reason="Missing 'path' query parameter")
|
||||||
|
resolved = resolve_request_path(session_id, path_value)
|
||||||
|
if not resolved.is_file():
|
||||||
|
raise web.HTTPNotFound(reason=f"File not found: {path_value}")
|
||||||
|
return web.FileResponse(resolved)
|
||||||
|
|
||||||
async def create_upload_folder(request: web.Request) -> web.Response:
|
async def create_upload_folder(request: web.Request) -> web.Response:
|
||||||
session_id = require_session_id(request)
|
session_id = require_session_id(request)
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -567,6 +577,7 @@ def create_app(
|
|||||||
app.router.add_post("/save-workflow-png", save_workflow_png)
|
app.router.add_post("/save-workflow-png", save_workflow_png)
|
||||||
app.router.add_get("/channels", get_channels)
|
app.router.add_get("/channels", get_channels)
|
||||||
app.router.add_get("/docs", get_node_doc)
|
app.router.add_get("/docs", get_node_doc)
|
||||||
|
app.router.add_get("/file-content", get_file_content)
|
||||||
app.router.add_get("/help-docs", get_help_docs)
|
app.router.add_get("/help-docs", get_help_docs)
|
||||||
app.router.add_get("/help-docs/{filename}", get_help_doc_file)
|
app.router.add_get("/help-docs/{filename}", get_help_doc_file)
|
||||||
app.router.add_post("/prompt", submit_prompt)
|
app.router.add_post("/prompt", submit_prompt)
|
||||||
|
|||||||
62
frontend/eslint.config.js
Normal file
62
frontend/eslint.config.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{js,jsx}'],
|
||||||
|
plugins: { 'react-hooks': reactHooks },
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
|
globals: {
|
||||||
|
window: 'readonly',
|
||||||
|
document: 'readonly',
|
||||||
|
console: 'readonly',
|
||||||
|
fetch: 'readonly',
|
||||||
|
setTimeout: 'readonly',
|
||||||
|
clearTimeout: 'readonly',
|
||||||
|
setInterval: 'readonly',
|
||||||
|
clearInterval: 'readonly',
|
||||||
|
requestAnimationFrame: 'readonly',
|
||||||
|
cancelAnimationFrame: 'readonly',
|
||||||
|
navigator: 'readonly',
|
||||||
|
crypto: 'readonly',
|
||||||
|
URL: 'readonly',
|
||||||
|
URLSearchParams: 'readonly',
|
||||||
|
Blob: 'readonly',
|
||||||
|
File: 'readonly',
|
||||||
|
FileReader: 'readonly',
|
||||||
|
FormData: 'readonly',
|
||||||
|
Headers: 'readonly',
|
||||||
|
Image: 'readonly',
|
||||||
|
WebSocket: 'readonly',
|
||||||
|
HTMLElement: 'readonly',
|
||||||
|
ClipboardItem: 'readonly',
|
||||||
|
CSS: 'readonly',
|
||||||
|
ResizeObserver: 'readonly',
|
||||||
|
MutationObserver: 'readonly',
|
||||||
|
IntersectionObserver: 'readonly',
|
||||||
|
atob: 'readonly',
|
||||||
|
btoa: 'readonly',
|
||||||
|
performance: 'readonly',
|
||||||
|
structuredClone: 'readonly',
|
||||||
|
queueMicrotask: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// Prevent the TDZ bug
|
||||||
|
'no-use-before-define': ['error', { functions: false, classes: false, variables: true }],
|
||||||
|
|
||||||
|
// React hooks correctness
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
|
||||||
|
// Turn off rules that are noisy without adding safety
|
||||||
|
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
'no-empty': 'off',
|
||||||
|
'no-prototype-builtins': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
903
frontend/package-lock.json
generated
903
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
|||||||
"dev": "vite --force",
|
"dev": "vite --force",
|
||||||
"build": "vite build --emptyOutDir",
|
"build": "vite build --emptyOutDir",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint src/",
|
||||||
"test": "node --test tests/**/*.test.mjs",
|
"test": "node --test tests/**/*.test.mjs",
|
||||||
"test:coverage": "c8 --reporter=text --reporter=lcov node --test tests/**/*.test.mjs"
|
"test:coverage": "c8 --reporter=text --reporter=lcov node --test tests/**/*.test.mjs"
|
||||||
},
|
},
|
||||||
@@ -22,8 +23,11 @@
|
|||||||
"three": "^0.183.2"
|
"three": "^0.183.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"c8": "^10.1.3",
|
"c8": "^10.1.3",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"vite": "^5.4.0"
|
"vite": "^5.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { embedWorkflow, extractWorkflow } from './pngMetadata';
|
|||||||
import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture';
|
import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture';
|
||||||
import tonoIconUrl from '../../resources/icon_1024.png';
|
import tonoIconUrl from '../../resources/icon_1024.png';
|
||||||
import { hydrateWorkflowState } from './workflowHydration';
|
import { hydrateWorkflowState } from './workflowHydration';
|
||||||
|
import { packWorkflow, unpackWorkflow } from './workflowPacking';
|
||||||
import { serializeWorkflowState } from './workflowSerialization';
|
import { serializeWorkflowState } from './workflowSerialization';
|
||||||
import { sortNodesForParentOrder } from './nodeHierarchy.js';
|
import { sortNodesForParentOrder } from './nodeHierarchy.js';
|
||||||
import {
|
import {
|
||||||
@@ -2008,8 +2009,8 @@ function Flow() {
|
|||||||
setStatus({ text: 'Graph cleared.', level: 'info' });
|
setStatus({ text: 'Graph cleared.', level: 'info' });
|
||||||
}, [setNodes, setEdges]);
|
}, [setNodes, setEdges]);
|
||||||
|
|
||||||
const applyWorkflowData = useCallback((data) => {
|
const applyWorkflowData = useCallback((data, { preservedPaths } = {}) => {
|
||||||
const hydrated = hydrateWorkflowState(data, nodeDefsRef.current);
|
const hydrated = hydrateWorkflowState(data, nodeDefsRef.current, { preservedPaths });
|
||||||
setNodes(sortNodesForParentOrder(hydrated.nodes));
|
setNodes(sortNodesForParentOrder(hydrated.nodes));
|
||||||
setEdges(hydrated.edges);
|
setEdges(hydrated.edges);
|
||||||
nextIdRef.current = hydrated.nextNodeId;
|
nextIdRef.current = hydrated.nextNodeId;
|
||||||
@@ -2029,6 +2030,16 @@ function Flow() {
|
|||||||
initializeDynamicNodes(hydrated.nodes);
|
initializeDynamicNodes(hydrated.nodes);
|
||||||
}, [initializeDynamicNodes, setNodes, setEdges]);
|
}, [initializeDynamicNodes, setNodes, setEdges]);
|
||||||
|
|
||||||
|
const applyMaybePackedWorkflow = useCallback(async (data) => {
|
||||||
|
if (data.packed && data.packedFiles) {
|
||||||
|
setStatus({ text: 'Unpacking files…', level: 'info' });
|
||||||
|
const { workflow, restoredPaths } = await unpackWorkflow(data);
|
||||||
|
applyWorkflowData(workflow, { preservedPaths: restoredPaths });
|
||||||
|
} else {
|
||||||
|
applyWorkflowData(data);
|
||||||
|
}
|
||||||
|
}, [applyWorkflowData]);
|
||||||
|
|
||||||
const loadDefaultWorkflow = useCallback(async () => {
|
const loadDefaultWorkflow = useCallback(async () => {
|
||||||
if (defaultWorkflowLoadAttemptedRef.current) return;
|
if (defaultWorkflowLoadAttemptedRef.current) return;
|
||||||
defaultWorkflowLoadAttemptedRef.current = true;
|
defaultWorkflowLoadAttemptedRef.current = true;
|
||||||
@@ -2045,7 +2056,7 @@ function Flow() {
|
|||||||
const loaded = await loadDefaultWorkflowAsset();
|
const loaded = await loadDefaultWorkflowAsset();
|
||||||
if (!loaded || graphHasContent()) return;
|
if (!loaded || graphHasContent()) return;
|
||||||
|
|
||||||
applyWorkflowData(loaded.workflow);
|
await applyMaybePackedWorkflow(loaded.workflow);
|
||||||
setStatus({ text: `Loaded default workflow from ${loaded.source}.`, level: 'info' });
|
setStatus({ text: `Loaded default workflow from ${loaded.source}.`, level: 'info' });
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => scheduleAutoRun());
|
requestAnimationFrame(() => scheduleAutoRun());
|
||||||
@@ -2053,7 +2064,7 @@ function Flow() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus({ text: 'Default workflow failed to load: ' + err.message, level: 'error' });
|
setStatus({ text: 'Default workflow failed to load: ' + err.message, level: 'error' });
|
||||||
}
|
}
|
||||||
}, [applyWorkflowData, reactFlow, scheduleAutoRun]);
|
}, [applyMaybePackedWorkflow, reactFlow, scheduleAutoRun]);
|
||||||
|
|
||||||
// ── Load node definitions ───────────────────────────────────────────
|
// ── Load node definitions ───────────────────────────────────────────
|
||||||
|
|
||||||
@@ -2224,6 +2235,90 @@ function Flow() {
|
|||||||
}
|
}
|
||||||
}, [getWorkflowBlob]);
|
}, [getWorkflowBlob]);
|
||||||
|
|
||||||
|
const savePackedWorkflow = useCallback(async () => {
|
||||||
|
setStatus({ text: 'Packing files…', level: 'info' });
|
||||||
|
try {
|
||||||
|
const viewportEl = document.querySelector('.react-flow__viewport');
|
||||||
|
if (!viewportEl) throw new Error('Flow element not found');
|
||||||
|
|
||||||
|
const allNodes = reactFlow.getNodes();
|
||||||
|
if (allNodes.length === 0) throw new Error('No nodes to capture');
|
||||||
|
|
||||||
|
const bounds = getRenderedNodeBounds(allNodes);
|
||||||
|
if (!bounds) throw new Error('Could not determine rendered node bounds');
|
||||||
|
const pad = 0.1;
|
||||||
|
const imageWidth = Math.ceil(bounds.width * (1 + pad * 2));
|
||||||
|
const imageHeight = Math.ceil(bounds.height * (1 + pad * 2));
|
||||||
|
const vp = getViewportForBounds(bounds, imageWidth, imageHeight, 0.5, 1, pad);
|
||||||
|
|
||||||
|
const blob = await captureWorkflowViewportBlob(viewportEl, {
|
||||||
|
backgroundColor: CANVAS_COLORS.bgDeep,
|
||||||
|
width: imageWidth,
|
||||||
|
height: imageHeight,
|
||||||
|
style: {
|
||||||
|
width: `${imageWidth}px`,
|
||||||
|
height: `${imageHeight}px`,
|
||||||
|
transform: `translate(${vp.x}px, ${vp.y}px) scale(${vp.zoom})`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!blob) throw new Error('Capture returned empty');
|
||||||
|
|
||||||
|
const stampedBlob = await stampLogoOnBlob(blob);
|
||||||
|
let workflow = serializeWorkflowState(allNodes, reactFlow.getEdges());
|
||||||
|
if (journalContentRef.current) workflow.journalContent = journalContentRef.current;
|
||||||
|
|
||||||
|
workflow = await packWorkflow(workflow, nodeDefsRef.current, (packed, total) => {
|
||||||
|
setStatus({ text: `Packing files… (${packed}/${total})`, level: 'info' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalBlob = await embedWorkflow(stampedBlob, workflow);
|
||||||
|
const defaultName = 'workflow-packed.png';
|
||||||
|
|
||||||
|
if (window.pywebview?.api?.choose_save_workflow_png_path) {
|
||||||
|
const requestedPath = await window.pywebview.api.choose_save_workflow_png_path(defaultName);
|
||||||
|
if (!requestedPath) { setStatus({ text: 'Save cancelled.', level: 'info' }); return; }
|
||||||
|
const resp = await fetch(`/save-workflow-png?path=${encodeURIComponent(requestedPath)}`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'image/png' }, body: finalBlob,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await resp.text() || `Save failed (${resp.status})`);
|
||||||
|
const { path: savedPath } = await resp.json();
|
||||||
|
if (!savedPath) { setStatus({ text: 'Save cancelled.', level: 'info' }); return; }
|
||||||
|
setStatus({ text: `Packed workflow saved to ${savedPath}.`, level: 'info' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('showSaveFilePicker' in window) {
|
||||||
|
try {
|
||||||
|
const handle = await window.showSaveFilePicker({
|
||||||
|
suggestedName: defaultName,
|
||||||
|
types: [{ description: 'PNG image', accept: { 'image/png': ['.png'] } }],
|
||||||
|
});
|
||||||
|
const writable = await handle.createWritable();
|
||||||
|
await writable.write(finalBlob);
|
||||||
|
await writable.close();
|
||||||
|
setStatus({ text: 'Packed workflow saved.', level: 'info' });
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.name === 'AbortError') { setStatus({ text: 'Save cancelled.', level: 'info' }); return; }
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch('/download?filename=' + defaultName, { method: 'POST', body: finalBlob });
|
||||||
|
const dlBlob = await resp.blob();
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(dlBlob);
|
||||||
|
a.download = defaultName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||||
|
setStatus({ text: `Packed workflow downloaded as ${defaultName}.`, level: 'info' });
|
||||||
|
} catch (err) {
|
||||||
|
setStatus({ text: 'Pack failed: ' + err.message, level: 'error' });
|
||||||
|
}
|
||||||
|
}, [reactFlow]);
|
||||||
|
|
||||||
const copySnapshot = useCallback(() => {
|
const copySnapshot = useCallback(() => {
|
||||||
setStatus({ text: 'Copying snapshot…', level: 'info' });
|
setStatus({ text: 'Copying snapshot…', level: 'info' });
|
||||||
// Pass a Promise<Blob> to ClipboardItem so the clipboard.write() call
|
// Pass a Promise<Blob> to ClipboardItem so the clipboard.write() call
|
||||||
@@ -2258,14 +2353,14 @@ function Flow() {
|
|||||||
} else {
|
} else {
|
||||||
data = JSON.parse(await file.text());
|
data = JSON.parse(await file.text());
|
||||||
}
|
}
|
||||||
applyWorkflowData(data);
|
await applyMaybePackedWorkflow(data);
|
||||||
setStatus({ text: 'Workflow loaded.', level: 'info' });
|
setStatus({ text: 'Workflow loaded.', level: 'info' });
|
||||||
} catch {
|
} catch {
|
||||||
setStatus({ text: 'Invalid workflow file.', level: 'error' });
|
setStatus({ text: 'Invalid workflow file.', level: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
}, [applyWorkflowData]);
|
}, [applyMaybePackedWorkflow]);
|
||||||
|
|
||||||
const uploadPlugin = useCallback(() => {
|
const uploadPlugin = useCallback(() => {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
@@ -2302,12 +2397,12 @@ function Flow() {
|
|||||||
setStatus({ text: 'No workflow data in this image.', level: 'error' });
|
setStatus({ text: 'No workflow data in this image.', level: 'error' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applyWorkflowData(data);
|
await applyMaybePackedWorkflow(data);
|
||||||
setStatus({ text: 'Workflow loaded from image.', level: 'info' });
|
setStatus({ text: 'Workflow loaded from image.', level: 'info' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus({ text: 'Failed to load: ' + err.message, level: 'error' });
|
setStatus({ text: 'Failed to load: ' + err.message, level: 'error' });
|
||||||
}
|
}
|
||||||
}, [applyWorkflowData]);
|
}, [applyMaybePackedWorkflow]);
|
||||||
|
|
||||||
const onDragOver = useCallback((event) => {
|
const onDragOver = useCallback((event) => {
|
||||||
if (event.dataTransfer?.types?.includes('Files')) {
|
if (event.dataTransfer?.types?.includes('Files')) {
|
||||||
@@ -2969,6 +3064,9 @@ function Flow() {
|
|||||||
<button className="btn" onClick={saveWorkflow} title="Save workflow as PNG">
|
<button className="btn" onClick={saveWorkflow} title="Save workflow as PNG">
|
||||||
⤓ Save
|
⤓ Save
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn" onClick={savePackedWorkflow} title="Save packed workflow (with files)">
|
||||||
|
⊞ Pack
|
||||||
|
</button>
|
||||||
<button className="btn" onClick={loadWorkflow} title="Load workflow (JSON or PNG)">
|
<button className="btn" onClick={loadWorkflow} title="Load workflow (JSON or PNG)">
|
||||||
⤒ Load
|
⤒ Load
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ export async function getChannels(filepath) {
|
|||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getFileContent(path) {
|
||||||
|
const r = await sessionFetch(`/file-content?path=${encodeURIComponent(path)}`);
|
||||||
|
if (!r.ok) {
|
||||||
|
const text = await r.text();
|
||||||
|
throw new Error(`Failed to read file (${r.status}): ${text}`);
|
||||||
|
}
|
||||||
|
return r.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
export async function getFolderFiles(folderpath) {
|
export async function getFolderFiles(folderpath) {
|
||||||
const r = await sessionFetch(`/folder-files?folder=${encodeURIComponent(folderpath)}`);
|
const r = await sessionFetch(`/folder-files?folder=${encodeURIComponent(folderpath)}`);
|
||||||
if (!r.ok) return [];
|
if (!r.ok) return [];
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ function getInputEntries(definition) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeWidgetValues(widgetValues, definition) {
|
function sanitizeWidgetValues(widgetValues, definition, preservedPaths) {
|
||||||
const nextValues = { ...(widgetValues || {}) };
|
const nextValues = { ...(widgetValues || {}) };
|
||||||
|
|
||||||
getInputEntries(definition).forEach(([inputName, inputDef]) => {
|
getInputEntries(definition).forEach(([inputName, inputDef]) => {
|
||||||
const type = getSocketType(inputDef);
|
const type = getSocketType(inputDef);
|
||||||
if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') {
|
if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') {
|
||||||
|
if (preservedPaths && preservedPaths.has(nextValues[inputName])) return;
|
||||||
nextValues[inputName] = '';
|
nextValues[inputName] = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -33,7 +34,7 @@ function sanitizeWidgetValues(widgetValues, definition) {
|
|||||||
return nextValues;
|
return nextValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hydrateWorkflowState(data, defs = {}) {
|
export function hydrateWorkflowState(data, defs = {}, { preservedPaths } = {}) {
|
||||||
const loadedNodes = Array.isArray(data?.nodes) ? data.nodes : [];
|
const loadedNodes = Array.isArray(data?.nodes) ? data.nodes : [];
|
||||||
const loadedEdges = Array.isArray(data?.edges) ? data.edges : [];
|
const loadedEdges = Array.isArray(data?.edges) ? data.edges : [];
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ export function hydrateWorkflowState(data, defs = {}) {
|
|||||||
data: {
|
data: {
|
||||||
...node.data,
|
...node.data,
|
||||||
label: node.data?.label || node.data?.className || 'Node',
|
label: node.data?.label || node.data?.className || 'Node',
|
||||||
widgetValues: sanitizeWidgetValues(node.data?.widgetValues, definition),
|
widgetValues: sanitizeWidgetValues(node.data?.widgetValues, definition, preservedPaths),
|
||||||
runtimeValues: sanitizeRuntimeValuesForPersistence(
|
runtimeValues: sanitizeRuntimeValuesForPersistence(
|
||||||
node.data?.className,
|
node.data?.className,
|
||||||
node.data?.runtimeValues,
|
node.data?.runtimeValues,
|
||||||
|
|||||||
187
frontend/src/workflowPacking.js
Normal file
187
frontend/src/workflowPacking.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* workflowPacking.js — Pack/unpack file assets into workflow JSON.
|
||||||
|
*
|
||||||
|
* Packed workflows embed base64-encoded file contents so they are
|
||||||
|
* portable across machines and sessions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as api from './api';
|
||||||
|
|
||||||
|
const MAX_PACKED_BYTES = 100 * 1024 * 1024; // 100 MB
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function arrayBufferToBase64(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToUint8Array(b64) {
|
||||||
|
const binary = atob(b64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputType(spec) {
|
||||||
|
if (!spec) return null;
|
||||||
|
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||||
|
return Array.isArray(type) ? type[0] : type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filenameFromPath(path) {
|
||||||
|
return String(path).split('/').pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the relative path portion from a session:// URI.
|
||||||
|
* e.g. "session://uploads/myfolder/scan.ibw" → "myfolder/scan.ibw"
|
||||||
|
*/
|
||||||
|
function sessionRelativePath(path) {
|
||||||
|
const prefix = 'session://uploads/';
|
||||||
|
if (path.startsWith(prefix)) return path.slice(prefix.length);
|
||||||
|
return filenameFromPath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pack ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embed referenced files into workflowData.
|
||||||
|
*
|
||||||
|
* @param {object} workflowData - Serialized workflow (from serializeWorkflowState)
|
||||||
|
* @param {object} nodeDefs - Node definition registry (nodeDefsRef.current)
|
||||||
|
* @param {function} [onProgress] - Optional (packed, total) callback
|
||||||
|
* @returns {object} workflowData with packedFiles added
|
||||||
|
*/
|
||||||
|
export async function packWorkflow(workflowData, nodeDefs, onProgress) {
|
||||||
|
// 1. Collect FILE_PICKER paths only (skip FOLDER_PICKER)
|
||||||
|
const filePaths = new Set();
|
||||||
|
|
||||||
|
for (const node of workflowData.nodes) {
|
||||||
|
const className = node.data?.className;
|
||||||
|
const def = className ? nodeDefs[className] : null;
|
||||||
|
if (!def) continue;
|
||||||
|
|
||||||
|
const allInputs = { ...(def.input?.required || {}), ...(def.input?.optional || {}) };
|
||||||
|
const widgetValues = node.data?.widgetValues || {};
|
||||||
|
|
||||||
|
for (const [name, spec] of Object.entries(allInputs)) {
|
||||||
|
const type = getInputType(spec);
|
||||||
|
const value = String(widgetValues[name] || '').trim();
|
||||||
|
if (!value) continue;
|
||||||
|
|
||||||
|
if (type === 'FILE_PICKER') {
|
||||||
|
filePaths.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filePaths.size === 0) {
|
||||||
|
return workflowData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch each file and encode
|
||||||
|
const packedFiles = {};
|
||||||
|
let totalBytes = 0;
|
||||||
|
let packed = 0;
|
||||||
|
const total = filePaths.size;
|
||||||
|
|
||||||
|
for (const path of filePaths) {
|
||||||
|
try {
|
||||||
|
const buffer = await api.getFileContent(path);
|
||||||
|
totalBytes += buffer.byteLength;
|
||||||
|
if (totalBytes > MAX_PACKED_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
`Packed workflow exceeds ${Math.round(MAX_PACKED_BYTES / 1024 / 1024)} MB limit ` +
|
||||||
|
`(${Math.round(totalBytes / 1024 / 1024)} MB so far). ` +
|
||||||
|
`Reduce the number or size of referenced files.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
packedFiles[path] = {
|
||||||
|
filename: filenameFromPath(path),
|
||||||
|
data: arrayBufferToBase64(buffer),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('limit')) throw err;
|
||||||
|
// File may not exist (e.g. cleared path) — skip
|
||||||
|
}
|
||||||
|
packed++;
|
||||||
|
if (onProgress) onProgress(packed, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(packedFiles).length === 0) {
|
||||||
|
return workflowData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...workflowData,
|
||||||
|
packed: true,
|
||||||
|
packedFiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unpack ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract packed files from workflowData and upload them to the current session.
|
||||||
|
*
|
||||||
|
* @param {object} workflowData - Workflow data potentially containing packedFiles
|
||||||
|
* @returns {{ workflow: object, restoredPaths: Set<string> }}
|
||||||
|
*/
|
||||||
|
export async function unpackWorkflow(workflowData) {
|
||||||
|
const packedFiles = workflowData.packedFiles;
|
||||||
|
if (!packedFiles || Object.keys(packedFiles).length === 0) {
|
||||||
|
return { workflow: workflowData, restoredPaths: new Set() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathMap = {}; // oldPath → newSessionPath
|
||||||
|
const restoredPaths = new Set();
|
||||||
|
|
||||||
|
// 1. Upload each packed file
|
||||||
|
for (const [origPath, entry] of Object.entries(packedFiles)) {
|
||||||
|
const bytes = base64ToUint8Array(entry.data);
|
||||||
|
const file = new File([bytes], entry.filename);
|
||||||
|
const relativePath = sessionRelativePath(origPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.uploadFile(file, { relativePath });
|
||||||
|
const newPath = result.path;
|
||||||
|
pathMap[origPath] = newPath;
|
||||||
|
restoredPaths.add(newPath);
|
||||||
|
} catch {
|
||||||
|
// Upload failed — skip this file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remap widget values in nodes
|
||||||
|
const updatedNodes = workflowData.nodes.map((node) => {
|
||||||
|
const wv = node.data?.widgetValues;
|
||||||
|
if (!wv) return node;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const nextWv = { ...wv };
|
||||||
|
for (const [key, val] of Object.entries(nextWv)) {
|
||||||
|
if (typeof val === 'string' && pathMap[val]) {
|
||||||
|
nextWv[key] = pathMap[val];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return node;
|
||||||
|
return { ...node, data: { ...node.data, widgetValues: nextWv } };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Strip packed data from the workflow to avoid storing it again on re-save
|
||||||
|
const { packedFiles: _, packed: __, ...cleanWorkflow } = workflowData;
|
||||||
|
|
||||||
|
return {
|
||||||
|
workflow: { ...cleanWorkflow, nodes: updatedNodes },
|
||||||
|
restoredPaths,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export default defineConfig({
|
|||||||
'/upload-folder': 'http://127.0.0.1:8188',
|
'/upload-folder': 'http://127.0.0.1:8188',
|
||||||
'/upload': 'http://127.0.0.1:8188',
|
'/upload': 'http://127.0.0.1:8188',
|
||||||
'/download': 'http://127.0.0.1:8188',
|
'/download': 'http://127.0.0.1:8188',
|
||||||
|
'/file-content': 'http://127.0.0.1:8188',
|
||||||
'/help-docs': { target: 'http://127.0.0.1:8188', changeOrigin: true },
|
'/help-docs': { target: 'http://127.0.0.1:8188', changeOrigin: true },
|
||||||
'/prompt': 'http://127.0.0.1:8188',
|
'/prompt': 'http://127.0.0.1:8188',
|
||||||
'/ws': {
|
'/ws': {
|
||||||
|
|||||||
Reference in New Issue
Block a user