Files
Skald-Circle/web/components/models-llm.js
T
dguiducci 126886e309 Major rebranding, i18n, dashboard, shared folders, role capabilities
- Rebrand: new app/agent icons, SKALD.md, warm "paper" CSS palette
  (terracotta accent, --radius tokens, WCAG contrast, reduced-motion),
  updated favicon, tray icon, skaldkonur asset
- i18n: backend crate (i18n.rs, locale column, ui_locale config),
  frontend library (web/lib/i18n.js, I18nMixin, t(key)),
  translation files (web/i18n/), every component wired
- Dashboard: <dashboard-page> replaces old home-page content;
  <app-copilot> becomes the landing page (full/dock layout modes)
- Shared folders: API endpoints (shared_folders.rs), frontend page,
  can_write membership, container mount topology, user_fs routing
- Role capabilities: new db table & authorization seam (data not enums),
  roles.attrs JSON for ui_mode / interface select
- Setup: skald-setup prompts for language + password, sets ui_locale
- General: components migrated to CSS variables, Lit conventions cleanup,
  connectors/catalog/marketplace/approval refactoring
2026-07-18 21:38:42 +01:00

846 lines
36 KiB
JavaScript

import { html } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.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 },
_providerTypes: { 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._providerTypes = [];
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.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
this._load();
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _load() {
try {
const [modelsRes, providersRes, typesRes] = await Promise.all([
fetch('/api/llm/models'),
fetch('/api/llm/providers'),
fetch('/api/llm/providers/types'),
]);
if (!modelsRes.ok) throw new Error(`models: HTTP ${modelsRes.status}`);
if (!providersRes.ok) throw new Error(`providers: HTTP ${providersRes.status}`);
if (!typesRes.ok) throw new Error(`provider types: HTTP ${typesRes.status}`);
this._models = await modelsRes.json();
this._providers = await providersRes.json();
this._providerTypes = await typesRes.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 = t('models.error.save_order', { msg: 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 typeMeta = this._providerTypes.find(t => t.type_id === provider.type);
if (typeMeta?.lists_models) {
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 = t('models.error.load_models', { msg: 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(t('models.confirm_delete', { type: 'LLM', name: 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 = t('models.error.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 = t('models.error.select_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 = t('models.error.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=${t('models.llm.price_tooltip')}>
${inp ?? '?'} <span style="opacity:0.45">→</span> ${out ?? '?'}
</span>
`;
}
_renderStrengthDot(strength) {
if (!strength) return html`<span style="opacity:0.3">${'—'}</span>`;
const label = { very_high: t('models.strength.very_high'), high: t('models.strength.high'), average: t('models.strength.average'), low: t('models.strength.low'), very_low: t('models.strength.very_low') }[strength] ?? strength;
return html`
<span class="llm-strength-dot"
style="background:${STRENGTH_COLORS[strength] ?? '#888'}"
title=${label}></span>
`;
}
_renderStatus(status) {
const cfg = {
healthy: { color: '#22c55e', title: t('models.status_healthy') },
degraded: { color: '#eab308', title: t('models.status_degraded') },
down: { color: '#ef4444', title: t('models.status_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=${t('models.move_up')}
?disabled=${first}
@click=${() => this._moveUp(i)}>
<i class="bi bi-chevron-up"></i>
</button>
<button class="llm-move-btn" title=${t('models.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">${t('models.default')}</span>` : ''}
<div class="llm-card-actions">
<button class="llm-btn-icon llm-btn-edit" title=${t('models.edit')} @click=${() => this._openEdit(m)}>
<i class="bi bi-pencil"></i>
</button>
<button class="llm-btn-icon llm-btn-delete" title=${t('models.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)}>+${t('models.extra_params').toLowerCase()}</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">${t('models.reasoning.label')}</label>
<select class="form-select form-select-sm"
@change=${(e) => setField('reasoning', e.target.value || null)}>
<option value="" ?selected=${form.reasoning == null}>${t('models.reasoning.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">
${t('models.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">${t('models.strength')}</label>
<select class="form-select form-select-sm"
@change=${(e) => setField('strength', e.target.value)}>
<option value="">${t('models.strength.none')}</option>
${STRENGTH_OPTIONS.map(s => html`
<option value=${s} ?selected=${form.strength === s}>${({ very_high: t('models.strength.very_high'), high: t('models.strength.high'), average: t('models.strength.average'), low: t('models.strength.low'), very_low: t('models.strength.very_low') })[s]}</option>
`)}
</select>
</div>
<div class="col-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('models.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">${t('models.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">${t('models.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">${t('models.add_model_title')}${t('models.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()}>${t('models.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">
${t('models.add_model_title')}
<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">${t('models.model_id')} <span class="text-muted fw-normal">${t('models.label.sent_to_api')}</span></label>
<input type="text" class="form-control form-control-sm" .value=${f.model_id} required
placeholder=${t('models.ph.model_id')}
@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">${t('models.name_alias')} <span class="text-muted fw-normal">${t('models.label.optional')}</span></label>
<input type="text" class="form-control form-control-sm" .value=${f.name}
placeholder=${f.model_id || t('models.ph.name_alias')}
@input=${(e) => this._setField('name', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">
${t('models.extra_params')} <span class="text-muted fw-normal">${t('models.extra_params_hint')}</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()}>${t('models.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving ? t('models.saving') : t('models.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">
${t('models.add_model_title')}
<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">${t('models.model_label')}</label>
<input type="text" class="form-control form-control-sm mb-1"
placeholder=${t('models.search')}
.value=${this._orSearch}
@input=${(e) => { this._orSearch = e.target.value; }} />
${this._orLoading
? html`<div class="text-muted py-2" style="font-size:0.82rem">${t('models.loading')}</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">${t('models.no_results')}</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">${t('models.name_alias')} <span class="text-muted fw-normal">${t('models.label.optional')}</span></label>
<input type="text" class="form-control form-control-sm" .value=${f.name}
placeholder=${selected?.name || f.model_id || t('models.ph.name_alias')}
@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">${t('models.label.max_output')} <span class="text-muted fw-normal">${t('models.label.max_output_hint')}</span></label>
<input type="number" class="form-control form-control-sm" .value=${f.max_tokens}
placeholder=${selected?.max_completion_tokens ? t('models.ph.max_tokens', { n: 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()}>${t('models.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving || !f.model_id}>
${this._saving ? t('models.saving') : t('models.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">
${t('models.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">
${t('models.edit_info')}
</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">${t('models.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">${unsafeHTML(t('models.name_help'))}</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()}>${t('models.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving ? t('models.saving') : t('models.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=${t('models.back')} @click=${this.onback}>
<i class="bi bi-arrow-left"></i>
</button>
` : ''}
<div>
<h2 class="llm-page-title">${t('models.llm.title')}</h2>
<span class="llm-page-count">${t('models.hub.count.many', { n: this._models.length })}</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>${t('models.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">${t('models.no_providers_llm')}</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>${t('models.list_empty_llm')}</p>
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
<i class="bi bi-plus-lg me-1"></i>${t('models.add_first')}
</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() : ''}
`;
}
}