From 151a201f228df7bbad9933766d71cdf0a97dc19f Mon Sep 17 00:00:00 2001 From: milanofthe Date: Sun, 3 May 2026 17:09:52 +0200 Subject: [PATCH 01/44] Add toolbox config types and localStorage-backed store --- src/lib/toolbox/index.ts | 25 +++++++++++++ src/lib/toolbox/store.ts | 81 ++++++++++++++++++++++++++++++++++++++++ src/lib/toolbox/types.ts | 77 ++++++++++++++++++++++++++++++++++++++ src/lib/types/nodes.ts | 29 +++++++++----- 4 files changed, 202 insertions(+), 10 deletions(-) create mode 100644 src/lib/toolbox/index.ts create mode 100644 src/lib/toolbox/store.ts create mode 100644 src/lib/toolbox/types.ts diff --git a/src/lib/toolbox/index.ts b/src/lib/toolbox/index.ts new file mode 100644 index 00000000..e5ab915d --- /dev/null +++ b/src/lib/toolbox/index.ts @@ -0,0 +1,25 @@ +/** + * Runtime toolbox subsystem. + * + * Public surface for the rest of the app. Internal modules (installer, + * extractor, register) wire into this from the next phases. + */ + +export type { + ToolboxConfig, + ToolboxSource, + ToolboxStorage, + BlockSelection, + EventSelection, + BlockOverride +} from './types'; +export { TOOLBOX_STORAGE_KEY } from './types'; + +export { + toolboxes, + toolboxIds, + getToolbox, + upsertToolbox, + removeToolbox, + replaceToolboxes +} from './store'; diff --git a/src/lib/toolbox/store.ts b/src/lib/toolbox/store.ts new file mode 100644 index 00000000..34b4de7b --- /dev/null +++ b/src/lib/toolbox/store.ts @@ -0,0 +1,81 @@ +/** + * Runtime toolbox persistence store. + * + * Backed by localStorage so the user's installed toolboxes survive reload. + * The actual install/register logic lives in the installer/registry layers; + * this store is a passive list of declarations. + */ + +import { writable, get, derived, type Readable } from 'svelte/store'; +import type { ToolboxConfig, ToolboxStorage } from './types'; +import { TOOLBOX_STORAGE_KEY } from './types'; + +function loadInitial(): ToolboxConfig[] { + if (typeof localStorage === 'undefined') return []; + const raw = localStorage.getItem(TOOLBOX_STORAGE_KEY); + if (!raw) return []; + try { + const parsed = JSON.parse(raw) as ToolboxStorage; + if (parsed?.version !== 1 || !Array.isArray(parsed.toolboxes)) return []; + return parsed.toolboxes; + } catch { + return []; + } +} + +function persist(list: ToolboxConfig[]): void { + if (typeof localStorage === 'undefined') return; + const envelope: ToolboxStorage = { version: 1, toolboxes: list }; + try { + localStorage.setItem(TOOLBOX_STORAGE_KEY, JSON.stringify(envelope)); + } catch (e) { + // Quota exceeded or similar — surface, don't crash the app. + // eslint-disable-next-line no-console + console.error('[toolbox] failed to persist:', e); + } +} + +const internal = writable(loadInitial()); +internal.subscribe((list) => persist(list)); + +export const toolboxes: Readable = { subscribe: internal.subscribe }; + +/** Find a toolbox by id. */ +export function getToolbox(id: string): ToolboxConfig | undefined { + return get(internal).find((t) => t.id === id); +} + +/** Insert or replace a toolbox by id. */ +export function upsertToolbox(toolbox: ToolboxConfig): void { + internal.update((list) => { + const idx = list.findIndex((t) => t.id === toolbox.id); + if (idx === -1) return [...list, toolbox]; + const next = [...list]; + next[idx] = toolbox; + return next; + }); +} + +/** Remove a toolbox by id. Returns true if anything was removed. */ +export function removeToolbox(id: string): boolean { + let removed = false; + internal.update((list) => { + const next = list.filter((t) => { + if (t.id === id) { + removed = true; + return false; + } + return true; + }); + return removed ? next : list; + }); + return removed; +} + +/** Replace the entire set of toolboxes (used for import/restore). */ +export function replaceToolboxes(list: ToolboxConfig[]): void { + internal.set(list); +} + +/** Derived: just the ids, useful for cheap reactive checks. */ +export const toolboxIds: Readable = derived(toolboxes, ($t) => $t.map((t) => t.id)); diff --git a/src/lib/toolbox/types.ts b/src/lib/toolbox/types.ts new file mode 100644 index 00000000..cde7980e --- /dev/null +++ b/src/lib/toolbox/types.ts @@ -0,0 +1,77 @@ +/** + * Runtime toolbox types + * + * A "toolbox" is a runtime-installable bundle of block (and optionally event) + * types. The user adds toolboxes via the wizard; their selections are + * persisted to localStorage and replayed at app start. + * + * Built-in toolboxes (currently `pathsim`) are bundled at build time and not + * represented here. + */ + +import type { NodeShape } from '$lib/types/nodes'; + +/** Where the toolbox came from. */ +export type ToolboxSource = + | { type: 'pypi'; pkg: string; version?: string } + | { type: 'url'; url: string } + | { type: 'inline'; filename: string; code: string } + | { type: 'curated'; id: string }; + +/** Per-block UI overrides applied on top of the introspected metadata. */ +export interface BlockOverride { + /** Display name override (defaults to the Python class name). */ + name?: string; + /** Category override (defaults to the toolbox-config category). */ + category?: string; + /** Custom default color for new instances. */ + color?: string; + /** Custom shape (pill/rect/circle/diamond). */ + shape?: NodeShape; +} + +/** A single block exposed by a toolbox; user can disable or override per-block. */ +export interface BlockSelection { + /** Python class name (also serves as the registered node `type`). */ + className: string; + /** Whether to register this block. Disabled blocks are hidden but kept in config. */ + enabled: boolean; + /** UI overrides (name/category/color/shape). */ + override?: BlockOverride; +} + +/** A single event exposed by a toolbox. */ +export interface EventSelection { + /** Python class name. */ + className: string; + enabled: boolean; + override?: { name?: string }; +} + +/** A runtime-installed toolbox. */ +export interface ToolboxConfig { + /** Stable id (slug) used for registry de-duplication. */ + id: string; + /** Display name shown in the Block Library. */ + displayName: string; + /** Where the toolbox came from. */ + source: ToolboxSource; + /** Python module path used for introspection (e.g. `pathsim_batt.blocks`). */ + importPath: string; + /** Optional events submodule (e.g. `pathsim_batt.events`). */ + eventsImportPath?: string; + /** Block selections + overrides. */ + blocks: BlockSelection[]; + /** Event selections + overrides. */ + events: EventSelection[]; + /** ISO timestamp of last successful install (for diagnostics). */ + installedAt: string; +} + +/** Versioned envelope persisted to localStorage. */ +export interface ToolboxStorage { + version: 1; + toolboxes: ToolboxConfig[]; +} + +export const TOOLBOX_STORAGE_KEY = 'pathview.toolboxes.v1'; diff --git a/src/lib/types/nodes.ts b/src/lib/types/nodes.ts index c20a05cb..7962afda 100644 --- a/src/lib/types/nodes.ts +++ b/src/lib/types/nodes.ts @@ -39,16 +39,25 @@ export interface ParamDefinition { options?: string[]; // For enum-like strings } -/** Node type category */ -export type NodeCategory = - | 'Sources' - | 'Dynamic' - | 'Algebraic' - | 'Logic' - | 'Mixed' - | 'Recording' - | 'Subsystem' - | 'Chemical'; +/** + * Node type category. Open-ended string so runtime-installed toolboxes can + * introduce new categories without source edits. Built-in categories used + * by core PathSim are listed in {@link BUILTIN_NODE_CATEGORIES}. + */ +export type NodeCategory = string; + +/** Built-in categories shipped with the core pathsim toolbox. */ +export const BUILTIN_NODE_CATEGORIES = [ + 'Sources', + 'Dynamic', + 'Algebraic', + 'Logic', + 'Mixed', + 'Recording', + 'Subsystem', + 'Chemical' +] as const; +export type BuiltInNodeCategory = (typeof BUILTIN_NODE_CATEGORIES)[number]; /** Node shape override (defaults based on category if not specified) */ export type NodeShape = 'pill' | 'rect' | 'circle' | 'diamond'; From d58089185a987d2908eeddd3b18819f8b61497aa Mon Sep 17 00:00:00 2001 From: milanofthe Date: Sun, 3 May 2026 17:11:35 +0200 Subject: [PATCH 02/44] Add Pyodide bridge for runtime toolbox install and introspection --- src/lib/toolbox/index.ts | 10 ++ src/lib/toolbox/installer.ts | 111 ++++++++++++++++++ src/lib/toolbox/python.ts | 217 +++++++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+) create mode 100644 src/lib/toolbox/installer.ts create mode 100644 src/lib/toolbox/python.ts diff --git a/src/lib/toolbox/index.ts b/src/lib/toolbox/index.ts index e5ab915d..8c9c2b2b 100644 --- a/src/lib/toolbox/index.ts +++ b/src/lib/toolbox/index.ts @@ -23,3 +23,13 @@ export { removeToolbox, replaceToolboxes } from './store'; + +export { + installPackage, + loadInlineModule, + introspectBlocks, + introspectEvents, + uninstallModule, + type IntrospectedBlock, + type IntrospectedEvent +} from './installer'; diff --git a/src/lib/toolbox/installer.ts b/src/lib/toolbox/installer.ts new file mode 100644 index 00000000..9ba680f0 --- /dev/null +++ b/src/lib/toolbox/installer.ts @@ -0,0 +1,111 @@ +/** + * Runtime toolbox installer + introspector. + * + * Bridges the JS side (toolbox config, wizard) to the Python side (micropip, + * importlib introspection) via the existing Pyodide REPL bridge. + */ + +import { exec, evaluate } from '$lib/pyodide/backend'; +import { TOOLBOX_PYTHON_HELPERS, TOOLBOX_HELPERS_SENTINEL } from './python'; + +/** Raw block metadata as returned by pathview_introspect_blocks. */ +export interface IntrospectedBlock { + className: string; + description: string; + inputs: Record | string[] | null; + outputs: Record | string[] | null; + params: { name: string; default: unknown; type: string }[]; + error?: string; +} + +/** Raw event metadata as returned by pathview_introspect_events. */ +export interface IntrospectedEvent { + className: string; + description: string; + params: { name: string; default: unknown; type: string }[]; +} + +let helpersLoaded = false; + +/** + * Make sure the Python-side helper functions are defined in the REPL. + * Idempotent: a sentinel global is checked first. + */ +async function ensureHelpers(): Promise { + if (helpersLoaded) return; + const present = await evaluate(TOOLBOX_HELPERS_SENTINEL); + if (!present) { + await exec(TOOLBOX_PYTHON_HELPERS); + } + helpersLoaded = true; +} + +/** Escape a string so it can be embedded as a Python literal. */ +function pyStr(s: string): string { + return JSON.stringify(s); +} + +/** + * Install a package via micropip. `spec` can be a PyPI name, a versioned + * spec ("name==1.2"), or a wheel URL. + */ +export async function installPackage(spec: string): Promise { + await ensureHelpers(); + // micropip.install must be awaited; runPythonAsync supports top-level await. + await exec(`await _pv_install_spec(${pyStr(spec)})`); +} + +/** + * Load a single-file Python module from inline source. The module is + * registered in sys.modules under `pathview_inline_`. + * + * Returns the actual module name registered (with prefix), so callers can + * use it as the importPath for introspection. + */ +export async function loadInlineModule(name: string, code: string): Promise { + await ensureHelpers(); + const result = await evaluate<{ ok: boolean; module: string; error?: string }>( + `_pv_load_inline(${pyStr(name)}, ${pyStr(code)})` + ); + if (!result.ok) { + throw new Error(`Failed to load inline module: ${result.error ?? 'unknown error'}`); + } + return result.module; +} + +/** Import the module and return all Block subclasses with their metadata. */ +export async function introspectBlocks(importPath: string): Promise { + await ensureHelpers(); + const result = await evaluate<{ ok: boolean; blocks?: IntrospectedBlock[]; error?: string }>( + `pathview_introspect_blocks(${pyStr(importPath)})` + ); + if (!result.ok || !result.blocks) { + throw new Error(`Introspection failed for "${importPath}": ${result.error ?? 'unknown error'}`); + } + return result.blocks; +} + +/** Import an events submodule and list event classes with their parameters. */ +export async function introspectEvents(importPath: string): Promise { + await ensureHelpers(); + const result = await evaluate<{ ok: boolean; events?: IntrospectedEvent[]; error?: string }>( + `pathview_introspect_events(${pyStr(importPath)})` + ); + if (!result.ok || !result.events) { + throw new Error(`Event introspection failed for "${importPath}": ${result.error ?? 'unknown error'}`); + } + return result.events; +} + +/** + * Drop a module from sys.modules. micropip has no real uninstall, so the + * package files stay cached in the Pyodide FS until reload, but importing + * the path again will re-execute the module body if needed. + */ +export async function uninstallModule(importPath: string): Promise { + await ensureHelpers(); + const result = await evaluate<{ ok: boolean; dropped: string[] }>( + `pathview_uninstall(${pyStr(importPath)})` + ); + return result.dropped; +} diff --git a/src/lib/toolbox/python.ts b/src/lib/toolbox/python.ts new file mode 100644 index 00000000..470c64c8 --- /dev/null +++ b/src/lib/toolbox/python.ts @@ -0,0 +1,217 @@ +/** + * Python-side helpers for runtime toolbox install + introspection. + * + * Loaded into the Pyodide REPL the first time a toolbox operation runs. + * The functions mirror the build-time logic in `scripts/extract.py` but + * trimmed to what is needed at runtime (no docstring HTML conversion, no + * disk I/O, no TypeScript generation). + */ + +export const TOOLBOX_PYTHON_HELPERS = ` +import sys as _pv_sys +import importlib as _pv_importlib +import inspect as _pv_inspect +import types as _pv_types +import json as _pv_json + +_PV_INLINE_PREFIX = "pathview_inline_" + +async def _pv_install_spec(spec): + """Install a package via micropip. Spec can be 'name', 'name==1.2', or a wheel URL.""" + import micropip + await micropip.install(spec, keep_going=True) + return {"ok": True, "spec": spec} + +def _pv_load_inline(module_name, code): + """Exec a single-file Python module string into sys.modules under module_name.""" + if not module_name.startswith(_PV_INLINE_PREFIX): + module_name = _PV_INLINE_PREFIX + module_name + mod = _pv_types.ModuleType(module_name) + mod.__file__ = "" + try: + exec(compile(code, mod.__file__, "exec"), mod.__dict__) + except Exception as e: + return {"ok": False, "error": str(e), "module": module_name} + _pv_sys.modules[module_name] = mod + return {"ok": True, "module": module_name} + +def _pv_drop_module(import_path): + """Drop a module (and its submodules) from sys.modules. Returns dropped names.""" + dropped = [] + prefix = import_path + "." + for name in list(_pv_sys.modules.keys()): + if name == import_path or name.startswith(prefix): + try: + del _pv_sys.modules[name] + dropped.append(name) + except KeyError: + pass + return dropped + +def _pv_default_repr(value): + """Best-effort JSON-friendly repr of a default parameter value.""" + if value is _pv_inspect.Parameter.empty: + return None + try: + _pv_json.dumps(value) + return value + except (TypeError, ValueError): + try: + return repr(value) + except Exception: + return None + +def _pv_infer_type(value): + """Infer a coarse parameter type from a default value.""" + if value is None or value is _pv_inspect.Parameter.empty: + return "any" + if isinstance(value, bool): + return "bool" + if isinstance(value, int): + return "number" + if isinstance(value, float): + return "number" + if isinstance(value, str): + return "string" + if isinstance(value, (list, tuple)): + return "array" + if isinstance(value, dict): + return "object" + if callable(value): + return "function" + return "any" + +def _pv_extract_params(cls): + """Extract __init__ parameters via inspect.signature.""" + params = [] + try: + sig = _pv_inspect.signature(cls.__init__) + except (TypeError, ValueError): + return params + for pname, p in sig.parameters.items(): + if pname == "self": + continue + if p.kind in ( + _pv_inspect.Parameter.VAR_POSITIONAL, + _pv_inspect.Parameter.VAR_KEYWORD, + ): + continue + default = _pv_default_repr(p.default) + params.append({ + "name": pname, + "default": default, + "type": _pv_infer_type(p.default), + }) + return params + +def _pv_extract_block(cls): + """Pull metadata for a single block class.""" + info = None + info_fn = getattr(cls, "info", None) + if callable(info_fn): + try: + info = info_fn() + except Exception: + info = None + + if info is not None: + params = [] + for pname, meta in (info.get("parameters") or {}).items(): + default = meta.get("default") if isinstance(meta, dict) else None + params.append({ + "name": pname, + "default": _pv_default_repr(default), + "type": _pv_infer_type(default), + }) + return { + "className": cls.__name__, + "description": (info.get("description") or "").strip(), + "inputs": info.get("input_port_labels"), + "outputs": info.get("output_port_labels"), + "params": params, + } + + return { + "className": cls.__name__, + "description": (cls.__doc__ or "").strip(), + "inputs": getattr(cls, "input_port_labels", None), + "outputs": getattr(cls, "output_port_labels", None), + "params": _pv_extract_params(cls), + } + +def _pv_is_block(cls): + """Check if a class is a pathsim Block subclass (best-effort, no hard import).""" + if not _pv_inspect.isclass(cls): + return False + for base in cls.__mro__[1:]: + if base.__name__ == "Block" and base.__module__.startswith("pathsim"): + return True + return False + +def _pv_is_event(cls): + """Heuristic for event-like classes.""" + if not _pv_inspect.isclass(cls): + return False + name = cls.__name__ + if name.startswith("_"): + return False + for base in cls.__mro__[1:]: + if base.__name__.endswith("Event") and "pathsim" in base.__module__: + return True + # Fallback: classname ends in Event + return name.endswith("Event") + +def pathview_introspect_blocks(import_path): + """Import the module and return all Block subclasses with metadata.""" + try: + mod = _pv_importlib.import_module(import_path) + except Exception as e: + return {"ok": False, "error": str(e)} + blocks = [] + for name in dir(mod): + if name.startswith("_"): + continue + obj = getattr(mod, name) + if not _pv_is_block(obj): + continue + # Skip classes re-exported from elsewhere + if obj.__module__ != mod.__name__ and not obj.__module__.startswith(mod.__name__ + "."): + continue + try: + blocks.append(_pv_extract_block(obj)) + except Exception as e: + blocks.append({"className": name, "error": str(e)}) + return {"ok": True, "blocks": blocks} + +def pathview_introspect_events(import_path): + """Import the events submodule and list event classes with their __init__ params.""" + try: + mod = _pv_importlib.import_module(import_path) + except Exception as e: + return {"ok": False, "error": str(e)} + events = [] + for name in dir(mod): + if name.startswith("_"): + continue + obj = getattr(mod, name) + if not _pv_is_event(obj): + continue + if obj.__module__ != mod.__name__ and not obj.__module__.startswith(mod.__name__ + "."): + continue + events.append({ + "className": obj.__name__, + "description": (obj.__doc__ or "").strip(), + "params": _pv_extract_params(obj), + }) + return {"ok": True, "events": events} + +def pathview_uninstall(import_path): + """Drop a module + submodules from sys.modules. micropip has no real uninstall.""" + dropped = _pv_drop_module(import_path) + return {"ok": True, "dropped": dropped} + +_pv_helpers_loaded = True +`; + +/** Sentinel expression used to check whether helpers are already loaded in the REPL. */ +export const TOOLBOX_HELPERS_SENTINEL = `'_pv_helpers_loaded' in dir()`; From 760e58dec51683012cfa4a4392fe196ddfc31231 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Sun, 3 May 2026 17:14:00 +0200 Subject: [PATCH 03/44] Extend node and event registries with sources and reactive change notifications --- src/lib/events/index.ts | 2 +- src/lib/events/registry.ts | 90 +++++++++++---- src/lib/nodes/index.ts | 2 +- src/lib/nodes/registry.ts | 144 ++++++++++++++++++------ src/lib/toolbox/index.ts | 7 ++ src/lib/toolbox/register.ts | 217 ++++++++++++++++++++++++++++++++++++ 6 files changed, 407 insertions(+), 55 deletions(-) create mode 100644 src/lib/toolbox/register.ts diff --git a/src/lib/events/index.ts b/src/lib/events/index.ts index c92a9098..64d5d23a 100644 --- a/src/lib/events/index.ts +++ b/src/lib/events/index.ts @@ -4,4 +4,4 @@ export * from './types'; export * from './definitions'; -export { eventRegistry } from './registry'; +export { eventRegistry, eventRegistryVersion, BUILTIN_EVENT_SOURCE } from './registry'; diff --git a/src/lib/events/registry.ts b/src/lib/events/registry.ts index 793ddc07..a984121e 100644 --- a/src/lib/events/registry.ts +++ b/src/lib/events/registry.ts @@ -1,49 +1,97 @@ /** * Event type registry - * Manages all available event type definitions + * Manages all available event type definitions, including runtime additions + * from installed toolboxes. */ +import { writable, type Readable } from 'svelte/store'; import type { EventTypeDefinition } from './types'; import { eventDefinitions } from './definitions'; +export const BUILTIN_EVENT_SOURCE = 'builtin'; + +interface Entry { + definition: EventTypeDefinition; + source: string; +} + class EventRegistry { - private events = new Map(); + private events = new Map(); + private bySource = new Map>(); constructor() { - // Register all built-in event definitions for (const def of eventDefinitions) { - this.register(def); + this.register(def, BUILTIN_EVENT_SOURCE); } } - /** - * Register an event type definition - */ - register(definition: EventTypeDefinition): void { - this.events.set(definition.type, definition); + register(definition: EventTypeDefinition, source: string = BUILTIN_EVENT_SOURCE): void { + if (this.events.has(definition.type)) { + this.removeFromSourceIndex(definition.type); + } + this.events.set(definition.type, { definition, source }); + const src = this.bySource.get(source) ?? new Set(); + src.add(definition.type); + this.bySource.set(source, src); + bumpVersion(); + } + + unregister(type: string): boolean { + if (!this.events.has(type)) return false; + this.removeFromSourceIndex(type); + this.events.delete(type); + bumpVersion(); + return true; + } + + unregisterSource(source: string): string[] { + const ids = Array.from(this.bySource.get(source) ?? []); + for (const id of ids) this.events.delete(id); + this.bySource.delete(source); + if (ids.length > 0) bumpVersion(); + return ids; + } + + private removeFromSourceIndex(type: string): void { + const entry = this.events.get(type); + if (!entry) return; + const src = this.bySource.get(entry.source); + if (src) { + src.delete(type); + if (src.size === 0) this.bySource.delete(entry.source); + } } - /** - * Get an event type definition by type ID - */ get(type: string): EventTypeDefinition | undefined { - return this.events.get(type); + return this.events.get(type)?.definition; + } + + getSource(type: string): string | undefined { + return this.events.get(type)?.source; + } + + getBySource(source: string): EventTypeDefinition[] { + const ids = this.bySource.get(source); + if (!ids) return []; + return Array.from(ids) + .map((id) => this.events.get(id)?.definition) + .filter((d): d is EventTypeDefinition => !!d); } - /** - * Get all registered event type definitions - */ getAll(): EventTypeDefinition[] { - return Array.from(this.events.values()); + return Array.from(this.events.values()).map((e) => e.definition); } - /** - * Check if an event type is registered - */ has(type: string): boolean { return this.events.has(type); } } -// Singleton instance +const versionStore = writable(0); +function bumpVersion(): void { + versionStore.update((n) => n + 1); +} + +export const eventRegistryVersion: Readable = { subscribe: versionStore.subscribe }; + export const eventRegistry = new EventRegistry(); diff --git a/src/lib/nodes/index.ts b/src/lib/nodes/index.ts index da53aee2..b38b74f9 100644 --- a/src/lib/nodes/index.ts +++ b/src/lib/nodes/index.ts @@ -7,7 +7,7 @@ export * from './types'; // Re-export registry (initializes blocks from generated data on import) -export { nodeRegistry } from './registry'; +export { nodeRegistry, registryVersion, BUILTIN_SOURCE } from './registry'; // Re-export defineNode helper export { defineNode } from './defineNode'; diff --git a/src/lib/nodes/registry.ts b/src/lib/nodes/registry.ts index 41cee8c2..22dab059 100644 --- a/src/lib/nodes/registry.ts +++ b/src/lib/nodes/registry.ts @@ -1,72 +1,152 @@ /** * Node type registry - * Manages all registered node types and provides lookup functionality + * Manages all registered node types and provides lookup functionality. + * + * Definitions can come from two sources: + * - "builtin": baked in at build time (extracted from `pathsim` core via + * `scripts/extract.py`). + * - a runtime toolbox id: registered by the toolbox installer when the + * user adds a toolbox via the wizard. Removed on uninstall. + * + * `registryVersion` is a Svelte store that bumps on every change so the UI + * (NodeLibrary, etc.) can stay in sync with runtime changes. */ +import { writable, type Readable } from 'svelte/store'; import type { NodeTypeDefinition, NodeCategory, ParamDefinition, ParamType } from './types'; import { defineNode } from './defineNode'; import { extractedBlocks, blockConfig, type ExtractedBlock } from './generated/blocks'; import { syncPortBlocks } from './uiConfig'; +/** Marker for built-in (build-time) definitions. */ +export const BUILTIN_SOURCE = 'builtin'; + +interface Entry { + definition: NodeTypeDefinition; + source: string; +} + class NodeRegistry { - private nodes: Map = new Map(); - private byCategory: Map = new Map(); + private nodes: Map = new Map(); + private byCategory: Map> = new Map(); + private bySource: Map> = new Map(); /** - * Register a new node type + * Register a node type. If a node with the same `type` is already + * registered, it is replaced (last writer wins) and a warning logged. */ - register(definition: NodeTypeDefinition): void { - this.nodes.set(definition.type, definition); + register(definition: NodeTypeDefinition, source: string = BUILTIN_SOURCE): void { + if (this.nodes.has(definition.type)) { + // eslint-disable-next-line no-console + console.warn( + `[nodeRegistry] replacing "${definition.type}" (was ${this.nodes.get(definition.type)?.source}, now ${source})` + ); + this.removeFromIndexes(definition.type); + } + this.nodes.set(definition.type, { definition, source }); + + const cat = this.byCategory.get(definition.category) ?? new Set(); + cat.add(definition.type); + this.byCategory.set(definition.category, cat); + + const src = this.bySource.get(source) ?? new Set(); + src.add(definition.type); + this.bySource.set(source, src); - const categoryNodes = this.byCategory.get(definition.category) || []; - categoryNodes.push(definition); - this.byCategory.set(definition.category, categoryNodes); + bumpVersion(); + } + + /** Remove a single node type. Returns true if it was registered. */ + unregister(type: string): boolean { + if (!this.nodes.has(type)) return false; + this.removeFromIndexes(type); + this.nodes.delete(type); + bumpVersion(); + return true; + } + + /** Remove every node registered under a given source (toolbox id). */ + unregisterSource(source: string): string[] { + const ids = Array.from(this.bySource.get(source) ?? []); + for (const id of ids) { + this.removeFromIndexes(id); + this.nodes.delete(id); + } + this.bySource.delete(source); + if (ids.length > 0) bumpVersion(); + return ids; + } + + private removeFromIndexes(type: string): void { + const entry = this.nodes.get(type); + if (!entry) return; + const cat = this.byCategory.get(entry.definition.category); + if (cat) { + cat.delete(type); + if (cat.size === 0) this.byCategory.delete(entry.definition.category); + } + const src = this.bySource.get(entry.source); + if (src) { + src.delete(type); + if (src.size === 0) this.bySource.delete(entry.source); + } } - /** - * Get a node type by its type ID - */ get(type: string): NodeTypeDefinition | undefined { - return this.nodes.get(type); + return this.nodes.get(type)?.definition; + } + + getSource(type: string): string | undefined { + return this.nodes.get(type)?.source; } - /** - * Get all node types in a category - */ getByCategory(category: NodeCategory): NodeTypeDefinition[] { - return this.byCategory.get(category) || []; + const ids = this.byCategory.get(category); + if (!ids) return []; + return Array.from(ids) + .map((id) => this.nodes.get(id)?.definition) + .filter((d): d is NodeTypeDefinition => !!d); + } + + getBySource(source: string): NodeTypeDefinition[] { + const ids = this.bySource.get(source); + if (!ids) return []; + return Array.from(ids) + .map((id) => this.nodes.get(id)?.definition) + .filter((d): d is NodeTypeDefinition => !!d); } - /** - * Get all registered categories - */ getAllCategories(): NodeCategory[] { return Array.from(this.byCategory.keys()); } - /** - * Get all registered node types - */ + getAllSources(): string[] { + return Array.from(this.bySource.keys()); + } + getAll(): NodeTypeDefinition[] { - return Array.from(this.nodes.values()); + return Array.from(this.nodes.values()).map((e) => e.definition); } - /** - * Check if a node type is registered - */ has(type: string): boolean { return this.nodes.has(type); } - /** - * Get the count of registered nodes - */ get size(): number { return this.nodes.size; } } -// Export singleton instance +// Reactive change counter — UI subscribes to this to re-render after +// runtime register/unregister. +const versionStore = writable(0); +function bumpVersion(): void { + versionStore.update((n) => n + 1); +} + +/** Subscribe to changes in the registry. The value is an opaque counter. */ +export const registryVersion: Readable = { subscribe: versionStore.subscribe }; + export const nodeRegistry = new NodeRegistry(); /** @@ -160,7 +240,7 @@ function createNodeFromExtracted( definition.docstring = extracted.docstringHtml; } - nodeRegistry.register(definition); + nodeRegistry.register(definition, BUILTIN_SOURCE); } /** diff --git a/src/lib/toolbox/index.ts b/src/lib/toolbox/index.ts index 8c9c2b2b..36dc4c80 100644 --- a/src/lib/toolbox/index.ts +++ b/src/lib/toolbox/index.ts @@ -33,3 +33,10 @@ export { type IntrospectedBlock, type IntrospectedEvent } from './installer'; + +export { + performInstall, + discoverToolbox, + registerToolbox, + uninstallToolbox +} from './register'; diff --git a/src/lib/toolbox/register.ts b/src/lib/toolbox/register.ts new file mode 100644 index 00000000..8fde9ec4 --- /dev/null +++ b/src/lib/toolbox/register.ts @@ -0,0 +1,217 @@ +/** + * Convert introspected toolbox metadata into registry definitions and apply + * the user's per-block overrides. + * + * One toolbox = one source id in the registry. Registering replaces, and + * `unregisterSource(id)` is the clean uninstall path. + */ + +import { defineNode } from '$lib/nodes/defineNode'; +import { nodeRegistry } from '$lib/nodes/registry'; +import type { ParamType } from '$lib/nodes/types'; +import { eventRegistry } from '$lib/events/registry'; +import type { + EventParamDefinition, + EventParamType, + EventTypeDefinition +} from '$lib/types/events'; +import { + installPackage, + loadInlineModule, + introspectBlocks, + introspectEvents, + uninstallModule, + type IntrospectedBlock, + type IntrospectedEvent +} from './installer'; +import type { BlockSelection, EventSelection, ToolboxConfig } from './types'; + +/** + * Resolve the port shape from introspected `input_port_labels` / `output_port_labels`. + * Same semantics as the build-time pipeline. + */ +function resolvePorts( + labels: Record | string[] | null | undefined +): { ports: string[] | undefined; max: number | null } { + if (labels === null || labels === undefined) { + return { ports: undefined, max: null }; // variable + } + if (Array.isArray(labels)) { + if (labels.length === 0) return { ports: [], max: 0 }; + return { ports: labels, max: labels.length }; + } + // Dict {name: index} + const entries = Object.entries(labels); + if (entries.length === 0) return { ports: [], max: 0 }; + entries.sort((a, b) => (a[1] as number) - (b[1] as number)); + const ports = entries.map(([name]) => name); + return { ports, max: ports.length }; +} + +function asParamType(t: string): ParamType { + if (t === 'number' || t === 'string' || t === 'bool' || t === 'array' || t === 'object') { + return t as ParamType; + } + return 'string'; +} + +function asEventParamType(t: string): EventParamType { + if (t === 'number' || t === 'string' || t === 'callable' || t === 'array') { + return t as EventParamType; + } + return 'string'; +} + +/** Build a node definition from one introspected block + the user's selection. */ +function buildBlockDefinition(block: IntrospectedBlock, selection: BlockSelection, fallbackCategory: string) { + const { ports: inputs, max: maxInputs } = resolvePorts(block.inputs); + const { ports: outputs, max: maxOutputs } = resolvePorts(block.outputs); + + const params: Record = {}; + for (const p of block.params) { + params[p.name] = { type: asParamType(p.type), default: p.default }; + } + + const definition = defineNode({ + name: selection.override?.name ?? block.className, + category: selection.override?.category ?? fallbackCategory, + blockClass: block.className, + description: block.description, + inputs, + outputs, + maxInputs, + maxOutputs, + shape: selection.override?.shape, + params + }); + + // Apply optional default color (used when instantiating fresh nodes). + if (selection.override?.color) { + (definition as unknown as { defaultColor?: string }).defaultColor = selection.override.color; + } + + return definition; +} + +function buildEventDefinition(event: IntrospectedEvent, selection: EventSelection, importPath: string): EventTypeDefinition { + const params: EventParamDefinition[] = event.params.map((p) => ({ + name: p.name, + type: asEventParamType(p.type), + default: p.default + })); + return { + type: `${importPath}.${event.className}`, + name: selection.override?.name ?? event.className, + description: event.description, + params, + eventClass: event.className + }; +} + +/** + * Run the source-specific install step (PyPI / URL / inline) and return the + * importPath that introspection should use afterwards. The caller is + * responsible for persisting the toolbox config. + */ +export async function performInstall( + source: ToolboxConfig['source'], + requestedImportPath?: string +): Promise<{ importPath: string }> { + if (source.type === 'pypi') { + const spec = source.version ? `${source.pkg}==${source.version}` : source.pkg; + await installPackage(spec); + // Default to the package name with `_` if caller didn't specify. + const importPath = requestedImportPath ?? source.pkg.replace(/-/g, '_'); + return { importPath }; + } + if (source.type === 'url') { + await installPackage(source.url); + if (!requestedImportPath) { + throw new Error('importPath is required when installing from URL'); + } + return { importPath: requestedImportPath }; + } + if (source.type === 'inline') { + const baseName = source.filename.replace(/\.py$/, '').replace(/[^A-Za-z0-9_]/g, '_'); + const moduleName = await loadInlineModule(baseName, source.code); + return { importPath: moduleName }; + } + if (source.type === 'curated') { + throw new Error('Curated catalog install not implemented yet'); + } + throw new Error(`Unknown toolbox source type: ${(source as { type: string }).type}`); +} + +/** + * Run introspection for a configured toolbox. The toolbox must already be + * installed (importable). Returns the raw introspection data so the caller + * can present it in the wizard (e.g. for selection step). + */ +export async function discoverToolbox(config: { + importPath: string; + eventsImportPath?: string; +}): Promise<{ blocks: IntrospectedBlock[]; events: IntrospectedEvent[] }> { + const blocks = await introspectBlocks(config.importPath); + let events: IntrospectedEvent[] = []; + if (config.eventsImportPath) { + try { + events = await introspectEvents(config.eventsImportPath); + } catch { + events = []; // Events submodule is optional. + } + } + return { blocks, events }; +} + +/** + * Register the user's selected blocks/events under the toolbox source id. + * Replaces any previous registration for the same toolbox id. + * + * `categoryByClass` lets the toolbox config pin a default category per + * class (used when the selection has no explicit override). + */ +export async function registerToolbox( + config: ToolboxConfig, + options: { + blocks: IntrospectedBlock[]; + events: IntrospectedEvent[]; + categoryByClass?: Record; + } +): Promise { + // Clear any prior registrations for this id. + nodeRegistry.unregisterSource(config.id); + eventRegistry.unregisterSource(config.id); + + const blocksByClass = new Map(options.blocks.map((b) => [b.className, b])); + const eventsByClass = new Map(options.events.map((e) => [e.className, e])); + + for (const sel of config.blocks) { + if (!sel.enabled) continue; + const block = blocksByClass.get(sel.className); + if (!block || block.error) continue; + const fallbackCategory = options.categoryByClass?.[sel.className] ?? config.displayName; + const def = buildBlockDefinition(block, sel, fallbackCategory); + nodeRegistry.register(def, config.id); + } + + for (const sel of config.events) { + if (!sel.enabled) continue; + const event = eventsByClass.get(sel.className); + if (!event) continue; + const importPath = config.eventsImportPath ?? config.importPath; + const def = buildEventDefinition(event, sel, importPath); + eventRegistry.register(def, config.id); + } +} + +/** Clean up a toolbox: drop registry entries and the Python module. */ +export async function uninstallToolbox(config: ToolboxConfig): Promise { + nodeRegistry.unregisterSource(config.id); + eventRegistry.unregisterSource(config.id); + try { + await uninstallModule(config.importPath); + if (config.eventsImportPath) await uninstallModule(config.eventsImportPath); + } catch { + // Best-effort: dropping from sys.modules is cosmetic at runtime. + } +} From 548cd4ada14c072c395493aac7621c51f85fb932 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Sun, 3 May 2026 18:05:51 +0200 Subject: [PATCH 04/44] Add toolbox wizard, catalog, NodeLibrary integration and bootstrap --- .../dialogs/ToolboxWizardDialog.svelte | 973 ++++++++++++++++++ src/lib/components/panels/NodeLibrary.svelte | 164 ++- src/lib/toolbox/bootstrap.ts | 72 ++ src/lib/toolbox/catalog.ts | 60 ++ src/lib/toolbox/index.ts | 4 + src/routes/+page.svelte | 32 + 6 files changed, 1284 insertions(+), 21 deletions(-) create mode 100644 src/lib/components/dialogs/ToolboxWizardDialog.svelte create mode 100644 src/lib/toolbox/bootstrap.ts create mode 100644 src/lib/toolbox/catalog.ts diff --git a/src/lib/components/dialogs/ToolboxWizardDialog.svelte b/src/lib/components/dialogs/ToolboxWizardDialog.svelte new file mode 100644 index 00000000..89943b6e --- /dev/null +++ b/src/lib/components/dialogs/ToolboxWizardDialog.svelte @@ -0,0 +1,973 @@ + + + + +{#if open} + +{/if} + + diff --git a/src/lib/components/panels/NodeLibrary.svelte b/src/lib/components/panels/NodeLibrary.svelte index a08c7aef..97381049 100644 --- a/src/lib/components/panels/NodeLibrary.svelte +++ b/src/lib/components/panels/NodeLibrary.svelte @@ -1,16 +1,27 @@