@ltm-blueverse/alpha-semantic-hub 0.7.3 → 0.8.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/README.md CHANGED
@@ -322,6 +322,23 @@ SDK is missing. Two patterns:
322
322
  Also available: Redshift, Cassandra, MongoDB, DuckDB, Elasticsearch, Kafka-style
323
323
  streams, email, RSS/Atom feeds, Airflow, Parquet/Arrow.
324
324
 
325
+ ### Ingestion, MCP tools and LLM endpoints in the Studio
326
+
327
+ The **Enrich** workspace has three tabs for this:
328
+
329
+ - **Source Ingestion** — connect Databricks, SAP (OData), Snowflake, Salesforce,
330
+ REST endpoints (incl. Microsoft Graph for SharePoint), web pages or files.
331
+ Test the connection, run ingestion with live progress, and extraction loads
332
+ the entities/relations straight into the graph on screen.
333
+ - **MCP Tools** — browse and invoke all 16 tools that Claude Code / Codex /
334
+ Copilot get over MCP. Calls run against the same live graph the Studio shows.
335
+ - **LLM Endpoints** — shows where the platform uses language models, which
336
+ providers are configured (OpenAI, Anthropic, Gemini, Groq, DeepSeek, Novita,
337
+ Ollama, HuggingFace) and fires a real test prompt per provider.
338
+
339
+ `npm-packages/` staging, `data/` volumes and container-local paths (`/data/…`)
340
+ apply as per the compose file.
341
+
325
342
  ### Running ingestion (Python workflow)
326
343
 
327
344
  Source ingestion is a **Python-library workflow** — the Studio UI imports graph
@@ -24,6 +24,9 @@ import { hasOntologyUrlState } from './workspaces/OntologyWorkspace/ontologyUrlS
24
24
 
25
25
  const DecisionWorkspace = lazy(() => import('./workspaces/DecisionWorkspace/DecisionWorkspace').then((module) => ({ default: module.DecisionWorkspace })));
26
26
  const DiffMergeWorkspace = lazy(() => import('./workspaces/DiffMergeWorkspace/DiffMergeWorkspace').then((module) => ({ default: module.DiffMergeWorkspace })));
27
+ const IngestWorkspace = lazy(() => import('./workspaces/IngestWorkspace/IngestWorkspace').then((module) => ({ default: module.IngestWorkspace })));
28
+ const McpConsoleWorkspace = lazy(() => import('./workspaces/McpConsoleWorkspace/McpConsoleWorkspace').then((module) => ({ default: module.McpConsoleWorkspace })));
29
+ const LlmSettingsWorkspace = lazy(() => import('./workspaces/LlmSettingsWorkspace/LlmSettingsWorkspace').then((module) => ({ default: module.LlmSettingsWorkspace })));
27
30
  const GraphWorkspace = lazy(() => import('./workspaces/GraphWorkspace/GraphWorkspace').then((module) => ({ default: module.GraphWorkspace })));
28
31
  const MemoryWorkspace = lazy(() => import('./workspaces/MemoryWorkspace').then((module) => ({ default: module.MemoryWorkspace })));
29
32
  const ImportExportWorkspace = lazy(() => import('./workspaces/ImportExportWorkspace/ImportExportWorkspace').then((module) => ({ default: module.ImportExportWorkspace })));
@@ -39,7 +42,7 @@ const OntologyWorkspace = lazy(() => import('./workspaces/OntologyWorkspace').th
39
42
 
40
43
  type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage' | 'ontology-hub';
41
44
  type AnalyzeView = 'sparql' | 'reasoning';
42
- type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
45
+ type EnrichView = 'ingest' | 'mcp' | 'llm' | 'import' | 'merge' | 'registry' | 'resolve';
43
46
  type ManageView = 'lineage' | 'kg-overview' | 'ontology';
44
47
 
45
48
  type NavItem = {
@@ -1926,7 +1929,16 @@ export default function App() {
1926
1929
  kicker="Knowledge Audit"
1927
1930
  tabs={
1928
1931
  <>
1929
- <button className="workspace-tab" data-active={enrichView === 'import'} onClick={() => setEnrichView('import')}>
1932
+ <button className="workspace-tab" data-active={enrichView === 'ingest'} onClick={() => setEnrichView('ingest')}>
1933
+ Source Ingestion
1934
+ </button>
1935
+ <button className="workspace-tab" data-active={enrichView === 'mcp'} onClick={() => setEnrichView('mcp')}>
1936
+ MCP Tools
1937
+ </button>
1938
+ <button className="workspace-tab" data-active={enrichView === 'llm'} onClick={() => setEnrichView('llm')}>
1939
+ LLM Endpoints
1940
+ </button>
1941
+ <button className="workspace-tab" data-active={enrichView === 'import'} onClick={() => setEnrichView('import')}>
1930
1942
  Import and Export
1931
1943
  </button>
1932
1944
  <button className="workspace-tab" data-active={enrichView === 'merge'} onClick={() => setEnrichView('merge')}>
@@ -1943,7 +1955,10 @@ export default function App() {
1943
1955
  >
1944
1956
  <ErrorBoundary key={`enrich-${enrichView}`}>
1945
1957
  <Suspense fallback={<WorkspaceFallback />}>
1946
- {enrichView === 'import' ? <ImportExportWorkspace /> :
1958
+ {enrichView === 'ingest' ? <IngestWorkspace /> :
1959
+ enrichView === 'mcp' ? <McpConsoleWorkspace /> :
1960
+ enrichView === 'llm' ? <LlmSettingsWorkspace /> :
1961
+ enrichView === 'import' ? <ImportExportWorkspace /> :
1947
1962
  enrichView === 'merge' ? <DiffMergeWorkspace /> :
1948
1963
  enrichView === 'resolve' ? <EntityResolutionTab /> :
1949
1964
  <RegistryTab />}
@@ -0,0 +1,268 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import {
3
+ Database, Plug, CheckCircle2, AlertCircle, Loader2, FileText, Globe,
4
+ Boxes, Cloud, Server, Play,
5
+ } from "lucide-react";
6
+
7
+ type SourceField = {
8
+ name: string;
9
+ label: string;
10
+ required: boolean;
11
+ secret?: boolean;
12
+ boolean?: boolean;
13
+ };
14
+
15
+ type SourceDef = {
16
+ kind: string;
17
+ label: string;
18
+ fields: SourceField[];
19
+ hint: string;
20
+ };
21
+
22
+ type Toast = { id: number; type: "success" | "error"; text: string };
23
+
24
+ const KIND_ICONS: Record<string, typeof Database> = {
25
+ file: FileText, databricks: Boxes, sap: Server, snowflake: Cloud,
26
+ salesforce: Cloud, rest: Plug, web: Globe,
27
+ };
28
+
29
+ type FieldValues = Record<string, string | boolean>;
30
+
31
+ function nestedSet(obj: Record<string, unknown>, path: string, value: unknown) {
32
+ const parts = path.split(".");
33
+ let cur = obj;
34
+ for (let i = 0; i < parts.length - 1; i++) {
35
+ if (typeof cur[parts[i]] !== "object" || cur[parts[i]] === null) cur[parts[i]] = {};
36
+ cur = cur[parts[i]] as Record<string, unknown>;
37
+ }
38
+ cur[parts[parts.length - 1]] = value;
39
+ }
40
+
41
+ export function IngestWorkspace() {
42
+ const [sources, setSources] = useState<SourceDef[]>([]);
43
+ const [activeKind, setActiveKind] = useState<string | null>(null);
44
+ const [values, setValues] = useState<FieldValues>({});
45
+ const [sourceRef, setSourceRef] = useState("");
46
+ const [extract, setExtract] = useState(true);
47
+ const [validating, setValidating] = useState(false);
48
+ const [validation, setValidation] = useState<{ ok: boolean; detail: string } | null>(null);
49
+ const [running, setRunning] = useState(false);
50
+ const [job, setJob] = useState<{ status: string; stage: string; progress: number; message: string } | null>(null);
51
+ const [toasts, setToasts] = useState<Toast[]>([]);
52
+ const pollRef = useRef<number | null>(null);
53
+
54
+ useEffect(() => {
55
+ fetch("/api/ingest/sources").then((r) => r.json()).then((d) => {
56
+ setSources(d.sources ?? []);
57
+ if (d.sources?.length) setActiveKind((k) => k ?? d.sources[0].kind);
58
+ }).catch(() => {});
59
+ return () => { if (pollRef.current) window.clearInterval(pollRef.current); };
60
+ }, []);
61
+
62
+ const active = sources.find((s) => s.kind === activeKind) ?? null;
63
+
64
+ function showToast(type: Toast["type"], text: string) {
65
+ const id = Date.now();
66
+ setToasts((p) => [...p, { id, type, text }]);
67
+ setTimeout(() => setToasts((p) => p.filter((t) => t.id !== id)), 6000);
68
+ }
69
+
70
+ function selectKind(kind: string) {
71
+ setActiveKind(kind);
72
+ setValues({});
73
+ setSourceRef("");
74
+ setValidation(null);
75
+ setJob(null);
76
+ }
77
+
78
+ async function handleValidate() {
79
+ if (!active) return;
80
+ setValidating(true);
81
+ setValidation(null);
82
+ try {
83
+ const options: Record<string, unknown> = {};
84
+ for (const [k, v] of Object.entries(values)) {
85
+ if (v !== "" && v !== false) nestedSet(options, k, v);
86
+ }
87
+ if (active.kind === "sap" && options["service"] === undefined) delete options["service"];
88
+ const res = await fetch("/api/ingest/validate", {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: JSON.stringify({ kind: active.kind, options }),
92
+ });
93
+ const data = await res.json();
94
+ setValidation({ ok: !!data.ok, detail: data.detail ?? data.detail?.toString() ?? "Unknown response" });
95
+ } catch (e) {
96
+ setValidation({ ok: false, detail: e instanceof Error ? e.message : "Validation failed" });
97
+ } finally {
98
+ setValidating(false);
99
+ }
100
+ }
101
+
102
+ async function handleRun() {
103
+ if (!active || !sourceRef.trim()) return;
104
+ setRunning(true);
105
+ setJob({ status: "queued", stage: "queued", progress: 0, message: "Starting…" });
106
+ try {
107
+ const options: Record<string, unknown> = {};
108
+ for (const [k, v] of Object.entries(values)) {
109
+ if (v !== "" && v !== false) nestedSet(options, k, v);
110
+ }
111
+ const res = await fetch("/api/ingest/run", {
112
+ method: "POST",
113
+ headers: { "Content-Type": "application/json" },
114
+ body: JSON.stringify({ kind: active.kind, source: sourceRef.trim(), options, extract }),
115
+ });
116
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
117
+ const { job_id } = await res.json();
118
+ pollRef.current = window.setInterval(async () => {
119
+ try {
120
+ const st = await fetch(`/api/ingest/status/${job_id}`);
121
+ if (!st.ok) return;
122
+ const data = await st.json();
123
+ setJob(data);
124
+ if (data.status === "done") {
125
+ window.clearInterval(pollRef.current!); pollRef.current = null;
126
+ setRunning(false);
127
+ showToast("success", data.message);
128
+ } else if (data.status === "error") {
129
+ window.clearInterval(pollRef.current!); pollRef.current = null;
130
+ setRunning(false);
131
+ showToast("error", data.message);
132
+ }
133
+ } catch { /* transient */ }
134
+ }, 1000);
135
+ } catch (e) {
136
+ setRunning(false);
137
+ setJob(null);
138
+ showToast("error", e instanceof Error ? e.message : "Failed to start ingestion");
139
+ }
140
+ }
141
+
142
+ return (
143
+ <div className="ws-page ws-scroll">
144
+ <div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 24, maxWidth: 980, margin: "0 auto", width: "100%" }}>
145
+ {/* Header */}
146
+ <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
147
+ <div style={{ width: 42, height: 42, borderRadius: 13, background: "var(--ws-accent-soft)", border: "1px solid var(--ws-border-strong)", display: "grid", placeItems: "center", color: "var(--ws-accent)", flexShrink: 0 }}>
148
+ <Database size={20} />
149
+ </div>
150
+ <div>
151
+ <h2 className="ws-title" style={{ fontSize: 18 }}>Ingest from Source Systems</h2>
152
+ <div className="ws-body" style={{ marginTop: 2 }}>Connect Databricks, SAP, Snowflake, Salesforce, REST APIs, web pages or files — extraction runs automatically and the graph loads into the Studio.</div>
153
+ </div>
154
+ </div>
155
+
156
+ <div style={{ display: "grid", gridTemplateColumns: "280px 1fr", gap: 18, alignItems: "start" }}>
157
+ {/* Source picker */}
158
+ <div className="ws-card" style={{ padding: 18, display: "flex", flexDirection: "column", gap: 8 }}>
159
+ <div className="ws-label">Source type</div>
160
+ {sources.map((s) => {
161
+ const Icon = KIND_ICONS[s.kind] ?? Database;
162
+ return (
163
+ <button key={s.kind} className="ws-btn" data-active={activeKind === s.kind}
164
+ onClick={() => selectKind(s.kind)}
165
+ style={{ justifyContent: "flex-start", fontSize: 13 }}>
166
+ <Icon size={15} /> {s.label}
167
+ </button>
168
+ );
169
+ })}
170
+ </div>
171
+
172
+ {/* Connection form */}
173
+ <div className="ws-card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
174
+ {!active ? <div className="ws-body">Loading sources…</div> : (
175
+ <>
176
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
177
+ <Plug size={16} color="var(--ws-accent)" />
178
+ <div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>{active.label} connection</div>
179
+ </div>
180
+ <div className="ws-body" style={{ fontSize: 12 }}>{active.hint}</div>
181
+
182
+ <div>
183
+ <label className="ws-label">{active.kind === "file" ? "File path" : active.kind === "web" || active.kind === "rest" ? "URL" : active.kind === "databricks" || active.kind === "snowflake" ? "Table (or SQL query)" : active.kind === "sap" ? "Entity set" : "SObject"}</label>
184
+ <input className="ws-input" style={{ width: "100%" }}
185
+ value={sourceRef}
186
+ onChange={(e) => setSourceRef(e.target.value)}
187
+ placeholder={active.kind === "file" ? "/data/documents/report.txt" : active.kind === "databricks" ? "catalog.schema.table" : active.kind === "snowflake" ? "DATABASE.SCHEMA.TABLE" : active.kind === "sap" ? "SalesOrderSet" : active.kind === "salesforce" ? "Account" : "https://…"}
188
+ disabled={running} />
189
+ </div>
190
+
191
+ {active.fields.filter((f) => f.name !== "source").map((f) => (
192
+ <div key={f.name}>
193
+ <label className="ws-label">{f.label}{f.required ? " *" : ""}</label>
194
+ {f.boolean ? (
195
+ <button className={`ws-btn ${values[f.name] ? "ws-btn--primary" : "ws-btn--ghost"}`}
196
+ onClick={() => setValues((v) => ({ ...v, [f.name]: !v[f.name] }))}
197
+ style={{ justifyContent: "center" }}>
198
+ {values[f.name] ? "Yes" : "No"}
199
+ </button>
200
+ ) : (
201
+ <input className="ws-input" style={{ width: "100%" }}
202
+ type={f.secret ? "password" : "text"}
203
+ value={String(values[f.name] ?? "")}
204
+ onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
205
+ disabled={running} />
206
+ )}
207
+ </div>
208
+ ))}
209
+
210
+ {/* Extraction toggle */}
211
+ <button className={`ws-btn ${extract ? "ws-btn--primary" : "ws-btn--ghost"}`}
212
+ onClick={() => setExtract((v) => !v)} disabled={running}
213
+ style={{ justifyContent: "center" }}>
214
+ {extract ? "Extraction ON — build entities & relations" : "Extraction OFF — documents only"}
215
+ </button>
216
+
217
+ {/* Validation result */}
218
+ {validation ? (
219
+ <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 14px", borderRadius: "var(--ws-radius-sm)", border: `1px solid ${validation.ok ? "rgba(76,195,138,0.4)" : "rgba(236,35,21,0.4)"}`, background: "rgba(255,255,255,0.6)" }}>
220
+ {validation.ok ? <CheckCircle2 size={15} color="#1f9d5f" /> : <AlertCircle size={15} color="#ec2315" />}
221
+ <span style={{ fontSize: 13, color: "var(--ws-text)" }}>{validation.detail}</span>
222
+ </div>
223
+ ) : null}
224
+
225
+ {/* Progress */}
226
+ {job ? (
227
+ <div style={{ display: "flex", flexDirection: "column", gap: 8, padding: "14px 16px", borderRadius: "var(--ws-radius-sm)", background: "rgba(0,0,0,0.16)", border: "1px solid var(--ws-border)" }}>
228
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
229
+ {job.status === "running" || job.status === "queued" ? <Loader2 size={15} className="ws-spin" color="var(--ws-accent)" /> : job.status === "done" ? <CheckCircle2 size={15} color="#1f9d5f" /> : <AlertCircle size={15} color="#ec2315" />}
230
+ <span style={{ fontSize: 13, color: "var(--ws-text)", fontWeight: 600 }}>{job.message}</span>
231
+ </div>
232
+ <div style={{ height: 6, borderRadius: 3, background: "rgba(0,0,0,0.25)", overflow: "hidden" }}>
233
+ <div style={{ width: `${job.progress}%`, height: "100%", background: job.status === "error" ? "#ec2315" : "var(--ws-accent)", transition: "width 400ms ease" }} />
234
+ </div>
235
+ <div className="ws-body" style={{ fontSize: 11 }}>Stage: {job.stage}</div>
236
+ </div>
237
+ ) : null}
238
+
239
+ <div style={{ display: "flex", gap: 10 }}>
240
+ <button className="ws-btn ws-btn--ghost" onClick={handleValidate}
241
+ disabled={validating || running || !sourceRef.trim()}
242
+ style={{ flex: 1, justifyContent: "center" }}>
243
+ {validating ? <Loader2 size={15} className="ws-spin" /> : <Plug size={15} />} Test connection
244
+ </button>
245
+ <button className="ws-btn" onClick={handleRun}
246
+ disabled={running || validating || !sourceRef.trim()}
247
+ style={{ flex: 2, justifyContent: "center", background: "var(--ws-accent-soft)", borderColor: "var(--ws-accent)", color: "var(--ws-accent)" }}>
248
+ {running ? <><Loader2 size={15} className="ws-spin" />Ingesting…</> : <><Play size={15} />Run ingestion</>}
249
+ </button>
250
+ </div>
251
+ </>
252
+ )}
253
+ </div>
254
+ </div>
255
+
256
+ {/* Toasts */}
257
+ <div style={{ position: "fixed", bottom: 28, right: 28, display: "flex", flexDirection: "column", gap: 10, zIndex: 1000 }}>
258
+ {toasts.map((t) => (
259
+ <div key={t.id} className="ws-animate-in" style={{ display: "flex", alignItems: "center", gap: 10, padding: "13px 18px", borderRadius: "var(--ws-radius-sm)", background: "rgba(255,255,255,0.96)", border: `1px solid ${t.type === "success" ? "rgba(76,195,138,0.4)" : "rgba(255,123,114,0.4)"}`, boxShadow: "0 8px 24px rgba(0,0,0,0.5)", backdropFilter: "blur(12px)" }}>
260
+ {t.type === "success" ? <CheckCircle2 size={16} color="#1f9d5f" /> : <AlertCircle size={16} color="#ec2315" />}
261
+ <span style={{ color: "var(--ws-text)", fontSize: 13, fontWeight: 500 }}>{t.text}</span>
262
+ </div>
263
+ ))}
264
+ </div>
265
+ </div>
266
+ </div>
267
+ );
268
+ }
@@ -0,0 +1,157 @@
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ Sparkles, CheckCircle2, AlertCircle, Loader2, Send, Info,
4
+ } from "lucide-react";
5
+
6
+ type Provider = {
7
+ name: string; label: string; env_var: string | null;
8
+ models: string[]; configured: boolean; env_value_hint: string | null;
9
+ };
10
+
11
+ type UsageRow = { task: string; when: string; where: string };
12
+
13
+ type Toast = { id: number; type: "success" | "error"; text: string };
14
+
15
+ export function LlmSettingsWorkspace() {
16
+ const [providers, setProviders] = useState<Provider[]>([]);
17
+ const [usage, setUsage] = useState<UsageRow[]>([]);
18
+ const [selected, setSelected] = useState<Provider | null>(null);
19
+ const [model, setModel] = useState("");
20
+ const [testing, setTesting] = useState(false);
21
+ const [result, setResult] = useState<{ ok: boolean; detail: string; model?: string } | null>(null);
22
+ const [toasts, setToasts] = useState<Toast[]>([]);
23
+
24
+ useEffect(() => {
25
+ fetch("/api/llm/status").then((r) => r.json()).then((d) => {
26
+ setProviders(d.providers ?? []);
27
+ setUsage(d.usage ?? []);
28
+ const first = (d.providers ?? []).find((p: Provider) => p.configured) ?? d.providers?.[0];
29
+ if (first) { setSelected(first); setModel(first.models[0] ?? ""); }
30
+ }).catch(() => {});
31
+ }, []);
32
+
33
+ function showToast(type: Toast["type"], text: string) {
34
+ const id = Date.now();
35
+ setToasts((p) => [...p, { id, type, text }]);
36
+ setTimeout(() => setToasts((p) => p.filter((t) => t.id !== id)), 5000);
37
+ }
38
+
39
+ async function handleTest() {
40
+ if (!selected) return;
41
+ setTesting(true);
42
+ setResult(null);
43
+ try {
44
+ const res = await fetch("/api/llm/test", {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify({ provider: selected.name, model }),
48
+ });
49
+ const data = await res.json();
50
+ setResult(data);
51
+ showToast(data.ok ? "success" : "error",
52
+ data.ok ? `${selected.label} responded` : `${selected.label}: unavailable`);
53
+ } catch (e) {
54
+ setResult({ ok: false, detail: e instanceof Error ? e.message : "Test failed" });
55
+ } finally {
56
+ setTesting(false);
57
+ }
58
+ }
59
+
60
+ return (
61
+ <div className="ws-page ws-scroll">
62
+ <div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 24, maxWidth: 980, margin: "0 auto", width: "100%" }}>
63
+ {/* Header */}
64
+ <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
65
+ <div style={{ width: 42, height: 42, borderRadius: 13, background: "var(--ws-accent-soft)", border: "1px solid var(--ws-border-strong)", display: "grid", placeItems: "center", color: "var(--ws-accent)", flexShrink: 0 }}>
66
+ <Sparkles size={20} />
67
+ </div>
68
+ <div>
69
+ <h2 className="ws-title" style={{ fontSize: 18 }}>LLM Endpoints</h2>
70
+ <div className="ws-body" style={{ marginTop: 2 }}>
71
+ Where the platform uses language models, which are configured in this deployment, and a live test for each.
72
+ </div>
73
+ </div>
74
+ </div>
75
+
76
+ {/* Where LLMs are used — informational */}
77
+ <div className="ws-card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 12 }}>
78
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
79
+ <Info size={16} color="var(--ws-accent)" />
80
+ <div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>When does this platform call an LLM?</div>
81
+ </div>
82
+ {usage.map((u) => (
83
+ <div key={u.task} style={{ padding: "10px 14px", borderRadius: "var(--ws-radius-sm)", background: "rgba(0,0,0,0.16)", border: "1px solid var(--ws-border)" }}>
84
+ <div style={{ fontSize: 13, fontWeight: 700, color: "var(--ws-text)" }}>{u.task}</div>
85
+ <div className="ws-body" style={{ fontSize: 12, marginTop: 2 }}>{u.when}</div>
86
+ <div className="ws-body" style={{ fontSize: 11, marginTop: 2, fontFamily: "var(--ws-mono, monospace)", opacity: 0.7 }}>{u.where}</div>
87
+ </div>
88
+ ))}
89
+ <div className="ws-body" style={{ fontSize: 12 }}>
90
+ Credentials are read from environment variables when the Studio starts — set them in <code style={{ color: "var(--ws-accent)" }}>docker-compose.yml</code> (or <code style={{ color: "var(--ws-accent)" }}>.env</code>) and restart.
91
+ </div>
92
+ </div>
93
+
94
+ {/* Provider grid */}
95
+ <div className="ws-card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
96
+ <div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>Configured providers</div>
97
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(210px, 1fr))", gap: 12 }}>
98
+ {providers.map((p) => (
99
+ <button key={p.name} className="ws-btn" data-active={selected?.name === p.name}
100
+ onClick={() => { setSelected(p); setModel(p.models[0] ?? ""); setResult(null); }}
101
+ style={{ flexDirection: "column", alignItems: "flex-start", gap: 6, padding: 14 }}>
102
+ <div style={{ display: "flex", alignItems: "center", gap: 8, width: "100%" }}>
103
+ {p.configured
104
+ ? <CheckCircle2 size={14} color="#1f9d5f" />
105
+ : <AlertCircle size={14} color="#c9a227" />}
106
+ <span style={{ fontSize: 13, fontWeight: 700, color: "var(--ws-text)" }}>{p.label}</span>
107
+ </div>
108
+ <div className="ws-body" style={{ fontSize: 11, textAlign: "left" }}>
109
+ {p.env_var ? <code>{p.env_var}</code> : "no key needed"}
110
+ {p.configured && p.env_value_hint ? ` · set (${p.env_value_hint})` : p.env_var ? " · not set" : ""}
111
+ </div>
112
+ </button>
113
+ ))}
114
+ </div>
115
+
116
+ {selected ? (
117
+ <>
118
+ <div>
119
+ <label className="ws-label">Model</label>
120
+ <select className="ws-input" style={{ width: "100%" }} value={model}
121
+ onChange={(e) => setModel(e.target.value)}>
122
+ {selected.models.map((m) => <option key={m} value={m}>{m}</option>)}
123
+ </select>
124
+ </div>
125
+
126
+ <button className="ws-btn" onClick={handleTest} disabled={testing}
127
+ style={{ justifyContent: "center", background: "var(--ws-accent-soft)", borderColor: "var(--ws-accent)", color: "var(--ws-accent)" }}>
128
+ {testing ? <><Loader2 size={15} className="ws-spin" />Sending test prompt…</> : <><Send size={15} />Send test prompt</>}
129
+ </button>
130
+
131
+ {result ? (
132
+ <div style={{ padding: "12px 14px", borderRadius: "var(--ws-radius-sm)", border: `1px solid ${result.ok ? "rgba(76,195,138,0.4)" : "rgba(236,35,21,0.4)"}`, background: "rgba(255,255,255,0.6)" }}>
133
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
134
+ {result.ok ? <CheckCircle2 size={15} color="#1f9d5f" /> : <AlertCircle size={15} color="#ec2315" />}
135
+ <span style={{ fontSize: 13, fontWeight: 700, color: "var(--ws-text)" }}>
136
+ {result.ok ? `${selected.label} responded${result.model ? ` (${result.model})` : ""}` : "Test failed"}
137
+ </span>
138
+ </div>
139
+ <div className="ws-body" style={{ fontSize: 12, marginTop: 6, whiteSpace: "pre-wrap", fontFamily: "var(--ws-mono, monospace)" }}>{result.detail}</div>
140
+ </div>
141
+ ) : null}
142
+ </>
143
+ ) : null}
144
+ </div>
145
+
146
+ <div style={{ position: "fixed", bottom: 28, right: 28, display: "flex", flexDirection: "column", gap: 10, zIndex: 1000 }}>
147
+ {toasts.map((t) => (
148
+ <div key={t.id} className="ws-animate-in" style={{ display: "flex", alignItems: "center", gap: 10, padding: "13px 18px", borderRadius: "var(--ws-radius-sm)", background: "rgba(255,255,255,0.96)", border: `1px solid ${t.type === "success" ? "rgba(76,195,138,0.4)" : "rgba(255,123,114,0.4)"}`, boxShadow: "0 8px 24px rgba(0,0,0,0.5)", backdropFilter: "blur(12px)" }}>
149
+ {t.type === "success" ? <CheckCircle2 size={16} color="#1f9d5f" /> : <AlertCircle size={16} color="#ec2315" />}
150
+ <span style={{ color: "var(--ws-text)", fontSize: 13, fontWeight: 500 }}>{t.text}</span>
151
+ </div>
152
+ ))}
153
+ </div>
154
+ </div>
155
+ </div>
156
+ );
157
+ }
@@ -0,0 +1,216 @@
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ Wrench, Play, Loader2, CheckCircle2, AlertCircle, ChevronRight, Wand2,
4
+ } from "lucide-react";
5
+
6
+ type JsonSchema = {
7
+ type?: string;
8
+ properties?: Record<string, JsonSchema & { description?: string; enum?: string[] }>;
9
+ required?: string[];
10
+ };
11
+
12
+ type Tool = { name: string; description: string; inputSchema: JsonSchema };
13
+ type Toast = { id: number; type: "success" | "error"; text: string };
14
+
15
+ const EXAMPLE_PROMPTS: Record<string, Record<string, unknown>> = {
16
+ extract_entities: { text: "Siemens AG signed a contract with Bosch GmbH in Stuttgart." },
17
+ extract_relations: { text: "Maria Keller approved the deal for Siemens AG." },
18
+ add_entity: { id: "ORG:Nvidia", label: "Nvidia", type: "ORG", metadata: { source: "studio-ui" } },
19
+ add_relationship: { source: "ORG:Nvidia", target: "PERSON:Maria Keller", type: "supplies_to", metadata: {} },
20
+ query_graph: { mode: "search", query: "Siemens", limit: 10 },
21
+ record_decision: {
22
+ category: "technology",
23
+ scenario: "Adopt Databricks as the primary lakehouse",
24
+ reasoning: "Existing Spark tooling and unified governance",
25
+ alternatives_considered: ["Snowflake", "BigQuery"],
26
+ outcome: "accepted",
27
+ confidence: 0.9,
28
+ },
29
+ find_precedents: { decision: "Adopt Databricks as the primary lakehouse" },
30
+ get_graph_analytics: {},
31
+ get_graph_summary: {},
32
+ export_graph: { format: "json" },
33
+ };
34
+
35
+ export function McpConsoleWorkspace() {
36
+ const [tools, setTools] = useState<Tool[]>([]);
37
+ const [selected, setSelected] = useState<Tool | null>(null);
38
+ const [args, setArgs] = useState("");
39
+ const [running, setRunning] = useState(false);
40
+ const [result, setResult] = useState<string | null>(null);
41
+ const [error, setError] = useState<string | null>(null);
42
+ const [toasts, setToasts] = useState<Toast[]>([]);
43
+
44
+ useEffect(() => {
45
+ fetch("/api/mcp/tools").then((r) => r.json()).then((d) => {
46
+ setTools(d.tools ?? []);
47
+ }).catch(() => {});
48
+ }, []);
49
+
50
+ function showToast(type: Toast["type"], text: string) {
51
+ const id = Date.now();
52
+ setToasts((p) => [...p, { id, type, text }]);
53
+ setTimeout(() => setToasts((p) => p.filter((t) => t.id !== id)), 5000);
54
+ }
55
+
56
+ function selectTool(tool: Tool) {
57
+ setSelected(tool);
58
+ setResult(null);
59
+ setError(null);
60
+ const example = EXAMPLE_PROMPTS[tool.name];
61
+ setArgs(JSON.stringify(example ?? {}, null, 2));
62
+ }
63
+
64
+ async function handleCall() {
65
+ if (!selected) return;
66
+ let parsed: Record<string, unknown>;
67
+ try {
68
+ parsed = args.trim() ? JSON.parse(args) : {};
69
+ } catch {
70
+ setError("Arguments must be valid JSON");
71
+ return;
72
+ }
73
+ setRunning(true);
74
+ setResult(null);
75
+ setError(null);
76
+ try {
77
+ const res = await fetch("/api/mcp/call", {
78
+ method: "POST",
79
+ headers: { "Content-Type": "application/json" },
80
+ body: JSON.stringify({ name: selected.name, arguments: parsed }),
81
+ });
82
+ const data = await res.json();
83
+ if (!res.ok) {
84
+ setError(data.detail ?? `HTTP ${res.status}`);
85
+ showToast("error", `${selected.name} failed`);
86
+ } else {
87
+ setResult(JSON.stringify(data.result, null, 2));
88
+ showToast("success", `${selected.name} completed`);
89
+ }
90
+ } catch (e) {
91
+ setError(e instanceof Error ? e.message : "Call failed");
92
+ } finally {
93
+ setRunning(false);
94
+ }
95
+ }
96
+
97
+ const required = selected?.inputSchema?.required ?? [];
98
+
99
+ return (
100
+ <div className="ws-page ws-scroll">
101
+ <div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 24, maxWidth: 1100, margin: "0 auto", width: "100%" }}>
102
+ {/* Header */}
103
+ <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
104
+ <div style={{ width: 42, height: 42, borderRadius: 13, background: "var(--ws-accent-soft)", border: "1px solid var(--ws-border-strong)", display: "grid", placeItems: "center", color: "var(--ws-accent)", flexShrink: 0 }}>
105
+ <Wrench size={20} />
106
+ </div>
107
+ <div>
108
+ <h2 className="ws-title" style={{ fontSize: 18 }}>MCP Tool Console</h2>
109
+ <div className="ws-body" style={{ marginTop: 2 }}>
110
+ {tools.length} tools published to Claude, Codex and Copilot. Calls run against this live Studio graph.
111
+ </div>
112
+ </div>
113
+ </div>
114
+
115
+ <div style={{ display: "grid", gridTemplateColumns: "300px 1fr", gap: 18, alignItems: "start" }}>
116
+ {/* Tool list */}
117
+ <div className="ws-card" style={{ padding: 16, display: "flex", flexDirection: "column", gap: 6, maxHeight: 560, overflowY: "auto" }}>
118
+ <div className="ws-label">Available tools</div>
119
+ {tools.length === 0 ? <div className="ws-body" style={{ fontSize: 12 }}>Loading…</div> : null}
120
+ {tools.map((t) => (
121
+ <button key={t.name} className="ws-btn" data-active={selected?.name === t.name}
122
+ onClick={() => selectTool(t)}
123
+ style={{ justifyContent: "space-between", fontSize: 12, textAlign: "left" }}>
124
+ <span style={{ fontFamily: "var(--ws-mono, monospace)" }}>{t.name}</span>
125
+ <ChevronRight size={13} />
126
+ </button>
127
+ ))}
128
+ </div>
129
+
130
+ {/* Call panel */}
131
+ <div className="ws-card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
132
+ {!selected ? (
133
+ <div className="ws-body">Select a tool to inspect its schema and invoke it.</div>
134
+ ) : (
135
+ <>
136
+ <div>
137
+ <div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14, fontFamily: "var(--ws-mono, monospace)" }}>{selected.name}</div>
138
+ <div className="ws-body" style={{ fontSize: 12, marginTop: 4 }}>{selected.description}</div>
139
+ </div>
140
+
141
+ {/* Schema summary */}
142
+ <div style={{ padding: "12px 14px", borderRadius: "var(--ws-radius-sm)", background: "rgba(0,0,0,0.16)", border: "1px solid var(--ws-border)" }}>
143
+ <div className="ws-label" style={{ marginBottom: 6 }}>Parameters</div>
144
+ {Object.keys(selected.inputSchema?.properties ?? {}).length === 0 ? (
145
+ <div className="ws-body" style={{ fontSize: 12 }}>No parameters.</div>
146
+ ) : (
147
+ <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
148
+ {Object.entries(selected.inputSchema.properties ?? {}).map(([name, spec]) => (
149
+ <div key={name} style={{ fontSize: 12, color: "var(--ws-text-muted)" }}>
150
+ <code style={{ color: "var(--ws-accent)" }}>{name}</code>
151
+ {required.includes(name) ? <span style={{ color: "#ec2315" }}> *</span> : null}
152
+ {spec.type ? <span> · {spec.type}</span> : null}
153
+ {spec.description ? <span> — {spec.description}</span> : null}
154
+ </div>
155
+ ))}
156
+ </div>
157
+ )}
158
+ </div>
159
+
160
+ <div>
161
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
162
+ <label className="ws-label">Arguments (JSON)</label>
163
+ {EXAMPLE_PROMPTS[selected.name] ? (
164
+ <button className="ws-btn ws-btn--ghost" style={{ fontSize: 11, padding: "4px 10px" }}
165
+ onClick={() => setArgs(JSON.stringify(EXAMPLE_PROMPTS[selected.name], null, 2))}>
166
+ <Wand2 size={12} /> Load example
167
+ </button>
168
+ ) : null}
169
+ </div>
170
+ <textarea className="ws-input" style={{ width: "100%", minHeight: 140, fontFamily: "var(--ws-mono, monospace)", fontSize: 12, resize: "vertical" }}
171
+ value={args} onChange={(e) => setArgs(e.target.value)} disabled={running} spellCheck={false} />
172
+ </div>
173
+
174
+ <button className="ws-btn" onClick={handleCall} disabled={running}
175
+ style={{ justifyContent: "center", background: "var(--ws-accent-soft)", borderColor: "var(--ws-accent)", color: "var(--ws-accent)" }}>
176
+ {running ? <><Loader2 size={15} className="ws-spin" />Running…</> : <><Play size={15} />Invoke tool</>}
177
+ </button>
178
+
179
+ {error ? (
180
+ <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 14px", borderRadius: "var(--ws-radius-sm)", border: "1px solid rgba(236,35,21,0.4)", background: "rgba(255,255,255,0.6)" }}>
181
+ <AlertCircle size={15} color="#ec2315" />
182
+ <span style={{ fontSize: 13, color: "var(--ws-text)" }}>{error}</span>
183
+ </div>
184
+ ) : null}
185
+
186
+ {result ? (
187
+ <div>
188
+ <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
189
+ <CheckCircle2 size={15} color="#1f9d5f" />
190
+ <span className="ws-label" style={{ margin: 0 }}>Result</span>
191
+ </div>
192
+ <pre style={{
193
+ margin: 0, padding: "14px 16px", borderRadius: "var(--ws-radius-sm)",
194
+ background: "rgba(0,0,0,0.2)", border: "1px solid var(--ws-border)",
195
+ color: "var(--ws-text)", fontSize: 12, overflowX: "auto", maxHeight: 340,
196
+ fontFamily: "var(--ws-mono, monospace)",
197
+ }}>{result}</pre>
198
+ </div>
199
+ ) : null}
200
+ </>
201
+ )}
202
+ </div>
203
+ </div>
204
+
205
+ <div style={{ position: "fixed", bottom: 28, right: 28, display: "flex", flexDirection: "column", gap: 10, zIndex: 1000 }}>
206
+ {toasts.map((t) => (
207
+ <div key={t.id} className="ws-animate-in" style={{ display: "flex", alignItems: "center", gap: 10, padding: "13px 18px", borderRadius: "var(--ws-radius-sm)", background: "rgba(255,255,255,0.96)", border: `1px solid ${t.type === "success" ? "rgba(76,195,138,0.4)" : "rgba(255,123,114,0.4)"}`, boxShadow: "0 8px 24px rgba(0,0,0,0.5)", backdropFilter: "blur(12px)" }}>
208
+ {t.type === "success" ? <CheckCircle2 size={16} color="#1f9d5f" /> : <AlertCircle size={16} color="#ec2315" />}
209
+ <span style={{ color: "var(--ws-text)", fontSize: 13, fontWeight: 500 }}>{t.text}</span>
210
+ </div>
211
+ ))}
212
+ </div>
213
+ </div>
214
+ </div>
215
+ );
216
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ltm-blueverse/alpha-semantic-hub",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "Alpha Semantic Hub \u2014 Graph-Native Infrastructure for Context and Accountable AI Systems",
5
5
  "keywords": [
6
6
  "alpha-semantic-hub",