feat(custom-model): generate Run-menu entries from saved endpoint profiles

Follow-up to #393, picking up the work Ark0N invited in his merge comment:
"generate those entries from the saved profiles rather than a fixed
duplicate per harness, and put it in a follow-up PR so this one stays the
backend... The Run-menu picker is yours if you want it."

Adds the frontend surface the backend has been waiting on:

- Run menu: a "Custom Endpoints" section lists one entry per (harness that
  supports customModelInjection, saved endpoint) pair, e.g.
  "Claude Code (llama.cpp)". The harness list comes from
  window.__codemanCustomModelClis, injected at page render straight off the
  CLI registry's own capabilities (never a hardcoded id list in the
  frontend), so a CLI whose injection recipe lands later appears with no
  frontend change. Picking an entry runs that harness's own existing run*()
  function unmodified (case creation, env overrides, everything, forced to
  a single instance) and then applies the endpoint's default model to the
  session it creates via the existing POST /api/sessions/:id/custom-model
  route. Entries are hidden for a remote/docker active case, since that
  route already refuses both.
- Settings: App Settings -> Models gets a "Custom model endpoints" group
  wiring up the customModelEndpointsEnabled toggle (declared since #393,
  read by nothing until now) plus CRUD against the existing
  /api/model-endpoints routes: list, add/edit (inline form), delete,
  discover models.
- Backend: CustomModelHost gains an optional defaultModelId, the model the
  picker applies with no further choice per endpoint (one generated menu
  entry per CLI+endpoint pair, not per CLI+endpoint+model). The route
  refuses a value that isn't one of the endpoint's own discovered models,
  and a fresh discovery drops a default that no longer appears rather than
  carrying an invalid one forward.

Docs: docs/custom-model-endpoints.md describes the new picker and settings
panel; CLAUDE.md's Custom Model Endpoint Profiles entry drops the
"backend-only" status note and documents the picker's generation mechanism.

Tests: four new route tests cover defaultModelId validation, acceptance,
and the drop/keep behaviour across a re-discovery; a new render-index-html
test pins the __codemanCustomModelClis injection (present, agent CLIs
supporting the capability, antigravity and shell excluded) and its
solo-window skip. No browser test was added for the Run-menu picker itself
or the settings CRUD panel (this box has no tmux, so the live server used
by test:browser/test:mobile could not be exercised here) -- worth a
Playwright pass before merge, same as any other frontend PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
This commit is contained in:
Devvyn
2026-09-16 07:01:23 +08:00
co-authored by Claude Sonnet 5
parent bd286bf502
commit 25fae9ad10
12 changed files with 583 additions and 14 deletions
+119
View File
@@ -461,6 +461,7 @@ Object.assign(CodemanApp.prototype, {
if (menu.classList.contains('active')) {
this._loadRunModeHistory();
this._refreshRunModeAvailability(menu);
this._refreshCustomModelRunOptions(menu);
const close = (ev) => {
if (!menu.contains(ev.target)) {
menu.classList.remove('active');
@@ -534,6 +535,124 @@ Object.assign(CodemanApp.prototype, {
if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none';
},
/**
* Generates the Run menu's Custom Model Endpoint entries
* (docs/custom-model-endpoints-plan.md): one button per (capable harness, saved
* endpoint) pair, e.g. "Claude Code (llama.cpp)". Hidden entirely when the
* feature is off, no endpoint has a usable default model, or the active case is
* remote/docker (the apply route refuses both — see session-routes.ts).
*
* `window.__codemanCustomModelClis` is server-injected at render time from the
* CLI registry's own `capabilities.customModelInjection` (never a hardcoded id
* list here), so a CLI gaining or losing the capability shows up with no
* frontend change.
*/
async _refreshCustomModelRunOptions(menu) {
const sep = menu.querySelector('#runModeCustomModelSep');
const header = menu.querySelector('#runModeCustomModelHeader');
const container = menu.querySelector('#runModeCustomModels');
if (!container) return;
const hide = () => {
if (sep) sep.style.display = 'none';
if (header) header.style.display = 'none';
container.innerHTML = '';
};
const settings = this.loadAppSettingsFromStorage();
const capableClis = window.__codemanCustomModelClis || [];
if (!settings.customModelEndpointsEnabled || capableClis.length === 0) return hide();
const caseName = document.getElementById('quickStartCase')?.value;
const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null;
if (activeCase?.location === 'remote' || activeCase?.location === 'docker') return hide();
let hosts;
try {
const res = await fetch('/api/model-endpoints');
hosts = await res.json();
} catch {
return hide();
}
if (!Array.isArray(hosts) || hosts.length === 0) return hide();
const rows = [];
for (const host of hosts) {
const modelId = host.defaultModelId || (host.models || [])[0];
if (!modelId) continue; // nothing discovered yet — the settings panel explains why
for (const cli of capableClis) {
rows.push(`
<button class="run-mode-option" data-mode="${escapeHtml(cli.id)}" data-endpoint="${escapeHtml(host.id)}"
onclick="app.runCustomModelEntry(${JSON.stringify(cli.id)}, ${JSON.stringify(host.id)}, ${JSON.stringify(modelId)})"
title="${escapeHtml(cli.label)} → ${escapeHtml(host.baseUrl)} (${escapeHtml(modelId)})">
<span class="run-mode-dot ${escapeHtml(cli.id)}"></span>${escapeHtml(cli.label)} (${escapeHtml(host.label)})
</button>`);
}
}
if (rows.length === 0) return hide();
if (sep) sep.style.display = '';
if (header) header.style.display = '';
container.innerHTML = rows.join('');
},
/**
* Runs a session on `mode` and immediately applies `endpointId`/`modelId` to it
* via POST /api/sessions/:id/custom-model (see session-routes.ts) — the same
* restart-in-place apply path the (not-yet-built) endpoint-management surface
* would use for an already-running session. Reuses the existing per-mode run*()
* functions wholesale (case creation, env overrides, the works) rather than a
* parallel create path, forcing a single instance: a custom-model run is a
* one-off "try this endpoint" action, not a batch spawn.
*/
async runCustomModelEntry(mode, endpointId, modelId) {
document.getElementById('runModeMenu')?.classList.remove('active');
const runners = {
claude: () => this.runClaude(),
opencode: () => this.runOpenCode(),
codex: () => this.runCodex(),
gemini: () => this.runGemini(),
pi: () => this.runPi(),
grok: () => this.runGrok(),
deepseek: () => this.runDeepSeek(),
omp: () => this.runOmp(),
};
const runner = runners[mode];
if (!runner) {
this.showToast(`No run function for mode ${mode}`, 'error');
return;
}
const tabCountEl = document.getElementById('tabCount');
const prevTabCount = tabCountEl?.value;
if (tabCountEl) tabCountEl.value = '1';
try {
await runner();
} finally {
if (tabCountEl && prevTabCount !== undefined) tabCountEl.value = prevTabCount;
}
// Every run*() ends by selecting the session it just created, so the active
// session at this point IS the new one — see runClaude/runShell's own comments
// on why selectSession must run before this reads activeSessionId.
const sessionId = this.activeSessionId;
if (!sessionId) return; // run() already reported its own error via toast
try {
const res = await fetch(`/api/sessions/${sessionId}/custom-model`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpointId, modelId }),
});
const data = await res.json();
if (!data.success) {
this.showToast(`Session started on the native backend — could not apply the custom endpoint: ${data.error}`, 'warning');
return;
}
this.showToast(`Pointed at ${endpointId} — restarting the session...`, 'info');
} catch (err) {
this.showToast(`Session started, but applying the custom endpoint failed: ${err.message}`, 'warning');
}
},
/**
* Start the DeepSeek Harness browser UI and open it as a Codeman web tab.
*