@cyrilmarin/dsh-lemonade 0.2.1 → 0.4.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.fr.md +6 -0
- package/README.md +21 -1
- package/lib/adapter.js +5 -4
- package/lib/client.js +200 -8
- package/lib/index.js +33 -21
- package/lib/json-parse.js +230 -0
- package/lib/server-api.js +320 -30
- package/lib/translate.js +67 -7
- package/lib/types/adapter.d.ts +7 -2
- package/lib/types/index.d.ts +1 -0
- package/lib/types/json-parse.d.ts +34 -0
- package/lib/types/server-api.d.ts +4 -10
- package/lib/types/translate.d.ts +19 -4
- package/package.json +6 -2
- package/src/adapter.ts +11 -4
- package/src/client/index.js +200 -8
- package/src/index.ts +32 -15
- package/src/json-parse.ts +175 -0
- package/src/server-api.ts +329 -30
- package/src/translate.ts +71 -5
package/README.fr.md
CHANGED
|
@@ -32,6 +32,12 @@ Depuis le répertoire du profil (ex. `~/.dsh/profiles/web`) :
|
|
|
32
32
|
pnpm add file:../dsh-lemonade-provider
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
ou depuis npm (le paquet publié est `@cyrilmarin/dsh-lemonade`) :
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pnpm add @cyrilmarin/dsh-lemonade@latest
|
|
39
|
+
```
|
|
40
|
+
|
|
35
41
|
ou, en ligne de commande dsh :
|
|
36
42
|
|
|
37
43
|
```sh
|
package/README.md
CHANGED
|
@@ -32,6 +32,12 @@ From the profile directory (e.g. `~/.dsh/profiles/web`):
|
|
|
32
32
|
pnpm add file:../dsh-lemonade-provider
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
Or from npm (the published package is `@cyrilmarin/dsh-lemonade`):
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pnpm add @cyrilmarin/dsh-lemonade@latest
|
|
39
|
+
```
|
|
40
|
+
|
|
35
41
|
Or, via the dsh command line:
|
|
36
42
|
|
|
37
43
|
```sh
|
|
@@ -63,6 +69,7 @@ Then add an entry to the profile's `cordis.patch.yml` (see
|
|
|
63
69
|
| `defaultContextWindow` | number | `32768` | Context window used when the server doesn't declare one |
|
|
64
70
|
| `maxTokens` | number | `8192` | Default output cap |
|
|
65
71
|
| `streamIdleTimeoutMs` | number | `300000` | SSE stream idle timeout |
|
|
72
|
+
| `listingTimeoutMs` | number | `5000` | Max time one live model-listing query may take (abort if exceeded) |
|
|
66
73
|
| `retryPolicy` | object | default values | Provider retry policy |
|
|
67
74
|
|
|
68
75
|
Each entry in `models`: `id` (required), `name`, `description`,
|
|
@@ -100,7 +107,8 @@ Lemonade via "Fetch available models" (`llm.discoverModels`).
|
|
|
100
107
|
|
|
101
108
|
A **Lemonade** tab (next to Chat/Trajectory) exposes the entry points of the
|
|
102
109
|
Lemonade-specific API (health/liveness, telemetry, models with
|
|
103
|
-
Load/Unload/Delete/Files/Update,
|
|
110
|
+
Load/Unload/Delete/Files/Update/Info, LoRA adapter list/load/unload,
|
|
111
|
+
controllable downloads, cloud keys, and a live log stream).
|
|
104
112
|
The browser calls the dsh server on the same origin (`/dsh-lemonade/api/<op>`);
|
|
105
113
|
the host proxy to Lemonade (`src/server-api.ts`) resolves baseURL + key
|
|
106
114
|
(these never leave the host). Key selection is **per endpoint**: regular
|
|
@@ -110,6 +118,18 @@ back to the regular key). The route is registered via
|
|
|
110
118
|
`ctx.webServer.register({ kind: 'prefix', path: '/dsh-lemonade/api', ... })`
|
|
111
119
|
when the `webServer` service is available.
|
|
112
120
|
|
|
121
|
+
The model table supports **batch operations**: a select-all checkbox in the
|
|
122
|
+
header and one per row let you mark models, then the **Load selected** /
|
|
123
|
+
**Unload selected** / **Delete selected** buttons dispatch one call per selected
|
|
124
|
+
model through the host proxy and report a `done/total` progress line plus how
|
|
125
|
+
many operations failed. The batch delete prompts in a **confirmation modal**
|
|
126
|
+
(instead of the browser's native `confirm()`) before it runs. A **Logs** pane
|
|
127
|
+
(**Logs button in the tab header**) opens a live stream of the Lemonade server's
|
|
128
|
+
own logs: the browser holds a plain SSE connection to the host proxy, which opens
|
|
129
|
+
a WebSocket *client* to Lemonade's `/logs/stream` (the log port is discovered
|
|
130
|
+
from `GET /v1/health` → `websocket_port`, which shares the Realtime Audio port)
|
|
131
|
+
and re-emits every upstream log message as an SSE event.
|
|
132
|
+
|
|
113
133
|
### Client browser bundle
|
|
114
134
|
|
|
115
135
|
The browser half lives in `src/client/index.js` and is copied verbatim to
|
package/lib/adapter.js
CHANGED
|
@@ -10,8 +10,8 @@ export const DEFAULT_CONTEXT_WINDOW = 32768;
|
|
|
10
10
|
export const DEFAULT_MAX_TOKENS = 8192;
|
|
11
11
|
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
|
12
12
|
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
13
|
-
/**
|
|
14
|
-
export const
|
|
13
|
+
/** Default maximum time one live model-listing query may take. */
|
|
14
|
+
export const DEFAULT_LISTING_TIMEOUT_MS = 5_000;
|
|
15
15
|
const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT';
|
|
16
16
|
/**
|
|
17
17
|
* Deployment labels that route a model to a non-chat endpoint; such models are
|
|
@@ -167,7 +167,7 @@ export class LemonadeAdapter extends LlmAdapter {
|
|
|
167
167
|
// No configured selection: advertise whatever the server currently offers.
|
|
168
168
|
try {
|
|
169
169
|
const apiKey = await this.config.resolveApiKey();
|
|
170
|
-
const entries = await fetchModelEntries(options.baseURL, apiKey, AbortSignal.timeout(
|
|
170
|
+
const entries = await fetchModelEntries(options.baseURL, apiKey, AbortSignal.timeout(options.listingTimeoutMs ?? DEFAULT_LISTING_TIMEOUT_MS));
|
|
171
171
|
this.lastKnown = new Map(entries.map((entry) => [entry.id, entry]));
|
|
172
172
|
return entries.map((entry) => modelInfo(provider, entry.id, entry));
|
|
173
173
|
}
|
|
@@ -281,7 +281,8 @@ export class LemonadeAdapter extends LlmAdapter {
|
|
|
281
281
|
}
|
|
282
282
|
if (!response.body)
|
|
283
283
|
throw new LlmError('Lemonade API returned no response body', 'EMPTY_RESPONSE');
|
|
284
|
-
|
|
284
|
+
const warn = (reason) => { this.config.logger?.().warn(reason); };
|
|
285
|
+
yield* translate(parseSse(response.body, onComment, warn), warn);
|
|
285
286
|
}
|
|
286
287
|
}
|
|
287
288
|
//# sourceMappingURL=adapter.js.map
|
package/lib/client.js
CHANGED
|
@@ -110,6 +110,25 @@ window.__ModuleLoader__.load({
|
|
|
110
110
|
downloadedSuffix: " (downloaded)",
|
|
111
111
|
filesNone: "No local files.",
|
|
112
112
|
missingSuffix: " (missing)",
|
|
113
|
+
logsTitle: "Logs",
|
|
114
|
+
logsTooltip: "Live stream of the Lemonade server logs (WebSocket /logs/stream)",
|
|
115
|
+
logsConnecting: "connecting…",
|
|
116
|
+
logsConnected: "connected",
|
|
117
|
+
logsOffline: "logs unavailable",
|
|
118
|
+
logsAutoScroll: "Auto-scroll",
|
|
119
|
+
logLines: "{count} log line(s) shown",
|
|
120
|
+
logsKeepAlive: "Reconnect",
|
|
121
|
+
logsKeepAliveTitle: "Re-open the log stream",
|
|
122
|
+
logsClosed: "Log stream closed.",
|
|
123
|
+
logsError: "Log stream error.",
|
|
124
|
+
batchHeader: "Batch action",
|
|
125
|
+
batchLoad: "Load selected",
|
|
126
|
+
batchUnload: "Unload selected",
|
|
127
|
+
batchDelete: "Delete selected",
|
|
128
|
+
batchProgress: "Running {done}/{total}",
|
|
129
|
+
batchOk: "Batch done ({failed} failed)",
|
|
130
|
+
modalClose: "Close",
|
|
131
|
+
modalConfirm: "Confirm",
|
|
113
132
|
};
|
|
114
133
|
|
|
115
134
|
/** Lookup + {param} interpolation; English is the default fallback. */
|
|
@@ -126,7 +145,15 @@ window.__ModuleLoader__.load({
|
|
|
126
145
|
}
|
|
127
146
|
const fallbackT = makeT(EN);
|
|
128
147
|
|
|
129
|
-
/** Same-origin call to the host proxy; returns the normalized wire result.
|
|
148
|
+
/** Same-origin call to the host proxy; returns the normalized wire result.
|
|
149
|
+
*
|
|
150
|
+
* Distinguishes three outcomes: a network failure (fetch throws — server
|
|
151
|
+
* unreachable) as code "CLIENT" with status 0, an HTTP error whose body is
|
|
152
|
+
* not the host wire format as "HTTP_<status>", and the host's own wire
|
|
153
|
+
* result (which carries the HTTP `status` and a stable `code`) unchanged.
|
|
154
|
+
* Reading the raw text first (not res.json()) keeps an error status from
|
|
155
|
+
* being swallowed when the body is non-JSON.
|
|
156
|
+
*/
|
|
130
157
|
async function apiCall(op, segments, queryObj, method, bodyObj) {
|
|
131
158
|
let url = API + "/" + op;
|
|
132
159
|
if (segments && segments.length) {
|
|
@@ -146,13 +173,28 @@ window.__ModuleLoader__.load({
|
|
|
146
173
|
init.headers["content-type"] = "application/json";
|
|
147
174
|
init.body = JSON.stringify(bodyObj);
|
|
148
175
|
}
|
|
176
|
+
let res;
|
|
149
177
|
try {
|
|
150
|
-
|
|
151
|
-
const data = await res.json().catch(() => null);
|
|
152
|
-
return data;
|
|
178
|
+
res = await fetch(url, init);
|
|
153
179
|
} catch (e) {
|
|
154
|
-
return { ok: false, error: { message: String((e && e.message) || e), code: "CLIENT" } };
|
|
180
|
+
return { ok: false, error: { message: String((e && e.message) || e), code: "CLIENT", status: 0 } };
|
|
155
181
|
}
|
|
182
|
+
const text = await res.text().catch(() => "");
|
|
183
|
+
let data = null;
|
|
184
|
+
if (text.length > 0) {
|
|
185
|
+
try { data = JSON.parse(text); } catch { data = null; }
|
|
186
|
+
}
|
|
187
|
+
// HTTP error without the host wire shape (non-JSON body, or a bare
|
|
188
|
+
// object): surface the status as a stable "HTTP_<status>" error.
|
|
189
|
+
if (!res.ok && (!data || typeof data !== "object" || !("ok" in data))) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
error: { message: data && (data.error && data.error.message) ? data.error.message : String((data && data.message) || "HTTP " + res.status), code: "HTTP_" + res.status, status: res.status },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// Host wire result ({ ok, error: { message, code, status? } }) — keep
|
|
196
|
+
// the host code and its HTTP status intact so the UI can distinguish.
|
|
197
|
+
return data;
|
|
156
198
|
}
|
|
157
199
|
|
|
158
200
|
const fmt = (value) => (value === undefined || value === null ? "—" : String(value));
|
|
@@ -196,6 +238,84 @@ window.__ModuleLoader__.load({
|
|
|
196
238
|
};
|
|
197
239
|
const el = (type, props, ...children) => h(type, props || {}, ...children);
|
|
198
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Live log pane. Opens an SSE connection to the host proxy's logsStream
|
|
243
|
+
* route (which itself owns a WebSocket client to Lemonade) and appends
|
|
244
|
+
* each upstream `data:` line as a log entry. Holds no internal timer: a
|
|
245
|
+
* reconnect button re-mounts the effect.
|
|
246
|
+
*/
|
|
247
|
+
function LogsStream(props) {
|
|
248
|
+
const t = props.t;
|
|
249
|
+
const [lines, setLines] = useState([]);
|
|
250
|
+
const [status, setStatus] = useState(t("logsConnecting"));
|
|
251
|
+
const [autoScroll, setAutoScroll] = useState(true);
|
|
252
|
+
const [logEl, setLogEl] = useState(null);
|
|
253
|
+
const [retry, setRetry] = useState(0);
|
|
254
|
+
|
|
255
|
+
const append = (text, isError) => {
|
|
256
|
+
setLines((prev) => {
|
|
257
|
+
const next = prev.concat({ text: text, isError: !!isError });
|
|
258
|
+
return next.length > 500 ? next.slice(-500) : next;
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
const controller = new AbortController();
|
|
264
|
+
setStatus(t("logsConnecting"));
|
|
265
|
+
fetch(API + "/logsStream", { headers: {}, signal: controller.signal })
|
|
266
|
+
.then(async (res) => {
|
|
267
|
+
if (!res.ok) {
|
|
268
|
+
append(t("logsError") + " HTTP " + (res.status || ""), true);
|
|
269
|
+
setStatus(t("logsOffline"));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
setStatus(t("logsConnected"));
|
|
273
|
+
const reader = res.body.getReader();
|
|
274
|
+
const decoder = new TextDecoder();
|
|
275
|
+
let buffer = "";
|
|
276
|
+
const loop = async () => {
|
|
277
|
+
for (;;) {
|
|
278
|
+
const { done, value } = await reader.read();
|
|
279
|
+
if (done) { setStatus(t("logsClosed")); return; }
|
|
280
|
+
buffer += decoder.decode(value, { stream: true });
|
|
281
|
+
let nl;
|
|
282
|
+
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
283
|
+
const line = buffer.slice(0, nl).replace(/\r$/, "").trim();
|
|
284
|
+
buffer = buffer.slice(nl + 1);
|
|
285
|
+
if (!line || line.indexOf("event:") === 0) continue;
|
|
286
|
+
if (line.indexOf("data:") === 0) append(line.slice(5).trim(), false);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
void loop();
|
|
291
|
+
})
|
|
292
|
+
.catch(() => { setStatus(t("logsOffline")); });
|
|
293
|
+
return () => controller.abort();
|
|
294
|
+
}, [t, retry]);
|
|
295
|
+
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
if (autoScroll && logEl) logEl.scrollTop = logEl.scrollHeight;
|
|
298
|
+
}, [lines, autoScroll, logEl]);
|
|
299
|
+
|
|
300
|
+
const connected = status.indexOf("connected") >= 0 || status.indexOf("connecting") >= 0;
|
|
301
|
+
|
|
302
|
+
return h("div", { style: styles.card },
|
|
303
|
+
h("div", { style: styles.row, justifyContent: "space-between" },
|
|
304
|
+
h("span", { style: { ...styles.cardTitle, display: "flex", alignItems: "center", gap: "6px" } },
|
|
305
|
+
h("span", { style: { ...styles.badge, ...(connected ? styles.badgeOk : styles.badgeBad) } }, "●"),
|
|
306
|
+
t("logsTitle")),
|
|
307
|
+
h("div", { style: styles.row },
|
|
308
|
+
h("label", { style: { display: "flex", alignItems: "center", gap: "6px", fontSize: "12px" } },
|
|
309
|
+
h("input", { type: "checkbox", checked: autoScroll === true, onChange: (e) => setAutoScroll(e.target.checked) }),
|
|
310
|
+
t("logsAutoScroll")),
|
|
311
|
+
h("button", { style: styles.button, title: t("logsKeepAliveTitle"), onClick: () => setRetry((r) => r + 1) }, t("logsKeepAlive"))),
|
|
312
|
+
),
|
|
313
|
+
h("div", { style: { maxHeight: 360, overflow: "auto", background: "var(--dsw-alias-bg-secondary, #f6f8fa)", borderRadius: 6, padding: "6px 8px", fontFamily: "monospace", fontSize: "12px", whiteSpace: "pre-wrap", wordBreak: "break-word" } },
|
|
314
|
+
h("div", { ref: logEl }, lines.map((l, i) => h("div", { key: i, style: { color: l.isError ? "#d1242f" : "inherit", opacity: l.isError ? 0.9 : 1 } }, l.text)))),
|
|
315
|
+
h("p", { style: styles.muted }, t("logLines", { count: lines.length })),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
199
319
|
function LemonadeServerView(props) {
|
|
200
320
|
const api = props.api;
|
|
201
321
|
const t = props && typeof props.t === "function" ? props.t : fallbackT;
|
|
@@ -223,6 +343,10 @@ window.__ModuleLoader__.load({
|
|
|
223
343
|
const [aliasTarget, setAliasTarget] = useState("");
|
|
224
344
|
const [adminOpen, setAdminOpen] = useState(true);
|
|
225
345
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
346
|
+
const [selectedModels, setSelectedModels] = useState({});
|
|
347
|
+
const [batchRunning, setBatchRunning] = useState(undefined);
|
|
348
|
+
const [modal, setModal] = useState(undefined);
|
|
349
|
+
const [logsOpen, setLogsOpen] = useState(false);
|
|
226
350
|
|
|
227
351
|
const loadHealth = useCallback(async () => {
|
|
228
352
|
const res = await apiCall("health");
|
|
@@ -255,6 +379,13 @@ window.__ModuleLoader__.load({
|
|
|
255
379
|
setBusy(false);
|
|
256
380
|
}, [loadHealth, loadTelemetry, loadModels, loadDownloads, loadAliases]);
|
|
257
381
|
useEffect(() => { loadAll(); }, [loadAll]);
|
|
382
|
+
// Notices self-dismiss after 5s so a transient "Model loaded" message
|
|
383
|
+
// cannot linger in the pane once the view re-renders for other reasons.
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
if (notice === undefined) return undefined;
|
|
386
|
+
const timer = setTimeout(() => setNotice(undefined), 5000);
|
|
387
|
+
return () => clearTimeout(timer);
|
|
388
|
+
}, [notice]);
|
|
258
389
|
useEffect(() => {
|
|
259
390
|
if (!autoRefresh) return;
|
|
260
391
|
const timer = setInterval(() => { loadHealth(); loadTelemetry(); }, 10000);
|
|
@@ -282,6 +413,34 @@ window.__ModuleLoader__.load({
|
|
|
282
413
|
|
|
283
414
|
const loadedByModel = (id) => (health && Array.isArray(health.all_models_loaded) ? health.all_models_loaded : []).find((m) => m.model_name === id);
|
|
284
415
|
|
|
416
|
+
// Batch multi-select: selectedModels maps model id -> boolean. A batch
|
|
417
|
+
// action dispatches one POST per id through the host proxy and reports
|
|
418
|
+
// how many failed alongside the successes.
|
|
419
|
+
const selectedIds = (Array.isArray(models) ? models : []).filter((m) => (m && selectedModels[m.id] === true)).map((m) => m.id);
|
|
420
|
+
const toggleSelect = (id) => setSelectedModels((prev) => ({ ...prev, [id]: !prev[id] }));
|
|
421
|
+
const runBatch = async (op, label) => {
|
|
422
|
+
if (!selectedIds.length) return;
|
|
423
|
+
setBatchRunning({ op, total: selectedIds.length, done: 0, failed: 0 });
|
|
424
|
+
let failed = 0;
|
|
425
|
+
for (const id of selectedIds) {
|
|
426
|
+
const r = await apiCall(op, [], undefined, "POST", { models: [id] });
|
|
427
|
+
if (!r || !r.ok) failed += 1;
|
|
428
|
+
setBatchRunning((prev) => prev && { ...prev, done: prev.done + 1, failed });
|
|
429
|
+
}
|
|
430
|
+
const done = selectedIds.length - failed;
|
|
431
|
+
setBatchRunning(undefined);
|
|
432
|
+
setSelectedModels({});
|
|
433
|
+
setNotice(t("batchOk", { failed: failed }));
|
|
434
|
+
await loadAll();
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
// Custom confirm modal: replaces the native confirm(); the caller
|
|
438
|
+
// passes message/confirmLabel and an onConfirm callback.
|
|
439
|
+
const ask = (message, confirmLabel, onConfirm) => {
|
|
440
|
+
setModal({ message, confirmLabel, onConfirm });
|
|
441
|
+
};
|
|
442
|
+
const closeAsk = () => setModal(undefined);
|
|
443
|
+
|
|
285
444
|
// The tab lists every model the server advertises, optionally filtered
|
|
286
445
|
// to downloaded ones (checkbox in the block header, checked by default).
|
|
287
446
|
// Aliases are hidden: an alias is an entry whose name is in the alias
|
|
@@ -354,6 +513,7 @@ window.__ModuleLoader__.load({
|
|
|
354
513
|
el("span", { style: styles.muted }, health && health.version ? "v" + health.version : ""),
|
|
355
514
|
el("button", { style: styles.button, disabled: busy, onClick: () => loadAll() }, busy ? t("loading") : t("refresh")),
|
|
356
515
|
el("button", { style: styles.button, disabled: busy, onClick: () => setAutoRefresh((v) => !v) }, t(autoRefresh ? "autoOn" : "autoOff")),
|
|
516
|
+
el("button", { style: { ...styles.button, opacity: logsOpen ? 1 : 0.6 }, title: t("logsTooltip"), onClick: () => setLogsOpen((v) => !v) }, "◉ " + t("logsTitle")),
|
|
357
517
|
),
|
|
358
518
|
el("p", { style: styles.muted }, serverURL),
|
|
359
519
|
healthErr && !healthOk ? el("p", { style: styles.error },
|
|
@@ -386,6 +546,10 @@ window.__ModuleLoader__.load({
|
|
|
386
546
|
el("details", { style: { ...styles.card, marginTop: 0 }, open: modelsOpen, onToggle: (e) => setModelsOpen(e.target.open) },
|
|
387
547
|
el("summary", { style: { ...styles.cardTitle, cursor: "pointer" }, title: t("modelsTooltip") }, t("models") + (Array.isArray(visibleModels) ? " (" + visibleModels.length + ")" : "")),
|
|
388
548
|
el("div", { style: styles.row, justifyContent: "flex-end" },
|
|
549
|
+
selectedIds.length ? el("div", { style: { display: "flex", alignItems: "center", gap: "4px", fontSize: "12px", opacity: 0.75 } }, t("batchHeader") + " " + selectedIds.length) : null,
|
|
550
|
+
el("button", { style: { ...styles.button, opacity: selectedIds.length ? 1 : 0.5 }, disabled: busy || !selectedIds.length, onClick: () => runBatch("batchLoad", t("batchLoad")) }, busy && batchRunning && batchRunning.op === "batchLoad" ? t("batchProgress", { done: batchRunning.done, total: batchRunning.total }) : t("batchLoad")),
|
|
551
|
+
el("button", { style: { ...styles.button, opacity: selectedIds.length ? 1 : 0.5 }, disabled: busy || !selectedIds.length, onClick: () => runBatch("batchUnload", t("batchUnload")) }, busy && batchRunning && batchRunning.op === "batchUnload" ? t("batchProgress", { done: batchRunning.done, total: batchRunning.total }) : t("batchUnload")),
|
|
552
|
+
el("button", { style: { ...styles.buttonDanger, opacity: selectedIds.length ? 1 : 0.5 }, disabled: busy || !selectedIds.length, onClick: () => runBatch("batchDelete", t("batchDelete")) }, t("batchDelete")),
|
|
389
553
|
el("label", { style: { display: "flex", alignItems: "center", gap: "6px", fontSize: "13px" } },
|
|
390
554
|
el("input", { type: "checkbox", checked: onlyDownloaded === true, onChange: (e) => setOnlyDownloaded(e.target.checked) }),
|
|
391
555
|
t("onlyDownloaded"),
|
|
@@ -397,6 +561,9 @@ window.__ModuleLoader__.load({
|
|
|
397
561
|
Array.isArray(visibleModels) && visibleModels.length === 0 ? el("p", { style: styles.muted }, t("noModels")) : null,
|
|
398
562
|
Array.isArray(visibleModels) && visibleModels.length > 0 ? el("table", { style: styles.table },
|
|
399
563
|
el("thead", null, el("tr", null,
|
|
564
|
+
el("th", { style: { ...styles.th, width: 40 } },
|
|
565
|
+
(visibleModels.length > 0 && selectedIds.length === visibleModels.length) ? el("input", { type: "checkbox", checked: true, onChange: () => { const all = visibleModels.every((m) => selectedModels[m.id] === true); setSelectedModels(Object.fromEntries(visibleModels.map((m) => [m.id, !all]))); } }) : el("input", { type: "checkbox", onChange: () => { const all = visibleModels.every((m) => selectedModels[m.id] === true); setSelectedModels(Object.fromEntries(visibleModels.map((m) => [m.id, !all]))); } })
|
|
566
|
+
),
|
|
400
567
|
el("th", { style: styles.th }, t("thModel")),
|
|
401
568
|
el("th", { style: styles.th }, t("thRecipe")),
|
|
402
569
|
el("th", { style: styles.th }, t("thSize")),
|
|
@@ -405,7 +572,11 @@ window.__ModuleLoader__.load({
|
|
|
405
572
|
)),
|
|
406
573
|
el("tbody", null, visibleModels.map((m) => {
|
|
407
574
|
const loaded = loadedByModel(m.id);
|
|
408
|
-
|
|
575
|
+
const checked = selectedModels[m.id] === true;
|
|
576
|
+
return el("tr", { key: m.id, style: { background: checked ? "rgba(26,127,55,0.05)" : "transparent" } },
|
|
577
|
+
el("td", { style: styles.td },
|
|
578
|
+
el("input", { type: "checkbox", checked: checked, onChange: () => toggleSelect(m.id) }),
|
|
579
|
+
),
|
|
409
580
|
el("td", { style: styles.td },
|
|
410
581
|
el("span", null, m.id),
|
|
411
582
|
m.update_available ? el("span", { style: { ...styles.chip, borderColor: "#9a6700", color: "#9a6700" } }, t("updateBadge")) : null,
|
|
@@ -422,7 +593,7 @@ window.__ModuleLoader__.load({
|
|
|
422
593
|
: 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
594
|
: 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
595
|
el("button", { style: styles.button, disabled: busy, onClick: () => toggleFiles(m.id) }, t("files")),
|
|
425
|
-
|
|
596
|
+
el("button", { style: styles.buttonDanger, disabled: busy, onClick: () => ask(t("confirmDeleteModel", { model: m.id }), t("delete"), () => 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
597
|
),
|
|
427
598
|
filesById[m.id] !== undefined ? divFiles(filesById[m.id], t) : null,
|
|
428
599
|
),
|
|
@@ -452,7 +623,7 @@ window.__ModuleLoader__.load({
|
|
|
452
623
|
Array.isArray(aliases) && aliases.length > 0 ? el("ul", { style: { margin: "4px 0 0 0", padding: 0, listStyle: "none" } },
|
|
453
624
|
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
625
|
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: () =>
|
|
626
|
+
el("button", { style: styles.buttonDanger, disabled: busy, onClick: () => ask(t("confirmDeleteAlias", { alias: al.alias }), t("delete"), () => 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
627
|
))) : null,
|
|
457
628
|
),
|
|
458
629
|
|
|
@@ -466,6 +637,27 @@ window.__ModuleLoader__.load({
|
|
|
466
637
|
|
|
467
638
|
error !== undefined ? el("p", { style: styles.error }, String(error)) : null,
|
|
468
639
|
notice !== undefined ? el("p", { style: styles.success }, String(notice)) : null,
|
|
640
|
+
|
|
641
|
+
logsOpen ? el("div", { style: styles.card }, h(LogsStream, { t })) : null,
|
|
642
|
+
|
|
643
|
+
modal ? h(ConfirmationModal, {
|
|
644
|
+
message: modal.message,
|
|
645
|
+
confirmLabel: modal.confirmLabel,
|
|
646
|
+
onCancel: closeAsk,
|
|
647
|
+
onConfirm: () => { const onConfirm = modal.onConfirm; closeAsk(); if (onConfirm) onConfirm(); },
|
|
648
|
+
}) : null,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Custom confirm modal rendered in place of the native confirm(). */
|
|
653
|
+
function ConfirmationModal(props) {
|
|
654
|
+
const { message, onConfirm, onCancel, confirmLabel } = props;
|
|
655
|
+
return h("div", { style: { position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 9999, fontFamily: "var(--dsw-font-family, sans-serif)" } },
|
|
656
|
+
h("div", { style: { background: "var(--dsw-alias-bg, #fff)", borderRadius: "10px", padding: "18px 20px", minWidth: 260, maxWidth: 420, boxShadow: "0 8px 30px rgba(0,0,0,0.18)" } },
|
|
657
|
+
h("div", { style: { fontSize: "15px", lineHeight: 1.5, marginBottom: "16px" } }, message),
|
|
658
|
+
h("div", { style: { display: "flex", justifyContent: "flex-end", gap: "8px" } },
|
|
659
|
+
h("button", { style: { padding: "6px 14px", fontSize: "13px", borderRadius: "6px", border: "1px solid var(--dsw-alias-border-l2, #d0d7de)", background: "#fff", cursor: "pointer" }, onClick: onCancel }, t("modalClose")),
|
|
660
|
+
h("button", { style: { padding: "6px 14px", fontSize: "13px", borderRadius: "6px", border: "none", background: "#1a7f37", color: "#fff", cursor: "pointer" }, onClick: onConfirm }, confirmLabel || t("modalConfirm")))),
|
|
469
661
|
);
|
|
470
662
|
}
|
|
471
663
|
|
package/lib/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
|
|
|
4
4
|
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
5
5
|
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout';
|
|
6
6
|
import z from '@deepseek-ai/schemastery';
|
|
7
|
-
import { DEFAULT_BASE_URL, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, LemonadeAdapter, discoverModels, } from './adapter.js';
|
|
7
|
+
import { DEFAULT_BASE_URL, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_LISTING_TIMEOUT_MS, LemonadeAdapter, discoverModels, } from './adapter.js';
|
|
8
8
|
import { API_ROUTE, createLemonadeApiHandler } from './server-api.js';
|
|
9
9
|
/** Short plugin name used in logs and configuration surfaces. */
|
|
10
10
|
export const name = 'llm-lemonade';
|
|
@@ -42,6 +42,7 @@ export const Config = z.object({
|
|
|
42
42
|
maxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS),
|
|
43
43
|
models: z.array(catalogModel).default([]),
|
|
44
44
|
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
|
45
|
+
listingTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_LISTING_TIMEOUT_MS),
|
|
45
46
|
retryPolicy: RetryPolicySchema,
|
|
46
47
|
});
|
|
47
48
|
/** Validate and detach the advisory model catalog. */
|
|
@@ -112,6 +113,10 @@ export function resolveAdapterOptions(config, environment) {
|
|
|
112
113
|
if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
|
|
113
114
|
throw new Error(`llm-lemonade: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
114
115
|
}
|
|
116
|
+
const listingTimeoutMs = config.listingTimeoutMs ?? DEFAULT_LISTING_TIMEOUT_MS;
|
|
117
|
+
if (!Number.isFinite(listingTimeoutMs) || listingTimeoutMs <= 0 || listingTimeoutMs > MAX_TIMER_DELAY_MS) {
|
|
118
|
+
throw new Error(`llm-lemonade: listingTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
119
|
+
}
|
|
115
120
|
const rawBase = config.baseURL ?? environment?.get(BASE_URL_ENV)?.value ?? DEFAULT_BASE_URL;
|
|
116
121
|
return {
|
|
117
122
|
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
|
|
@@ -122,27 +127,43 @@ export function resolveAdapterOptions(config, environment) {
|
|
|
122
127
|
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
123
128
|
models: resolveModels(config.models),
|
|
124
129
|
streamIdleTimeoutMs,
|
|
130
|
+
listingTimeoutMs,
|
|
125
131
|
retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-lemonade: retryPolicy'),
|
|
126
132
|
};
|
|
127
133
|
}
|
|
128
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* Resolve one credential reference through the credentials seam, falling back
|
|
136
|
+
* to the launch environment. Never returns a blank value; `undefined` when the
|
|
137
|
+
* key is unconfigured. Shared by the adapter bearer-token resolver (which may
|
|
138
|
+
* additionally enforce `requireAuth`) and by the host proxy key resolver.
|
|
139
|
+
*/
|
|
140
|
+
async function resolveCredential(ctx, ref) {
|
|
141
|
+
let value;
|
|
142
|
+
const credentials = ctx.get('credentials');
|
|
143
|
+
if (credentials !== undefined)
|
|
144
|
+
value = (await credentials.resolve(ref))?.value;
|
|
145
|
+
if (value === undefined)
|
|
146
|
+
value = launchEnvironmentOf(ctx).get(ref)?.value;
|
|
147
|
+
if (value === undefined || value.length === 0)
|
|
148
|
+
return undefined;
|
|
149
|
+
return assertUsableApiKey(value, 'llm-lemonade', String(ref));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Resolve the optional bearer token, enforcing `requireAuth` when configured.
|
|
153
|
+
* The credentials-or-env resolution itself is delegated to resolveCredential.
|
|
154
|
+
*/
|
|
129
155
|
function makeResolveApiKey(ctx, options) {
|
|
130
156
|
return async () => {
|
|
131
157
|
const connection = options();
|
|
132
158
|
const ref = connection.apiKeyEnv;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (credentials !== undefined)
|
|
136
|
-
value = (await credentials.resolve(ref))?.value;
|
|
137
|
-
if (value === undefined)
|
|
138
|
-
value = launchEnvironmentOf(ctx).get(ref)?.value;
|
|
139
|
-
if (value === undefined || value.length === 0) {
|
|
159
|
+
const value = await resolveCredential(ctx, ref);
|
|
160
|
+
if (value === undefined) {
|
|
140
161
|
if (connection.requireAuth) {
|
|
141
162
|
throw new LlmError(`llm-lemonade: no API key for provider route "${PROVIDER}"; store ${String(ref)} through the credentials service (the web Models page writes it), or export ${String(ref)} in the launching environment`, 'MISSING_CREDENTIAL');
|
|
142
163
|
}
|
|
143
164
|
return undefined;
|
|
144
165
|
}
|
|
145
|
-
return
|
|
166
|
+
return value;
|
|
146
167
|
};
|
|
147
168
|
}
|
|
148
169
|
/**
|
|
@@ -178,6 +199,7 @@ export function apply(ctx, config) {
|
|
|
178
199
|
options,
|
|
179
200
|
resolveApiKey,
|
|
180
201
|
resolveAttachments: () => ctx.get('attachments'),
|
|
202
|
+
logger: () => ctx.logger,
|
|
181
203
|
});
|
|
182
204
|
ctx.llm.registerConfigurableProviders([
|
|
183
205
|
{ provider: PROVIDER, displayName: 'Lemonade', settingsNs: NS, settingsPath: [] },
|
|
@@ -211,17 +233,7 @@ export function apply(ctx, config) {
|
|
|
211
233
|
// Lemonade-specific API proxy: browser client half calls these routes
|
|
212
234
|
// same-origin; the keys are resolved host-side and never reach the browser.
|
|
213
235
|
// Per-endpoint key selection (regular vs admin) lives in server-api.ts.
|
|
214
|
-
const resolveKey =
|
|
215
|
-
let value;
|
|
216
|
-
const credentials = ctx.get('credentials');
|
|
217
|
-
if (credentials !== undefined)
|
|
218
|
-
value = (await credentials.resolve(ref))?.value;
|
|
219
|
-
if (value === undefined)
|
|
220
|
-
value = launchEnvironmentOf(ctx).get(ref)?.value;
|
|
221
|
-
if (value === undefined || value.length === 0)
|
|
222
|
-
return undefined;
|
|
223
|
-
return assertUsableApiKey(value, 'llm-lemonade', String(ref));
|
|
224
|
-
};
|
|
236
|
+
const resolveKey = (ref) => resolveCredential(ctx, ref);
|
|
225
237
|
const apiCfg = {
|
|
226
238
|
baseURL: () => options().baseURL,
|
|
227
239
|
requireAuth: () => options().requireAuth,
|