@ltm-blueverse/alpha-semantic-hub 0.8.0 → 0.9.1
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,41 @@ 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
|
+
|
|
325
360
|
### Ingestion, MCP tools and LLM endpoints in the Studio
|
|
326
361
|
|
|
327
362
|
The **Enrich** workspace has three tabs for this:
|
|
@@ -1,33 +1,39 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Database, Plug, CheckCircle2, AlertCircle, Loader2, FileText, Globe,
|
|
4
|
-
Boxes, Cloud, Server, Play,
|
|
4
|
+
Boxes, Cloud, Server, Play, KeyRound, Save, Trash2, Lock,
|
|
5
5
|
} from "lucide-react";
|
|
6
6
|
|
|
7
|
-
type
|
|
8
|
-
name: string;
|
|
9
|
-
|
|
10
|
-
required: boolean;
|
|
11
|
-
secret?: boolean;
|
|
12
|
-
boolean?: boolean;
|
|
7
|
+
type ConnField = {
|
|
8
|
+
name: string; label: string; required?: boolean;
|
|
9
|
+
secret?: boolean; boolean?: boolean;
|
|
13
10
|
};
|
|
14
11
|
|
|
15
|
-
type
|
|
16
|
-
kind: string;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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;
|
|
20
17
|
};
|
|
21
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
|
+
};
|
|
22
24
|
type Toast = { id: number; type: "success" | "error"; text: string };
|
|
25
|
+
type FieldValues = Record<string, string | boolean>;
|
|
23
26
|
|
|
24
27
|
const KIND_ICONS: Record<string, typeof Database> = {
|
|
25
|
-
|
|
26
|
-
|
|
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,
|
|
27
35
|
};
|
|
28
36
|
|
|
29
|
-
type FieldValues = Record<string, string | boolean>;
|
|
30
|
-
|
|
31
37
|
function nestedSet(obj: Record<string, unknown>, path: string, value: unknown) {
|
|
32
38
|
const parts = path.split(".");
|
|
33
39
|
let cur = obj;
|
|
@@ -39,9 +45,15 @@ function nestedSet(obj: Record<string, unknown>, path: string, value: unknown) {
|
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
export function IngestWorkspace() {
|
|
42
|
-
const [
|
|
43
|
-
const [
|
|
48
|
+
const [categories, setCategories] = useState<Category[]>([]);
|
|
49
|
+
const [active, setActive] = useState<Connector | null>(null);
|
|
50
|
+
const [activeCategory, setActiveCategory] = useState<string>("");
|
|
51
|
+
const [search, setSearch] = useState("");
|
|
52
|
+
const [credentials, setCredentials] = useState<Credential[]>([]);
|
|
53
|
+
const [credentialId, setCredentialId] = useState("");
|
|
44
54
|
const [values, setValues] = useState<FieldValues>({});
|
|
55
|
+
const [saveToVault, setSaveToVault] = useState(true);
|
|
56
|
+
const [vaultName, setVaultName] = useState("");
|
|
45
57
|
const [sourceRef, setSourceRef] = useState("");
|
|
46
58
|
const [extract, setExtract] = useState(true);
|
|
47
59
|
const [validating, setValidating] = useState(false);
|
|
@@ -51,28 +63,91 @@ export function IngestWorkspace() {
|
|
|
51
63
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
52
64
|
const pollRef = useRef<number | null>(null);
|
|
53
65
|
|
|
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
66
|
function showToast(type: Toast["type"], text: string) {
|
|
65
67
|
const id = Date.now();
|
|
66
68
|
setToasts((p) => [...p, { id, type, text }]);
|
|
67
69
|
setTimeout(() => setToasts((p) => p.filter((t) => t.id !== id)), 6000);
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
+
async function loadCredentials() {
|
|
73
|
+
try {
|
|
74
|
+
const r = await fetch("/api/vault/credentials?kind=source");
|
|
75
|
+
const d = await r.json();
|
|
76
|
+
setCredentials(d.credentials ?? []);
|
|
77
|
+
} catch { /* ignore */ }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
fetch("/api/ingest/sources").then((r) => r.json()).then((d) => {
|
|
82
|
+
const cats: Category[] = d.categories ?? [];
|
|
83
|
+
setCategories(cats);
|
|
84
|
+
const firstCat = cats[0];
|
|
85
|
+
if (firstCat) setActiveCategory((c) => c || firstCat.category);
|
|
86
|
+
const first = firstCat?.connectors[0];
|
|
87
|
+
if (first) setActive(first);
|
|
88
|
+
}).catch(() => {});
|
|
89
|
+
loadCredentials();
|
|
90
|
+
return () => { if (pollRef.current) window.clearInterval(pollRef.current); };
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
function selectConnector(c: Connector) {
|
|
94
|
+
setActive(c);
|
|
72
95
|
setValues({});
|
|
73
96
|
setSourceRef("");
|
|
74
97
|
setValidation(null);
|
|
75
98
|
setJob(null);
|
|
99
|
+
setCredentialId("");
|
|
100
|
+
setVaultName(`${c.kind}-credentials`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function applyCredential(id: string) {
|
|
104
|
+
setCredentialId(id);
|
|
105
|
+
setValidation(null);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildOptions(): Record<string, unknown> {
|
|
109
|
+
const options: Record<string, unknown> = {};
|
|
110
|
+
for (const [k, v] of Object.entries(values)) {
|
|
111
|
+
if (v !== "" && v !== false && v !== undefined) nestedSet(options, k, v);
|
|
112
|
+
}
|
|
113
|
+
return options;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function handleSaveCredential() {
|
|
117
|
+
if (!active) return;
|
|
118
|
+
const fields: Record<string, string> = {};
|
|
119
|
+
for (const [k, v] of Object.entries(values)) {
|
|
120
|
+
if (typeof v === "string" && v.trim()) fields[k] = v;
|
|
121
|
+
}
|
|
122
|
+
if (Object.keys(fields).length === 0) {
|
|
123
|
+
showToast("error", "Enter credential fields before saving");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const r = await fetch("/api/vault/credentials", {
|
|
128
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
129
|
+
body: JSON.stringify({ id: vaultName.trim() || `${active.kind}-credentials`,
|
|
130
|
+
kind: "source", label: `${active.label} credentials`, fields }),
|
|
131
|
+
});
|
|
132
|
+
if (!r.ok) throw new Error((await r.json()).detail ?? `HTTP ${r.status}`);
|
|
133
|
+
showToast("success", "Saved to vault");
|
|
134
|
+
await loadCredentials();
|
|
135
|
+
setCredentialId(vaultName.trim() || `${active.kind}-credentials`);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
showToast("error", e instanceof Error ? e.message : "Save failed");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function handleDeleteCredential(id: string) {
|
|
142
|
+
try {
|
|
143
|
+
const r = await fetch(`/api/vault/credentials/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
144
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
145
|
+
showToast("success", "Credential deleted");
|
|
146
|
+
if (credentialId === id) setCredentialId("");
|
|
147
|
+
await loadCredentials();
|
|
148
|
+
} catch (e) {
|
|
149
|
+
showToast("error", e instanceof Error ? e.message : "Delete failed");
|
|
150
|
+
}
|
|
76
151
|
}
|
|
77
152
|
|
|
78
153
|
async function handleValidate() {
|
|
@@ -80,18 +155,12 @@ export function IngestWorkspace() {
|
|
|
80
155
|
setValidating(true);
|
|
81
156
|
setValidation(null);
|
|
82
157
|
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
158
|
const res = await fetch("/api/ingest/validate", {
|
|
89
|
-
method: "POST",
|
|
90
|
-
|
|
91
|
-
body: JSON.stringify({ kind: active.kind, options }),
|
|
159
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
160
|
+
body: JSON.stringify({ kind: active.kind, credential_id: credentialId || null, options: buildOptions() }),
|
|
92
161
|
});
|
|
93
162
|
const data = await res.json();
|
|
94
|
-
setValidation({ ok: !!data.ok, detail: data.detail ??
|
|
163
|
+
setValidation({ ok: !!data.ok, detail: data.detail ?? "Unknown response" });
|
|
95
164
|
} catch (e) {
|
|
96
165
|
setValidation({ ok: false, detail: e instanceof Error ? e.message : "Validation failed" });
|
|
97
166
|
} finally {
|
|
@@ -100,20 +169,26 @@ export function IngestWorkspace() {
|
|
|
100
169
|
}
|
|
101
170
|
|
|
102
171
|
async function handleRun() {
|
|
103
|
-
if (!active
|
|
172
|
+
if (!active) return;
|
|
173
|
+
if (saveToVault && credentialId === "") {
|
|
174
|
+
const fields: Record<string, string> = {};
|
|
175
|
+
for (const [k, v] of Object.entries(values)) {
|
|
176
|
+
if (typeof v === "string" && v.trim()) fields[k] = v;
|
|
177
|
+
}
|
|
178
|
+
if (Object.keys(fields).length > 0) await handleSaveCredential();
|
|
179
|
+
}
|
|
104
180
|
setRunning(true);
|
|
105
181
|
setJob({ status: "queued", stage: "queued", progress: 0, message: "Starting…" });
|
|
106
182
|
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
183
|
const res = await fetch("/api/ingest/run", {
|
|
112
|
-
method: "POST",
|
|
113
|
-
|
|
114
|
-
|
|
184
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
185
|
+
body: JSON.stringify({
|
|
186
|
+
kind: active.kind, source: sourceRef.trim(),
|
|
187
|
+
credential_id: credentialId || null,
|
|
188
|
+
options: buildOptions(), extract,
|
|
189
|
+
}),
|
|
115
190
|
});
|
|
116
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
191
|
+
if (!res.ok) throw new Error((await res.json()).detail ?? `HTTP ${res.status}`);
|
|
117
192
|
const { job_id } = await res.json();
|
|
118
193
|
pollRef.current = window.setInterval(async () => {
|
|
119
194
|
try {
|
|
@@ -121,27 +196,24 @@ export function IngestWorkspace() {
|
|
|
121
196
|
if (!st.ok) return;
|
|
122
197
|
const data = await st.json();
|
|
123
198
|
setJob(data);
|
|
124
|
-
if (data.status === "done") {
|
|
199
|
+
if (data.status === "done" || data.status === "error") {
|
|
125
200
|
window.clearInterval(pollRef.current!); pollRef.current = null;
|
|
126
201
|
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);
|
|
202
|
+
showToast(data.status === "done" ? "success" : "error", data.message);
|
|
132
203
|
}
|
|
133
204
|
} catch { /* transient */ }
|
|
134
205
|
}, 1000);
|
|
135
206
|
} catch (e) {
|
|
136
|
-
setRunning(false);
|
|
137
|
-
setJob(null);
|
|
207
|
+
setRunning(false); setJob(null);
|
|
138
208
|
showToast("error", e instanceof Error ? e.message : "Failed to start ingestion");
|
|
139
209
|
}
|
|
140
210
|
}
|
|
141
211
|
|
|
212
|
+
const vaultForKind = credentials.filter((c) => c.id.startsWith(active?.kind ?? "___"));
|
|
213
|
+
|
|
142
214
|
return (
|
|
143
215
|
<div className="ws-page ws-scroll">
|
|
144
|
-
<div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 24, maxWidth:
|
|
216
|
+
<div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 24, maxWidth: 1040, margin: "0 auto", width: "100%" }}>
|
|
145
217
|
{/* Header */}
|
|
146
218
|
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
|
147
219
|
<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 }}>
|
|
@@ -149,111 +221,238 @@ export function IngestWorkspace() {
|
|
|
149
221
|
</div>
|
|
150
222
|
<div>
|
|
151
223
|
<h2 className="ws-title" style={{ fontSize: 18 }}>Ingest from Source Systems</h2>
|
|
152
|
-
<div className="ws-body" style={{ marginTop: 2 }}>
|
|
224
|
+
<div className="ws-body" style={{ marginTop: 2 }}>
|
|
225
|
+
{categories.reduce((n, c) => n + c.connectors.length, 0)} connectors across{" "}
|
|
226
|
+
{categories.length} categories. Credentials save to the encrypted vault and can be reused.
|
|
227
|
+
</div>
|
|
153
228
|
</div>
|
|
154
229
|
</div>
|
|
155
230
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
231
|
+
{/* Connector browser: category sidebar + searchable connector grid.
|
|
232
|
+
Deliberately NOT an absolutely-positioned dropdown — .ws-card sets
|
|
233
|
+
overflow:hidden, which clipped the overlay to one visible row. */}
|
|
234
|
+
<div className="ws-card" style={{ padding: 18, display: "flex", flexDirection: "column", gap: 14 }}>
|
|
235
|
+
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
|
236
|
+
<div className="ws-label" style={{ margin: 0 }}>Connector</div>
|
|
237
|
+
<input className="ws-input" style={{ flex: 1, minWidth: 200 }}
|
|
238
|
+
placeholder="Search connectors…" value={search}
|
|
239
|
+
onChange={(e) => setSearch(e.target.value)} />
|
|
240
|
+
<span style={{ fontSize: 12, color: "var(--ws-text-muted)" }}>
|
|
241
|
+
{search
|
|
242
|
+
? `${categories.flatMap((c) => c.connectors).filter((c) => c.label.toLowerCase().includes(search.toLowerCase())).length} match`
|
|
243
|
+
: `${categories.reduce((n, c) => n + c.connectors.length, 0)} total`}
|
|
244
|
+
</span>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<div style={{ display: "grid", gridTemplateColumns: "230px 1fr", gap: 14, alignItems: "start" }}>
|
|
248
|
+
{/* Category sidebar — always visible, never clipped */}
|
|
249
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
|
250
|
+
{categories.map((cat) => {
|
|
251
|
+
const isActiveCat = !search && activeCategory === cat.category;
|
|
252
|
+
const matches = search
|
|
253
|
+
? cat.connectors.filter((c) => c.label.toLowerCase().includes(search.toLowerCase())).length
|
|
254
|
+
: cat.connectors.length;
|
|
255
|
+
if (search && matches === 0) return null;
|
|
256
|
+
return (
|
|
257
|
+
<button key={cat.category} className="ws-btn" data-active={isActiveCat}
|
|
258
|
+
onClick={() => { setActiveCategory(cat.category); setSearch(""); }}
|
|
259
|
+
style={{ justifyContent: "space-between", fontSize: 12.5 }}>
|
|
260
|
+
<span>{cat.category}</span>
|
|
261
|
+
<span style={{ opacity: 0.6, fontSize: 11 }}>{matches}</span>
|
|
262
|
+
</button>
|
|
263
|
+
);
|
|
264
|
+
})}
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
{/* Connector grid — shows the active category, or search results */}
|
|
268
|
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(215px, 1fr))", gap: 8 }}>
|
|
269
|
+
{(search
|
|
270
|
+
? categories.flatMap((c) => c.connectors)
|
|
271
|
+
.filter((c) => c.label.toLowerCase().includes(search.toLowerCase()))
|
|
272
|
+
: categories.find((c) => c.category === activeCategory)?.connectors ?? []
|
|
273
|
+
).map((c) => {
|
|
274
|
+
const Icon = KIND_ICONS[c.kind] ?? Database;
|
|
275
|
+
const isSel = active?.kind === c.kind;
|
|
276
|
+
return (
|
|
277
|
+
<button key={c.kind} className="ws-btn" data-active={isSel}
|
|
278
|
+
onClick={() => selectConnector(c)}
|
|
279
|
+
style={{ justifyContent: "flex-start", fontSize: 12.5, textAlign: "left" }}>
|
|
280
|
+
<Icon size={14} />
|
|
281
|
+
<span style={{ flex: 1 }}>{c.label}</span>
|
|
282
|
+
{c.extra_dependency ? (
|
|
283
|
+
<span title={`Needs: ${c.extra_dependency}`}
|
|
284
|
+
style={{ fontSize: 10, color: "#c9a227" }}>dep</span>
|
|
285
|
+
) : null}
|
|
286
|
+
</button>
|
|
287
|
+
);
|
|
288
|
+
})}
|
|
289
|
+
</div>
|
|
170
290
|
</div>
|
|
171
291
|
|
|
172
|
-
{
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
292
|
+
{active ? (
|
|
293
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 4, paddingTop: 10, borderTop: "1px solid var(--ws-border)" }}>
|
|
294
|
+
<div style={{ fontSize: 12.5, fontWeight: 700, color: "var(--ws-text)" }}>
|
|
295
|
+
Selected: {active.label}
|
|
296
|
+
</div>
|
|
297
|
+
{active.credential_hint ? (
|
|
298
|
+
<div className="ws-body" style={{ fontSize: 12 }}>
|
|
299
|
+
<KeyRound size={12} style={{ verticalAlign: -2, marginRight: 6 }} />
|
|
300
|
+
Credentials: {active.credential_hint}
|
|
301
|
+
</div>
|
|
302
|
+
) : null}
|
|
303
|
+
{active.env_fallback ? (
|
|
304
|
+
<div className="ws-body" style={{ fontSize: 11, opacity: 0.75 }}>
|
|
305
|
+
Env fallback: <code>{active.env_fallback}</code>
|
|
179
306
|
</div>
|
|
180
|
-
|
|
307
|
+
) : null}
|
|
308
|
+
{active.extra_dependency ? (
|
|
309
|
+
<div style={{ fontSize: 11, color: "#a8791f" }}>
|
|
310
|
+
Requires extra dependency: <code>{active.extra_dependency}</code>
|
|
311
|
+
</div>
|
|
312
|
+
) : null}
|
|
313
|
+
</div>
|
|
314
|
+
) : null}
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
{active ? (
|
|
318
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 18, alignItems: "start" }}>
|
|
319
|
+
{/* Credentials card */}
|
|
320
|
+
<div className="ws-card" style={{ padding: 22, display: "flex", flexDirection: "column", gap: 14 }}>
|
|
321
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
322
|
+
<Lock size={16} color="var(--ws-accent)" />
|
|
323
|
+
<div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>Credentials</div>
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
<div>
|
|
327
|
+
<label className="ws-label">Saved credential (from vault)</label>
|
|
328
|
+
<select className="ws-input" style={{ width: "100%" }} value={credentialId}
|
|
329
|
+
onChange={(e) => applyCredential(e.target.value)} disabled={running}>
|
|
330
|
+
<option value="">— none (enter below / use env) —</option>
|
|
331
|
+
{credentials.map((c) => (
|
|
332
|
+
<option key={c.id} value={c.id}>{c.label} ({c.id})</option>
|
|
333
|
+
))}
|
|
334
|
+
</select>
|
|
335
|
+
{vaultForKind.length > 0 ? (
|
|
336
|
+
<div className="ws-body" style={{ fontSize: 11, marginTop: 4 }}>
|
|
337
|
+
{vaultForKind.length} credential(s) saved for {active.kind}
|
|
338
|
+
</div>
|
|
339
|
+
) : null}
|
|
340
|
+
</div>
|
|
181
341
|
|
|
182
|
-
|
|
183
|
-
|
|
342
|
+
{active.optional_fields.filter((f) => !["as_query", "include_subfolders", "download", "as_file", "respect_robots", "unread_only"].includes(f.name)).map((f) => (
|
|
343
|
+
<div key={f.name}>
|
|
344
|
+
<label className="ws-label">{f.label}{f.required ? " *" : ""}</label>
|
|
184
345
|
<input className="ws-input" style={{ width: "100%" }}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
346
|
+
type={f.secret ? "password" : "text"}
|
|
347
|
+
value={String(values[f.name] ?? "")}
|
|
348
|
+
onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
|
|
188
349
|
disabled={running} />
|
|
189
350
|
</div>
|
|
351
|
+
))}
|
|
190
352
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
{f.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
))}
|
|
353
|
+
{active.optional_fields.filter((f) => f.boolean).length > 0 ? (
|
|
354
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
|
355
|
+
{active.optional_fields.filter((f) => f.boolean).map((f) => (
|
|
356
|
+
<button key={f.name} className={`ws-btn ${values[f.name] ? "ws-btn--primary" : "ws-btn--ghost"}`}
|
|
357
|
+
onClick={() => setValues((v) => ({ ...v, [f.name]: !v[f.name] }))}
|
|
358
|
+
disabled={running} style={{ fontSize: 11.5 }}>
|
|
359
|
+
{f.label}: {values[f.name] ? "Yes" : "No"}
|
|
360
|
+
</button>
|
|
361
|
+
))}
|
|
362
|
+
</div>
|
|
363
|
+
) : null}
|
|
209
364
|
|
|
210
|
-
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
style={{
|
|
214
|
-
|
|
365
|
+
<div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
|
|
366
|
+
<div style={{ flex: 1 }}>
|
|
367
|
+
<label className="ws-label">Save as (vault name)</label>
|
|
368
|
+
<input className="ws-input" style={{ width: "100%" }} value={vaultName}
|
|
369
|
+
onChange={(e) => setVaultName(e.target.value)} disabled={running} />
|
|
370
|
+
</div>
|
|
371
|
+
<button className="ws-btn ws-btn--ghost" onClick={handleSaveCredential}
|
|
372
|
+
disabled={running} style={{ whiteSpace: "nowrap" }}>
|
|
373
|
+
<Save size={14} /> Save
|
|
215
374
|
</button>
|
|
375
|
+
</div>
|
|
216
376
|
|
|
217
|
-
|
|
218
|
-
{
|
|
219
|
-
<div
|
|
220
|
-
|
|
221
|
-
<
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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" }} />
|
|
377
|
+
{credentials.length > 0 ? (
|
|
378
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
|
379
|
+
<div className="ws-label" style={{ marginBottom: 2 }}>Vault entries</div>
|
|
380
|
+
{credentials.map((c) => (
|
|
381
|
+
<div key={c.id} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12 }}>
|
|
382
|
+
<CheckCircle2 size={12} color="#1f9d5f" />
|
|
383
|
+
<span style={{ flex: 1, color: "var(--ws-text)" }}>{c.label}</span>
|
|
384
|
+
<button className="ws-btn ws-btn--ghost" style={{ padding: "2px 8px" }}
|
|
385
|
+
onClick={() => handleDeleteCredential(c.id)} title="Delete">
|
|
386
|
+
<Trash2 size={12} />
|
|
387
|
+
</button>
|
|
234
388
|
</div>
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
389
|
+
))}
|
|
390
|
+
</div>
|
|
391
|
+
) : null}
|
|
392
|
+
</div>
|
|
238
393
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
394
|
+
{/* Run card */}
|
|
395
|
+
<div className="ws-card" style={{ padding: 22, display: "flex", flexDirection: "column", gap: 14 }}>
|
|
396
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
397
|
+
<Play size={16} color="var(--ws-accent)" />
|
|
398
|
+
<div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>Run ingestion</div>
|
|
399
|
+
</div>
|
|
400
|
+
|
|
401
|
+
<div>
|
|
402
|
+
<label className="ws-label">{active.source_label}</label>
|
|
403
|
+
<input className="ws-input" style={{ width: "100%" }} value={sourceRef}
|
|
404
|
+
onChange={(e) => setSourceRef(e.target.value)}
|
|
405
|
+
placeholder={active.source_placeholder} disabled={running} />
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
<button className={`ws-btn ${extract ? "ws-btn--primary" : "ws-btn--ghost"}`}
|
|
409
|
+
onClick={() => setExtract((v) => !v)} disabled={running}
|
|
410
|
+
style={{ justifyContent: "center", fontSize: 12 }}>
|
|
411
|
+
{extract ? "Extraction ON — build entities & relations" : "Extraction OFF — documents only"}
|
|
412
|
+
</button>
|
|
413
|
+
|
|
414
|
+
<button className={`ws-btn ${saveToVault ? "ws-btn--primary" : "ws-btn--ghost"}`}
|
|
415
|
+
onClick={() => setSaveToVault((v) => !v)} disabled={running}
|
|
416
|
+
style={{ justifyContent: "center", fontSize: 12 }}>
|
|
417
|
+
<Lock size={13} /> {saveToVault ? "Auto-save credentials to vault" : "Don't save credentials"}
|
|
418
|
+
</button>
|
|
419
|
+
|
|
420
|
+
{validation ? (
|
|
421
|
+
<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)" }}>
|
|
422
|
+
{validation.ok ? <CheckCircle2 size={15} color="#1f9d5f" /> : <AlertCircle size={15} color="#ec2315" />}
|
|
423
|
+
<span style={{ fontSize: 12.5, color: "var(--ws-text)" }}>{validation.detail}</span>
|
|
424
|
+
</div>
|
|
425
|
+
) : null}
|
|
426
|
+
|
|
427
|
+
{job ? (
|
|
428
|
+
<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)" }}>
|
|
429
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
430
|
+
{job.status === "running" || job.status === "queued"
|
|
431
|
+
? <Loader2 size={15} className="ws-spin" color="var(--ws-accent)" />
|
|
432
|
+
: job.status === "done" ? <CheckCircle2 size={15} color="#1f9d5f" /> : <AlertCircle size={15} color="#ec2315" />}
|
|
433
|
+
<span style={{ fontSize: 12.5, color: "var(--ws-text)", fontWeight: 600 }}>{job.message}</span>
|
|
434
|
+
</div>
|
|
435
|
+
<div style={{ height: 6, borderRadius: 3, background: "rgba(0,0,0,0.25)", overflow: "hidden" }}>
|
|
436
|
+
<div style={{ width: `${job.progress}%`, height: "100%", background: job.status === "error" ? "#ec2315" : "var(--ws-accent)", transition: "width 400ms ease" }} />
|
|
437
|
+
</div>
|
|
438
|
+
<div className="ws-body" style={{ fontSize: 11 }}>Stage: {job.stage}</div>
|
|
250
439
|
</div>
|
|
251
|
-
|
|
252
|
-
|
|
440
|
+
) : null}
|
|
441
|
+
|
|
442
|
+
<div style={{ display: "flex", gap: 10 }}>
|
|
443
|
+
<button className="ws-btn ws-btn--ghost" onClick={handleValidate}
|
|
444
|
+
disabled={validating || running} style={{ flex: 1, justifyContent: "center" }}>
|
|
445
|
+
{validating ? <Loader2 size={15} className="ws-spin" /> : <Plug size={15} />} Test connection
|
|
446
|
+
</button>
|
|
447
|
+
<button className="ws-btn" onClick={handleRun} disabled={running || validating}
|
|
448
|
+
style={{ flex: 2, justifyContent: "center", background: "var(--ws-accent-soft)", borderColor: "var(--ws-accent)", color: "var(--ws-accent)" }}>
|
|
449
|
+
{running ? <><Loader2 size={15} className="ws-spin" />Ingesting…</> : <><Play size={15} />Run ingestion</>}
|
|
450
|
+
</button>
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
253
453
|
</div>
|
|
254
|
-
|
|
454
|
+
) : null}
|
|
255
455
|
|
|
256
|
-
{/* Toasts */}
|
|
257
456
|
<div style={{ position: "fixed", bottom: 28, right: 28, display: "flex", flexDirection: "column", gap: 10, zIndex: 1000 }}>
|
|
258
457
|
{toasts.map((t) => (
|
|
259
458
|
<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)" }}>
|
|
@@ -265,4 +464,4 @@ export function IngestWorkspace() {
|
|
|
265
464
|
</div>
|
|
266
465
|
</div>
|
|
267
466
|
);
|
|
268
|
-
}
|
|
467
|
+
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import {
|
|
3
|
-
Sparkles, CheckCircle2, AlertCircle, Loader2, Send, Info,
|
|
3
|
+
Sparkles, CheckCircle2, AlertCircle, Loader2, Send, Info, Save, Plus, Trash2,
|
|
4
4
|
} from "lucide-react";
|
|
5
5
|
|
|
6
6
|
type Provider = {
|
|
7
7
|
name: string; label: string; env_var: string | null;
|
|
8
8
|
models: string[]; configured: boolean; env_value_hint: string | null;
|
|
9
|
+
type: "builtin" | "custom"; base_url?: string;
|
|
9
10
|
};
|
|
10
11
|
|
|
11
12
|
type UsageRow = { task: string; when: string; where: string };
|
|
12
|
-
|
|
13
13
|
type Toast = { id: number; type: "success" | "error"; text: string };
|
|
14
14
|
|
|
15
15
|
export function LlmSettingsWorkspace() {
|
|
@@ -17,18 +17,19 @@ export function LlmSettingsWorkspace() {
|
|
|
17
17
|
const [usage, setUsage] = useState<UsageRow[]>([]);
|
|
18
18
|
const [selected, setSelected] = useState<Provider | null>(null);
|
|
19
19
|
const [model, setModel] = useState("");
|
|
20
|
+
const [fetchedModels, setFetchedModels] = useState<string[]>([]);
|
|
20
21
|
const [testing, setTesting] = useState(false);
|
|
22
|
+
const [fetching, setFetching] = useState(false);
|
|
21
23
|
const [result, setResult] = useState<{ ok: boolean; detail: string; model?: string } | null>(null);
|
|
22
|
-
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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[]>([]);
|
|
32
33
|
|
|
33
34
|
function showToast(type: Toast["type"], text: string) {
|
|
34
35
|
const id = Date.now();
|
|
@@ -36,30 +37,108 @@ export function LlmSettingsWorkspace() {
|
|
|
36
37
|
setTimeout(() => setToasts((p) => p.filter((t) => t.id !== id)), 5000);
|
|
37
38
|
}
|
|
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
|
+
|
|
39
83
|
async function handleTest() {
|
|
40
84
|
if (!selected) return;
|
|
41
|
-
setTesting(true);
|
|
42
|
-
setResult(null);
|
|
85
|
+
setTesting(true); setResult(null);
|
|
43
86
|
try {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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),
|
|
48
92
|
});
|
|
49
|
-
const
|
|
50
|
-
setResult(
|
|
51
|
-
showToast(
|
|
52
|
-
data.ok ? `${selected.label} responded` : `${selected.label}: unavailable`);
|
|
93
|
+
const d = await r.json();
|
|
94
|
+
setResult(d);
|
|
95
|
+
showToast(d.ok ? "success" : "error", d.ok ? `${selected.label} responded` : `${selected.label}: unavailable`);
|
|
53
96
|
} catch (e) {
|
|
54
97
|
setResult({ ok: false, detail: e instanceof Error ? e.message : "Test failed" });
|
|
55
|
-
} finally {
|
|
56
|
-
|
|
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");
|
|
57
134
|
}
|
|
58
135
|
}
|
|
59
136
|
|
|
137
|
+
const modelChoices = fetchedModels.length > 0 ? fetchedModels : (selected?.models ?? []);
|
|
138
|
+
|
|
60
139
|
return (
|
|
61
140
|
<div className="ws-page ws-scroll">
|
|
62
|
-
<div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 24, maxWidth:
|
|
141
|
+
<div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 24, maxWidth: 1020, margin: "0 auto", width: "100%" }}>
|
|
63
142
|
{/* Header */}
|
|
64
143
|
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
|
65
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 }}>
|
|
@@ -68,12 +147,12 @@ export function LlmSettingsWorkspace() {
|
|
|
68
147
|
<div>
|
|
69
148
|
<h2 className="ws-title" style={{ fontSize: 18 }}>LLM Endpoints</h2>
|
|
70
149
|
<div className="ws-body" style={{ marginTop: 2 }}>
|
|
71
|
-
|
|
150
|
+
Built-in providers, OpenAI-compatible <code>/v1</code> endpoints (vLLM, Ollama, OpenRouter, gateways), and where the platform uses LLMs.
|
|
72
151
|
</div>
|
|
73
152
|
</div>
|
|
74
153
|
</div>
|
|
75
154
|
|
|
76
|
-
{/* Where LLMs are used
|
|
155
|
+
{/* Where LLMs are used */}
|
|
77
156
|
<div className="ws-card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 12 }}>
|
|
78
157
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
79
158
|
<Info size={16} color="var(--ws-accent)" />
|
|
@@ -86,28 +165,66 @@ export function LlmSettingsWorkspace() {
|
|
|
86
165
|
<div className="ws-body" style={{ fontSize: 11, marginTop: 2, fontFamily: "var(--ws-mono, monospace)", opacity: 0.7 }}>{u.where}</div>
|
|
87
166
|
</div>
|
|
88
167
|
))}
|
|
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
168
|
</div>
|
|
93
169
|
|
|
94
|
-
{/*
|
|
170
|
+
{/* Providers */}
|
|
95
171
|
<div className="ws-card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
|
|
96
|
-
<div style={{
|
|
97
|
-
|
|
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 }}>
|
|
98
212
|
{providers.map((p) => (
|
|
99
213
|
<button key={p.name} className="ws-btn" data-active={selected?.name === p.name}
|
|
100
|
-
onClick={() =>
|
|
214
|
+
onClick={() => selectProvider(p)}
|
|
101
215
|
style={{ flexDirection: "column", alignItems: "flex-start", gap: 6, padding: 14 }}>
|
|
102
216
|
<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" />}
|
|
217
|
+
{p.configured ? <CheckCircle2 size={14} color="#1f9d5f" /> : <AlertCircle size={14} color="#c9a227" />}
|
|
106
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}
|
|
107
222
|
</div>
|
|
108
|
-
<div className="ws-body" style={{ fontSize: 11, textAlign: "left" }}>
|
|
109
|
-
{p.
|
|
110
|
-
|
|
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" : ""}
|
|
111
228
|
</div>
|
|
112
229
|
</button>
|
|
113
230
|
))}
|
|
@@ -116,11 +233,23 @@ export function LlmSettingsWorkspace() {
|
|
|
116
233
|
{selected ? (
|
|
117
234
|
<>
|
|
118
235
|
<div>
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
+
)}
|
|
124
253
|
</div>
|
|
125
254
|
|
|
126
255
|
<button className="ws-btn" onClick={handleTest} disabled={testing}
|
|
@@ -139,6 +268,13 @@ export function LlmSettingsWorkspace() {
|
|
|
139
268
|
<div className="ws-body" style={{ fontSize: 12, marginTop: 6, whiteSpace: "pre-wrap", fontFamily: "var(--ws-mono, monospace)" }}>{result.detail}</div>
|
|
140
269
|
</div>
|
|
141
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}
|
|
142
278
|
</>
|
|
143
279
|
) : null}
|
|
144
280
|
</div>
|
package/package.json
CHANGED