mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
Merge pull request #757: feat(custom-model): mark the model a session is using in the endpoint model picker
This commit is contained in:
commit
f7dfe091c5
8 files changed
+383
-41
No files matched your search
@@ -627,7 +627,10 @@
|
||||
'Custom Endpoints': '自定义端点',
|
||||
'Choose a model': '选择模型',
|
||||
'Currently loaded': '当前已加载',
|
||||
'In use by a session': '会话正在使用',
|
||||
'Last used': '上次使用',
|
||||
'Nothing is marked: this endpoint did not report which model it has loaded, and none of your running sessions uses one. Pick the model to launch.':
|
||||
'未标记任何模型:此端点没有报告它已加载了哪个模型,你运行中的会话也没有在使用它。请选择要启动的模型。',
|
||||
'That endpoint no longer exists': '该端点已不存在',
|
||||
'No models discovered for this endpoint yet': '此端点尚未发现任何模型',
|
||||
// Model picker search box (index.html placeholder/aria-label and the no-matches line)
|
||||
|
||||
@@ -1041,6 +1041,11 @@
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="search"
|
||||
oninput="app.filterCustomModelPick(this.value)">
|
||||
<p class="form-hint custom-model-pick-no-matches" id="customModelPickNoMatches" role="status" hidden>No models match</p>
|
||||
<!-- Shown only when no row carries a loaded/in-use/last-used mark AND the endpoint
|
||||
answered that it is not llama-swap (see _openCustomModelPickModal): it says out
|
||||
loud WHY nothing is marked, in the user's language, rather than leaving an
|
||||
alphabetical wall that looks like the question was ignored. -->
|
||||
<p class="form-hint custom-model-pick-unknown" id="customModelPickUnknown" role="status" hidden>Nothing is marked: this endpoint did not report which model it has loaded, and none of your running sessions uses one. Pick the model to launch.</p>
|
||||
<div id="customModelPickList" class="run-mode-custom-models"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1002,12 +1002,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Best-effort lookup of the model llama-swap currently has loaded and ready on this
|
||||
* endpoint, so the picker can offer it first instead of making the user remember what
|
||||
* they picked last time it mattered. Mirrors `_watchLlamaSwapLoading`'s own
|
||||
* `state === 'ready'` check. Returns null for a plain (non-llama-swap) server, an
|
||||
* unreachable endpoint, or a loaded model this host no longer lists as discovered —
|
||||
* never throws, since a failed probe should just skip promotion, not break the picker.
|
||||
* Best-effort lookup of what the endpoint itself says it has loaded and ready, so the
|
||||
* picker can offer it first instead of making the user remember what they picked last
|
||||
* time it mattered. Only llama-swap answers this question (the route reads its
|
||||
* `/running`); every other OpenAI-compatible server (a bare llama.cpp, Ollama, vLLM,
|
||||
* or a gateway such as Bifrost fronting a whole fleet) replies `isLlamaSwap: false`,
|
||||
* which is why `_getCustomModelInUseBySession` exists. `state === 'ready'` mirrors
|
||||
* `_watchLlamaSwapLoading`'s own check.
|
||||
*
|
||||
* Resolves the raw route payload rather than a model id so the caller can tell the
|
||||
* three answers apart: llama-swap naming a model; `isLlamaSwap: false`, which is what
|
||||
* the route answers for EVERY failure it catches (a server without a `/running`
|
||||
* route, a refused or unresolvable endpoint, a 401 from a wrong key) — safe to
|
||||
* explain in the UI as long as the wording also fits an endpoint that is down; and
|
||||
* `null`, only when Codeman's own request to the route failed or the client-side
|
||||
* bound below won the race, which stays silent, since the endpoint may merely have
|
||||
* been asleep. Never throws: a failed probe should
|
||||
* just skip promotion, not break the picker.
|
||||
*
|
||||
* ⚠️ Client-side bounded to ~800ms via Promise.race, on top of (never instead of) the
|
||||
* route's own 5s server-side timeout (`RUNNING_TIMEOUT_MS`, custom-model-routes.ts) —
|
||||
@@ -1024,17 +1035,58 @@ Object.assign(CodemanApp.prototype, {
|
||||
* `vi.useFakeTimers()` patches, so a param is the only way to test the timeout without
|
||||
* actually waiting on it. Real callers never pass it.
|
||||
*/
|
||||
async _getCustomModelCurrentlyLoaded(host, timeoutMs = 800) {
|
||||
async _getCustomModelRunningStatus(host, timeoutMs = 800) {
|
||||
const probe = this._apiJson(`/api/model-endpoints/${encodeURIComponent(host.id)}/running-status`).catch(
|
||||
() => null
|
||||
);
|
||||
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs));
|
||||
const status = await Promise.race([probe, timeout]);
|
||||
return Promise.race([probe, timeout]);
|
||||
},
|
||||
|
||||
/**
|
||||
* The model llama-swap reports `ready` on this endpoint AND that this host still lists
|
||||
* as discovered (an undropped-but-undiscovered model has no row to promote), else null.
|
||||
*/
|
||||
_pickReadyRunningModel(status, host) {
|
||||
if (!status?.isLlamaSwap) return null;
|
||||
const ready = (status.running || []).find((r) => r.state === 'ready' && (host.models || []).includes(r.model));
|
||||
return ready?.model || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* The model a LIVE session of this instance is pointed at on `host`, or null. It is the
|
||||
* fallback for every endpoint that cannot answer "what is loaded?" about itself.
|
||||
* A session already running on the endpoint is first-hand evidence of a model in use
|
||||
* there: Codeman itself injected that endpoint and model into it, so it earns the same
|
||||
* promotion llama-swap's own answer gets, under its own honest tag.
|
||||
*
|
||||
* ⚠️ Reads ONLY `customModel` (what Codeman injected), never `displayModel`: that field
|
||||
* is display text scraped off a CLI's footer or statusline for a session header (pane
|
||||
* derived, `source: 'screen'`), and promoting a row because a terminal printed a
|
||||
* similar string is worse than showing nothing.
|
||||
* ⚠️ Requires a live pane: `status: 'stopped'`, a null `pid` (the PTY exited; the
|
||||
* same live-pane test keep-warm applies, `src/prompt-cache-keepwarm.ts`) or a recorded
|
||||
* `paneExit` means nothing is on the endpoint any more, and a dead session's stale pin
|
||||
* is exactly what an "in use" claim must not resurrect. `session:updated` keeps it
|
||||
* current, so a session cleared back to the CLI's own backend or moved to another
|
||||
* endpoint stops counting.
|
||||
* ⚠️ A since-deleted endpoint simply matches no host, so no extra liveness check is
|
||||
* needed here.
|
||||
*/
|
||||
_getCustomModelInUseBySession(host) {
|
||||
const models = host?.models || [];
|
||||
if (models.length === 0) return null;
|
||||
let newest = null;
|
||||
for (const [, session] of this.sessions || []) {
|
||||
if (!session || session.status === 'stopped' || session.pid === null || session.paneExit) continue;
|
||||
const pinned = session.customModel;
|
||||
if (!pinned || pinned.endpointId !== host.id) continue;
|
||||
if (!models.includes(pinned.modelId)) continue; // undiscovered since: no row to promote
|
||||
if (!newest || (newest.createdAt || 0) <= (session.createdAt || 0)) newest = session;
|
||||
}
|
||||
return newest?.customModel?.modelId || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the "which model" picker for a (harness, endpoint) pair with more than one
|
||||
* discovered model. Async since it now awaits the currently-loaded-model probe below,
|
||||
@@ -1055,15 +1107,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
const generation = (this._customModelPickGeneration = (this._customModelPickGeneration || 0) + 1);
|
||||
const isCurrent = () => this._customModelPickGeneration === generation;
|
||||
|
||||
// Whichever model llama-swap actually has loaded right now beats a merely
|
||||
// remembered choice — it's what a launch would attach to with zero wait, while
|
||||
// "last used" might have been swapped out by another session since. Neither
|
||||
// reorders past the top: exactly one model is promoted, everything else keeps
|
||||
// its discovery order.
|
||||
const currentlyLoaded = await this._getCustomModelCurrentlyLoaded(host);
|
||||
// Three kinds of knowledge, best first: what llama-swap says it has loaded right
|
||||
// now (zero wait to attach to), what a live session of this instance is pointed at
|
||||
// on that endpoint (the endpoint itself cannot say, but Codeman put it there), and
|
||||
// only then this device's memory of the last launch. One wins, and it never reorders
|
||||
// anything else: every other row keeps its discovery order.
|
||||
const runningStatus = await this._getCustomModelRunningStatus(host);
|
||||
if (!isCurrent()) return; // a newer pick opened (and possibly already rendered) while this probe was in flight
|
||||
const lastUsed = currentlyLoaded ? null : this._getCustomModelLastUsed(mode, host.id);
|
||||
const promoted = currentlyLoaded || lastUsed;
|
||||
const currentlyLoaded = this._pickReadyRunningModel(runningStatus, host);
|
||||
const inUse = currentlyLoaded ? null : this._getCustomModelInUseBySession(host);
|
||||
const lastUsed = currentlyLoaded || inUse ? null : this._getCustomModelLastUsed(mode, host.id);
|
||||
const promoted = currentlyLoaded || inUse || lastUsed;
|
||||
const models = [...(host.models || [])];
|
||||
if (promoted && models.includes(promoted)) {
|
||||
models.splice(models.indexOf(promoted), 1);
|
||||
@@ -1078,14 +1132,37 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('customModelPickTitle').textContent = 'Choose a model';
|
||||
document.getElementById('customModelPickHint').textContent =
|
||||
`${cliLabel} → ${host.label} — ${(host.models || []).length} models discovered.`;
|
||||
// A plain static line (its markup lives in index.html), shown when NOTHING is known
|
||||
// about any row: 40 alphabetical rows with no mark on any of them otherwise read as
|
||||
// the app ignoring the question. `isLlamaSwap: false` is what the route answers for
|
||||
// every failure it catches: a server that cannot report AND a down or refused one
|
||||
// alike (`getLlamaSwapStatus`, custom-model-routes.ts), so the line's wording
|
||||
// promises only that nothing was reported, which stays true either way. A probe
|
||||
// that never got an answer at all (`runningStatus === null`: Codeman's own request
|
||||
// failed or the 800ms bound won) keeps the line hidden, the box may merely be
|
||||
// asleep.
|
||||
// A remembered id that has since vanished from host.models is no promotion: count
|
||||
// only a promoted id that is actually a row, or a stale "last used" would both skip
|
||||
// tagging any row and hide this line, leaving 40 unmarked rows with no explanation.
|
||||
const nothingKnown = !(promoted && models.includes(promoted)) && runningStatus?.isLlamaSwap === false;
|
||||
const unknownLine = document.getElementById('customModelPickUnknown');
|
||||
if (unknownLine) unknownLine.hidden = !nothingKnown;
|
||||
list.innerHTML = models
|
||||
.map((m) => {
|
||||
// Two independent tags, never one exclusive slot: the promotion tag says what
|
||||
// llama-swap (or this device's history) knows about the model, the Default pill
|
||||
// says what the saved endpoint says about it, and on a single-purpose GPU box the
|
||||
// promoted model IS the default more often than not. One slot holding whichever
|
||||
// applied first silently dropped the Default marking for exactly that row.
|
||||
const promotion = m === currentlyLoaded ? 'Currently loaded' : m === lastUsed ? 'Last used' : null;
|
||||
// Two independent tags, never one exclusive slot: the promotion tag says what the
|
||||
// endpoint, a live session, or this device's history knows about the model, the
|
||||
// Default pill says what the saved endpoint says about it, and on a
|
||||
// single-purpose GPU box the promoted model IS the default more often than not.
|
||||
// One slot holding whichever applied first silently dropped the Default marking
|
||||
// for exactly that row.
|
||||
const promotion =
|
||||
m === currentlyLoaded
|
||||
? 'Currently loaded'
|
||||
: m === inUse
|
||||
? 'In use by a session'
|
||||
: m === lastUsed
|
||||
? 'Last used'
|
||||
: null;
|
||||
const tags = [promotion, m === host.defaultModelId ? 'Default' : null].filter(Boolean);
|
||||
const arg = escapeHtml(JSON.stringify(m));
|
||||
// data-model-id is the filter's haystack (escapeHtml covers `"` as well as
|
||||
|
||||
@@ -7831,6 +7831,17 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
/* "Nothing is marked: this endpoint did not report which model it has loaded": the same
|
||||
`.form-hint` `display: block` beats `[hidden]` trap the two rules below fix for the
|
||||
no-matches line and the filtered rows, so the attribute needs the same re-assertion. */
|
||||
#customModelPickModal .custom-model-pick-unknown {
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
#customModelPickModal .custom-model-pick-unknown[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Same class of bug as the row rule below: the no-matches line carries
|
||||
`.form-hint`, whose `display: block` outranks the UA `[hidden]` rule, so
|
||||
`filterCustomModelPick()` toggling `hidden` changed nothing on screen and
|
||||
|
||||
Reference in new issue
Block a user