@cyrilmarin/dsh-lemonade 0.2.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/lib/client.js ADDED
@@ -0,0 +1,561 @@
1
+ /**
2
+ * Browser half of dsh-lemonade-provider: the "Lemonade" conversation view tab
3
+ * implementing the Lemonade-specific API entry points through the host proxy
4
+ * at /dsh-lemonade/api (same origin, keys host-side).
5
+ *
6
+ * All user-facing copy is English by default through the EN dictionary below
7
+ * and is registered with the dsh locale service (i18n = swap
8
+ /replace the
9
+ * dictionary or add locales). Shipped in the module-loader factory format.
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: "@cyrilmarin/dsh-lemonade",
13
+ factory: (require) => {
14
+ var module = { exports: {} };
15
+ var exports = module.exports;
16
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
17
+
18
+ const React = require("react");
19
+ const { useState, useEffect, useCallback } = React;
20
+ const h = React.createElement;
21
+
22
+ const API = "/dsh-lemonade/api";
23
+ const NS = "llm-lemonade";
24
+ const LOCALE_NS = "lemonade";
25
+
26
+ /** English dictionary (default). Add locales alongside or replace. */
27
+ const EN = {
28
+ tabLabel: "Lemonade",
29
+ title: "Lemonade Server",
30
+ online: "Online",
31
+ offline: "Offline",
32
+ keyMissing: "Key missing",
33
+ refresh: "Refresh",
34
+ loading: "Loading...",
35
+ autoOn: "Auto 10s \u25cf",
36
+ autoOff: "Auto 10s \u25cb",
37
+ keyMissingBanner: "API key missing — configure it in Settings > Models > Lemonade. ",
38
+ serverUnreachableBanner: "Server unreachable. ",
39
+ lastRequest: "Last request (v1/stats)",
40
+ hostTitle: "Host (v1/system-stats)",
41
+ ttft: "TTFT",
42
+ tokPerSec: "tokens/s",
43
+ inOut: "in / out",
44
+ prompt: "prompt",
45
+ cpu: "CPU",
46
+ ram: "RAM",
47
+ gpu: "GPU",
48
+ vram: "VRAM",
49
+ na: "n/a",
50
+ systemDetails: "System details",
51
+ systemInfo: "System info",
52
+ models: "Models",
53
+ modelsTooltip: "Models served by the Lemonade server",
54
+ downloaded: "Downloaded",
55
+ all: "All",
56
+ onlyDownloaded: "Only downloaded",
57
+ checkUpdates: "Check updates",
58
+ add: "+ Add",
59
+ close: "Close",
60
+ noModels: "No models.",
61
+ thModel: "Model",
62
+ thRecipe: "Recipe",
63
+ thSize: "Size",
64
+ thState: "State",
65
+ thActions: "Actions",
66
+ loaded: "loaded",
67
+ notLoaded: "not loaded",
68
+ toDownload: "to download",
69
+ updateBadge: "update",
70
+ load: "Load",
71
+ unload: "Unload",
72
+ download: "Download",
73
+ files: "Files",
74
+ delete: "Delete",
75
+ modelLoaded: "Model loaded",
76
+ modelUnloaded: "Model unloaded",
77
+ downloadStarted: "Download started",
78
+ modelDeleted: "Model deleted",
79
+ confirmDeleteModel: "Delete model {model} ?",
80
+ updatesNotice: "{count} update(s) available: {models}",
81
+ noModelsPinned: "No models pinned yet; fetch the server catalog and select the ones to use in dsh.",
82
+ addModel: "Add a model (Hugging Face / ModelScope catalog)",
83
+ searchPlaceholder: "search (≥ 3 characters)",
84
+ search: "Search",
85
+ noResults: "No results.",
86
+ install: "Install",
87
+ downloadsTitle: "Downloads",
88
+ noDownloads: "No active downloads.",
89
+ downloadProgress: "{percent} % · {bytes}",
90
+ fileProgress: " · file {index}/{total}",
91
+ pause: "Pause",
92
+ cancel: "Cancel",
93
+ remove: "Remove",
94
+ runningSuffix: "\u00b7 running",
95
+ downloadPaused: "Download paused",
96
+ downloadCancelled: "Download cancelled",
97
+ entryRemoved: "Entry removed",
98
+ aliasesTitle: "Aliases",
99
+ aliasesTooltip: "Internal endpoints /internal/* — authenticated via LEMONADE_ADMIN_API_KEY",
100
+ aliasPlaceholder: "alias",
101
+ targetPlaceholder: "target (model or canonical id)",
102
+ link: "Link",
103
+ list: "List",
104
+ flushTelemetry: "Flush telemetry",
105
+ noAliases: "No active aliases.",
106
+ confirmDeleteAlias: "Delete alias {alias} ?",
107
+ aliasLinked: "Alias linked",
108
+ telemetryFlushed: "Telemetry flushed",
109
+ aliasDeleted: "Alias deleted",
110
+ downloadedSuffix: " (downloaded)",
111
+ filesNone: "No local files.",
112
+ missingSuffix: " (missing)",
113
+ };
114
+
115
+ /** Lookup + {param} interpolation; English is the default fallback. */
116
+ function makeT(dict) {
117
+ return function t(key, params) {
118
+ let value = Object.prototype.hasOwnProperty.call(dict, key) ? dict[key] : key;
119
+ if (params) {
120
+ for (const k of Object.keys(params)) {
121
+ value = value.split("{" + k + "}").join(String(params[k]));
122
+ }
123
+ }
124
+ return value;
125
+ };
126
+ }
127
+ const fallbackT = makeT(EN);
128
+
129
+ /** Same-origin call to the host proxy; returns the normalized wire result. */
130
+ async function apiCall(op, segments, queryObj, method, bodyObj) {
131
+ let url = API + "/" + op;
132
+ if (segments && segments.length) {
133
+ for (const part of segments) url += "/" + encodeURIComponent(part);
134
+ }
135
+ if (queryObj) {
136
+ const params = new URLSearchParams();
137
+ for (const key of Object.keys(queryObj)) {
138
+ const value = queryObj[key];
139
+ if (value !== undefined && value !== null && value !== "") params.set(key, String(value));
140
+ }
141
+ const qs = params.toString();
142
+ if (qs.length) url += "?" + qs;
143
+ }
144
+ const init = { method: method || "GET", headers: {} };
145
+ if (bodyObj !== undefined) {
146
+ init.headers["content-type"] = "application/json";
147
+ init.body = JSON.stringify(bodyObj);
148
+ }
149
+ try {
150
+ const res = await fetch(url, init);
151
+ const data = await res.json().catch(() => null);
152
+ return data;
153
+ } catch (e) {
154
+ return { ok: false, error: { message: String((e && e.message) || e), code: "CLIENT" } };
155
+ }
156
+ }
157
+
158
+ const fmt = (value) => (value === undefined || value === null ? "—" : String(value));
159
+ const fmtNum = (value) => (typeof value === "number" ? String(Math.round(value * 10) / 10) : "—");
160
+ function fmtBytes(n) {
161
+ if (typeof n !== "number" || !Number.isFinite(n)) return "—";
162
+ if (n >= 1073741824) return String(Math.round((n / 1073741824) * 10) / 10) + " GB";
163
+ if (n >= 1048576) return String(Math.round((n / 1048576) * 10) / 10) + " MB";
164
+ return String(n) + " B";
165
+ }
166
+ const errMsg = (res) => (res && res.error ? (res.error.message || res.error.code || "Error") : "Unknown error");
167
+
168
+ const styles = {
169
+ wrap: { display: "flex", flexDirection: "column", gap: "12px", padding: "16px 20px", color: "var(--dsw-alias-label-primary, #1f2329)", maxWidth: 860 },
170
+ header: { display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" },
171
+ title: { margin: "0", fontSize: "16px", fontWeight: 600 },
172
+ badge: { borderRadius: "10px", padding: "2px 8px", fontSize: "12px", fontWeight: 600, lineHeight: "18px" },
173
+ badgeOk: { background: "rgba(26,127,55,0.12)", color: "#1a7f37" },
174
+ badgeWarn: { background: "rgba(191,144,0,0.16)", color: "#9a6700" },
175
+ badgeBad: { background: "rgba(209,36,47,0.12)", color: "#d1242f" },
176
+ muted: { margin: "0", fontSize: "12px", lineHeight: "16px", opacity: 0.7 },
177
+ button: { padding: "4px 10px", fontSize: "13px", lineHeight: "18px", borderRadius: "6px", border: "1px solid var(--dsw-alias-border-l2, #d0d7de)", background: "var(--dsw-alias-bg, #fff)", cursor: "pointer", color: "inherit" },
178
+ buttonPrimary: { padding: "4px 12px", fontSize: "13px", lineHeight: "18px", borderRadius: "6px", border: "none", background: "#1a7f37", color: "#fff", cursor: "pointer" },
179
+ buttonDanger: { padding: "4px 10px", fontSize: "13px", lineHeight: "18px", borderRadius: "6px", border: "1px solid rgba(209,36,47,0.5)", background: "transparent", color: "#d1242f", cursor: "pointer" },
180
+ card: { border: "1px solid var(--dsw-alias-border-l2, #d0d7de)", borderRadius: "10px", padding: "10px 12px", display: "flex", flexDirection: "column", gap: "6px" },
181
+ cardTitle: { margin: "0", fontSize: "13px", fontWeight: 600, lineHeight: "18px" },
182
+ twoCol: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px" },
183
+ kv: { display: "flex", justifyContent: "space-between", fontSize: "13px", lineHeight: "20px" },
184
+ table: { borderCollapse: "collapse", width: "100%", fontSize: "13px" },
185
+ th: { textAlign: "left", fontSize: "11px", textTransform: "uppercase", opacity: 0.6, padding: "4px 6px", borderBottom: "1px solid var(--dsw-alias-border-l2, #d0d7de)" },
186
+ td: { padding: "6px", borderBottom: "1px solid var(--dsw-alias-border-l2, #d0d7de)", verticalAlign: "top" },
187
+ chip: { display: "inline-block", borderRadius: "4px", padding: "0 5px", fontSize: "11px", lineHeight: "16px", border: "1px solid var(--dsw-alias-border-l3, #d0d7de)", marginRight: "4px" },
188
+ input: { boxSizing: "border-box", padding: "5px 8px", fontSize: "13px", borderRadius: "6px", border: "1px solid var(--dsw-alias-border-l2, #d0d7de)", background: "var(--dsw-alias-bg, #fff)", color: "inherit" },
189
+ notice: { margin: "0", fontSize: "12px", lineHeight: "16px", color: "#9a6700" },
190
+ success: { margin: "0", fontSize: "12px", lineHeight: "16px", color: "#1a7f37" },
191
+ error: { margin: "0", fontSize: "12px", lineHeight: "16px", color: "#d1242f" },
192
+ progress: { height: "6px", borderRadius: "3px", background: "var(--dsw-alias-border-l2, #d0d7de)", position: "relative" },
193
+ progressFill: { height: "6px", borderRadius: "3px", background: "#1a7f37" },
194
+ row: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" },
195
+ details: { fontSize: "13px" },
196
+ };
197
+ const el = (type, props, ...children) => h(type, props || {}, ...children);
198
+
199
+ function LemonadeServerView(props) {
200
+ const api = props.api;
201
+ const t = props && typeof props.t === "function" ? props.t : fallbackT;
202
+ const [health, setHealth] = useState(undefined);
203
+ const [healthErr, setHealthErr] = useState(undefined);
204
+ const [models, setModels] = useState(undefined);
205
+ const [modelsOpen, setModelsOpen] = useState(false);
206
+ const [onlyDownloaded, setOnlyDownloaded] = useState(true);
207
+ const [filesById, setFilesById] = useState({});
208
+ const [downloads, setDownloads] = useState(undefined);
209
+ const [stats, setStats] = useState(undefined);
210
+ const [sysStats, setSysStats] = useState(undefined);
211
+ const [sysInfo, setSysInfo] = useState(undefined);
212
+ const [serverURL, setServerURL] = useState("http://localhost:13305/api");
213
+ const [busy, setBusy] = useState(false);
214
+ const [error, setError] = useState(undefined);
215
+ const [notice, setNotice] = useState(undefined);
216
+ const [updates, setUpdates] = useState(undefined);
217
+ const [showAdd, setShowAdd] = useState(false);
218
+ const [searchText, setSearchText] = useState("");
219
+ const [searchResults, setSearchResults] = useState(undefined);
220
+ const [adding, setAdding] = useState(false);
221
+ const [aliases, setAliases] = useState(undefined);
222
+ const [aliasAlias, setAliasAlias] = useState("");
223
+ const [aliasTarget, setAliasTarget] = useState("");
224
+ const [adminOpen, setAdminOpen] = useState(true);
225
+ const [autoRefresh, setAutoRefresh] = useState(true);
226
+
227
+ const loadHealth = useCallback(async () => {
228
+ const res = await apiCall("health");
229
+ if (res && res.ok) { setHealth(res.value); setHealthErr(undefined); }
230
+ else { setHealth(undefined); setHealthErr(res && res.error ? res.error : { message: "hors ligne", code: "TRANSPORT" }); }
231
+ }, []);
232
+ const loadTelemetry = useCallback(async () => {
233
+ const [s1, s2] = await Promise.all([apiCall("stats"), apiCall("systemStats")]);
234
+ if (s1 && s1.ok) setStats(s1.value);
235
+ if (s2 && s2.ok) setSysStats(s2.value);
236
+ }, []);
237
+ const loadModels = useCallback(async () => {
238
+ const res = await apiCall("models", [], { show_all: "true" });
239
+ if (res && res.ok) {
240
+ const data = res.value && Array.isArray(res.value.data) ? res.value.data : [];
241
+ setModels(data);
242
+ }
243
+ }, []);
244
+ const loadDownloads = useCallback(async () => {
245
+ const res = await apiCall("downloads");
246
+ if (res && res.ok) setDownloads(Array.isArray(res.value) ? res.value : []);
247
+ }, []);
248
+ const loadAliases = useCallback(async () => {
249
+ const res = await apiCall("internalAliases");
250
+ if (res && res.ok && res.value && Array.isArray(res.value.aliases)) setAliases(res.value.aliases);
251
+ }, []);
252
+ const loadAll = useCallback(async () => {
253
+ setBusy(true); setError(undefined);
254
+ await Promise.all([loadHealth(), loadTelemetry(), loadModels(), loadDownloads(), loadAliases()]);
255
+ setBusy(false);
256
+ }, [loadHealth, loadTelemetry, loadModels, loadDownloads, loadAliases]);
257
+ useEffect(() => { loadAll(); }, [loadAll]);
258
+ useEffect(() => {
259
+ if (!autoRefresh) return;
260
+ const timer = setInterval(() => { loadHealth(); loadTelemetry(); }, 10000);
261
+ return () => clearInterval(timer);
262
+ }, [autoRefresh, loadHealth, loadTelemetry]);
263
+ useEffect(() => {
264
+ if (!api || !api.settings) return;
265
+ api.settings.describe({}).then((response) => {
266
+ const view = response && response.result && response.result.ok ? response.result.value : undefined;
267
+ if (!view) return;
268
+ const found = (view.namespaces || []).find((n) => n.ns === NS);
269
+ if (found && found.value) {
270
+ if (typeof found.value.baseURL === "string" && found.value.baseURL.length) setServerURL(found.value.baseURL);
271
+ }
272
+ }).catch(() => {});
273
+ }, [api]);
274
+
275
+ const run = async (fn, okMessage) => {
276
+ setBusy(true); setError(undefined); setNotice(undefined); setUpdates(undefined);
277
+ try { await fn(); } catch (e) { setError(String((e && e.message) || e)); }
278
+ if (okMessage) setNotice(okMessage);
279
+ await loadAll();
280
+ setBusy(false);
281
+ };
282
+
283
+ const loadedByModel = (id) => (health && Array.isArray(health.all_models_loaded) ? health.all_models_loaded : []).find((m) => m.model_name === id);
284
+
285
+ // The tab lists every model the server advertises, optionally filtered
286
+ // to downloaded ones (checkbox in the block header, checked by default).
287
+ // Aliases are hidden: an alias is an entry whose name is in the alias
288
+ // listing (GET /internal/aliases) or that carries a "model" field
289
+ // pointing at its target (a real downloaded model never does).
290
+ const aliasNames = new Set(
291
+ (Array.isArray(aliases) ? aliases : [])
292
+ .map((a) => (a ? (a.alias !== undefined ? a.alias : a.name) : undefined))
293
+ .filter((v) => typeof v === "string" && v.length > 0),
294
+ );
295
+ const visibleModels = (Array.isArray(models) ? models : []).filter((m) => {
296
+ if (m === null || typeof m !== "object") return false;
297
+ if (typeof m.model === "string" && m.model.length > 0) return false;
298
+ if (aliasNames.has(m.id) || aliasNames.has(m.name)) return false;
299
+ return !onlyDownloaded || m.downloaded !== false;
300
+ });
301
+
302
+ const toggleFiles = async (id) => {
303
+ if (filesById[id] !== undefined) {
304
+ const next = { ...filesById };
305
+ delete next[id];
306
+ setFilesById(next);
307
+ return;
308
+ }
309
+ const res = await apiCall("modelFiles", [id]);
310
+ if (res && res.ok) setFilesById((prev) => ({ ...prev, [id]: res.value && Array.isArray(res.value.files) ? res.value.files : [] }));
311
+ else setError(errMsg(res));
312
+ };
313
+
314
+ const doSearch = async () => {
315
+ setAdding(true); setError(undefined); setSearchResults(undefined); setUpdates(undefined);
316
+ const query = searchText.trim();
317
+ const res = await apiCall("registrySearch", [], { query, format: "gguf" });
318
+ if (res && res.ok) setSearchResults(res.value && Array.isArray(res.value.results) ? res.value.results : []);
319
+ else setError(errMsg(res));
320
+ setAdding(false);
321
+ };
322
+
323
+ const doPull = async (repositoryId) => {
324
+ setAdding(true); setError(undefined);
325
+ // Conformant pull flow (spec): variants -> model_name/recipe/checkpoint.
326
+ let modelName = "user." + String(repositoryId).split("/").pop();
327
+ let recipe = "llamacpp";
328
+ let checkpoint = repositoryId;
329
+ const v = await apiCall("pullVariants", [], { checkpoint: repositoryId });
330
+ if (v && v.ok && v.value) {
331
+ if (typeof v.value.suggested_name === "string" && v.value.suggested_name.length) modelName = "user." + v.value.suggested_name;
332
+ if (typeof v.value.recipe === "string" && v.value.recipe.length) recipe = v.value.recipe;
333
+ const variants = Array.isArray(v.value.variants) ? v.value.variants : [];
334
+ if (variants.length > 0 && variants[0] && typeof variants[0].name === "string" && variants[0].name.length) {
335
+ checkpoint = repositoryId + ":" + variants[0].name;
336
+ }
337
+ }
338
+ const res = await apiCall("pull", [], undefined, "POST", { model_name: modelName, recipe, checkpoint });
339
+ if (res && res.ok) { setNotice(t("downloadStarted")); setShowAdd(false); setSearchResults(undefined); setSearchText(""); loadDownloads(); }
340
+ else setError(errMsg(res));
341
+ setAdding(false);
342
+ };
343
+
344
+ const healthOk = health !== undefined && health.status === "ok";
345
+ let badgeKey = "offline";
346
+ if (healthOk) badgeKey = "online";
347
+ else if (healthErr && healthErr.code === "MISSING_CREDENTIAL") badgeKey = "keyMissing";
348
+
349
+ return el("div", { style: styles.wrap },
350
+ // ---- header ----
351
+ el("div", { style: styles.header },
352
+ el("h2", { style: styles.title }, t("title")),
353
+ el("span", { style: { ...styles.badge, ...(badgeKey === "online" ? styles.badgeOk : badgeKey === "keyMissing" ? styles.badgeWarn : styles.badgeBad) } }, "● " + t(badgeKey)),
354
+ el("span", { style: styles.muted }, health && health.version ? "v" + health.version : ""),
355
+ el("button", { style: styles.button, disabled: busy, onClick: () => loadAll() }, busy ? t("loading") : t("refresh")),
356
+ el("button", { style: styles.button, disabled: busy, onClick: () => setAutoRefresh((v) => !v) }, t(autoRefresh ? "autoOn" : "autoOff")),
357
+ ),
358
+ el("p", { style: styles.muted }, serverURL),
359
+ healthErr && !healthOk ? el("p", { style: styles.error },
360
+ (healthErr.code === "MISSING_CREDENTIAL" ? t("keyMissingBanner") : t("serverUnreachableBanner")) + errMsg({ error: healthErr })) : null,
361
+
362
+ // ---- telemetry ----
363
+ el("div", { style: styles.twoCol },
364
+ el("div", { style: styles.card },
365
+ el("h3", { style: styles.cardTitle }, t("lastRequest")),
366
+ kv(t("ttft"), stats ? fmtNum(stats.time_to_first_token) + " s" : "—"),
367
+ kv(t("tokPerSec"), stats ? fmtNum(stats.tokens_per_second) + " tok/s" : "—"),
368
+ kv(t("inOut"), stats ? fmt(stats.input_tokens) + " / " + fmt(stats.output_tokens) : "—"),
369
+ kv(t("prompt"), stats ? fmt(stats.prompt_tokens) : "—"),
370
+ ),
371
+ el("div", { style: styles.card },
372
+ el("h3", { style: styles.cardTitle }, t("hostTitle")),
373
+ kv(t("cpu"), sysStats ? fmtNum(sysStats.cpu_percent) + " %" : "—"),
374
+ kv(t("ram"), sysStats ? fmtNum(sysStats.memory_gb) + " GB" : "—"),
375
+ kv(t("gpu"), sysStats && sysStats.gpu_percent !== null ? fmtNum(sysStats.gpu_percent) + " %" : t("na")),
376
+ kv(t("vram"), sysStats && sysStats.vram_gb !== null ? fmtNum(sysStats.vram_gb) + " GB" : t("na")),
377
+ sysStats === undefined ? el("button", { style: styles.button, onClick: async () => { const r = await apiCall("systemInfo"); if (r && r.ok) setSysInfo(r.value); else setError(errMsg(r)); } }, t("systemDetails")) : null,
378
+ sysInfo ? el("details", { style: styles.details },
379
+ el("summary", null, t("systemInfo")),
380
+ el("pre", { style: { fontSize: "11px", whiteSpace: "pre-wrap", maxHeight: 300, overflow: "auto", margin: "4px 0" } }, JSON.stringify(sysInfo, null, 2)),
381
+ ) : null,
382
+ ),
383
+ ),
384
+
385
+ // ---- models ----
386
+ el("details", { style: { ...styles.card, marginTop: 0 }, open: modelsOpen, onToggle: (e) => setModelsOpen(e.target.open) },
387
+ el("summary", { style: { ...styles.cardTitle, cursor: "pointer" }, title: t("modelsTooltip") }, t("models") + (Array.isArray(visibleModels) ? " (" + visibleModels.length + ")" : "")),
388
+ el("div", { style: styles.row, justifyContent: "flex-end" },
389
+ el("label", { style: { display: "flex", alignItems: "center", gap: "6px", fontSize: "13px" } },
390
+ el("input", { type: "checkbox", checked: onlyDownloaded === true, onChange: (e) => setOnlyDownloaded(e.target.checked) }),
391
+ t("onlyDownloaded"),
392
+ ),
393
+ el("button", { style: styles.button, disabled: busy, onClick: async () => { const r = await apiCall("checkUpdates", [], undefined, "POST"); if (r && r.ok) setUpdates(r.value); else setError(errMsg(r)); } }, t("checkUpdates")),
394
+ el("button", { style: styles.button, disabled: busy, onClick: () => setShowAdd((v) => !v) }, showAdd ? t("close") : t("add")),
395
+ ),
396
+ updates ? el("p", { style: styles.notice }, t("updatesNotice", { count: updates.updates_available || 0, models: Array.isArray(updates.models) ? updates.models.join(", ") : "" })) : null,
397
+ Array.isArray(visibleModels) && visibleModels.length === 0 ? el("p", { style: styles.muted }, t("noModels")) : null,
398
+ Array.isArray(visibleModels) && visibleModels.length > 0 ? el("table", { style: styles.table },
399
+ el("thead", null, el("tr", null,
400
+ el("th", { style: styles.th }, t("thModel")),
401
+ el("th", { style: styles.th }, t("thRecipe")),
402
+ el("th", { style: styles.th }, t("thSize")),
403
+ el("th", { style: styles.th }, t("thState")),
404
+ el("th", { style: styles.th }, t("thActions")),
405
+ )),
406
+ el("tbody", null, visibleModels.map((m) => {
407
+ const loaded = loadedByModel(m.id);
408
+ return el("tr", { key: m.id },
409
+ el("td", { style: styles.td },
410
+ el("span", null, m.id),
411
+ m.update_available ? el("span", { style: { ...styles.chip, borderColor: "#9a6700", color: "#9a6700" } }, t("updateBadge")) : null,
412
+ ),
413
+ el("td", { style: styles.td }, m.recipe ? el("span", { style: styles.chip }, m.recipe) : ""),
414
+ el("td", { style: styles.td }, typeof m.size === "number" ? String(m.size) : "—"),
415
+ el("td", { style: styles.td },
416
+ loaded ? el("span", { style: { ...styles.chip, background: "rgba(26,127,55,0.1)", color: "#1a7f37" } }, t("loaded")) : el("span", { style: styles.chip }, t("notLoaded")),
417
+ m.downloaded === false ? el("span", { style: styles.chip }, t("toDownload")) : null,
418
+ ),
419
+ el("td", { style: styles.td },
420
+ el("div", { style: styles.row },
421
+ loaded ? el("button", { style: styles.button, disabled: busy, onClick: () => run(async () => { const r = await apiCall("unload", [], undefined, "POST", { model: m.id }); if (!r || !r.ok) throw new Error(errMsg(r)); }, t("modelUnloaded")) }, t("unload"))
422
+ : m.downloaded === false ? el("button", { style: styles.button, disabled: busy, onClick: () => run(async () => { const r = await apiCall("pull", [], undefined, "POST", { checkpoint: m.id }); if (!r || !r.ok) throw new Error(errMsg(r)); }, t("downloadStarted")) }, t("download"))
423
+ : el("button", { style: styles.button, disabled: busy, onClick: () => run(async () => { const r = await apiCall("load", [], undefined, "POST", { model: m.id }); if (!r || !r.ok) throw new Error(errMsg(r)); }, t("modelLoaded")) }, t("load")),
424
+ el("button", { style: styles.button, disabled: busy, onClick: () => toggleFiles(m.id) }, t("files")),
425
+ el("button", { style: styles.buttonDanger, disabled: busy, onClick: () => { if (confirm(t("confirmDeleteModel", { model: m.id }))) run(async () => { const r = await apiCall("delete", [], undefined, "POST", { model: m.id }); if (!r || !r.ok) throw new Error(errMsg(r)); }, t("modelDeleted")); } }, t("delete")),
426
+ ),
427
+ filesById[m.id] !== undefined ? divFiles(filesById[m.id], t) : null,
428
+ ),
429
+ );
430
+ })),
431
+ ) : null,
432
+ showAdd ? addModelCard(styles, el, searchText, setSearchText, searchResults, adding, doSearch, doPull, setShowAdd, t) : null,
433
+ ),
434
+
435
+ // ---- admin (aliases) ----
436
+ el("details", { style: { ...styles.card, marginTop: 0 }, open: adminOpen, onToggle: (e) => setAdminOpen(e.target.open) },
437
+ el("summary", { style: { ...styles.cardTitle, cursor: "pointer" }, title: t("aliasesTooltip") }, t("aliasesTitle")),
438
+ el("div", { style: styles.row },
439
+ el("input", { style: { ...styles.input, flex: 1, minWidth: 200 }, placeholder: t("aliasPlaceholder"), value: aliasAlias, onChange: (e) => setAliasAlias(e.target.value) }),
440
+ el("input", { style: { ...styles.input, flex: 2, minWidth: 220 }, placeholder: t("targetPlaceholder"), value: aliasTarget, onChange: (e) => setAliasTarget(e.target.value) }),
441
+ el("button", { style: styles.buttonPrimary, disabled: busy || !aliasAlias.trim() || !aliasTarget.trim(), onClick: () => run(async () => {
442
+ const r = await apiCall("internalAliasesSet", [], undefined, "POST", { alias: aliasAlias.trim(), target: aliasTarget.trim() });
443
+ if (!r || !r.ok) throw new Error(errMsg(r));
444
+ setAliasAlias(""); setAliasTarget("");
445
+ const l = await apiCall("internalAliases");
446
+ if (l && l.ok) setAliases(l.value && Array.isArray(l.value.aliases) ? l.value.aliases : []);
447
+ }, t("aliasLinked")) }, t("link")),
448
+ el("button", { style: styles.button, disabled: busy, onClick: async () => { const l = await apiCall("internalAliases"); if (l && l.ok) setAliases(l.value && Array.isArray(l.value.aliases) ? l.value.aliases : []); else setError(errMsg(l)); } }, t("list")),
449
+ el("button", { style: styles.button, disabled: busy, onClick: () => run(async () => { const r = await apiCall("internalTelemetryFlush", [], undefined, "POST"); if (!r || !r.ok) throw new Error(errMsg(r)); }, t("telemetryFlushed")) }, t("flushTelemetry")),
450
+ ),
451
+ Array.isArray(aliases) && aliases.length === 0 ? el("p", { style: styles.muted }, t("noAliases")) : null,
452
+ Array.isArray(aliases) && aliases.length > 0 ? el("ul", { style: { margin: "4px 0 0 0", padding: 0, listStyle: "none" } },
453
+ aliases.map((al) => el("li", { key: al.alias, style: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "4px 0", borderBottom: "1px solid var(--dsw-alias-border-l2, #d0d7de)" } },
454
+ el("span", { style: { fontSize: "13px" } }, String(al.alias) + " → " + String(al.target || al.model || "") + (al.downloaded === true ? t("downloadedSuffix") : "")),
455
+ el("button", { style: styles.buttonDanger, disabled: busy, onClick: () => { if (confirm(t("confirmDeleteAlias", { alias: al.alias }))) run(async () => { const r = await apiCall("internalAliasesDelete", [al.alias]); if (!r || !r.ok) throw new Error(errMsg(r)); setAliases((prev) => Array.isArray(prev) ? prev.filter((x) => x.alias !== al.alias) : prev); }, t("aliasDeleted")); } }, t("delete")),
456
+ ))) : null,
457
+ ),
458
+
459
+
460
+ // ---- downloads ---- (refreshed by the tab-wide refresh button)
461
+ el("div", { style: styles.card },
462
+ el("h3", { style: styles.cardTitle }, t("downloadsTitle")),
463
+ Array.isArray(downloads) && downloads.length === 0 ? el("p", { style: styles.muted }, t("noDownloads")) : null,
464
+ Array.isArray(downloads) ? downloads.map((job) => downloadRow(job, busy, run, loadDownloads, styles, el, t)) : null,
465
+ ),
466
+
467
+ error !== undefined ? el("p", { style: styles.error }, String(error)) : null,
468
+ notice !== undefined ? el("p", { style: styles.success }, String(notice)) : null,
469
+ );
470
+ }
471
+
472
+ function kv(label, value) {
473
+ return el("div", { style: styles.kv }, el("span", { style: styles.muted }, label), el("span", null, value));
474
+ }
475
+ const pct = (job) => (typeof job.percent === "number" ? Math.max(0, Math.min(100, Math.round(job.percent))) : (job.complete ? 100 : 0));
476
+ function downloadRow(job, busy, run, reload, st, h2, t) {
477
+ const percent = pct(job);
478
+ const bytes = fmtBytes(job.bytes_downloaded) + " / " + fmtBytes(job.bytes_total);
479
+ const fileInfo = typeof job.total_files === "number" ? t("fileProgress", { index: fmt(job.file_index), total: fmt(job.total_files) }) : "";
480
+ return h2("div", { key: job.id },
481
+ h2("div", { style: { display: "flex", justifyContent: "space-between", fontSize: "13px" } },
482
+ h2("span", null, job.model_name || job.id),
483
+ h2("span", { style: st.muted }, job.status + (job.running ? " " + t("runningSuffix") : "")),
484
+ ),
485
+ h2("div", { style: st.progress }, h2("div", { style: { ...st.progressFill, width: percent + "%" } })),
486
+ h2("p", { style: st.muted }, t("downloadProgress", { percent: percent, bytes: bytes }) + fileInfo),
487
+ h2("div", { style: styles.row },
488
+ job.running ? h2("button", { style: st.button, disabled: busy, onClick: () => run(async () => { const r = await apiCall("downloadsControl", [], undefined, "POST", { id: job.id, action: "pause" }); if (!r || !r.ok) throw new Error(errMsg(r)); }, t("downloadPaused")) }, t("pause")) : null,
489
+ h2("button", { style: st.button, disabled: busy, onClick: () => run(async () => { const r = await apiCall("downloadsControl", [], undefined, "POST", { id: job.id, action: "cancel" }); if (!r || !r.ok) throw new Error(errMsg(r)); }, t("downloadCancelled")) }, t("cancel")),
490
+ !job.running ? h2("button", { style: st.buttonDanger, disabled: busy, onClick: () => run(async () => { const r = await apiCall("downloadsControl", [], undefined, "POST", { id: job.id, action: "remove" }); if (!r || !r.ok) throw new Error(errMsg(r)); }, t("entryRemoved")) }, t("remove")) : null,
491
+ ),
492
+ );
493
+ }
494
+ function divFiles(files, t) {
495
+ if (!Array.isArray(files) || files.length === 0) return el("p", { style: styles.muted }, t("filesNone"));
496
+ return el("ul", { style: { margin: "4px 0 0 0", paddingLeft: "16px", fontSize: "12px" } },
497
+ files.map((f) => el("li", { key: f.name },
498
+ f.name + (f.role && f.role !== "main" ? " (" + f.role + ")" : "") + " — " + fmtBytes(f.size_bytes) + (f.exists ? "" : t("missingSuffix")),
499
+ )),
500
+ );
501
+ }
502
+ function addModelCard(st, h2, searchText, setSearchText, results, adding, doSearch, doPull, setShowAdd, t) {
503
+ return h2("div", { style: { ...st.card, background: "var(--dsw-alias-bg-secondary, #f6f8fa)" } },
504
+ h2("h4", { style: st.cardTitle }, t("addModel")),
505
+ h2("div", { style: styles.row },
506
+ h2("input", { style: { ...st.input, flex: 1, minWidth: 220 }, placeholder: t("searchPlaceholder"), value: searchText, onChange: (e) => setSearchText(e.target.value), onKeyDown: (e) => { if (e.key === "Enter") doSearch(); } }),
507
+ h2("button", { style: st.buttonPrimary, disabled: adding || searchText.trim().length < 3, onClick: doSearch }, t("search")),
508
+ ),
509
+ typeof results === "object" && results !== null && results.length === 0 ? h2("p", { style: st.muted }, t("noResults")) : null,
510
+ Array.isArray(results) && results.length > 0 ? h2("ul", { style: { margin: "4px 0 0 0", padding: 0, listStyle: "none" } },
511
+ results.map((r2) => h2("li", { key: r2.repository_id, style: { padding: "6px 0", borderBottom: "1px solid var(--dsw-alias-border-l2, #d0d7de)" } },
512
+ h2("div", { style: styles.row, justifyContent: "space-between" },
513
+ h2("div", null,
514
+ h2("div", { style: { fontSize: "13px", fontWeight: 600 } }, r2.display_name || r2.repository_id),
515
+ h2("div", { style: st.muted }, String(r2.repository_id) + (r2.description ? " — " + r2.description : "")),
516
+ ),
517
+ h2("div", { style: styles.row },
518
+ h2("span", { style: st.muted }, "♥ " + fmt(r2.likes) + " · ⬇ " + fmt(r2.downloads)),
519
+ h2("button", { style: st.button, disabled: adding, onClick: () => doPull(r2.repository_id) }, t("install")),
520
+ ),
521
+ ),
522
+ ))) : null,
523
+ );
524
+ }
525
+
526
+ /** Register the Lemonade conversation view tab (next to Chat/Trajectory). */
527
+ function apply(ctx) {
528
+ const slots = ctx.get("slots");
529
+ const connection = ctx.get("connection");
530
+ if (slots === undefined || connection === undefined) return;
531
+ // i18n: register the dictionary with the dsh locale service and bind
532
+ // its translator (en + zh both point at EN, so English is the default
533
+ // in every preference); fall back to EN-only when locale is absent.
534
+ let viewT = fallbackT;
535
+ const locale = ctx.get("locale");
536
+ if (locale !== undefined && typeof locale.register === "function" && typeof locale.bind === "function") {
537
+ try {
538
+ locale.register(LOCALE_NS, { en: EN, zh: EN });
539
+ viewT = locale.bind(LOCALE_NS);
540
+ } catch (err) {
541
+ viewT = fallbackT;
542
+ }
543
+ }
544
+ slots.inject("conversation.view", () => slots.register(
545
+ {
546
+ name: "conversation.view",
547
+ id: "lemonade",
548
+ order: 10,
549
+ label: () => viewT("tabLabel"),
550
+ inject: () => ({ api: connection.api, t: viewT }),
551
+ },
552
+ LemonadeServerView,
553
+ ));
554
+ }
555
+ var inject = ["slots", "connection"];
556
+
557
+ exports.apply = apply;
558
+ exports.inject = inject;
559
+ return module.exports;
560
+ }
561
+ });