@nodetool-ai/websocket 0.7.0-rc.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent-runtime.d.ts +47 -0
- package/dist/agent/agent-runtime.d.ts.map +1 -0
- package/dist/agent/agent-runtime.js +577 -0
- package/dist/agent/agent-runtime.js.map +1 -0
- package/dist/agent/codex-agent.d.ts +41 -0
- package/dist/agent/codex-agent.d.ts.map +1 -0
- package/dist/agent/codex-agent.js +334 -0
- package/dist/agent/codex-agent.js.map +1 -0
- package/dist/agent/index.d.ts +6 -0
- package/dist/agent/index.d.ts.map +1 -0
- package/dist/agent/index.js +4 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/agent/llm-agent.d.ts +51 -0
- package/dist/agent/llm-agent.d.ts.map +1 -0
- package/dist/agent/llm-agent.js +526 -0
- package/dist/agent/llm-agent.js.map +1 -0
- package/dist/agent/mcp-tool-server.d.ts +33 -0
- package/dist/agent/mcp-tool-server.d.ts.map +1 -0
- package/dist/agent/mcp-tool-server.js +292 -0
- package/dist/agent/mcp-tool-server.js.map +1 -0
- package/dist/agent/opencode-agent.d.ts +32 -0
- package/dist/agent/opencode-agent.d.ts.map +1 -0
- package/dist/agent/opencode-agent.js +332 -0
- package/dist/agent/opencode-agent.js.map +1 -0
- package/dist/agent/pi-agent.d.ts +42 -0
- package/dist/agent/pi-agent.d.ts.map +1 -0
- package/dist/agent/pi-agent.js +429 -0
- package/dist/agent/pi-agent.js.map +1 -0
- package/dist/agent/sdk-provider.d.ts +46 -0
- package/dist/agent/sdk-provider.d.ts.map +1 -0
- package/dist/agent/sdk-provider.js +50 -0
- package/dist/agent/sdk-provider.js.map +1 -0
- package/dist/agent/socket-route.d.ts +11 -0
- package/dist/agent/socket-route.d.ts.map +1 -0
- package/dist/agent/socket-route.js +262 -0
- package/dist/agent/socket-route.js.map +1 -0
- package/dist/agent/transport.d.ts +39 -0
- package/dist/agent/transport.d.ts.map +1 -0
- package/dist/agent/transport.js +17 -0
- package/dist/agent/transport.js.map +1 -0
- package/dist/agent/types.d.ts +11 -0
- package/dist/agent/types.d.ts.map +1 -0
- package/dist/agent/types.js +10 -0
- package/dist/agent/types.js.map +1 -0
- package/dist/collection-api.d.ts +14 -0
- package/dist/collection-api.d.ts.map +1 -0
- package/dist/collection-api.js +75 -0
- package/dist/collection-api.js.map +1 -0
- package/dist/error-codes.d.ts +3 -0
- package/dist/error-codes.d.ts.map +1 -0
- package/dist/error-codes.js +2 -0
- package/dist/error-codes.js.map +1 -0
- package/dist/file-api.d.ts +12 -0
- package/dist/file-api.d.ts.map +1 -0
- package/dist/file-api.js +76 -0
- package/dist/file-api.js.map +1 -0
- package/dist/http-api.d.ts +94 -0
- package/dist/http-api.d.ts.map +1 -0
- package/dist/http-api.js +1472 -0
- package/dist/http-api.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/asset-paths.d.ts +10 -0
- package/dist/lib/asset-paths.d.ts.map +1 -0
- package/dist/lib/asset-paths.js +45 -0
- package/dist/lib/asset-paths.js.map +1 -0
- package/dist/lib/bridge.d.ts +8 -0
- package/dist/lib/bridge.d.ts.map +1 -0
- package/dist/lib/bridge.js +70 -0
- package/dist/lib/bridge.js.map +1 -0
- package/dist/mcp-server.d.ts +32 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +842 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/models-api.d.ts +12 -0
- package/dist/models-api.d.ts.map +1 -0
- package/dist/models-api.js +730 -0
- package/dist/models-api.js.map +1 -0
- package/dist/oauth-api.d.ts +28 -0
- package/dist/oauth-api.d.ts.map +1 -0
- package/dist/oauth-api.js +681 -0
- package/dist/oauth-api.js.map +1 -0
- package/dist/openai-api.d.ts +63 -0
- package/dist/openai-api.d.ts.map +1 -0
- package/dist/openai-api.js +359 -0
- package/dist/openai-api.js.map +1 -0
- package/dist/plugins/websocket.d.ts +14 -0
- package/dist/plugins/websocket.d.ts.map +1 -0
- package/dist/plugins/websocket.js +431 -0
- package/dist/plugins/websocket.js.map +1 -0
- package/dist/resolve-media-urls.d.ts +19 -0
- package/dist/resolve-media-urls.d.ts.map +1 -0
- package/dist/resolve-media-urls.js +109 -0
- package/dist/resolve-media-urls.js.map +1 -0
- package/dist/routes/assets.d.ts +22 -0
- package/dist/routes/assets.d.ts.map +1 -0
- package/dist/routes/assets.js +168 -0
- package/dist/routes/assets.js.map +1 -0
- package/dist/routes/collections.d.ts +16 -0
- package/dist/routes/collections.d.ts.map +1 -0
- package/dist/routes/collections.js +32 -0
- package/dist/routes/collections.js.map +1 -0
- package/dist/routes/files.d.ts +12 -0
- package/dist/routes/files.d.ts.map +1 -0
- package/dist/routes/files.js +11 -0
- package/dist/routes/files.js.map +1 -0
- package/dist/routes/health.d.ts +4 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +41 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/jobs.d.ts +16 -0
- package/dist/routes/jobs.d.ts.map +1 -0
- package/dist/routes/jobs.js +25 -0
- package/dist/routes/jobs.js.map +1 -0
- package/dist/routes/nodes.d.ts +8 -0
- package/dist/routes/nodes.d.ts.map +1 -0
- package/dist/routes/nodes.js +124 -0
- package/dist/routes/nodes.js.map +1 -0
- package/dist/routes/oauth.d.ts +8 -0
- package/dist/routes/oauth.d.ts.map +1 -0
- package/dist/routes/oauth.js +19 -0
- package/dist/routes/oauth.js.map +1 -0
- package/dist/routes/openai.d.ts +8 -0
- package/dist/routes/openai.d.ts.map +1 -0
- package/dist/routes/openai.js +20 -0
- package/dist/routes/openai.js.map +1 -0
- package/dist/routes/storage.d.ts +12 -0
- package/dist/routes/storage.d.ts.map +1 -0
- package/dist/routes/storage.js +13 -0
- package/dist/routes/storage.js.map +1 -0
- package/dist/routes/workflows.d.ts +18 -0
- package/dist/routes/workflows.d.ts.map +1 -0
- package/dist/routes/workflows.js +25 -0
- package/dist/routes/workflows.js.map +1 -0
- package/dist/routes/workspace.d.ts +15 -0
- package/dist/routes/workspace.d.ts.map +1 -0
- package/dist/routes/workspace.js +24 -0
- package/dist/routes/workspace.js.map +1 -0
- package/dist/screenshot-server.d.ts +21 -0
- package/dist/screenshot-server.d.ts.map +1 -0
- package/dist/screenshot-server.js +222 -0
- package/dist/screenshot-server.js.map +1 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +783 -0
- package/dist/server.js.map +1 -0
- package/dist/settings-registry.d.ts +29 -0
- package/dist/settings-registry.d.ts.map +1 -0
- package/dist/settings-registry.js +117 -0
- package/dist/settings-registry.js.map +1 -0
- package/dist/storage-api.d.ts +5 -0
- package/dist/storage-api.d.ts.map +1 -0
- package/dist/storage-api.js +221 -0
- package/dist/storage-api.js.map +1 -0
- package/dist/test-ui-server.d.ts +19 -0
- package/dist/test-ui-server.d.ts.map +1 -0
- package/dist/test-ui-server.js +1256 -0
- package/dist/test-ui-server.js.map +1 -0
- package/dist/trpc/context.d.ts +21 -0
- package/dist/trpc/context.d.ts.map +1 -0
- package/dist/trpc/context.js +10 -0
- package/dist/trpc/context.js.map +1 -0
- package/dist/trpc/error-formatter.d.ts +18 -0
- package/dist/trpc/error-formatter.d.ts.map +1 -0
- package/dist/trpc/error-formatter.js +41 -0
- package/dist/trpc/error-formatter.js.map +1 -0
- package/dist/trpc/index.d.ts +18 -0
- package/dist/trpc/index.d.ts.map +1 -0
- package/dist/trpc/index.js +13 -0
- package/dist/trpc/index.js.map +1 -0
- package/dist/trpc/middleware.d.ts +8 -0
- package/dist/trpc/middleware.d.ts.map +1 -0
- package/dist/trpc/middleware.js +12 -0
- package/dist/trpc/middleware.js.map +1 -0
- package/dist/trpc/router.d.ts +2543 -0
- package/dist/trpc/router.d.ts.map +1 -0
- package/dist/trpc/router.js +43 -0
- package/dist/trpc/router.js.map +1 -0
- package/dist/trpc/routers/assets.d.ts +229 -0
- package/dist/trpc/routers/assets.d.ts.map +1 -0
- package/dist/trpc/routers/assets.js +263 -0
- package/dist/trpc/routers/assets.js.map +1 -0
- package/dist/trpc/routers/collections.d.ts +88 -0
- package/dist/trpc/routers/collections.d.ts.map +1 -0
- package/dist/trpc/routers/collections.js +166 -0
- package/dist/trpc/routers/collections.js.map +1 -0
- package/dist/trpc/routers/costs.d.ts +129 -0
- package/dist/trpc/routers/costs.d.ts.map +1 -0
- package/dist/trpc/routers/costs.js +95 -0
- package/dist/trpc/routers/costs.js.map +1 -0
- package/dist/trpc/routers/files.d.ts +34 -0
- package/dist/trpc/routers/files.d.ts.map +1 -0
- package/dist/trpc/routers/files.js +92 -0
- package/dist/trpc/routers/files.js.map +1 -0
- package/dist/trpc/routers/jobs.d.ts +92 -0
- package/dist/trpc/routers/jobs.d.ts.map +1 -0
- package/dist/trpc/routers/jobs.js +100 -0
- package/dist/trpc/routers/jobs.js.map +1 -0
- package/dist/trpc/routers/mcp-config.d.ts +69 -0
- package/dist/trpc/routers/mcp-config.d.ts.map +1 -0
- package/dist/trpc/routers/mcp-config.js +290 -0
- package/dist/trpc/routers/mcp-config.js.map +1 -0
- package/dist/trpc/routers/messages.d.ts +121 -0
- package/dist/trpc/routers/messages.d.ts.map +1 -0
- package/dist/trpc/routers/messages.js +113 -0
- package/dist/trpc/routers/messages.js.map +1 -0
- package/dist/trpc/routers/models.d.ts +748 -0
- package/dist/trpc/routers/models.d.ts.map +1 -0
- package/dist/trpc/routers/models.js +1107 -0
- package/dist/trpc/routers/models.js.map +1 -0
- package/dist/trpc/routers/nodes.d.ts +48 -0
- package/dist/trpc/routers/nodes.d.ts.map +1 -0
- package/dist/trpc/routers/nodes.js +57 -0
- package/dist/trpc/routers/nodes.js.map +1 -0
- package/dist/trpc/routers/sandboxes.d.ts +64 -0
- package/dist/trpc/routers/sandboxes.d.ts.map +1 -0
- package/dist/trpc/routers/sandboxes.js +327 -0
- package/dist/trpc/routers/sandboxes.js.map +1 -0
- package/dist/trpc/routers/settings.d.ts +113 -0
- package/dist/trpc/routers/settings.d.ts.map +1 -0
- package/dist/trpc/routers/settings.js +193 -0
- package/dist/trpc/routers/settings.js.map +1 -0
- package/dist/trpc/routers/skills.d.ts +41 -0
- package/dist/trpc/routers/skills.d.ts.map +1 -0
- package/dist/trpc/routers/skills.js +194 -0
- package/dist/trpc/routers/skills.js.map +1 -0
- package/dist/trpc/routers/storage.d.ts +44 -0
- package/dist/trpc/routers/storage.d.ts.map +1 -0
- package/dist/trpc/routers/storage.js +153 -0
- package/dist/trpc/routers/storage.js.map +1 -0
- package/dist/trpc/routers/threads.d.ts +95 -0
- package/dist/trpc/routers/threads.d.ts.map +1 -0
- package/dist/trpc/routers/threads.js +150 -0
- package/dist/trpc/routers/threads.js.map +1 -0
- package/dist/trpc/routers/users.d.ts +79 -0
- package/dist/trpc/routers/users.d.ts.map +1 -0
- package/dist/trpc/routers/users.js +125 -0
- package/dist/trpc/routers/users.js.map +1 -0
- package/dist/trpc/routers/workflows.d.ts +681 -0
- package/dist/trpc/routers/workflows.d.ts.map +1 -0
- package/dist/trpc/routers/workflows.js +725 -0
- package/dist/trpc/routers/workflows.js.map +1 -0
- package/dist/trpc/routers/workspace.d.ts +127 -0
- package/dist/trpc/routers/workspace.d.ts.map +1 -0
- package/dist/trpc/routers/workspace.js +203 -0
- package/dist/trpc/routers/workspace.js.map +1 -0
- package/dist/unified-websocket-runner.d.ts +280 -0
- package/dist/unified-websocket-runner.d.ts.map +1 -0
- package/dist/unified-websocket-runner.js +3840 -0
- package/dist/unified-websocket-runner.js.map +1 -0
- package/dist/workspace-api.d.ts +14 -0
- package/dist/workspace-api.d.ts.map +1 -0
- package/dist/workspace-api.js +104 -0
- package/dist/workspace-api.js.map +1 -0
- package/dist/ws-adapter.d.ts +25 -0
- package/dist/ws-adapter.d.ts.map +1 -0
- package/dist/ws-adapter.js +63 -0
- package/dist/ws-adapter.js.map +1 -0
- package/package.json +92 -0
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { createLogger, getDefaultDbPath } from "@nodetool-ai/config";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { WebSocketServer } from "ws";
|
|
7
|
+
import { NodeRegistry, createGraphNodeTypeResolver } from "@nodetool-ai/node-sdk";
|
|
8
|
+
import { registerBaseNodes } from "@nodetool-ai/base-nodes";
|
|
9
|
+
import { registerElevenLabsNodes } from "@nodetool-ai/elevenlabs-nodes";
|
|
10
|
+
import { registerTransformersJsNodes } from "@nodetool-ai/transformers-js-nodes";
|
|
11
|
+
import { UnifiedWebSocketRunner } from "./unified-websocket-runner.js";
|
|
12
|
+
import { ScriptedProvider, autoScript } from "@nodetool-ai/runtime";
|
|
13
|
+
import { handleNodeHttpRequest } from "./http-api.js";
|
|
14
|
+
import { initDb } from "@nodetool-ai/models";
|
|
15
|
+
const log = createLogger("nodetool.websocket.server");
|
|
16
|
+
function htmlPage() {
|
|
17
|
+
return `<!doctype html>
|
|
18
|
+
<html lang="en">
|
|
19
|
+
<head>
|
|
20
|
+
<meta charset="utf-8" />
|
|
21
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
22
|
+
<title>NodeTool Pipeline Tester</title>
|
|
23
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
24
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
25
|
+
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
|
26
|
+
<style>
|
|
27
|
+
html, body, #root { margin: 0; min-height: 100%; }
|
|
28
|
+
</style>
|
|
29
|
+
</head>
|
|
30
|
+
<body>
|
|
31
|
+
<div id="root"></div>
|
|
32
|
+
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
|
|
33
|
+
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
|
|
34
|
+
<script src="https://unpkg.com/@mui/material@5.15.19/umd/material-ui.development.js" crossorigin></script>
|
|
35
|
+
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
36
|
+
<script type="text/babel" data-presets="env,react">
|
|
37
|
+
function normalizeType(raw) {
|
|
38
|
+
const t = String(raw || '').toLowerCase();
|
|
39
|
+
if (!t) return 'any';
|
|
40
|
+
if (['any', 'object', 'unknown'].includes(t)) return 'any';
|
|
41
|
+
if (['string', 'str', 'text'].includes(t)) return 'str';
|
|
42
|
+
if (['int', 'integer'].includes(t)) return 'int';
|
|
43
|
+
if (['float', 'double', 'number'].includes(t)) return 'float';
|
|
44
|
+
if (['bool', 'boolean'].includes(t)) return 'bool';
|
|
45
|
+
if (['list', 'array'].includes(t)) return 'list';
|
|
46
|
+
if (['dict', 'dictionary', 'map'].includes(t)) return 'dict';
|
|
47
|
+
return t;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function outputType(out) {
|
|
51
|
+
return normalizeType(out?.type?.type);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function inputType(prop) {
|
|
55
|
+
return normalizeType(prop?.type?.type);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isTypeCompatible(fromType, toType) {
|
|
59
|
+
if (fromType === 'any' || toType === 'any') return true;
|
|
60
|
+
if (fromType === toType) return true;
|
|
61
|
+
if (fromType === 'int' && toType === 'float') return true;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function chooseLink(aMd, bMd) {
|
|
66
|
+
const outputs = Array.isArray(aMd?.outputs) ? aMd.outputs : [];
|
|
67
|
+
const inputs = Array.isArray(bMd?.properties) ? bMd.properties : [];
|
|
68
|
+
if (!outputs.length || !inputs.length) {
|
|
69
|
+
return {
|
|
70
|
+
sourceHandle: outputs[0]?.name || 'output',
|
|
71
|
+
targetHandle: inputs[0]?.name || 'input',
|
|
72
|
+
warning: 'Fallback handle mapping due to missing metadata slots',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const out of outputs) {
|
|
77
|
+
for (const inp of inputs) {
|
|
78
|
+
const ot = outputType(out);
|
|
79
|
+
const it = inputType(inp);
|
|
80
|
+
if (isTypeCompatible(ot, it)) {
|
|
81
|
+
return { sourceHandle: out.name, targetHandle: inp.name, warning: null };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
sourceHandle: outputs[0].name,
|
|
88
|
+
targetHandle: inputs[0].name,
|
|
89
|
+
warning: 'No compatible type match (' + outputType(outputs[0]) + ' -> ' + inputType(inputs[0]) + '), using first handles',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const OUTPUT_NODE_TYPE = 'nodetool.output.Output';
|
|
94
|
+
|
|
95
|
+
const {
|
|
96
|
+
Alert, Box, Button, Chip, CssBaseline, Divider, FormControlLabel, Grid, LinearProgress, List,
|
|
97
|
+
ListItemButton, ListItemText, MenuItem, Paper, Stack, TextField, ThemeProvider, Typography, Checkbox, createTheme
|
|
98
|
+
} = MaterialUI;
|
|
99
|
+
const { useEffect, useMemo, useRef, useState } = React;
|
|
100
|
+
|
|
101
|
+
function propertyEnumValues(prop) {
|
|
102
|
+
if (Array.isArray(prop?.type?.values) && prop.type.values.length > 0) return prop.type.values;
|
|
103
|
+
if (Array.isArray(prop?.type?.type_args?.[0]?.values) && prop.type.type_args[0].values.length > 0) {
|
|
104
|
+
return prop.type.type_args[0].values;
|
|
105
|
+
}
|
|
106
|
+
if (Array.isArray(prop?.values) && prop.values.length > 0) return prop.values;
|
|
107
|
+
if (Array.isArray(prop?.enum) && prop.enum.length > 0) return prop.enum;
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function inputEditorKind(prop) {
|
|
112
|
+
const t = String(prop?.type?.type || 'str').toLowerCase();
|
|
113
|
+
const enumValues = propertyEnumValues(prop);
|
|
114
|
+
if (enumValues.length > 0 || t === 'enum' || t === 'select') return 'enum';
|
|
115
|
+
if (t === 'bool' || t === 'boolean') return 'checkbox';
|
|
116
|
+
if (['int', 'integer', 'float', 'number'].includes(t)) return 'number';
|
|
117
|
+
if (['text'].includes(t)) return 'multiline';
|
|
118
|
+
if (['list', 'dict', 'json', 'record_type', 'dataframe', 'image_size'].includes(t)) return 'json';
|
|
119
|
+
if (t === 'union') return 'json';
|
|
120
|
+
return 'text';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizePropertyValue(prop, value) {
|
|
124
|
+
const kind = inputEditorKind(prop);
|
|
125
|
+
if (kind === 'checkbox') return Boolean(value);
|
|
126
|
+
if (kind === 'number') {
|
|
127
|
+
if (value === '' || value === null || value === undefined) return null;
|
|
128
|
+
const n = Number(value);
|
|
129
|
+
if (!Number.isFinite(n)) return value;
|
|
130
|
+
const t = String(prop?.type?.type || '').toLowerCase();
|
|
131
|
+
return (t === 'int' || t === 'integer') ? Math.trunc(n) : n;
|
|
132
|
+
}
|
|
133
|
+
if (kind === 'json') {
|
|
134
|
+
if (value === null || value === undefined) return null;
|
|
135
|
+
if (typeof value !== 'string') return value;
|
|
136
|
+
const raw = value.trim();
|
|
137
|
+
if (!raw) return null;
|
|
138
|
+
try {
|
|
139
|
+
return JSON.parse(raw);
|
|
140
|
+
} catch {
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function App() {
|
|
148
|
+
const [metadata, setMetadata] = useState([]);
|
|
149
|
+
const [search, setSearch] = useState('');
|
|
150
|
+
const [selectedNodeType, setSelectedNodeType] = useState('');
|
|
151
|
+
const [steps, setSteps] = useState([]);
|
|
152
|
+
const [savedWorkflows, setSavedWorkflows] = useState([]);
|
|
153
|
+
const [selectedWorkflowId, setSelectedWorkflowId] = useState('');
|
|
154
|
+
const [examples, setExamples] = useState([]);
|
|
155
|
+
const [selectedExampleId, setSelectedExampleId] = useState('');
|
|
156
|
+
const [importedGraph, setImportedGraph] = useState(null);
|
|
157
|
+
const [importedGraphName, setImportedGraphName] = useState('');
|
|
158
|
+
const [workflowName, setWorkflowName] = useState('Quick Test Pipeline');
|
|
159
|
+
const [paramsText, setParamsText] = useState('');
|
|
160
|
+
const [fixtureKey, setFixtureKey] = useState('text');
|
|
161
|
+
const [statusText, setStatusText] = useState('Idle');
|
|
162
|
+
const [logs, setLogs] = useState([]);
|
|
163
|
+
const [runEvents, setRunEvents] = useState([]);
|
|
164
|
+
const [outputEvents, setOutputEvents] = useState([]);
|
|
165
|
+
const [jobId, setJobId] = useState(null);
|
|
166
|
+
const [fixtureFile, setFixtureFile] = useState(null);
|
|
167
|
+
const [isRunning, setIsRunning] = useState(false);
|
|
168
|
+
const [elapsedTime, setElapsedTime] = useState(0);
|
|
169
|
+
const wsRef = useRef(null);
|
|
170
|
+
const startTimeRef = useRef(null);
|
|
171
|
+
|
|
172
|
+
const log = (msg, type) => {
|
|
173
|
+
const time = new Date().toLocaleTimeString();
|
|
174
|
+
setLogs((prev) => [{ text: '[' + time + '] ' + msg, type: type || 'info' }, ...prev].slice(0, 200));
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const filtered = useMemo(() => {
|
|
178
|
+
const q = search.trim().toLowerCase();
|
|
179
|
+
return metadata.filter((n) => (
|
|
180
|
+
!q ||
|
|
181
|
+
String(n.title || '').toLowerCase().includes(q) ||
|
|
182
|
+
String(n.node_type || '').toLowerCase().includes(q) ||
|
|
183
|
+
String(n.namespace || '').toLowerCase().includes(q)
|
|
184
|
+
));
|
|
185
|
+
}, [metadata, search]);
|
|
186
|
+
|
|
187
|
+
const graphData = useMemo(() => {
|
|
188
|
+
if (importedGraph && Array.isArray(importedGraph.nodes) && Array.isArray(importedGraph.edges)) {
|
|
189
|
+
return { graph: importedGraph, warnings: ['Loaded example graph (read-only in builder mode)'] };
|
|
190
|
+
}
|
|
191
|
+
const state = { metadata, steps };
|
|
192
|
+
return compileGraph(state);
|
|
193
|
+
}, [metadata, steps, importedGraph]);
|
|
194
|
+
|
|
195
|
+
const summaryText = 'events=' + runEvents.length + ', outputs=' + outputEvents.length;
|
|
196
|
+
const lastOutput = outputEvents[outputEvents.length - 1];
|
|
197
|
+
const formatDuration = (seconds) => {
|
|
198
|
+
if (seconds < 1) return Math.round(seconds * 1000) + 'ms';
|
|
199
|
+
if (seconds < 60) return seconds.toFixed(1) + 's';
|
|
200
|
+
const minutes = Math.floor(seconds / 60);
|
|
201
|
+
const remain = Math.round(seconds % 60);
|
|
202
|
+
return minutes + 'm ' + remain + 's';
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
async function refreshSavedWorkflows() {
|
|
206
|
+
const res = await fetch('/api/workflows?run_mode=test&limit=200');
|
|
207
|
+
if (!res.ok) throw new Error('failed to list workflows: ' + res.status);
|
|
208
|
+
const body = await res.json();
|
|
209
|
+
setSavedWorkflows(Array.isArray(body.workflows) ? body.workflows : []);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function refreshExamples() {
|
|
213
|
+
const res = await fetch('/api/examples');
|
|
214
|
+
if (!res.ok) throw new Error('failed to list examples: ' + res.status);
|
|
215
|
+
const body = await res.json();
|
|
216
|
+
setExamples(Array.isArray(body.examples) ? body.examples : []);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function loadMetadata() {
|
|
220
|
+
const res = await fetch('/api/node/metadata');
|
|
221
|
+
if (!res.ok) throw new Error('metadata fetch failed: ' + res.status);
|
|
222
|
+
const all = await res.json();
|
|
223
|
+
const filteredAll = all.filter((n) => n && typeof n.node_type === 'string');
|
|
224
|
+
setMetadata(filteredAll);
|
|
225
|
+
log('Loaded ' + filteredAll.length + ' node metadata entries', 'ok');
|
|
226
|
+
await refreshSavedWorkflows();
|
|
227
|
+
await refreshExamples();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
loadMetadata().catch((err) => log(String(err), 'err'));
|
|
232
|
+
}, []);
|
|
233
|
+
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (!isRunning) {
|
|
236
|
+
startTimeRef.current = null;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
startTimeRef.current = Date.now();
|
|
240
|
+
setElapsedTime(0);
|
|
241
|
+
const interval = setInterval(() => {
|
|
242
|
+
if (startTimeRef.current) {
|
|
243
|
+
setElapsedTime((Date.now() - startTimeRef.current) / 1000);
|
|
244
|
+
}
|
|
245
|
+
}, 100);
|
|
246
|
+
return () => clearInterval(interval);
|
|
247
|
+
}, [isRunning]);
|
|
248
|
+
|
|
249
|
+
async function connectWs() {
|
|
250
|
+
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return wsRef.current;
|
|
251
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
252
|
+
const ws = new WebSocket(proto + '//' + location.host + '/ws');
|
|
253
|
+
return await new Promise((resolve, reject) => {
|
|
254
|
+
ws.onopen = () => {
|
|
255
|
+
wsRef.current = ws;
|
|
256
|
+
ws.send(JSON.stringify({ command: 'set_mode', data: { mode: 'text' } }));
|
|
257
|
+
log('WS connected', 'ok');
|
|
258
|
+
resolve(ws);
|
|
259
|
+
};
|
|
260
|
+
ws.onerror = (err) => reject(err);
|
|
261
|
+
ws.onclose = () => {
|
|
262
|
+
log('WS disconnected', 'err');
|
|
263
|
+
if (wsRef.current === ws) wsRef.current = null;
|
|
264
|
+
setIsRunning(false);
|
|
265
|
+
};
|
|
266
|
+
ws.onmessage = (event) => {
|
|
267
|
+
let data = null;
|
|
268
|
+
try { data = JSON.parse(event.data); } catch { return; }
|
|
269
|
+
setRunEvents((prev) => [...prev, data]);
|
|
270
|
+
if (data.type === 'output_update') {
|
|
271
|
+
setOutputEvents((prev) => [...prev, data]);
|
|
272
|
+
}
|
|
273
|
+
if (data.type === 'job_update') {
|
|
274
|
+
if (typeof data.job_id === 'string' && data.job_id) setJobId(data.job_id);
|
|
275
|
+
const status = String(data.status || '');
|
|
276
|
+
if (status === 'running') setIsRunning(true);
|
|
277
|
+
if (['completed', 'failed', 'cancelled'].includes(status)) {
|
|
278
|
+
setJobId(null);
|
|
279
|
+
setIsRunning(false);
|
|
280
|
+
}
|
|
281
|
+
setStatusText('Job ' + (data.job_id || '') + ': ' + data.status + (data.error ? (' (' + data.error + ')') : ''));
|
|
282
|
+
}
|
|
283
|
+
if (data.error) log('Error: ' + data.error, 'err');
|
|
284
|
+
else if (data.message) log(data.message, 'ok');
|
|
285
|
+
else if (data.type) log(data.type + (data.status ? (' -> ' + data.status) : ''), 'info');
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function runPipeline() {
|
|
291
|
+
try {
|
|
292
|
+
const ws = await connectWs();
|
|
293
|
+
setJobId(null);
|
|
294
|
+
setRunEvents([]);
|
|
295
|
+
setOutputEvents([]);
|
|
296
|
+
setIsRunning(true);
|
|
297
|
+
const raw = paramsText.trim();
|
|
298
|
+
const params = raw ? JSON.parse(raw) : {};
|
|
299
|
+
if (!steps.length) {
|
|
300
|
+
log('Add at least one step', 'err');
|
|
301
|
+
setIsRunning(false);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
ws.send(JSON.stringify({
|
|
305
|
+
command: 'run_job',
|
|
306
|
+
data: { graph: graphData.graph, params }
|
|
307
|
+
}));
|
|
308
|
+
setStatusText('Submitted run request...');
|
|
309
|
+
} catch (err) {
|
|
310
|
+
log('Run failed: ' + String(err), 'err');
|
|
311
|
+
setIsRunning(false);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function cancelRun() {
|
|
316
|
+
const ws = wsRef.current;
|
|
317
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
318
|
+
if (!jobId) {
|
|
319
|
+
log('No active job to cancel yet', 'err');
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
ws.send(JSON.stringify({ command: 'cancel_job', data: { job_id: jobId } }));
|
|
323
|
+
log('Cancellation requested for job ' + jobId, 'info');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function saveCurrentWorkflow() {
|
|
327
|
+
const name = (workflowName || '').trim() || 'Quick Test Pipeline';
|
|
328
|
+
if (!steps.length) {
|
|
329
|
+
log('Cannot save empty pipeline', 'err');
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const payload = {
|
|
333
|
+
name,
|
|
334
|
+
access: 'private',
|
|
335
|
+
description: 'Saved from TS test UI',
|
|
336
|
+
run_mode: 'test',
|
|
337
|
+
graph: graphData.graph,
|
|
338
|
+
tags: ['test-ui']
|
|
339
|
+
};
|
|
340
|
+
const res = await fetch('/api/workflows', {
|
|
341
|
+
method: 'POST',
|
|
342
|
+
headers: { 'content-type': 'application/json' },
|
|
343
|
+
body: JSON.stringify(payload)
|
|
344
|
+
});
|
|
345
|
+
if (!res.ok) {
|
|
346
|
+
const detail = await res.text();
|
|
347
|
+
throw new Error('save failed: ' + res.status + ' ' + detail);
|
|
348
|
+
}
|
|
349
|
+
const wf = await res.json();
|
|
350
|
+
setSelectedWorkflowId(wf.id);
|
|
351
|
+
log('Saved test workflow: ' + wf.name, 'ok');
|
|
352
|
+
await refreshSavedWorkflows();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function loadSelectedExample() {
|
|
356
|
+
if (!selectedExampleId) {
|
|
357
|
+
log('Select an example first', 'err');
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const res = await fetch('/api/examples/' + encodeURIComponent(selectedExampleId));
|
|
361
|
+
if (!res.ok) throw new Error('example load failed: ' + res.status);
|
|
362
|
+
const example = await res.json();
|
|
363
|
+
if (!example?.graph || !Array.isArray(example.graph.nodes) || !Array.isArray(example.graph.edges)) {
|
|
364
|
+
throw new Error('example graph is invalid');
|
|
365
|
+
}
|
|
366
|
+
setImportedGraph(example.graph);
|
|
367
|
+
setImportedGraphName(typeof example.name === 'string' ? example.name : selectedExampleId);
|
|
368
|
+
const nodes = Array.isArray(example.graph.nodes) ? example.graph.nodes : [];
|
|
369
|
+
setSteps(
|
|
370
|
+
nodes
|
|
371
|
+
.filter((n) => n && n.type !== OUTPUT_NODE_TYPE)
|
|
372
|
+
.map((n) => ({
|
|
373
|
+
type: n.type,
|
|
374
|
+
properties:
|
|
375
|
+
n && typeof n === 'object' && n.data && typeof n.data === 'object'
|
|
376
|
+
? n.data
|
|
377
|
+
: n && typeof n === 'object' && n.properties && typeof n.properties === 'object'
|
|
378
|
+
? n.properties
|
|
379
|
+
: {}
|
|
380
|
+
}))
|
|
381
|
+
);
|
|
382
|
+
log('Loaded example: ' + (example.name || selectedExampleId), 'ok');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function loadSelectedWorkflow() {
|
|
386
|
+
if (!selectedWorkflowId) {
|
|
387
|
+
log('Select a saved workflow first', 'err');
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const res = await fetch('/api/workflows/' + encodeURIComponent(selectedWorkflowId));
|
|
391
|
+
if (!res.ok) throw new Error('load failed: ' + res.status);
|
|
392
|
+
const wf = await res.json();
|
|
393
|
+
const nodes = Array.isArray(wf.graph?.nodes) ? wf.graph.nodes : [];
|
|
394
|
+
setSteps(
|
|
395
|
+
nodes
|
|
396
|
+
.filter((n) => n && n.type !== OUTPUT_NODE_TYPE)
|
|
397
|
+
.map((n) => ({
|
|
398
|
+
type: n.type,
|
|
399
|
+
properties: n.properties && typeof n.properties === 'object' ? n.properties : {}
|
|
400
|
+
}))
|
|
401
|
+
);
|
|
402
|
+
setImportedGraph(null);
|
|
403
|
+
setImportedGraphName('');
|
|
404
|
+
if (wf.name) setWorkflowName(wf.name);
|
|
405
|
+
log('Loaded workflow: ' + (wf.name || selectedWorkflowId), 'ok');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function applyFixture() {
|
|
409
|
+
const key = (fixtureKey || '').trim();
|
|
410
|
+
const file = fixtureFile;
|
|
411
|
+
if (!key) {
|
|
412
|
+
log('Fixture param key is required', 'err');
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (!file) {
|
|
416
|
+
log('Choose a fixture file first', 'err');
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const params = paramsText.trim() ? JSON.parse(paramsText) : {};
|
|
420
|
+
if (file.type.startsWith('text/') || file.name.endsWith('.txt') || file.name.endsWith('.md') || file.name.endsWith('.json')) {
|
|
421
|
+
params[key] = await file.text();
|
|
422
|
+
} else if (file.type.startsWith('image/')) {
|
|
423
|
+
const dataUri = await new Promise((resolve, reject) => {
|
|
424
|
+
const reader = new FileReader();
|
|
425
|
+
reader.onerror = () => reject(new Error('Failed reading image file'));
|
|
426
|
+
reader.onload = () => resolve(String(reader.result || ''));
|
|
427
|
+
reader.readAsDataURL(file);
|
|
428
|
+
});
|
|
429
|
+
params[key] = dataUri;
|
|
430
|
+
} else {
|
|
431
|
+
log('Unsupported fixture type for quick mode; use text/image', 'err');
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
setParamsText(JSON.stringify(params, null, 2));
|
|
435
|
+
log('Fixture applied to params key: ' + key, 'ok');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const theme = useMemo(() => createTheme({
|
|
439
|
+
palette: {
|
|
440
|
+
mode: 'dark',
|
|
441
|
+
primary: { main: '#3b82f6' },
|
|
442
|
+
secondary: { main: '#66bb6a' },
|
|
443
|
+
error: { main: '#ef5350' },
|
|
444
|
+
background: { default: '#0b1220', paper: '#0f172a' }
|
|
445
|
+
},
|
|
446
|
+
shape: { borderRadius: 12 },
|
|
447
|
+
components: {
|
|
448
|
+
MuiPaper: {
|
|
449
|
+
styleOverrides: {
|
|
450
|
+
root: {
|
|
451
|
+
border: '1px solid rgba(148,163,184,0.16)',
|
|
452
|
+
boxShadow: '0 12px 30px rgba(2,6,23,0.28)'
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}), []);
|
|
458
|
+
|
|
459
|
+
return (
|
|
460
|
+
<ThemeProvider theme={theme}>
|
|
461
|
+
<CssBaseline />
|
|
462
|
+
<Box sx={{
|
|
463
|
+
minHeight: '100vh',
|
|
464
|
+
p: { xs: 1.5, md: 3 },
|
|
465
|
+
background: 'radial-gradient(1200px 700px at 0% -15%, rgba(59,130,246,0.22) 0%, transparent 62%), radial-gradient(900px 500px at 100% -20%, rgba(16,185,129,0.16) 0%, transparent 60%), #0b1220'
|
|
466
|
+
}}>
|
|
467
|
+
<Box sx={{ maxWidth: 1700, mx: 'auto' }}>
|
|
468
|
+
<Stack spacing={0.75} sx={{ mb: 2 }}>
|
|
469
|
+
<Typography variant="h4" sx={{ fontWeight: 300 }}>NodeTool Pipeline Tester</Typography>
|
|
470
|
+
<Typography variant="body2" color="text.secondary">
|
|
471
|
+
Compose a quick test workflow, run it over websocket, and inspect outputs.
|
|
472
|
+
</Typography>
|
|
473
|
+
</Stack>
|
|
474
|
+
{isRunning && (
|
|
475
|
+
<Paper sx={{ p: 1.25, mb: 2 }}>
|
|
476
|
+
<Stack direction="row" spacing={1.25} alignItems="center">
|
|
477
|
+
<Box sx={{ flex: 1 }}>
|
|
478
|
+
<LinearProgress />
|
|
479
|
+
</Box>
|
|
480
|
+
<Typography variant="caption" color="text.secondary">
|
|
481
|
+
Running • {formatDuration(elapsedTime)}
|
|
482
|
+
</Typography>
|
|
483
|
+
</Stack>
|
|
484
|
+
</Paper>
|
|
485
|
+
)}
|
|
486
|
+
<Grid container spacing={2}>
|
|
487
|
+
<Grid item xs={12} lg={3}>
|
|
488
|
+
<Paper sx={{ p: 2 }}>
|
|
489
|
+
<Stack spacing={1.25}>
|
|
490
|
+
<Typography variant="h6">Pipeline Builder</Typography>
|
|
491
|
+
<TextField size="small" label="Search nodes" value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
492
|
+
<List dense sx={{ maxHeight: 260, overflow: 'auto', border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
|
493
|
+
{filtered.map((md) => (
|
|
494
|
+
<ListItemButton key={md.node_type} selected={selectedNodeType === md.node_type} onClick={() => setSelectedNodeType(md.node_type)}>
|
|
495
|
+
<ListItemText primary={md.title + ' (' + md.node_type + ')'} />
|
|
496
|
+
</ListItemButton>
|
|
497
|
+
))}
|
|
498
|
+
</List>
|
|
499
|
+
<Button
|
|
500
|
+
variant="contained"
|
|
501
|
+
disabled={Boolean(importedGraph)}
|
|
502
|
+
onClick={() => selectedNodeType && setSteps((prev) => [...prev, { type: selectedNodeType, properties: {} }])}
|
|
503
|
+
>
|
|
504
|
+
Add Step
|
|
505
|
+
</Button>
|
|
506
|
+
<Divider />
|
|
507
|
+
<Typography variant="subtitle2">Saved Tests</Typography>
|
|
508
|
+
<List dense sx={{ maxHeight: 180, overflow: 'auto', border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
|
509
|
+
{savedWorkflows.map((wf) => (
|
|
510
|
+
<ListItemButton key={wf.id} selected={selectedWorkflowId === wf.id} onClick={() => setSelectedWorkflowId(wf.id)}>
|
|
511
|
+
<ListItemText primary={wf.name + ' (' + String(wf.id).slice(0, 8) + ')'} />
|
|
512
|
+
</ListItemButton>
|
|
513
|
+
))}
|
|
514
|
+
</List>
|
|
515
|
+
<Typography variant="subtitle2">NodeTool-Base Examples</Typography>
|
|
516
|
+
<List dense sx={{ maxHeight: 200, overflow: 'auto', border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
|
517
|
+
{examples.map((ex) => (
|
|
518
|
+
<ListItemButton key={ex.id} selected={selectedExampleId === ex.id} onClick={() => setSelectedExampleId(ex.id)}>
|
|
519
|
+
<ListItemText
|
|
520
|
+
primary={ex.name || ex.id}
|
|
521
|
+
secondary={ex.description || ''}
|
|
522
|
+
secondaryTypographyProps={{ sx: { display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical', overflow: 'hidden' } }}
|
|
523
|
+
/>
|
|
524
|
+
</ListItemButton>
|
|
525
|
+
))}
|
|
526
|
+
</List>
|
|
527
|
+
<Button variant="outlined" onClick={() => void loadSelectedExample().catch((err) => log(String(err), 'err'))}>Load Example</Button>
|
|
528
|
+
{importedGraph && (
|
|
529
|
+
<Alert
|
|
530
|
+
severity="info"
|
|
531
|
+
action={<Button size="small" color="inherit" onClick={() => { setImportedGraph(null); setImportedGraphName(''); }}>Use Builder</Button>}
|
|
532
|
+
>
|
|
533
|
+
Using imported example graph: {importedGraphName || selectedExampleId}
|
|
534
|
+
</Alert>
|
|
535
|
+
)}
|
|
536
|
+
<TextField size="small" label="Workflow name" value={workflowName} onChange={(e) => setWorkflowName(e.target.value)} />
|
|
537
|
+
<Stack direction="row" spacing={1}>
|
|
538
|
+
<Button fullWidth variant="outlined" onClick={() => void saveCurrentWorkflow().catch((err) => log(String(err), 'err'))}>Save Test</Button>
|
|
539
|
+
<Button fullWidth variant="outlined" onClick={() => void loadSelectedWorkflow().catch((err) => log(String(err), 'err'))}>Load</Button>
|
|
540
|
+
</Stack>
|
|
541
|
+
<Typography variant="subtitle2">Run Params (JSON)</Typography>
|
|
542
|
+
<TextField multiline minRows={7} value={paramsText} onChange={(e) => setParamsText(e.target.value)} placeholder='{"text":"hello"}' />
|
|
543
|
+
<Typography variant="subtitle2">Asset Fixture</Typography>
|
|
544
|
+
<TextField size="small" label="Param key" value={fixtureKey} onChange={(e) => setFixtureKey(e.target.value)} />
|
|
545
|
+
<Button variant="outlined" component="label">
|
|
546
|
+
{fixtureFile ? fixtureFile.name : 'Choose fixture file'}
|
|
547
|
+
<input hidden type="file" onChange={(e) => setFixtureFile(e.target.files && e.target.files[0] ? e.target.files[0] : null)} />
|
|
548
|
+
</Button>
|
|
549
|
+
<Button variant="outlined" onClick={() => void applyFixture().catch((err) => log(String(err), 'err'))}>Apply Fixture To Params</Button>
|
|
550
|
+
<Typography variant="caption" color="text.secondary">Text files become plain strings, images become data URIs.</Typography>
|
|
551
|
+
<Stack direction="row" spacing={1}>
|
|
552
|
+
<Button fullWidth variant="contained" color={isRunning ? 'warning' : 'secondary'} onClick={isRunning ? cancelRun : runPipeline}>
|
|
553
|
+
{isRunning ? 'Stop' : 'Run Pipeline'}
|
|
554
|
+
</Button>
|
|
555
|
+
<Button fullWidth variant="contained" color="error" onClick={cancelRun} disabled={!isRunning}>Cancel</Button>
|
|
556
|
+
</Stack>
|
|
557
|
+
<Typography variant="body2" color="text.secondary">{statusText}</Typography>
|
|
558
|
+
</Stack>
|
|
559
|
+
</Paper>
|
|
560
|
+
</Grid>
|
|
561
|
+
<Grid item xs={12} lg={5}>
|
|
562
|
+
<Paper sx={{ p: 2 }}>
|
|
563
|
+
<Stack spacing={1.25}>
|
|
564
|
+
<Typography variant="h6">Steps</Typography>
|
|
565
|
+
{steps.length === 0 && <Typography variant="body2" color="text.secondary">No steps yet</Typography>}
|
|
566
|
+
{steps.map((step, idx) => {
|
|
567
|
+
const md = metadata.find((m) => m.node_type === step.type);
|
|
568
|
+
const propList = md?.properties || [];
|
|
569
|
+
return (
|
|
570
|
+
<Paper key={idx} variant="outlined" sx={{ p: 1.25 }}>
|
|
571
|
+
<Stack spacing={1}>
|
|
572
|
+
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
573
|
+
<Typography variant="subtitle2">{(idx + 1) + '. ' + (md?.title || step.type)}</Typography>
|
|
574
|
+
<Chip size="small" label={md?.namespace || ''} />
|
|
575
|
+
</Stack>
|
|
576
|
+
<Typography variant="caption" color="text.secondary">{step.type}</Typography>
|
|
577
|
+
{propList.map((prop) => {
|
|
578
|
+
const kind = inputEditorKind(prop);
|
|
579
|
+
const current = step.properties[prop.name] ?? prop.default ?? '';
|
|
580
|
+
const enumValues = propertyEnumValues(prop);
|
|
581
|
+
if (kind === 'checkbox') {
|
|
582
|
+
return (
|
|
583
|
+
<FormControlLabel
|
|
584
|
+
key={prop.name}
|
|
585
|
+
control={
|
|
586
|
+
<Checkbox
|
|
587
|
+
checked={Boolean(current)}
|
|
588
|
+
disabled={Boolean(importedGraph)}
|
|
589
|
+
onChange={(e) => {
|
|
590
|
+
setSteps((prev) => prev.map((s, i) => i === idx ? { ...s, properties: { ...s.properties, [prop.name]: e.target.checked } } : s));
|
|
591
|
+
}}
|
|
592
|
+
/>
|
|
593
|
+
}
|
|
594
|
+
label={prop.name}
|
|
595
|
+
/>
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
if (kind === 'enum') {
|
|
599
|
+
const value = current === null || current === undefined ? '' : String(current);
|
|
600
|
+
return (
|
|
601
|
+
<TextField
|
|
602
|
+
key={prop.name}
|
|
603
|
+
size="small"
|
|
604
|
+
select
|
|
605
|
+
label={prop.name}
|
|
606
|
+
value={value}
|
|
607
|
+
disabled={Boolean(importedGraph)}
|
|
608
|
+
onChange={(e) => {
|
|
609
|
+
setSteps((prev) => prev.map((s, i) => i === idx ? { ...s, properties: { ...s.properties, [prop.name]: e.target.value } } : s));
|
|
610
|
+
}}
|
|
611
|
+
>
|
|
612
|
+
{enumValues.map((opt, optIdx) => (
|
|
613
|
+
<MenuItem key={String(opt) + ':' + optIdx} value={String(opt)}>{String(opt)}</MenuItem>
|
|
614
|
+
))}
|
|
615
|
+
</TextField>
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
if (kind === 'json') {
|
|
619
|
+
const value = typeof current === 'string' ? current : JSON.stringify(current, null, 2);
|
|
620
|
+
return (
|
|
621
|
+
<TextField
|
|
622
|
+
key={prop.name}
|
|
623
|
+
size="small"
|
|
624
|
+
multiline
|
|
625
|
+
minRows={3}
|
|
626
|
+
label={prop.name}
|
|
627
|
+
value={value}
|
|
628
|
+
disabled={Boolean(importedGraph)}
|
|
629
|
+
onChange={(e) => {
|
|
630
|
+
setSteps((prev) => prev.map((s, i) => i === idx ? { ...s, properties: { ...s.properties, [prop.name]: e.target.value } } : s));
|
|
631
|
+
}}
|
|
632
|
+
/>
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
return (
|
|
636
|
+
<TextField
|
|
637
|
+
key={prop.name}
|
|
638
|
+
size="small"
|
|
639
|
+
type={kind === 'multiline' ? 'text' : kind}
|
|
640
|
+
multiline={kind === 'multiline'}
|
|
641
|
+
minRows={kind === 'multiline' ? 3 : undefined}
|
|
642
|
+
label={prop.name}
|
|
643
|
+
value={current === null || current === undefined ? '' : String(current)}
|
|
644
|
+
disabled={Boolean(importedGraph)}
|
|
645
|
+
onChange={(e) => {
|
|
646
|
+
setSteps((prev) => prev.map((s, i) => i === idx ? { ...s, properties: { ...s.properties, [prop.name]: e.target.value } } : s));
|
|
647
|
+
}}
|
|
648
|
+
/>
|
|
649
|
+
);
|
|
650
|
+
})}
|
|
651
|
+
<Stack direction="row" spacing={1}>
|
|
652
|
+
<Button
|
|
653
|
+
fullWidth
|
|
654
|
+
variant="outlined"
|
|
655
|
+
disabled={Boolean(importedGraph)}
|
|
656
|
+
onClick={() => {
|
|
657
|
+
if (idx === 0) return;
|
|
658
|
+
setSteps((prev) => {
|
|
659
|
+
const next = [...prev];
|
|
660
|
+
const x = next[idx - 1];
|
|
661
|
+
next[idx - 1] = next[idx];
|
|
662
|
+
next[idx] = x;
|
|
663
|
+
return next;
|
|
664
|
+
});
|
|
665
|
+
}}
|
|
666
|
+
>Up</Button>
|
|
667
|
+
<Button
|
|
668
|
+
fullWidth
|
|
669
|
+
variant="outlined"
|
|
670
|
+
disabled={Boolean(importedGraph)}
|
|
671
|
+
onClick={() => {
|
|
672
|
+
if (idx === steps.length - 1) return;
|
|
673
|
+
setSteps((prev) => {
|
|
674
|
+
const next = [...prev];
|
|
675
|
+
const x = next[idx + 1];
|
|
676
|
+
next[idx + 1] = next[idx];
|
|
677
|
+
next[idx] = x;
|
|
678
|
+
return next;
|
|
679
|
+
});
|
|
680
|
+
}}
|
|
681
|
+
>Down</Button>
|
|
682
|
+
<Button fullWidth variant="contained" color="error" disabled={Boolean(importedGraph)} onClick={() => setSteps((prev) => prev.filter((_, i) => i !== idx))}>Remove</Button>
|
|
683
|
+
</Stack>
|
|
684
|
+
</Stack>
|
|
685
|
+
</Paper>
|
|
686
|
+
);
|
|
687
|
+
})}
|
|
688
|
+
<Typography variant="subtitle2">Connection Warnings</Typography>
|
|
689
|
+
{graphData.warnings.length ? (
|
|
690
|
+
<Stack spacing={0.75}>
|
|
691
|
+
{graphData.warnings.map((w, i) => <Alert key={i} severity="warning">{w}</Alert>)}
|
|
692
|
+
</Stack>
|
|
693
|
+
) : (
|
|
694
|
+
<Typography variant="body2" color="text.secondary">No connection warnings</Typography>
|
|
695
|
+
)}
|
|
696
|
+
<Typography variant="subtitle2">Compiled Graph</Typography>
|
|
697
|
+
<Box component="pre" sx={{ m: 0, p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1, backgroundColor: 'rgba(0,0,0,0.25)', maxHeight: 300, overflow: 'auto', fontSize: 12 }}>
|
|
698
|
+
{JSON.stringify(graphData.graph, null, 2)}
|
|
699
|
+
</Box>
|
|
700
|
+
</Stack>
|
|
701
|
+
</Paper>
|
|
702
|
+
</Grid>
|
|
703
|
+
<Grid item xs={12} lg={4}>
|
|
704
|
+
<Paper sx={{ p: 2 }}>
|
|
705
|
+
<Stack spacing={1.25}>
|
|
706
|
+
<Typography variant="h6">Run Output</Typography>
|
|
707
|
+
<Typography variant="caption" color="text.secondary">{summaryText || 'No run yet'}</Typography>
|
|
708
|
+
<Typography variant="subtitle2">Output Updates</Typography>
|
|
709
|
+
<Box component="pre" sx={{ m: 0, p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1, backgroundColor: 'rgba(0,0,0,0.25)', maxHeight: 220, overflow: 'auto', fontSize: 12 }}>
|
|
710
|
+
{JSON.stringify(outputEvents, null, 2)}
|
|
711
|
+
</Box>
|
|
712
|
+
<Typography variant="subtitle2">Preview</Typography>
|
|
713
|
+
{!lastOutput && <Typography variant="body2" color="text.secondary">No output yet</Typography>}
|
|
714
|
+
{lastOutput && typeof lastOutput.value === 'string' && lastOutput.value.startsWith('data:image/') && (
|
|
715
|
+
<Box component="img" src={lastOutput.value} sx={{ width: '100%', borderRadius: 1, border: '1px solid', borderColor: 'divider' }} />
|
|
716
|
+
)}
|
|
717
|
+
{lastOutput && !(typeof lastOutput.value === 'string' && lastOutput.value.startsWith('data:image/')) && (
|
|
718
|
+
<Box component="pre" sx={{ m: 0, p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1, backgroundColor: 'rgba(0,0,0,0.25)', maxHeight: 220, overflow: 'auto', fontSize: 12 }}>
|
|
719
|
+
{typeof lastOutput.value === 'string' ? lastOutput.value : JSON.stringify(lastOutput.value, null, 2)}
|
|
720
|
+
</Box>
|
|
721
|
+
)}
|
|
722
|
+
<Typography variant="subtitle2">Event Log</Typography>
|
|
723
|
+
<List dense sx={{ maxHeight: 320, overflow: 'auto', border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
|
724
|
+
{logs.map((entry, idx) => (
|
|
725
|
+
<ListItemText
|
|
726
|
+
key={idx}
|
|
727
|
+
primaryTypographyProps={{
|
|
728
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
729
|
+
fontSize: 12,
|
|
730
|
+
color: entry.type === 'err' ? 'error.main' : entry.type === 'ok' ? 'success.main' : 'text.primary',
|
|
731
|
+
sx: { px: 1, py: 0.4 }
|
|
732
|
+
}}
|
|
733
|
+
primary={entry.text}
|
|
734
|
+
/>
|
|
735
|
+
))}
|
|
736
|
+
</List>
|
|
737
|
+
</Stack>
|
|
738
|
+
</Paper>
|
|
739
|
+
</Grid>
|
|
740
|
+
</Grid>
|
|
741
|
+
</Box>
|
|
742
|
+
</Box>
|
|
743
|
+
</ThemeProvider>
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function compileGraph(state) {
|
|
748
|
+
const warnings = [];
|
|
749
|
+
const userNodes = state.steps.map((step, i) => {
|
|
750
|
+
const id = 'n' + (i + 1);
|
|
751
|
+
const md = state.metadata.find((m) => m.node_type === step.type);
|
|
752
|
+
const propDefs = Array.isArray(md?.properties) ? md.properties : [];
|
|
753
|
+
const normalizedProps = { ...step.properties };
|
|
754
|
+
for (const prop of propDefs) {
|
|
755
|
+
if (!(prop.name in normalizedProps)) continue;
|
|
756
|
+
normalizedProps[prop.name] = normalizePropertyValue(prop, normalizedProps[prop.name]);
|
|
757
|
+
}
|
|
758
|
+
return {
|
|
759
|
+
id,
|
|
760
|
+
type: step.type,
|
|
761
|
+
name: step.type,
|
|
762
|
+
properties: normalizedProps
|
|
763
|
+
};
|
|
764
|
+
});
|
|
765
|
+
const nodes = [...userNodes];
|
|
766
|
+
if (userNodes.length) {
|
|
767
|
+
const id = 'n' + (userNodes.length + 1);
|
|
768
|
+
nodes.push({
|
|
769
|
+
id,
|
|
770
|
+
type: OUTPUT_NODE_TYPE,
|
|
771
|
+
name: OUTPUT_NODE_TYPE,
|
|
772
|
+
properties: {}
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
const edges = [];
|
|
776
|
+
for (let i = 0; i < nodes.length - 1; i++) {
|
|
777
|
+
const aMd = state.metadata.find((m) => m.node_type === nodes[i].type);
|
|
778
|
+
const bMd = state.metadata.find((m) => m.node_type === nodes[i + 1].type);
|
|
779
|
+
const isOutputTarget = nodes[i + 1].type === OUTPUT_NODE_TYPE;
|
|
780
|
+
const link = isOutputTarget
|
|
781
|
+
? {
|
|
782
|
+
sourceHandle: Array.isArray(aMd?.outputs) && aMd.outputs.length ? aMd.outputs[0].name : 'output',
|
|
783
|
+
targetHandle: 'value',
|
|
784
|
+
warning:
|
|
785
|
+
Array.isArray(aMd?.outputs) && aMd.outputs.length
|
|
786
|
+
? null
|
|
787
|
+
: 'Auto output wiring used fallback source handle "output"'
|
|
788
|
+
}
|
|
789
|
+
: chooseLink(aMd, bMd);
|
|
790
|
+
if (link.warning) {
|
|
791
|
+
warnings.push(
|
|
792
|
+
'Step ' + (i + 1) + ' -> ' + (i + 2) + ': ' + link.warning + ' (' + nodes[i].type + ' -> ' + nodes[i + 1].type + ')'
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
edges.push({
|
|
796
|
+
id: 'e' + (i + 1),
|
|
797
|
+
source: nodes[i].id,
|
|
798
|
+
target: nodes[i + 1].id,
|
|
799
|
+
sourceHandle: link.sourceHandle,
|
|
800
|
+
targetHandle: link.targetHandle,
|
|
801
|
+
edge_type: 'data'
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
return { graph: { nodes, edges }, warnings };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
|
808
|
+
</script>
|
|
809
|
+
</body>
|
|
810
|
+
</html>`;
|
|
811
|
+
}
|
|
812
|
+
class WsAdapter {
|
|
813
|
+
socket;
|
|
814
|
+
clientState = "connected";
|
|
815
|
+
applicationState = "connected";
|
|
816
|
+
queue = [];
|
|
817
|
+
waiters = [];
|
|
818
|
+
constructor(socket) {
|
|
819
|
+
this.socket = socket;
|
|
820
|
+
socket.on("message", (raw, isBinary) => {
|
|
821
|
+
const frame = isBinary
|
|
822
|
+
? {
|
|
823
|
+
type: "websocket.message",
|
|
824
|
+
bytes: raw instanceof Uint8Array ? raw : new Uint8Array(raw)
|
|
825
|
+
}
|
|
826
|
+
: { type: "websocket.message", text: raw.toString() };
|
|
827
|
+
const waiter = this.waiters.shift();
|
|
828
|
+
if (waiter)
|
|
829
|
+
waiter(frame);
|
|
830
|
+
else
|
|
831
|
+
this.queue.push(frame);
|
|
832
|
+
});
|
|
833
|
+
socket.on("close", () => {
|
|
834
|
+
this.clientState = "disconnected";
|
|
835
|
+
this.applicationState = "disconnected";
|
|
836
|
+
const waiter = this.waiters.shift();
|
|
837
|
+
if (waiter)
|
|
838
|
+
waiter({ type: "websocket.disconnect" });
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
async accept() { }
|
|
842
|
+
async receive() {
|
|
843
|
+
const next = this.queue.shift();
|
|
844
|
+
if (next)
|
|
845
|
+
return next;
|
|
846
|
+
return await new Promise((resolve) => this.waiters.push(resolve));
|
|
847
|
+
}
|
|
848
|
+
async sendBytes(data) {
|
|
849
|
+
this.socket.send(data);
|
|
850
|
+
}
|
|
851
|
+
async sendText(data) {
|
|
852
|
+
this.socket.send(data);
|
|
853
|
+
}
|
|
854
|
+
async close(code, reason) {
|
|
855
|
+
this.clientState = "disconnected";
|
|
856
|
+
this.applicationState = "disconnected";
|
|
857
|
+
this.socket.close(code, reason);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
function detectMetadataRootsFromPip() {
|
|
861
|
+
const script = `
|
|
862
|
+
import json
|
|
863
|
+
import pathlib
|
|
864
|
+
import subprocess
|
|
865
|
+
import sys
|
|
866
|
+
|
|
867
|
+
packages = ["nodetool-core", "nodetool-base"]
|
|
868
|
+
roots = set()
|
|
869
|
+
|
|
870
|
+
try:
|
|
871
|
+
proc = subprocess.run(
|
|
872
|
+
[sys.executable, "-m", "pip", "show", "-f", *packages],
|
|
873
|
+
capture_output=True,
|
|
874
|
+
text=True,
|
|
875
|
+
check=False,
|
|
876
|
+
)
|
|
877
|
+
output = proc.stdout or ""
|
|
878
|
+
except Exception:
|
|
879
|
+
output = ""
|
|
880
|
+
|
|
881
|
+
location = None
|
|
882
|
+
in_files = False
|
|
883
|
+
for raw in output.splitlines():
|
|
884
|
+
line = raw.rstrip("\\n")
|
|
885
|
+
if line.startswith("Name: "):
|
|
886
|
+
location = None
|
|
887
|
+
in_files = False
|
|
888
|
+
continue
|
|
889
|
+
if line.startswith("Location: "):
|
|
890
|
+
location = line.split(":", 1)[1].strip()
|
|
891
|
+
continue
|
|
892
|
+
if line.startswith("Editable project location: "):
|
|
893
|
+
editable = line.split(":", 1)[1].strip()
|
|
894
|
+
if editable:
|
|
895
|
+
roots.add(editable)
|
|
896
|
+
continue
|
|
897
|
+
if line.startswith("Files:"):
|
|
898
|
+
in_files = True
|
|
899
|
+
continue
|
|
900
|
+
if line.startswith("---"):
|
|
901
|
+
location = None
|
|
902
|
+
in_files = False
|
|
903
|
+
continue
|
|
904
|
+
|
|
905
|
+
if not in_files or not location or not line.startswith(" "):
|
|
906
|
+
continue
|
|
907
|
+
|
|
908
|
+
rel = line.strip().replace("\\\\", "/")
|
|
909
|
+
if "package_metadata" not in rel:
|
|
910
|
+
continue
|
|
911
|
+
abs_path = (pathlib.Path(location) / rel).resolve()
|
|
912
|
+
metadata_dir = abs_path if abs_path.is_dir() else abs_path.parent
|
|
913
|
+
roots.add(str(metadata_dir))
|
|
914
|
+
|
|
915
|
+
print(json.dumps(sorted(roots)))
|
|
916
|
+
`;
|
|
917
|
+
for (const python of ["python3", "python"]) {
|
|
918
|
+
const proc = spawnSync(python, ["-c", script], {
|
|
919
|
+
encoding: "utf8",
|
|
920
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
921
|
+
});
|
|
922
|
+
if (proc.status !== 0 || !proc.stdout)
|
|
923
|
+
continue;
|
|
924
|
+
try {
|
|
925
|
+
const roots = JSON.parse(proc.stdout.trim());
|
|
926
|
+
if (!Array.isArray(roots))
|
|
927
|
+
continue;
|
|
928
|
+
return roots.filter((p) => typeof p === "string" && p.length > 0 && existsSync(p));
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
// try next python executable
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return [];
|
|
935
|
+
}
|
|
936
|
+
function resolveMetadataRoots(options) {
|
|
937
|
+
if (options.metadataRoots && options.metadataRoots.length > 0) {
|
|
938
|
+
return options.metadataRoots;
|
|
939
|
+
}
|
|
940
|
+
if (process.env.METADATA_ROOTS) {
|
|
941
|
+
return process.env.METADATA_ROOTS.split(":").filter(Boolean);
|
|
942
|
+
}
|
|
943
|
+
const detected = detectMetadataRootsFromPip();
|
|
944
|
+
if (detected.length > 0)
|
|
945
|
+
return detected;
|
|
946
|
+
const nearby = detectNearbyMetadataRoots(process.cwd());
|
|
947
|
+
if (nearby.length > 0)
|
|
948
|
+
return nearby;
|
|
949
|
+
return [process.cwd()];
|
|
950
|
+
}
|
|
951
|
+
function hasMetadataLayout(root) {
|
|
952
|
+
return (existsSync(path.join(root, "src", "nodetool", "package_metadata")) ||
|
|
953
|
+
existsSync(path.join(root, "nodetool", "package_metadata")));
|
|
954
|
+
}
|
|
955
|
+
function detectNearbyMetadataRoots(cwd) {
|
|
956
|
+
const candidates = new Set();
|
|
957
|
+
let cur = path.resolve(cwd);
|
|
958
|
+
for (let i = 0; i < 8; i++) {
|
|
959
|
+
candidates.add(cur);
|
|
960
|
+
const parent = path.dirname(cur);
|
|
961
|
+
if (parent === cur)
|
|
962
|
+
break;
|
|
963
|
+
cur = parent;
|
|
964
|
+
}
|
|
965
|
+
// Also inspect siblings in the parent workspace (e.g. nodetool-* repos).
|
|
966
|
+
const workspaceRoot = path.resolve(cwd, "..", "..", "..", "..");
|
|
967
|
+
if (existsSync(workspaceRoot)) {
|
|
968
|
+
try {
|
|
969
|
+
for (const entry of readdirSync(workspaceRoot, { withFileTypes: true })) {
|
|
970
|
+
if (!entry.isDirectory())
|
|
971
|
+
continue;
|
|
972
|
+
const name = entry.name.toLowerCase();
|
|
973
|
+
if (!name.startsWith("nodetool"))
|
|
974
|
+
continue;
|
|
975
|
+
candidates.add(path.join(workspaceRoot, entry.name));
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
// ignore discovery failures
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return [...candidates].filter(hasMetadataLayout);
|
|
983
|
+
}
|
|
984
|
+
function hasNodetoolBaseExamplesLayout(root) {
|
|
985
|
+
// Installed/editable package layout: src/nodetool/examples/nodetool-base
|
|
986
|
+
return (existsSync(path.join(root, "src", "nodetool", "examples", "nodetool-base")) ||
|
|
987
|
+
// Monorepo layout: nodetool/examples/nodetool-base (e.g. packages/base-nodes/)
|
|
988
|
+
existsSync(path.join(root, "nodetool", "examples", "nodetool-base")));
|
|
989
|
+
}
|
|
990
|
+
function detectNearbyNodetoolBaseRoot(cwd) {
|
|
991
|
+
const candidates = new Set();
|
|
992
|
+
let cur = path.resolve(cwd);
|
|
993
|
+
for (let i = 0; i < 8; i++) {
|
|
994
|
+
candidates.add(cur);
|
|
995
|
+
const parent = path.dirname(cur);
|
|
996
|
+
if (parent === cur)
|
|
997
|
+
break;
|
|
998
|
+
cur = parent;
|
|
999
|
+
}
|
|
1000
|
+
// Also check packages/base-nodes relative to the monorepo root
|
|
1001
|
+
for (const candidate of [...candidates]) {
|
|
1002
|
+
const baseNodesCandidate = path.join(candidate, "packages", "base-nodes");
|
|
1003
|
+
if (existsSync(baseNodesCandidate)) {
|
|
1004
|
+
candidates.add(baseNodesCandidate);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
const workspaceRoot = path.resolve(cwd, "..", "..", "..", "..");
|
|
1008
|
+
if (existsSync(workspaceRoot)) {
|
|
1009
|
+
try {
|
|
1010
|
+
for (const entry of readdirSync(workspaceRoot, { withFileTypes: true })) {
|
|
1011
|
+
if (!entry.isDirectory())
|
|
1012
|
+
continue;
|
|
1013
|
+
if (entry.name.toLowerCase() !== "nodetool-base")
|
|
1014
|
+
continue;
|
|
1015
|
+
candidates.add(path.join(workspaceRoot, entry.name));
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
catch {
|
|
1019
|
+
// ignore discovery failures
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
for (const candidate of candidates) {
|
|
1023
|
+
if (hasNodetoolBaseExamplesLayout(candidate))
|
|
1024
|
+
return candidate;
|
|
1025
|
+
}
|
|
1026
|
+
return null;
|
|
1027
|
+
}
|
|
1028
|
+
function resolveExamplesDir(options) {
|
|
1029
|
+
if (options.examplesDir && existsSync(options.examplesDir))
|
|
1030
|
+
return options.examplesDir;
|
|
1031
|
+
if (process.env.NODETOOL_BASE_EXAMPLES_DIR &&
|
|
1032
|
+
existsSync(process.env.NODETOOL_BASE_EXAMPLES_DIR)) {
|
|
1033
|
+
return process.env.NODETOOL_BASE_EXAMPLES_DIR;
|
|
1034
|
+
}
|
|
1035
|
+
const baseRoot = detectNearbyNodetoolBaseRoot(process.cwd());
|
|
1036
|
+
if (!baseRoot)
|
|
1037
|
+
return null;
|
|
1038
|
+
// Try monorepo layout first (packages/base-nodes/nodetool/examples/nodetool-base)
|
|
1039
|
+
const monoDir = path.join(baseRoot, "nodetool", "examples", "nodetool-base");
|
|
1040
|
+
if (existsSync(monoDir))
|
|
1041
|
+
return monoDir;
|
|
1042
|
+
// Fall back to installed/editable layout (src/nodetool/examples/nodetool-base)
|
|
1043
|
+
const srcDir = path.join(baseRoot, "src", "nodetool", "examples", "nodetool-base");
|
|
1044
|
+
return existsSync(srcDir) ? srcDir : null;
|
|
1045
|
+
}
|
|
1046
|
+
function listExampleWorkflows(examplesDir) {
|
|
1047
|
+
const files = readdirSync(examplesDir, { withFileTypes: true })
|
|
1048
|
+
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".json"))
|
|
1049
|
+
.map((entry) => entry.name)
|
|
1050
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1051
|
+
const items = [];
|
|
1052
|
+
for (const file of files) {
|
|
1053
|
+
try {
|
|
1054
|
+
const raw = readFileSync(path.join(examplesDir, file), "utf8");
|
|
1055
|
+
const parsed = JSON.parse(raw);
|
|
1056
|
+
items.push({
|
|
1057
|
+
id: file,
|
|
1058
|
+
name: typeof parsed.name === "string"
|
|
1059
|
+
? parsed.name
|
|
1060
|
+
: file.replace(/\.json$/i, ""),
|
|
1061
|
+
description: typeof parsed.description === "string" ? parsed.description : "",
|
|
1062
|
+
tags: Array.isArray(parsed.tags)
|
|
1063
|
+
? parsed.tags.filter((t) => typeof t === "string")
|
|
1064
|
+
: []
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
catch {
|
|
1068
|
+
// skip invalid files
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return items;
|
|
1072
|
+
}
|
|
1073
|
+
function readExampleWorkflow(examplesDir, id) {
|
|
1074
|
+
const safeId = path.basename(id);
|
|
1075
|
+
if (!safeId.toLowerCase().endsWith(".json"))
|
|
1076
|
+
return null;
|
|
1077
|
+
const full = path.join(examplesDir, safeId);
|
|
1078
|
+
if (!existsSync(full))
|
|
1079
|
+
return null;
|
|
1080
|
+
const raw = readFileSync(full, "utf8");
|
|
1081
|
+
const parsed = JSON.parse(raw);
|
|
1082
|
+
const graph = parsed.graph;
|
|
1083
|
+
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges))
|
|
1084
|
+
return null;
|
|
1085
|
+
return {
|
|
1086
|
+
id: safeId,
|
|
1087
|
+
name: typeof parsed.name === "string"
|
|
1088
|
+
? parsed.name
|
|
1089
|
+
: safeId.replace(/\.json$/i, ""),
|
|
1090
|
+
description: typeof parsed.description === "string" ? parsed.description : "",
|
|
1091
|
+
tags: Array.isArray(parsed.tags)
|
|
1092
|
+
? parsed.tags.filter((t) => typeof t === "string")
|
|
1093
|
+
: [],
|
|
1094
|
+
graph
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
export function createTestUiServer(options = {}) {
|
|
1098
|
+
const host = options.host ?? "127.0.0.1";
|
|
1099
|
+
const port = options.port ?? Number(process.env.PORT ?? 7777);
|
|
1100
|
+
const examplesDir = resolveExamplesDir(options);
|
|
1101
|
+
const metadataRoots = resolveMetadataRoots(options);
|
|
1102
|
+
const registry = new NodeRegistry();
|
|
1103
|
+
registry.loadPythonMetadata({
|
|
1104
|
+
roots: metadataRoots,
|
|
1105
|
+
maxDepth: options.metadataMaxDepth ?? 8
|
|
1106
|
+
});
|
|
1107
|
+
registerBaseNodes(registry);
|
|
1108
|
+
registerElevenLabsNodes(registry);
|
|
1109
|
+
registerTransformersJsNodes(registry);
|
|
1110
|
+
const resolvedApiOptions = {
|
|
1111
|
+
...options,
|
|
1112
|
+
metadataRoots,
|
|
1113
|
+
registry,
|
|
1114
|
+
// Pass the resolved examples directory so handleWorkflowExamples can serve
|
|
1115
|
+
// examples directly from the filesystem without requiring Python metadata.
|
|
1116
|
+
...(examplesDir ? { examplesDir } : {})
|
|
1117
|
+
};
|
|
1118
|
+
const graphNodeTypeResolver = createGraphNodeTypeResolver(registry);
|
|
1119
|
+
const server = createServer((req, res) => {
|
|
1120
|
+
const url = new URL(req.url ?? "/", `http://${host}:${port}`);
|
|
1121
|
+
if (url.pathname === "/" || url.pathname === "/test-ui") {
|
|
1122
|
+
res.statusCode = 200;
|
|
1123
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
1124
|
+
res.end(htmlPage());
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
if (url.pathname === "/api/examples") {
|
|
1128
|
+
if (!examplesDir) {
|
|
1129
|
+
res.statusCode = 200;
|
|
1130
|
+
res.setHeader("content-type", "application/json");
|
|
1131
|
+
res.end(JSON.stringify({ examples: [] }));
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
const examples = listExampleWorkflows(examplesDir);
|
|
1135
|
+
res.statusCode = 200;
|
|
1136
|
+
res.setHeader("content-type", "application/json");
|
|
1137
|
+
res.end(JSON.stringify({ examples }));
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
if (url.pathname.startsWith("/api/examples/")) {
|
|
1141
|
+
if (!examplesDir) {
|
|
1142
|
+
res.statusCode = 404;
|
|
1143
|
+
res.setHeader("content-type", "application/json");
|
|
1144
|
+
res.end(JSON.stringify({ detail: "Examples directory not configured" }));
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const id = decodeURIComponent(url.pathname.slice("/api/examples/".length));
|
|
1148
|
+
const example = readExampleWorkflow(examplesDir, id);
|
|
1149
|
+
if (!example) {
|
|
1150
|
+
res.statusCode = 404;
|
|
1151
|
+
res.setHeader("content-type", "application/json");
|
|
1152
|
+
res.end(JSON.stringify({ detail: "Example not found" }));
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
res.statusCode = 200;
|
|
1156
|
+
res.setHeader("content-type", "application/json");
|
|
1157
|
+
res.end(JSON.stringify(example));
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
if (url.pathname.startsWith("/api/") ||
|
|
1161
|
+
url.pathname.startsWith("/v1/") ||
|
|
1162
|
+
url.pathname.startsWith("/admin/")) {
|
|
1163
|
+
void handleNodeHttpRequest(req, res, resolvedApiOptions);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
res.statusCode = 404;
|
|
1167
|
+
res.setHeader("content-type", "application/json");
|
|
1168
|
+
res.end(JSON.stringify({ detail: "Not found" }));
|
|
1169
|
+
});
|
|
1170
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
1171
|
+
wss.on("error", (error) => {
|
|
1172
|
+
log.error("WebSocketServer error", error);
|
|
1173
|
+
});
|
|
1174
|
+
server.on("upgrade", (request, socket, head) => {
|
|
1175
|
+
const url = new URL(request.url ?? "/", `http://${host}:${port}`);
|
|
1176
|
+
if (url.pathname !== "/ws") {
|
|
1177
|
+
socket.destroy();
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
1181
|
+
ws.on("error", (error) => {
|
|
1182
|
+
log.error("WebSocket client error", error);
|
|
1183
|
+
});
|
|
1184
|
+
const runner = new UnifiedWebSocketRunner({
|
|
1185
|
+
resolveExecutor: (node) => registry.resolve(node),
|
|
1186
|
+
resolveNodeType: graphNodeTypeResolver,
|
|
1187
|
+
resolveProvider: async (_providerId) => new ScriptedProvider([
|
|
1188
|
+
autoScript({
|
|
1189
|
+
plan: {
|
|
1190
|
+
title: "Agent task",
|
|
1191
|
+
steps: [
|
|
1192
|
+
{
|
|
1193
|
+
id: "s1",
|
|
1194
|
+
instructions: "Complete the objective",
|
|
1195
|
+
depends_on: []
|
|
1196
|
+
}
|
|
1197
|
+
]
|
|
1198
|
+
},
|
|
1199
|
+
text: "fake agent response from server"
|
|
1200
|
+
})
|
|
1201
|
+
]),
|
|
1202
|
+
getNodeMetadata: (nodeType) => registry.getMetadata(nodeType)
|
|
1203
|
+
});
|
|
1204
|
+
log.info("WebSocket client connected");
|
|
1205
|
+
void runner.run(new WsAdapter(ws)).catch((error) => {
|
|
1206
|
+
log.error("Runner crashed", error instanceof Error ? error : new Error(String(error)));
|
|
1207
|
+
});
|
|
1208
|
+
});
|
|
1209
|
+
});
|
|
1210
|
+
return {
|
|
1211
|
+
server,
|
|
1212
|
+
listen: () => new Promise((resolve) => {
|
|
1213
|
+
server.listen(port, host, () => resolve());
|
|
1214
|
+
}),
|
|
1215
|
+
close: () => new Promise((resolve, reject) => {
|
|
1216
|
+
wss.close((err) => {
|
|
1217
|
+
if (err)
|
|
1218
|
+
reject(err);
|
|
1219
|
+
else {
|
|
1220
|
+
server.close((closeErr) => {
|
|
1221
|
+
if (closeErr)
|
|
1222
|
+
reject(closeErr);
|
|
1223
|
+
else
|
|
1224
|
+
resolve();
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
});
|
|
1228
|
+
}),
|
|
1229
|
+
info: {
|
|
1230
|
+
host,
|
|
1231
|
+
port,
|
|
1232
|
+
metadataRoots,
|
|
1233
|
+
metadataCount: registry.listMetadata().length
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
1238
|
+
// Initialize SQLite adapter pointing at the same DB as the Python side
|
|
1239
|
+
const dbPath = getDefaultDbPath();
|
|
1240
|
+
try {
|
|
1241
|
+
initDb(dbPath);
|
|
1242
|
+
}
|
|
1243
|
+
catch {
|
|
1244
|
+
// DB unavailable — secrets will appear unconfigured
|
|
1245
|
+
}
|
|
1246
|
+
const srv = createTestUiServer();
|
|
1247
|
+
void srv.listen().then(() => {
|
|
1248
|
+
log.info("Server listening", {
|
|
1249
|
+
host: srv.info.host,
|
|
1250
|
+
port: srv.info.port,
|
|
1251
|
+
metadataRoots: srv.info.metadataRoots,
|
|
1252
|
+
nodes: srv.info.metadataCount
|
|
1253
|
+
});
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
//# sourceMappingURL=test-ui-server.js.map
|