mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
feat(custom-model): remove loading-banner countdown, add manual Cancel
Replaces the size-scaled expected-time estimate + matching auto-timeout with a generic hardware/model-size disclaimer and a user-driven Cancel button, per explicit request. Real load time depends on hardware this feature has no way to know (VRAM, storage speed, GPU contention), so the old estimate/timeout was a guess dressed up as a fact — worse, one that could kill a genuinely slow load partway through on slower hardware. - _watchLlamaSwapLoading (session-ui.js): dropped maxWaitMs/deadline entirely — polls indefinitely until ready or cancelled, no automatic give-up. Message is now "Loading <model> (<size>) on <endpoint> — this can take a while depending on your hardware and the model size.", with the real llama.cpp log line still on its own second line. Removed _MODEL_LOAD_TIME_MATRIX/_estimateModelLoad/ _formatRemaining (dead code once the countdown is gone) — _lookupModelSizeGB is kept, the GB figure still shows. - _showCenterStatus (panels-ui.js) gains opts.onCancel: renders a real "Cancel" button (distinct from the error-type "×" close button, since Cancel has a real consequence) that calls it on click. Caller owns what cancelling actually means, same split as the swap-confirm modal's promise-resolving buttons. - Cancelling dismisses the banner, shows an info toast (not an error — this was deliberate), and closes the session, mirroring what the old timeout used to do automatically but now on the user's own call. - New .center-status-cancel CSS (bordered pill button, distinct from the plain "×" close glyph). Test changes: removed the now-invalid timeout-auto-close/estimate tests, added cancel-flow tests (dismiss/toast-type/session-close, never-closes-with-no-sessionId, unbounded-polling), and real-DOM tests for the new Cancel button (bootAppWithRealCenterStatus, evaluating panels-ui.js instead of stubbing _showCenterStatus, since this button is worth verifying for real rather than just through the stub every other test in the file uses). Typecheck/lint/frontend-syntax clean; full suite shows no new regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
2d3fc65758
commit
db9729e1fc
@@ -23,4 +23,6 @@ Two more, from actually clicking through the swap-confirm and context-warning di
|
|||||||
|
|
||||||
Remote (SSH) and Docker sessions are refused for now (400) — their restart reattaches the durable remote/in-container tmux rather than relaunching the agent.
|
Remote (SSH) and Docker sessions are refused for now (400) — their restart reattaches the durable remote/in-container tmux rather than relaunching the agent.
|
||||||
|
|
||||||
|
- **The loading banner's countdown is gone, replaced by a generic disclaimer and a Cancel button.** Its size-scaled expected-time estimate and matching auto-timeout were both a guess dressed up as a fact — real load time depends on hardware this feature has no way to know, and a fixed number could kill a genuinely slow load partway through. The banner now says "this can take a while depending on your hardware and the model size", polls indefinitely, and carries a **Cancel** button that ends the wait and closes the session on the user's own call rather than a guessed deadline.
|
||||||
|
|
||||||
**One more, from watching it launch live: opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP now launch directly on the endpoint, with no restart at all.** Picking one of these seven from the Run-menu picker used to launch natively first, wait for it to settle, then restart it in place with the endpoint applied — a deliberate two-step design, but visibly a native boot immediately followed by a second one, worst on a CLI whose TUI fully reinitializes on a restart (confirmed live on Codex). `POST /api/quick-start` now accepts a `customModel` field and computes the same injection _before_ the session exists, launching straight onto the endpoint the first time — no visible relaunch, and it also runs the same llama-swap conflict check (warns before unloading a model another live session is using) at create time. Claude still uses the original launch-then-restart path for now (its own `--resume`-based restart is far less jarring, and `runClaude()`'s multi-tab and docker-config-drift-retry logic make folding it into the one-shot path separate work).
|
**One more, from watching it launch live: opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP now launch directly on the endpoint, with no restart at all.** Picking one of these seven from the Run-menu picker used to launch natively first, wait for it to settle, then restart it in place with the endpoint applied — a deliberate two-step design, but visibly a native boot immediately followed by a second one, worst on a CLI whose TUI fully reinitializes on a restart (confirmed live on Codex). `POST /api/quick-start` now accepts a `customModel` field and computes the same injection _before_ the session exists, launching straight onto the endpoint the first time — no visible relaunch, and it also runs the same llama-swap conflict check (warns before unloading a model another live session is using) at create time. Claude still uses the original launch-then-restart path for now (its own `--resume`-based restart is far less jarring, and `runClaude()`'s multi-tab and docker-config-drift-retry logic make folding it into the one-shot path separate work).
|
||||||
|
|||||||
@@ -103,22 +103,23 @@ into `modelSizesGB` — unlike context length, this needs no `/props` probe
|
|||||||
(the figure is right there in the `/v1/models` response) and so is populated
|
(the figure is right there in the `/v1/models` response) and so is populated
|
||||||
for every model regardless of loaded state. A hand-configured profile's own
|
for every model regardless of loaded state. A hand-configured profile's own
|
||||||
description has no such figure and correctly gets no entry, never a guess.
|
description has no such figure and correctly gets no entry, never a guess.
|
||||||
Used only to label the Run-menu picker's "loading model" banner with a
|
Used only to label the Run-menu picker's "loading model" banner (e.g.
|
||||||
rough, UNMEASURED expected-time estimate (`_estimateModelLoad()` in
|
"Loading qwen3.8-27b-ud-q4_k_xl (16.4 GB) on llama-swap..."); never anything
|
||||||
session-ui.js, based on typical local NVMe/SSD throughput — not benchmarked
|
a server-side check relies on.
|
||||||
against any real endpoint's actual hardware/storage) and to scale that same
|
|
||||||
banner's own give-up timeout for a very large model; never anything a
|
|
||||||
server-side check relies on.
|
|
||||||
|
|
||||||
**The loading banner shows a live countdown against that same timeout, and
|
**The loading banner is unbounded by design, and says so — no countdown, no
|
||||||
treats a real timeout as a failure, not a shrug.** It checks llama-swap's
|
automatic give-up.** An earlier version scaled an expected-time estimate and
|
||||||
own `/running` every second (`GET /api/model-endpoints/:id/running-status`)
|
a timeout off the model's file size and auto-closed the session once that
|
||||||
and counts down against the size-scaled (or flat 5-minute) timeout live; if
|
elapsed, but a real load's actual duration depends on hardware this feature
|
||||||
the countdown reaches zero with the target model still not ready, the
|
has no way to know (VRAM, storage speed, whatever else is contending for the
|
||||||
banner turns into a sticky error naming the llama-swap server's own logs as
|
GPU) — any fixed number was a guess dressed up as a fact, and a model that
|
||||||
where to look, and the session the load was for is closed automatically —
|
genuinely takes 10+ minutes on slower hardware would just get killed
|
||||||
a console left open and pointed at a model that never finished loading is
|
mid-load by its own display. The banner now says outright that it can take a
|
||||||
worse than no console at all.
|
while depending on hardware and model size, polls
|
||||||
|
`GET /api/model-endpoints/:id/running-status` every second for as long as it
|
||||||
|
takes, and carries a **Cancel** button (rendered on the banner itself) that
|
||||||
|
ends the wait and closes the session the load was for — the user's own call
|
||||||
|
on when it's taking too long, not a fixed number baked into the client.
|
||||||
|
|
||||||
**The banner's second line is the real backend log line, not a guess.**
|
**The banner's second line is the real backend log line, not a guess.**
|
||||||
llama-swap's `GET /api/events` SSE stream carries the actual `llama-server`
|
llama-swap's `GET /api/events` SSE stream carries the actual `llama-server`
|
||||||
@@ -126,22 +127,22 @@ process's own stdout — `load_model: loading model '<path>'`,
|
|||||||
`llama_server: model loaded`, tokenizer warnings, all of it — tagged
|
`llama_server: model loaded`, tokenizer warnings, all of it — tagged
|
||||||
`source: "upstream"`, distinct from llama-swap's own `source: "proxy"`
|
`source: "upstream"`, distinct from llama-swap's own `source: "proxy"`
|
||||||
request-access lines. `running-status`'s response now includes `logLine`
|
request-access lines. `running-status`'s response now includes `logLine`
|
||||||
(via `getLatestLlamaSwapLogLine`), and the banner shows it under the
|
(via `getLatestLlamaSwapLogLine`), and the banner shows it on its own line
|
||||||
countdown, e.g. "llama.cpp: load_model: loading model '...'" — confirmed
|
under the disclaimer, e.g. "llama.cpp: load_model: loading model '...'" —
|
||||||
live end-to-end through a real forced swap, sequentially showing the model
|
confirmed live end-to-end through a real forced swap, sequentially showing
|
||||||
path, a tokenizer warning, then staying on whatever llama.cpp last printed
|
the model path, a tokenizer warning, then staying on whatever llama.cpp last
|
||||||
once the load goes quiet (never cleared back to blank). ⚠️ **`GET /logs`
|
printed once the load goes quiet (never cleared back to blank). ⚠️
|
||||||
— the endpoint this feature's own first cut was built against — turns out
|
**`GET /logs` — the endpoint this feature's own first cut was built
|
||||||
to carry ONLY llama-swap's own proxy request-access log.** Confirmed live
|
against — turns out to carry ONLY llama-swap's own proxy request-access
|
||||||
it never showed a single backend line, even seconds after a real, verified
|
log.** Confirmed live it never showed a single backend line, even seconds
|
||||||
model swap; `/api/events`'s `logData` frames are the only source that
|
after a real, verified model swap; `/api/events`'s `logData` frames are the
|
||||||
actually has it, and its own `source` field (`upstream` vs `proxy`) is
|
only source that actually has it, and its own `source` field (`upstream` vs
|
||||||
what `getLatestLlamaSwapLogLine` filters on. One `/api/events` connection
|
`proxy`) is what `getLatestLlamaSwapLogLine` filters on. One `/api/events`
|
||||||
is held open per endpoint and reused across every session watching a load
|
connection is held open per endpoint and reused across every session
|
||||||
on it (confirmed live to stay open indefinitely, unlike `/logs`, which
|
watching a load on it (confirmed live to stay open indefinitely, unlike
|
||||||
closes after a fixed ~100KB), idle-closed after 30s of nobody polling it
|
`/logs`, which closes after a fixed ~100KB), idle-closed after 30s of nobody
|
||||||
(`pruneIdleLlamaSwapLogTails`, same 20s sweep as the swap-displacement
|
polling it (`pruneIdleLlamaSwapLogTails`, same 20s sweep as the
|
||||||
check below).
|
swap-displacement check below).
|
||||||
|
|
||||||
`defaultModelId` names which discovered model the picker pre-marks for that
|
`defaultModelId` names which discovered model the picker pre-marks for that
|
||||||
endpoint — the settings panel's Edit form exposes it as a select populated
|
endpoint — the settings panel's Edit form exposes it as a select populated
|
||||||
|
|||||||
@@ -80,14 +80,15 @@ just applying a selection never reached llama-swap's own logs at all until somet
|
|||||||
it to load. Picking an entry now also sends the smallest real request that will trigger
|
it to load. Picking an entry now also sends the smallest real request that will trigger
|
||||||
that load, in the background, the moment the target model isn't already loaded and ready.
|
that load, in the background, the moment the target model isn't already loaded and ready.
|
||||||
|
|
||||||
**The centred loading banner shows a live countdown, and a real timeout is an error, not a
|
**The centred loading banner has no countdown and no automatic timeout — it waits as long as
|
||||||
shrug.** When it knows the model's discovered file size (its GB figure, when llama-swap
|
it takes, and tells you so.** When it knows the model's discovered file size (its GB figure,
|
||||||
states one), it shows both a rough expected-time estimate and a live countdown against it —
|
when llama-swap states one) it's shown too, e.g. "Loading qwen3.8-27b (16.4 GB) on
|
||||||
e.g. "Loading qwen3.8-27b (16.4 GB, typically ~1–3 min) on llama-swap — 47s remaining". If
|
llama-swap — this can take a while depending on your hardware and the model size." An
|
||||||
the countdown reaches zero and the model still isn't ready, the banner turns into a sticky
|
earlier version tried to estimate and enforce a time limit, but real load time depends on
|
||||||
error telling you to check the llama-swap server's own logs, and **the session that load was
|
hardware this feature has no way to know, so a fixed number was always a guess — worse, one
|
||||||
for is closed automatically** — a console left open and pointed at a model that never
|
that could kill a genuinely slow load partway through. If it really is taking too long, a
|
||||||
finished loading would just be confusing to leave sitting there.
|
**Cancel** button right on the banner ends the wait and **closes the session that load was
|
||||||
|
for**, on your own call rather than a guessed deadline.
|
||||||
|
|
||||||
**The banner also shows a real, live second line of what llama.cpp itself is doing** — not
|
**The banner also shows a real, live second line of what llama.cpp itself is doing** — not
|
||||||
a made-up progress phase, the actual next line the `llama-server` process printed, e.g.
|
a made-up progress phase, the actual next line the `llama-server` process printed, e.g.
|
||||||
|
|||||||
@@ -5567,9 +5567,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* close button, since a sticky error the user cannot dismiss would just sit there). The
|
* close button, since a sticky error the user cannot dismiss would just sit there). The
|
||||||
* DOM is rebuilt fresh each call rather than patched, since which children exist differs
|
* DOM is rebuilt fresh each call rather than patched, since which children exist differs
|
||||||
* by type; `setMessage` still only ever touches the text node afterwards.
|
* by type; `setMessage` still only ever touches the text node afterwards.
|
||||||
|
*
|
||||||
|
* `opts.onCancel` — when given (any type, but in practice only 'info': an 'error' banner
|
||||||
|
* already has its own close button), renders a "Cancel" button that calls it on click.
|
||||||
|
* The callback owns everything that follows (dismissing the banner, stopping whatever
|
||||||
|
* loop this was showing progress for, closing a session it was for) — this helper only
|
||||||
|
* renders the button and wires the click, the same "caller decides what cancel means"
|
||||||
|
* split as `_confirmModelSwap`'s promise-resolving buttons.
|
||||||
*/
|
*/
|
||||||
_showCenterStatus(message, opts = {}) {
|
_showCenterStatus(message, opts = {}) {
|
||||||
const { type = 'info' } = opts;
|
const { type = 'info', onCancel } = opts;
|
||||||
let el = document.getElementById('customModelCenterStatus');
|
let el = document.getElementById('customModelCenterStatus');
|
||||||
if (!el) {
|
if (!el) {
|
||||||
el = document.createElement('div');
|
el = document.createElement('div');
|
||||||
@@ -5604,6 +5611,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
dismiss();
|
dismiss();
|
||||||
};
|
};
|
||||||
el.appendChild(closeBtn);
|
el.appendChild(closeBtn);
|
||||||
|
} else if (onCancel) {
|
||||||
|
const cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.className = 'center-status-cancel';
|
||||||
|
cancelBtn.textContent = 'Cancel';
|
||||||
|
cancelBtn.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
el.appendChild(cancelBtn);
|
||||||
}
|
}
|
||||||
el.hidden = false;
|
el.hidden = false;
|
||||||
requestAnimationFrame(() => el.classList.add('show'));
|
requestAnimationFrame(() => el.classList.add('show'));
|
||||||
|
|||||||
@@ -1009,40 +1009,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return typeof size === 'number' && Number.isFinite(size) && size > 0 ? size : undefined;
|
return typeof size === 'number' && Number.isFinite(size) && size > 0 ? size : undefined;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Rough, UNMEASURED load-time brackets by model file size, for the loading banner's text
|
|
||||||
* and as a size-scaled fallback timeout (larger models get longer before
|
|
||||||
* _watchLlamaSwapLoading gives up and warns). Sourced from typical local NVMe/SSD
|
|
||||||
* throughput for llama.cpp's mmap-and-warm sequence — NOT benchmarked against any real
|
|
||||||
* endpoint's actual hardware/storage (network storage, spinning disks, or a GPU with
|
|
||||||
* less VRAM than the model needs would all be meaningfully slower), so the label is an
|
|
||||||
* expectation-setter, never a guarantee. `maxGB` is the bracket's own upper bound
|
|
||||||
* (inclusive); brackets are checked in order, so list them smallest first.
|
|
||||||
*/
|
|
||||||
_MODEL_LOAD_TIME_MATRIX: [
|
|
||||||
{ maxGB: 2, label: '~5–15s', waitMs: 60000 },
|
|
||||||
{ maxGB: 8, label: '~15–45s', waitMs: 120000 },
|
|
||||||
{ maxGB: 16, label: '~30–90s', waitMs: 180000 },
|
|
||||||
{ maxGB: 32, label: '~1–3 min', waitMs: 300000 },
|
|
||||||
{ maxGB: 64, label: '~2–5 min', waitMs: 480000 },
|
|
||||||
{ maxGB: Infinity, label: '~5+ min', waitMs: 900000 },
|
|
||||||
],
|
|
||||||
|
|
||||||
/** `sizeGB` -> `{label, waitMs}` from `_MODEL_LOAD_TIME_MATRIX`, or `null` when `sizeGB`
|
|
||||||
* is unknown (no estimate is always safer than a fabricated one). */
|
|
||||||
_estimateModelLoad(sizeGB) {
|
|
||||||
if (typeof sizeGB !== 'number' || !Number.isFinite(sizeGB) || sizeGB <= 0) return null;
|
|
||||||
return this._MODEL_LOAD_TIME_MATRIX.find((bracket) => sizeGB <= bracket.maxGB) ?? null;
|
|
||||||
},
|
|
||||||
|
|
||||||
/** `ms` -> `"1m 08s remaining"` / `"8s remaining"`, for the loading banner's live countdown. */
|
|
||||||
_formatRemaining(ms) {
|
|
||||||
const totalSec = Math.max(0, Math.ceil(ms / 1000));
|
|
||||||
const mins = Math.floor(totalSec / 60);
|
|
||||||
const secs = totalSec % 60;
|
|
||||||
return mins > 0 ? `${mins}m ${String(secs).padStart(2, '0')}s remaining` : `${secs}s remaining`;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strips llama.cpp's own bootlog prefix (`<uptime> <I|W|E> <component> `, e.g.
|
* Strips llama.cpp's own bootlog prefix (`<uptime> <I|W|E> <component> `, e.g.
|
||||||
* `0.31.428.568 I srv llama_server: model loaded`) for display, leaving just
|
* `0.31.428.568 I srv llama_server: model loaded`) for display, leaving just
|
||||||
@@ -1058,20 +1024,20 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Polls llama-swap's own `/running` (via the read-only running-status route) until
|
* Polls llama-swap's own `/running` (via the read-only running-status route) until
|
||||||
* `modelId` reports `state: 'ready'`, showing a sticky banner with a live countdown the
|
* `modelId` reports `state: 'ready'`, showing a sticky banner the whole time so a slow
|
||||||
* whole time so a slow unload/reload (measured well over a minute for a large model)
|
* unload/reload (measured well over a minute for a large model) reads as "loading,
|
||||||
* reads as "loading, N seconds left", never as silence or a wrong answer from whatever
|
* still working on it", never as silence or a wrong answer from whatever was loaded
|
||||||
* was loaded before. Checks immediately (a fast load, or a re-apply onto an
|
* before. Checks immediately (a fast load, or a re-apply onto an already-ready model,
|
||||||
* already-ready model, shouldn't wait a full interval to say so), then every
|
* shouldn't wait a full interval to say so), then every `pollIntervalMs`.
|
||||||
* `pollIntervalMs`. Bounded at `maxWaitMs` — defaults to a rough, size-scaled estimate
|
|
||||||
* (`_estimateModelLoad`) when the model's discovered size is known, falling back to a
|
|
||||||
* flat 5 minutes when it isn't.
|
|
||||||
*
|
*
|
||||||
* If the countdown reaches zero with the model still not ready, this is a real failure,
|
* Deliberately UNBOUNDED — no estimate, no countdown, no automatic give-up. An earlier
|
||||||
* not a "keep waiting" — the banner turns into a sticky error naming the llama-swap
|
* version scaled a timeout off the model's discovered file size and auto-closed the
|
||||||
* server's own logs as where to look, and `sessionId` (the session this was launched
|
* session when it elapsed, but a real load's actual duration depends on hardware this
|
||||||
* for) is closed automatically: a console left open and pointed at a model that never
|
* feature has no way to know (VRAM, storage speed, what else is contending for the
|
||||||
* finished loading is worse than no console at all.
|
* GPU), so any fixed number was a guess dressed up as a fact — the banner now says so
|
||||||
|
* outright instead of pretending to a precision it doesn't have, and a Cancel button on
|
||||||
|
* the banner itself (`_showCenterStatus`'s `onCancel`) is how the user ends it if it's
|
||||||
|
* taking too long, closing `sessionId` the same way the old timeout used to.
|
||||||
*
|
*
|
||||||
* `_watchLlamaSwapGeneration` guards against two overlapping calls (a second launch
|
* `_watchLlamaSwapGeneration` guards against two overlapping calls (a second launch
|
||||||
* started before the first one's loop finished) clobbering each other's banner:
|
* started before the first one's loop finished) clobbering each other's banner:
|
||||||
@@ -1080,37 +1046,41 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* or overwrite the WRONG one, or close the WRONG session. Each call claims the counter
|
* or overwrite the WRONG one, or close the WRONG session. Each call claims the counter
|
||||||
* as its own "generation" and checks it still owns it before touching either.
|
* as its own "generation" and checks it still owns it before touching either.
|
||||||
*
|
*
|
||||||
* `pollIntervalMs`/`maxWaitMs` exist to let a test drive this in milliseconds instead of
|
* `pollIntervalMs` exists to let a test drive this in milliseconds instead of seconds —
|
||||||
* minutes — real callers never pass `maxWaitMs`, which is what keeps the size-scaled
|
* real callers never pass it.
|
||||||
* default live here rather than only in a test fixture.
|
|
||||||
*/
|
*/
|
||||||
async _watchLlamaSwapLoading(endpointId, modelId, sessionId, pollIntervalMs = 1000, maxWaitMs) {
|
async _watchLlamaSwapLoading(endpointId, modelId, sessionId, pollIntervalMs = 1000) {
|
||||||
const generation = (this._watchLlamaSwapGeneration = (this._watchLlamaSwapGeneration || 0) + 1);
|
const generation = (this._watchLlamaSwapGeneration = (this._watchLlamaSwapGeneration || 0) + 1);
|
||||||
const isCurrent = () => this._watchLlamaSwapGeneration === generation;
|
const isCurrent = () => this._watchLlamaSwapGeneration === generation;
|
||||||
const sizeGB = await this._lookupModelSizeGB(endpointId, modelId);
|
const sizeGB = await this._lookupModelSizeGB(endpointId, modelId);
|
||||||
const estimate = this._estimateModelLoad(sizeGB);
|
|
||||||
const effectiveMaxWaitMs = maxWaitMs ?? estimate?.waitMs ?? 300000;
|
|
||||||
if (!isCurrent()) return; // a newer launch already took over before the lookup even finished
|
if (!isCurrent()) return; // a newer launch already took over before the lookup even finished
|
||||||
const sizeSuffix = sizeGB
|
const sizeSuffix = sizeGB ? ` (${sizeGB.toFixed(1)} GB)` : '';
|
||||||
? ` (${sizeGB.toFixed(1)} GB${estimate ? `, typically ${estimate.label}` : ''})`
|
const baseMessage =
|
||||||
: '';
|
`Loading ${modelId}${sizeSuffix} on ${endpointId} — this can take a while depending on ` +
|
||||||
const baseMessage = `Loading ${modelId}${sizeSuffix} on ${endpointId} —`;
|
`your hardware and the model size.`;
|
||||||
// Second line, when llama-swap's /logs actually gives us one: the real backend
|
// Second line, when llama-swap's own event feed actually gives us one: the real
|
||||||
// llama-server process's own latest log line (load_model:/llama_server: ..., see
|
// backend llama-server process's own latest log line (load_model:/llama_server: ...,
|
||||||
// getLatestLlamaSwapLogLine) — a countdown alone says "something is happening,
|
// see getLatestLlamaSwapLogLine) — a bare "please wait" says nothing is broken, this
|
||||||
// trust me," this says what. Absent on the very first render (no poll has landed
|
// says what's actually happening. Absent on the very first render (no poll has
|
||||||
// yet) and whenever the endpoint doesn't expose /logs at all — never fabricated.
|
// landed yet) and whenever the endpoint doesn't expose it at all — never fabricated,
|
||||||
const buildMessage = (remainingMs, logLine) => {
|
// and never cleared back to blank once seen (stays on the last real thing llama.cpp
|
||||||
|
// said if a later poll comes back with nothing new).
|
||||||
|
const buildMessage = (logLine) => {
|
||||||
const line = this._formatLlamaLogLine(logLine);
|
const line = this._formatLlamaLogLine(logLine);
|
||||||
return `${baseMessage} ${this._formatRemaining(remainingMs)}` + (line ? `\nllama.cpp: ${line}` : '');
|
return baseMessage + (line ? `\nllama.cpp: ${line}` : '');
|
||||||
};
|
};
|
||||||
|
let cancelled = false;
|
||||||
// Prominent and screen-centred, not a corner toast — a real llama-swap model load can
|
// Prominent and screen-centred, not a corner toast — a real llama-swap model load can
|
||||||
// sit on screen for well over a minute, easy to mistake for nothing happening there.
|
// sit on screen for well over a minute, easy to mistake for nothing happening there.
|
||||||
const deadline = Date.now() + effectiveMaxWaitMs;
|
const toast = this._showCenterStatus(buildMessage(), {
|
||||||
const toast = this._showCenterStatus(buildMessage(deadline - Date.now()));
|
onCancel: () => {
|
||||||
while (Date.now() < deadline) {
|
cancelled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
while (!cancelled) {
|
||||||
const status = await this._apiJson(`/api/model-endpoints/${encodeURIComponent(endpointId)}/running-status`);
|
const status = await this._apiJson(`/api/model-endpoints/${encodeURIComponent(endpointId)}/running-status`);
|
||||||
if (!isCurrent()) return; // a newer launch took over the banner — this loop is done
|
if (!isCurrent()) return; // a newer launch took over the banner — this loop is done
|
||||||
|
if (cancelled) break;
|
||||||
if (!status) {
|
if (!status) {
|
||||||
// transient failure — keep waiting rather than giving up early
|
// transient failure — keep waiting rather than giving up early
|
||||||
} else if (!status.isLlamaSwap) {
|
} else if (!status.isLlamaSwap) {
|
||||||
@@ -1123,16 +1093,17 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.showToast(`${modelId} is ready`, 'success', { duration: 2500 });
|
this.showToast(`${modelId} is ready`, 'success', { duration: 2500 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isCurrent()) return;
|
if (!isCurrent() || cancelled) break;
|
||||||
toast?.setMessage(buildMessage(deadline - Date.now(), status?.logLine));
|
toast?.setMessage(buildMessage(status?.logLine));
|
||||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||||
}
|
}
|
||||||
if (!isCurrent()) return;
|
if (!isCurrent()) return;
|
||||||
this._showCenterStatus(
|
// Cancelled by the user, not a timeout — an ordinary info toast, not a scary error
|
||||||
`${modelId} did not finish loading on ${endpointId} within the expected time. ` +
|
// banner, since this was deliberate rather than something going wrong.
|
||||||
`Check the llama-swap server logs for details.` +
|
toast?.dismiss();
|
||||||
(sessionId ? ' The session has been closed.' : ''),
|
this.showToast(
|
||||||
{ type: 'error' }
|
`Cancelled loading ${modelId} on ${endpointId}` + (sessionId ? ' — the session has been closed.' : '.'),
|
||||||
|
'info'
|
||||||
);
|
);
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8636,6 +8636,28 @@ kbd {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The Cancel button on an 'info' banner (e.g. the model-loading banner) — a real button
|
||||||
|
rather than the bare "×" close glyph above, since "Cancel" is an action with a
|
||||||
|
consequence (the caller's onCancel closes a session), not a plain dismiss. */
|
||||||
|
.center-status-cancel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.75;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-status-cancel:hover {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: var(--text-muted, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
.toast-success { border-color: rgba(34, 197, 94, 0.4); }
|
.toast-success { border-color: rgba(34, 197, 94, 0.4); }
|
||||||
.toast-error { border-color: rgba(239, 68, 68, 0.4); }
|
.toast-error { border-color: rgba(239, 68, 68, 0.4); }
|
||||||
.toast-warning { border-color: rgba(234, 179, 8, 0.4); }
|
.toast-warning { border-color: rgba(234, 179, 8, 0.4); }
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
|
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
|
||||||
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
||||||
|
// Only for the real _showCenterStatus DOM tests below (`bootAppWithRealCenterStatus`) —
|
||||||
|
// every other test in this file stubs _showCenterStatus itself and has no need of it.
|
||||||
|
const PANELS_UI_JS = readFileSync(new URL('../src/web/public/panels-ui.js', import.meta.url), 'utf-8');
|
||||||
|
|
||||||
function resp(body: unknown, ok = true) {
|
function resp(body: unknown, ok = true) {
|
||||||
return { ok, json: async () => body };
|
return { ok, json: async () => body };
|
||||||
@@ -98,6 +101,28 @@ function bootApp(
|
|||||||
return { dom, win, app };
|
return { dom, win, app };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `bootApp`, but also evaluates panels-ui.js so `_showCenterStatus` is the REAL
|
||||||
|
* implementation rather than the plain stub `bootApp` installs — for the Cancel-button
|
||||||
|
* rendering tests, which need to see actual DOM the app would produce.
|
||||||
|
*/
|
||||||
|
function bootAppWithRealCenterStatus() {
|
||||||
|
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/', runScripts: 'dangerously' });
|
||||||
|
const win = dom.window as unknown as Window & typeof globalThis & { CodemanApp: new () => any };
|
||||||
|
// jsdom doesn't polyfill requestAnimationFrame, and _showCenterStatus calls it to add
|
||||||
|
// the 'show' class — run it synchronously, which is all a non-visual test needs.
|
||||||
|
(win as unknown as { requestAnimationFrame: (cb: () => void) => number }).requestAnimationFrame = (cb) => {
|
||||||
|
cb();
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
(win as unknown as { eval: (s: string) => void }).eval('window.CodemanApp = function CodemanApp() {};');
|
||||||
|
(win as unknown as { eval: (s: string) => void }).eval(CONSTANTS_JS);
|
||||||
|
(win as unknown as { eval: (s: string) => void }).eval(SESSION_UI_JS);
|
||||||
|
(win as unknown as { eval: (s: string) => void }).eval(PANELS_UI_JS);
|
||||||
|
const app = new win.CodemanApp();
|
||||||
|
return { win, app };
|
||||||
|
}
|
||||||
|
|
||||||
describe('Custom Model Endpoint Profiles: Run-menu picker generation', () => {
|
describe('Custom Model Endpoint Profiles: Run-menu picker generation', () => {
|
||||||
it('generates a real, clickable button per (capable CLI, endpoint) pair', async () => {
|
it('generates a real, clickable button per (capable CLI, endpoint) pair', async () => {
|
||||||
const { win, app } = bootApp({
|
const { win, app } = bootApp({
|
||||||
@@ -619,7 +644,7 @@ describe('Custom Model Endpoint Profiles: _watchLlamaSwapLoading polling', () =>
|
|||||||
};
|
};
|
||||||
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] });
|
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] });
|
||||||
|
|
||||||
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5, 200);
|
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5);
|
||||||
|
|
||||||
expect(bannerMessages[0]).toMatch(/loading qwen3/i);
|
expect(bannerMessages[0]).toMatch(/loading qwen3/i);
|
||||||
expect(dismissed).toContain(bannerMessages[0]);
|
expect(dismissed).toContain(bannerMessages[0]);
|
||||||
@@ -648,7 +673,7 @@ describe('Custom Model Endpoint Profiles: _watchLlamaSwapLoading polling', () =>
|
|||||||
return { isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] };
|
return { isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] };
|
||||||
};
|
};
|
||||||
|
|
||||||
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5, 200);
|
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5);
|
||||||
|
|
||||||
// First render (before any poll has landed) has no log line at all.
|
// First render (before any poll has landed) has no log line at all.
|
||||||
expect(bannerMessages[0]).not.toMatch(/llama\.cpp:/);
|
expect(bannerMessages[0]).not.toMatch(/llama\.cpp:/);
|
||||||
@@ -669,36 +694,70 @@ describe('Custom Model Endpoint Profiles: _watchLlamaSwapLoading polling', () =>
|
|||||||
app.showToast = () => {};
|
app.showToast = () => {};
|
||||||
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] });
|
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] });
|
||||||
|
|
||||||
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5, 200);
|
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5);
|
||||||
|
|
||||||
expect(bannerMessages.some((m) => m.includes('llama.cpp:'))).toBe(false);
|
expect(bannerMessages.some((m) => m.includes('llama.cpp:'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives up after the bounded wait, turns the banner into a sticky error, and closes the session', async () => {
|
it('is unbounded — never gives up on its own, even after many polls with no ready model', async () => {
|
||||||
|
// No countdown, no timeout: confirms the loop just keeps polling rather than
|
||||||
|
// eventually erroring out on its own after some fixed number of checks.
|
||||||
const { app } = bootApp({});
|
const { app } = bootApp({});
|
||||||
const banners: Array<{ message: string; opts: unknown }> = [];
|
app._showCenterStatus = () => ({ dismiss: () => {}, setMessage: () => {} });
|
||||||
app._showCenterStatus = (message: string, opts: unknown) => {
|
|
||||||
banners.push({ message, opts });
|
|
||||||
return { dismiss: () => {}, setMessage: () => {} };
|
|
||||||
};
|
|
||||||
app.showToast = () => {};
|
app.showToast = () => {};
|
||||||
|
let calls = 0;
|
||||||
|
app._apiJson = async (path: string) => {
|
||||||
|
if (path === '/api/model-endpoints') return null;
|
||||||
|
calls += 1;
|
||||||
|
if (calls >= 20) return { isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] };
|
||||||
|
return { isLlamaSwap: true, running: [{ model: 'something-else', state: 'ready' }] };
|
||||||
|
};
|
||||||
|
|
||||||
|
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 1);
|
||||||
|
|
||||||
|
expect(calls).toBe(20); // it really did keep polling past what the old bounded wait allowed
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Cancel on the banner dismisses it, shows an info toast (not an error), and closes the session', async () => {
|
||||||
|
const { app } = bootApp({});
|
||||||
|
let onCancel: (() => void) | undefined;
|
||||||
|
let dismissed = false;
|
||||||
|
app._showCenterStatus = (_message: string, opts?: { onCancel?: () => void }) => {
|
||||||
|
onCancel = opts?.onCancel;
|
||||||
|
return { dismiss: () => (dismissed = true), setMessage: () => {} };
|
||||||
|
};
|
||||||
|
const toastCalls: Array<{ message: string; type: string }> = [];
|
||||||
|
app.showToast = (message: string, type = 'info') => {
|
||||||
|
toastCalls.push({ message, type });
|
||||||
|
};
|
||||||
let closedSessionId: string | undefined;
|
let closedSessionId: string | undefined;
|
||||||
app.closeSession = async (id: string) => {
|
app.closeSession = async (id: string) => {
|
||||||
closedSessionId = id;
|
closedSessionId = id;
|
||||||
};
|
};
|
||||||
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'something-else', state: 'ready' }] });
|
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'something-else', state: 'ready' }] });
|
||||||
|
|
||||||
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5, 30);
|
const watch = app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5);
|
||||||
|
// Give the loop a couple of ticks to actually be polling, then cancel it — a real
|
||||||
|
// click happens whenever the user gets around to it, not on the very first render.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||||
|
expect(onCancel).toBeTypeOf('function');
|
||||||
|
onCancel!();
|
||||||
|
await watch;
|
||||||
|
|
||||||
const errorBanner = banners.find((b) => (b.opts as { type?: string } | undefined)?.type === 'error');
|
expect(dismissed).toBe(true);
|
||||||
expect(errorBanner?.message).toMatch(/did not finish loading/i);
|
const cancelToast = toastCalls.find((t) => /cancelled/i.test(t.message));
|
||||||
expect(errorBanner?.message).toMatch(/llama-swap server logs/i);
|
expect(cancelToast?.type).toBe('info'); // not 'error' — this was deliberate, not a failure
|
||||||
|
expect(cancelToast?.message).toMatch(/session has been closed/i);
|
||||||
expect(closedSessionId).toBe('sess-1');
|
expect(closedSessionId).toBe('sess-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never closes anything when no sessionId was given (a caller that has none to close)', async () => {
|
it('never closes anything when no sessionId was given (a caller that has none to close)', async () => {
|
||||||
const { app } = bootApp({});
|
const { app } = bootApp({});
|
||||||
app._showCenterStatus = () => ({ dismiss: () => {}, setMessage: () => {} });
|
let onCancel: (() => void) | undefined;
|
||||||
|
app._showCenterStatus = (_message: string, opts?: { onCancel?: () => void }) => {
|
||||||
|
onCancel = opts?.onCancel;
|
||||||
|
return { dismiss: () => {}, setMessage: () => {} };
|
||||||
|
};
|
||||||
app.showToast = () => {};
|
app.showToast = () => {};
|
||||||
let closeCalled = false;
|
let closeCalled = false;
|
||||||
app.closeSession = async () => {
|
app.closeSession = async () => {
|
||||||
@@ -706,7 +765,10 @@ describe('Custom Model Endpoint Profiles: _watchLlamaSwapLoading polling', () =>
|
|||||||
};
|
};
|
||||||
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'something-else', state: 'ready' }] });
|
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'something-else', state: 'ready' }] });
|
||||||
|
|
||||||
await app._watchLlamaSwapLoading('llama-box', 'qwen3', undefined, 5, 30);
|
const watch = app._watchLlamaSwapLoading('llama-box', 'qwen3', undefined, 5);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||||
|
onCancel!();
|
||||||
|
await watch;
|
||||||
|
|
||||||
expect(closeCalled).toBe(false);
|
expect(closeCalled).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -726,7 +788,7 @@ describe('Custom Model Endpoint Profiles: _watchLlamaSwapLoading polling', () =>
|
|||||||
};
|
};
|
||||||
app._apiJson = async () => ({ isLlamaSwap: false, running: [] });
|
app._apiJson = async () => ({ isLlamaSwap: false, running: [] });
|
||||||
|
|
||||||
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5, 200);
|
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5);
|
||||||
|
|
||||||
expect(bannerDismissed).toBe(true);
|
expect(bannerDismissed).toBe(true);
|
||||||
expect(toastCalls).toHaveLength(0); // no follow-up warning toast
|
expect(toastCalls).toHaveLength(0); // no follow-up warning toast
|
||||||
@@ -746,7 +808,7 @@ describe('Custom Model Endpoint Profiles: _watchLlamaSwapLoading polling', () =>
|
|||||||
return { isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] };
|
return { isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] };
|
||||||
};
|
};
|
||||||
|
|
||||||
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5, 200);
|
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 5);
|
||||||
|
|
||||||
expect(toastCalls.at(-1)).toMatch(/ready/i);
|
expect(toastCalls.at(-1)).toMatch(/ready/i);
|
||||||
});
|
});
|
||||||
@@ -766,7 +828,7 @@ describe('Custom Model Endpoint Profiles: _watchLlamaSwapLoading polling', () =>
|
|||||||
|
|
||||||
// A huge interval that would time the test out if the function actually waited for
|
// A huge interval that would time the test out if the function actually waited for
|
||||||
// it before the first check.
|
// it before the first check.
|
||||||
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 60000, 300000);
|
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 'sess-1', 60000);
|
||||||
|
|
||||||
expect(calls).toBe(1);
|
expect(calls).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -779,48 +841,34 @@ describe('Custom Model Endpoint Profiles: _watchLlamaSwapLoading polling', () =>
|
|||||||
setMessage: () => {},
|
setMessage: () => {},
|
||||||
});
|
});
|
||||||
app.showToast = () => {};
|
app.showToast = () => {};
|
||||||
// The FIRST call never sees its own target model ready, so left alone it would run all
|
// The FIRST call never sees its own target model ready — left alone (unbounded, no
|
||||||
// the way to its own timeout and (now) turn into an error + close its session — but no
|
// timeout) it would poll forever, but being superseded below must still make it stop
|
||||||
// sessionId is passed, so there is nothing for it to close even if it does get there.
|
// on its own very next isCurrent() check rather than needing a timeout to exit.
|
||||||
app._apiJson = async (path: string) => {
|
app._apiJson = async (path: string) => {
|
||||||
if (path === '/api/model-endpoints') return [];
|
if (path === '/api/model-endpoints') return [];
|
||||||
return { isLlamaSwap: true, running: [] };
|
return { isLlamaSwap: true, running: [] };
|
||||||
};
|
};
|
||||||
const firstCall = app._watchLlamaSwapLoading('llama-box', 'model-a', undefined, 5, 30);
|
const firstCall = app._watchLlamaSwapLoading('llama-box', 'model-a', undefined, 5);
|
||||||
|
|
||||||
// Second call, for a DIFFERENT model that IS ready right away, takes over the banner
|
// Second call, for a DIFFERENT model that IS ready right away, takes over the banner.
|
||||||
// before the first call's own bounded wait has elapsed.
|
|
||||||
app._apiJson = async (path: string) => {
|
app._apiJson = async (path: string) => {
|
||||||
if (path === '/api/model-endpoints') return [];
|
if (path === '/api/model-endpoints') return [];
|
||||||
return { isLlamaSwap: true, running: [{ model: 'model-b', state: 'ready' }] };
|
return { isLlamaSwap: true, running: [{ model: 'model-b', state: 'ready' }] };
|
||||||
};
|
};
|
||||||
await app._watchLlamaSwapLoading('llama-box', 'model-b', undefined, 5, 200);
|
await app._watchLlamaSwapLoading('llama-box', 'model-b', undefined, 5);
|
||||||
|
|
||||||
// Let the stale first call run out its own bounded wait and finish.
|
// Let the stale first call notice it's been superseded and return on its own.
|
||||||
await firstCall;
|
await firstCall;
|
||||||
|
|
||||||
// Whatever the first call did or didn't show along the way, its own eventual
|
// Whatever the first call did or didn't show along the way, being superseded must
|
||||||
// completion (a timeout, in this case) must never touch a banner state that belongs
|
// never touch a banner state that belongs to the newer, still-current call — exactly
|
||||||
// to the newer, still-current call — exactly one dismiss, for model-b, is the tell.
|
// one dismiss, for model-b, is the tell.
|
||||||
expect(dismissCalls).toHaveLength(1);
|
expect(dismissCalls).toHaveLength(1);
|
||||||
expect(dismissCalls[0]).toContain('model-b');
|
expect(dismissCalls[0]).toContain('model-b');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Custom Model Endpoint Profiles: model-size load-time estimate', () => {
|
describe('Custom Model Endpoint Profiles: model size lookup (no time estimate — see the unbounded-wait describe above)', () => {
|
||||||
it('_estimateModelLoad picks the smallest matching bracket, and returns null for an unknown size', () => {
|
|
||||||
const { app } = bootApp({});
|
|
||||||
expect(app._estimateModelLoad(1)).toMatchObject({ label: '~5–15s' });
|
|
||||||
expect(app._estimateModelLoad(2)).toMatchObject({ label: '~5–15s' }); // inclusive upper bound
|
|
||||||
expect(app._estimateModelLoad(2.1)).toMatchObject({ label: '~15–45s' });
|
|
||||||
expect(app._estimateModelLoad(16.35)).toMatchObject({ label: '~1–3 min' }); // just over the 16GB bracket
|
|
||||||
expect(app._estimateModelLoad(200)).toMatchObject({ label: '~5+ min' });
|
|
||||||
expect(app._estimateModelLoad(undefined)).toBeNull();
|
|
||||||
expect(app._estimateModelLoad(0)).toBeNull();
|
|
||||||
expect(app._estimateModelLoad(-5)).toBeNull();
|
|
||||||
expect(app._estimateModelLoad(NaN)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('_lookupModelSizeGB reads the size off the matching endpoint/model, ignoring one with no parseable size', async () => {
|
it('_lookupModelSizeGB reads the size off the matching endpoint/model, ignoring one with no parseable size', async () => {
|
||||||
const { app } = bootApp({});
|
const { app } = bootApp({});
|
||||||
app._apiJson = async (path: string) => {
|
app._apiJson = async (path: string) => {
|
||||||
@@ -848,7 +896,7 @@ describe('Custom Model Endpoint Profiles: model-size load-time estimate', () =>
|
|||||||
await expect(app._lookupModelSizeGB('llama-box', 'qwen3')).resolves.toBeUndefined();
|
await expect(app._lookupModelSizeGB('llama-box', 'qwen3')).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the loading banner includes the size and estimate when the size is known', async () => {
|
it('the loading banner includes the size, and the generic hardware/model-size disclaimer, when the size is known', async () => {
|
||||||
const { app } = bootApp({});
|
const { app } = bootApp({});
|
||||||
const bannerMessages: string[] = [];
|
const bannerMessages: string[] = [];
|
||||||
app._showCenterStatus = (message: string) => {
|
app._showCenterStatus = (message: string) => {
|
||||||
@@ -865,12 +913,12 @@ describe('Custom Model Endpoint Profiles: model-size load-time estimate', () =>
|
|||||||
|
|
||||||
await app._watchLlamaSwapLoading('llama-box', 'qwen3.8-27b-ud-q4_k_xl', undefined, 5);
|
await app._watchLlamaSwapLoading('llama-box', 'qwen3.8-27b-ud-q4_k_xl', undefined, 5);
|
||||||
|
|
||||||
expect(bannerMessages[0]).toMatch(
|
expect(bannerMessages[0]).toBe(
|
||||||
/^Loading qwen3\.8-27b-ud-q4_k_xl \(16\.4 GB, typically ~1–3 min\) on llama-box — .+ remaining$/
|
'Loading qwen3.8-27b-ud-q4_k_xl (16.4 GB) on llama-box — this can take a while depending on your hardware and the model size.'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the loading banner omits the size/estimate entirely when the size is unknown', async () => {
|
it('the loading banner omits the size but keeps the disclaimer when the size is unknown', async () => {
|
||||||
const { app } = bootApp({});
|
const { app } = bootApp({});
|
||||||
const bannerMessages: string[] = [];
|
const bannerMessages: string[] = [];
|
||||||
app._showCenterStatus = (message: string) => {
|
app._showCenterStatus = (message: string) => {
|
||||||
@@ -885,28 +933,41 @@ describe('Custom Model Endpoint Profiles: model-size load-time estimate', () =>
|
|||||||
|
|
||||||
await app._watchLlamaSwapLoading('llama-box', 'big', undefined, 5);
|
await app._watchLlamaSwapLoading('llama-box', 'big', undefined, 5);
|
||||||
|
|
||||||
expect(bannerMessages[0]).toMatch(/^Loading big on llama-box — .+ remaining$/);
|
expect(bannerMessages[0]).toBe(
|
||||||
|
'Loading big on llama-box — this can take a while depending on your hardware and the model size.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Custom Model Endpoint Profiles: _showCenterStatus Cancel button (real DOM, not the stub)', () => {
|
||||||
|
it('renders a real, clickable Cancel button when onCancel is given, and wires it up', () => {
|
||||||
|
const { win, app } = bootAppWithRealCenterStatus();
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
app._showCenterStatus('Loading qwen3 on llama-box…', { onCancel: () => (cancelled = true) });
|
||||||
|
|
||||||
|
const btn = win.document.querySelector('.center-status-cancel') as HTMLButtonElement | null;
|
||||||
|
expect(btn).not.toBeNull();
|
||||||
|
expect(btn!.textContent).toBe('Cancel');
|
||||||
|
btn!.onclick!(new (win as any).Event('click'));
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the size-scaled estimate as the default timeout when maxWaitMs is not passed', async () => {
|
it('renders no Cancel button at all when onCancel is not given', () => {
|
||||||
// A 200GB model estimates to the top "~5+ min" bracket (900000ms); a huge poll interval
|
const { win, app } = bootAppWithRealCenterStatus();
|
||||||
// would time the TEST out if the function only waited the flat, smaller previous
|
|
||||||
// default (300000ms) instead of the size-scaled one.
|
|
||||||
const { app } = bootApp({});
|
|
||||||
app._showCenterStatus = () => ({ dismiss: () => {}, setMessage: () => {} });
|
|
||||||
app.showToast = () => {};
|
|
||||||
let calls = 0;
|
|
||||||
app._apiJson = async (path: string) => {
|
|
||||||
if (path === '/api/model-endpoints') return [{ id: 'llama-box', modelSizesGB: { huge: 200 } }];
|
|
||||||
calls += 1;
|
|
||||||
if (calls < 3) return { isLlamaSwap: true, running: [] }; // not ready on the first couple of checks
|
|
||||||
return { isLlamaSwap: true, running: [{ model: 'huge', state: 'ready' }] };
|
|
||||||
};
|
|
||||||
|
|
||||||
// pollIntervalMs only — maxWaitMs omitted, so it must fall back to the size estimate.
|
app._showCenterStatus('Loading qwen3 on llama-box…');
|
||||||
await app._watchLlamaSwapLoading('llama-box', 'huge', undefined, 5);
|
|
||||||
|
|
||||||
expect(calls).toBe(3);
|
expect(win.document.querySelector('.center-status-cancel')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an 'error' banner keeps its own × close button rather than growing a redundant Cancel, even if onCancel is passed", () => {
|
||||||
|
const { win, app } = bootAppWithRealCenterStatus();
|
||||||
|
|
||||||
|
app._showCenterStatus('Something went wrong', { type: 'error', onCancel: () => {} });
|
||||||
|
|
||||||
|
expect(win.document.querySelector('.center-status-close')).not.toBeNull();
|
||||||
|
expect(win.document.querySelector('.center-status-cancel')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user