Files
2026-07-10 15:02:09 +01:00

831 lines
34 KiB
JavaScript

import { html } from 'lit';
import { LightElement } from '../lib/base.js';
const STRENGTH_COLORS = {
very_high: '#ef4444',
high: '#f97316',
average: '#eab308',
low: '#84cc16',
very_low: '#22c55e',
};
const STRENGTH_LABELS = {
very_high: 'Very High',
high: 'High',
average: 'Average',
low: 'Low',
very_low: 'Very Low',
};
const STRENGTH_OPTIONS = ['very_low', 'low', 'average', 'high', 'very_high'];
const SCOPE_OPTIONS = ['coding', 'writing', 'reasoning', 'math', 'basic', 'search'];
function emptyMeta() {
// `reasoning` is the selected reasoning value: a string for a ValueSet mode,
// a number for a Range mode, or null (off). Interpreted per provider.
return { strength: '', scope: [], priority: 100, is_default: false, reasoning: null };
}
function emptyOrForm() {
return {
model_id: '',
name: '',
max_tokens: '',
...emptyMeta(),
};
}
function emptyDefaultForm() {
return { model_id: '', name: '', extra_params: '', ...emptyMeta() };
}
export class ModelsLlmSection extends LightElement {
static properties = {
onback: { attribute: false },
_models: { state: true },
_providers: { state: true },
_modal: { state: true },
_saving: { state: true },
_error: { state: true },
_orModels: { state: true },
_orLoading: { state: true },
_orSearch: { state: true },
_orForm: { state: true },
_form: { state: true },
_pickedProvider: { state: true },
_reasoningMode: { state: true },
};
constructor() {
super();
this.onback = null;
this._models = [];
this._providers = [];
this._modal = null;
this._saving = false;
this._error = null;
this._orModels = [];
this._orLoading = false;
this._orSearch = '';
this._orForm = emptyOrForm();
this._form = emptyDefaultForm();
this._pickedProvider = null;
this._reasoningMode = null;
}
connectedCallback() {
super.connectedCallback();
this._load();
}
async _load() {
try {
const [modelsRes, providersRes] = await Promise.all([
fetch('/api/llm/models'),
fetch('/api/llm/providers'),
]);
if (!modelsRes.ok) throw new Error(`models: HTTP ${modelsRes.status}`);
if (!providersRes.ok) throw new Error(`providers: HTTP ${providersRes.status}`);
this._models = await modelsRes.json();
this._providers = await providersRes.json();
} catch (e) {
this._error = e.message;
}
}
// ── Move up/down ─────────────────────────────────────────────────────────────
async _moveUp(i) {
if (i <= 0) return;
const models = [...this._models];
[models[i - 1], models[i]] = [models[i], models[i - 1]];
this._models = models;
await this._savePriorities();
}
async _moveDown(i) {
if (i >= this._models.length - 1) return;
const models = [...this._models];
[models[i], models[i + 1]] = [models[i + 1], models[i]];
this._models = models;
await this._savePriorities();
}
async _savePriorities() {
try {
await Promise.all(this._models.map((m, i) =>
fetch(`/api/llm/models/${m.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
// A PUT overwrites every column, so carry through all preserved
// fields (catalog metadata + reasoning) — omitting them would wipe
// them on reorder.
body: JSON.stringify({
provider_id: m.provider_id,
model_id: m.model_id,
name: m.name,
strength: m.strength ?? null,
scope: m.scope,
is_default: m.is_default,
priority: (i + 1) * 10,
extra_params: m.extra_params ?? null,
context_length: m.context_length ?? null,
max_output_tokens: m.max_output_tokens ?? null,
knowledge_cutoff: m.knowledge_cutoff ?? null,
capabilities: m.capabilities ?? null,
reasoning: m.reasoning ?? null,
}),
})
));
await this._load();
} catch (e) {
this._error = `Failed to save order: ${e.message}`;
await this._load();
}
}
// ── Add flow ─────────────────────────────────────────────────────────────────
_openAdd() {
this._error = null;
this._pickedProvider = null;
this._modal = 'provider-pick';
}
async _pickProvider(provider) {
this._error = null;
this._pickedProvider = provider;
this._reasoningMode = null;
const hasModelPicker = ['openrouter', 'ollama', 'lm_studio', 'deepseek', 'zai'].includes(provider.type);
if (hasModelPicker) {
this._orForm = emptyOrForm();
this._orSearch = '';
this._orModels = [];
this._modal = 'add-openrouter';
await this._loadOrModels(provider.id);
} else {
this._form = { ...emptyDefaultForm(), provider_id: provider.id };
this._modal = 'add-default';
}
}
async _loadOrModels(providerId) {
this._orLoading = true;
try {
const res = await fetch(`/api/llm/providers/${providerId}/models`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
this._orModels = await res.json();
} catch (e) {
this._error = `Failed to load models: ${e.message}`;
} finally {
this._orLoading = false;
}
}
// ── Edit flow ────────────────────────────────────────────────────────────────
async _openEdit(model) {
this._error = null;
try {
const res = await fetch(`/api/llm/models/${model.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const record = await res.json();
this._form = {
strength: record.strength ?? '',
scope: record.scope ?? [],
priority: record.priority,
is_default: record.is_default,
provider_id: record.provider_id,
model_id: record.model_id,
name: record.name,
extra_params: record.extra_params ? JSON.stringify(record.extra_params) : '',
reasoning: record.reasoning ?? null,
// Preserved so the PUT doesn't wipe catalog metadata (see _submitEdit).
context_length: record.context_length ?? null,
max_output_tokens: record.max_output_tokens ?? null,
knowledge_cutoff: record.knowledge_cutoff ?? null,
capabilities: record.capabilities ?? null,
};
// The reasoning control descriptor lives on the list item (LlmModelInfo).
this._modal = {
mode: 'edit', id: record.id, name: record.name, model_id: record.model_id,
reasoning_mode: model.reasoning_mode ?? null,
};
} catch (e) {
this._error = e.message;
}
}
// ── Delete ───────────────────────────────────────────────────────────────────
async _delete(model) {
if (!confirm(`Delete model "${model.name}"?`)) return;
try {
const res = await fetch(`/api/llm/models/${model.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
await this._load();
} catch (e) {
this._error = e.message;
}
}
// ── Submit: add default ──────────────────────────────────────────────────────
async _submitDefault(e) {
e.preventDefault();
if (this._saving) return;
this._saving = true;
this._error = null;
const f = this._form;
let extra_params = null;
if (f.extra_params && f.extra_params.trim()) {
try { extra_params = JSON.parse(f.extra_params); }
catch { this._error = 'Extra params: invalid JSON'; this._saving = false; return; }
}
try {
const res = await fetch('/api/llm/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider_id: Number(this._pickedProvider.id),
model_id: f.model_id,
name: f.name || f.model_id,
strength: f.strength || null,
scope: f.scope,
is_default: f.is_default,
priority: Number(f.priority),
extra_params,
reasoning: f.reasoning ?? null,
}),
});
if (!res.ok) throw new Error(await res.text());
this._modal = null;
await this._load();
} catch (err) {
this._error = err.message;
} finally {
this._saving = false;
}
}
// ── Submit: add openrouter ───────────────────────────────────────────────────
async _submitCatalog(e) {
e.preventDefault();
if (this._saving) return;
if (!this._orForm.model_id) { this._error = 'Select a model'; return; }
this._saving = true;
this._error = null;
const f = this._orForm;
const extra_params = {};
if (f.max_tokens) extra_params.max_tokens = Number(f.max_tokens);
const selected = this._orModels.find(m => m.id === f.model_id);
try {
const res = await fetch('/api/llm/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider_id: Number(this._pickedProvider.id),
model_id: f.model_id,
name: f.name || f.model_id,
strength: f.strength || null,
scope: f.scope,
is_default: f.is_default,
priority: Number(f.priority),
extra_params: Object.keys(extra_params).length ? extra_params : null,
context_length: selected?.context_length ?? null,
max_output_tokens: selected?.max_completion_tokens ?? null,
knowledge_cutoff: selected?.knowledge_cutoff ?? null,
capabilities: selected?.capabilities?.length ? selected.capabilities : null,
reasoning: f.reasoning ?? null,
}),
});
if (!res.ok) throw new Error(await res.text());
this._modal = null;
await this._load();
} catch (err) {
this._error = err.message;
} finally {
this._saving = false;
}
}
// ── Submit: edit ─────────────────────────────────────────────────────────────
async _submitEdit(e) {
e.preventDefault();
if (this._saving) return;
this._saving = true;
this._error = null;
const f = this._form;
let extra_params = null;
if (f.extra_params && f.extra_params.trim()) {
try { extra_params = JSON.parse(f.extra_params); }
catch { this._error = 'Extra params: invalid JSON'; this._saving = false; return; }
}
try {
const res = await fetch(`/api/llm/models/${this._modal.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider_id: f.provider_id,
model_id: f.model_id,
name: f.name,
strength: f.strength || null,
scope: f.scope,
is_default: f.is_default,
priority: Number(f.priority),
extra_params,
reasoning: f.reasoning ?? null,
context_length: f.context_length ?? null,
max_output_tokens: f.max_output_tokens ?? null,
knowledge_cutoff: f.knowledge_cutoff ?? null,
capabilities: f.capabilities ?? null,
}),
});
if (!res.ok) throw new Error(await res.text());
this._modal = null;
await this._load();
} catch (err) {
this._error = err.message;
} finally {
this._saving = false;
}
}
// ── Field helpers ────────────────────────────────────────────────────────────
_setField(field, value) {
this._form = { ...this._form, [field]: value };
}
// Manual "add model" flow: the reasoning control descriptor depends on the
// model_id, so fetch it (per provider) when the field settles.
async _fetchDefaultReasoning(modelId) {
this._reasoningMode = null;
this._setField('reasoning', null);
const pid = this._pickedProvider?.id;
if (!pid || !modelId.trim()) return;
try {
const res = await fetch(`/api/llm/providers/${pid}/reasoning-mode?model_id=${encodeURIComponent(modelId)}`);
if (res.ok) this._reasoningMode = await res.json();
} catch { /* descriptor is optional; ignore */ }
}
_setOrField(field, value) {
this._orForm = { ...this._orForm, [field]: value };
}
_toggleScope(scope, isOr = false) {
if (isOr) {
const s = this._orForm.scope;
this._orForm = { ...this._orForm, scope: s.includes(scope) ? s.filter(x => x !== scope) : [...s, scope] };
} else {
const s = this._form.scope;
this._form = { ...this._form, scope: s.includes(scope) ? s.filter(x => x !== scope) : [...s, scope] };
}
}
_closeModal() { this._modal = null; this._error = null; }
// ── Render helpers ───────────────────────────────────────────────────────────
_fmtP(p) {
if (p == null) return null;
if (p === 0) return '$0';
return p < 0.01 ? `$${p.toFixed(4)}` : `$${p.toFixed(3)}`;
}
_renderPriceCell(model) {
const inp = this._fmtP(model.price_input_per_million);
const out = this._fmtP(model.price_output_per_million);
if (!inp && !out) return html`<span style="opacity:0.3">—</span>`;
return html`
<span class="llm-price-tag" title="Input/Output per 1M tokens">
${inp ?? '?'} <span style="opacity:0.45">→</span> ${out ?? '?'}
</span>
`;
}
_renderStrengthDot(strength) {
if (!strength) return html`<span style="opacity:0.3">—</span>`;
return html`
<span class="llm-strength-dot"
style="background:${STRENGTH_COLORS[strength] ?? '#888'}"
title=${STRENGTH_LABELS[strength] ?? strength}></span>
`;
}
_renderStatus(status) {
const cfg = {
healthy: { color: '#22c55e', title: 'Healthy' },
degraded: { color: '#eab308', title: 'Degraded' },
down: { color: '#ef4444', title: 'Down' },
}[status] ?? { color: '#888', title: status };
return html`<span class="llm-strength-dot" style="background:${cfg.color}" title=${cfg.title}></span>`;
}
_renderCard(m, i) {
const first = i === 0;
const last = i === this._models.length - 1;
return html`
<div class="llm-card">
<div class="llm-card-row1">
<div class="llm-move-btns">
<button class="llm-move-btn" title="Move up"
?disabled=${first}
@click=${() => this._moveUp(i)}>
<i class="bi bi-chevron-up"></i>
</button>
<button class="llm-move-btn" title="Move down"
?disabled=${last}
@click=${() => this._moveDown(i)}>
<i class="bi bi-chevron-down"></i>
</button>
</div>
${this._renderStrengthDot(m.strength)}
${this._renderStatus(m.status)}
<span class="llm-card-name">${m.name}</span>
${m.is_default ? html`<span class="llm-card-badge">default</span>` : ''}
<div class="llm-card-actions">
<button class="llm-btn-icon llm-btn-edit" title="Edit" @click=${() => this._openEdit(m)}>
<i class="bi bi-pencil"></i>
</button>
<button class="llm-btn-icon llm-btn-delete" title="Delete" @click=${() => this._delete(m)}>
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="llm-card-row2">
<span class="llm-provider-name">${m.provider_name}</span>
<span class="llm-model-id">${m.model_id}</span>
${this._renderPriceCell(m)}
</div>
${(m.scope ?? []).length > 0 || m.extra_params ? html`
<div class="llm-card-row3">
${(m.scope ?? []).map(s => html`<span class="llm-scope-pill">${s}</span>`)}
${m.extra_params ? html`<span class="llm-scope-pill llm-params-pill" title=${JSON.stringify(m.extra_params)}>+params</span>` : ''}
</div>
` : ''}
</div>
`;
}
// Data-driven reasoning control. `mode` is the provider-supplied descriptor
// (ReasoningMode): a `value_set` (discrete choices → dropdown) or a `range`
// (numeric budget → checkbox + number input). `form.reasoning` holds the
// current value (string for value_set, number for range, or null = off).
_renderReasoning(form, setField, mode) {
if (!mode) return '';
if (mode.type === 'value_set') {
return html`
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Reasoning</label>
<select class="form-select form-select-sm"
@change=${(e) => setField('reasoning', e.target.value || null)}>
<option value="" ?selected=${form.reasoning == null}>— off —</option>
${mode.values.map(v => html`
<option value=${v} ?selected=${form.reasoning === v}>${v}</option>
`)}
</select>
</div>`;
}
// range
const enabled = form.reasoning != null;
return html`
<div class="mb-3">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="m-reasoning-on"
.checked=${enabled}
@change=${(e) => setField('reasoning', e.target.checked ? (mode.default ?? mode.min) : null)} />
<label class="form-check-label fw-semibold" for="m-reasoning-on" style="font-size:0.82rem">
Reasoning (thinking)
</label>
</div>
${enabled ? html`
<div class="d-flex align-items-center gap-2 ms-3">
<input type="number" class="form-control form-control-sm" style="max-width:140px"
min=${mode.min} max=${mode.max} step=${mode.step ?? 1}
.value=${String(form.reasoning)}
@input=${(e) => setField('reasoning', e.target.value === '' ? null : Number(e.target.value))} />
<span class="text-muted" style="font-size:0.78rem">${mode.unit ?? ''}</span>
</div>
` : ''}
</div>`;
}
_renderMetaFields(form, setField, toggleScope, reasoningMode) {
return html`
${this._renderReasoning(form, setField, reasoningMode)}
<div class="row g-3 mb-3">
<div class="col-8">
<label class="form-label fw-semibold" style="font-size:0.82rem">Strength</label>
<select class="form-select form-select-sm"
@change=${(e) => setField('strength', e.target.value)}>
<option value="">— none —</option>
${STRENGTH_OPTIONS.map(s => html`
<option value=${s} ?selected=${form.strength === s}>${STRENGTH_LABELS[s]}</option>
`)}
</select>
</div>
<div class="col-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Priority</label>
<input type="number" class="form-control form-control-sm" .value=${String(form.priority)} min="1"
@input=${(e) => setField('priority', e.target.value)} />
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Scope</label>
<div class="llm-scope-grid">
${SCOPE_OPTIONS.map(s => html`
<div class="form-check">
<input class="form-check-input" type="checkbox" id="scope-${s}"
.checked=${form.scope.includes(s)} @change=${() => toggleScope(s)} />
<label class="form-check-label" for="scope-${s}" style="font-size:0.82rem">${s}</label>
</div>
`)}
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="m-is-default"
.checked=${form.is_default} @change=${(e) => setField('is_default', e.target.checked)} />
<label class="form-check-label" for="m-is-default" style="font-size:0.82rem">Default model</label>
</div>
</div>
`;
}
// ── Modal: provider picker ───────────────────────────────────────────────────
_renderProviderPick() {
return html`
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
<div class="agent-dialog llm-modal">
<div class="llm-modal-title">Add Model — Choose Provider</div>
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
<div class="llm-provider-grid">
${this._providers.filter(p => (p.supported_types ?? []).includes('llm')).map(p => html`
<button class="llm-provider-card" @click=${() => this._pickProvider(p)}>
<div class="llm-provider-card-name">${p.name}</div>
<div class="llm-provider-card-type text-muted" style="font-size:0.75rem">${p.type}</div>
</button>
`)}
</div>
<div class="agent-dialog-actions mt-3">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
</div>
</div>
</div>
`;
}
// ── Modal: add default ───────────────────────────────────────────────────────
_renderAddDefault() {
const f = this._form;
const p = this._pickedProvider;
return html`
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
<div class="agent-dialog llm-modal">
<div class="llm-modal-title">
Add Model
<span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>
</div>
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
<form @submit=${(e) => this._submitDefault(e)}>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Model ID <span class="text-muted fw-normal">(sent to API)</span></label>
<input type="text" class="form-control form-control-sm" .value=${f.model_id} required
placeholder="e.g. gpt-4o"
@input=${(e) => this._setField('model_id', e.target.value)}
@change=${(e) => this._fetchDefaultReasoning(e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Name / Alias <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" class="form-control form-control-sm" .value=${f.name}
placeholder=${f.model_id || 'same as model ID'}
@input=${(e) => this._setField('name', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">
Extra params <span class="text-muted fw-normal">(JSON, optional)</span>
</label>
<textarea class="form-control form-control-sm font-monospace" rows="3"
.value=${f.extra_params}
@input=${(e) => this._setField('extra_params', e.target.value)}
style="font-size:0.78rem;resize:vertical"></textarea>
</div>
${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._reasoningMode)}
<div class="agent-dialog-actions">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving ? 'Saving…' : 'Add model'}
</button>
</div>
</form>
</div>
</div>
`;
}
// ── Modal: add model from catalog ────────────────────────────────────────────
_renderAddCatalog() {
const f = this._orForm;
const p = this._pickedProvider;
const search = this._orSearch.toLowerCase();
const filtered = this._orModels.filter(m =>
!search || m.id.toLowerCase().includes(search) || m.name.toLowerCase().includes(search)
);
const selected = this._orModels.find(m => m.id === f.model_id);
const supportsMaxTokens = selected?.capabilities?.includes('max_tokens') ?? true;
const formatPrice = (m) => {
const i = this._fmtP(m.price_input_per_million);
const o = this._fmtP(m.price_output_per_million);
if (!i && !o) return null;
return `${i ?? '?'}${o ?? '?'}/M`;
};
return html`
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
<div class="agent-dialog llm-modal llm-modal-wide">
<div class="llm-modal-title">
Add Model
<span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>
</div>
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
<form @submit=${(e) => this._submitCatalog(e)}>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Model</label>
<input type="text" class="form-control form-control-sm mb-1"
placeholder="Search models…"
.value=${this._orSearch}
@input=${(e) => { this._orSearch = e.target.value; }} />
${this._orLoading
? html`<div class="text-muted py-2" style="font-size:0.82rem">Loading models…</div>`
: html`
<div class="llm-or-model-list">
${filtered.length === 0
? html`<div class="text-muted px-2 py-1" style="font-size:0.82rem">No models found</div>`
: filtered.map(m => html`
<div class="llm-or-model-row ${f.model_id === m.id ? 'selected' : ''}"
@click=${() => this._setOrField('model_id', m.id)}>
<div class="llm-or-model-name">${m.name}</div>
<div class="llm-or-model-meta">
<span class="text-muted" style="font-size:0.72rem">${m.id}</span>
${(m.price_input_per_million != null || m.price_output_per_million != null) ? html`
<span class="llm-or-price">${formatPrice(m)}</span>
` : ''}
${m.context_length ? html`
<span class="text-muted" style="font-size:0.72rem">${(m.context_length / 1000).toFixed(0)}k ctx</span>
` : ''}
</div>
</div>
`)
}
</div>
`
}
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Name / Alias <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" class="form-control form-control-sm" .value=${f.name}
placeholder=${selected?.name || f.model_id || 'same as model ID'}
@input=${(e) => this._setOrField('name', e.target.value)} />
</div>
${supportsMaxTokens ? html`
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Max output tokens <span class="text-muted fw-normal">(optional)</span></label>
<input type="number" class="form-control form-control-sm" .value=${f.max_tokens}
placeholder=${selected?.max_completion_tokens ? `up to ${selected.max_completion_tokens.toLocaleString()}` : ''}
min="1"
@input=${(e) => this._setOrField('max_tokens', e.target.value)} />
</div>
` : ''}
${this._renderMetaFields(f, (k, v) => this._setOrField(k, v), (s) => this._toggleScope(s, true), selected?.reasoning)}
<div class="agent-dialog-actions">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving || !f.model_id}>
${this._saving ? 'Saving…' : 'Add model'}
</button>
</div>
</form>
</div>
</div>
`;
}
// ── Modal: edit ──────────────────────────────────────────────────────────────
_renderEdit() {
const m = this._modal;
const f = this._form;
return html`
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
<div class="agent-dialog llm-modal">
<div class="llm-modal-title">
Edit
<span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${m.name}</span>
</div>
<p class="text-muted mb-3" style="font-size:0.8rem">
Model ID and provider cannot be changed. To use a different model, add a new entry.
</p>
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
<form @submit=${(e) => this._submitEdit(e)}>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Name / Alias</label>
<input type="text" class="form-control form-control-sm" .value=${f.name}
placeholder=${m.model_id || 'model name'}
@input=${(e) => this._setField('name', e.target.value)} />
<div class="form-text" style="font-size:0.75rem">Used to reference this model (e.g. in an agent's <code>client</code>). Must be unique.</div>
</div>
${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._modal.reasoning_mode)}
<div class="agent-dialog-actions">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving ? 'Saving…' : 'Save changes'}
</button>
</div>
</form>
</div>
</div>
`;
}
render() {
return html`
<div class="llm-page">
<div class="llm-page-header">
<div class="llm-header-left">
${this.onback ? html`
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back to models" @click=${this.onback}>
<i class="bi bi-arrow-left"></i>
</button>
` : ''}
<div>
<h2 class="llm-page-title">LLM Models</h2>
<span class="llm-page-count">${this._models.length} model${this._models.length !== 1 ? 's' : ''}</span>
</div>
</div>
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}
?disabled=${this._providers.length === 0}>
<i class="bi bi-plus-lg me-1"></i>Add
</button>
</div>
${this._providers.length === 0 ? html`
<div class="agent-info-banner">
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
<div class="agent-info-banner-body">
<p class="mb-0">Add a <strong>Provider</strong> first, then come back here to add models.</p>
</div>
</div>
` : ''}
${this._error && !this._modal ? html`
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
` : ''}
<div class="llm-card-list">
${this._models.length === 0 && this._providers.length > 0 ? html`
<div class="llm-empty-state">
<i class="bi bi-cpu"></i>
<p>No models configured yet.</p>
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
<i class="bi bi-plus-lg me-1"></i>Add your first model
</button>
</div>
` : this._models.map((m, i) => this._renderCard(m, i))}
</div>
</div>
${this._modal === 'provider-pick' ? this._renderProviderPick() : ''}
${this._modal === 'add-openrouter' ? this._renderAddCatalog() : ''}
${this._modal === 'add-default' ? this._renderAddDefault() : ''}
${this._modal?.mode === 'edit' ? this._renderEdit() : ''}
`;
}
}