fix folder and file save
This commit is contained in:
@@ -70,6 +70,7 @@ class ExecutionEngine:
|
|||||||
on_overlay: Callable[[str, str], None] | None = None,
|
on_overlay: Callable[[str, str], None] | None = None,
|
||||||
on_value: Callable[[str, Any], None] | None = None,
|
on_value: Callable[[str, Any], None] | None = None,
|
||||||
on_warning: Callable[[str, str], None] | None = None,
|
on_warning: Callable[[str, str], None] | None = None,
|
||||||
|
on_file_download: Callable[[str, str], None] | None = None,
|
||||||
) -> dict[str, tuple]:
|
) -> dict[str, tuple]:
|
||||||
"""
|
"""
|
||||||
Execute the workflow described by `prompt`.
|
Execute the workflow described by `prompt`.
|
||||||
@@ -100,6 +101,7 @@ class ExecutionEngine:
|
|||||||
overlay=on_overlay,
|
overlay=on_overlay,
|
||||||
value=on_value,
|
value=on_value,
|
||||||
warning=on_warning,
|
warning=on_warning,
|
||||||
|
file_download=on_file_download,
|
||||||
):
|
):
|
||||||
for node_id in order:
|
for node_id in order:
|
||||||
node_def = prompt[node_id]
|
node_def = prompt[node_id]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ _LEGACY_CALLBACK_ATTRS = {
|
|||||||
"overlay": "_broadcast_overlay_fn",
|
"overlay": "_broadcast_overlay_fn",
|
||||||
"value": "_broadcast_value_fn",
|
"value": "_broadcast_value_fn",
|
||||||
"warning": "_broadcast_warning_fn",
|
"warning": "_broadcast_warning_fn",
|
||||||
|
"file_download": "_broadcast_file_download_fn",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ def execution_callbacks(
|
|||||||
overlay: Callback | None = None,
|
overlay: Callback | None = None,
|
||||||
value: Callback | None = None,
|
value: Callback | None = None,
|
||||||
warning: Callback | None = None,
|
warning: Callback | None = None,
|
||||||
|
file_download: Callback | None = None,
|
||||||
):
|
):
|
||||||
token = _callbacks_var.set({
|
token = _callbacks_var.set({
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
@@ -40,6 +42,7 @@ def execution_callbacks(
|
|||||||
"overlay": overlay,
|
"overlay": overlay,
|
||||||
"value": value,
|
"value": value,
|
||||||
"warning": warning,
|
"warning": warning,
|
||||||
|
"file_download": file_download,
|
||||||
})
|
})
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
@@ -120,3 +123,7 @@ def emit_value(payload: Any) -> None:
|
|||||||
|
|
||||||
def emit_warning(message: str) -> None:
|
def emit_warning(message: str) -> None:
|
||||||
_emit("warning", message)
|
_emit("warning", message)
|
||||||
|
|
||||||
|
|
||||||
|
def emit_file_download(path: str) -> None:
|
||||||
|
_emit("file_download", path)
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ from pathlib import Path
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
from backend.execution_context import emit_warning
|
from backend.execution_context import emit_warning, emit_file_download
|
||||||
from backend.data_types import DataField, LineData, MeshModel, datafield_to_uint8, image_to_uint8
|
from backend.data_types import DataField, LineData, MeshModel, datafield_to_uint8, image_to_uint8
|
||||||
|
|
||||||
|
DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "tono-downloads"
|
||||||
|
|
||||||
@register_node(display_name="Save")
|
@register_node(display_name="Save")
|
||||||
class Save:
|
class Save:
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -21,13 +25,6 @@ class Save:
|
|||||||
"placeholder": "filename",
|
"placeholder": "filename",
|
||||||
"placement": "top",
|
"placement": "top",
|
||||||
}),
|
}),
|
||||||
"directory_path": ("FOLDER_PICKER", {
|
|
||||||
"default": "",
|
|
||||||
"label": "directory",
|
|
||||||
"placement": "top",
|
|
||||||
"hide_when_input_connected": "directory",
|
|
||||||
"top_socket_input": "directory",
|
|
||||||
}),
|
|
||||||
"value": ("DATA_FIELD", {
|
"value": ("DATA_FIELD", {
|
||||||
"label": "value",
|
"label": "value",
|
||||||
"accepted_types": [
|
"accepted_types": [
|
||||||
@@ -56,11 +53,11 @@ class Save:
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
"optional": {
|
"optional": {
|
||||||
"directory": ("DIRECTORY", {"label": "directory"}),
|
|
||||||
"plot_title": ("STRING", {
|
"plot_title": ("STRING", {
|
||||||
"default": "",
|
"default": "",
|
||||||
"placeholder": "plot title (optional)",
|
"placeholder": "plot title (optional)",
|
||||||
"label": "title",
|
"label": "title",
|
||||||
|
"show_when_source_type": {"value": ["LINE"]},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -80,13 +77,11 @@ class Save:
|
|||||||
def save(
|
def save(
|
||||||
self,
|
self,
|
||||||
filename: str,
|
filename: str,
|
||||||
directory_path: str,
|
|
||||||
format: str,
|
format: str,
|
||||||
value,
|
value,
|
||||||
directory: str | None = None,
|
|
||||||
plot_title: str = "",
|
plot_title: str = "",
|
||||||
):
|
):
|
||||||
path = self._resolve_save_path(filename, format, directory, directory_path)
|
path = self._resolve_save_path(filename, format)
|
||||||
|
|
||||||
if isinstance(value, MeshModel):
|
if isinstance(value, MeshModel):
|
||||||
self._save_mesh(path, value, format)
|
self._save_mesh(path, value, format)
|
||||||
@@ -107,15 +102,10 @@ class Save:
|
|||||||
raise ValueError(f"Save does not support input type: {type(value).__name__}")
|
raise ValueError(f"Save does not support input type: {type(value).__name__}")
|
||||||
|
|
||||||
self._send_warning(f"Saved to {path.name}")
|
self._send_warning(f"Saved to {path.name}")
|
||||||
|
emit_file_download(str(path))
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
def _resolve_save_path(
|
def _resolve_save_path(self, filename: str, format_name: str) -> Path:
|
||||||
self,
|
|
||||||
filename: str,
|
|
||||||
format_name: str,
|
|
||||||
directory: str | None,
|
|
||||||
directory_path: str = "",
|
|
||||||
) -> Path:
|
|
||||||
ext_map = {
|
ext_map = {
|
||||||
"PNG": ".png",
|
"PNG": ".png",
|
||||||
"TIFF": ".tiff",
|
"TIFF": ".tiff",
|
||||||
@@ -129,25 +119,16 @@ class Save:
|
|||||||
ext = ext_map[format_name]
|
ext = ext_map[format_name]
|
||||||
|
|
||||||
raw_filename = str(filename).strip() if filename is not None else ""
|
raw_filename = str(filename).strip() if filename is not None else ""
|
||||||
raw_directory = str(directory).strip() if directory is not None else ""
|
|
||||||
if not raw_directory:
|
|
||||||
raw_directory = str(directory_path).strip() if directory_path is not None else ""
|
|
||||||
|
|
||||||
if not raw_filename:
|
if not raw_filename:
|
||||||
raise ValueError("No output filename selected — enter a file name.")
|
raise ValueError("No output filename selected — enter a file name.")
|
||||||
|
|
||||||
if raw_directory:
|
candidate = Path(raw_filename).expanduser()
|
||||||
dir_path = Path(raw_directory).expanduser()
|
if candidate.is_absolute():
|
||||||
if dir_path.exists() and not dir_path.is_dir():
|
candidate.parent.mkdir(parents=True, exist_ok=True)
|
||||||
raise ValueError("Directory input expects a folder path, not a file path.")
|
path = candidate
|
||||||
if not dir_path.exists():
|
|
||||||
if dir_path.suffix:
|
|
||||||
raise ValueError("Directory input expects a folder path, not a file path.")
|
|
||||||
dir_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
path = dir_path / Path(raw_filename).name
|
|
||||||
else:
|
else:
|
||||||
path = Path(raw_filename).expanduser()
|
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path = DOWNLOAD_DIR / candidate.name
|
||||||
|
|
||||||
if path.suffix.lower() != ext:
|
if path.suffix.lower() != ext:
|
||||||
path = path.with_suffix(ext)
|
path = path.with_suffix(ext)
|
||||||
@@ -156,7 +137,7 @@ class Save:
|
|||||||
def _save_datafield(self, path: Path, field: DataField, format_name: str):
|
def _save_datafield(self, path: Path, field: DataField, format_name: str):
|
||||||
if format_name == "TIFF":
|
if format_name == "TIFF":
|
||||||
import tifffile
|
import tifffile
|
||||||
tifffile.imwrite(str(path), np.asarray(field.data, dtype=np.float32))
|
tifffile.imwrite(str(path), datafield_to_uint8(field, field.colormap))
|
||||||
return
|
return
|
||||||
if format_name == "NPZ":
|
if format_name == "NPZ":
|
||||||
np.savez(str(path), field=np.asarray(field.data))
|
np.savez(str(path), field=np.asarray(field.data))
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import numpy as np
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
from backend.execution_context import emit_warning
|
from backend.execution_context import emit_warning, emit_file_download
|
||||||
from backend.data_types import DataField, image_to_uint8
|
from backend.data_types import DataField, image_to_uint8
|
||||||
from backend.nodes.helpers import _MAX_SAVE_FIELDS
|
from backend.nodes.helpers import _MAX_SAVE_FIELDS
|
||||||
|
|
||||||
@@ -35,9 +35,10 @@ class SaveImage:
|
|||||||
"placeholder": "filename",
|
"placeholder": "filename",
|
||||||
"placement": "top",
|
"placement": "top",
|
||||||
}),
|
}),
|
||||||
"directory_path": ("FOLDER_PICKER", {
|
"directory_path": ("STRING", {
|
||||||
"default": "",
|
"default": "",
|
||||||
"label": "directory",
|
"label": "directory",
|
||||||
|
"placeholder": "directory (optional, desktop only)",
|
||||||
"placement": "top",
|
"placement": "top",
|
||||||
"hide_when_input_connected": "directory",
|
"hide_when_input_connected": "directory",
|
||||||
"top_socket_input": "directory",
|
"top_socket_input": "directory",
|
||||||
@@ -92,6 +93,7 @@ class SaveImage:
|
|||||||
self._save_npz(path, layers, layer_names)
|
self._save_npz(path, layers, layer_names)
|
||||||
|
|
||||||
self._send_warning(f"Saved {len(layers)} layer(s) to {path.name}")
|
self._send_warning(f"Saved {len(layers)} layer(s) to {path.name}")
|
||||||
|
emit_file_download(str(path))
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
def _save_tiff(self, path: Path, layers: list[DataField | np.ndarray], layer_names: list[str]):
|
def _save_tiff(self, path: Path, layers: list[DataField | np.ndarray], layer_names: list[str]):
|
||||||
@@ -140,9 +142,15 @@ class SaveImage:
|
|||||||
path = dir_path / filename_part
|
path = dir_path / filename_part
|
||||||
else:
|
else:
|
||||||
if not raw_filename:
|
if not raw_filename:
|
||||||
raise ValueError("No output path selected — use Browse to pick a location.")
|
raise ValueError("No output filename selected — enter a file name.")
|
||||||
path = Path(raw_filename).expanduser()
|
candidate = Path(raw_filename).expanduser()
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
if candidate.is_absolute():
|
||||||
|
candidate.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = candidate
|
||||||
|
else:
|
||||||
|
from backend.nodes.save import DOWNLOAD_DIR
|
||||||
|
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = DOWNLOAD_DIR / candidate.name
|
||||||
|
|
||||||
if path.suffix.lower() != ext:
|
if path.suffix.lower() != ext:
|
||||||
path = path.with_suffix(ext)
|
path = path.with_suffix(ext)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
@@ -139,6 +140,7 @@ def create_app(
|
|||||||
|
|
||||||
session_engines: dict[str, ExecutionEngine] = {}
|
session_engines: dict[str, ExecutionEngine] = {}
|
||||||
session_websockets: dict[str, set[web.WebSocketResponse]] = defaultdict(set)
|
session_websockets: dict[str, set[web.WebSocketResponse]] = defaultdict(set)
|
||||||
|
pending_downloads: dict[str, Path] = {}
|
||||||
|
|
||||||
def _is_link(value) -> bool:
|
def _is_link(value) -> bool:
|
||||||
return (
|
return (
|
||||||
@@ -254,6 +256,12 @@ def create_app(
|
|||||||
def on_warning(session_id: str, node_id: str, message: str) -> None:
|
def on_warning(session_id: str, node_id: str, message: str) -> None:
|
||||||
broadcast(session_id, {"type": "node_warning", "data": {"node_id": node_id, "message": message}})
|
broadcast(session_id, {"type": "node_warning", "data": {"node_id": node_id, "message": message}})
|
||||||
|
|
||||||
|
def on_file_download(session_id: str, node_id: str, file_path: str) -> None:
|
||||||
|
token = secrets.token_urlsafe(16)
|
||||||
|
path = Path(file_path)
|
||||||
|
pending_downloads[token] = path
|
||||||
|
broadcast(session_id, {"type": "file_download", "data": {"node_id": node_id, "token": token, "filename": path.name}})
|
||||||
|
|
||||||
async def index(request: web.Request) -> web.Response:
|
async def index(request: web.Request) -> web.Response:
|
||||||
if not getattr(sys, "frozen", False):
|
if not getattr(sys, "frozen", False):
|
||||||
try:
|
try:
|
||||||
@@ -470,6 +478,16 @@ def create_app(
|
|||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def download_saved_file(request: web.Request) -> web.Response:
|
||||||
|
token = request.match_info["token"]
|
||||||
|
path = pending_downloads.pop(token, None)
|
||||||
|
if path is None or not path.is_file():
|
||||||
|
raise web.HTTPNotFound(reason="File not found")
|
||||||
|
return web.FileResponse(
|
||||||
|
path,
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{path.name}"'},
|
||||||
|
)
|
||||||
|
|
||||||
async def save_workflow_png(request: web.Request) -> web.Response:
|
async def save_workflow_png(request: web.Request) -> web.Response:
|
||||||
body = await request.read()
|
body = await request.read()
|
||||||
target_path = request.query.get("path", "")
|
target_path = request.query.get("path", "")
|
||||||
@@ -535,6 +553,7 @@ def create_app(
|
|||||||
on_overlay=lambda node_id, overlay_data: on_overlay(session_id, node_id, overlay_data),
|
on_overlay=lambda node_id, overlay_data: on_overlay(session_id, node_id, overlay_data),
|
||||||
on_value=lambda node_id, payload: on_value(session_id, node_id, payload),
|
on_value=lambda node_id, payload: on_value(session_id, node_id, payload),
|
||||||
on_warning=lambda node_id, message: on_warning(session_id, node_id, message),
|
on_warning=lambda node_id, message: on_warning(session_id, node_id, message),
|
||||||
|
on_file_download=lambda node_id, file_path: on_file_download(session_id, node_id, file_path),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
broadcast(session_id, {"type": "execution_complete", "data": {"prompt_id": prompt_id}})
|
broadcast(session_id, {"type": "execution_complete", "data": {"prompt_id": prompt_id}})
|
||||||
@@ -627,6 +646,7 @@ def create_app(
|
|||||||
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)
|
||||||
|
app.router.add_get("/download-save/{token}", download_saved_file)
|
||||||
app.router.add_get("/check-update", check_update)
|
app.router.add_get("/check-update", check_update)
|
||||||
app.router.add_get("/ws", websocket_handler)
|
app.router.add_get("/ws", websocket_handler)
|
||||||
|
|
||||||
|
|||||||
@@ -906,6 +906,7 @@ function Flow() {
|
|||||||
const nextIdRef = useRef(1);
|
const nextIdRef = useRef(1);
|
||||||
const autoRunTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autoRunTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const autoRunRef = useRef<(() => void) | null>(null);
|
const autoRunRef = useRef<(() => void) | null>(null);
|
||||||
|
const pendingBrowserFilesRef = useRef<Map<string, File>>(new Map());
|
||||||
const defaultWorkflowLoadAttemptedRef = useRef(false);
|
const defaultWorkflowLoadAttemptedRef = useRef(false);
|
||||||
const lastPastedClipboardTextRef = useRef('');
|
const lastPastedClipboardTextRef = useRef('');
|
||||||
const pasteRepeatCountRef = useRef(0);
|
const pasteRepeatCountRef = useRef(0);
|
||||||
@@ -1305,7 +1306,34 @@ function Flow() {
|
|||||||
}, [getResolvedPathInput, reactFlow, setNodeOutputs]);
|
}, [getResolvedPathInput, reactFlow, setNodeOutputs]);
|
||||||
|
|
||||||
const refreshFolderNodeOutputs = useCallback(async (nodeId: string, folderPath: any) => {
|
const refreshFolderNodeOutputs = useCallback(async (nodeId: string, folderPath: any) => {
|
||||||
const entries = folderPath ? await api.getFolderFiles(folderPath) : [];
|
let entries: any[] = [];
|
||||||
|
|
||||||
|
if (folderPath) {
|
||||||
|
// Check for pending browser files first (folder was picked but files not yet uploaded)
|
||||||
|
const prefix = String(folderPath).endsWith('/') ? String(folderPath) : String(folderPath) + '/';
|
||||||
|
const pendingEntries: any[] = [];
|
||||||
|
for (const uri of pendingBrowserFilesRef.current.keys()) {
|
||||||
|
if (uri.startsWith(prefix)) {
|
||||||
|
const name = uri.slice(prefix.length);
|
||||||
|
// Skip files in subdirectories for the top-level listing
|
||||||
|
if (!name.includes('/')) {
|
||||||
|
pendingEntries.push({ name, type: 'FILE_PATH', path: uri });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingEntries.length > 0) {
|
||||||
|
// Build listing locally from pending files
|
||||||
|
entries = [
|
||||||
|
{ name: 'directory', type: 'DIRECTORY', path: folderPath },
|
||||||
|
...pendingEntries.sort((a: any, b: any) => a.name.localeCompare(b.name)),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Fall back to server (native builds, or files already uploaded)
|
||||||
|
entries = await api.getFolderFiles(folderPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setNodeOutputs(
|
setNodeOutputs(
|
||||||
nodeId,
|
nodeId,
|
||||||
entries.map((entry: any) => entry.type),
|
entries.map((entry: any) => entry.type),
|
||||||
@@ -1407,6 +1435,24 @@ function Flow() {
|
|||||||
case 'node_warning':
|
case 'node_warning':
|
||||||
updateNodeData(msg.data.node_id, { warning: msg.data.message });
|
updateNodeData(msg.data.node_id, { warning: msg.data.message });
|
||||||
break;
|
break;
|
||||||
|
case 'file_download': {
|
||||||
|
const dlToken = msg.data.token;
|
||||||
|
const dlFilename = msg.data.filename || 'download';
|
||||||
|
fetch(`/download-save/${encodeURIComponent(dlToken)}`)
|
||||||
|
.then((r) => r.ok ? r.blob() : Promise.reject(new Error(`Download failed: ${r.status}`)))
|
||||||
|
.then((blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = dlFilename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
})
|
||||||
|
.catch((err) => setStatus({ text: String(err.message), level: 'error' }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'nodes_updated':
|
case 'nodes_updated':
|
||||||
api.getNodes().then((defs) => {
|
api.getNodes().then((defs) => {
|
||||||
nodeDefsRef.current = defs;
|
nodeDefsRef.current = defs;
|
||||||
@@ -1675,16 +1721,21 @@ function Flow() {
|
|||||||
throw new Error('Selected folder is empty or could not be read.');
|
throw new Error('Selected folder is empty or could not be read.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const folder = await api.createUploadFolder(rootName);
|
||||||
|
const folderUri = folder.path; // e.g. "session://uploads/myfolder"
|
||||||
|
|
||||||
|
// Store File objects for lazy upload — only uploaded when actually used
|
||||||
|
for (const entry of selection.entries || []) {
|
||||||
|
const fileUri = `session://uploads/${entry.relativePath}`;
|
||||||
|
pendingBrowserFilesRef.current.set(fileUri, entry.file);
|
||||||
|
}
|
||||||
|
|
||||||
setStatus({
|
setStatus({
|
||||||
text: `Importing folder "${rootName}" into this session…`,
|
text: `Folder "${rootName}" loaded (${(selection.entries || []).length} files).`,
|
||||||
level: 'info',
|
level: 'info',
|
||||||
});
|
});
|
||||||
|
|
||||||
const folder = await api.createUploadFolder(rootName);
|
return folderUri;
|
||||||
for (const entry of selection.entries || []) {
|
|
||||||
await api.uploadFile(entry.file, { relativePath: entry.relativePath });
|
|
||||||
}
|
|
||||||
return folder.path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [entry] = selection.entries || [];
|
const [entry] = selection.entries || [];
|
||||||
@@ -1729,19 +1780,63 @@ function Flow() {
|
|||||||
}
|
}
|
||||||
}, [uploadBrowserSelection]);
|
}, [uploadBrowserSelection]);
|
||||||
|
|
||||||
|
// ── Lazy upload of pending browser files ─────────────────────────────
|
||||||
|
|
||||||
|
const uploadPendingFiles = useCallback(async (prompt: Record<string, any>) => {
|
||||||
|
const pending = pendingBrowserFilesRef.current;
|
||||||
|
if (pending.size === 0) return;
|
||||||
|
|
||||||
|
// Collect all string values from the prompt (folder paths and file paths)
|
||||||
|
const promptValues = new Set<string>();
|
||||||
|
for (const nodeData of Object.values(prompt)) {
|
||||||
|
const inputs = (nodeData as any)?.inputs;
|
||||||
|
if (!inputs || typeof inputs !== 'object') continue;
|
||||||
|
for (const val of Object.values(inputs)) {
|
||||||
|
if (typeof val === 'string') promptValues.add(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload files that are directly referenced OR inside a referenced folder
|
||||||
|
const toUpload = new Set<string>();
|
||||||
|
for (const uri of pending.keys()) {
|
||||||
|
if (promptValues.has(uri)) {
|
||||||
|
toUpload.add(uri);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Check if any prompt value is a folder prefix of this file
|
||||||
|
for (const pv of promptValues) {
|
||||||
|
const prefix = pv.endsWith('/') ? pv : pv + '/';
|
||||||
|
if (uri.startsWith(prefix)) {
|
||||||
|
toUpload.add(uri);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const uri of toUpload) {
|
||||||
|
const file = pending.get(uri)!;
|
||||||
|
const relativePath = uri.replace(/^session:\/\/uploads\//, '');
|
||||||
|
await api.uploadFile(file, { relativePath });
|
||||||
|
pending.delete(uri);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ── Node context value (stable) ─────────────────────────────────────
|
// ── Node context value (stable) ─────────────────────────────────────
|
||||||
|
|
||||||
const onManualTrigger = useCallback((nodeId: string) => {
|
const onManualTrigger = useCallback(async (nodeId: string) => {
|
||||||
const currentNodes = (reactFlow.getNodes() as TonoNode[]);
|
const currentNodes = (reactFlow.getNodes() as TonoNode[]);
|
||||||
const currentEdges = (reactFlow.getEdges() as TonoEdge[]);
|
const currentEdges = (reactFlow.getEdges() as TonoEdge[]);
|
||||||
// Include ALL nodes (no excludeManualTrigger) so the save node is in the prompt
|
// Include ALL nodes (no excludeManualTrigger) so the save node is in the prompt
|
||||||
const prompt = serializeExecutionGraph(currentNodes, currentEdges);
|
const prompt = serializeExecutionGraph(currentNodes, currentEdges);
|
||||||
if (!prompt || Object.keys(prompt).length === 0) return;
|
if (!prompt || Object.keys(prompt).length === 0) return;
|
||||||
setStatus({ text: 'Saving…', level: 'info' });
|
setStatus({ text: 'Saving…', level: 'info' });
|
||||||
api.runPrompt(prompt).catch((err) => {
|
try {
|
||||||
|
await uploadPendingFiles(prompt);
|
||||||
|
await api.runPrompt(prompt);
|
||||||
|
} catch (err: any) {
|
||||||
setStatus({ text: 'Save failed: ' + err.message, level: 'error' });
|
setStatus({ text: 'Save failed: ' + err.message, level: 'error' });
|
||||||
});
|
}
|
||||||
}, [reactFlow]);
|
}, [reactFlow, uploadPendingFiles]);
|
||||||
|
|
||||||
const openJournalTab = useCallback(() => {
|
const openJournalTab = useCallback(() => {
|
||||||
setHelpTabs((prev) => {
|
setHelpTabs((prev) => {
|
||||||
@@ -1874,11 +1969,12 @@ function Flow() {
|
|||||||
}
|
}
|
||||||
setStatus({ text: 'Running…', level: 'info' });
|
setStatus({ text: 'Running…', level: 'info' });
|
||||||
try {
|
try {
|
||||||
|
await uploadPendingFiles(prompt);
|
||||||
await api.runPrompt(prompt);
|
await api.runPrompt(prompt);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setStatus({ text: 'Failed: ' + err.message, level: 'error' });
|
setStatus({ text: 'Failed: ' + err.message, level: 'error' });
|
||||||
}
|
}
|
||||||
}, [reactFlow]);
|
}, [reactFlow, uploadPendingFiles]);
|
||||||
|
|
||||||
// Debounced auto-run via ref to avoid dependency chains
|
// Debounced auto-run via ref to avoid dependency chains
|
||||||
autoRunRef.current = () => {
|
autoRunRef.current = () => {
|
||||||
@@ -1895,7 +1991,7 @@ function Flow() {
|
|||||||
const prompt = serializeExecutionGraph(currentNodes, currentEdges, { excludeManualTrigger: true });
|
const prompt = serializeExecutionGraph(currentNodes, currentEdges, { excludeManualTrigger: true });
|
||||||
if (!prompt || Object.keys(prompt).length === 0) return;
|
if (!prompt || Object.keys(prompt).length === 0) return;
|
||||||
setStatus({ text: 'Running…', level: 'info' });
|
setStatus({ text: 'Running…', level: 'info' });
|
||||||
api.runPrompt(prompt).catch((err) => {
|
uploadPendingFiles(prompt).then(() => api.runPrompt(prompt)).catch((err) => {
|
||||||
setStatus({ text: 'Failed: ' + err.message, level: 'error' });
|
setStatus({ text: 'Failed: ' + err.message, level: 'error' });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
const FILE_ACCEPT = [
|
const SUPPORTED_EXTENSIONS = new Set([
|
||||||
'.png', '.jpg', '.jpeg', '.tiff', '.tif', '.bmp',
|
'.png', '.jpg', '.jpeg', '.tiff', '.tif', '.bmp',
|
||||||
'.npy', '.npz',
|
'.npy', '.npz',
|
||||||
'.gwy', '.sxm', '.ibw',
|
'.gwy', '.sxm', '.ibw',
|
||||||
|
'.h5', '.hdf5',
|
||||||
'.ttf', '.otf', '.woff', '.woff2',
|
'.ttf', '.otf', '.woff', '.woff2',
|
||||||
].join(',');
|
]);
|
||||||
|
|
||||||
|
const FILE_ACCEPT = [...SUPPORTED_EXTENSIONS].join(',');
|
||||||
|
|
||||||
|
function hasSupportedExtension(name: string): boolean {
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
if (dot < 0) return false;
|
||||||
|
return SUPPORTED_EXTENSIONS.has(name.slice(dot).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRelativePath(path: string) {
|
function normalizeRelativePath(path: string) {
|
||||||
return String(path || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
return String(path || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
||||||
@@ -48,6 +57,7 @@ async function collectDirectoryEntries(handle: FileSystemDirectoryHandle, prefix
|
|||||||
for await (const [name, child] of (handle as any).entries()) {
|
for await (const [name, child] of (handle as any).entries()) {
|
||||||
const relativePath = prefix ? `${prefix}/${name}` : name;
|
const relativePath = prefix ? `${prefix}/${name}` : name;
|
||||||
if (child.kind === 'file') {
|
if (child.kind === 'file') {
|
||||||
|
if (!hasSupportedExtension(name)) continue;
|
||||||
const file = await child.getFile();
|
const file = await child.getFile();
|
||||||
entries.push({ file, relativePath: normalizeRelativePath(relativePath) });
|
entries.push({ file, relativePath: normalizeRelativePath(relativePath) });
|
||||||
continue;
|
continue;
|
||||||
@@ -108,7 +118,9 @@ export async function pickNativeDirectorySelection() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = await pickWithInput({ directory: true });
|
const allFiles = await pickWithInput({ directory: true });
|
||||||
|
if (allFiles.length === 0) return null;
|
||||||
|
const files = allFiles.filter((f) => hasSupportedExtension(f.name));
|
||||||
if (files.length === 0) return null;
|
if (files.length === 0) return null;
|
||||||
const entries = files.map((file: File) => ({
|
const entries = files.map((file: File) => ({
|
||||||
file,
|
file,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default defineConfig({
|
|||||||
'/channels': 'http://127.0.0.1:8188',
|
'/channels': 'http://127.0.0.1:8188',
|
||||||
'/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-save': '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',
|
'/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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user