fix folder and file save

This commit is contained in:
2026-04-02 00:03:44 -07:00
parent df97b25985
commit 1f9b05cd4b
8 changed files with 183 additions and 56 deletions

View File

@@ -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]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

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