mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
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:
co-authored by
Claude Sonnet 5
parent
bd286bf502
commit
25fae9ad10
@@ -395,6 +395,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
|
||||
// Approvals Inbox: synced, default OFF (opt-in; only an explicit true enables).
|
||||
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true;
|
||||
// Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both
|
||||
// the Run-menu picker's generated entries and this settings panel's visibility;
|
||||
// the endpoint list itself is server state, loaded separately below.
|
||||
document.getElementById('appSettingsCustomModelEndpoints').checked = settings.customModelEndpointsEnabled === true;
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
@@ -509,6 +513,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsNiceValue').value = niceSettings.niceValue ?? 10;
|
||||
// Model configuration (loaded from server)
|
||||
this.loadModelConfigForSettings();
|
||||
// Custom Model Endpoint Profiles: server state, own load path (mirrors the
|
||||
// model-config pair above) rather than the settings payload — endpoints are
|
||||
// infra records (CRUD'd via /api/model-endpoints), not user preferences.
|
||||
this.loadCustomModelEndpointsForSettings();
|
||||
// Notification settings
|
||||
const notifPrefs = this.notificationManager?.preferences || {};
|
||||
document.getElementById('appSettingsNotifEnabled').checked = notifPrefs.enabled ?? true;
|
||||
@@ -2106,6 +2114,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
|
||||
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
|
||||
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
|
||||
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
|
||||
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
@@ -2487,6 +2496,161 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md)
|
||||
//
|
||||
// CRUD against /api/model-endpoints, rendered into the Models settings section.
|
||||
// Deliberately its own load/save pair rather than folded into openAppSettings/
|
||||
// saveAppSettings: these are server-side infra records (like remote/docker
|
||||
// hosts), not a settings-payload field, so the app-settings-structure guard's
|
||||
// by-id contract does not apply to them — only the `customModelEndpointsEnabled`
|
||||
// toggle itself goes through that path.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async loadCustomModelEndpointsForSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/model-endpoints');
|
||||
const hosts = await res.json();
|
||||
this._customModelHosts = Array.isArray(hosts) ? hosts : [];
|
||||
} catch (err) {
|
||||
console.warn('Failed to load model endpoints:', err);
|
||||
this._customModelHosts = this._customModelHosts || [];
|
||||
}
|
||||
this.renderCustomModelHostsList();
|
||||
},
|
||||
|
||||
renderCustomModelHostsList() {
|
||||
const list = document.getElementById('customModelHostsList');
|
||||
if (!list) return;
|
||||
const hosts = this._customModelHosts || [];
|
||||
if (hosts.length === 0) {
|
||||
list.innerHTML = '<p class="set-group-hint">No endpoints yet. Add one below to point a harness at a local or cloud OpenAI-compatible server.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = hosts
|
||||
.map((h) => {
|
||||
const modelCount = (h.models || []).length;
|
||||
const modelSummary = modelCount === 0
|
||||
? 'No models discovered yet'
|
||||
: `${modelCount} model${modelCount === 1 ? '' : 's'}${h.defaultModelId ? ` · default: ${escapeHtml(h.defaultModelId)}` : ' · no default set'}`;
|
||||
return `
|
||||
<div class="set-row" data-endpoint-id="${escapeHtml(h.id)}">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">${escapeHtml(h.label)}</span>
|
||||
<span class="set-row-desc">${escapeHtml(h.baseUrl)} — ${modelSummary}</span>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.discoverCustomModelHostModels(${JSON.stringify(h.id)})">Discover</button>
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.openCustomModelHostEditor(${JSON.stringify(h.id)})">Edit</button>
|
||||
<button type="button" class="btn-toolbar btn-danger btn-sm" onclick="app.deleteCustomModelHost(${JSON.stringify(h.id)})">Delete</button>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
|
||||
/** Opens the inline add/edit form. Pass no id to add a new endpoint. */
|
||||
openCustomModelHostEditor(hostId) {
|
||||
const host = hostId ? (this._customModelHosts || []).find((h) => h.id === hostId) : null;
|
||||
this._editingCustomModelHostId = host ? host.id : null;
|
||||
document.getElementById('customModelHostEditorTitle').textContent = host ? `Edit ${host.label}` : 'Add endpoint';
|
||||
document.getElementById('customModelHostId').value = host?.id || '';
|
||||
document.getElementById('customModelHostId').disabled = !!host; // id is immutable once created
|
||||
document.getElementById('customModelHostLabel').value = host?.label || '';
|
||||
document.getElementById('customModelHostBaseUrl').value = host?.baseUrl || '';
|
||||
document.getElementById('customModelHostApiKey').value = ''; // never round-tripped back into the field
|
||||
document.getElementById('customModelHostApiKey').placeholder = host?.apiKey ? '•••••••• (unchanged if left blank)' : '';
|
||||
document.getElementById('customModelHostAuthStyle').value = host?.authStyle || 'bearer';
|
||||
this._populateCustomModelDefaultSelect(host);
|
||||
document.getElementById('customModelHostEditor').style.display = '';
|
||||
},
|
||||
|
||||
closeCustomModelHostEditor() {
|
||||
document.getElementById('customModelHostEditor').style.display = 'none';
|
||||
this._editingCustomModelHostId = null;
|
||||
},
|
||||
|
||||
_populateCustomModelDefaultSelect(host) {
|
||||
const select = document.getElementById('customModelHostDefaultModel');
|
||||
const models = host?.models || [];
|
||||
select.innerHTML =
|
||||
'<option value="">No default (picker uses the first discovered model)</option>' +
|
||||
models.map((m) => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).join('');
|
||||
select.value = host?.defaultModelId || '';
|
||||
select.disabled = models.length === 0;
|
||||
},
|
||||
|
||||
async saveCustomModelHostFromEditor() {
|
||||
const id = document.getElementById('customModelHostId').value.trim();
|
||||
const label = document.getElementById('customModelHostLabel').value.trim();
|
||||
const baseUrl = document.getElementById('customModelHostBaseUrl').value.trim();
|
||||
const apiKeyInput = document.getElementById('customModelHostApiKey').value;
|
||||
const authStyle = document.getElementById('customModelHostAuthStyle').value;
|
||||
const defaultModelId = document.getElementById('customModelHostDefaultModel').value || undefined;
|
||||
if (!id || !label || !baseUrl) {
|
||||
this.showToast('Id, label and base URL are all required', 'warning');
|
||||
return;
|
||||
}
|
||||
const editing = this._editingCustomModelHostId;
|
||||
const existing = editing ? (this._customModelHosts || []).find((h) => h.id === editing) : null;
|
||||
const body = {
|
||||
id,
|
||||
label,
|
||||
baseUrl,
|
||||
authStyle,
|
||||
defaultModelId,
|
||||
// A blank key on EDIT means "leave it alone", never "clear it" — the field
|
||||
// is never pre-filled with the real value (see openCustomModelHostEditor),
|
||||
// so an unedited save must not silently wipe a working credential.
|
||||
apiKey: apiKeyInput ? apiKeyInput : existing?.apiKey,
|
||||
models: existing?.models,
|
||||
lastDiscoveredAt: existing?.lastDiscoveredAt,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(editing ? `/api/model-endpoints/${encodeURIComponent(editing)}` : '/api/model-endpoints', {
|
||||
method: editing ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
this.showToast(data.error || 'Failed to save endpoint', 'error');
|
||||
return;
|
||||
}
|
||||
this.showToast(editing ? 'Endpoint updated' : 'Endpoint added', 'success');
|
||||
this.closeCustomModelHostEditor();
|
||||
await this.loadCustomModelEndpointsForSettings();
|
||||
} catch (err) {
|
||||
this.showToast(`Failed to save endpoint: ${err.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async discoverCustomModelHostModels(hostId) {
|
||||
this.showToast('Discovering models…', 'info');
|
||||
try {
|
||||
const res = await fetch(`/api/model-endpoints/${encodeURIComponent(hostId)}/discover-models`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
this.showToast(data.error || 'Discovery failed', 'error');
|
||||
return;
|
||||
}
|
||||
this.showToast(`Found ${data.data.models.length} model${data.data.models.length === 1 ? '' : 's'}`, 'success');
|
||||
await this.loadCustomModelEndpointsForSettings();
|
||||
} catch (err) {
|
||||
this.showToast(`Discovery failed: ${err.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCustomModelHost(hostId) {
|
||||
const host = (this._customModelHosts || []).find((h) => h.id === hostId);
|
||||
if (!confirm(`Delete endpoint "${host?.label || hostId}"? Any session currently pointed at it keeps running until cleared.`)) return;
|
||||
try {
|
||||
await fetch(`/api/model-endpoints/${encodeURIComponent(hostId)}`, { method: 'DELETE' });
|
||||
await this.loadCustomModelEndpointsForSettings();
|
||||
} catch (err) {
|
||||
this.showToast(`Failed to delete endpoint: ${err.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Visibility Settings & Device-Specific Defaults
|
||||
|
||||
Reference in New Issue
Block a user