Files
Skald-Circle/web/components/models-tts.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

488 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { html } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
// Audio formats accepted by the OpenAI-compatible `/audio/speech` endpoint.
const TTS_RESPONSE_FORMATS = ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm'];
function emptyTtsForm() {
return { provider_id: '', model_id: '', voice_id: '', name: '', description: '', instructions: '', response_format: '', priority: 100 };
}
export class ModelsTtsSection extends LightElement {
static properties = {
onback: { attribute: false },
_models: { state: true },
_providers: { state: true },
_modal: { state: true },
_form: { state: true },
_saving: { state: true },
_error: { state: true },
_provider: { state: true },
_remoteModels: { state: true },
_loadingModels: { state: true },
};
constructor() {
super();
this.onback = null;
this._models = [];
this._providers = [];
this._modal = null;
this._form = emptyTtsForm();
this._saving = false;
this._error = null;
this._provider = null;
this._remoteModels = null;
this._loadingModels = false;
}
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] = await Promise.all([
fetch('/api/tts/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;
}
}
// ── Add flow ──────────────────────────────────────────────────────────────────
_openAdd() {
this._error = null;
this._provider = null;
this._form = emptyTtsForm();
this._modal = 'pick-provider';
}
async _pickProvider(provider) {
this._provider = provider;
this._remoteModels = null;
this._form = { ...emptyTtsForm(), provider_id: provider.id };
this._loadingModels = true;
this._modal = 'pick-model';
try {
const res = await fetch(`/api/tts/providers/${provider.id}/models`);
this._remoteModels = res.ok ? await res.json() : null;
} catch {
this._remoteModels = null;
} finally {
this._loadingModels = false;
if (!this._remoteModels || this._remoteModels.length === 0) {
this._modal = 'add';
}
}
}
_pickRemoteModel(remote) {
this._form = {
...this._form,
model_id: remote.id,
name: remote.name,
description: remote.description ?? '',
instructions: remote.instructions ?? '',
};
this._modal = 'add';
}
// ── Edit flow ─────────────────────────────────────────────────────────────────
async _openEdit(m) {
this._error = null;
try {
const res = await fetch(`/api/tts/models/${m.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const r = await res.json();
this._provider = this._providers.find(p => p.id === r.provider_id) ?? null;
this._form = {
provider_id: r.provider_id,
model_id: r.model_id,
voice_id: r.voice_id ?? '',
name: r.name,
description: r.description ?? '',
instructions: r.instructions ?? '',
response_format: r.response_format ?? '',
priority: r.priority,
};
this._modal = { mode: 'edit', id: r.id, name: r.name };
} catch (e) {
this._error = e.message;
}
}
// ── Delete ────────────────────────────────────────────────────────────────────
async _delete(m) {
if (!confirm(t('models.confirm_delete', { type: t('models.hub.card.tts.title'), name: m.name }))) return;
try {
const res = await fetch(`/api/tts/models/${m.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
await this._load();
} catch (e) {
this._error = e.message;
}
}
// ── Submit ────────────────────────────────────────────────────────────────────
_payload() {
const f = this._form;
return {
provider_id: Number(f.provider_id),
model_id: f.model_id,
voice_id: f.voice_id || null,
name: f.name || f.model_id,
description: f.description || null,
instructions: f.instructions || null,
response_format: f.response_format || null,
priority: Number(f.priority) || 100,
};
}
async _submitAdd(e) {
e.preventDefault();
if (this._saving) return;
this._saving = true;
this._error = null;
try {
const res = await fetch('/api/tts/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this._payload()),
});
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;
}
}
async _submitEdit(e) {
e.preventDefault();
if (this._saving) return;
this._saving = true;
this._error = null;
const id = this._modal.id;
try {
const res = await fetch(`/api/tts/models/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this._payload()),
});
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;
}
}
_closeModal() { this._modal = null; this._error = null; }
// ── Render card ───────────────────────────────────────────────────────────────
_renderCard(m) {
const isPlugin = m.from_plugin;
return html`
<div class="llm-card">
<div class="llm-card-row1">
${isPlugin
? html`<span class="ig-source-badge ig-source-plugin">${t('models.source_plugin')}</span>`
: html`<span class="ig-source-badge ig-source-cloud">${t('models.source_cloud')}</span>`}
<span class="llm-card-name">${m.name}</span>
<div class="llm-card-actions">
${isPlugin ? html`
<span class="llm-btn-icon" title=${t('models.managed_plugin')} style="cursor:default;opacity:0.4">
<i class="bi bi-lock"></i>
</span>
` : html`
<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">
${!isPlugin ? html`<span class="llm-provider-name">${m.provider_name}</span>` : ''}
<span class="llm-model-id">${isPlugin ? m.model_id || m.id : m.model_id}</span>
${m.voice_id ? html`<span class="llm-model-id" style="opacity:0.6" title=${t('models.label.voice_id')}>${m.voice_id}</span>` : ''}
${m.response_format ? html`<span class="llm-model-id" style="opacity:0.6" title=${t('models.label.response_fmt')}>${m.response_format}</span>` : ''}
${!isPlugin ? html`<span class="ig-priority-tag" title=${t('models.priority')}>#${m.priority}</span>` : ''}
</div>
${m.description ? html`
<div class="ig-card-desc">${m.description}</div>
` : ''}
${m.instructions ? html`
<div class="ig-card-desc" style="font-style:italic;color:var(--bs-secondary)">${m.instructions}</div>
` : ''}
</div>
`;
}
// ── Modal: pick provider ──────────────────────────────────────────────────────
_renderPickProvider() {
const ttsProviders = this._providers.filter(p =>
Array.isArray(p.supported_types) && p.supported_types.includes('tts')
);
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_provider', { type: t('models.hub.card.tts.title') })}</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">
${ttsProviders.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: pick remote model ──────────────────────────────────────────────────
_renderPickModel() {
const p = this._provider;
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_type', { type: t('models.hub.card.tts.title') })}
<span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>
</div>
${this._loadingModels ? html`
<div class="text-center py-4 text-muted" style="font-size:0.85rem">
<div class="spinner-border spinner-border-sm me-2"></div>${t('models.loading')}
</div>
` : html`
<div class="tts-model-pick-list">
${(this._remoteModels ?? []).map(m => html`
<button class="tts-model-pick-item" @click=${() => this._pickRemoteModel(m)}>
<div class="tts-model-pick-row1">
<span class="tts-model-pick-name">${m.name}</span>
${m.cost_factor != null ? html`
<span class="tts-model-pick-cost" title=${t('models.tts.cost_multiplier')}>×${m.cost_factor.toFixed(1)}</span>
` : ''}
</div>
${m.description ? html`<div class="tts-model-pick-desc">${m.description}</div>` : ''}
${m.languages?.length ? html`
<div class="tts-model-pick-langs">${m.languages.slice(0, 6).join(', ')}${m.languages.length > 6 ? ` +${m.languages.length - 6}` : ''}</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>
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => { this._modal = 'add'; }}>
${t('models.enter_id')}
</button>
</div>
`}
</div>
</div>
`;
}
// ── Modal: add / edit form ────────────────────────────────────────────────────
_renderForm(isEdit = false) {
const f = this._form;
const p = this._provider;
const title = isEdit
? html`${t('models.edit')} <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.name}</span>`
: html`${t('models.add_model_type', { type: t('models.hub.card.tts.title') })} <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>`;
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">${title}</div>
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
<form @submit=${(e) => isEdit ? this._submitEdit(e) : this._submitAdd(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_tts')}
?disabled=${isEdit}
@input=${(e) => this._form = { ...this._form, model_id: e.target.value }} />
${isEdit ? html`<div class="form-text">${t('models.form.model_lock')}</div>` : ''}
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">
${t('models.label.voice_id')} <span class="text-muted fw-normal">${t('models.label.voice_id_hint')}</span>
</label>
<input type="text" class="form-control form-control-sm" .value=${f.voice_id}
placeholder=${t('models.ph.voice_id')}
@input=${(e) => this._form = { ...this._form, voice_id: e.target.value }} />
<div class="form-text">${unsafeHTML(t('models.form.voice_hint'))}</div>
</div>
<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=${f.model_id || t('models.ph.name_alias')}
@input=${(e) => this._form = { ...this._form, name: e.target.value }} />
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">
${t('models.label.description')} <span class="text-muted fw-normal">${t('models.label.description_hint')}</span>
</label>
<input type="text" class="form-control form-control-sm" .value=${f.description}
placeholder=${t('models.ph.description')}
@input=${(e) => this._form = { ...this._form, description: e.target.value }} />
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">
${t('models.label.instructions')} <span class="text-muted fw-normal">${t('models.label.instructions_hint')}</span>
</label>
<textarea class="form-control form-control-sm" rows="3" .value=${f.instructions}
placeholder=${t('models.ph.instructions')}
@input=${(e) => this._form = { ...this._form, instructions: e.target.value }}></textarea>
<div class="form-text">${t('models.form.instructions_hint')}</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">
${t('models.label.response_fmt')} <span class="text-muted fw-normal">${t('models.label.response_fmt_hint')}</span>
</label>
<select class="form-select form-select-sm" .value=${f.response_format}
@change=${(e) => this._form = { ...this._form, response_format: e.target.value }}>
<option value="">${t('models.form.response_default')}</option>
${TTS_RESPONSE_FORMATS.map(fmt => html`
<option value=${fmt}>${fmt}</option>
`)}
</select>
<div class="form-text">${unsafeHTML(t('models.form.response_hint'))}</div>
</div>
<div class="mb-3">
<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(f.priority)} min="1"
@input=${(e) => this._form = { ...this._form, priority: e.target.value }} />
<div class="form-text">${t('models.priority_hint_short')}</div>
</div>
<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') : isEdit ? t('models.save_changes') : t('models.add_model')}
</button>
</div>
</form>
</div>
</div>
`;
}
render() {
const ttsProviders = this._providers.filter(p =>
Array.isArray(p.supported_types) && p.supported_types.includes('tts')
);
const canAdd = ttsProviders.length > 0;
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.tts.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=${!canAdd}>
<i class="bi bi-plus-lg me-1"></i>${t('models.add')}
</button>
</div>
${!canAdd ? 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_tts')}</p>
</div>
</div>
` : ''}
${this._models.some(m => m.from_plugin) ? 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.readonly_plugin')}</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 ? html`
<div class="llm-empty-state">
<i class="bi bi-volume-up"></i>
<p>${t('models.list_empty_tts')}</p>
${canAdd ? html`
<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 => this._renderCard(m))}
</div>
</div>
${this._modal === 'pick-provider' ? this._renderPickProvider() : ''}
${this._modal === 'pick-model' ? this._renderPickModel() : ''}
${this._modal === 'add' ? this._renderForm(false) : ''}
${this._modal?.mode === 'edit' ? this._renderForm(true) : ''}
`;
}
}