@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/lib/translate.js CHANGED
@@ -16,15 +16,51 @@
16
16
  */
17
17
  import { CallId, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm';
18
18
  import { EventSourceParserStream } from 'eventsource-parser/stream';
19
+ /**
20
+ * A `[DONE]` sentinel in the *middle* of a payload stream (i.e. not the final
21
+ * event) is malformed: clean consumers normally emit it only once, at EOF.
22
+ * This is a soft warning — the caller ignores it rather than aborting — so a
23
+ * briefly misbehaving server never silently kills a generation.
24
+ */
25
+ export const MID_STREAM_DONE_WARNING = 'lemonade-sse: mid-stream [DONE] detected; the stream may end prematurely';
26
+ /** Max consecutive malformed SSE payloads tolerated before aborting. */
27
+ const MAX_SKIP = 20;
19
28
  /** Parse an SSE byte stream into its `data` payloads. */
20
- export async function* parseSse(stream, onComment) {
29
+ export async function* parseSse(stream, onComment, onSkip) {
21
30
  const events = stream
22
31
  .pipeThrough(new TextDecoderStream())
23
32
  .pipeThrough(new EventSourceParserStream({ onComment }));
33
+ let consecutiveSkips = 0;
24
34
  for await (const { data } of events) {
35
+ // The `[DONE]` sentinel is not valid JSON, so special-case it before the
36
+ // JSON.parse path (which would otherwise treat it as a malformed payload
37
+ // and drop it). It is yielded verbatim and translate() decides, from the
38
+ // payloads that follow it, whether it landed mid-stream.
39
+ if (data === '[DONE]') {
40
+ yield data;
41
+ continue;
42
+ }
43
+ let parsed;
44
+ try {
45
+ parsed = data.length === 0 ? {} : JSON.parse(data);
46
+ }
47
+ catch {
48
+ consecutiveSkips += 1;
49
+ if (consecutiveSkips > MAX_SKIP) {
50
+ onSkip?.('malformed SSE payloads exhausted tolerance (' + consecutiveSkips + ')');
51
+ return;
52
+ }
53
+ onSkip?.('skipping malformed SSE payload');
54
+ continue;
55
+ }
56
+ consecutiveSkips = 0;
57
+ // A non-object payload (e.g. a bare string or number) carries no usable
58
+ // choices/usage; skip it rather than treating it as an empty object.
59
+ if (parsed === null || typeof parsed !== 'object') {
60
+ onSkip?.('skipping non-object SSE payload');
61
+ continue;
62
+ }
25
63
  yield data;
26
- if (data === '[DONE]')
27
- return;
28
64
  }
29
65
  }
30
66
  /**
@@ -73,10 +109,18 @@ function closeBlock(block) {
73
109
  /**
74
110
  * Consume SSE data payloads (optionally ending with `[DONE]`) and yield
75
111
  * harness StreamChunks. Malformed JSON payloads abort the stream with
76
- * `MALFORMED_RESPONSE`. A `stop` (or absent) finish with no opened blocks is a
77
- * degenerate provider completion and maps to an `EMPTY_RESPONSE` error finish.
112
+ * `MALFORMED_RESPONSE` parseSse already skips transiently malformed payloads
113
+ * with a threshold, so a payload reaching this point is genuinely corrupt. A
114
+ * `stop` (or absent) finish with no opened blocks is a degenerate provider
115
+ * completion and maps to an `EMPTY_RESPONSE` error finish.
116
+ *
117
+ * `[DONE]` is skipped (not terminal here): it flows through parseSse and is
118
+ * only treated as a soft warning when a *further* payload follows it — a clean
119
+ * terminal `[DONE]` ends the loop without warning. A mid-stream `[DONE]` (or
120
+ * content after the sentinel) logs a soft warning and the loop continues
121
+ * rather than crashing.
78
122
  */
79
- export async function* translate(payloads) {
123
+ export async function* translate(payloads, onSkip) {
80
124
  let nextIndex = 0;
81
125
  let textBlock;
82
126
  let reasoningBlock;
@@ -84,14 +128,30 @@ export async function* translate(payloads) {
84
128
  const order = [];
85
129
  let pendingFinish;
86
130
  let pendingUsage;
131
+ // True while a `[DONE]` sentinel has been seen without a following real
132
+ // payload: the next real payload (or a repeated sentinel) proves it was
133
+ // mid-stream. A clean terminal `[DONE]` simply leaves the loop ending here.
134
+ let doneSeen = false;
87
135
  function open(kind) {
88
136
  const block = { index: nextIndex++, kind, text: '' };
89
137
  order.push(block);
90
138
  return block;
91
139
  }
92
140
  for await (const payload of payloads) {
93
- if (payload === '[DONE]')
141
+ if (payload === '[DONE]') {
142
+ // The sentinel is terminal when it is the last event. Only warn when a
143
+ // prior sentinel was already seen AND a further payload follows it — a
144
+ // misbehaving server re-emitting the sentinel, or emitting content after
145
+ // it. A clean terminal `[DONE]` (loop simply ends after it) warns nothing.
146
+ if (doneSeen)
147
+ onSkip?.(MID_STREAM_DONE_WARNING);
148
+ doneSeen = true;
94
149
  continue;
150
+ }
151
+ if (doneSeen) {
152
+ onSkip?.(MID_STREAM_DONE_WARNING);
153
+ doneSeen = false;
154
+ }
95
155
  let chunk;
96
156
  try {
97
157
  chunk = JSON.parse(payload);
@@ -21,8 +21,8 @@ export declare const DEFAULT_CONTEXT_WINDOW = 32768;
21
21
  export declare const DEFAULT_MAX_TOKENS = 8192;
22
22
  /** Default maximum idle interval while an adapter stream read is outstanding. */
23
23
  export declare const DEFAULT_STREAM_IDLE_TIMEOUT_MS: number;
24
- /** Maximum time one live model-listing query may take. */
25
- export declare const LISTING_TIMEOUT_MS = 5000;
24
+ /** Default maximum time one live model-listing query may take. */
25
+ export declare const DEFAULT_LISTING_TIMEOUT_MS = 5000;
26
26
  /** One entry of the user-pinned advisory model catalog. */
27
27
  export interface LemonadeCatalogModel {
28
28
  id: string;
@@ -42,6 +42,7 @@ export interface LemonadeOptions {
42
42
  maxTokens: number;
43
43
  models: LemonadeCatalogModel[];
44
44
  streamIdleTimeoutMs: number;
45
+ listingTimeoutMs: number;
45
46
  retryPolicy: ResolvedRetryPolicy;
46
47
  }
47
48
  /** The adapter's dependency thunks, owned by the registering plugin. */
@@ -52,6 +53,10 @@ export interface LemonadeAdapterConfig {
52
53
  resolveApiKey(): Promise<string | undefined>;
53
54
  /** The attachment service, when one is mounted (needed to send images). */
54
55
  resolveAttachments(): AttachmentStore | undefined;
56
+ /** Optional sink for soft, non-fatal stream warnings (mid-stream `[DONE]`, skipped payloads). */
57
+ logger?: () => {
58
+ warn(...args: unknown[]): void;
59
+ };
55
60
  }
56
61
  /** One Lemonade model entry as read from `GET /v1/models`. */
57
62
  export interface LemonadeModelEntry {
@@ -65,6 +65,7 @@ export interface LemonadeResolvedConfig {
65
65
  maxTokens: number;
66
66
  models: LemonadeCatalogModel[];
67
67
  streamIdleTimeoutMs: number;
68
+ listingTimeoutMs: number;
68
69
  retryPolicy?: RetryPolicyConfig;
69
70
  }
70
71
  /** Raw composition entry: every field optional (schema defaults apply on resolution). */
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Depth-bounded JSON parser for proxied Lemonade request bodies.
3
+ *
4
+ * A hostile client can send a "JSON bomb": a tree whose width is small but
5
+ * whose depth is enormous. A naive `JSON.parse` walks such input with a stack
6
+ * proportional to the depth, which can blow the V8 stack. This parser walks it
7
+ * with an explicit recursion depth cap (`maxDepth`) and rejects deeper input
8
+ * with a {@link JsonParseError} carrying the byte offset of the offending
9
+ * token, so the caller can surface a precise message without the request ever
10
+ * reaching a downstream consumer.
11
+ *
12
+ * Only what the Lemonade proxy needs is supported: objects, arrays, strings
13
+ * (with escapes), numbers, and the `true`/`false`/`null` literals. Whitespace
14
+ * between tokens is skipped. Trailing characters after the value are rejected.
15
+ *
16
+ * @module dsh-lemonade-provider/json-parse
17
+ */
18
+ /** Error thrown by {@link parseJsonValue} on malformed input or depth overflow. */
19
+ export declare class JsonParseError extends Error {
20
+ /** Byte offset of the offending token (or where parsing ended). */
21
+ readonly position: number;
22
+ /** True when the cap on nesting depth was exceeded rather than the text being malformed. */
23
+ readonly isDepthOverflow: boolean;
24
+ constructor(message: string, position: number, isDepthOverflow?: boolean);
25
+ }
26
+ /** Parse one UTF-8 JSON value from `text`.
27
+ * @param text - the raw request body.
28
+ * @param options - parser options; only `maxDepth` is honoured today.
29
+ * @returns the parsed value (never `undefined`; use `parseJsonValue` for that).
30
+ * @throws {JsonParseError} when the text is not a single valid JSON value or exceeds `maxDepth`.
31
+ */
32
+ export declare function parseJsonValue(text: string, options?: {
33
+ maxDepth?: number;
34
+ }): unknown;
@@ -14,6 +14,10 @@ import type { CredentialRef } from '@deepseek-ai/dsh-credentials';
14
14
  export declare const API_ROUTE = "/dsh-lemonade/api";
15
15
  /** Maximum accepted request body and proxied response body in bytes. */
16
16
  export declare const MAX_BODY_BYTES = 1000000;
17
+ /** Maximum JSON nesting depth accepted in a proxied request body. */
18
+ export declare const MAX_JSON_DEPTH = 64;
19
+ /** Maximum path length after the route prefix (op + args). */
20
+ export declare const MAX_SEGMENTS = 5;
17
21
  /** Fetch timeout for proxied Lemonade calls. */
18
22
  export declare const API_TIMEOUT_MS = 10000;
19
23
  /** Host-side connection facts the proxy resolves per request. */
@@ -59,16 +63,6 @@ export declare function mapLemonadeStatus(status: number): string;
59
63
  * @param signal - optional caller cancellation.
60
64
  */
61
65
  export declare function serveLemonadeApi(cfg: LemonadeApiConfig, method: string, op: string, args: readonly string[], query: URLSearchParams, body: unknown, signal?: AbortSignal): Promise<LemonadeWireResult>;
62
- /**
63
- * Stream the Lemonade server logs (WS /logs/stream) as newline-delimited JSON
64
- * to the browser. The spec: the log WebSocket shares the Realtime Audio port,
65
- * discovered via /v1/health (websocket_port) — not the main HTTP port — then
66
- * `ws://<host>:<port>/logs/stream`, subscribe with `{ type: 'logs.subscribe',
67
- * after_seq: <int|null> }`, and the server answers `logs.snapshot` (up to
68
- * 5000 retained entries) then `logs.entry` lines. Messages are relayed as-is
69
- * (`{ type: 'logs.snapshot' | 'logs.entry' | 'error', ... }`); the response is
70
- * held open and closed when the browser disconnects.
71
- */
72
66
  /**
73
67
  * Build the node:http handler mounting the Lemonade-specific API proxy at the
74
68
  * /dsh-lemonade/api prefix route (ctx.webServer.register). Never throws out:
@@ -1,10 +1,25 @@
1
1
  import type { StreamChunk } from '@deepseek-ai/dsh-llm';
2
+ /**
3
+ * A `[DONE]` sentinel in the *middle* of a payload stream (i.e. not the final
4
+ * event) is malformed: clean consumers normally emit it only once, at EOF.
5
+ * This is a soft warning — the caller ignores it rather than aborting — so a
6
+ * briefly misbehaving server never silently kills a generation.
7
+ */
8
+ export declare const MID_STREAM_DONE_WARNING = "lemonade-sse: mid-stream [DONE] detected; the stream may end prematurely";
2
9
  /** Parse an SSE byte stream into its `data` payloads. */
3
- export declare function parseSse(stream: ReadableStream<Uint8Array>, onComment?: (comment: string) => void): AsyncGenerator<string>;
10
+ export declare function parseSse(stream: ReadableStream<Uint8Array>, onComment?: (comment: string) => void, onSkip?: (reason: string) => void): AsyncGenerator<string>;
4
11
  /**
5
12
  * Consume SSE data payloads (optionally ending with `[DONE]`) and yield
6
13
  * harness StreamChunks. Malformed JSON payloads abort the stream with
7
- * `MALFORMED_RESPONSE`. A `stop` (or absent) finish with no opened blocks is a
8
- * degenerate provider completion and maps to an `EMPTY_RESPONSE` error finish.
14
+ * `MALFORMED_RESPONSE` parseSse already skips transiently malformed payloads
15
+ * with a threshold, so a payload reaching this point is genuinely corrupt. A
16
+ * `stop` (or absent) finish with no opened blocks is a degenerate provider
17
+ * completion and maps to an `EMPTY_RESPONSE` error finish.
18
+ *
19
+ * `[DONE]` is skipped (not terminal here): it flows through parseSse and is
20
+ * only treated as a soft warning when a *further* payload follows it — a clean
21
+ * terminal `[DONE]` ends the loop without warning. A mid-stream `[DONE]` (or
22
+ * content after the sentinel) logs a soft warning and the loop continues
23
+ * rather than crashing.
9
24
  */
10
- export declare function translate(payloads: AsyncIterable<string>): AsyncGenerator<StreamChunk>;
25
+ export declare function translate(payloads: AsyncIterable<string>, onSkip?: (reason: string) => void): AsyncGenerator<StreamChunk>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cyrilmarin/dsh-lemonade",
3
3
  "description": "Lemonade Server (OpenAI-compatible) LLM provider plugin for the DeepSeek Harness",
4
- "version": "0.2.1",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -24,6 +24,10 @@
24
24
  "lib/client.js"
25
25
  ],
26
26
  "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/ziouf/dsh-lemonade-provider"
30
+ },
27
31
  "publishConfig": {
28
32
  "access": "public"
29
33
  },
@@ -84,7 +88,7 @@
84
88
  "scripts": {
85
89
  "build": "tsc -p tsconfig.json && node scripts/copy-client.mjs",
86
90
  "typecheck": "tsc -p tsconfig.json --noEmit",
87
- "test": "node test/adapter.test.mjs && node test/server-api.test.mjs && node test/client-bundle.test.mjs lib/client.js",
91
+ "test": "node test/adapter.test.mjs && node test/server-api.test.mjs && node test/json-parse.test.mjs && node test/translate.test.mjs && node test/client-bundle.test.mjs lib/client.js",
88
92
  "release": "node scripts/release.mjs",
89
93
  "release:dry": "node scripts/release.mjs --dry-run",
90
94
  "release:major": "node scripts/release.mjs --bump major",
package/src/adapter.ts CHANGED
@@ -42,8 +42,8 @@ export const DEFAULT_CONTEXT_WINDOW = 32768;
42
42
  export const DEFAULT_MAX_TOKENS = 8192;
43
43
  /** Default maximum idle interval while an adapter stream read is outstanding. */
44
44
  export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 5 * 60_000;
45
- /** Maximum time one live model-listing query may take. */
46
- export const LISTING_TIMEOUT_MS = 5_000;
45
+ /** Default maximum time one live model-listing query may take. */
46
+ export const DEFAULT_LISTING_TIMEOUT_MS = 5_000;
47
47
  const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT';
48
48
 
49
49
  /** One entry of the user-pinned advisory model catalog. */
@@ -66,6 +66,7 @@ export interface LemonadeOptions {
66
66
  maxTokens: number;
67
67
  models: LemonadeCatalogModel[];
68
68
  streamIdleTimeoutMs: number;
69
+ listingTimeoutMs: number;
69
70
  retryPolicy: ResolvedRetryPolicy;
70
71
  }
71
72
 
@@ -77,6 +78,8 @@ export interface LemonadeAdapterConfig {
77
78
  resolveApiKey(): Promise<string | undefined>;
78
79
  /** The attachment service, when one is mounted (needed to send images). */
79
80
  resolveAttachments(): AttachmentStore | undefined;
81
+ /** Optional sink for soft, non-fatal stream warnings (mid-stream `[DONE]`, skipped payloads). */
82
+ logger?: () => { warn(...args: unknown[]): void };
80
83
  }
81
84
 
82
85
  /** One Lemonade model entry as read from `GET /v1/models`. */
@@ -259,7 +262,7 @@ export class LemonadeAdapter extends LlmAdapter {
259
262
  // No configured selection: advertise whatever the server currently offers.
260
263
  try {
261
264
  const apiKey = await this.config.resolveApiKey();
262
- const entries = await fetchModelEntries(options.baseURL, apiKey, AbortSignal.timeout(LISTING_TIMEOUT_MS));
265
+ const entries = await fetchModelEntries(options.baseURL, apiKey, AbortSignal.timeout(options.listingTimeoutMs ?? DEFAULT_LISTING_TIMEOUT_MS));
263
266
  this.lastKnown = new Map(entries.map((entry) => [entry.id, entry]));
264
267
  return entries.map((entry) => modelInfo(provider, entry.id, entry));
265
268
  } catch {
@@ -376,6 +379,10 @@ export class LemonadeAdapter extends LlmAdapter {
376
379
  });
377
380
  }
378
381
  if (!response.body) throw new LlmError('Lemonade API returned no response body', 'EMPTY_RESPONSE');
379
- yield* translate(parseSse(response.body, onComment));
382
+ const warn = (reason: string): void => { this.config.logger?.().warn(reason); };
383
+ yield* translate(
384
+ parseSse(response.body, onComment, warn),
385
+ warn,
386
+ );
380
387
  }
381
388
  }
@@ -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
- const res = await fetch(url, init);
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
- return el("tr", { key: m.id },
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
- 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")),
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: () => { 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")),
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