@unoverse-platform/studio 0.1.0
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/bin/studio.mjs +124 -0
- package/index.html +12 -0
- package/local/catalog.ts +105 -0
- package/local/keys.ts +118 -0
- package/local/nodes.ts +77 -0
- package/local/plugin.ts +401 -0
- package/local/scaffold.d.mts +13 -0
- package/local/scaffold.mjs +85 -0
- package/package.json +43 -0
- package/public/logo.png +0 -0
- package/screens/AtomsView.tsx +206 -0
- package/screens/ComponentsView.tsx +551 -0
- package/screens/ConfigForm.tsx +217 -0
- package/screens/DesignSystemView.tsx +324 -0
- package/screens/IdentityHeader.tsx +88 -0
- package/screens/PromptBlocksView.tsx +137 -0
- package/screens/SkillsView.tsx +167 -0
- package/screens/TemplatesView.tsx +1005 -0
- package/screens/index.ts +26 -0
- package/screens/states.ts +44 -0
- package/src/App.tsx +252 -0
- package/src/ConnectUniverse.tsx +232 -0
- package/src/NewProject.tsx +89 -0
- package/src/NodeKeys.tsx +98 -0
- package/src/NodesView.tsx +443 -0
- package/src/PublishDialog.tsx +309 -0
- package/src/UniverseSession.tsx +121 -0
- package/src/host.ts +165 -0
- package/src/index.css +7 -0
- package/src/main.tsx +11 -0
- package/src/universes.ts +210 -0
- package/vendor/sdk/appEngine.tsx +219 -0
- package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
- package/vendor/sdk/lib/connection.tsx +302 -0
- package/vendor/sdk/lib/core/actions.ts +84 -0
- package/vendor/sdk/lib/core/client.ts +134 -0
- package/vendor/sdk/lib/core/conditions.ts +43 -0
- package/vendor/sdk/lib/core/connection.ts +142 -0
- package/vendor/sdk/lib/core/index.ts +32 -0
- package/vendor/sdk/lib/core/store.ts +455 -0
- package/vendor/sdk/lib/core/types.ts +194 -0
- package/vendor/sdk/lib/index.ts +59 -0
- package/vendor/sdk/lib/isolate.tsx +70 -0
- package/vendor/sdk/lib/primitives.tsx +453 -0
- package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
- package/vendor/sdk/lib/realtime/types.ts +45 -0
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
- package/vendor/sdk/lib/render.tsx +178 -0
- package/vendor/sdk/lib/streamed.tsx +65 -0
- package/vendor/sdk/lib/style.ts +227 -0
- package/vendor/sdk/lib/template.tsx +521 -0
- package/vendor/sdk/lib/theme.ts +55 -0
- package/vendor/sdk/lib/voice.tsx +311 -0
- package/vendor/sdk/main.tsx +96 -0
- package/vite.config.ts +70 -0
package/src/NodeKeys.tsx
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The selected node's keys, read from the project's `.env`.
|
|
3
|
+
*
|
|
4
|
+
* Two decisions show up here. Keys belong to the NODE being tested, not to a global
|
|
5
|
+
* list: a developer testing one node should see exactly the credentials it declares.
|
|
6
|
+
* And Studio does not store secrets at all. Developers already have a `.env`, already
|
|
7
|
+
* gitignored; a second secret store would be one more thing to learn and to leak.
|
|
8
|
+
*
|
|
9
|
+
* So this panel is read-only. It names the exact variable each field wants and says
|
|
10
|
+
* whether it is set. Entering the value happens in the editor they are already in.
|
|
11
|
+
*
|
|
12
|
+
* See docs/architecture/LOCAL_STUDIO.md.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useState } from "react";
|
|
15
|
+
|
|
16
|
+
export interface CredentialType {
|
|
17
|
+
name: string;
|
|
18
|
+
displayName?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
properties?: { name: string; displayName?: string; description?: string; required?: boolean }[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface FieldPresence {
|
|
24
|
+
field: string;
|
|
25
|
+
env: string;
|
|
26
|
+
set: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function NodeKeys({ needs, types }: { needs: string[]; types: CredentialType[] }) {
|
|
30
|
+
const [present, setPresent] = useState<Record<string, FieldPresence[]>>({});
|
|
31
|
+
const [envFiles, setEnvFiles] = useState<string[]>([]);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
fetch("/keys")
|
|
35
|
+
.then((r) => r.json())
|
|
36
|
+
.then((d) => setPresent(d.present ?? {}))
|
|
37
|
+
.catch(() => setPresent({}));
|
|
38
|
+
fetch("/local/info")
|
|
39
|
+
.then((r) => r.json())
|
|
40
|
+
.then((d) => setEnvFiles(d.envFiles ?? []))
|
|
41
|
+
.catch(() => {});
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
const mine = needs.map((n) => types.find((t) => t.name === n) ?? { name: n }) as CredentialType[];
|
|
45
|
+
if (mine.length === 0) return null;
|
|
46
|
+
|
|
47
|
+
const missing = mine.some((t) => (present[t.name] ?? []).some((f) => !f.set));
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="mt-4">
|
|
51
|
+
<div className="flex items-baseline gap-2">
|
|
52
|
+
<span className="text-[11px] font-semibold uppercase tracking-wider text-gray-500">Keys</span>
|
|
53
|
+
<span className="text-[11px] text-gray-500">
|
|
54
|
+
from your <code className="rounded bg-gray-200 px-1 text-gray-700">.env</code>
|
|
55
|
+
{envFiles[0] && <span className="ml-1 text-gray-600" title={envFiles.join(" or ")}>({envFiles[0]})</span>}
|
|
56
|
+
</span>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
{missing && (
|
|
60
|
+
<p className="mt-1 text-[11px] text-amber-700">
|
|
61
|
+
Add the variables below to your .env and restart Studio to pick them up.
|
|
62
|
+
</p>
|
|
63
|
+
)}
|
|
64
|
+
|
|
65
|
+
<div className="mt-2 space-y-3">
|
|
66
|
+
{mine.map((t) => {
|
|
67
|
+
const fields = present[t.name] ?? [];
|
|
68
|
+
return (
|
|
69
|
+
<section key={t.name} className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
|
70
|
+
<div className="flex items-center gap-2">
|
|
71
|
+
<h3 className="text-xs font-semibold text-gray-800">{t.displayName ?? t.name}</h3>
|
|
72
|
+
{fields.length > 0 && fields.every((f) => f.set) && (
|
|
73
|
+
<span className="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-700">
|
|
74
|
+
ready
|
|
75
|
+
</span>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
{t.description && <p className="mt-0.5 text-[11px] text-gray-500">{t.description}</p>}
|
|
79
|
+
|
|
80
|
+
<div className="mt-2 space-y-1">
|
|
81
|
+
{fields.map((f) => (
|
|
82
|
+
<div key={f.field} className="flex items-center gap-2 text-[11px]">
|
|
83
|
+
<span className={f.set ? "text-emerald-600" : "text-gray-400"}>{f.set ? "●" : "○"}</span>
|
|
84
|
+
<code className={`rounded px-1 py-0.5 font-mono ${f.set ? "bg-gray-200 text-gray-700" : "bg-amber-100 text-amber-800"}`}>
|
|
85
|
+
{f.env}
|
|
86
|
+
</code>
|
|
87
|
+
<span className="text-gray-600">{f.set ? "set" : "not set"}</span>
|
|
88
|
+
</div>
|
|
89
|
+
))}
|
|
90
|
+
{fields.length === 0 && <p className="text-[11px] text-gray-500">This credential declares no fields.</p>}
|
|
91
|
+
</div>
|
|
92
|
+
</section>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nodes view — Canvas-style. Left: a searchable palette of node cards grouped by
|
|
3
|
+
* category (name + category pill + description + colored accent). Right: a
|
|
4
|
+
* schema-driven config form (like the Canvas node panel) + Run, with the raw
|
|
5
|
+
* output (single) or streamed frames (generator).
|
|
6
|
+
*
|
|
7
|
+
* The runtime routes (/nodes, /execute, /execute-stream) are auth-gated server-side;
|
|
8
|
+
* authedFetch attaches the workbench bearer.
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
11
|
+
import { authedFetch, usePersistentState, useRegistryToggles, ToggleSwitch } from "./host";
|
|
12
|
+
import { ConfigForm, defaultsFromSchema } from "@unoverse/studio";
|
|
13
|
+
import { NodeKeys, type CredentialType } from "./NodeKeys";
|
|
14
|
+
|
|
15
|
+
interface NodeInput {
|
|
16
|
+
name: string;
|
|
17
|
+
type?: string;
|
|
18
|
+
}
|
|
19
|
+
interface NodeDef {
|
|
20
|
+
type: string;
|
|
21
|
+
name: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
whenToUse?: string;
|
|
24
|
+
executionMode: "single" | "generator";
|
|
25
|
+
category?: string;
|
|
26
|
+
inputs?: NodeInput[];
|
|
27
|
+
configSchema?: any;
|
|
28
|
+
credentials?: Array<{ name?: string } | string>;
|
|
29
|
+
testData?: any; // author-declared sample inputs/config, if any
|
|
30
|
+
relevance?: number; // 0–1 blended score, present only on semantic-search results
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const pretty = (v: unknown) => JSON.stringify(v, null, 2);
|
|
34
|
+
// Nodes wrap their result in `__outputs` (the downstream-routing envelope); show the
|
|
35
|
+
// payload itself, not the wrapper.
|
|
36
|
+
const unwrap = (v: any) => (v && typeof v === "object" && "__outputs" in v ? v.__outputs : v);
|
|
37
|
+
|
|
38
|
+
// Category → accent color (left border + pill), mirroring the Canvas palette.
|
|
39
|
+
// Descriptive taxonomy (see docs 14 — category drives both sidebar grouping and
|
|
40
|
+
// UNO catalog ranking).
|
|
41
|
+
const CAT_COLOR: Record<string, string> = {
|
|
42
|
+
AI: "#6366f1",
|
|
43
|
+
Voice: "#8b5cf6",
|
|
44
|
+
"Go To Market": "#f43f5e",
|
|
45
|
+
Search: "#06b6d4",
|
|
46
|
+
"Web Scraping": "#0ea5e9",
|
|
47
|
+
"Media & Design": "#ec4899",
|
|
48
|
+
Documents: "#f59e0b",
|
|
49
|
+
"Knowledge & Vectors": "#a855f7",
|
|
50
|
+
"Storage & Data": "#3b82f6",
|
|
51
|
+
Communication: "#14b8a6",
|
|
52
|
+
Flow: "#22c55e",
|
|
53
|
+
Output: "#eab308",
|
|
54
|
+
// legacy / display
|
|
55
|
+
"Design System": "#ec4899",
|
|
56
|
+
Triggers: "#22c55e",
|
|
57
|
+
Storage: "#3b82f6",
|
|
58
|
+
Memory: "#a855f7",
|
|
59
|
+
Ingest: "#06b6d4",
|
|
60
|
+
};
|
|
61
|
+
const colorFor = (cat?: string) => CAT_COLOR[cat ?? ""] ?? "#6b7280";
|
|
62
|
+
|
|
63
|
+
function jsonEditor(label: string, value: string, onChange: (v: string) => void) {
|
|
64
|
+
return (
|
|
65
|
+
<label className="flex flex-col gap-1">
|
|
66
|
+
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400">{label}</span>
|
|
67
|
+
<textarea
|
|
68
|
+
value={value}
|
|
69
|
+
onChange={(e) => onChange(e.target.value)}
|
|
70
|
+
spellCheck={false}
|
|
71
|
+
className="h-28 w-full resize-y rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-gray-800 focus:border-emerald-500 focus:outline-none"
|
|
72
|
+
/>
|
|
73
|
+
</label>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function NodesView() {
|
|
78
|
+
const [nodes, setNodes] = useState<NodeDef[]>([]);
|
|
79
|
+
// Credential DEFINITIONS ride the same /nodes payload; the key panel renders from them.
|
|
80
|
+
const [credentialTypes, setCredentialTypes] = useState<CredentialType[]>([]);
|
|
81
|
+
// The folder these nodes came from. Shown so it is obvious this is YOUR nodes/ folder
|
|
82
|
+
// and not a marketplace listing.
|
|
83
|
+
const [nodesDir, setNodesDir] = useState<string>("");
|
|
84
|
+
const [selected, setSelected] = usePersistentState<string | null>("wb:nodes:selected", null);
|
|
85
|
+
const active = useRegistryToggles("node");
|
|
86
|
+
const [query, setQuery] = useState("");
|
|
87
|
+
const [config, setConfig] = useState<Record<string, any>>({});
|
|
88
|
+
const [inputs, setInputs] = useState("{}");
|
|
89
|
+
const [context, setContext] = useState(
|
|
90
|
+
pretty({ nodeId: "wb-1", executionId: "wb-exec-1", publishingContext: { chatId: "wb", conversationId: "wb", userId: "wb-user" } }),
|
|
91
|
+
);
|
|
92
|
+
const [advanced, setAdvanced] = useState(false);
|
|
93
|
+
// Bumped to remount ConfigForm when config is replaced wholesale (node switch /
|
|
94
|
+
// Load sample) — its multi-line fields keep a local text mirror that otherwise
|
|
95
|
+
// wouldn't pick up an external value change.
|
|
96
|
+
const [formKey, setFormKey] = useState(0);
|
|
97
|
+
const [running, setRunning] = useState(false);
|
|
98
|
+
const [output, setOutput] = useState("");
|
|
99
|
+
const [error, setError] = useState<string | null>(null);
|
|
100
|
+
// Semantic-search results in relevance order, or null when browsing / when
|
|
101
|
+
// ranking is unavailable (then the palette falls back to the local substring
|
|
102
|
+
// filter below — never a wrong order).
|
|
103
|
+
const [ranked, setRanked] = useState<NodeDef[] | null>(null);
|
|
104
|
+
const [searching, setSearching] = useState(false);
|
|
105
|
+
|
|
106
|
+
// Fetch the loaded-node catalog. Reused after a marketplace install/uninstall so the
|
|
107
|
+
// palette reflects the new/removed node types without a page reload.
|
|
108
|
+
const loadNodes = useCallback(async (): Promise<NodeDef[]> => {
|
|
109
|
+
try {
|
|
110
|
+
const r = await authedFetch("/nodes");
|
|
111
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
112
|
+
const d = await r.json();
|
|
113
|
+
// Design-system display components (category "Design System") are rendered via
|
|
114
|
+
// the component stream, not run standalone — hide them from the Nodes tab.
|
|
115
|
+
const list: NodeDef[] = (d.nodes ?? []).filter((n: NodeDef) => n.category !== "Design System");
|
|
116
|
+
setNodes(list);
|
|
117
|
+
setCredentialTypes(d.credentialTypes ?? []);
|
|
118
|
+
fetch("/local/info").then((r) => r.json()).then((i) => setNodesDir(i.nodes ?? "")).catch(() => {});
|
|
119
|
+
setError(null);
|
|
120
|
+
return list;
|
|
121
|
+
} catch (e: any) {
|
|
122
|
+
setError(String(e.message ?? e));
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
loadNodes().then((list) => {
|
|
129
|
+
// Restore the persisted selection across reloads; fall back to the first.
|
|
130
|
+
const restore = list.find((n) => n.type === selected) ?? list[0];
|
|
131
|
+
if (restore) choose(restore);
|
|
132
|
+
});
|
|
133
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
// Semantic search — debounced POST /nodes/search (the same embedding+lexical
|
|
137
|
+
// ranker the agent's getNodeCatalog uses, over this server's own catalog). An
|
|
138
|
+
// empty box shows the browse view; while a query is in flight the local
|
|
139
|
+
// substring filter renders, then swaps to ranked order when results arrive.
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
const q = query.trim();
|
|
142
|
+
if (!q) {
|
|
143
|
+
setRanked(null);
|
|
144
|
+
setSearching(false);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
let cancelled = false;
|
|
148
|
+
setSearching(true);
|
|
149
|
+
const t = setTimeout(async () => {
|
|
150
|
+
try {
|
|
151
|
+
const r = await authedFetch("/nodes/search", {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { "content-type": "application/json" },
|
|
154
|
+
body: JSON.stringify({ task: q, limit: 40 }),
|
|
155
|
+
});
|
|
156
|
+
const d = await r.json();
|
|
157
|
+
// ranked:false (no OpenAI key / API error) → null → local substring fallback.
|
|
158
|
+
// Drop design-system display components here too (same as the browse list).
|
|
159
|
+
const hits: NodeDef[] = (d.nodes ?? []).filter((n: NodeDef) => n.category !== "Design System");
|
|
160
|
+
if (!cancelled) setRanked(r.ok && d.ranked ? hits : null);
|
|
161
|
+
} catch {
|
|
162
|
+
if (!cancelled) setRanked(null);
|
|
163
|
+
} finally {
|
|
164
|
+
if (!cancelled) setSearching(false);
|
|
165
|
+
}
|
|
166
|
+
}, 250);
|
|
167
|
+
return () => {
|
|
168
|
+
cancelled = true;
|
|
169
|
+
clearTimeout(t);
|
|
170
|
+
};
|
|
171
|
+
}, [query]);
|
|
172
|
+
|
|
173
|
+
function choose(n: NodeDef) {
|
|
174
|
+
setSelected(n.type);
|
|
175
|
+
setConfig(defaultsFromSchema(n.configSchema));
|
|
176
|
+
setInputs(pretty(Object.fromEntries((n.inputs ?? []).map((i) => [i.name, ""]))));
|
|
177
|
+
setOutput("");
|
|
178
|
+
setError(null);
|
|
179
|
+
}
|
|
180
|
+
const setField = (k: string, v: any) => setConfig((c) => ({ ...c, [k]: v }));
|
|
181
|
+
|
|
182
|
+
/** Fill the form from the node's author-declared `testData`, if present. */
|
|
183
|
+
function loadSample() {
|
|
184
|
+
const td = current?.testData;
|
|
185
|
+
if (!td) return;
|
|
186
|
+
if (td.config) setConfig({ ...defaultsFromSchema(current!.configSchema), ...td.config });
|
|
187
|
+
if (td.inputs) setInputs(pretty(td.inputs));
|
|
188
|
+
if (!td.config && !td.inputs) setInputs(pretty(td)); // treat the whole blob as inputs
|
|
189
|
+
setFormKey((k) => k + 1); // remount the form so fields show the loaded values
|
|
190
|
+
setError(null);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const current = nodes.find((n) => n.type === selected);
|
|
194
|
+
|
|
195
|
+
async function run() {
|
|
196
|
+
if (!current) return;
|
|
197
|
+
let parsedInputs: unknown;
|
|
198
|
+
let parsedContext: unknown;
|
|
199
|
+
try {
|
|
200
|
+
parsedInputs = JSON.parse(inputs);
|
|
201
|
+
parsedContext = JSON.parse(context);
|
|
202
|
+
} catch (e: any) {
|
|
203
|
+
setError(`Invalid JSON in inputs/context: ${e.message}`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
setError(null);
|
|
207
|
+
setOutput("");
|
|
208
|
+
setRunning(true);
|
|
209
|
+
const body = JSON.stringify({ nodeType: current.type, inputs: parsedInputs, config, context: parsedContext });
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
if (current.executionMode === "generator") {
|
|
213
|
+
const res = await authedFetch("/execute-stream", { method: "POST", headers: { "content-type": "application/json" }, body });
|
|
214
|
+
if (!res.ok || !res.body) {
|
|
215
|
+
setError(`HTTP ${res.status}: ${await res.text()}`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const reader = res.body.getReader();
|
|
219
|
+
const dec = new TextDecoder();
|
|
220
|
+
let buf = "";
|
|
221
|
+
for (;;) {
|
|
222
|
+
const { done, value } = await reader.read();
|
|
223
|
+
if (done) break;
|
|
224
|
+
buf += dec.decode(value, { stream: true });
|
|
225
|
+
const parts = buf.split("\n\n");
|
|
226
|
+
buf = parts.pop() ?? "";
|
|
227
|
+
for (const part of parts) {
|
|
228
|
+
const line = part.split("\n").find((l) => l.startsWith("data: "));
|
|
229
|
+
if (!line) continue;
|
|
230
|
+
const frame = JSON.parse(line.slice(6));
|
|
231
|
+
const display = frame.type === "NODE_OUTPUT" ? unwrap(frame.output) : frame;
|
|
232
|
+
setOutput((o) => o + pretty(display) + "\n");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
const res = await authedFetch("/execute", { method: "POST", headers: { "content-type": "application/json" }, body });
|
|
237
|
+
const data = await res.json();
|
|
238
|
+
if (!res.ok) setError(data.error ?? `HTTP ${res.status}`);
|
|
239
|
+
setOutput(pretty(unwrap(data.result ?? data)));
|
|
240
|
+
}
|
|
241
|
+
} catch (e: any) {
|
|
242
|
+
setError(String(e.message ?? e));
|
|
243
|
+
} finally {
|
|
244
|
+
setRunning(false);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Browse view (empty box) + substring fallback (query in flight, or semantic
|
|
249
|
+
// ranking unavailable), grouped by category.
|
|
250
|
+
const byCategory = useMemo(() => {
|
|
251
|
+
const q = query.trim().toLowerCase();
|
|
252
|
+
const filtered = q
|
|
253
|
+
? nodes.filter((n) => `${n.type} ${n.name} ${n.description ?? ""}`.toLowerCase().includes(q))
|
|
254
|
+
: nodes;
|
|
255
|
+
return Object.entries(
|
|
256
|
+
filtered.reduce<Record<string, NodeDef[]>>((acc, n) => {
|
|
257
|
+
(acc[n.category ?? "Other"] ??= []).push(n);
|
|
258
|
+
return acc;
|
|
259
|
+
}, {}),
|
|
260
|
+
).sort(([a], [b]) => a.localeCompare(b));
|
|
261
|
+
}, [nodes, query]);
|
|
262
|
+
|
|
263
|
+
// Show ranked order once results land; until then (or if ranking is off) the
|
|
264
|
+
// grouped browse/substring view renders.
|
|
265
|
+
const showRanked = query.trim().length > 0 && ranked !== null;
|
|
266
|
+
|
|
267
|
+
function renderCard(n: NodeDef) {
|
|
268
|
+
const color = colorFor(n.category);
|
|
269
|
+
const active = selected === n.type;
|
|
270
|
+
return (
|
|
271
|
+
<button
|
|
272
|
+
key={n.type}
|
|
273
|
+
onClick={() => choose(n)}
|
|
274
|
+
className={`flex flex-col gap-1 rounded-lg border bg-[#232a34] p-3 text-left transition ${
|
|
275
|
+
active ? "border-emerald-500/60" : "border-gray-700 hover:border-gray-600"
|
|
276
|
+
}`}
|
|
277
|
+
style={{ borderLeftColor: color, borderLeftWidth: 3 }}
|
|
278
|
+
>
|
|
279
|
+
<div className="flex items-center gap-2">
|
|
280
|
+
<span className="truncate text-sm font-semibold text-gray-100">{n.name || n.type}</span>
|
|
281
|
+
{typeof n.relevance === "number" && (
|
|
282
|
+
<span className="shrink-0 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-medium text-emerald-300">
|
|
283
|
+
{Math.round(n.relevance * 100)}%
|
|
284
|
+
</span>
|
|
285
|
+
)}
|
|
286
|
+
<span className="ml-auto shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium" style={{ background: `${color}22`, color }}>
|
|
287
|
+
{n.category ?? "node"}
|
|
288
|
+
</span>
|
|
289
|
+
</div>
|
|
290
|
+
{n.description && <p className="line-clamp-2 text-[11px] leading-snug text-gray-400">{n.description}</p>}
|
|
291
|
+
{n.executionMode === "generator" && <span className="text-[9px] uppercase tracking-wide text-gray-500">stream</span>}
|
|
292
|
+
</button>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<div className="grid min-h-0 flex-1 grid-cols-[330px_1fr] overflow-hidden">
|
|
298
|
+
{/* PALETTE — Canvas-style searchable node cards */}
|
|
299
|
+
<nav className="flex min-h-0 min-w-0 flex-col overflow-auto border-r border-gray-700 bg-[#1b2129] p-3">
|
|
300
|
+
<div className="relative mb-3">
|
|
301
|
+
<input
|
|
302
|
+
value={query}
|
|
303
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
304
|
+
placeholder="Search nodes…"
|
|
305
|
+
className="w-full rounded-lg border border-gray-700 bg-[#0f141a] px-3 py-2 pr-9 text-sm text-gray-200 placeholder:text-gray-500 focus:border-emerald-500 focus:outline-none"
|
|
306
|
+
/>
|
|
307
|
+
{nodesDir && (
|
|
308
|
+
<p className="px-1 pt-1 text-[10px] leading-snug text-gray-600" title={nodesDir}>
|
|
309
|
+
your nodes: <span className="text-gray-500">{nodesDir}</span>
|
|
310
|
+
</p>
|
|
311
|
+
)}
|
|
312
|
+
{searching && (
|
|
313
|
+
<svg className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-emerald-400" fill="none" viewBox="0 0 24 24">
|
|
314
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
315
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
316
|
+
</svg>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
{/* Marketplace — install/update packages inline (§4b). On change, reload the
|
|
320
|
+
palette so new/removed node types appear below. */}
|
|
321
|
+
{nodes.length === 0 && (
|
|
322
|
+
<div className="p-2 text-xs text-gray-400">{error ? `Couldn't load /nodes: ${error}` : "No nodes in Unoverse yet."}</div>
|
|
323
|
+
)}
|
|
324
|
+
{showRanked ? (
|
|
325
|
+
<div className="mb-4">
|
|
326
|
+
<div className="mb-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500">best matches</div>
|
|
327
|
+
<div className="flex flex-col gap-2">
|
|
328
|
+
{ranked!.length === 0 ? (
|
|
329
|
+
<div className="px-1 text-xs text-gray-500">No matching nodes.</div>
|
|
330
|
+
) : (
|
|
331
|
+
ranked!.map(renderCard)
|
|
332
|
+
)}
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
) : (
|
|
336
|
+
byCategory.map(([cat, items]) => (
|
|
337
|
+
<div key={cat} className="mb-4">
|
|
338
|
+
<div className="mb-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500">{cat}</div>
|
|
339
|
+
<div className="flex flex-col gap-2">{items.map(renderCard)}</div>
|
|
340
|
+
</div>
|
|
341
|
+
))
|
|
342
|
+
)}
|
|
343
|
+
</nav>
|
|
344
|
+
|
|
345
|
+
{/* PANEL — request (left) · response (right), Run pinned in the header */}
|
|
346
|
+
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-white">
|
|
347
|
+
{!current ? (
|
|
348
|
+
<div className="m-auto text-xs text-gray-500">Select a node.</div>
|
|
349
|
+
) : (
|
|
350
|
+
<>
|
|
351
|
+
{/* HEADER BAR — identity + actions */}
|
|
352
|
+
<div className="flex items-start gap-3 border-b border-gray-800 px-6 py-4">
|
|
353
|
+
<div className="min-w-0">
|
|
354
|
+
<div className="flex items-center gap-2">
|
|
355
|
+
<h2 className="truncate text-lg font-semibold text-gray-900">{current.name || current.type}</h2>
|
|
356
|
+
<span className="shrink-0 rounded bg-white/5 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-gray-400">
|
|
357
|
+
{current.executionMode}
|
|
358
|
+
</span>
|
|
359
|
+
{current.category && (
|
|
360
|
+
<span
|
|
361
|
+
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium"
|
|
362
|
+
style={{ background: `${colorFor(current.category)}22`, color: colorFor(current.category) }}
|
|
363
|
+
>
|
|
364
|
+
{current.category}
|
|
365
|
+
</span>
|
|
366
|
+
)}
|
|
367
|
+
<span className="ml-2 shrink-0">
|
|
368
|
+
<ToggleSwitch on={active.isEnabled(current.type)} onClick={() => active.toggle(current.type)} label="Active" />
|
|
369
|
+
</span>
|
|
370
|
+
</div>
|
|
371
|
+
{current.description && <p className="mt-0.5 text-sm text-gray-300">{current.description}</p>}
|
|
372
|
+
{current.whenToUse && (
|
|
373
|
+
<p className="mt-1 max-w-3xl text-[12px] leading-snug text-gray-600">
|
|
374
|
+
<span className="font-medium text-gray-500">When to use — </span>
|
|
375
|
+
{current.whenToUse}
|
|
376
|
+
</p>
|
|
377
|
+
)}
|
|
378
|
+
{(current.credentials?.length ?? 0) > 0 && (
|
|
379
|
+
<div className="mt-1 text-[11px] text-amber-700">
|
|
380
|
+
needs credentials: {current.credentials!.map((c) => (typeof c === "string" ? c : c?.name)).filter(Boolean).join(", ")}
|
|
381
|
+
</div>
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
384
|
+
<div className="ml-auto flex shrink-0 items-center gap-2">
|
|
385
|
+
{current.testData && (
|
|
386
|
+
<button onClick={loadSample} className="rounded border border-gray-700 px-3 py-1.5 text-sm font-medium text-gray-300 hover:bg-white/5">
|
|
387
|
+
Load sample
|
|
388
|
+
</button>
|
|
389
|
+
)}
|
|
390
|
+
<button
|
|
391
|
+
onClick={run}
|
|
392
|
+
disabled={running}
|
|
393
|
+
className="rounded bg-emerald-500/90 px-5 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500 disabled:opacity-50"
|
|
394
|
+
>
|
|
395
|
+
{running ? "Running…" : current.executionMode === "generator" ? "Run ▸ stream" : "Run ▸"}
|
|
396
|
+
</button>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
{error && (
|
|
400
|
+
<div className="border-b border-red-500/20 bg-red-500/5 px-6 py-2 text-xs font-medium text-red-400">{error}</div>
|
|
401
|
+
)}
|
|
402
|
+
|
|
403
|
+
{/* BODY — config/inputs (left) · output (right) */}
|
|
404
|
+
<div className="grid min-h-0 flex-1 grid-cols-2 overflow-hidden">
|
|
405
|
+
<div className="flex min-h-0 flex-col gap-5 overflow-auto border-r border-gray-800 p-6">
|
|
406
|
+
<div className="flex flex-col">
|
|
407
|
+
<span className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-400">configuration</span>
|
|
408
|
+
<div className="rounded-md border border-gray-200 bg-gray-50 p-4">
|
|
409
|
+
<ConfigForm key={`${selected}:${formKey}`} schema={current.configSchema} value={config} onChange={setField} />
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
<div>
|
|
413
|
+
<button onClick={() => setAdvanced((a) => !a)} className="text-[11px] font-medium text-gray-500 hover:text-gray-800">
|
|
414
|
+
{advanced ? "▾" : "▸"} inputs & context (JSON)
|
|
415
|
+
</button>
|
|
416
|
+
{/* Keys belong to the NODE being tested, not a global list: only the
|
|
417
|
+
credentials this node declares, built from their own definitions. */}
|
|
418
|
+
<NodeKeys
|
|
419
|
+
needs={(current.credentials ?? []).map((c: any) => (typeof c === "string" ? c : c?.name)).filter(Boolean)}
|
|
420
|
+
types={credentialTypes}
|
|
421
|
+
/>
|
|
422
|
+
{advanced && (
|
|
423
|
+
<div className="mt-2 flex flex-col gap-3">
|
|
424
|
+
{jsonEditor("inputs", inputs, setInputs)}
|
|
425
|
+
{jsonEditor("context", context, setContext)}
|
|
426
|
+
</div>
|
|
427
|
+
)}
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
|
|
431
|
+
<div className="flex min-h-0 flex-col overflow-hidden p-6">
|
|
432
|
+
<span className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-400">output</span>
|
|
433
|
+
<pre className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap rounded-md border border-gray-200 bg-gray-50 p-4 font-mono text-xs text-gray-800">
|
|
434
|
+
{output || <span className="text-gray-500">— run the node to see output —</span>}
|
|
435
|
+
</pre>
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
</>
|
|
439
|
+
)}
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
);
|
|
443
|
+
}
|