rework web server so multiple clients can be server at a time
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
118
frontend/src/nativePicker.js
Normal file
118
frontend/src/nativePicker.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -9,9 +9,9 @@ export default defineConfig({
|
||||
proxy: {
|
||||
'/nodes': 'http://127.0.0.1:8188',
|
||||
'/files': 'http://127.0.0.1:8188',
|
||||
'/browse': 'http://127.0.0.1:8188',
|
||||
'/folder-files': 'http://127.0.0.1:8188',
|
||||
'/channels': 'http://127.0.0.1:8188',
|
||||
'/upload-folder': 'http://127.0.0.1:8188',
|
||||
'/upload': 'http://127.0.0.1:8188',
|
||||
'/download': 'http://127.0.0.1:8188',
|
||||
'/prompt': 'http://127.0.0.1:8188',
|
||||
|
||||
Reference in New Issue
Block a user