rework web server so multiple clients can be server at a time

This commit is contained in:
matei jordache
2026-03-27 16:18:22 -07:00
parent 1eda4030d1
commit 558046e7aa
33 changed files with 1042 additions and 551 deletions

View File

@@ -4,13 +4,13 @@ import React, {
import {
ReactFlow, Background, Controls, MiniMap,
useNodesState, useEdgesState, addEdge, useReactFlow,
ReactFlowProvider, getViewportForBounds,
ReactFlowProvider, getViewportForBounds, PanOnScrollMode, SelectionMode,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import CustomNode, { NodeContext } from './CustomNode';
import FileBrowser from './FileBrowser';
import * as api from './api';
import { pickNativeDirectorySelection, pickNativeFileSelection } from './nativePicker';
import { toBlob } from 'html-to-image';
import { embedWorkflow, extractWorkflow } from './pngMetadata';
import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture';
@@ -791,7 +791,6 @@ function Flow() {
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' });
const [contextMenu, setContextMenu] = useState(null);
const [fileBrowserState, setFileBrowserState] = useState(null);
const nodeDefsRef = useRef({});
const nextIdRef = useRef(1);
@@ -1481,22 +1480,68 @@ function Flow() {
// ── File browser ────────────────────────────────────────────────────
const openFileBrowser = useCallback((callback, { selectionMode = 'file' } = {}) => {
const uploadBrowserSelection = useCallback(async (selection, selectionMode) => {
if (!selection) return null;
if (selectionMode === 'folder') {
const rootName = String(selection.rootName || '').trim();
if (!rootName) {
throw new Error('Selected folder is empty or could not be read.');
}
setStatus({
text: `Importing folder "${rootName}" into this session…`,
level: 'info',
});
const folder = await api.createUploadFolder(rootName);
for (const entry of selection.entries || []) {
await api.uploadFile(entry.file, { relativePath: entry.relativePath });
}
return folder.path;
}
const [entry] = selection.entries || [];
if (!entry) return null;
setStatus({
text: `Uploading ${entry.file.name}`,
level: 'info',
});
const uploaded = await api.uploadFile(entry.file, { relativePath: entry.relativePath });
return uploaded.path;
}, []);
const openFileBrowser = useCallback(async (callback, { selectionMode = 'file' } = {}) => {
if (selectionMode === 'folder' && window.pywebview?.api?.open_folder_dialog) {
window.pywebview.api.open_folder_dialog().then((path) => {
if (path) callback(path);
});
return;
}
// Use native file picker when running inside pywebview (desktop app)
if (selectionMode === 'file' && window.pywebview?.api?.open_file_dialog) {
window.pywebview.api.open_file_dialog().then((path) => {
if (path) callback(path);
});
return;
}
setFileBrowserState({ callback, selectionMode });
}, []);
try {
const selection = selectionMode === 'folder'
? await pickNativeDirectorySelection()
: await pickNativeFileSelection();
if (!selection) return;
const uploadedPath = await uploadBrowserSelection(selection, selectionMode);
if (uploadedPath) callback(uploadedPath);
} catch (error) {
setStatus({
text: `Browse failed: ${error.message || String(error)}`,
level: 'error',
});
}
}, [uploadBrowserSelection]);
// ── Node context value (stable) ─────────────────────────────────────
@@ -1782,6 +1827,21 @@ function Flow() {
setTimeout(() => reactFlow.updateNodeInternals(String(groupId)), 0);
}, [reactFlow, setNodes]);
const renameGroup = useCallback((groupId, label) => {
const nextLabel = String(label || '').trim() || 'group';
setNodes((existing) => existing.map((node) => {
if (String(node.id) !== String(groupId) || node.data?.className !== 'Group') return node;
if (String(node.data?.label || 'group') === nextLabel) return node;
return {
...node,
data: {
...node.data,
label: nextLabel,
},
};
}));
}, [setNodes]);
const contextValue = useMemo(() => ({
onWidgetChange,
onRuntimeValuesChange,
@@ -1789,8 +1849,9 @@ function Flow() {
onManualTrigger,
onToggleGroupCollapse: toggleGroupCollapse,
onResizeGroup: resizeGroup,
onRenameGroup: renameGroup,
onUngroup: ungroupGroup,
}), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, resizeGroup, toggleGroupCollapse, ungroupGroup]);
}), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, renameGroup, resizeGroup, toggleGroupCollapse, ungroupGroup]);
const clearGraph = useCallback(() => {
setNodes([]);
@@ -2602,6 +2663,12 @@ function Flow() {
nodeTypes={NODE_TYPES}
onPaneContextMenu={onPaneContextMenu}
colorMode="dark"
panOnDrag={[1]}
panOnScroll
panOnScrollMode={PanOnScrollMode.Free}
zoomOnScroll={false}
selectionOnDrag
selectionMode={SelectionMode.Partial}
multiSelectionKeyCode={['Shift']}
deleteKeyCode={['Backspace', 'Delete']}
defaultEdgeOptions={{ type: 'default' }}
@@ -2631,14 +2698,6 @@ function Flow() {
)}
</div>
{/* File browser modal */}
{fileBrowserState && (
<FileBrowser
selectionMode={fileBrowserState.selectionMode}
onSelect={(path) => { fileBrowserState.callback(path); setFileBrowserState(null); }}
onClose={() => setFileBrowserState(null)}
/>
)}
</div>
</NodeContext.Provider>
);

View File

@@ -45,6 +45,9 @@ function GroupNode({ id, data }) {
const childCount = Number(data.childCount) || 0;
const collapsed = !!data.collapsed;
const maxRows = Math.max(proxyInputs.length, proxyOutputs.length, collapsed ? 1 : 0);
const [isEditingLabel, setIsEditingLabel] = useState(false);
const [draftLabel, setDraftLabel] = useState(String(data.label || 'group'));
const labelInputRef = useRef(null);
const selected = useStore(
useCallback(
(s) => {
@@ -62,6 +65,33 @@ function GroupNode({ id, data }) {
[id],
),
);
const displayLabel = String(data.label || 'group');
useEffect(() => {
if (!isEditingLabel) {
setDraftLabel(displayLabel);
}
}, [displayLabel, isEditingLabel]);
useEffect(() => {
if (!isEditingLabel) return;
labelInputRef.current?.focus();
labelInputRef.current?.select();
}, [isEditingLabel]);
const commitLabel = useCallback(() => {
const nextLabel = String(draftLabel || '').trim() || 'group';
setIsEditingLabel(false);
setDraftLabel(nextLabel);
if (nextLabel !== displayLabel) {
ctx.onRenameGroup?.(id, nextLabel);
}
}, [ctx, displayLabel, draftLabel, id]);
const cancelLabelEdit = useCallback(() => {
setDraftLabel(displayLabel);
setIsEditingLabel(false);
}, [displayLabel]);
return (
<>
@@ -84,7 +114,40 @@ function GroupNode({ id, data }) {
>
{collapsed ? '▸' : '▾'}
</button>
<span className="node-title-main">{formatUiLabel(data.label || 'group')}</span>
{isEditingLabel ? (
<input
ref={labelInputRef}
className="group-title-input nodrag"
type="text"
value={draftLabel}
onChange={(event) => setDraftLabel(event.target.value)}
onBlur={commitLabel}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commitLabel();
} else if (event.key === 'Escape') {
event.preventDefault();
cancelLabelEdit();
}
}}
/>
) : (
<button
type="button"
className="group-title-button nodrag"
title="rename group"
onClick={(event) => {
event.stopPropagation();
setDraftLabel(displayLabel);
setIsEditingLabel(true);
}}
>
{displayLabel}
</button>
)}
<div className="group-node-actions">
<button
type="button"

View File

@@ -1,103 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import * as api from './api';
/**
* Server-side file browser modal.
*
* Props:
* onSelect(absolutePath) — called when user picks a file or folder
* onClose() — called when user dismisses the dialog
*/
export default function FileBrowser({ onSelect, onClose, selectionMode = 'file' }) {
const [path, setPath] = useState('');
const [parent, setParent] = useState(null);
const [dirs, setDirs] = useState([]);
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const navigate = useCallback(async (dir) => {
setLoading(true);
setError(null);
try {
const data = await api.browse(dir);
setPath(data.path);
setParent(data.parent);
setDirs(data.dirs);
setFiles(data.files);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
// Start at home directory on mount
useEffect(() => {
navigate(null);
}, [navigate]);
return (
<div className="fb-backdrop" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="fb-dialog">
{/* Header */}
<div className="fb-header">
<span className="fb-path">{path}</span>
{selectionMode === 'folder' && (
<button className="fb-select-btn" onClick={() => { onSelect(path); onClose(); }}>
Select Folder
</button>
)}
<button className="fb-close" onClick={onClose}></button>
</div>
{/* File list */}
<div className="fb-list">
{loading && <div className="fb-loading">Loading</div>}
{error && <div className="fb-loading">Error: {error}</div>}
{!loading && !error && (
<>
{/* Parent directory */}
{parent && (
<div className="fb-entry fb-dir" onClick={() => navigate(parent)}>
..
</div>
)}
{/* Directories */}
{dirs.map((d) => (
<div
key={d}
className="fb-entry fb-dir"
onClick={() => navigate(path + '/' + d)}
>
📁 {d}
</div>
))}
{/* Files */}
{files.map((f) => (
<div
key={f}
className={`fb-entry fb-file${selectionMode === 'folder' ? ' fb-file-disabled' : ''}`}
onClick={() => {
if (selectionMode === 'folder') return;
onSelect(path + '/' + f);
onClose();
}}
>
{f}
</div>
))}
{dirs.length === 0 && files.length === 0 && (
<div className="fb-loading">Empty directory</div>
)}
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -5,49 +5,105 @@
* and production same-origin serving both work transparently.
*/
// ── REST helpers ──────────────────────────────────────────────────────
const SESSION_STORAGE_KEY = 'argonode-session-id';
let _sessionId = null;
let _ws = null;
let _handler = null;
let _reconnectTimer = null;
function generateSessionId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `session-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
}
export function getSessionId() {
if (_sessionId) return _sessionId;
if (typeof window === 'undefined') {
_sessionId = 'session-test-runner';
return _sessionId;
}
try {
const stored = window.sessionStorage?.getItem(SESSION_STORAGE_KEY);
if (stored) {
_sessionId = stored;
return _sessionId;
}
} catch {
// Fall through to in-memory session id generation.
}
_sessionId = generateSessionId();
try {
window.sessionStorage?.setItem(SESSION_STORAGE_KEY, _sessionId);
} catch {
// Ignore storage failures and keep the in-memory id.
}
return _sessionId;
}
function withSessionHeaders(init = {}) {
const headers = new Headers(init.headers || {});
headers.set('X-Argonode-Session', getSessionId());
return { ...init, headers };
}
async function sessionFetch(input, init) {
return fetch(input, withSessionHeaders(init));
}
export async function getNodes() {
const r = await fetch('/nodes');
const r = await sessionFetch('/nodes');
if (!r.ok) throw new Error(`GET /nodes failed: ${r.status}`);
return r.json();
}
export async function getFiles() {
const r = await fetch('/files');
const r = await sessionFetch('/files');
if (!r.ok) return [];
return r.json();
}
export async function browse(dir) {
const url = dir ? `/browse?dir=${encodeURIComponent(dir)}` : '/browse';
const r = await fetch(url);
if (!r.ok) throw new Error(`Browse failed: ${r.status}`);
export async function createUploadFolder(relativePath) {
const r = await sessionFetch('/upload-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: relativePath }),
});
if (!r.ok) throw new Error(`Create folder failed: ${r.status}`);
return r.json();
}
export async function uploadFile(file) {
export async function uploadFile(file, { relativePath = '' } = {}) {
const fd = new FormData();
if (relativePath) fd.append('relative_path', relativePath);
fd.append('file', file);
const r = await fetch('/upload', { method: 'POST', body: fd });
if (!r.ok) throw new Error(`Upload failed: ${r.status}`);
const r = await sessionFetch('/upload', { method: 'POST', body: fd });
if (!r.ok) {
const text = await r.text();
throw new Error(`Upload failed (${r.status}): ${text}`);
}
return r.json();
}
export async function getChannels(filepath) {
const r = await fetch(`/channels?file=${encodeURIComponent(filepath)}`);
const r = await sessionFetch(`/channels?file=${encodeURIComponent(filepath)}`);
if (!r.ok) return [{ name: 'field', type: 'DATA_FIELD' }];
return r.json();
}
export async function getFolderFiles(folderpath) {
const r = await fetch(`/folder-files?folder=${encodeURIComponent(folderpath)}`);
const r = await sessionFetch(`/folder-files?folder=${encodeURIComponent(folderpath)}`);
if (!r.ok) return [];
return r.json();
}
export async function runPrompt(prompt) {
const r = await fetch('/prompt', {
const r = await sessionFetch('/prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
@@ -59,21 +115,16 @@ export async function runPrompt(prompt) {
return r.json();
}
// ── WebSocket ─────────────────────────────────────────────────────────
let _ws = null;
let _handler = null;
let _reconnectTimer = null;
export function setMessageHandler(fn) {
_handler = fn;
}
export function initWS() {
if (_ws && _ws.readyState < 2) return; // already open or connecting
if (_ws && _ws.readyState < 2) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
_ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
const session = encodeURIComponent(getSessionId());
_ws = new WebSocket(`${protocol}//${window.location.host}/ws?session=${session}`);
_ws.onopen = () => {
console.log('[argonode] WebSocket connected');

View File

@@ -0,0 +1,118 @@
const FILE_ACCEPT = [
'.png', '.jpg', '.jpeg', '.tiff', '.tif', '.bmp',
'.npy', '.npz',
'.gwy', '.sxm', '.ibw',
'.ttf', '.otf', '.woff', '.woff2',
].join(',');
function normalizeRelativePath(path) {
return String(path || '').replace(/\\/g, '/').replace(/^\/+/, '');
}
function pickWithInput({ directory = false } = {}) {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.style.position = 'fixed';
input.style.left = '-9999px';
if (directory) {
input.multiple = true;
input.setAttribute('webkitdirectory', '');
input.setAttribute('directory', '');
} else {
input.accept = FILE_ACCEPT;
}
const cleanup = () => {
input.remove();
};
input.addEventListener('change', () => {
const files = Array.from(input.files || []);
cleanup();
resolve(files);
}, { once: true });
document.body.appendChild(input);
input.click();
});
}
async function collectDirectoryEntries(handle, prefix = handle.name) {
const entries = [];
for await (const [name, child] of handle.entries()) {
const relativePath = prefix ? `${prefix}/${name}` : name;
if (child.kind === 'file') {
const file = await child.getFile();
entries.push({ file, relativePath: normalizeRelativePath(relativePath) });
continue;
}
if (child.kind === 'directory') {
entries.push(...await collectDirectoryEntries(child, relativePath));
}
}
return entries;
}
export async function pickNativeFileSelection() {
try {
if (typeof window.showOpenFilePicker === 'function') {
const [handle] = await window.showOpenFilePicker({
multiple: false,
types: [{
description: 'Supported files',
accept: {
'application/octet-stream': ['.npy', '.npz', '.gwy', '.sxm', '.ibw', '.ttf', '.otf', '.woff', '.woff2'],
'image/*': ['.png', '.jpg', '.jpeg', '.bmp', '.tif', '.tiff'],
},
}],
});
if (!handle) return null;
const file = await handle.getFile();
return {
rootName: file.name,
entries: [{ file, relativePath: normalizeRelativePath(file.name) }],
};
}
} catch (error) {
if (error?.name !== 'AbortError') throw error;
return null;
}
const files = await pickWithInput({ directory: false });
if (files.length === 0) return null;
return {
rootName: files[0].name,
entries: [{ file: files[0], relativePath: normalizeRelativePath(files[0].name) }],
};
}
export async function pickNativeDirectorySelection() {
try {
if (typeof window.showDirectoryPicker === 'function') {
const handle = await window.showDirectoryPicker();
if (!handle) return null;
const entries = await collectDirectoryEntries(handle, handle.name);
return {
rootName: handle.name,
entries,
};
}
} catch (error) {
if (error?.name !== 'AbortError') throw error;
return null;
}
const files = await pickWithInput({ directory: true });
if (files.length === 0) return null;
const entries = files.map((file) => ({
file,
relativePath: normalizeRelativePath(file.webkitRelativePath || file.name),
}));
const rootName = entries[0]?.relativePath.split('/')[0] || '';
if (!rootName) return null;
return {
rootName,
entries,
};
}

View File

@@ -259,6 +259,34 @@ html, body, #root {
flex: 1;
}
.group-title-button {
flex: 1;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--text-heading);
font: inherit;
font-weight: inherit;
text-align: left;
cursor: text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-title-input {
flex: 1;
min-width: 0;
height: 22px;
padding: 2px 6px;
border: 1px solid rgba(148, 163, 184, 0.45);
border-radius: 4px;
background: rgba(15, 23, 42, 0.72);
color: var(--text-heading);
font: inherit;
}
.group-node-actions {
display: flex;
align-items: center;