@ltm-blueverse/alpha-semantic-hub 0.7.3 → 0.9.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 +52 -0
- package/explorer/src/App.tsx +18 -3
- package/explorer/src/workspaces/IngestWorkspace/IngestWorkspace.tsx +442 -0
- package/explorer/src/workspaces/LlmSettingsWorkspace/LlmSettingsWorkspace.tsx +293 -0
- package/explorer/src/workspaces/McpConsoleWorkspace/McpConsoleWorkspace.tsx +216 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -322,6 +322,58 @@ 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
|
+
### Connector catalog and the credential vault
|
|
326
|
+
|
|
327
|
+
**26 connectors in 5 categories**, all selectable from the Studio's Source
|
|
328
|
+
Ingestion tab:
|
|
329
|
+
|
|
330
|
+
| Category | Connectors |
|
|
331
|
+
|---|---|
|
|
332
|
+
| Warehouses & Lakehouses | Databricks, Snowflake, BigQuery, Redshift |
|
|
333
|
+
| Databases | SQL (Postgres/MySQL/SQLite), DuckDB, MongoDB, Cassandra, Elasticsearch |
|
|
334
|
+
| Business SaaS | Salesforce, SAP (OData), ServiceNow, Power BI, SharePoint/OneDrive (Graph API) |
|
|
335
|
+
| Files & Storage | File, Parquet, XML, Cloud Storage (S3/GCS/Azure), Google Drive |
|
|
336
|
+
| Web & APIs | REST, Web page, RSS/Atom, Git repo, Email (IMAP/POP3), HuggingFace, MCP server |
|
|
337
|
+
|
|
338
|
+
Connectors marked `dep` need an optional dependency (e.g. `lxml`, `boto3`,
|
|
339
|
+
`pymongo`, `gitpython`, `duckdb`, `google-cloud-bigquery`); the Studio shows
|
|
340
|
+
this before you run.
|
|
341
|
+
|
|
342
|
+
**Credentials vault.** Secrets entered in the UI are encrypted and stored under
|
|
343
|
+
`ASHUB_VAULT_DIR` (default `/data/vault`, mounted from `./data`). Saved
|
|
344
|
+
credentials are reusable across ingestion runs and LLM calls.
|
|
345
|
+
|
|
346
|
+
* `ASHUB_VAULT_KEY` — optional 32-byte master key (base64/hex). When unset, a
|
|
347
|
+
key file is generated at `<vault_dir>/vault.key` with `0600` permissions.
|
|
348
|
+
* Records are encrypted with encrypt-then-MAC (HMAC-SHA256, HKDF-derived keys)
|
|
349
|
+
using only the standard library — no new dependencies in the hash-pinned
|
|
350
|
+
image. Tampered records fail closed rather than decrypting.
|
|
351
|
+
* Secrets are never returned by the list API and are scrubbed from job/error
|
|
352
|
+
messages.
|
|
353
|
+
|
|
354
|
+
**OpenAI-compatible LLM endpoints.** Besides the 8 built-in providers, the LLM
|
|
355
|
+
Endpoints tab can add any `/v1`-style endpoint (vLLM, Ollama's OpenAI layer,
|
|
356
|
+
OpenRouter, LiteLLM proxies, Azure gateways): enter a base URL + optional API
|
|
357
|
+
key, **fetch the model list from the endpoint**, pick a model, save it to the
|
|
358
|
+
vault, and set it as the platform default.
|
|
359
|
+
|
|
360
|
+
### Ingestion, MCP tools and LLM endpoints in the Studio
|
|
361
|
+
|
|
362
|
+
The **Enrich** workspace has three tabs for this:
|
|
363
|
+
|
|
364
|
+
- **Source Ingestion** — connect Databricks, SAP (OData), Snowflake, Salesforce,
|
|
365
|
+
REST endpoints (incl. Microsoft Graph for SharePoint), web pages or files.
|
|
366
|
+
Test the connection, run ingestion with live progress, and extraction loads
|
|
367
|
+
the entities/relations straight into the graph on screen.
|
|
368
|
+
- **MCP Tools** — browse and invoke all 16 tools that Claude Code / Codex /
|
|
369
|
+
Copilot get over MCP. Calls run against the same live graph the Studio shows.
|
|
370
|
+
- **LLM Endpoints** — shows where the platform uses language models, which
|
|
371
|
+
providers are configured (OpenAI, Anthropic, Gemini, Groq, DeepSeek, Novita,
|
|
372
|
+
Ollama, HuggingFace) and fires a real test prompt per provider.
|
|
373
|
+
|
|
374
|
+
`npm-packages/` staging, `data/` volumes and container-local paths (`/data/…`)
|
|
375
|
+
apply as per the compose file.
|
|
376
|
+
|
|
325
377
|
### Running ingestion (Python workflow)
|
|
326
378
|
|
|
327
379
|
Source ingestion is a **Python-library workflow** — the Studio UI imports graph
|
package/explorer/src/App.tsx
CHANGED
|
@@ -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 === '
|
|
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 === '
|
|
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,442 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Database, Plug, CheckCircle2, AlertCircle, Loader2, FileText, Globe,
|
|
4
|
+
Boxes, Cloud, Server, Play, ChevronDown, KeyRound, Save, Trash2, Lock,
|
|
5
|
+
} from "lucide-react";
|
|
6
|
+
|
|
7
|
+
type ConnField = {
|
|
8
|
+
name: string; label: string; required?: boolean;
|
|
9
|
+
secret?: boolean; boolean?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type Connector = {
|
|
13
|
+
kind: string; label: string; credential_hint: string | null;
|
|
14
|
+
env_fallback: string | null; source_label: string; source_placeholder: string;
|
|
15
|
+
optional_fields: ConnField[]; secret_fields: string[];
|
|
16
|
+
extra_dependency: string | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type Category = { category: string; connectors: Connector[] };
|
|
20
|
+
type Credential = {
|
|
21
|
+
id: string; kind: string; label: string; metadata: Record<string, unknown>;
|
|
22
|
+
field_names: string[]; updated_at: number;
|
|
23
|
+
};
|
|
24
|
+
type Toast = { id: number; type: "success" | "error"; text: string };
|
|
25
|
+
type FieldValues = Record<string, string | boolean>;
|
|
26
|
+
|
|
27
|
+
const KIND_ICONS: Record<string, typeof Database> = {
|
|
28
|
+
databricks: Boxes, snowflake: Cloud, bigquery: Cloud, redshift: Cloud,
|
|
29
|
+
sql: Database, duckdb: Database, mongo: Database, cassandra: Database,
|
|
30
|
+
elastic: Database, salesforce: Cloud, sap: Server, servicenow: Server,
|
|
31
|
+
powerbi: Cloud, sharepoint: Cloud, file: FileText, parquet: FileText,
|
|
32
|
+
xml: FileText, cloudstorage: Cloud, gdrive: Cloud, rest: Plug,
|
|
33
|
+
web: Globe, feed: Globe, repo: Globe, email: Globe,
|
|
34
|
+
huggingface: Globe, mcp: Plug,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function nestedSet(obj: Record<string, unknown>, path: string, value: unknown) {
|
|
38
|
+
const parts = path.split(".");
|
|
39
|
+
let cur = obj;
|
|
40
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
41
|
+
if (typeof cur[parts[i]] !== "object" || cur[parts[i]] === null) cur[parts[i]] = {};
|
|
42
|
+
cur = cur[parts[i]] as Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
cur[parts[parts.length - 1]] = value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function IngestWorkspace() {
|
|
48
|
+
const [categories, setCategories] = useState<Category[]>([]);
|
|
49
|
+
const [active, setActive] = useState<Connector | null>(null);
|
|
50
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
51
|
+
const [credentials, setCredentials] = useState<Credential[]>([]);
|
|
52
|
+
const [credentialId, setCredentialId] = useState("");
|
|
53
|
+
const [values, setValues] = useState<FieldValues>({});
|
|
54
|
+
const [saveToVault, setSaveToVault] = useState(true);
|
|
55
|
+
const [vaultName, setVaultName] = useState("");
|
|
56
|
+
const [sourceRef, setSourceRef] = useState("");
|
|
57
|
+
const [extract, setExtract] = useState(true);
|
|
58
|
+
const [validating, setValidating] = useState(false);
|
|
59
|
+
const [validation, setValidation] = useState<{ ok: boolean; detail: string } | null>(null);
|
|
60
|
+
const [running, setRunning] = useState(false);
|
|
61
|
+
const [job, setJob] = useState<{ status: string; stage: string; progress: number; message: string } | null>(null);
|
|
62
|
+
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
63
|
+
const pollRef = useRef<number | null>(null);
|
|
64
|
+
|
|
65
|
+
function showToast(type: Toast["type"], text: string) {
|
|
66
|
+
const id = Date.now();
|
|
67
|
+
setToasts((p) => [...p, { id, type, text }]);
|
|
68
|
+
setTimeout(() => setToasts((p) => p.filter((t) => t.id !== id)), 6000);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function loadCredentials() {
|
|
72
|
+
try {
|
|
73
|
+
const r = await fetch("/api/vault/credentials?kind=source");
|
|
74
|
+
const d = await r.json();
|
|
75
|
+
setCredentials(d.credentials ?? []);
|
|
76
|
+
} catch { /* ignore */ }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
fetch("/api/ingest/sources").then((r) => r.json()).then((d) => {
|
|
81
|
+
const cats: Category[] = d.categories ?? [];
|
|
82
|
+
setCategories(cats);
|
|
83
|
+
const first = cats[0]?.connectors[0];
|
|
84
|
+
if (first) setActive(first);
|
|
85
|
+
}).catch(() => {});
|
|
86
|
+
loadCredentials();
|
|
87
|
+
return () => { if (pollRef.current) window.clearInterval(pollRef.current); };
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
function selectConnector(c: Connector) {
|
|
91
|
+
setActive(c);
|
|
92
|
+
setPickerOpen(false);
|
|
93
|
+
setValues({});
|
|
94
|
+
setSourceRef("");
|
|
95
|
+
setValidation(null);
|
|
96
|
+
setJob(null);
|
|
97
|
+
setCredentialId("");
|
|
98
|
+
setVaultName(`${c.kind}-credentials`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function applyCredential(id: string) {
|
|
102
|
+
setCredentialId(id);
|
|
103
|
+
setValidation(null);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildOptions(): Record<string, unknown> {
|
|
107
|
+
const options: Record<string, unknown> = {};
|
|
108
|
+
for (const [k, v] of Object.entries(values)) {
|
|
109
|
+
if (v !== "" && v !== false && v !== undefined) nestedSet(options, k, v);
|
|
110
|
+
}
|
|
111
|
+
return options;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function handleSaveCredential() {
|
|
115
|
+
if (!active) return;
|
|
116
|
+
const fields: Record<string, string> = {};
|
|
117
|
+
for (const [k, v] of Object.entries(values)) {
|
|
118
|
+
if (typeof v === "string" && v.trim()) fields[k] = v;
|
|
119
|
+
}
|
|
120
|
+
if (Object.keys(fields).length === 0) {
|
|
121
|
+
showToast("error", "Enter credential fields before saving");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const r = await fetch("/api/vault/credentials", {
|
|
126
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
127
|
+
body: JSON.stringify({ id: vaultName.trim() || `${active.kind}-credentials`,
|
|
128
|
+
kind: "source", label: `${active.label} credentials`, fields }),
|
|
129
|
+
});
|
|
130
|
+
if (!r.ok) throw new Error((await r.json()).detail ?? `HTTP ${r.status}`);
|
|
131
|
+
showToast("success", "Saved to vault");
|
|
132
|
+
await loadCredentials();
|
|
133
|
+
setCredentialId(vaultName.trim() || `${active.kind}-credentials`);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
showToast("error", e instanceof Error ? e.message : "Save failed");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function handleDeleteCredential(id: string) {
|
|
140
|
+
try {
|
|
141
|
+
const r = await fetch(`/api/vault/credentials/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
142
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
143
|
+
showToast("success", "Credential deleted");
|
|
144
|
+
if (credentialId === id) setCredentialId("");
|
|
145
|
+
await loadCredentials();
|
|
146
|
+
} catch (e) {
|
|
147
|
+
showToast("error", e instanceof Error ? e.message : "Delete failed");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function handleValidate() {
|
|
152
|
+
if (!active) return;
|
|
153
|
+
setValidating(true);
|
|
154
|
+
setValidation(null);
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetch("/api/ingest/validate", {
|
|
157
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
158
|
+
body: JSON.stringify({ kind: active.kind, credential_id: credentialId || null, options: buildOptions() }),
|
|
159
|
+
});
|
|
160
|
+
const data = await res.json();
|
|
161
|
+
setValidation({ ok: !!data.ok, detail: data.detail ?? "Unknown response" });
|
|
162
|
+
} catch (e) {
|
|
163
|
+
setValidation({ ok: false, detail: e instanceof Error ? e.message : "Validation failed" });
|
|
164
|
+
} finally {
|
|
165
|
+
setValidating(false);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function handleRun() {
|
|
170
|
+
if (!active) return;
|
|
171
|
+
if (saveToVault && credentialId === "") {
|
|
172
|
+
const fields: Record<string, string> = {};
|
|
173
|
+
for (const [k, v] of Object.entries(values)) {
|
|
174
|
+
if (typeof v === "string" && v.trim()) fields[k] = v;
|
|
175
|
+
}
|
|
176
|
+
if (Object.keys(fields).length > 0) await handleSaveCredential();
|
|
177
|
+
}
|
|
178
|
+
setRunning(true);
|
|
179
|
+
setJob({ status: "queued", stage: "queued", progress: 0, message: "Starting…" });
|
|
180
|
+
try {
|
|
181
|
+
const res = await fetch("/api/ingest/run", {
|
|
182
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
kind: active.kind, source: sourceRef.trim(),
|
|
185
|
+
credential_id: credentialId || null,
|
|
186
|
+
options: buildOptions(), extract,
|
|
187
|
+
}),
|
|
188
|
+
});
|
|
189
|
+
if (!res.ok) throw new Error((await res.json()).detail ?? `HTTP ${res.status}`);
|
|
190
|
+
const { job_id } = await res.json();
|
|
191
|
+
pollRef.current = window.setInterval(async () => {
|
|
192
|
+
try {
|
|
193
|
+
const st = await fetch(`/api/ingest/status/${job_id}`);
|
|
194
|
+
if (!st.ok) return;
|
|
195
|
+
const data = await st.json();
|
|
196
|
+
setJob(data);
|
|
197
|
+
if (data.status === "done" || data.status === "error") {
|
|
198
|
+
window.clearInterval(pollRef.current!); pollRef.current = null;
|
|
199
|
+
setRunning(false);
|
|
200
|
+
showToast(data.status === "done" ? "success" : "error", data.message);
|
|
201
|
+
}
|
|
202
|
+
} catch { /* transient */ }
|
|
203
|
+
}, 1000);
|
|
204
|
+
} catch (e) {
|
|
205
|
+
setRunning(false); setJob(null);
|
|
206
|
+
showToast("error", e instanceof Error ? e.message : "Failed to start ingestion");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const ActiveIcon = active ? (KIND_ICONS[active.kind] ?? Database) : Database;
|
|
211
|
+
const vaultForKind = credentials.filter((c) => c.id.startsWith(active?.kind ?? "___"));
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div className="ws-page ws-scroll">
|
|
215
|
+
<div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 24, maxWidth: 1040, margin: "0 auto", width: "100%" }}>
|
|
216
|
+
{/* Header */}
|
|
217
|
+
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
|
218
|
+
<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 }}>
|
|
219
|
+
<Database size={20} />
|
|
220
|
+
</div>
|
|
221
|
+
<div>
|
|
222
|
+
<h2 className="ws-title" style={{ fontSize: 18 }}>Ingest from Source Systems</h2>
|
|
223
|
+
<div className="ws-body" style={{ marginTop: 2 }}>
|
|
224
|
+
{categories.reduce((n, c) => n + c.connectors.length, 0)} connectors across{" "}
|
|
225
|
+
{categories.length} categories. Credentials save to the encrypted vault and can be reused.
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{/* Connector picker */}
|
|
231
|
+
<div className="ws-card" style={{ padding: 18, position: "relative" }}>
|
|
232
|
+
<div className="ws-label">Connector</div>
|
|
233
|
+
<button className="ws-btn" onClick={() => setPickerOpen((v) => !v)}
|
|
234
|
+
style={{ width: "100%", justifyContent: "space-between", fontSize: 13 }}>
|
|
235
|
+
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
236
|
+
<ActiveIcon size={15} /> {active?.label ?? "Select a connector…"}
|
|
237
|
+
</span>
|
|
238
|
+
<ChevronDown size={15} />
|
|
239
|
+
</button>
|
|
240
|
+
|
|
241
|
+
{pickerOpen ? (
|
|
242
|
+
<div style={{
|
|
243
|
+
position: "absolute", top: "100%", left: 18, right: 18, zIndex: 50, marginTop: 6,
|
|
244
|
+
background: "#fff", border: "1px solid var(--ws-border)", borderRadius: "var(--ws-radius)",
|
|
245
|
+
boxShadow: "0 12px 32px rgba(0,0,0,0.18)", maxHeight: 420, overflowY: "auto", padding: 10,
|
|
246
|
+
}}>
|
|
247
|
+
{categories.map((cat) => (
|
|
248
|
+
<div key={cat.category} style={{ marginBottom: 8 }}>
|
|
249
|
+
<div className="ws-label" style={{ padding: "6px 8px", margin: 0, opacity: 0.75 }}>{cat.category}</div>
|
|
250
|
+
{cat.connectors.map((c) => {
|
|
251
|
+
const Icon = KIND_ICONS[c.kind] ?? Database;
|
|
252
|
+
return (
|
|
253
|
+
<button key={c.kind} className="ws-btn" data-active={active?.kind === c.kind}
|
|
254
|
+
onClick={() => selectConnector(c)}
|
|
255
|
+
style={{ width: "100%", justifyContent: "flex-start", fontSize: 12.5, marginBottom: 2 }}>
|
|
256
|
+
<Icon size={14} />
|
|
257
|
+
<span style={{ flex: 1, textAlign: "left" }}>{c.label}</span>
|
|
258
|
+
{c.extra_dependency ? (
|
|
259
|
+
<span title={`Extra dependency: ${c.extra_dependency}`}
|
|
260
|
+
style={{ fontSize: 10, opacity: 0.6, color: "#c9a227" }}>dep</span>
|
|
261
|
+
) : null}
|
|
262
|
+
</button>
|
|
263
|
+
);
|
|
264
|
+
})}
|
|
265
|
+
</div>
|
|
266
|
+
))}
|
|
267
|
+
</div>
|
|
268
|
+
) : null}
|
|
269
|
+
|
|
270
|
+
{active ? (
|
|
271
|
+
<div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 4 }}>
|
|
272
|
+
{active.credential_hint ? (
|
|
273
|
+
<div className="ws-body" style={{ fontSize: 12 }}>
|
|
274
|
+
<KeyRound size={12} style={{ verticalAlign: -2, marginRight: 6 }} />
|
|
275
|
+
Credentials: {active.credential_hint}
|
|
276
|
+
</div>
|
|
277
|
+
) : null}
|
|
278
|
+
{active.env_fallback ? (
|
|
279
|
+
<div className="ws-body" style={{ fontSize: 11, opacity: 0.75 }}>
|
|
280
|
+
Env fallback: <code>{active.env_fallback}</code>
|
|
281
|
+
</div>
|
|
282
|
+
) : null}
|
|
283
|
+
{active.extra_dependency ? (
|
|
284
|
+
<div style={{ fontSize: 11, color: "#a8791f" }}>
|
|
285
|
+
Requires extra dependency: <code>{active.extra_dependency}</code>
|
|
286
|
+
</div>
|
|
287
|
+
) : null}
|
|
288
|
+
</div>
|
|
289
|
+
) : null}
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
{active ? (
|
|
293
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 18, alignItems: "start" }}>
|
|
294
|
+
{/* Credentials card */}
|
|
295
|
+
<div className="ws-card" style={{ padding: 22, display: "flex", flexDirection: "column", gap: 14 }}>
|
|
296
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
297
|
+
<Lock size={16} color="var(--ws-accent)" />
|
|
298
|
+
<div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>Credentials</div>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<div>
|
|
302
|
+
<label className="ws-label">Saved credential (from vault)</label>
|
|
303
|
+
<select className="ws-input" style={{ width: "100%" }} value={credentialId}
|
|
304
|
+
onChange={(e) => applyCredential(e.target.value)} disabled={running}>
|
|
305
|
+
<option value="">— none (enter below / use env) —</option>
|
|
306
|
+
{credentials.map((c) => (
|
|
307
|
+
<option key={c.id} value={c.id}>{c.label} ({c.id})</option>
|
|
308
|
+
))}
|
|
309
|
+
</select>
|
|
310
|
+
{vaultForKind.length > 0 ? (
|
|
311
|
+
<div className="ws-body" style={{ fontSize: 11, marginTop: 4 }}>
|
|
312
|
+
{vaultForKind.length} credential(s) saved for {active.kind}
|
|
313
|
+
</div>
|
|
314
|
+
) : null}
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
{active.optional_fields.filter((f) => !["as_query", "include_subfolders", "download", "as_file", "respect_robots", "unread_only"].includes(f.name)).map((f) => (
|
|
318
|
+
<div key={f.name}>
|
|
319
|
+
<label className="ws-label">{f.label}{f.required ? " *" : ""}</label>
|
|
320
|
+
<input className="ws-input" style={{ width: "100%" }}
|
|
321
|
+
type={f.secret ? "password" : "text"}
|
|
322
|
+
value={String(values[f.name] ?? "")}
|
|
323
|
+
onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
|
|
324
|
+
disabled={running} />
|
|
325
|
+
</div>
|
|
326
|
+
))}
|
|
327
|
+
|
|
328
|
+
{active.optional_fields.filter((f) => f.boolean).length > 0 ? (
|
|
329
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
|
330
|
+
{active.optional_fields.filter((f) => f.boolean).map((f) => (
|
|
331
|
+
<button key={f.name} className={`ws-btn ${values[f.name] ? "ws-btn--primary" : "ws-btn--ghost"}`}
|
|
332
|
+
onClick={() => setValues((v) => ({ ...v, [f.name]: !v[f.name] }))}
|
|
333
|
+
disabled={running} style={{ fontSize: 11.5 }}>
|
|
334
|
+
{f.label}: {values[f.name] ? "Yes" : "No"}
|
|
335
|
+
</button>
|
|
336
|
+
))}
|
|
337
|
+
</div>
|
|
338
|
+
) : null}
|
|
339
|
+
|
|
340
|
+
<div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
|
|
341
|
+
<div style={{ flex: 1 }}>
|
|
342
|
+
<label className="ws-label">Save as (vault name)</label>
|
|
343
|
+
<input className="ws-input" style={{ width: "100%" }} value={vaultName}
|
|
344
|
+
onChange={(e) => setVaultName(e.target.value)} disabled={running} />
|
|
345
|
+
</div>
|
|
346
|
+
<button className="ws-btn ws-btn--ghost" onClick={handleSaveCredential}
|
|
347
|
+
disabled={running} style={{ whiteSpace: "nowrap" }}>
|
|
348
|
+
<Save size={14} /> Save
|
|
349
|
+
</button>
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
{credentials.length > 0 ? (
|
|
353
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
|
354
|
+
<div className="ws-label" style={{ marginBottom: 2 }}>Vault entries</div>
|
|
355
|
+
{credentials.map((c) => (
|
|
356
|
+
<div key={c.id} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12 }}>
|
|
357
|
+
<CheckCircle2 size={12} color="#1f9d5f" />
|
|
358
|
+
<span style={{ flex: 1, color: "var(--ws-text)" }}>{c.label}</span>
|
|
359
|
+
<button className="ws-btn ws-btn--ghost" style={{ padding: "2px 8px" }}
|
|
360
|
+
onClick={() => handleDeleteCredential(c.id)} title="Delete">
|
|
361
|
+
<Trash2 size={12} />
|
|
362
|
+
</button>
|
|
363
|
+
</div>
|
|
364
|
+
))}
|
|
365
|
+
</div>
|
|
366
|
+
) : null}
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
{/* Run card */}
|
|
370
|
+
<div className="ws-card" style={{ padding: 22, display: "flex", flexDirection: "column", gap: 14 }}>
|
|
371
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
372
|
+
<Play size={16} color="var(--ws-accent)" />
|
|
373
|
+
<div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>Run ingestion</div>
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
<div>
|
|
377
|
+
<label className="ws-label">{active.source_label}</label>
|
|
378
|
+
<input className="ws-input" style={{ width: "100%" }} value={sourceRef}
|
|
379
|
+
onChange={(e) => setSourceRef(e.target.value)}
|
|
380
|
+
placeholder={active.source_placeholder} disabled={running} />
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
<button className={`ws-btn ${extract ? "ws-btn--primary" : "ws-btn--ghost"}`}
|
|
384
|
+
onClick={() => setExtract((v) => !v)} disabled={running}
|
|
385
|
+
style={{ justifyContent: "center", fontSize: 12 }}>
|
|
386
|
+
{extract ? "Extraction ON — build entities & relations" : "Extraction OFF — documents only"}
|
|
387
|
+
</button>
|
|
388
|
+
|
|
389
|
+
<button className={`ws-btn ${saveToVault ? "ws-btn--primary" : "ws-btn--ghost"}`}
|
|
390
|
+
onClick={() => setSaveToVault((v) => !v)} disabled={running}
|
|
391
|
+
style={{ justifyContent: "center", fontSize: 12 }}>
|
|
392
|
+
<Lock size={13} /> {saveToVault ? "Auto-save credentials to vault" : "Don't save credentials"}
|
|
393
|
+
</button>
|
|
394
|
+
|
|
395
|
+
{validation ? (
|
|
396
|
+
<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)" }}>
|
|
397
|
+
{validation.ok ? <CheckCircle2 size={15} color="#1f9d5f" /> : <AlertCircle size={15} color="#ec2315" />}
|
|
398
|
+
<span style={{ fontSize: 12.5, color: "var(--ws-text)" }}>{validation.detail}</span>
|
|
399
|
+
</div>
|
|
400
|
+
) : null}
|
|
401
|
+
|
|
402
|
+
{job ? (
|
|
403
|
+
<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)" }}>
|
|
404
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
405
|
+
{job.status === "running" || job.status === "queued"
|
|
406
|
+
? <Loader2 size={15} className="ws-spin" color="var(--ws-accent)" />
|
|
407
|
+
: job.status === "done" ? <CheckCircle2 size={15} color="#1f9d5f" /> : <AlertCircle size={15} color="#ec2315" />}
|
|
408
|
+
<span style={{ fontSize: 12.5, color: "var(--ws-text)", fontWeight: 600 }}>{job.message}</span>
|
|
409
|
+
</div>
|
|
410
|
+
<div style={{ height: 6, borderRadius: 3, background: "rgba(0,0,0,0.25)", overflow: "hidden" }}>
|
|
411
|
+
<div style={{ width: `${job.progress}%`, height: "100%", background: job.status === "error" ? "#ec2315" : "var(--ws-accent)", transition: "width 400ms ease" }} />
|
|
412
|
+
</div>
|
|
413
|
+
<div className="ws-body" style={{ fontSize: 11 }}>Stage: {job.stage}</div>
|
|
414
|
+
</div>
|
|
415
|
+
) : null}
|
|
416
|
+
|
|
417
|
+
<div style={{ display: "flex", gap: 10 }}>
|
|
418
|
+
<button className="ws-btn ws-btn--ghost" onClick={handleValidate}
|
|
419
|
+
disabled={validating || running} style={{ flex: 1, justifyContent: "center" }}>
|
|
420
|
+
{validating ? <Loader2 size={15} className="ws-spin" /> : <Plug size={15} />} Test connection
|
|
421
|
+
</button>
|
|
422
|
+
<button className="ws-btn" onClick={handleRun} disabled={running || validating}
|
|
423
|
+
style={{ flex: 2, justifyContent: "center", background: "var(--ws-accent-soft)", borderColor: "var(--ws-accent)", color: "var(--ws-accent)" }}>
|
|
424
|
+
{running ? <><Loader2 size={15} className="ws-spin" />Ingesting…</> : <><Play size={15} />Run ingestion</>}
|
|
425
|
+
</button>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
) : null}
|
|
430
|
+
|
|
431
|
+
<div style={{ position: "fixed", bottom: 28, right: 28, display: "flex", flexDirection: "column", gap: 10, zIndex: 1000 }}>
|
|
432
|
+
{toasts.map((t) => (
|
|
433
|
+
<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)" }}>
|
|
434
|
+
{t.type === "success" ? <CheckCircle2 size={16} color="#1f9d5f" /> : <AlertCircle size={16} color="#ec2315" />}
|
|
435
|
+
<span style={{ color: "var(--ws-text)", fontSize: 13, fontWeight: 500 }}>{t.text}</span>
|
|
436
|
+
</div>
|
|
437
|
+
))}
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Sparkles, CheckCircle2, AlertCircle, Loader2, Send, Info, Save, Plus, Trash2,
|
|
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
|
+
type: "builtin" | "custom"; base_url?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type UsageRow = { task: string; when: string; where: string };
|
|
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 [fetchedModels, setFetchedModels] = useState<string[]>([]);
|
|
21
|
+
const [testing, setTesting] = useState(false);
|
|
22
|
+
const [fetching, setFetching] = useState(false);
|
|
23
|
+
const [result, setResult] = useState<{ ok: boolean; detail: string; model?: string } | null>(null);
|
|
24
|
+
|
|
25
|
+
// custom endpoint form
|
|
26
|
+
const [showAdd, setShowAdd] = useState(false);
|
|
27
|
+
const [epName, setEpName] = useState("");
|
|
28
|
+
const [epLabel, setEpLabel] = useState("");
|
|
29
|
+
const [epBaseUrl, setEpBaseUrl] = useState("");
|
|
30
|
+
const [epKey, setEpKey] = useState("");
|
|
31
|
+
const [saving, setSaving] = useState(false);
|
|
32
|
+
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
33
|
+
|
|
34
|
+
function showToast(type: Toast["type"], text: string) {
|
|
35
|
+
const id = Date.now();
|
|
36
|
+
setToasts((p) => [...p, { id, type, text }]);
|
|
37
|
+
setTimeout(() => setToasts((p) => p.filter((t) => t.id !== id)), 5000);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function loadStatus() {
|
|
41
|
+
try {
|
|
42
|
+
const r = await fetch("/api/llm/status");
|
|
43
|
+
const d = await r.json();
|
|
44
|
+
setProviders(d.providers ?? []);
|
|
45
|
+
setUsage(d.usage ?? []);
|
|
46
|
+
const first = (d.providers ?? []).find((p: Provider) => p.configured) ?? d.providers?.[0];
|
|
47
|
+
if (first) { setSelected((s) => s ?? first); setModel((m) => m || first.models[0] || ""); }
|
|
48
|
+
} catch { /* ignore */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
useEffect(() => { loadStatus(); }, []);
|
|
52
|
+
|
|
53
|
+
function selectProvider(p: Provider) {
|
|
54
|
+
setSelected(p);
|
|
55
|
+
setResult(null);
|
|
56
|
+
setFetchedModels([]);
|
|
57
|
+
setModel(p.models[0] ?? "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function handleFetchModels() {
|
|
61
|
+
if (!selected) return;
|
|
62
|
+
setFetching(true);
|
|
63
|
+
try {
|
|
64
|
+
const body = selected.type === "custom"
|
|
65
|
+
? { provider: "custom", credential_id: selected.name }
|
|
66
|
+
: { provider: selected.name };
|
|
67
|
+
const r = await fetch("/api/llm/fetch_models", {
|
|
68
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
69
|
+
});
|
|
70
|
+
const d = await r.json();
|
|
71
|
+
if (d.ok) {
|
|
72
|
+
setFetchedModels(d.models ?? []);
|
|
73
|
+
if (d.models?.length && !d.models.includes(model)) setModel(d.models[0]);
|
|
74
|
+
showToast("success", `Found ${d.models?.length ?? 0} models`);
|
|
75
|
+
} else {
|
|
76
|
+
showToast("error", d.detail ?? "Fetch failed");
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
showToast("error", e instanceof Error ? e.message : "Fetch failed");
|
|
80
|
+
} finally { setFetching(false); }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function handleTest() {
|
|
84
|
+
if (!selected) return;
|
|
85
|
+
setTesting(true); setResult(null);
|
|
86
|
+
try {
|
|
87
|
+
const body = selected.type === "custom"
|
|
88
|
+
? { provider: "custom", credential_id: selected.name, model }
|
|
89
|
+
: { provider: selected.name, model };
|
|
90
|
+
const r = await fetch("/api/llm/test", {
|
|
91
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
92
|
+
});
|
|
93
|
+
const d = await r.json();
|
|
94
|
+
setResult(d);
|
|
95
|
+
showToast(d.ok ? "success" : "error", d.ok ? `${selected.label} responded` : `${selected.label}: unavailable`);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
setResult({ ok: false, detail: e instanceof Error ? e.message : "Test failed" });
|
|
98
|
+
} finally { setTesting(false); }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function handleSaveEndpoint() {
|
|
102
|
+
if (!epName.trim() || !epBaseUrl.trim()) {
|
|
103
|
+
showToast("error", "Name and base URL are required");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
setSaving(true);
|
|
107
|
+
try {
|
|
108
|
+
const r = await fetch("/api/llm/save", {
|
|
109
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
110
|
+
body: JSON.stringify({ name: epName.trim(), label: epLabel.trim() || epName.trim(),
|
|
111
|
+
base_url: epBaseUrl.trim(), api_key: epKey,
|
|
112
|
+
default_model: model }),
|
|
113
|
+
});
|
|
114
|
+
const d = await r.json();
|
|
115
|
+
if (!r.ok) throw new Error(d.detail ?? `HTTP ${r.status}`);
|
|
116
|
+
showToast("success", "Endpoint saved to vault");
|
|
117
|
+
setShowAdd(false); setEpName(""); setEpLabel(""); setEpBaseUrl(""); setEpKey("");
|
|
118
|
+
await loadStatus();
|
|
119
|
+
} catch (e) {
|
|
120
|
+
showToast("error", e instanceof Error ? e.message : "Save failed");
|
|
121
|
+
} finally { setSaving(false); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function handleDeleteProvider(p: Provider) {
|
|
125
|
+
if (p.type !== "custom") return;
|
|
126
|
+
try {
|
|
127
|
+
const r = await fetch(`/api/vault/credentials/${encodeURIComponent(p.name)}`, { method: "DELETE" });
|
|
128
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
129
|
+
showToast("success", `${p.label} removed`);
|
|
130
|
+
if (selected?.name === p.name) setSelected(null);
|
|
131
|
+
await loadStatus();
|
|
132
|
+
} catch (e) {
|
|
133
|
+
showToast("error", e instanceof Error ? e.message : "Delete failed");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const modelChoices = fetchedModels.length > 0 ? fetchedModels : (selected?.models ?? []);
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className="ws-page ws-scroll">
|
|
141
|
+
<div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 24, maxWidth: 1020, margin: "0 auto", width: "100%" }}>
|
|
142
|
+
{/* Header */}
|
|
143
|
+
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
|
144
|
+
<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 }}>
|
|
145
|
+
<Sparkles size={20} />
|
|
146
|
+
</div>
|
|
147
|
+
<div>
|
|
148
|
+
<h2 className="ws-title" style={{ fontSize: 18 }}>LLM Endpoints</h2>
|
|
149
|
+
<div className="ws-body" style={{ marginTop: 2 }}>
|
|
150
|
+
Built-in providers, OpenAI-compatible <code>/v1</code> endpoints (vLLM, Ollama, OpenRouter, gateways), and where the platform uses LLMs.
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{/* Where LLMs are used */}
|
|
156
|
+
<div className="ws-card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 12 }}>
|
|
157
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
158
|
+
<Info size={16} color="var(--ws-accent)" />
|
|
159
|
+
<div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>When does this platform call an LLM?</div>
|
|
160
|
+
</div>
|
|
161
|
+
{usage.map((u) => (
|
|
162
|
+
<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)" }}>
|
|
163
|
+
<div style={{ fontSize: 13, fontWeight: 700, color: "var(--ws-text)" }}>{u.task}</div>
|
|
164
|
+
<div className="ws-body" style={{ fontSize: 12, marginTop: 2 }}>{u.when}</div>
|
|
165
|
+
<div className="ws-body" style={{ fontSize: 11, marginTop: 2, fontFamily: "var(--ws-mono, monospace)", opacity: 0.7 }}>{u.where}</div>
|
|
166
|
+
</div>
|
|
167
|
+
))}
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{/* Providers */}
|
|
171
|
+
<div className="ws-card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
|
|
172
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
|
173
|
+
<div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>Providers & endpoints</div>
|
|
174
|
+
<button className="ws-btn ws-btn--ghost" style={{ fontSize: 12 }} onClick={() => setShowAdd((v) => !v)}>
|
|
175
|
+
<Plus size={14} /> Add OpenAI-compatible endpoint
|
|
176
|
+
</button>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{showAdd ? (
|
|
180
|
+
<div style={{ padding: 16, borderRadius: "var(--ws-radius-sm)", border: "1px solid var(--ws-border-strong)", background: "rgba(0,0,0,0.16)", display: "flex", flexDirection: "column", gap: 10 }}>
|
|
181
|
+
<div className="ws-label" style={{ margin: 0 }}>New endpoint (stored encrypted in the vault)</div>
|
|
182
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
|
183
|
+
<div>
|
|
184
|
+
<label className="ws-label">Name *</label>
|
|
185
|
+
<input className="ws-input" style={{ width: "100%" }} value={epName}
|
|
186
|
+
onChange={(e) => setEpName(e.target.value)} placeholder="local-vllm" />
|
|
187
|
+
</div>
|
|
188
|
+
<div>
|
|
189
|
+
<label className="ws-label">Display label</label>
|
|
190
|
+
<input className="ws-input" style={{ width: "100%" }} value={epLabel}
|
|
191
|
+
onChange={(e) => setEpLabel(e.target.value)} placeholder="Local vLLM" />
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
<div>
|
|
195
|
+
<label className="ws-label">Base URL (OpenAI-compatible /v1) *</label>
|
|
196
|
+
<input className="ws-input" style={{ width: "100%" }} value={epBaseUrl}
|
|
197
|
+
onChange={(e) => setEpBaseUrl(e.target.value)} placeholder="http://localhost:8000/v1" />
|
|
198
|
+
</div>
|
|
199
|
+
<div>
|
|
200
|
+
<label className="ws-label">API key (optional — many local endpoints need none)</label>
|
|
201
|
+
<input className="ws-input" style={{ width: "100%" }} type="password" value={epKey}
|
|
202
|
+
onChange={(e) => setEpKey(e.target.value)} placeholder="sk-…" />
|
|
203
|
+
</div>
|
|
204
|
+
<button className="ws-btn" onClick={handleSaveEndpoint} disabled={saving}
|
|
205
|
+
style={{ justifyContent: "center", background: "var(--ws-accent-soft)", borderColor: "var(--ws-accent)", color: "var(--ws-accent)" }}>
|
|
206
|
+
{saving ? <Loader2 size={15} className="ws-spin" /> : <Save size={15} />} Save to vault
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
) : null}
|
|
210
|
+
|
|
211
|
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 12 }}>
|
|
212
|
+
{providers.map((p) => (
|
|
213
|
+
<button key={p.name} className="ws-btn" data-active={selected?.name === p.name}
|
|
214
|
+
onClick={() => selectProvider(p)}
|
|
215
|
+
style={{ flexDirection: "column", alignItems: "flex-start", gap: 6, padding: 14 }}>
|
|
216
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8, width: "100%" }}>
|
|
217
|
+
{p.configured ? <CheckCircle2 size={14} color="#1f9d5f" /> : <AlertCircle size={14} color="#c9a227" />}
|
|
218
|
+
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--ws-text)" }}>{p.label}</span>
|
|
219
|
+
{p.type === "custom" ? (
|
|
220
|
+
<span style={{ fontSize: 10, padding: "1px 6px", borderRadius: 6, background: "var(--ws-accent-soft)", color: "var(--ws-accent)" }}>custom</span>
|
|
221
|
+
) : null}
|
|
222
|
+
</div>
|
|
223
|
+
<div className="ws-body" style={{ fontSize: 11, textAlign: "left", wordBreak: "break-all" }}>
|
|
224
|
+
{p.type === "custom"
|
|
225
|
+
? p.base_url
|
|
226
|
+
: p.env_var ? <code>{p.env_var}</code> : "no key needed"}
|
|
227
|
+
{p.configured && p.env_value_hint ? ` · set (${p.env_value_hint})` : p.env_var && !p.configured ? " · not set" : ""}
|
|
228
|
+
</div>
|
|
229
|
+
</button>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{selected ? (
|
|
234
|
+
<>
|
|
235
|
+
<div>
|
|
236
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
|
237
|
+
<label className="ws-label">Model</label>
|
|
238
|
+
<button className="ws-btn ws-btn--ghost" style={{ fontSize: 11, padding: "3px 10px" }}
|
|
239
|
+
onClick={handleFetchModels} disabled={fetching || selected.type !== "custom"}>
|
|
240
|
+
{fetching ? <Loader2 size={12} className="ws-spin" /> : null}
|
|
241
|
+
{selected.type === "custom" ? "Fetch models from endpoint" : "Model list (built-in)"}
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
{modelChoices.length > 0 ? (
|
|
245
|
+
<select className="ws-input" style={{ width: "100%" }} value={model}
|
|
246
|
+
onChange={(e) => setModel(e.target.value)}>
|
|
247
|
+
{modelChoices.map((m) => <option key={m} value={m}>{m}</option>)}
|
|
248
|
+
</select>
|
|
249
|
+
) : (
|
|
250
|
+
<input className="ws-input" style={{ width: "100%" }} value={model}
|
|
251
|
+
onChange={(e) => setModel(e.target.value)} placeholder="model id" />
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<button className="ws-btn" onClick={handleTest} disabled={testing}
|
|
256
|
+
style={{ justifyContent: "center", background: "var(--ws-accent-soft)", borderColor: "var(--ws-accent)", color: "var(--ws-accent)" }}>
|
|
257
|
+
{testing ? <><Loader2 size={15} className="ws-spin" />Sending test prompt…</> : <><Send size={15} />Send test prompt</>}
|
|
258
|
+
</button>
|
|
259
|
+
|
|
260
|
+
{result ? (
|
|
261
|
+
<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)" }}>
|
|
262
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
263
|
+
{result.ok ? <CheckCircle2 size={15} color="#1f9d5f" /> : <AlertCircle size={15} color="#ec2315" />}
|
|
264
|
+
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--ws-text)" }}>
|
|
265
|
+
{result.ok ? `${selected.label} responded${result.model ? ` (${result.model})` : ""}` : "Test failed"}
|
|
266
|
+
</span>
|
|
267
|
+
</div>
|
|
268
|
+
<div className="ws-body" style={{ fontSize: 12, marginTop: 6, whiteSpace: "pre-wrap", fontFamily: "var(--ws-mono, monospace)" }}>{result.detail}</div>
|
|
269
|
+
</div>
|
|
270
|
+
) : null}
|
|
271
|
+
|
|
272
|
+
{selected.type === "custom" ? (
|
|
273
|
+
<button className="ws-btn ws-btn--ghost" style={{ fontSize: 12, color: "#a33" }}
|
|
274
|
+
onClick={() => handleDeleteProvider(selected)}>
|
|
275
|
+
<Trash2 size={13} /> Remove this endpoint
|
|
276
|
+
</button>
|
|
277
|
+
) : null}
|
|
278
|
+
</>
|
|
279
|
+
) : null}
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
<div style={{ position: "fixed", bottom: 28, right: 28, display: "flex", flexDirection: "column", gap: 10, zIndex: 1000 }}>
|
|
283
|
+
{toasts.map((t) => (
|
|
284
|
+
<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)" }}>
|
|
285
|
+
{t.type === "success" ? <CheckCircle2 size={16} color="#1f9d5f" /> : <AlertCircle size={16} color="#ec2315" />}
|
|
286
|
+
<span style={{ color: "var(--ws-text)", fontSize: 13, fontWeight: 500 }}>{t.text}</span>
|
|
287
|
+
</div>
|
|
288
|
+
))}
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
@@ -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