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
This commit is contained in:
2026-07-18 21:38:42 +01:00
parent 2b35312abd
commit 126886e309
109 changed files with 6228 additions and 1390 deletions
+11 -6
View File
@@ -11,6 +11,7 @@ import { TasksPage } from './components/tasks/index.js';
import { AgentsPage } from './components/agents.js';
import { UsersPage } from './components/users-page.js';
import { RolesPage } from './components/roles-page.js';
import { SharedFoldersPage } from './components/shared-folders.js';
import { ConnectorsPage } from './components/connectors.js';
import { ConnectorDetailPage } from './components/connector-detail.js';
import { MarketplacePage } from './components/marketplace.js';
@@ -18,9 +19,9 @@ import { CatalogPage } from './components/catalog.js';
import { ProfilePage } from './components/profile-page.js';
import { ApprovalGroupsPage } from './components/approval-groups.js';
import { ApprovalRulesPage } from './components/approval-rules.js';
import { ConfigPage } from './components/config-page.js';
import { AgentInboxPage } from './components/agent-inbox.js';
import { HomePage } from './components/home-page.js';
import { ConfigPage } from './components/config-page.js';
import { DashboardPage } from './components/dashboard-page.js';
import { AgentInboxPage } from './components/agent-inbox.js';
import { LlmRequestsPage } from './components/llm-requests.js';
import { LlmRequestDetail } from './components/llm-request-detail.js';
import { SessionDetailPage } from './components/session-detail.js';
@@ -32,6 +33,7 @@ import { LoginPage } from './components/login-page.js';
// Register the global `openFile(path)` helper (window.openFile → location.hash).
import './lib/open-file.js';
import { initI18n } from './lib/i18n.js';
customElements.define('app-topbar', AppTopbar);
customElements.define('app-sidebar', AppSidebar);
@@ -46,6 +48,7 @@ customElements.define('tasks-page', TasksPage);
customElements.define('agents-page', AgentsPage);
customElements.define('users-page', UsersPage);
customElements.define('roles-page', RolesPage);
customElements.define('shared-folders-page', SharedFoldersPage);
customElements.define('connectors-page', ConnectorsPage);
customElements.define('connector-detail-page', ConnectorDetailPage);
customElements.define('marketplace-page', MarketplacePage);
@@ -53,9 +56,9 @@ customElements.define('catalog-page', CatalogPage);
customElements.define('profile-page', ProfilePage);
customElements.define('approval-groups-page', ApprovalGroupsPage);
customElements.define('approval-rules-page', ApprovalRulesPage);
customElements.define('config-page', ConfigPage);
customElements.define('agent-inbox-page', AgentInboxPage);
customElements.define('home-page', HomePage);
customElements.define('config-page', ConfigPage);
customElements.define('dashboard-page', DashboardPage);
customElements.define('agent-inbox-page', AgentInboxPage);
customElements.define('llm-requests-page', LlmRequestsPage);
customElements.define('llm-request-detail', LlmRequestDetail);
customElements.define('session-detail-page', SessionDetailPage);
@@ -98,5 +101,7 @@ window.addEventListener('llm-page-change', (e) => {
if (login) login.style.display = '';
return;
}
// Logged in: resolve the effective locale (user pref → instance default).
initI18n();
} catch { /* show app by default */ }
})();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+3 -2
View File
@@ -1,8 +1,9 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { InboxMixin } from '../lib/inbox-mixin.js';
import { t, I18nMixin } from '../lib/i18n.js';
export class AgentInboxPage extends InboxMixin(LightElement) {
export class AgentInboxPage extends I18nMixin(InboxMixin(LightElement)) {
static get properties() {
return {
@@ -58,7 +59,7 @@ export class AgentInboxPage extends InboxMixin(LightElement) {
<div class="page-panel">
<div class="page-panel-header">
<h5 class="mb-0">
Agent Inbox
${t('nav.inbox')}
${total > 0 ? html`<span class="badge bg-danger ms-2">${total}</span>` : nothing}
</h5>
<button class="inbox-refresh-btn" title="Refresh" @click=${() => this._loadInbox()}>
+42 -40
View File
@@ -1,6 +1,7 @@
import { html } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement, renderMarkdown } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const STRENGTH_COLORS = {
very_high: '#ef4444',
@@ -38,6 +39,8 @@ export class AgentsPage extends LightElement {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'agents';
this.style.display = this._open ? 'flex' : 'none';
@@ -46,6 +49,11 @@ export class AgentsPage extends LightElement {
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _loadList() {
this._loading = true;
this._error = null;
@@ -81,12 +89,16 @@ export class AgentsPage extends LightElement {
// ── Render helpers ────────────────────────────────────────────────────────
_strengthLabel(strength) {
return { very_high: t('agents.strength.very_high'), high: t('agents.strength.high'), average: t('agents.strength.average'), low: t('agents.strength.low'), very_low: t('agents.strength.very_low') }[strength] ?? strength;
}
_strengthDot(strength, size = '0.62rem') {
if (!strength) return html`<span style="opacity:0.3;font-size:${size}"></span>`;
if (!strength) return html`<span style="opacity:0.3;font-size:${size}">${'—'}</span>`;
return html`
<span class="agent-strength-dot"
style="background:${STRENGTH_COLORS[strength] ?? '#888'}"
title=${STRENGTH_LABELS[strength] ?? strength}></span>
title=${this._strengthLabel(strength)}></span>
`;
}
@@ -113,7 +125,7 @@ export class AgentsPage extends LightElement {
${agent.strength ? html`
<span class="agent-meta-item">
${this._strengthDot(agent.strength)}
<span>${STRENGTH_LABELS[agent.strength] ?? agent.strength}</span>
<span>${this._strengthLabel(agent.strength)}</span>
</span>
` : ''}
${agent.scope ? html`${this._scopePill(agent.scope)}` : ''}
@@ -142,18 +154,16 @@ export class AgentsPage extends LightElement {
}
_renderList() {
if (this._loading) return html`<div class="text-muted py-4 text-center">Loading…</div>`;
if (this._loading) return html`<div class="text-muted py-4 text-center">${t('agents.loading')}</div>`;
if (this._error) return html`<div class="alert alert-danger py-2" style="font-size:0.85rem">${this._error}</div>`;
if (this._agents.length === 0) return html`<p class="text-muted">No agents found.</p>`;
// Group by role: chat entry-points, dispatchable task executors, and
// runtime-internal system agents (e.g. tic).
if (this._agents.length === 0) return html`<p class="text-muted">${t('agents.empty')}</p>`;
const chat = this._agents.filter(a => a.type === 'chat');
const task = this._agents.filter(a => a.type === 'task');
const system = this._agents.filter(a => a.type === 'system');
return html`
${this._renderSection('Chat', chat)}
${this._renderSection('Task Executors', task)}
${this._renderSection('System', system)}
${this._renderSection(t('agents.section.chat'), chat)}
${this._renderSection(t('agents.section.task'), task)}
${this._renderSection(t('agents.section.system'), system)}
`;
}
@@ -167,7 +177,7 @@ export class AgentsPage extends LightElement {
<td>${this._strengthDot(m.strength)}</td>
<td>
<span class="fw-semibold">${m.name}</span>
${m.is_default ? html`<span class="badge bg-primary ms-1" style="font-size:0.6rem">default</span>` : ''}
${m.is_default ? html`<span class="badge bg-primary ms-1" style="font-size:0.6rem">${t('agents.detail.default')}</span>` : ''}
</td>
<td class="text-muted agent-model-id">${m.model_id}</td>
<td>
@@ -178,17 +188,16 @@ export class AgentsPage extends LightElement {
}
_renderDetail() {
if (this._loading && !this._detail) return html`<div class="text-muted py-4 text-center">Loading…</div>`;
if (this._loading && !this._detail) return html`<div class="text-muted py-4 text-center">${t('agents.loading')}</div>`;
if (!this._detail) return '';
const { meta, prompt, models } = this._detail;
return html`
<div class="agent-detail">
<!-- Header -->
<div class="agent-detail-header">
<button class="btn btn-sm btn-link px-0" @click=${() => this._back()}>
<i class="bi bi-arrow-left me-1"></i>Agents
<i class="bi bi-arrow-left me-1"></i>${t('agents.back')}
</button>
<div class="agent-detail-title-row">
${meta.icon ? html`
@@ -204,28 +213,27 @@ export class AgentsPage extends LightElement {
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
<div class="agent-detail-body">
<!-- Meta -->
<section class="agent-section">
<h3 class="agent-section-title">Metadata</h3>
<h3 class="agent-section-title">${t('agents.detail.meta')}</h3>
<table class="agent-meta-table">
<tbody>
<tr><td class="agent-meta-key">ID</td><td><code>${meta.id}</code></td></tr>
<tr><td class="agent-meta-key">${t('agents.detail.id')}</td><td><code>${meta.id}</code></td></tr>
${meta.strength ? html`
<tr><td class="agent-meta-key">Strength</td>
<tr><td class="agent-meta-key">${t('agents.detail.strength')}</td>
<td class="d-flex align-items-center gap-2">
${this._strengthDot(meta.strength)}
${STRENGTH_LABELS[meta.strength] ?? meta.strength}
${this._strengthLabel(meta.strength)}
</td>
</tr>
` : ''}
${meta.scope ? html`
<tr><td class="agent-meta-key">Scope</td><td>${this._scopePill(meta.scope)}</td></tr>
<tr><td class="agent-meta-key">${t('agents.detail.scope')}</td><td>${this._scopePill(meta.scope)}</td></tr>
` : ''}
${meta.client ? html`
<tr><td class="agent-meta-key">Pinned model</td><td><code>${meta.client}</code></td></tr>
<tr><td class="agent-meta-key">${t('agents.detail.pinned_model')}</td><td><code>${meta.client}</code></td></tr>
` : ''}
${meta.inject_memory?.length ? html`
<tr><td class="agent-meta-key">Memory files</td>
<tr><td class="agent-meta-key">${t('agents.detail.memory_files')}</td>
<td>${meta.inject_memory.map(f => html`<div style="font-size:0.8rem"><code>${f}</code></div>`)}</td>
</tr>
` : ''}
@@ -233,25 +241,23 @@ export class AgentsPage extends LightElement {
</table>
</section>
<!-- Model resolution order -->
<section class="agent-section">
<h3 class="agent-section-title">Model resolution order</h3>
<h3 class="agent-section-title">${t('agents.detail.model_order')}</h3>
<p class="text-muted mb-2" style="font-size:0.8rem">
Models sorted by how well they match this agent's requirements.
The system uses the first available model from the top.
${t('agents.detail.model_order_desc')}
</p>
${models.length === 0
? html`<p class="text-muted" style="font-size:0.85rem">No models configured.</p>`
? html`<p class="text-muted" style="font-size:0.85rem">${t('agents.detail.no_models')}</p>`
: html`
<div class="table-responsive">
<table class="table table-sm agent-model-table mb-0">
<thead>
<tr>
<th>#</th>
<th>Strength</th>
<th>Name</th>
<th>Model ID</th>
<th>Scope</th>
<th>${t('agents.table.rank')}</th>
<th>${t('agents.table.strength')}</th>
<th>${t('agents.table.name')}</th>
<th>${t('agents.table.model_id')}</th>
<th>${t('agents.table.scope')}</th>
</tr>
</thead>
<tbody>
@@ -263,9 +269,8 @@ export class AgentsPage extends LightElement {
}
</section>
<!-- System prompt -->
<section class="agent-section">
<h3 class="agent-section-title">System prompt</h3>
<h3 class="agent-section-title">${t('agents.detail.prompt')}</h3>
<div class="agent-prompt-body markdown-body">
${unsafeHTML(renderMarkdown(prompt))}
</div>
@@ -284,17 +289,14 @@ export class AgentsPage extends LightElement {
? this._renderDetail()
: html`
<div class="agents-page-header">
<h2 class="llm-page-title">Agents</h2>
<h2 class="llm-page-title">${t('agents.title')}</h2>
</div>
<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-1"><strong>Read-only view.</strong> Agents are defined by files in <code>agents/</code>
— to add, remove, or modify an agent, edit the corresponding <code>AGENT.md</code> file in that
directory.</p>
<p class="mb-0">You can also ask <strong>Copilot</strong> (top bar) to create a new agent for you
— just describe what it should do and it will set up all the files automatically.</p>
<p class="mb-1">${unsafeHTML(t('agents.banner.title'))}</p>
<p class="mb-0">${unsafeHTML(t('agents.banner.text'))}</p>
</div>
</div>
+49 -47
View File
@@ -1,5 +1,7 @@
import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
export class ApprovalGroupsPage extends LightElement {
static properties = {
@@ -31,6 +33,8 @@ export class ApprovalGroupsPage extends LightElement {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', async (e) => {
this._open = e.detail.page === 'approval';
this.style.display = this._open ? 'flex' : 'none';
@@ -58,6 +62,11 @@ export class ApprovalGroupsPage extends LightElement {
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _load() {
this._error = null;
try {
@@ -65,8 +74,8 @@ export class ApprovalGroupsPage extends LightElement {
fetch('/api/tool-permission-groups'),
fetch('/api/approval/rules'),
]);
if (!gRes.ok) throw new Error(`Groups: HTTP ${gRes.status}`);
if (!rRes.ok) throw new Error(`Rules: HTTP ${rRes.status}`);
if (!gRes.ok) throw new Error(`HTTP ${gRes.status}`);
if (!rRes.ok) throw new Error(`HTTP ${rRes.status}`);
const groups = await gRes.json();
this._groups = groups.sort((a, b) => {
if (a.id === 'default') return -1;
@@ -114,8 +123,8 @@ export class ApprovalGroupsPage extends LightElement {
async _saveGroup() {
const isNew = this._groupEditId === 'new';
if (!this._groupForm.name.trim()) { this._error = 'Group name is required.'; return; }
if (isNew && !this._groupForm.id.trim()) { this._error = 'Group ID is required.'; return; }
if (!this._groupForm.name.trim()) { this._error = t('security.error.group_name_required'); return; }
if (isNew && !this._groupForm.id.trim()) { this._error = t('security.error.group_id_required'); return; }
this._groupSaving = true;
this._error = null;
try {
@@ -141,8 +150,8 @@ export class ApprovalGroupsPage extends LightElement {
async _deleteGroup(group) {
const count = this._rulesForGroup(group.id).length;
const msg = count > 0
? `Delete group "${group.name}" and its ${count} rule${count === 1 ? '' : 's'}?`
: `Delete group "${group.name}"?`;
? t('security.confirm.delete_with_rules', { name: group.name, n: count, s: count === 1 ? '' : 's' })
: t('security.confirm.delete', { name: group.name });
if (!confirm(msg)) return;
try {
const res = await fetch(`/api/tool-permission-groups/${group.id}`, { method: 'DELETE' });
@@ -159,7 +168,7 @@ export class ApprovalGroupsPage extends LightElement {
this._duplicateOf = group;
this._dupForm = {
id: `${group.id}_copy`,
name: `Copy of ${group.name}`,
name: `${t('security.duplicate')} ${group.name}`,
};
this._groupEditId = null; // close any open create/rename form
}
@@ -167,8 +176,8 @@ export class ApprovalGroupsPage extends LightElement {
_cancelDuplicate() { this._duplicateOf = null; }
async _saveDuplicate() {
if (!this._dupForm.name.trim()) { this._error = 'Name is required.'; return; }
if (!this._dupForm.id.trim()) { this._error = 'ID is required.'; return; }
if (!this._dupForm.name.trim()) { this._error = t('security.error.name_required'); return; }
if (!this._dupForm.id.trim()) { this._error = t('security.error.id_required'); return; }
this._dupSaving = true;
this._error = null;
try {
@@ -196,7 +205,7 @@ export class ApprovalGroupsPage extends LightElement {
<div class="apr-form">
<div class="apr-form-header">
<i class="bi bi-collection"></i>
<span>${isNew ? 'New group' : 'Rename group'}</span>
<span>${isNew ? t('security.new_group') : t('security.rename_group')}</span>
<button class="apr-form-close" @click=${() => this._cancelGroupEdit()}>
<i class="bi bi-x"></i>
</button>
@@ -205,41 +214,41 @@ export class ApprovalGroupsPage extends LightElement {
<div class="row g-3">
${isNew ? html`
<div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">ID <span class="text-danger">*</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('security.form.id')} <span class="text-danger">*</span></label>
<input
class="form-control form-control-sm font-monospace"
placeholder="e.g. cron_strict"
placeholder=${t('security.form.id_ph')}
.value=${f.id}
@input=${(e) => this._patchGroup('id', e.target.value)}
/>
<div class="form-text" style="font-size:0.75rem">Lowercase slug, no spaces. Cannot be changed later.</div>
<div class="form-text" style="font-size:0.75rem">${t('security.form.id_hint')}</div>
</div>
` : nothing}
<div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Name <span class="text-danger">*</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('security.form.name')} <span class="text-danger">*</span></label>
<input
class="form-control form-control-sm"
placeholder="e.g. Cron strict"
placeholder=${t('security.form.name_ph')}
.value=${f.name}
@input=${(e) => this._patchGroup('name', e.target.value)}
/>
</div>
<div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Description <span class="text-muted fw-normal">(optional)</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('security.form.description')} <span class="text-muted fw-normal">${t('approval.label.optional')}</span></label>
<input
class="form-control form-control-sm"
placeholder="Short description…"
placeholder=${t('security.form.description_ph')}
.value=${f.description}
@input=${(e) => this._patchGroup('description', e.target.value)}
/>
</div>
</div>
<div class="apr-form-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelGroupEdit()}>Cancel</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelGroupEdit()}>${t('security.form.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._saveGroup()} ?disabled=${this._groupSaving}>
${this._groupSaving
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
: html`<i class="bi bi-check-lg me-1"></i>Save`}
? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('security.form.saving')}`
: html`<i class="bi bi-check-lg me-1"></i>${t('security.form.save')}`}
</button>
</div>
</div>
@@ -256,7 +265,7 @@ export class ApprovalGroupsPage extends LightElement {
<div class="apr-form">
<div class="apr-form-header">
<i class="bi bi-copy"></i>
<span>Duplicate <strong>${src.name}</strong></span>
<span>${t('security.duplicate_title', { name: src.name })}</span>
<button class="apr-form-close" @click=${() => this._cancelDuplicate()}>
<i class="bi bi-x"></i>
</button>
@@ -264,7 +273,7 @@ export class ApprovalGroupsPage extends LightElement {
<div class="apr-form-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">New name <span class="text-danger">*</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('security.form.new_name')} <span class="text-danger">*</span></label>
<input
class="form-control form-control-sm"
.value=${f.name}
@@ -272,27 +281,27 @@ export class ApprovalGroupsPage extends LightElement {
/>
</div>
<div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">New ID <span class="text-danger">*</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('security.form.new_id')} <span class="text-danger">*</span></label>
<input
class="form-control form-control-sm font-monospace"
.value=${f.id}
@input=${(e) => { this._dupForm = { ...this._dupForm, id: e.target.value }; }}
/>
<div class="form-text" style="font-size:0.75rem">Lowercase slug, no spaces. Cannot be changed later.</div>
<div class="form-text" style="font-size:0.75rem">${t('security.form.id_hint')}</div>
</div>
</div>
<div class="apr-form-body" style="padding:0;margin-top:0.5rem">
<div class="alert alert-info py-2 mb-0" style="font-size:0.8rem">
<i class="bi bi-info-circle me-1"></i>
All <strong>${this._rulesForGroup(src.id).length}</strong> rule${this._rulesForGroup(src.id).length === 1 ? '' : 's'} from <em>${src.name}</em> will be copied.
${unsafeHTML(t('security.form.copy_info', { n: this._rulesForGroup(src.id).length, s: this._rulesForGroup(src.id).length === 1 ? '' : 's', name: src.name }))}
</div>
</div>
<div class="apr-form-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelDuplicate()}>Cancel</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelDuplicate()}>${t('security.form.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._saveDuplicate()} ?disabled=${this._dupSaving}>
${this._dupSaving
? html`<span class="spinner-border spinner-border-sm me-1"></span>Duplicating`
: html`<i class="bi bi-copy me-1"></i>Duplicate`}
? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('security.duplicating')}`
: html`<i class="bi bi-copy me-1"></i>${t('security.duplicate')}`}
</button>
</div>
</div>
@@ -308,24 +317,24 @@ export class ApprovalGroupsPage extends LightElement {
return html`
<div class="apr-card apr-group-card" @click=${() => this._navigateTo(group)}>
<div class="apr-card-row1">
${isDefault ? html`<span class="apr-group-default-badge">Default</span>` : nothing}
${isDefault ? html`<span class="apr-group-default-badge">${t('security.card.default_badge')}</span>` : nothing}
<span class="apr-group-name">${group.name}</span>
<span class="apr-priority-badge ms-auto" title="${count} rule${count === 1 ? '' : 's'}">
<span class="apr-priority-badge ms-auto" title="${count === 1 ? t('security.card.rule_count', { n: count }) : t('security.card.rule_count_plural', { n: count })}">
<i class="bi bi-list-ul"></i>
${count}
</span>
<div class="apr-card-actions" @click=${(e) => e.stopPropagation()}>
<button class="apr-btn-icon" title="Duplicate"
<button class="apr-btn-icon" title=${t('security.card.duplicate')}
@click=${(e) => { e.stopPropagation(); this._startDuplicate(group); }}>
<i class="bi bi-copy"></i>
</button>
<button class="apr-btn-icon apr-btn-edit" title="Rename"
<button class="apr-btn-icon apr-btn-edit" title=${t('security.card.rename')}
@click=${(e) => { e.stopPropagation(); this._startEditGroup(group); }}>
<i class="bi bi-pencil"></i>
</button>
<button
class="apr-btn-icon apr-btn-delete"
title=${isDefault ? 'Cannot delete the default group' : 'Delete group'}
title=${isDefault ? t('security.card.delete_disabled') : t('security.card.delete')}
?disabled=${isDefault}
@click=${(e) => { e.stopPropagation(); if (!isDefault) this._deleteGroup(group); }}
>
@@ -349,12 +358,12 @@ export class ApprovalGroupsPage extends LightElement {
<div class="apr-page">
<div class="apr-header">
<h2 class="apr-title">
<i class="bi bi-shield-check me-2"></i>Security
<i class="bi bi-shield-check me-2"></i>${t('security.title')}
</h2>
<div class="apr-header-right">
<span class="apr-header-count">${this._groups.length} group${this._groups.length === 1 ? '' : 's'}</span>
<span class="apr-header-count">${this._groups.length === 1 ? t('security.group_count', { n: this._groups.length }) : t('security.group_count_plural', { n: this._groups.length })}</span>
<button class="btn btn-sm btn-primary" @click=${() => this._startNewGroup()}>
<i class="bi bi-plus-lg me-1"></i>New group
<i class="bi bi-plus-lg me-1"></i>${t('security.new_group')}
</button>
</div>
</div>
@@ -362,15 +371,8 @@ export class ApprovalGroupsPage extends LightElement {
<div class="agent-info-banner" style="margin: 14px 20px 0">
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
<div class="agent-info-banner-body">
<p class="mb-1">
<strong>Permission groups</strong> are named sets of approval rules.
A session's active <strong>Agent Profile</strong> determines which group applies —
that group's rules are evaluated first, with the <strong>Default</strong> group as fallback.
</p>
<p class="mb-0">
Click a group to view and manage its rules.
The <strong>Default</strong> group cannot be deleted, but its rules can be edited freely.
</p>
<p class="mb-1">${unsafeHTML(t('security.banner.text1'))}</p>
<p class="mb-0">${unsafeHTML(t('security.banner.text2'))}</p>
</div>
</div>
@@ -385,9 +387,9 @@ export class ApprovalGroupsPage extends LightElement {
${this._groups.length === 0 ? html`
<div class="apr-empty">
<i class="bi bi-collection"></i>
<p>No groups yet.</p>
<p>${t('security.empty.title')}</p>
<button class="btn btn-sm btn-primary" @click=${() => this._startNewGroup()}>
<i class="bi bi-plus-lg me-1"></i>Create first group
<i class="bi bi-plus-lg me-1"></i>${t('security.create_first')}
</button>
</div>
` : this._groups.map(g => this._renderGroupCard(g))}
+120 -98
View File
@@ -1,40 +1,29 @@
import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const DEFAULT_PRIORITY = 999999;
const ACTIONS = ['require', 'allow', 'deny'];
const ACTION_STYLE = {
require: { icon: 'bi-person-check', label: 'Require', bg: 'rgba(234,179,8,0.12)', color: '#a16207' },
allow: { icon: 'bi-check-circle', label: 'Allow', bg: 'rgba(34,197,94,0.12)', color: '#16a34a' },
deny: { icon: 'bi-slash-circle', label: 'Deny', bg: 'rgba(239,68,68,0.12)', color: '#dc2626' },
require: { icon: 'bi-person-check', bg: 'rgba(234,179,8,0.12)', color: '#a16207' },
allow: { icon: 'bi-check-circle', bg: 'rgba(34,197,94,0.12)', color: '#16a34a' },
deny: { icon: 'bi-slash-circle', bg: 'rgba(239,68,68,0.12)', color: '#dc2626' },
};
const CATEGORY_LABELS = {
filesystem: 'File System',
shell: 'Shell',
subagent: 'Agents',
introspection: 'Introspection',
config: 'Config',
// Tools injected dynamically outside the ToolRegistry (interface/plugin/
// provider tools), surfaced via runtime discovery — see docs/approval.
dynamic: 'Dynamic',
};
const CATEGORY_ORDER = [
'File System', 'Shell', 'Agents', 'Introspection', 'Config', 'Dynamic',
];
const CATEGORY_ORDER = ['filesystem', 'shell', 'subagent', 'introspection', 'config', 'dynamic'];
// File System permission model. Each path row maps to exactly one approval rule via a
// synthetic `@fs_*` tool_pattern token (understood by the backend matcher). A single
// selector collapses the (access-class × action) axes into the mental model from the
// mockup: Allow read / Allow write / Deny / Require.
const FS_ACCESS = {
allow_read: { tool_pattern: '@fs_read', action: 'allow', label: 'Allow read' },
allow_write: { tool_pattern: '@fs_any', action: 'allow', label: 'Allow write' },
deny: { tool_pattern: '@fs_any', action: 'deny', label: 'Deny' },
require: { tool_pattern: '@fs_any', action: 'require', label: 'Require' },
allow_read: { tool_pattern: '@fs_read', action: 'allow' },
allow_write: { tool_pattern: '@fs_any', action: 'allow' },
deny: { tool_pattern: '@fs_any', action: 'deny' },
require: { tool_pattern: '@fs_any', action: 'require' },
};
// Priority band for the settable "Default" row (below specific fs path rules, above the
// global `*` catch-all at 999999).
@@ -91,6 +80,8 @@ export class ApprovalRulesPage extends LightElement {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
if (e.detail.page !== 'approval') {
this._open = false;
@@ -118,6 +109,11 @@ export class ApprovalRulesPage extends LightElement {
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _load() {
this._error = null;
try {
@@ -125,8 +121,8 @@ export class ApprovalRulesPage extends LightElement {
fetch('/api/approval/rules'),
fetch('/api/approval/tools'),
]);
if (!rulesRes.ok) throw new Error(`Rules: HTTP ${rulesRes.status}`);
if (!toolsRes.ok) throw new Error(`Tools: HTTP ${toolsRes.status}`);
if (!rulesRes.ok) throw new Error(`HTTP ${rulesRes.status}`);
if (!toolsRes.ok) throw new Error(`HTTP ${toolsRes.status}`);
this._rules = await rulesRes.json();
this._tools = await toolsRes.json();
} catch (e) {
@@ -331,7 +327,7 @@ export class ApprovalRulesPage extends LightElement {
async _addFsRule() {
const clean = this._normalizeFsPath(this._fsNewPath);
if (!clean) { this._error = 'Enter a directory path.'; return; }
if (!clean) { this._error = t('approval.error.enter_path'); return; }
const access = FS_ACCESS[this._fsNewAccess] ?? FS_ACCESS.allow_read;
this._fsSaving = new Set([...this._fsSaving, 'new']);
this._error = null;
@@ -386,7 +382,7 @@ export class ApprovalRulesPage extends LightElement {
}
async _deleteFsRule(rule) {
if (!confirm(`Remove File System rule for "${this._fsDisplayPath(rule)}"?`)) return;
if (!confirm(t('approval.confirm.delete_fs', { path: this._fsDisplayPath(rule) }))) return;
this._fsSaving = new Set([...this._fsSaving, rule.id]);
this._error = null;
try {
@@ -443,15 +439,25 @@ export class ApprovalRulesPage extends LightElement {
// ── Tool grouping ─────────────────────────────────────────────────────────────
_catLabel(key) {
return {
filesystem: t('approval.category.filesystem'),
shell: t('approval.category.shell'),
subagent: t('approval.category.subagent'),
introspection: t('approval.category.introspection'),
config: t('approval.category.config'),
dynamic: t('approval.category.dynamic'),
}[key] ?? key;
}
_groupedTools() {
if (!this._tools) return [];
const map = new Map();
const metaMap = new Map(); // category key → { description }
const metaMap = new Map();
for (const t of this._tools.built_in) {
// Filesystem tools are gated by path in the File System panel, not per-tool here.
if (t.category === 'filesystem') continue;
const cat = t.category ? (CATEGORY_LABELS[t.category] ?? t.category) : 'Other';
const cat = t.category || 'other';
if (!map.has(cat)) map.set(cat, []);
map.get(cat).push(t);
}
@@ -459,7 +465,7 @@ export class ApprovalRulesPage extends LightElement {
for (const t of this._tools.mcp) {
const serverId = t.server ?? t.name;
const meta = servers[serverId] ?? {};
const key = `MCP · ${meta.friendly_name ?? serverId}`;
const key = `mcp:${serverId}`;
if (!map.has(key)) {
map.set(key, []);
if (meta.description) metaMap.set(key, meta.description);
@@ -472,9 +478,9 @@ export class ApprovalRulesPage extends LightElement {
if (map.has(cat)) result.push([cat, map.get(cat), null]);
}
for (const [key, tools] of map.entries()) {
if (!CATEGORY_ORDER.includes(key) && key !== 'Other') result.push([key, tools, metaMap.get(key) ?? null]);
if (!CATEGORY_ORDER.includes(key) && key !== 'other') result.push([key, tools, metaMap.get(key) ?? null]);
}
if (map.has('Other')) result.push(['Other', map.get('Other'), null]);
if (map.has('other')) result.push(['other', map.get('other'), null]);
return result;
}
@@ -513,14 +519,14 @@ export class ApprovalRulesPage extends LightElement {
_selectTool(name) { this._form = { ...this._form, tool_pattern: name }; }
async _save() {
if (!this._form.tool_pattern.trim()) { this._error = 'Tool pattern is required.'; return; }
if (!this._form.tool_pattern.trim()) { this._error = t('approval.error.tool_required'); return; }
const p = Number(this._form.priority);
if (this._formMode === 'override' && p >= 0) {
this._error = 'Override rules must have priority < 0.'; return;
this._error = t('approval.error.override_prio'); return;
}
if (this._formMode === 'lowprio' && (p <= 0 || p >= DEFAULT_PRIORITY)) {
this._error = `Low priority rules must have priority between 1 and ${DEFAULT_PRIORITY - 1}.`; return;
this._error = t('approval.error.lowprio_range', { max: DEFAULT_PRIORITY - 1 }); return;
}
this._saving = true;
@@ -555,7 +561,7 @@ export class ApprovalRulesPage extends LightElement {
}
async _delete(rule) {
if (!confirm(`Delete rule for "${rule.tool_pattern}"?`)) return;
if (!confirm(t('approval.confirm.delete_rule', { pattern: rule.tool_pattern }))) return;
try {
const res = await fetch(`/api/approval/rules/${rule.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
@@ -582,8 +588,8 @@ export class ApprovalRulesPage extends LightElement {
const current = this._form.tool_pattern;
const allTools = [
{ name: '*', description: 'Any tool', source: 'glob', server: null },
{ name: 'mcp__*', description: 'Any MCP tool', source: 'glob', server: null },
{ name: '*', description: t('approval.tool.any'), source: 'glob', server: null },
{ name: 'mcp__*', description: t('approval.tool.any_mcp'), source: 'glob', server: null },
...this._tools.built_in,
...this._tools.mcp,
];
@@ -596,17 +602,21 @@ export class ApprovalRulesPage extends LightElement {
);
const groups = {};
for (const t of filtered) {
const key = t.source === 'mcp' ? `MCP · ${t.server}` : t.source === 'built-in' ? 'Built-in' : 'Glob';
for (const tool of filtered) {
const key = tool.source === 'mcp'
? t('approval.tool.group_mcp', { server: tool.server })
: tool.source === 'built-in'
? t('approval.tool.group_builtin')
: t('approval.tool.group_glob');
if (!groups[key]) groups[key] = [];
groups[key].push(t);
groups[key].push(tool);
}
return html`
<div class="apr-tool-picker">
<input
class="form-control form-control-sm mb-2"
placeholder="Search tools…"
placeholder=${t('approval.tool.search')}
.value=${this._toolFilter}
@input=${(e) => { this._toolFilter = e.target.value; }}
/>
@@ -624,7 +634,7 @@ export class ApprovalRulesPage extends LightElement {
</button>
`)}
`)}
${filtered.length === 0 ? html`<div class="text-muted p-2">No results</div>` : nothing}
${filtered.length === 0 ? html`<div class="text-muted p-2">${t('approval.tool.no_results')}</div>` : nothing}
</div>
</div>
`;
@@ -640,8 +650,8 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-form-header">
<i class="bi ${isOverride ? 'bi-exclamation-triangle' : 'bi-arrow-down-circle'}"></i>
<span>${this._editingId === 'new'
? (isOverride ? 'New override rule' : 'New low priority rule')
: 'Edit rule'}</span>
? (isOverride ? t('approval.form.new_override') : t('approval.form.new_lowprio'))
: t('approval.form.edit')}</span>
<button class="apr-form-close" @click=${() => this._cancelEdit()}>
<i class="bi bi-x"></i>
</button>
@@ -649,31 +659,31 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-form-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Tool pattern <span class="text-danger">*</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.tool_pattern')} <span class="text-danger">*</span></label>
<input
class="form-control form-control-sm font-monospace"
placeholder="e.g. mcp__whatsapp__* or execute_cmd"
placeholder=${t('approval.form.tool_pattern_ph')}
.value=${f.tool_pattern}
@input=${(e) => this._patch('tool_pattern', e.target.value)}
/>
<div class="form-text" style="font-size:0.75rem">Use <code>*</code> as a trailing wildcard, e.g. <code>mcp__whatsapp__*</code></div>
<div class="form-text" style="font-size:0.75rem">${unsafeHTML(t('approval.form.tool_pattern_hint'))}</div>
</div>
<div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Select tool</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.select_tool')}</label>
${this._renderToolPicker()}
</div>
<div class="col-12">
<label class="form-label fw-semibold" style="font-size:0.82rem">Path pattern <span class="text-muted fw-normal">(optional)</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.path_pattern')} <span class="text-muted fw-normal">${t('approval.label.optional')}</span></label>
<input
class="form-control form-control-sm font-monospace"
placeholder="e.g. data/* or data/notes/*"
placeholder=${t('approval.form.path_pattern_ph')}
.value=${f.path_pattern}
@input=${(e) => this._patch('path_pattern', e.target.value)}
/>
<div class="form-text" style="font-size:0.75rem">Filter by file path. Use <code>*</code> as a wildcard.</div>
<div class="form-text" style="font-size:0.75rem">${unsafeHTML(t('approval.form.path_pattern_hint'))}</div>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Action</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.action')}</label>
<select
class="form-select form-select-sm"
.value=${f.action}
@@ -683,7 +693,7 @@ export class ApprovalRulesPage extends LightElement {
</select>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Priority</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.priority')}</label>
<input
type="number"
class="form-control form-control-sm"
@@ -692,47 +702,47 @@ export class ApprovalRulesPage extends LightElement {
/>
<div class="form-text" style="font-size:0.75rem">
${isOverride
? html`Must be <strong>&lt; 0</strong> (e.g. 10)`
: html`Must be <strong>1 ${DEFAULT_PRIORITY - 1}</strong>`}
? unsafeHTML(t('approval.form.priority_override_hint'))
: unsafeHTML(t('approval.form.priority_lowprio_hint', { max: DEFAULT_PRIORITY - 1 }))}
</div>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Source <span class="text-muted fw-normal">(optional)</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.source')} <span class="text-muted fw-normal">${t('approval.label.optional')}</span></label>
<select
class="form-select form-select-sm"
@change=${(e) => this._patch('source', e.target.value)}
>
<option value="" ?selected=${!f.source}>Any</option>
<option value="" ?selected=${!f.source}>${t('approval.form.source_any')}</option>
${['web', 'telegram', 'cron'].map(s => html`
<option value=${s} ?selected=${f.source === s}>${s}</option>
`)}
</select>
</div>
<div class="col-sm-6">
<label class="form-label fw-semibold" style="font-size:0.82rem">Agent ID <span class="text-muted fw-normal">(optional)</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.agent_id')} <span class="text-muted fw-normal">${t('approval.label.optional')}</span></label>
<input
class="form-control form-control-sm font-monospace"
placeholder="main (empty = any)"
placeholder=${t('approval.form.agent_id_ph')}
.value=${f.agent_id}
@input=${(e) => this._patch('agent_id', e.target.value)}
/>
</div>
<div class="col-sm-6">
<label class="form-label fw-semibold" style="font-size:0.82rem">Note <span class="text-muted fw-normal">(optional)</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('approval.form.note')} <span class="text-muted fw-normal">${t('approval.label.optional')}</span></label>
<input
class="form-control form-control-sm"
placeholder="Short description…"
placeholder=${t('approval.form.note_ph')}
.value=${f.note}
@input=${(e) => this._patch('note', e.target.value)}
/>
</div>
</div>
<div class="apr-form-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelEdit()}>Cancel</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelEdit()}>${t('approval.form.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._save()} ?disabled=${this._saving}>
${this._saving
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
: html`<i class="bi bi-check-lg me-1"></i>Save`}
? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('approval.form.saving')}`
: html`<i class="bi bi-check-lg me-1"></i>${t('approval.form.save')}`}
</button>
</div>
</div>
@@ -750,18 +760,18 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-card-row1">
<span class="apr-action-badge">
<i class="bi ${s.icon}"></i>
${s.label}
${{ require: t('approval.action.require'), allow: t('approval.action.allow'), deny: t('approval.action.deny') }[rule.action] ?? rule.action}
</span>
<code class="apr-pattern">${rule.tool_pattern}</code>
<span class="apr-priority-badge" title="Priority">
<span class="apr-priority-badge" title=${t('approval.card.priority')}>
<i class="bi bi-list-ol"></i>
${rule.priority}
</span>
<div class="apr-card-actions">
<button class="apr-btn-icon apr-btn-edit" title="Edit" @click=${() => this._startEdit(rule)}>
<button class="apr-btn-icon apr-btn-edit" title=${t('approval.card.edit')} @click=${() => this._startEdit(rule)}>
<i class="bi bi-pencil"></i>
</button>
<button class="apr-btn-icon apr-btn-delete" title="Delete" @click=${() => this._delete(rule)}>
<button class="apr-btn-icon apr-btn-delete" title=${t('approval.card.delete')} @click=${() => this._delete(rule)}>
<i class="bi bi-trash"></i>
</button>
</div>
@@ -784,10 +794,10 @@ export class ApprovalRulesPage extends LightElement {
_renderChipGroup(currentAction, onChange) {
const chips = [
{ action: null, label: '—' },
{ action: 'allow', label: 'Allow' },
{ action: 'require', label: 'Req' },
{ action: 'deny', label: 'Deny' },
{ action: null, label: t('approval.chip.unset') },
{ action: 'allow', label: t('approval.action.allow') },
{ action: 'require', label: t('approval.chip.req') },
{ action: 'deny', label: t('approval.action.deny') },
];
return html`
<div class="apr-chip-group">
@@ -827,11 +837,14 @@ export class ApprovalRulesPage extends LightElement {
const open = this._openSections.has(key);
const groupId = this._selectedGroup.id;
const configured = tools.filter(t => this._getSimpleRule(t.name, groupId) !== null).length;
const label = key.startsWith('mcp:')
? t('approval.tool.group_mcp', { server: key.slice(4) })
: this._catLabel(key);
return html`
<div class="apr-cat-section ${open ? 'apr-cat-section--open' : ''}">
<div class="apr-cat-header" @click=${() => this._toggleSection(key)}>
<i class="bi bi-chevron-${open ? 'down' : 'right'} apr-cat-chevron"></i>
<span class="apr-cat-name">${key}</span>
<span class="apr-cat-name">${label}</span>
${description ? html`<span class="apr-cat-desc">${description}</span>` : nothing}
<span class="apr-cat-count ${configured === 0 ? 'apr-cat-count--muted' : ''}">
${configured > 0 ? `${configured}/` : ''}${tools.length}
@@ -851,12 +864,12 @@ export class ApprovalRulesPage extends LightElement {
return html`
<div class="apr-matrix">
<div class="apr-matrix-header">
<span class="apr-matrix-title">Per-tool</span>
<span class="apr-matrix-subtitle">priority = 0 · exact tool name · no path/source filters</span>
<span class="apr-matrix-title">${t('approval.matrix.title')}</span>
<span class="apr-matrix-subtitle">${t('approval.matrix.subtitle')}</span>
</div>
<div class="apr-matrix-body">
${groups.length === 0
? html`<div class="text-muted p-4 text-center" style="font-size:0.85rem">Loading tools…</div>`
? html`<div class="text-muted p-4 text-center" style="font-size:0.85rem">${t('approval.matrix.loading')}</div>`
: groups.map(([key, tools, desc]) => this._renderCategorySection(key, tools, desc))}
</div>
</div>
@@ -865,6 +878,15 @@ export class ApprovalRulesPage extends LightElement {
// ── File System panel ─────────────────────────────────────────────────────────
_fsAccessLabel(key) {
return {
allow_read: t('approval.fs.allow_read'),
allow_write: t('approval.fs.allow_write'),
deny: t('approval.fs.deny'),
require: t('approval.fs.require'),
}[key] ?? key;
}
_renderFsAccessSelect(value, onChange, allowUnset) {
return html`
<select
@@ -872,10 +894,10 @@ export class ApprovalRulesPage extends LightElement {
@change=${(e) => onChange(e.target.value || null)}
>
${allowUnset
? html`<option value="" ?selected=${!value}>Require (system default)</option>`
? html`<option value="" ?selected=${!value}>${t('approval.fs.default')}</option>`
: nothing}
${Object.entries(FS_ACCESS).map(([k, v]) => html`
<option value=${k} ?selected=${value === k}>${v.label}</option>
${Object.entries(FS_ACCESS).map(([k]) => html`
<option value=${k} ?selected=${value === k}>${this._fsAccessLabel(k)}</option>
`)}
</select>
`;
@@ -892,7 +914,7 @@ export class ApprovalRulesPage extends LightElement {
? html`<span class="spinner-border spinner-border-sm ms-auto" style="flex-shrink:0"></span>`
: html`
${this._renderFsAccessSelect(value, (v) => v && this._setFsAccess(rule, v), false)}
<button class="apr-btn-icon apr-btn-delete" title="Remove" @click=${() => this._deleteFsRule(rule)}>
<button class="apr-btn-icon apr-btn-delete" title=${t('approval.card.remove')} @click=${() => this._deleteFsRule(rule)}>
<i class="bi bi-trash"></i>
</button>
`}
@@ -907,7 +929,7 @@ export class ApprovalRulesPage extends LightElement {
<i class="bi bi-plus-circle apr-fs-row-icon"></i>
<input
class="form-control form-control-sm font-monospace apr-fs-path-input"
placeholder="Add directory path, e.g. docs"
placeholder=${t('approval.fs.add_ph')}
.value=${this._fsNewPath}
@input=${(e) => { this._fsNewPath = e.target.value; }}
@keydown=${(e) => { if (e.key === 'Enter') this._addFsRule(); }}
@@ -916,8 +938,8 @@ export class ApprovalRulesPage extends LightElement {
class="form-select form-select-sm apr-fs-select"
@change=${(e) => { this._fsNewAccess = e.target.value; }}
>
${Object.entries(FS_ACCESS).map(([k, v]) => html`
<option value=${k} ?selected=${this._fsNewAccess === k}>${v.label}</option>
${Object.entries(FS_ACCESS).map(([k]) => html`
<option value=${k} ?selected=${this._fsNewAccess === k}>${this._fsAccessLabel(k)}</option>
`)}
</select>
<button class="btn btn-sm btn-primary apr-fs-add-btn" @click=${() => this._addFsRule()} ?disabled=${saving}>
@@ -940,19 +962,19 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-side-panel-header" @click=${() => { this._fsOpen = !this._fsOpen; }}>
<i class="bi bi-chevron-${isOpen ? 'down' : 'right'} apr-cat-chevron"></i>
<i class="bi bi-hdd-stack apr-panel-icon"></i>
<span class="apr-panel-title">File System</span>
<span class="apr-panel-subtitle">path-scoped read / write access</span>
<span class="apr-panel-title">${t('approval.fs.title')}</span>
<span class="apr-panel-subtitle">${t('approval.fs.subtitle')}</span>
${rules.length > 0 ? html`<span class="apr-count-badge">${rules.length}</span>` : nothing}
</div>
${isOpen ? html`
<div class="apr-side-panel-body">
${rules.length === 0
? html`<div class="apr-panel-empty">No path rules yet — add one below.</div>`
? html`<div class="apr-panel-empty">${t('approval.fs.empty')}</div>`
: rules.map(r => this._renderFsRow(r))}
${this._renderFsAddRow()}
<div class="apr-fs-row apr-fs-default">
<i class="bi bi-skip-end-fill apr-fs-row-icon"></i>
<span class="apr-fs-path apr-fs-default-label">Default <span class="apr-default-hint">unmatched paths</span></span>
<span class="apr-fs-path apr-fs-default-label">${t('approval.fs.default_label')} <span class="apr-default-hint">${t('approval.fs.default_hint')}</span></span>
${this._renderFsAccessSelect(defValue, (v) => this._setFsDefault(v), true)}
</div>
</div>
@@ -976,13 +998,13 @@ export class ApprovalRulesPage extends LightElement {
<button
class="btn btn-sm btn-outline-secondary apr-panel-add-btn"
@click=${(e) => { e.stopPropagation(); onAdd(); }}
><i class="bi bi-plus-lg me-1"></i>Add</button>
><i class="bi bi-plus-lg me-1"></i>${t('approval.sidebar.add')}</button>
</div>
${isOpen ? html`
<div class="apr-side-panel-body">
${formActive ? this._renderForm() : nothing}
${rules.length === 0 && !formActive
? html`<div class="apr-panel-empty">No rules yet.</div>`
? html`<div class="apr-panel-empty">${t('approval.sidebar.empty')}</div>`
: rules.map(r => this._renderCard(r))}
</div>
` : nothing}
@@ -998,12 +1020,12 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-default-bar">
<div class="apr-default-label">
<i class="bi bi-skip-end-fill me-1"></i>
<strong>Default action</strong>
<span class="apr-default-hint">if no rule matches</span>
<strong>${t('approval.default_bar.title')}</strong>
<span class="apr-default-hint">${t('approval.default_bar.hint')}</span>
</div>
${this._renderChipGroup(action, (a) => this._setDefaultAction(a))}
${action === null
? html`<span class="apr-default-unset">system default: allow</span>`
? html`<span class="apr-default-unset">${t('approval.default_bar.unset')}</span>`
: nothing}
</div>
`;
@@ -1029,11 +1051,11 @@ export class ApprovalRulesPage extends LightElement {
<i class="bi bi-arrow-left"></i>
</button>
<h2 class="apr-title">
${isDefault ? html`<span class="apr-group-default-badge" style="vertical-align:middle">Default</span>` : nothing}
${isDefault ? html`<span class="apr-group-default-badge" style="vertical-align:middle">${t('approval.header.default_badge')}</span>` : nothing}
${group.name}
</h2>
<div class="apr-header-right">
<span class="apr-header-count">${totalRules} rule${totalRules === 1 ? '' : 's'}</span>
<span class="apr-header-count">${totalRules === 1 ? t('approval.header.rule_count', { n: totalRules }) : t('approval.header.rule_count_plural', { n: totalRules })}</span>
</div>
</div>
@@ -1044,9 +1066,9 @@ export class ApprovalRulesPage extends LightElement {
<div class="apr-rules-body">
${this._renderSidePanel(
'override',
'Overrides',
t('approval.sidebar.overrides'),
'bi-exclamation-triangle-fill',
'priority < 0 · evaluated first',
t('approval.sidebar.overrides_sub'),
overrides,
this._overrideOpen,
() => { this._overrideOpen = !this._overrideOpen; },
@@ -1059,9 +1081,9 @@ export class ApprovalRulesPage extends LightElement {
${this._renderSidePanel(
'lowprio',
'Low Priority',
t('approval.sidebar.lowprio'),
'bi-arrow-down-circle-fill',
'priority 1999998 · evaluated after per-tool',
t('approval.sidebar.lowprio_sub'),
lowPrio,
this._lowPrioOpen,
() => { this._lowPrioOpen = !this._lowPrioOpen; },
+44 -51
View File
@@ -1,5 +1,7 @@
import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
// Connector catalog — blueprint §14/§15. Admin only.
//
@@ -54,15 +56,21 @@ export class CatalogPage extends LightElement {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'catalog';
this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._load();
});
// Close the chooser when clicking anywhere else.
document.addEventListener('click', () => { if (this._addOpen) this._addOpen = false; });
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
async _load() {
@@ -108,7 +116,7 @@ export class CatalogPage extends LightElement {
async _saveManual() {
const f = this._modal.form;
if (!f.name.trim()) { this._error = 'Name is required.'; return; }
if (!f.name.trim()) { this._error = t('catalog.error.name'); return; }
const listField = (s) => s.split(/[\n,]/).map(x => x.trim()).filter(Boolean);
try {
await jf('/api/mcp/catalog', {
@@ -135,7 +143,7 @@ export class CatalogPage extends LightElement {
}
async _delete(row) {
if (!confirm(`Remove "${row.name}" from the catalog?\n\nAnything already activated from it keeps running.`)) return;
if (!confirm(t('catalog.confirm.delete', { name: row.name }))) return;
try {
await jf(`/api/mcp/catalog/${row.id}`, { method: 'DELETE' });
await this._load();
@@ -152,7 +160,7 @@ export class CatalogPage extends LightElement {
return html`
<div class="um-page">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-journal-text me-2"></i>Connector Catalog</h2>
<h2 class="um-title"><i class="bi bi-journal-text me-2"></i>${t('catalog.title')}</h2>
<div class="um-header-right">
${this._isAdmin ? this._renderAddButton() : nothing}
</div>
@@ -165,20 +173,13 @@ export class CatalogPage extends LightElement {
${this._me && !this._isAdmin ? html`
<div class="um-empty" style="padding:2rem">
<i class="bi bi-shield-lock"></i>
<p>The catalog is managed by the admin.</p>
<p style="font-size:.8rem;opacity:.7">
What you can activate is on the
<a href="#connectors" @click=${(e) => { e.preventDefault(); this._goConnectors(); }}>Connectors</a> page.
</p>
<p>${t('catalog.not_admin')}</p>
<p style="font-size:.8rem;opacity:.7">${unsafeHTML(t('catalog.not_admin_link'))}</p>
</div>
` : loading ? html`
<div class="um-empty" style="padding:1rem"><i class="bi bi-hourglass-split"></i><p>Loading…</p></div>
<div class="um-empty" style="padding:1rem"><i class="bi bi-hourglass-split"></i><p>${t('catalog.loading')}</p></div>
` : html`
<div class="text-muted mt-3 mb-3" style="font-size:.8rem">
What this box offers. Nothing here is running — a global entry still needs
enabling, a per-user one still needs each user to activate it, both on the
<a href="#connectors" @click=${(e) => { e.preventDefault(); this._goConnectors(); }}>Connectors</a> page.
</div>
<div class="text-muted mt-3 mb-3" style="font-size:.8rem">${unsafeHTML(t('catalog.desc'))}</div>
${rows.length === 0 ? this._renderEmpty() : this._renderTable(rows)}
`}
</div>
@@ -194,27 +195,23 @@ export class CatalogPage extends LightElement {
return html`
<div class="dropdown" style="position:relative" @click=${(e) => e.stopPropagation()}>
<button class="btn btn-sm btn-primary" @click=${() => { this._addOpen = !this._addOpen; }}>
<i class="bi bi-plus-lg me-1"></i>Add connector
<i class="bi bi-plus-lg me-1"></i>${t('catalog.btn.add')}
<i class="bi bi-chevron-down ms-1" style="font-size:.7rem"></i>
</button>
${this._addOpen ? html`
<div class="dropdown-menu show" style="right:0;left:auto;top:calc(100% + .25rem);min-width:280px">
<button class="dropdown-item" style="white-space:normal" @click=${() => this._goMarketplace()}>
<div style="display:flex;align-items:center;gap:.5rem">
<i class="bi bi-shop"></i><strong style="font-size:.85rem">From the marketplace</strong>
</div>
<div class="text-muted" style="font-size:.7rem;margin-top:.15rem">
Vetted connectors, files verified by SHA-256.
<i class="bi bi-shop"></i><strong style="font-size:.85rem">${t('catalog.dropdown.marketplace')}</strong>
</div>
<div class="text-muted" style="font-size:.7rem;margin-top:.15rem">${t('catalog.dropdown.marketplace_desc')}</div>
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item" style="white-space:normal" @click=${() => this._openManual()}>
<div style="display:flex;align-items:center;gap:.5rem">
<i class="bi bi-pencil"></i><strong style="font-size:.85rem">Manually</strong>
</div>
<div class="text-muted" style="font-size:.7rem;margin-top:.15rem">
You supply the config, and vouch for it yourself.
<i class="bi bi-pencil"></i><strong style="font-size:.85rem">${t('catalog.dropdown.manual')}</strong>
</div>
<div class="text-muted" style="font-size:.7rem;margin-top:.15rem">${t('catalog.dropdown.manual_desc')}</div>
</button>
</div>` : nothing}
</div>`;
@@ -224,10 +221,10 @@ export class CatalogPage extends LightElement {
return html`
<div class="um-empty" style="padding:2rem">
<i class="bi bi-journal"></i>
<p>The catalog is empty.</p>
<p style="font-size:.8rem;opacity:.7">Add a connector from the marketplace to get started.</p>
<p>${t('catalog.empty.title')}</p>
<p style="font-size:.8rem;opacity:.7">${t('catalog.empty.hint')}</p>
<button class="btn btn-sm btn-primary mt-2" @click=${() => this._goMarketplace()}>
<i class="bi bi-shop me-1"></i>Browse the marketplace
<i class="bi bi-shop me-1"></i>${t('catalog.empty.action')}
</button>
</div>`;
}
@@ -235,7 +232,7 @@ export class CatalogPage extends LightElement {
_renderTable(rows) {
return html`
<table class="um-table">
<thead><tr><th>Connector</th><th>Scope</th><th>Type</th><th>Auth</th><th></th></tr></thead>
<thead><tr><th>${t('catalog.table.connector')}</th><th>${t('catalog.table.scope')}</th><th>${t('catalog.table.type')}</th><th>${t('catalog.table.auth')}</th><th></th></tr></thead>
<tbody>
${rows.map(r => html`
<tr>
@@ -247,12 +244,12 @@ export class CatalogPage extends LightElement {
text-overflow:ellipsis;white-space:nowrap" title=${r.description}>${r.description}</div>` : nothing}
</td>
<td><span class="badge ${r.scope === 'global' ? 'bg-info' : 'bg-secondary'}" style="font-size:.65rem">
${r.scope === 'global' ? 'global' : 'per-user'}</span></td>
${r.scope === 'global' ? t('catalog.badge.global') : t('catalog.badge.per_user')}</span></td>
<td><span class="badge ${r.source === 'local_script' ? 'bg-warning text-dark' : 'bg-secondary'}" style="font-size:.65rem">
${r.source === 'local_script' ? 'local script' : 'remote'}</span></td>
${r.source === 'local_script' ? t('catalog.badge.local_script') : t('catalog.badge.remote')}</span></td>
<td><span class="text-muted" style="font-size:.78rem">${r.auth_kind}</span></td>
<td><div class="um-actions">
<button class="um-btn-icon" title="Remove from catalog" @click=${() => this._delete(r)}>
<button class="um-btn-icon" title=${t('catalog.action.remove')} @click=${() => this._delete(r)}>
<i class="bi bi-trash"></i></button>
</div></td>
</tr>`)}
@@ -285,35 +282,31 @@ export class CatalogPage extends LightElement {
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
<div class="um-modal">
<div class="um-modal-header">
<i class="bi bi-pencil"></i><span>Add connector manually</span>
<i class="bi bi-pencil"></i><span>${t('catalog.modal.title')}</span>
<button class="um-btn-icon ms-auto" @click=${() => this._closeModal()}><i class="bi bi-x-lg"></i></button>
</div>
<div class="um-modal-body">
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
${isScript ? html`
<div class="alert alert-warning py-2 mb-3" style="font-size:.78rem">
<i class="bi bi-exclamation-triangle me-1"></i>A local script runs code on this box.
Nothing verifies it — unlike the marketplace path, there is no digest to check.
</div>` : nothing}
${this._field('Name', f.name, e => this._patch('name', e.target.value), { hint: 'slug', mono: true })}
${this._select('Scope', f.scope, ['per_user', 'global'], e => this._patch('scope', e.target.value))}
${this._select('Type', f.source, ['remote', 'local_script'], e => this._patch('source', e.target.value))}
${this._select('Transport', f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))}
<div class="alert alert-warning py-2 mb-3" style="font-size:.78rem">${unsafeHTML(t('catalog.modal.script_warn'))}</div>` : nothing}
${this._field(t('catalog.modal.name'), f.name, e => this._patch('name', e.target.value), { hint: t('catalog.modal.name_hint'), mono: true })}
${this._select(t('catalog.modal.scope'), f.scope, ['per_user', 'global'], e => this._patch('scope', e.target.value))}
${this._select(t('catalog.modal.type'), f.source, ['remote', 'local_script'], e => this._patch('source', e.target.value))}
${this._select(t('catalog.modal.transport'), f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))}
${isScript
? html`${this._field('Command', f.command, e => this._patch('command', e.target.value), { placeholder: 'python3', mono: true })}
${this._field('Script path', f.script_path, e => this._patch('script_path', e.target.value), { hint: 'as <connector>/<file>, under ./connectors', mono: true })}`
: this._field('URL', f.url, e => this._patch('url', e.target.value), { mono: true })}
${this._field('Args', f.args, e => this._patch('args', e.target.value), { hint: 'one per line', mono: true })}
${this._field('Required secret/env keys', f.config_schema, e => this._patch('config_schema', e.target.value), { hint: 'comma/newline', mono: true })}
${this._select('Auth', f.auth_kind, ['none', 'api_key', 'oauth', 'qr', 'ssh_key'], e => this._patch('auth_kind', e.target.value))}
${this._field('Friendly name', f.friendly_name, e => this._patch('friendly_name', e.target.value))}
${this._field('Description', f.description, e => this._patch('description', e.target.value),
{ hint: 'the LLM reads this when deciding to activate the connector' })}
? html`${this._field(t('catalog.modal.command'), f.command, e => this._patch('command', e.target.value), { placeholder: t('catalog.modal.command_ph'), mono: true })}
${this._field(t('catalog.modal.script_path'), f.script_path, e => this._patch('script_path', e.target.value), { hint: t('catalog.modal.script_path_hint'), mono: true })}`
: this._field(t('catalog.modal.url'), f.url, e => this._patch('url', e.target.value), { mono: true })}
${this._field(t('catalog.modal.args'), f.args, e => this._patch('args', e.target.value), { hint: t('catalog.modal.args_hint'), mono: true })}
${this._field(t('catalog.modal.config_schema'), f.config_schema, e => this._patch('config_schema', e.target.value), { hint: t('catalog.modal.config_schema_hint'), mono: true })}
${this._select(t('catalog.modal.auth'), f.auth_kind, ['none', 'api_key', 'oauth', 'qr', 'ssh_key'], e => this._patch('auth_kind', e.target.value))}
${this._field(t('catalog.modal.friendly'), f.friendly_name, e => this._patch('friendly_name', e.target.value))}
${this._field(t('catalog.modal.desc'), f.description, e => this._patch('description', e.target.value), { hint: t('catalog.modal.desc_hint') })}
</div>
<div class="um-modal-footer">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>Cancel</button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>${t('catalog.modal.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._saveManual()}>
<i class="bi bi-check-lg me-1"></i>Add to catalog</button>
<i class="bi bi-check-lg me-1"></i>${t('catalog.modal.save')}</button>
</div>
</div>
</div>`;
+72 -4
View File
@@ -1,5 +1,6 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
export class ConfigPage extends LightElement {
static properties = {
@@ -9,6 +10,8 @@ export class ConfigPage extends LightElement {
_saving: { state: true }, // Set<key>
_saved: { state: true }, // Set<key> (brief flash)
_error: { state: true },
_debugMode: { state: true },
_debugLoading: { state: true },
};
constructor() {
@@ -19,17 +22,55 @@ export class ConfigPage extends LightElement {
this._saving = new Set();
this._saved = new Set();
this._error = null;
this._debugMode = false;
this._debugLoading = true;
}
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'config';
this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._load();
if (this._open) { this._load(); this._loadDebugMode(); }
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _loadDebugMode() {
try {
const res = await fetch('/api/dev/debug_mode');
if (!res.ok) throw new Error();
const data = await res.json();
this._debugMode = data.enabled;
} catch {
// ignore, keep current value
} finally {
this._debugLoading = false;
}
}
async _toggleDebugMode() {
const next = !this._debugMode;
this._debugMode = next;
try {
const res = await fetch('/api/dev/debug_mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: next }),
});
if (!res.ok) throw new Error();
window.dispatchEvent(new CustomEvent('debug-mode-change', { detail: { enabled: next } }));
} catch {
this._debugMode = !next;
}
}
async _load() {
this._error = null;
try {
@@ -70,7 +111,7 @@ export class ConfigPage extends LightElement {
this._saved = new Set([...this._saved].filter(k => k !== key));
}, 1500);
} catch (e) {
alert(`Error saving ${prop.name}: ${e.message}`);
alert(t('config.error_save', { name: prop.name, msg: e.message }));
} finally {
this._saving = new Set([...this._saving].filter(k => k !== key));
}
@@ -164,18 +205,45 @@ export class ConfigPage extends LightElement {
return html`
<div class="config-page">
<div class="config-page-header">
<h2 class="llm-page-title">Config</h2>
<h2 class="llm-page-title">${t('config.title')}</h2>
</div>
${this._error ? html`
<div class="alert alert-danger">${this._error}</div>` : nothing}
${this._properties.length === 0 && !this._error ? html`
<p class="text-muted mt-2">Loading…</p>` : nothing}
<p class="text-muted mt-2">${t('config.loading')}</p>` : nothing}
<div class="config-sets">
${this._properties.map(s => this._renderSet(s))}
</div>
<div class="config-set">
<div class="config-set-header">
<div class="config-set-name">${t('config.developer')}</div>
<div class="config-set-desc"></div>
</div>
<div class="config-rows">
<div class="config-row">
<div class="config-row-meta">
<div class="config-row-name">${t('config.debug')}</div>
<div class="config-row-desc">${t('config.debug.desc')}</div>
</div>
<div class="config-row-control">
<div class="form-check form-switch config-bool-switch">
<input class="form-check-input" type="checkbox" role="switch"
id="cfg-debug-mode"
.checked=${this._debugMode}
?disabled=${this._debugLoading}
@change=${() => this._toggleDebugMode()} />
<label class="form-check-label" for="cfg-debug-mode">
${this._debugMode ? 'Enabled' : 'Disabled'}
</label>
</div>
</div>
</div>
</div>
</div>
</div>`;
}
}
+58 -53
View File
@@ -1,5 +1,6 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import {
announceChange, connectorIconUrl, jf, normalizeSchema, parseJson, seedEnv, statusOf,
} from './shared/connector-common.js';
@@ -75,6 +76,8 @@ export class ConnectorDetailPage extends LightElement {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none';
@@ -85,6 +88,11 @@ export class ConnectorDetailPage extends LightElement {
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
get _isGlobal() { return (this._entry?.scope ?? (this._glob ? 'global' : null)) === 'global'; }
get _status() { return statusOf({ _act: this._act, _glob: this._glob }); }
@@ -112,7 +120,7 @@ export class ConnectorDetailPage extends LightElement {
const act = (activated ?? []).find(r => r.catalog_name === this._name) ?? null;
if (!entry && !glob) {
this._error = `No connector named “${this._name}” is available to you.`;
this._error = t('connectors.error.no_connector', { name: this._name });
return;
}
this._entry = entry;
@@ -193,7 +201,7 @@ export class ConnectorDetailPage extends LightElement {
});
if (res?.auth_state === 'pending') {
this._test = res.verify ?? { ok: false, message: 'Verification failed.' };
this._error = 'Saved, but the credentials did not check out — fix them and test again.';
this._error = t('connectors.detail.test.error_saved');
} else if (res?.error) {
this._error = res.error;
}
@@ -204,7 +212,7 @@ export class ConnectorDetailPage extends LightElement {
}
async _deactivate() {
if (!confirm(`Deactivate “${this._entry?.friendly_name || this._name}”?`)) return;
if (!confirm(t('connectors.detail.confirm.deactivate', { name: this._entry?.friendly_name || this._name }))) return;
this._busy = true;
try {
await jf(`/api/mcp/activated/${this._act.id}`, { method: 'DELETE' });
@@ -273,7 +281,7 @@ export class ConnectorDetailPage extends LightElement {
});
if (res?.verify && !res.verify.ok && !res.verify.skipped) {
this._test = res.verify;
this._error = 'Verification failed — the connector stays disabled until the credentials are fixed.';
this._error = t('connectors.detail.test.error_verify');
} else if (res?.error) {
this._error = res.error;
}
@@ -284,7 +292,7 @@ export class ConnectorDetailPage extends LightElement {
}
async _disableGlobal() {
if (!confirm(`Disable “${this._glob.friendly_name || this._name}”?\n\nIt stops for everyone who can use it.`)) return;
if (!confirm(t('connectors.detail.confirm.disable_global', { name: this._glob.friendly_name || this._name }))) return;
this._busy = true;
try {
await jf(`/api/mcp/global/${this._glob.id}`, { method: 'DELETE' });
@@ -328,7 +336,7 @@ export class ConnectorDetailPage extends LightElement {
}
if (!this._entry && !this._glob) {
return html`<div class="um-page">${this._renderHeader()}
<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div></div>`;
<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('connectors.loading')}</div></div>`;
}
return html`
@@ -349,7 +357,7 @@ export class ConnectorDetailPage extends LightElement {
return html`
<div class="um-header">
<div class="d-flex align-items-center gap-2" style="min-width:0">
<button class="btn btn-sm btn-outline-secondary" title="Back" @click=${() => this._back()}>
<button class="btn btn-sm btn-outline-secondary" title=${t('connectors.detail.back')} @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i>
</button>
<h2 class="um-title" style="min-width:0;overflow:hidden;text-overflow:ellipsis">${title}</h2>
@@ -362,6 +370,7 @@ export class ConnectorDetailPage extends LightElement {
const isScript = e?.source === 'local_script';
const status = this._status;
const desc = e?.description || this._glob?.description;
const _statusText = (s) => ({ active: t('connectors.detail.status.active'), pending: t('connectors.detail.status.needs_fix'), needs_login: t('connectors.detail.status.needs_signin') })[s] ?? s;
return html`
<div class="connector-card" style="margin-top:1rem">
@@ -382,24 +391,24 @@ export class ConnectorDetailPage extends LightElement {
${desc ? html`<div class="connector-card-desc" style="-webkit-line-clamp:initial">${desc}</div>` : nothing}
<div class="connector-chips">
<span class="connector-chip connector-chip--scope">
<i class="bi ${this._isGlobal ? 'bi-globe' : 'bi-person'}"></i>${this._isGlobal ? 'global' : 'per-user'}
<i class="bi ${this._isGlobal ? 'bi-globe' : 'bi-person'}"></i>${this._isGlobal ? t('connectors.detail.detail_scope_global') : t('connectors.chip.per_user')}
</span>
${isScript ? html`
<span class="connector-chip connector-chip--script">
<i class="bi bi-file-earmark-code"></i>runs code on this box
<i class="bi bi-file-earmark-code"></i>${t('connectors.detail.scope_local')}
</span>` : nothing}
${e?.auth_kind && e.auth_kind !== 'none' ? html`
<span class="connector-chip"><i class="bi bi-key"></i>${e.auth_kind}</span>` : nothing}
${status === 'active' ? html`
<span class="connector-chip connector-chip--ok"><i class="bi bi-check-circle"></i>active</span>` : nothing}
<span class="connector-chip connector-chip--ok"><i class="bi bi-check-circle"></i>${t('connectors.detail.status.active')}</span>` : nothing}
${status === 'pending' ? html`
<span class="connector-chip connector-chip--script"><i class="bi bi-exclamation-triangle"></i>needs fixing</span>` : nothing}
<span class="connector-chip connector-chip--script"><i class="bi bi-exclamation-triangle"></i>${t('connectors.detail.status.needs_fix')}</span>` : nothing}
${status === 'needs_login' ? html`
<span class="connector-chip connector-chip--script"><i class="bi bi-box-arrow-in-right"></i>needs sign-in</span>` : nothing}
<span class="connector-chip connector-chip--script"><i class="bi bi-box-arrow-in-right"></i>${t('connectors.detail.status.needs_signin')}</span>` : nothing}
</div>
${this._isGlobal ? html`
<div class="connector-card-note">
<i class="bi bi-info-circle"></i>Runs once for the household, on the host. Nobody reaches it until they are granted access.
<i class="bi bi-info-circle"></i>${t('connectors.detail.global_note')}
</div>` : nothing}
</div>`;
}
@@ -412,8 +421,8 @@ export class ConnectorDetailPage extends LightElement {
return html`
<div style="margin-top:1.5rem">
<div class="um-empty" style="padding:1rem"><i class="bi bi-check2-circle"></i>
<p>This connector is managed for you.</p>
<p style="font-size:.8rem;opacity:.7">It is enabled by an admin and granted to you — there is nothing to configure.</p>
<p>${t('connectors.detail.managed.title')}</p>
<p style="font-size:.8rem;opacity:.7">${t('connectors.detail.managed.desc')}</p>
</div>
</div>`;
}
@@ -436,7 +445,7 @@ export class ConnectorDetailPage extends LightElement {
return html`
<div style="margin-top:1.5rem">
<div class="um-header" style="padding:0 0 .5rem">
<h3 class="um-title" style="font-size:1rem"><i class="bi bi-key me-2"></i>Sign in</h3>
<h3 class="um-title" style="font-size:1rem"><i class="bi bi-key me-2"></i>${t('connectors.detail.oauth.title')}</h3>
</div>
${this._renderOauth()}
</div>`;
@@ -446,20 +455,20 @@ export class ConnectorDetailPage extends LightElement {
<div style="margin-top:1.5rem">
<div class="um-header" style="padding:0 0 .5rem">
<h3 class="um-title" style="font-size:1rem">
<i class="bi bi-sliders me-2"></i>${active ? 'Configuration' : 'Set up'}
<i class="bi bi-sliders me-2"></i>${active ? t('connectors.detail.config.title_active') : t('connectors.detail.config.title_setup')}
</h3>
</div>
${active ? html`
<div class="text-muted mb-3" style="font-size:.78rem">
${this._isGlobal
? 'Already enabled. Re-submitting replaces the stored credentials.'
: 'Already active. Re-submitting replaces the stored credentials.'}
? t('connectors.detail.config.already_global')
: t('connectors.detail.config.already_user')}
</div>` : nothing}
${e.auth_kind === 'api_key' && !schemaHasSecret ? html`
<div class="mb-3">
<label class="form-label">API key<span class="text-danger">*</span></label>
<label class="form-label">${t('connectors.detail.config.api_key')}<span class="text-danger">*</span></label>
<input class="form-control" type="password" .value=${this._form.api_key}
@input=${(ev) => { this._form = { ...this._form, api_key: ev.target.value }; }} />
</div>` : nothing}
@@ -472,25 +481,25 @@ export class ConnectorDetailPage extends LightElement {
<button class="btn btn-sm btn-outline-secondary" ?disabled=${this._test === 'running' || this._busy}
@click=${() => this._testCreds()}>
<i class="bi bi-${this._test === 'running' ? 'arrow-repeat' : 'check2-gear'} me-1"></i>
${this._test === 'running' ? 'Testing' : 'Test credentials'}
${this._test === 'running' ? t('connectors.detail.config.btn_testing') : t('connectors.detail.config.btn_test')}
</button>` : nothing}
${this._isGlobal
? html`
<button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._enableGlobal()}>
<i class="bi bi-globe me-1"></i>${this._glob ? 'Save & restart' : 'Enable globally'}
<i class="bi bi-globe me-1"></i>${this._glob ? t('connectors.detail.config.btn_save_restart') : t('connectors.detail.config.btn_enable_global')}
</button>
${this._glob ? html`
<button class="btn btn-sm btn-outline-danger" ?disabled=${this._busy} @click=${() => this._disableGlobal()}>
<i class="bi bi-trash me-1"></i>Disable
<i class="bi bi-trash me-1"></i>${t('connectors.detail.config.btn_disable')}
</button>` : nothing}`
: html`
<button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._activate()}>
<i class="bi bi-plug me-1"></i>${this._act ? 'Save & restart' : 'Activate'}
<i class="bi bi-plug me-1"></i>${this._act ? t('connectors.detail.config.btn_save_restart') : t('connectors.detail.config.btn_activate')}
</button>
${this._act ? html`
<button class="btn btn-sm btn-outline-danger" ?disabled=${this._busy} @click=${() => this._deactivate()}>
<i class="bi bi-trash me-1"></i>Deactivate
<i class="bi bi-trash me-1"></i>${t('connectors.detail.config.btn_deactivate')}
</button>` : nothing}`}
</div>
</div>`;
@@ -504,40 +513,38 @@ export class ConnectorDetailPage extends LightElement {
const scopes = parseJson(this._entry?.oauth_scopes_json, []);
return html`
<div class="text-muted mb-3" style="font-size:.78rem">
Signs in with ${label}. You approve access in a browser tab, then paste back the
code the page shows you — nothing is stored on this box until you do.
</div>
<div class="text-muted mb-3" style="font-size:.78rem">${t('connectors.detail.oauth.desc', { provider: label })}</div>
${scopes.length ? html`
<div class="mb-3" style="font-size:.72rem">
<div class="text-muted mb-1">It will request access to:</div>
<div class="text-muted mb-1">${t('connectors.detail.oauth.scopes')}</div>
<ul class="mb-0 ps-3">${scopes.map(s => html`<li><code style="font-size:.68rem">${s}</code></li>`)}</ul>
</div>` : nothing}
${active ? html`
<div class="alert alert-success py-2 mb-3" style="font-size:.82rem">
<i class="bi bi-check-circle-fill me-1"></i>Signed in and active.
<i class="bi bi-check-circle-fill me-1"></i>${t('connectors.detail.oauth.signed_in')}
</div>` : nothing}
${!this._oauth ? html`
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._startOauth()}>
<i class="bi bi-box-arrow-in-right me-1"></i>${active ? 'Sign in again' : (pending ? 'Finish sign-in' : `Sign in with ${label}`)}
<i class="bi bi-box-arrow-in-right me-1"></i>
${active ? t('connectors.detail.oauth.btn_signin_again') : (pending ? t('connectors.detail.oauth.btn_finish') : t('connectors.detail.oauth.btn_signin', { provider: label }))}
</button>
${this._act ? html`
<button class="btn btn-sm btn-outline-danger" ?disabled=${this._busy} @click=${() => this._deactivate()}>
<i class="bi bi-trash me-1"></i>Deactivate
<i class="bi bi-trash me-1"></i>${t('connectors.detail.oauth.deactivate')}
</button>` : nothing}
</div>`
: html`
<div class="connector-card" style="margin-top:.25rem">
<div class="mb-2" style="font-size:.8rem">
<i class="bi bi-1-circle me-1"></i>A tab opened for ${label}. Approve access there.
<div class="mt-1"><a href=${this._oauth.auth_url} target="_blank" rel="noopener">Re-open the sign-in page</a></div>
<i class="bi bi-1-circle me-1"></i>${t('connectors.detail.oauth.step1', { provider: label })}
<div class="mt-1"><a href=${this._oauth.auth_url} target="_blank" rel="noopener">${t('connectors.detail.oauth.step1_link')}</a></div>
</div>
<div class="mb-2" style="font-size:.8rem">
<i class="bi bi-2-circle me-1"></i>Paste the code the page gave you:
<i class="bi bi-2-circle me-1"></i>${t('connectors.detail.oauth.step2')}
</div>
<input class="form-control font-monospace mb-2" placeholder="4/0A…"
.value=${this._oauth.code}
@@ -545,10 +552,10 @@ export class ConnectorDetailPage extends LightElement {
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" ?disabled=${this._busy || !this._oauth.code.trim()}
@click=${() => this._completeOauth()}>
<i class="bi bi-check-lg me-1"></i>Complete sign-in
<i class="bi bi-check-lg me-1"></i>${t('connectors.detail.oauth.btn_complete')}
</button>
<button class="btn btn-sm btn-outline-secondary" ?disabled=${this._busy}
@click=${() => { this._oauth = null; }}>Cancel</button>
@click=${() => { this._oauth = null; }}>${t('connectors.detail.oauth.cancel')}</button>
</div>
</div>`}
`;
@@ -574,20 +581,20 @@ export class ConnectorDetailPage extends LightElement {
}
_renderVerifyBox() {
const t = this._test;
if (t === null) return nothing;
if (t === 'running') {
const result = this._test;
if (result === null) return nothing;
if (result === 'running') {
return html`<div class="alert alert-secondary py-2 mb-3" style="font-size:.82rem">
<i class="bi bi-arrow-repeat me-1"></i>Testing credentials…</div>`;
<i class="bi bi-arrow-repeat me-1"></i>${t('connectors.detail.test.running')}</div>`;
}
if (t.skipped) {
if (result.skipped) {
return html`<div class="alert alert-secondary py-2 mb-3" style="font-size:.82rem">
<i class="bi bi-info-circle me-1"></i>${t.message || 'No verification step for this connector.'}</div>`;
<i class="bi bi-info-circle me-1"></i>${result.message || t('connectors.detail.test.skipped')}</div>`;
}
return html`
<div class="alert alert-${t.ok ? 'success' : 'danger'} py-2 mb-3" style="font-size:.82rem">
<i class="bi ${t.ok ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>${t.ok ? 'OK' : 'Failed'}</strong> — ${t.message}
<div class="alert alert-${result.ok ? 'success' : 'danger'} py-2 mb-3" style="font-size:.82rem">
<i class="bi ${result.ok ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>${result.ok ? t('connectors.detail.test.ok_label') : t('connectors.detail.test.fail_label')}</strong> — ${result.message}
${t.details ? html`
<pre class="mb-0 mt-1 p-2 rounded bg-dark text-light"
style="font-size:.7rem;white-space:pre-wrap">${JSON.stringify(t.details, null, 2)}</pre>` : nothing}
@@ -602,13 +609,11 @@ export class ConnectorDetailPage extends LightElement {
return html`
<div style="margin-top:1.75rem">
<div class="um-header" style="padding:0 0 .5rem">
<h3 class="um-title" style="font-size:1rem"><i class="bi bi-people me-2"></i>Who can use it</h3>
</div>
<div class="text-muted mb-2" style="font-size:.78rem">
Ticking a box grants this connector's tools to that person's agent. Saving replaces the whole list.
<h3 class="um-title" style="font-size:1rem"><i class="bi bi-people me-2"></i>${t('connectors.detail.access.title')}</h3>
</div>
<div class="text-muted mb-2" style="font-size:.78rem">${t('connectors.detail.access.desc')}</div>
${users.length === 0
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-people"></i><p>No users.</p></div>`
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-people"></i><p>${t('connectors.detail.access.empty')}</p></div>`
: html`
<div class="connector-card">
${users.map(u => html`
@@ -624,7 +629,7 @@ export class ConnectorDetailPage extends LightElement {
</div>`}
<button class="btn btn-sm btn-primary mt-2" ?disabled=${this._busy || !this._access}
@click=${() => this._saveAccess()}>
<i class="bi bi-check-lg me-1"></i>Save access
<i class="bi bi-check-lg me-1"></i>${t('connectors.detail.access.save')}
</button>
</div>`;
}
+47 -44
View File
@@ -1,6 +1,7 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { connectorIconUrl, statusOf, STATUS_LABEL } from './shared/connector-common.js';
import { t } from '../lib/i18n.js';
import { connectorIconUrl, statusOf, STATUS_LABEL, statusText } from './shared/connector-common.js';
// Connectors (MCP) — blueprint §7/§14/§15.
//
@@ -65,16 +66,21 @@ export class ConnectorsPage extends LightElement {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'connectors';
this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._load();
});
// Coming back from a connector's page must show its new state, not the state
// captured before the user activated it.
window.addEventListener('connectors-changed', () => { if (this._open) this._load(); });
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
async _load() {
@@ -153,11 +159,11 @@ export class ConnectorsPage extends LightElement {
async _saveProvider() {
const f = this._pForm;
if (!f.name.trim() || !f.client_id.trim()) {
this._pError = 'Name and client id are required.';
this._pError = t('connectors.providers.error.name_client');
return;
}
if (f._isNew && !f.client_secret.trim()) {
this._pError = 'A client secret is required for a new provider.';
this._pError = t('connectors.providers.error.secret');
return;
}
this._pError = null;
@@ -173,7 +179,7 @@ export class ConnectorsPage extends LightElement {
}
async _deleteProvider(name) {
if (!confirm(`Delete the “${name}” sign-in provider?\n\nConnectors that use it will no longer be able to sign in.`)) return;
if (!confirm(t('connectors.providers.delete_confirm', { name }))) return;
try {
await jf(`/api/mcp/providers/${encodeURIComponent(name)}`, { method: 'DELETE' });
this._providers = await jf('/api/mcp/providers');
@@ -240,17 +246,17 @@ export class ConnectorsPage extends LightElement {
return html`
<div class="um-page">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-plug me-2"></i>Connectors</h2>
<h2 class="um-title"><i class="bi bi-plug me-2"></i>${t('connectors.title')}</h2>
<div class="um-header-right">
${this._isAdmin ? html`
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._openProviders()}>
<i class="bi bi-key me-1"></i>Sign-in providers
<i class="bi bi-key me-1"></i>${t('connectors.btn.signin_providers')}
</button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._go('catalog', '#catalog')}>
<i class="bi bi-journal-text me-1"></i>Catalog
<i class="bi bi-journal-text me-1"></i>${t('connectors.btn.catalog')}
</button>
<button class="btn btn-sm btn-primary" @click=${() => this._go('marketplace', '#marketplace')}>
<i class="bi bi-bag me-1"></i>Marketplace
<i class="bi bi-bag me-1"></i>${t('connectors.btn.marketplace')}
</button>` : nothing}
</div>
</div>
@@ -259,13 +265,13 @@ export class ConnectorsPage extends LightElement {
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>` : nothing}
${loading
? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>`
? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('connectors.loading')}</div>`
: html`
<div style="padding:0 1.25rem 1.5rem; overflow:auto">
<div class="connector-filters">
<div class="connector-search">
<i class="bi bi-search"></i>
<input class="form-control form-control-sm" placeholder="Search connectors…"
<input class="form-control form-control-sm" placeholder=${t('connectors.search')}
.value=${this._q} @input=${(e) => { this._q = e.target.value; }} />
</div>
</div>
@@ -283,15 +289,12 @@ export class ConnectorsPage extends LightElement {
@click=${(e) => { if (e.target === e.currentTarget) this._closeProviders(); }}>
<div class="connector-card" style="width:100%;max-width:560px;cursor:default">
<div class="d-flex align-items-center justify-content-between mb-2">
<h3 class="um-title" style="font-size:1rem;margin:0"><i class="bi bi-key me-2"></i>Sign-in providers</h3>
<h3 class="um-title" style="font-size:1rem;margin:0"><i class="bi bi-key me-2"></i>${t('connectors.providers.title')}</h3>
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeProviders()}>
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="text-muted mb-3" style="font-size:.78rem">
OAuth apps that per-user connectors sign in through. One app (e.g. Google) covers all of
its services. The client secret is stored on this box and never shown again.
</div>
<div class="text-muted mb-3" style="font-size:.78rem">${t('connectors.providers.desc')}</div>
${this._pError ? html`
<div class="alert alert-danger py-2 mb-2" style="font-size:.82rem">${this._pError}</div>` : nothing}
${this._pForm ? this._renderProviderForm() : this._renderProviderList()}
@@ -304,7 +307,7 @@ export class ConnectorsPage extends LightElement {
return html`
${list.length === 0 ? html`
<div class="um-empty" style="padding:1rem"><i class="bi bi-key"></i>
<p>No sign-in providers yet.</p></div>` : html`
<p>${t('connectors.providers.empty')}</p></div>` : html`
<div class="d-flex flex-column gap-2 mb-3">
${list.map(p => html`
<div class="d-flex align-items-center justify-content-between p-2 rounded"
@@ -314,9 +317,9 @@ export class ConnectorsPage extends LightElement {
<code class="text-muted" style="font-size:.7rem">${p.name}</code></div>
<div class="text-muted" style="font-size:.72rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${p.has_client_secret
? html`<i class="bi bi-check-circle text-success"></i> secret set`
: html`<i class="bi bi-exclamation-triangle text-warning"></i> no secret`}
· ${p.client_id || '(no client id)'}
? html`<i class="bi bi-check-circle text-success"></i> ${t('connectors.providers.secret_set')}`
: html`<i class="bi bi-exclamation-triangle text-warning"></i> ${t('connectors.providers.no_secret')}`}
· ${p.client_id || t('connectors.providers.no_client_id')}
</div>
</div>
<div class="d-flex gap-1">
@@ -329,10 +332,10 @@ export class ConnectorsPage extends LightElement {
</div>`}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" @click=${() => this._presetGoogle()}>
<i class="bi bi-google me-1"></i>Add Google
<i class="bi bi-google me-1"></i>${t('connectors.providers.add_google')}
</button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => { this._pForm = { ...this._blankProvider(), _isNew: true }; }}>
<i class="bi bi-plus-lg me-1"></i>Add other
<i class="bi bi-plus-lg me-1"></i>${t('connectors.providers.add_other')}
</button>
</div>`;
}
@@ -350,24 +353,24 @@ export class ConnectorsPage extends LightElement {
${opts.help ? html`<div class="form-text" style="font-size:.7rem">${opts.help}</div>` : nothing}
</div>`;
return html`
${field('name', 'Provider id', { req: true, mono: true, ph: 'google',
help: 'The slug a connector references (must match the manifest\'s auth.provider).' })}
${field('display_name', 'Display name', { ph: 'Google' })}
${field('client_id', 'Client id', { req: true, mono: true })}
${field('client_secret', 'Client secret', { secret: true, mono: true,
help: f._isNew ? 'Required.' : 'Leave blank to keep the stored secret.' })}
${field('auth_url', 'Authorization URL', { mono: true, ph: 'https://accounts.google.com/o/oauth2/v2/auth' })}
${field('token_url', 'Token URL', { mono: true, ph: 'https://oauth2.googleapis.com/token' })}
${field('redirect_uri', 'Redirect URI', { mono: true,
help: 'The copy-paste page. Must be registered as an authorized redirect in the provider\'s console.' })}
${field('extra_params', 'Extra params (JSON)', { mono: true, ph: '{"access_type":"offline","prompt":"consent"}',
help: 'Merged into the consent URL. Google needs these two to return a refresh token.' })}
${field('name', t('connectors.providers.field.name'), { req: true, mono: true, ph: 'google',
help: t('connectors.providers.field.name_help') })}
${field('display_name', t('connectors.providers.field.display'), { ph: 'Google' })}
${field('client_id', t('connectors.providers.field.client_id'), { req: true, mono: true })}
${field('client_secret', t('connectors.providers.field.client_secret'), { secret: true, mono: true,
help: f._isNew ? t('connectors.providers.field.secret_help_new') : t('connectors.providers.field.secret_help_edit') })}
${field('auth_url', t('connectors.providers.field.auth_url'), { mono: true, ph: 'https://accounts.google.com/o/oauth2/v2/auth' })}
${field('token_url', t('connectors.providers.field.token_url'), { mono: true, ph: 'https://oauth2.googleapis.com/token' })}
${field('redirect_uri', t('connectors.providers.field.redirect'), { mono: true,
help: t('connectors.providers.field.redirect_help') })}
${field('extra_params', t('connectors.providers.field.extra'), { mono: true, ph: '{"access_type":"offline","prompt":"consent"}',
help: t('connectors.providers.field.extra_help') })}
<div class="d-flex gap-2 mt-3">
<button class="btn btn-sm btn-primary" @click=${() => this._saveProvider()}>
<i class="bi bi-check-lg me-1"></i>Save
<i class="bi bi-check-lg me-1"></i>${t('connectors.providers.save')}
</button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => { this._pForm = null; this._pError = null; }}>
Cancel
${t('connectors.providers.cancel')}
</button>
</div>`;
}
@@ -375,14 +378,14 @@ export class ConnectorsPage extends LightElement {
_renderEmpty() {
if (this._q.trim()) {
return html`<div class="um-empty" style="padding:1rem"><i class="bi bi-search"></i>
<p>No connector matches “${this._q}”.</p></div>`;
<p>${t('connectors.empty.match', { query: this._q })}</p></div>`;
}
return html`
<div class="um-empty" style="padding:1rem"><i class="bi bi-plug"></i>
<p>${this._isAdmin ? 'No connectors installed yet.' : 'Nothing available to you yet.'}</p>
<p>${this._isAdmin ? t('connectors.empty.installed') : t('connectors.empty.available')}</p>
${this._isAdmin
? html`<p style="font-size:.8rem;opacity:.7">Install one from the Marketplace to get started.</p>`
: html`<p style="font-size:.8rem;opacity:.7">Ask an admin to make one available.</p>`}
? html`<p style="font-size:.8rem;opacity:.7">${t('connectors.empty.install_hint')}</p>`
: html`<p style="font-size:.8rem;opacity:.7">${t('connectors.empty.ask_admin')}</p>`}
</div>`;
}
@@ -407,7 +410,7 @@ export class ConnectorsPage extends LightElement {
<div class="connector-card-sub">${r.name}</div>
</div>
<span class=${`connector-chip${STATUS_LABEL[status].tone ? ` connector-chip--${STATUS_LABEL[status].tone}` : ''}`}>
${STATUS_LABEL[status].text}
${statusText(status)}
</span>
</div>
@@ -415,11 +418,11 @@ export class ConnectorsPage extends LightElement {
<div class="connector-chips">
<span class="connector-chip connector-chip--scope">
<i class="bi ${isGlobal ? 'bi-globe' : 'bi-person'}"></i>${isGlobal ? 'global' : 'per-user'}
<i class="bi ${isGlobal ? 'bi-globe' : 'bi-person'}"></i>${isGlobal ? t('connectors.chip.global') : t('connectors.chip.per_user')}
</span>
${isScript ? html`
<span class="connector-chip connector-chip--script">
<i class="bi bi-file-earmark-code"></i>local script
<i class="bi bi-file-earmark-code"></i>${t('connectors.chip.local_script')}
</span>` : nothing}
${r.auth_kind && r.auth_kind !== 'none' ? html`
<span class="connector-chip"><i class="bi bi-key"></i>${r.auth_kind}</span>` : nothing}
+37 -36
View File
@@ -2,6 +2,7 @@ import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { renderMarkdown } from '../lib/base.js';
import { openFile } from '../lib/open-file.js';
import { t } from '../lib/i18n.js';
// ── Utilities ────────────────────────────────────────────────────────────────
@@ -33,7 +34,7 @@ function renderPath(seg, path) {
if (!path || seg !== path) return html`<code>${seg}</code>`;
const open = (e) => { e.stopPropagation(); openFile(seg); };
return html`<span class="copilot-tool-path" role="button" tabindex="0"
title="Open in viewer"
title=${t('copilot.open_in_viewer')}
@click=${open}
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); } }}
>${seg}</span>`;
@@ -90,7 +91,7 @@ export function renderDiff(oldText, newText) {
result.push(html`<span class="diff-unchanged">${eqBuf.join('\n')}\n</span>`);
} else {
result.push(html`<span class="diff-unchanged">${eqBuf.slice(0, 3).join('\n')}\n</span>`);
result.push(html`<span class="diff-ellipsis">${eqBuf.length - 6} unchanged lines</span>`);
result.push(html`<span class="diff-ellipsis">${t('copilot.unchanged_lines', { n: eqBuf.length - 6 })}</span>`);
result.push(html`<span class="diff-unchanged">\n${eqBuf.slice(-3).join('\n')}\n</span>`);
}
eqBuf = [];
@@ -118,15 +119,15 @@ export function renderPendingWrite(host, msg) {
<div class="copilot-approval-header">
<i class="bi bi-pencil-square"></i>
<span class="copilot-approval-path copilot-tool-path" role="button" tabindex="0"
title="Open in viewer"
title=${t('copilot.open_in_viewer')}
@click=${() => openFile(msg.path)}
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(msg.path); } }}
>${msg.path}</span>
${msg.status === 'pending'
? html`<span class="badge bg-warning text-dark ms-auto">Pending approval</span>`
? html`<span class="badge bg-warning text-dark ms-auto">${t('approval.pending')}</span>`
: msg.status === 'approved'
? html`<span class="badge bg-success ms-auto">Approved</span>`
: html`<span class="badge bg-danger ms-auto">Rejected</span>`}
? html`<span class="badge bg-success ms-auto">${t('approval.approved')}</span>`
: html`<span class="badge bg-danger ms-auto">${t('approval.rejected')}</span>`}
</div>
<pre class="copilot-diff">${renderDiff(msg.old_content, msg.new_content)}</pre>
@@ -137,33 +138,33 @@ export function renderPendingWrite(host, msg) {
<textarea
class="form-control form-control-sm copilot-reject-note"
rows="2"
placeholder="Optional: explain why you rejected this (sent to the LLM)"
placeholder=${t('approval.reject_hint')}
.value=${host._rejectNote}
@input=${(e) => { host._rejectNote = e.target.value; }}
></textarea>
<div class="copilot-approval-btns">
<button class="btn btn-sm btn-danger" @click=${() => host._confirmReject(msg)}>
<i class="bi bi-x-circle me-1"></i>Confirm reject
<i class="bi bi-x-circle me-1"></i>${t('approval.confirm_reject')}
</button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => { host._rejectingId = null; }}>
Cancel
${t('copilot.cancel')}
</button>
</div>
` : html`
<div class="copilot-approval-btns">
<button class="btn btn-sm btn-success" @click=${() => host._approve(msg)}>
<i class="bi bi-check-circle me-1"></i>Approve
<i class="bi bi-check-circle me-1"></i>${t('approval.approve')}
</button>
<button class="btn btn-sm btn-outline-danger" @click=${() => host._startReject(msg)}>
<i class="bi bi-x-circle me-1"></i>Reject
<i class="bi bi-x-circle me-1"></i>${t('approval.reject')}
</button>
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip similar approvals for 15 minutes"
<button class="btn btn-sm btn-outline-secondary" title=${t('approval.bypass_15')}
@click=${() => host._approveWriteBypass(msg, 900)}>
<i class="bi bi-clock me-1"></i>15 min
<i class="bi bi-clock me-1"></i>${t('copilot.bypass_15min')}
</button>
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip all approvals for this session"
<button class="btn btn-sm btn-outline-secondary" title=${t('approval.bypass_all')}
@click=${() => host._approveWriteBypass(msg, 0)}>
<i class="bi bi-arrow-repeat me-1"></i>Session
<i class="bi bi-arrow-repeat me-1"></i>${t('copilot.bypass_session')}
</button>
</div>
`}
@@ -183,13 +184,13 @@ export function renderTool(host, msg) {
msg.status === 'running'
? html`<span class="spinner-border spinner-border-sm" role="status"></span>`
: isPending
? html`<span class="spinner-border spinner-border-sm text-warning" role="status" title="Awaiting approval"></span>`
? html`<span class="spinner-border spinner-border-sm text-warning" role="status" title=${t('copilot.status_awaiting')}></span>`
: msg.status === 'done'
? html`<i class="bi bi-check-circle-fill text-success"></i>`
: msg.status === 'cancelled'
? html`<i class="bi bi-slash-circle-fill text-secondary" title="Cancelled by user"></i>`
? html`<i class="bi bi-slash-circle-fill text-secondary" title=${t('copilot.status_cancelled')}></i>`
: msg.status === 'rejected'
? html`<i class="bi bi-shield-fill-x text-warning" title="Denied by policy"></i>`
? html`<i class="bi bi-shield-fill-x text-warning" title=${t('copilot.status_denied')}></i>`
: html`<i class="bi bi-x-circle-fill text-danger"></i>`;
return html`
@@ -197,7 +198,7 @@ export function renderTool(host, msg) {
<button class="copilot-tool-header" @click=${() => host._toggleExpand(msg.tool_call_id)}>
<span class="copilot-tool-status">${statusIcon}</span>
<span class="copilot-tool-name">${renderLabel(msg.label_full || msg.name, msg.path)}</span>
${isPending ? html`<span class="badge bg-warning text-dark ms-2">Pending approval</span>` : nothing}
${isPending ? html`<span class="badge bg-warning text-dark ms-2">${t('approval.pending')}</span>` : nothing}
<i class="bi bi-chevron-${isOpen ? 'up' : 'down'} ms-auto"></i>
</button>
${isOpen ? html`
@@ -226,7 +227,7 @@ export function renderTool(host, msg) {
<textarea
class="form-control form-control-sm copilot-reject-note"
rows="2"
placeholder="Type your answer…"
placeholder=${t('copilot.clarification_ph')}
.value=${host._clarificationAnswer}
@input=${(e) => { host._clarificationAnswer = e.target.value; }}
@keydown=${(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); host._answerQuestion(msg); } }}
@@ -234,7 +235,7 @@ export function renderTool(host, msg) {
<button class="btn btn-sm btn-primary ms-2"
@click=${() => host._answerQuestion(msg)}
?disabled=${!host._clarificationAnswer.trim()}>
<i class="bi bi-send me-1"></i>Send
<i class="bi bi-send me-1"></i>${t('copilot.send')}
</button>
</div>
</div>
@@ -244,38 +245,38 @@ export function renderTool(host, msg) {
<textarea
class="form-control form-control-sm copilot-reject-note"
rows="2"
placeholder="Reason for rejection (optional, sent to the LLM)"
placeholder=${t('approval.reject_hint')}
.value=${host._rejectNote}
@input=${(e) => { host._rejectNote = e.target.value; }}
></textarea>
<div class="copilot-approval-btns">
<button class="btn btn-sm btn-danger"
@click=${() => msg.request_id != null ? host._rejectWsTool(msg) : host._rejectTool(msg)}>
<i class="bi bi-x-circle me-1"></i>Confirm reject
<i class="bi bi-x-circle me-1"></i>${t('approval.confirm_reject')}
</button>
<button class="btn btn-sm btn-outline-secondary"
@click=${() => { host._rejectingId = null; }}>
Cancel
${t('copilot.cancel')}
</button>
</div>
` : html`
<div class="copilot-approval-btns">
<button class="btn btn-sm btn-success"
@click=${(e) => { e.stopPropagation(); msg.request_id != null ? host._approveWsTool(msg) : host._approveTool(msg); }}>
<i class="bi bi-check-circle me-1"></i>Approve
<i class="bi bi-check-circle me-1"></i>${t('approval.approve')}
</button>
<button class="btn btn-sm btn-outline-danger"
@click=${(e) => { e.stopPropagation(); host._rejectingId = msg.tool_call_id; host._rejectNote = ''; }}>
<i class="bi bi-x-circle me-1"></i>Reject
<i class="bi bi-x-circle me-1"></i>${t('approval.reject')}
</button>
${msg.request_id != null ? html`
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip similar approvals for 15 minutes"
<button class="btn btn-sm btn-outline-secondary" title=${t('approval.bypass_15')}
@click=${(e) => { e.stopPropagation(); host._approveWsToolBypass(msg, 900); }}>
<i class="bi bi-clock me-1"></i>15 min
<i class="bi bi-clock me-1"></i>${t('copilot.bypass_15min')}
</button>
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip all approvals for this session"
<button class="btn btn-sm btn-outline-secondary" title=${t('approval.bypass_all')}
@click=${(e) => { e.stopPropagation(); host._approveWsToolBypass(msg, 0); }}>
<i class="bi bi-arrow-repeat me-1"></i>Session
<i class="bi bi-arrow-repeat me-1"></i>${t('copilot.bypass_session')}
</button>
` : nothing}
</div>
@@ -284,7 +285,7 @@ export function renderTool(host, msg) {
`) : msg.status !== 'running' ? (
msg.status === 'done' && msg.result_type === 'json' ? html`
<div class="copilot-tool-section">
<span class="copilot-tool-label copilot-tool-label--done">result · json</span>
<span class="copilot-tool-label copilot-tool-label--done">${t('copilot.result_json')}</span>
<pre class="copilot-tool-pre copilot-tool-pre--done copilot-tool-pre--json">${
truncate(prettyJson(msg.result))
}</pre>
@@ -292,7 +293,7 @@ export function renderTool(host, msg) {
` : html`
<div class="copilot-tool-section">
<span class="copilot-tool-label copilot-tool-label--${msg.status}">
${msg.status === 'done' ? 'result' : 'error'}
${msg.status === 'done' ? t('copilot.result') : t('copilot.error_label')}
</span>
<pre class="copilot-tool-pre copilot-tool-pre--${msg.status}">${
truncate(msg.status === 'done' ? msg.result : msg.error)
@@ -316,7 +317,7 @@ export function renderAgent(msg) {
<i class="bi bi-arrow-right mx-1" style="font-size:0.7rem"></i>
<strong>${msg.agent_id}</strong>
</span>
${msg.done ? html`<span class="copilot-agent-badge done">done</span>` : html`<span class="copilot-agent-badge running">running…</span>`}
${msg.done ? html`<span class="copilot-agent-badge done">${t('copilot.agent_done')}</span>` : html`<span class="copilot-agent-badge running">${t('copilot.agent_running')}</span>`}
</div>
${msg.prompt_preview ? html`
<pre class="copilot-agent-preview">${msg.prompt_preview}</pre>
@@ -335,7 +336,7 @@ export function renderAgentEnd(msg) {
<i class="bi bi-arrow-right mx-1" style="font-size:0.7rem"></i>
<strong>${msg.parent_agent_id ?? 'main'}</strong>
</span>
<span class="copilot-agent-badge done">finished</span>
<span class="copilot-agent-badge done">${t('copilot.agent_finished')}</span>
</div>
${msg.result_preview ? html`
<pre class="copilot-agent-preview copilot-agent-preview--result">${msg.result_preview}</pre>
@@ -345,7 +346,7 @@ export function renderAgentEnd(msg) {
}
function failedBadge() {
return html`<span class="copilot-failed-badge" title="This message is not sent to the LLM">
return html`<span class="copilot-failed-badge" title=${t('copilot.not_sent_to_llm')}>
<i class="bi bi-exclamation-triangle-fill"></i>
</span>`;
}
@@ -393,7 +394,7 @@ export function renderAttachmentChips(host, attachments, { removable = false } =
<span class="attach-chip-name">${att.name}</span>
${att.filesize != null ? html`<span class="attach-chip-size">${fmtSize(att.filesize)}</span>` : nothing}
${removable ? html`
<button class="attach-chip-remove" title="Remove"
<button class="attach-chip-remove" title=${t('copilot.remove')}
@click=${(e) => { e.stopPropagation(); host._removeAttachment(i); }}>
<i class="bi bi-x"></i>
</button>` : nothing}
+124 -43
View File
@@ -1,26 +1,29 @@
import { html, nothing } from 'lit';
import { ChatSession } from '../lib/chat-session.js';
import { t, I18nMixin } from '../lib/i18n.js';
import { renderMsg, renderAttachmentChips } from './copilot-render.js';
// Built-in (server-handled) slash commands shown at the top of the composer
// autocomplete. Custom commands (from `commands/<name>/`) are fetched from
// `/api/commands` and appended below.
const SYSTEM_COMMAND_ITEMS = [
{ name: 'help', description: 'Show available commands' },
{ name: 'clear', description: 'Start a new conversation' },
{ name: 'new', description: 'Alias for /clear' },
{ name: 'models', description: 'List available LLM models' },
{ name: 'model', description: 'Select the model for this chat' },
{ name: 'context', description: "Last turn's token usage" },
{ name: 'cost', description: 'Session spend (USD)' },
{ name: 'compact', description: 'Force context compaction' },
{ name: 'resettools', description: 'Remove activated tool groups' },
{ name: 'sethome', description: 'Set web as notification home' },
{ name: 'help', description: () => t('copilot.cmd.help') },
{ name: 'clear', description: () => t('copilot.cmd.clear') },
{ name: 'new', description: () => t('copilot.cmd.new') },
{ name: 'models', description: () => t('copilot.cmd.models') },
{ name: 'model', description: () => t('copilot.cmd.model') },
{ name: 'context', description: () => t('copilot.cmd.context') },
{ name: 'cost', description: () => t('copilot.cmd.cost') },
{ name: 'compact', description: () => t('copilot.cmd.compact') },
{ name: 'resettools', description: () => t('copilot.cmd.resettools') },
{ name: 'sethome', description: () => t('copilot.cmd.sethome') },
];
export class AppCopilot extends ChatSession {
export class AppCopilot extends I18nMixin(ChatSession) {
static properties = {
_collapsed: { state: true },
_mode: { state: true },
_me: { state: true },
_modelOpen: { state: true },
_tabs: { state: true },
_activeSource: { state: true },
@@ -31,6 +34,9 @@ export class AppCopilot extends ChatSession {
constructor() {
super();
this._collapsed = false;
// 'full' fills the workspace (home route), 'dock' is the side panel.
this._mode = 'dock';
this._me = null;
this._modelOpen = false;
this._resizing = false;
// Slash-command autocomplete: `_cmdMenu` is the filtered list currently shown
@@ -41,23 +47,53 @@ export class AppCopilot extends ChatSession {
this._allCommands = null;
// Browser-style tabs: 'General' (the default 'web' source) is always present and
// not closable; project chats are added on demand and addressed by their source.
this._tabs = [{ source: 'web', label: 'General' }];
this._tabs = [{ source: 'web', label: t('chat.tab.general') }];
this._onResizeMove = this._onResizeMove.bind(this);
this._onResizeUp = this._onResizeUp.bind(this);
this._onKeydown = this._onKeydown.bind(this);
this._onKeyup = this._onKeyup.bind(this);
this._onProjectChatOpen = this._onProjectChatOpen.bind(this);
this._onCopilotOpen = this._onCopilotOpen.bind(this);
this._onPageChange = this._onPageChange.bind(this);
}
connectedCallback() {
super.connectedCallback?.();
this._restoreState();
this._loadCommands();
this._loadMe();
// Same element, two layouts: the chat is the home page ('full') and docks
// to the side on every other route — state is never lost, it only resizes.
this._applyMode(this._pageFromHash() === 'home' ? 'full' : 'dock');
window.addEventListener('keydown', this._onKeydown);
window.addEventListener('keyup', this._onKeyup);
window.addEventListener('project-chat-open', this._onProjectChatOpen);
window.addEventListener('copilot-open', this._onCopilotOpen);
window.addEventListener('llm-page-change', this._onPageChange);
}
_pageFromHash() {
const m = location.hash.slice(1).match(/^([^/?]+)/);
const seg = m ? m[1] : '';
const known = ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'];
return known.includes(seg) ? seg : 'home';
}
_onPageChange(e) {
this._applyMode(e.detail?.page === 'home' ? 'full' : 'dock');
}
_applyMode(mode) {
if (mode === this._mode && this.getAttribute('mode') === mode) return;
this._mode = mode;
this.setAttribute('mode', mode);
}
async _loadMe() {
try {
const res = await fetch('/api/auth/me');
if (res.ok) this._me = await res.json();
} catch { /* ignore */ }
}
_restoreState() {
@@ -74,6 +110,7 @@ export class AppCopilot extends ChatSession {
window.removeEventListener('keyup', this._onKeyup);
window.removeEventListener('project-chat-open', this._onProjectChatOpen);
window.removeEventListener('copilot-open', this._onCopilotOpen);
window.removeEventListener('llm-page-change', this._onPageChange);
}
_onCopilotOpen() {
@@ -254,36 +291,82 @@ export class AppCopilot extends ChatSession {
// ── Render ────────────────────────────────────────────────────────────────────
_sendSuggestion(text) {
const el = this._inputEl();
if (!el) return;
el.value = text;
this._send();
}
_renderEmptyState() {
// Dock mode keeps the compact greeting bubble; full mode (home) shows the
// welcome hero with a few prompt suggestions to get a conversation going.
if (this._mode !== 'full') {
return html`<div class="copilot-msg assistant">${t('chat.hello')}</div>`;
}
const name = this._me?.display_name || this._me?.username;
const suggestions = [
{ icon: 'bi-stars', text: t('chat.suggest.1') },
{ icon: 'bi-calendar-check', text: t('chat.suggest.2') },
{ icon: 'bi-book', text: t('chat.suggest.3') },
{ icon: 'bi-heart', text: t('chat.suggest.4') },
];
return html`
<div class="chat-hero">
<img class="chat-hero-logo" src="/assets/icons/icon-192.png" alt="" />
<h1 class="chat-hero-title">${name ? t('chat.greeting.named', { name }) : t('chat.greeting')}</h1>
<p class="chat-hero-sub">${t('chat.greeting.sub')}</p>
<div class="chat-suggestions">
${suggestions.map(s => html`
<button class="chat-suggestion" @click=${() => this._sendSuggestion(s.text)}>
<i class="bi ${s.icon}"></i>
<span>${s.text}</span>
</button>
`)}
</div>
</div>
`;
}
render() {
if (this._collapsed) return nothing;
// Collapse applies to the dock only: on the home route the chat IS the page.
if (this._collapsed && this._mode !== 'full') return nothing;
const full = this._mode === 'full';
return html`
<div class="copilot-resize-handle" @mousedown=${(e) => this._startResize(e)}></div>
${!full ? html`
<div class="copilot-resize-handle" @mousedown=${(e) => this._startResize(e)}></div>
` : nothing}
<div class="copilot-header">
<i class="bi bi-stars"></i>
<span>Copilot</span>
<button
class="btn btn-sm btn-outline-secondary ms-auto copilot-collapse-btn"
title="Collapse copilot"
@click=${() => { this._setCollapsed(true); }}
>
<i class="bi bi-chevron-right"></i>
</button>
<span>${t('chat.title')}</span>
<span class="chat-privacy" title=${t('chat.privacy.hint')}>
<i class="bi bi-lock-fill"></i>${t('chat.privacy')}
</span>
${!full ? html`
<button
class="btn btn-sm btn-outline-secondary ms-auto copilot-collapse-btn"
title=${t('chat.collapse')}
@click=${() => { this._setCollapsed(true); }}
>
<i class="bi bi-chevron-right"></i>
</button>
` : nothing}
</div>
${this._tabs.length > 1 ? html`
<div class="copilot-tabs">
${this._tabs.map(t => html`
${this._tabs.map(tab => html`
<div
class="copilot-tab ${t.source === this._source ? 'copilot-tab--active' : ''}"
@click=${() => this._selectTab(t.source)}
title=${t.label}
class="copilot-tab ${tab.source === this._source ? 'copilot-tab--active' : ''}"
@click=${() => this._selectTab(tab.source)}
title=${tab.label}
>
<span class="copilot-tab-label">${t.label}</span>
${t.source !== 'web' ? html`
<button class="copilot-tab-close" title="Close tab"
@click=${e => this._closeTab(t.source, e)}>
<span class="copilot-tab-label">${tab.label}</span>
${tab.source !== 'web' ? html`
<button class="copilot-tab-close" title=${t('chat.close_tab')}
@click=${e => this._closeTab(tab.source, e)}>
<i class="bi bi-x"></i>
</button>
` : nothing}
@@ -293,16 +376,14 @@ export class AppCopilot extends ChatSession {
` : nothing}
<div class="copilot-messages">
${this._messages.length === 0 ? html`
<div class="copilot-msg assistant">
Hello! How can I help you today?
</div>
` : this._messages.map(m => renderMsg(this, m))}
${this._messages.length === 0
? this._renderEmptyState()
: this._messages.map(m => renderMsg(this, m))}
${this._waiting ? html`
<div class="copilot-msg assistant copilot-thinking">
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Thinking…
${t('chat.thinking')}
</div>
` : nothing}
</div>
@@ -319,7 +400,7 @@ export class AppCopilot extends ChatSession {
@mousedown=${(e) => { e.preventDefault(); this._applyCmd(c.name); }}
>
<span class="copilot-cmd-name">/${c.name}</span>
<span class="copilot-cmd-desc">${c.description}</span>
<span class="copilot-cmd-desc">${typeof c.description === 'function' ? c.description() : c.description}</span>
</button>
`)}
</div>
@@ -335,7 +416,7 @@ export class AppCopilot extends ChatSession {
<textarea
class="copilot-textarea"
rows="1"
placeholder="Ask the copilot… (Enter to send, Shift+Enter for new line)"
placeholder=${t('chat.placeholder')}
@keydown=${this._composerKeydown}
@input=${(e) => { this._autoResize(e.target); this._updateCmdMenu(e.target.value); }}
@paste=${(e) => this._onPaste(e)}
@@ -344,7 +425,7 @@ export class AppCopilot extends ChatSession {
<div class="copilot-toolbar-left">
<button
class="copilot-toolbar-btn"
title="Attach files"
title=${t('chat.attach')}
@click=${() => this.querySelector('.copilot-file-input')?.click()}
><i class="bi bi-paperclip"></i></button>
${this._providers.length > 1 ? html`
@@ -369,7 +450,7 @@ export class AppCopilot extends ChatSession {
` : nothing}
<button
class="copilot-toolbar-btn"
title="New session"
title=${t('chat.new_session')}
@click=${() => this._startNewSession()}
><i class="bi bi-trash"></i></button>
</div>
@@ -377,18 +458,18 @@ export class AppCopilot extends ChatSession {
${this._hasTranscribe ? html`
<button
class="copilot-send-btn ${this._recording ? 'copilot-send-btn--recording' : ''}"
title="${this._recording ? 'Stop recording' : 'Record voice (Ctrl+Space)'}"
title="${this._recording ? t('chat.stop') : t('chat.attach')}"
@click=${() => this._toggleRecording()}
>
<i class="bi ${this._recording ? 'bi-stop-circle-fill' : 'bi-mic-fill'}"></i>
</button>
` : nothing}
${this._waiting
? html`<button class="copilot-send-btn copilot-send-btn--stop" @click=${() => this._cancel()} title="Stop">
? html`<button class="copilot-send-btn copilot-send-btn--stop" @click=${() => this._cancel()} title=${t('chat.stop')}>
<i class="bi bi-stop-fill"></i>
</button>`
: nothing}
<button class="copilot-send-btn" @click=${() => this._send()} title="Send">
<button class="copilot-send-btn" @click=${() => this._send()} title=${t('chat.send')}>
<i class="bi bi-send-fill"></i>
</button>
</div>
@@ -1,53 +1,9 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { InboxMixin } from '../lib/inbox-mixin.js';
const GUIDE = [
{
icon: 'bi-chat-dots-fill',
title: 'Copilot',
desc: 'The chat panel on the right knows everything — ask it to run agents, enable plugins, write code, or search the web.',
color: '#0d6efd',
},
{
icon: 'bi-inbox',
title: 'Inbox',
desc: 'Pending approvals and agent questions that need your input before background tasks can continue.',
color: '#f59e0b',
},
{
icon: 'bi-people',
title: 'Agents',
desc: 'Specialized sub-agents (engineer, architect, QA…). Each has a focused system prompt, tool set, and model selection.',
color: '#8b5cf6',
},
{
icon: 'bi-clock',
title: 'Cron',
desc: 'Scheduled tasks that run automatically at set intervals, even when the Copilot is idle.',
color: '#f97316',
},
{
icon: 'bi-cpu',
title: 'Models',
desc: 'Manage LLM, transcription, and image generation models. Drag to reorder priority.',
color: '#10b981',
},
{
icon: 'bi-plug',
title: 'Providers',
desc: 'Add API keys for LLM providers (Anthropic, OpenAI, OpenRouter, Ollama…).',
color: '#06b6d4',
},
{
icon: 'bi-shield-check',
title: 'Security',
desc: 'Define rules to auto-approve or auto-reject tool calls — skip repetitive confirmation prompts.',
color: '#ef4444',
},
];
export class HomePage extends InboxMixin(LightElement) {
export class DashboardPage extends InboxMixin(LightElement) {
static get properties() {
return {
@@ -55,8 +11,6 @@ export class HomePage extends InboxMixin(LightElement) {
_open: { state: true },
_models: { state: true },
_plugins: { state: true },
_debugMode: { state: true },
_debugLoading: { state: true },
_stats: { state: true },
_statsRange: { state: true },
};
@@ -68,8 +22,6 @@ export class HomePage extends InboxMixin(LightElement) {
this._models = null; // null = loading, [] = no models configured
this._plugins = null;
this._pollTimer = null;
this._debugMode = false;
this._debugLoading = true;
this._stats = null; // null = loading
this._statsRange = 'week';
this._chartInstances = {};
@@ -78,8 +30,10 @@ export class HomePage extends InboxMixin(LightElement) {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'home';
this._open = e.detail.page === 'dashboard';
this.style.display = this._open ? 'flex' : 'none';
if (this._open) {
this._loadAll();
@@ -92,6 +46,7 @@ export class HomePage extends InboxMixin(LightElement) {
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
this._stopPolling();
this._destroyCharts();
@@ -120,39 +75,9 @@ export class HomePage extends InboxMixin(LightElement) {
this._loadModels(),
this._loadPlugins(),
this._loadInbox(),
this._loadDebugMode(),
]);
}
async _loadDebugMode() {
try {
const res = await fetch('/api/dev/debug_mode');
if (!res.ok) throw new Error();
const data = await res.json();
this._debugMode = data.enabled;
} catch {
// ignore, keep current value
} finally {
this._debugLoading = false;
}
}
async _toggleDebugMode() {
const next = !this._debugMode;
this._debugMode = next;
try {
const res = await fetch('/api/dev/debug_mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: next }),
});
if (!res.ok) throw new Error();
window.dispatchEvent(new CustomEvent('debug-mode-change', { detail: { enabled: next } }));
} catch {
this._debugMode = !next;
}
}
async _loadModels() {
try {
const res = await fetch('/api/llm/models');
@@ -195,15 +120,27 @@ export class HomePage extends InboxMixin(LightElement) {
}
get _statusInfo() {
if (this._models === null) return { cls: 'loading', dot: false, icon: null, text: 'Loading' };
if (this._models.length === 0) return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: 'No LLM models' };
if (this._models.some(m => m.status === 'healthy')) return { cls: 'online', dot: true, icon: null, text: 'Online & ready' };
if (this._models.some(m => m.status === 'degraded')) return { cls: 'warn', dot: true, icon: 'bi-exclamation-triangle-fill', text: 'Degraded' };
return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: 'All models offline' };
if (this._models === null) return { cls: 'loading', dot: false, icon: null, text: t('dashboard.status.loading') };
if (this._models.length === 0) return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: t('dashboard.status.no_models') };
if (this._models.some(m => m.status === 'healthy')) return { cls: 'online', dot: true, icon: null, text: t('dashboard.status.online') };
if (this._models.some(m => m.status === 'degraded')) return { cls: 'warn', dot: true, icon: 'bi-exclamation-triangle-fill', text: t('dashboard.status.degraded') };
return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: t('dashboard.status.offline') };
}
get _guide() {
return [
{ icon: 'bi-chat-dots-fill', title: t('dashboard.guide.chat.title'), desc: t('dashboard.guide.chat.desc'), color: '#d95d4e' },
{ icon: 'bi-inbox', title: t('dashboard.guide.inbox.title'), desc: t('dashboard.guide.inbox.desc'), color: '#f59e0b' },
{ icon: 'bi-people', title: t('dashboard.guide.agents.title'), desc: t('dashboard.guide.agents.desc'), color: '#8b5cf6' },
{ icon: 'bi-clock', title: t('dashboard.guide.cron.title'), desc: t('dashboard.guide.cron.desc'), color: '#f97316' },
{ icon: 'bi-cpu', title: t('dashboard.guide.models.title'), desc: t('dashboard.guide.models.desc'), color: '#10b981' },
{ icon: 'bi-plug', title: t('dashboard.guide.providers.title'), desc: t('dashboard.guide.providers.desc'), color: '#06b6d4' },
{ icon: 'bi-shield-check', title: t('dashboard.guide.security.title'), desc: t('dashboard.guide.security.desc'), color: '#ef4444' },
];
}
_nav(page) {
const url = page === 'home' ? location.pathname : '#' + page;
const url = '#' + page;
history.pushState({ page }, '', url);
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } }));
}
@@ -225,7 +162,7 @@ export class HomePage extends InboxMixin(LightElement) {
}
get _periodLabel() {
return { hour: '/ min', day: '/ hour', week: '/ day', month: '/ day' }[this._statsRange] ?? '/ day';
return { hour: t('dashboard.stats.per_min'), day: t('dashboard.stats.per_hour'), week: t('dashboard.stats.per_day'), month: t('dashboard.stats.per_day') }[this._statsRange] ?? t('dashboard.stats.per_day');
}
// Generates the full sequence of expected slots for the current range and
@@ -341,15 +278,15 @@ export class HomePage extends InboxMixin(LightElement) {
data: {
labels: days,
datasets: isHour ? [
lineDs(inp, '#3b82f6', 'rgba(59,130,246,0)', { fill: false, label: 'Input' }),
lineDs(out, '#10b981', 'rgba(16,185,129,0)', { fill: false, label: 'Output' }),
lineDs(cache, '#f59e0b', 'rgba(245,158,11,0)', { fill: false, label: 'Cached' }),
lineDs(inp, '#3b82f6', 'rgba(59,130,246,0)', { fill: false, label: t('dashboard.stats.chart.input') }),
lineDs(out, '#10b981', 'rgba(16,185,129,0)', { fill: false, label: t('dashboard.stats.chart.output') }),
lineDs(cache, '#f59e0b', 'rgba(245,158,11,0)', { fill: false, label: t('dashboard.stats.chart.cached') }),
] : (() => {
const nonCached = inp.map((v, i) => Math.max(0, v - (cache[i] ?? 0)));
return [
{ label: 'Cached', data: cache, backgroundColor: '#f59e0b', stack: 'tok', borderSkipped: false },
{ label: 'Non-cached', data: nonCached, backgroundColor: '#3b82f6', stack: 'tok', borderSkipped: false },
{ label: 'Output', data: out, backgroundColor: '#10b981', stack: 'tok', borderRadius: 4, borderSkipped: false },
{ label: t('dashboard.stats.chart.cached'), data: cache, backgroundColor: '#f59e0b', stack: 'tok', borderSkipped: false },
{ label: t('dashboard.stats.chart.non_cached'), data: nonCached, backgroundColor: '#3b82f6', stack: 'tok', borderSkipped: false },
{ label: t('dashboard.stats.chart.output'), data: out, backgroundColor: '#10b981', stack: 'tok', borderRadius: 4, borderSkipped: false },
];
})(),
},
@@ -365,7 +302,7 @@ export class HomePage extends InboxMixin(LightElement) {
const total = inp[idx] ?? 0;
if (!total) return '';
const pct = Math.round((cache[idx] ?? 0) / total * 100);
return `Cache hit: ${pct}%`;
return t('dashboard.stats.chart.cache_hit', { pct });
},
},
},
@@ -413,7 +350,7 @@ export class HomePage extends InboxMixin(LightElement) {
_renderStats() {
if (this._stats === null) {
return html`<div class="home-stats-loading"><i class="bi bi-hourglass-split"></i> Loading stats…</div>`;
return html`<div class="home-stats-loading"><i class="bi bi-hourglass-split"></i> ${t('dashboard.stats.loading')}</div>`;
}
const empty = this._stats.daily.length === 0 && this._stats.models.length === 0;
@@ -421,7 +358,7 @@ export class HomePage extends InboxMixin(LightElement) {
return html`
<div class="home-stats-empty">
<i class="bi bi-bar-chart"></i>
<span>No LLM requests in the selected range.</span>
<span>${t('dashboard.stats.empty')}</span>
</div>
`;
}
@@ -429,19 +366,19 @@ export class HomePage extends InboxMixin(LightElement) {
return html`
<div class="home-stats-grid">
<div class="home-stat-card">
<div class="home-stat-card-title">Requests ${this._periodLabel}</div>
<div class="home-stat-card-title">${t('dashboard.stats.requests', { per: this._periodLabel })}</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-requests"></canvas></div>
</div>
<div class="home-stat-card">
<div class="home-stat-card-title">Tokens ${this._periodLabel}</div>
<div class="home-stat-card-title">${t('dashboard.stats.tokens', { per: this._periodLabel })}</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-tokens"></canvas></div>
</div>
<div class="home-stat-card">
<div class="home-stat-card-title">Avg latency (ms)</div>
<div class="home-stat-card-title">${t('dashboard.stats.latency')}</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-latency"></canvas></div>
</div>
<div class="home-stat-card">
<div class="home-stat-card-title">Models</div>
<div class="home-stat-card-title">${t('dashboard.stats.models')}</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-models"></canvas></div>
</div>
</div>
@@ -459,28 +396,14 @@ export class HomePage extends InboxMixin(LightElement) {
return html`
<div class="home-page">
<!-- Debug toggle -->
<div class="home-debug-bar">
<label class="home-debug-toggle" title="${this._debugMode ? 'Debug mode on' : 'Debug mode off'}">
<i class="bi bi-bug-fill"></i>
<span>Debug</span>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox"
.checked=${this._debugMode}
@change=${this._toggleDebugMode}
?disabled=${this._debugLoading} />
</div>
</label>
</div>
<!-- Hero -->
<div class="home-hero">
<div class="home-hero-image">
<img src="/assets/icons/icon-1024.png" alt="Skald" />
<img src="/assets/icons/icon-1024.png" alt=${t('chat.title')} />
</div>
<div class="home-hero-text">
<h1 class="home-hero-title">Skald</h1>
<p class="home-hero-desc">Your AI command centre research, code, plan, and orchestrate. All in one place.</p>
<h1 class="home-hero-title">${t('chat.title')}</h1>
<p class="home-hero-desc">${t('dashboard.hero.subtitle')}</p>
<div class="home-hero-status home-hero-status--${st.cls}">
${st.dot ? html`<span class="home-hero-dot"></span>` : nothing}
${st.icon ? html`<i class="bi ${st.icon}"></i>` : nothing}
@@ -494,11 +417,11 @@ export class HomePage extends InboxMixin(LightElement) {
<div class="home-banner home-banner--error">
<div class="home-banner-icon"><i class="bi bi-cpu-fill"></i></div>
<div class="home-banner-body">
<strong>No LLM models configured.</strong>
Start by adding a provider (Anthropic, OpenAI, OpenRouter), then add at least one model in the Models section.
<strong>${t('dashboard.banner.no_models.title')}</strong>
${t('dashboard.banner.no_models.desc')}
</div>
<button class="btn btn-sm btn-danger" @click=${() => this._nav('providers')}>
Add a provider
${t('dashboard.banner.no_models.action')}
</button>
</div>
` : nothing}
@@ -506,9 +429,9 @@ export class HomePage extends InboxMixin(LightElement) {
<!-- LLM Stats -->
<div class="home-section-title">
<i class="bi bi-bar-chart-fill"></i>
<span>LLM Stats</span>
<span>${t('dashboard.section.stats')}</span>
<div class="home-stats-range ms-auto">
${[['hour','1h'],['day','24h'],['week','7d'],['month','30d']].map(([r, label]) => html`
${[['hour', t('dashboard.stats.range.hour')], ['day', t('dashboard.stats.range.day')], ['week', t('dashboard.stats.range.week')], ['month', t('dashboard.stats.range.month')]].map(([r, label]) => html`
<button class="home-stats-range-btn ${this._statsRange === r ? 'active' : ''}"
@click=${() => this._setRange(r)}>${label}</button>
`)}
@@ -519,9 +442,9 @@ export class HomePage extends InboxMixin(LightElement) {
<!-- Pending inbox -->
<div class="home-section-title">
<i class="bi bi-inbox"></i>
<span>Pending</span>
<span>${t('dashboard.section.pending')}</span>
${inboxTotal > 0 ? html`<span class="badge bg-danger">${inboxTotal}</span>` : nothing}
<button class="inbox-refresh-btn ms-auto" title="Refresh" @click=${() => this._loadInbox()}>
<button class="inbox-refresh-btn ms-auto" title=${t('dashboard.refresh')} @click=${() => this._loadInbox()}>
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
@@ -532,8 +455,8 @@ export class HomePage extends InboxMixin(LightElement) {
<div class="home-tip">
<div class="home-tip-icon"><i class="bi bi-lightbulb-fill"></i></div>
<div class="home-tip-body">
<strong>Enable Honcho</strong>
<span>Persistent long-term memory the agent learns your preferences over time. Ask the Copilot to enable it.</span>
<strong>${t('dashboard.tip.honcho.title')}</strong>
<span>${t('dashboard.tip.honcho.desc')}</span>
</div>
</div>
` : nothing}
@@ -541,10 +464,10 @@ export class HomePage extends InboxMixin(LightElement) {
<!-- Quick guide -->
<div class="home-section-title">
<i class="bi bi-map"></i>
<span>Quick guide</span>
<span>${t('dashboard.section.guide')}</span>
</div>
<div class="home-guide">
${GUIDE.map(s => html`
${this._guide.map(s => html`
<div class="home-card" style="--home-card-color: ${s.color}">
<div class="home-card-icon">
<i class="bi ${s.icon}"></i>
+3 -2
View File
@@ -1,4 +1,5 @@
import { html, nothing } from 'lit';
import { t } from '../lib/i18n.js';
import { FileViewerBase } from './shared/file-viewer-base.js';
const PAGE_ID = 'file_viewer';
@@ -58,14 +59,14 @@ export class FileViewerPage extends FileViewerBase {
<div class="llm-page fv-page">
<div class="llm-page-header">
<div class="llm-header-left">
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back" @click=${() => this._back()}>
<button class="btn btn-sm btn-outline-secondary back-btn" title=${t('fv.back')} @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i>
</button>
<h2 class="llm-page-title fv-title" title=${this._path ?? ''}><bdi>${this._path ?? ''}</bdi></h2>
</div>
<div class="fv-header-actions">
${this._renderModeToggle('btn btn-sm btn-outline-secondary fv-download-btn')}
<button class="btn btn-sm btn-outline-secondary fv-download-btn" title="Download" @click=${() => this._download()}>
<button class="btn btn-sm btn-outline-secondary fv-download-btn" title=${t('fv.download')} @click=${() => this._download()}>
<i class="bi bi-download"></i>
</button>
</div>
+34 -27
View File
@@ -1,5 +1,6 @@
import { html } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
function emptyForm(firstTypeId = '') {
return { name: '', type: firstTypeId, api_key: '', base_url: '', description: '' };
@@ -35,6 +36,8 @@ export class LlmProvidersPage extends LightElement {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'providers';
this.style.display = this._open ? 'flex' : 'none';
@@ -42,6 +45,11 @@ export class LlmProvidersPage extends LightElement {
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _load() {
try {
const [typesRes, provRes, modelsRes] = await Promise.all([
@@ -49,9 +57,9 @@ export class LlmProvidersPage extends LightElement {
fetch('/api/llm/providers'),
fetch('/api/llm/models'),
]);
if (!typesRes.ok) throw new Error(`Provider types: HTTP ${typesRes.status}`);
if (!provRes.ok) throw new Error(`Providers: HTTP ${provRes.status}`);
if (!modelsRes.ok) throw new Error(`Models: HTTP ${modelsRes.status}`);
if (!typesRes.ok) throw new Error(`HTTP ${typesRes.status}`);
if (!provRes.ok) throw new Error(`HTTP ${provRes.status}`);
if (!modelsRes.ok) throw new Error(`HTTP ${modelsRes.status}`);
const providerTypes = await typesRes.json();
const providers = await provRes.json();
@@ -104,7 +112,7 @@ export class LlmProvidersPage extends LightElement {
}
async _delete(provider) {
if (!confirm(`Delete provider "${provider.name}"? All associated models will be deleted too.`)) return;
if (!confirm(t('providers.confirm.delete', { name: provider.name }))) return;
try {
const res = await fetch(`/api/llm/providers/${provider.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
@@ -176,15 +184,15 @@ export class LlmProvidersPage extends LightElement {
<span class="pv-card-name">${p.name}</span>
<span class="pv-card-type-badge">${label}</span>
${count != null ? html`
<span class="pv-card-count" title="Models using this provider">
<span class="pv-card-count" title=${t('providers.card.models_title')}>
<i class="bi bi-cpu me-1"></i>${count}
</span>
` : ''}
<div class="pv-card-actions">
<button class="pv-btn-icon pv-btn-edit" title="Edit" @click=${() => this._openEdit(p)}>
<button class="pv-btn-icon pv-btn-edit" title=${t('providers.card.edit')} @click=${() => this._openEdit(p)}>
<i class="bi bi-pencil"></i>
</button>
<button class="pv-btn-icon pv-btn-delete" title="Delete" @click=${() => this._delete(p)}>
<button class="pv-btn-icon pv-btn-delete" title=${t('providers.card.delete')} @click=${() => this._delete(p)}>
<i class="bi bi-trash"></i>
</button>
</div>
@@ -199,10 +207,10 @@ export class LlmProvidersPage extends LightElement {
<div class="pv-card-row3">
<span class="pv-card-tag ${hasKey ? 'pv-tag-ok' : 'pv-tag-missing'}">
<i class="bi ${hasKey ? 'bi-lock-fill' : 'bi-unlock'}"></i>
API key ${hasKey ? 'configured' : 'missing'}
${hasKey ? t('providers.card.api_key_configured') : t('providers.card.api_key_missing')}
</span>
${needsUrl && p.base_url ? html`
<span class="pv-card-tag pv-tag-url" title="Base URL">
<span class="pv-card-tag pv-tag-url" title=${t('providers.card.base_url')}>
<i class="bi bi-link-45deg"></i>
<span class="pv-card-url-text">${p.base_url}</span>
</span>
@@ -232,7 +240,7 @@ export class LlmProvidersPage extends LightElement {
<div class="agent-dialog pv-modal">
<div class="pv-modal-header">
<i class="bi bi-plug"></i>
<span>${isEdit ? 'Edit Provider' : 'Add Provider'}</span>
<span>${isEdit ? t('providers.modal.edit') : t('providers.modal.add')}</span>
<button type="button" class="pv-modal-close" @click=${() => this._closeModal()}>
<i class="bi bi-x"></i>
</button>
@@ -242,13 +250,13 @@ export class LlmProvidersPage extends LightElement {
<form @submit=${(e) => this._onSubmit(e)}>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Name</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('providers.modal.name')}</label>
<input type="text" class="form-control form-control-sm" .value=${f.name} required
placeholder="e.g. My Anthropic" @input=${(e) => this._setField('name', e.target.value)} />
placeholder=${t('providers.modal.name_ph')} @input=${(e) => this._setField('name', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Type</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('providers.modal.type')}</label>
<select class="form-select form-select-sm" .value=${f.type}
@change=${(e) => this._setField('type', e.target.value)}>
${this._providerTypes.map(t => html`<option value=${t.type_id}>${t.display_name}</option>`)}
@@ -257,35 +265,35 @@ export class LlmProvidersPage extends LightElement {
${needsKey ? html`
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">API Key</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('providers.modal.api_key')}</label>
<input type="password" class="form-control form-control-sm" .value=${f.api_key}
autocomplete="new-password"
placeholder=${isEdit ? 'Leave blank to keep existing key' : ''}
placeholder=${isEdit ? t('providers.modal.api_key_ph') : ''}
@input=${(e) => this._setField('api_key', e.target.value)} />
</div>
` : ''}
${needsUrl ? html`
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Base URL</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('providers.modal.base_url')}</label>
<input type="text" class="form-control form-control-sm" .value=${f.base_url}
placeholder=${f.type === 'ollama' ? 'http://localhost:11434' : 'http://localhost:1234/v1'}
placeholder=${f.type === 'ollama' ? t('providers.modal.base_url_ollama') : t('providers.modal.base_url_oai')}
@input=${(e) => this._setField('base_url', e.target.value)} />
</div>
` : ''}
<div class="mb-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Description <span class="text-muted fw-normal">(optional)</span></label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('providers.modal.description')} <span class="text-muted fw-normal">${t('providers.modal.description_optional')}</span></label>
<input type="text" class="form-control form-control-sm" .value=${f.description}
@input=${(e) => this._setField('description', e.target.value)} />
</div>
<div class="pv-modal-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>Cancel</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>${t('providers.modal.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
: html`<i class="bi bi-check-lg me-1"></i>${isEdit ? 'Save changes' : 'Add provider'}`}
? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('providers.modal.saving')}`
: html`<i class="bi bi-check-lg me-1"></i>${isEdit ? t('providers.modal.save_changes') : t('providers.modal.add_provider')}`}
</button>
</div>
</form>
@@ -301,13 +309,12 @@ export class LlmProvidersPage extends LightElement {
<div class="pv-page">
<div class="pv-header">
<h2 class="pv-title">
<i class="bi bi-plug me-2"></i>Providers
<i class="bi bi-plug me-2"></i>${t('providers.title')}
</h2>
<div class="pv-header-right">
<span class="pv-header-count">${this._providers.length}</span>
<span class="pv-header-count">${t('providers.count', { n: this._providers.length })}</span>
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
<i class="bi bi-plus-lg me-1"></i>Add
</button>
<i class="bi bi-plus-lg me-1"></i>${t('providers.add')}
</div>
</div>
@@ -319,9 +326,9 @@ export class LlmProvidersPage extends LightElement {
${this._providers.length === 0 ? html`
<div class="pv-empty">
<i class="bi bi-plug"></i>
<p>No providers configured yet.</p>
<p>${t('providers.empty')}</p>
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
<i class="bi bi-plus-lg me-1"></i>Add your first provider
<i class="bi bi-plus-lg me-1"></i>${t('providers.add_first')}
</button>
</div>
` : this._providers.map(p => this._renderCard(p))}
+37 -25
View File
@@ -1,6 +1,7 @@
import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement, renderMarkdown } from '../lib/base.js';
import { t } from '../lib/i18n.js';
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -180,6 +181,17 @@ export class LlmRequestDetail extends LightElement {
this._expandedTools = new Set();
}
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
updated(changed) {
if (changed.has('detailId') && this.detailId != null) {
this._detail = null;
@@ -263,20 +275,20 @@ export class LlmRequestDetail extends LightElement {
_renderStatBar(d) {
return html`
<div class="llmr-detail-statbar">
<span class="llmr-badge-agent">${d.agent_id ?? 'no agent'}</span>
<span class="llmr-badge-agent">${d.agent_id ?? t('llmr.detail.no_agent')}</span>
<span class="llmr-badge-source">${d.source ?? '—'}</span>
<span class="llmr-detail-model">${d.model_name}</span>
${d.stack_id != null ? html`<span class="llmr-detail-pill llmr-detail-pill--stack">stack #${d.stack_id}</span>` : nothing}
<span class="llmr-detail-sep"></span>
<span class="llmr-detail-stat" title="Input tokens">
<span class="llmr-detail-stat" title=${t('llmr.detail.stat_input')}>
<i class="bi bi-arrow-up-circle"></i> ${fmtTokens(d.input_tokens)}
</span>
<span class="llmr-detail-stat" title="Output tokens">
<span class="llmr-detail-stat" title=${t('llmr.detail.stat_output')}>
<i class="bi bi-arrow-down-circle"></i> ${fmtTokens(d.output_tokens)}
</span>
${d.cache_read_tokens > 0 ? html`
<span class="llmr-detail-stat llmr-detail-stat--cache" title=${cacheTooltip(d)}>
<i class="bi bi-lightning-charge"></i> cache ${cacheHitPct(d)}
<i class="bi bi-lightning-charge"></i> ${t('llmr.detail.cache_label', { pct: cacheHitPct(d) })}
</span>
` : nothing}
<span class="llmr-detail-stat">
@@ -285,7 +297,7 @@ export class LlmRequestDetail extends LightElement {
<span class="llmr-detail-date">${formatDate(d.created_at)}</span>
${d.error_text ? html`
<span class="llmr-detail-error-badge" title=${d.error_text}>
<i class="bi bi-exclamation-triangle-fill"></i> error
<i class="bi bi-exclamation-triangle-fill"></i> ${t('llmr.detail.error_badge')}
</span>
` : nothing}
</div>
@@ -312,7 +324,7 @@ export class LlmRequestDetail extends LightElement {
<div class="llmr-reasoning-block">
<div class="llmr-reasoning-header" @click=${() => this._toggleToolExpand(key)}>
<i class="bi bi-lightbulb"></i>
<span>reasoning</span>
<span>${t('llmr.detail.reasoning_label')}</span>
<span class="llmr-tool-toggle ms-auto">
<i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i>
</span>
@@ -346,11 +358,11 @@ export class LlmRequestDetail extends LightElement {
</div>
${open ? html`
<div class="llmr-tool-expanded">
<div class="llmr-tool-section-label">Parameters</div>
<div class="llmr-tool-section-label">${t('llmr.detail.tool_params')}</div>
<pre class="llmr-tool-pre">${args}</pre>
${result != null ? html`
<div class="llmr-tool-section-label llmr-tool-section-label--result">
Result ${result.is_error ? html`<span class="badge bg-danger ms-1">error</span>` : nothing}
${t('llmr.detail.tool_result')} ${result.is_error ? html`<span class="badge bg-danger ms-1">${t('llmr.detail.error_badge')}</span>` : nothing}
</div>
<pre class="llmr-tool-pre">${result.content}</pre>
` : nothing}
@@ -370,9 +382,9 @@ export class LlmRequestDetail extends LightElement {
<div class="llmr-tool-block llmr-tool-block--result ${block.is_error ? 'llmr-tool-block--error' : ''}">
<div class="llmr-tool-block-header" @click=${() => this._toggleToolExpand(key)}>
<i class="bi bi-arrow-return-left"></i>
<span class="llmr-tool-name">result</span>
<span class="llmr-tool-name">${t('llmr.detail.tool_result')}</span>
<span class="llmr-tool-id">${block.tool_use_id ?? ''}</span>
${block.is_error ? html`<span class="badge bg-danger ms-1">error</span>` : nothing}
${block.is_error ? html`<span class="badge bg-danger ms-1">${t('llmr.detail.error_badge')}</span>` : nothing}
<span class="llmr-tool-toggle ms-auto">
<i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i>
</span>
@@ -403,7 +415,7 @@ export class LlmRequestDetail extends LightElement {
if (!text) return nothing;
return html`
<div class="llmr-msg llmr-msg--system">
<div class="llmr-msg-role"><i class="bi bi-shield-lock-fill"></i> system</div>
<div class="llmr-msg-role"><i class="bi bi-shield-lock-fill"></i> ${t('llmr.detail.system_role')}</div>
<div class="llmr-msg-body">
<div class="llmr-system-md copilot-markdown">${unsafeHTML(renderMarkdown(text))}</div>
</div>
@@ -434,12 +446,12 @@ export class LlmRequestDetail extends LightElement {
<div class="llmr-page">
<div class="llmr-detail-back">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i> Back
<i class="bi bi-arrow-left"></i> ${t('llmr.detail.back')}
</button>
</div>
<div class="llmr-state">
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
<span>Loading…</span>
<span>${t('llmr.detail.loading')}</span>
</div>
</div>
`;
@@ -448,7 +460,7 @@ export class LlmRequestDetail extends LightElement {
<div class="llmr-page">
<div class="llmr-detail-back">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i> Back
<i class="bi bi-arrow-left"></i> ${t('llmr.detail.back')}
</button>
</div>
<div class="llmr-state llmr-state--error">
@@ -479,10 +491,10 @@ export class LlmRequestDetail extends LightElement {
<div class="llmr-page">
<div class="llmr-detail-back">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i> Back
<i class="bi bi-arrow-left"></i> ${t('llmr.detail.back')}
</button>
<span class="llmr-detail-title">
<i class="bi bi-journal-code"></i> Request <span class="llmr-detail-id">#${d.id}</span>
<i class="bi bi-journal-code"></i> ${t('llmr.detail.request')} <span class="llmr-detail-id">#${d.id}</span>
</span>
</div>
@@ -491,34 +503,34 @@ export class LlmRequestDetail extends LightElement {
${payloadMissing ? html`
<div class="llmr-purged-banner">
<i class="bi bi-hourglass-split"></i>
Payload not available — this request has been purged by the retention policy.
${t('llmr.detail.purged')}
</div>
` : nothing}
${hdrs ? this._renderSection('req-headers', 'Request Headers',
${hdrs ? this._renderSection('req-headers', t('llmr.detail.section_req_headers'),
this._renderKvTable(Object.entries(hdrs))
) : nothing}
${respHdrs ? this._renderSection('resp-headers', 'Response Headers',
${respHdrs ? this._renderSection('resp-headers', t('llmr.detail.section_resp_headers'),
this._renderKvTable(Object.entries(respHdrs))
) : nothing}
${params.length ? this._renderSection('params', 'Parameters',
${params.length ? this._renderSection('params', t('llmr.detail.section_params'),
this._renderKvTable(params)
) : nothing}
${system ? this._renderSection('system', 'System Prompt',
${system ? this._renderSection('system', t('llmr.detail.section_system'),
html`<div class="llmr-system-md copilot-markdown">${unsafeHTML(renderMarkdown(system))}</div>`
) : nothing}
${msgs.length ? this._renderSection('conversation', 'Conversation',
${msgs.length ? this._renderSection('conversation', t('llmr.detail.section_conversation'),
html`<div class="llmr-msg-list">
${msgs.map((m, i) => this._renderMessage(m, i, toolResultMap))}
</div>`,
msgs.length
) : nothing}
${tools.length ? this._renderSection('tools', 'Tools Defined',
${tools.length ? this._renderSection('tools', t('llmr.detail.section_tools'),
html`<div class="llmr-tool-def-list">
${tools.map((t, i) => {
// Anthropic: { name, description, input_schema }
@@ -550,7 +562,7 @@ export class LlmRequestDetail extends LightElement {
tools.length
) : nothing}
${resp ? this._renderSection('response', 'Response',
${resp ? this._renderSection('response', t('llmr.detail.section_response'),
html`
${respMeta.length ? this._renderKvTable(respMeta) : nothing}
<div class="llmr-msg-list llmr-msg-list--resp">
+31 -23
View File
@@ -1,5 +1,6 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const PAGE_ID = 'llm-requests';
const PAGE_SIZE = 20;
@@ -25,8 +26,8 @@ function cacheHitPct(item) {
function cacheTooltip(item) {
const parts = [];
if (item.cache_read_tokens != null) parts.push(`read: ${item.cache_read_tokens.toLocaleString()} tk`);
if (item.cache_creation_tokens != null) parts.push(`write: ${item.cache_creation_tokens.toLocaleString()} tk`);
if (item.cache_read_tokens != null) parts.push(t('llmr.cache_read', { n: item.cache_read_tokens.toLocaleString() }));
if (item.cache_creation_tokens != null) parts.push(t('llmr.cache_write', { n: item.cache_creation_tokens.toLocaleString() }));
return parts.length ? parts.join(' | ') : '';
}
@@ -64,6 +65,8 @@ export class LlmRequestsPage extends LightElement {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none';
@@ -75,6 +78,11 @@ export class LlmRequestsPage extends LightElement {
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
_idFromHash() {
const parts = location.hash.replace('#', '').split('/');
if (parts[0] === PAGE_ID && parts[1]) {
@@ -138,29 +146,29 @@ export class LlmRequestsPage extends LightElement {
return html`
<div class="llmr-filters">
<div class="llmr-filter-group">
<label class="llmr-filter-label">Agent ID</label>
<label class="llmr-filter-label">${t('llmr.filter.agent_id')}</label>
<input class="form-control form-control-sm" type="text"
placeholder="e.g. main"
placeholder=${t('llmr.filter.agent_ph')}
.value=${this._agentId}
@input=${e => this._agentId = e.target.value}
@keydown=${e => e.key === 'Enter' && this._apply()} />
</div>
<div class="llmr-filter-group">
<label class="llmr-filter-label">Source</label>
<label class="llmr-filter-label">${t('llmr.filter.source')}</label>
<input class="form-control form-control-sm" type="text"
placeholder="e.g. web, tic, cron"
placeholder=${t('llmr.filter.source_ph')}
.value=${this._source}
@input=${e => this._source = e.target.value}
@keydown=${e => e.key === 'Enter' && this._apply()} />
</div>
<div class="llmr-filter-group">
<label class="llmr-filter-label">From</label>
<label class="llmr-filter-label">${t('llmr.filter.from')}</label>
<input class="form-control form-control-sm" type="date"
.value=${this._from}
@change=${e => this._from = e.target.value} />
</div>
<div class="llmr-filter-group">
<label class="llmr-filter-label">To</label>
<label class="llmr-filter-label">${t('llmr.filter.to')}</label>
<input class="form-control form-control-sm" type="date"
.value=${this._to}
@change=${e => this._to = e.target.value} />
@@ -168,11 +176,11 @@ export class LlmRequestsPage extends LightElement {
<div class="llmr-filter-actions">
<button class="btn btn-sm btn-primary" @click=${() => this._apply()}
?disabled=${this._loading}>
Apply
${t('llmr.filter.apply')}
</button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._reset()}
?disabled=${this._loading}>
Reset
${t('llmr.filter.reset')}
</button>
</div>
</div>
@@ -183,7 +191,7 @@ export class LlmRequestsPage extends LightElement {
if (this._loading) return html`
<div class="llmr-state">
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
<span>Loading…</span>
<span>${t('llmr.loading')}</span>
</div>
`;
if (this._error) return html`
@@ -195,7 +203,7 @@ export class LlmRequestsPage extends LightElement {
if (this._items.length === 0) return html`
<div class="llmr-state">
<i class="bi bi-inbox"></i>
<span>No requests found.</span>
<span>${t('llmr.empty')}</span>
</div>
`;
@@ -204,14 +212,14 @@ export class LlmRequestsPage extends LightElement {
<table class="table table-sm llmr-table">
<thead>
<tr>
<th>Agent</th>
<th>Source</th>
<th>Model</th>
<th>Date</th>
<th class="text-end">In tokens</th>
<th class="text-end">Out tokens</th>
<th class="text-end">Cache hit</th>
<th class="text-end">ms</th>
<th>${t('llmr.table.agent')}</th>
<th>${t('llmr.table.source')}</th>
<th>${t('llmr.table.model')}</th>
<th>${t('llmr.table.date')}</th>
<th class="text-end">${t('llmr.table.in_tokens')}</th>
<th class="text-end">${t('llmr.table.out_tokens')}</th>
<th class="text-end">${t('llmr.table.cache_hit')}</th>
<th class="text-end">${t('llmr.table.ms')}</th>
</tr>
</thead>
<tbody>
@@ -254,7 +262,7 @@ export class LlmRequestsPage extends LightElement {
@click=${() => this._fetch(cur - 1)}>
<i class="bi bi-chevron-left"></i>
</button>
<span class="llmr-page-info">Page ${cur} of ${pages} &mdash; ${this._total} results</span>
<span class="llmr-page-info">${t('llmr.pagination', { cur, pages, total: this._total })}</span>
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur >= pages}
@click=${() => this._fetch(cur + 1)}>
<i class="bi bi-chevron-right"></i>
@@ -276,8 +284,8 @@ export class LlmRequestsPage extends LightElement {
return html`
<div class="llmr-page">
<div class="llmr-header">
<h2 class="llmr-title"><i class="bi bi-journal-code"></i> LLM Requests</h2>
<span class="llmr-total-badge">${this._total} rows</span>
<h2 class="llmr-title"><i class="bi bi-journal-code"></i> ${t('llmr.title')}</h2>
<span class="llmr-total-badge">${t('llmr.total', { n: this._total })}</span>
</div>
${this._renderFilters()}
${this._renderTable()}
+11 -10
View File
@@ -1,7 +1,8 @@
import { html } from 'lit';
import { LightElement } from '../lib/base.js';
import { t, I18nMixin } from '../lib/i18n.js';
export class LoginPage extends LightElement {
export class LoginPage extends I18nMixin(LightElement) {
static get properties() {
return {
@@ -27,7 +28,7 @@ export class LoginPage extends LightElement {
this._error = null;
if (!this._username.trim() || !this._password) {
this._error = 'Enter your username and password.';
this._error = t('login.missing');
return;
}
@@ -46,13 +47,13 @@ export class LoginPage extends LightElement {
}),
});
if (!res.ok) {
this._error = 'Invalid username or password.';
this._error = t('login.error');
return;
}
// Logged in — reload into the app.
window.location.reload();
} catch {
this._error = 'Network error — please try again.';
this._error = t('login.network');
} finally {
this._busy = false;
}
@@ -60,8 +61,8 @@ export class LoginPage extends LightElement {
render() {
const btnLabel = this._busy
? html`<span class="login-spinner"></span>Signing in…`
: 'Sign in';
? html`<span class="login-spinner"></span>${t('login.signing')}`
: t('login.submit');
return html`
<div class="login-page">
@@ -69,13 +70,13 @@ export class LoginPage extends LightElement {
<div class="login-logo">
<img src="/assets/icons/icon-192.png" alt="Skald" />
</div>
<h1 class="login-title">Welcome back</h1>
<p class="login-subtitle">Sign in to your account.</p>
<h1 class="login-title">${t('login.title')}</h1>
<p class="login-subtitle">${t('login.subtitle')}</p>
${this._error ? html`<div class="login-error">${this._error}</div>` : null}
<div class="mb-3">
<label class="form-label">Username</label>
<label class="form-label">${t('login.username')}</label>
<input
type="text"
class="form-control"
@@ -86,7 +87,7 @@ export class LoginPage extends LightElement {
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<label class="form-label">${t('login.password')}</label>
<input
type="password"
class="form-control"
+36 -32
View File
@@ -1,5 +1,7 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
// Connector marketplace — blueprint §14/§15.
//
@@ -57,6 +59,8 @@ export class MarketplacePage extends LightElement {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'marketplace';
this.style.display = this._open ? 'flex' : 'none';
@@ -64,6 +68,11 @@ export class MarketplacePage extends LightElement {
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
get _isAdmin() { return this._me?.role_id === ADMIN_ID; }
async _load() {
@@ -91,9 +100,9 @@ export class MarketplacePage extends LightElement {
async _install(card) {
const warn = card.source === 'local_script'
? `\n\nThis puts code on this box:\n${card.file_count} file(s), each verified against its SHA-256\n • installed into ./connectors/${card.id}/`
? '\n\n' + t('marketplace.confirm.install_warn', { n: card.file_count, id: card.id })
: '';
if (!confirm(`Install "${card.name}" into the catalog?${warn}\n\nInstalling does not activate it.`)) return;
if (!confirm(t('marketplace.confirm.install_body', { name: card.name }) + warn)) return;
this._installing = card.id;
this._error = null;
try {
@@ -137,13 +146,13 @@ export class MarketplacePage extends LightElement {
return html`
<div class="um-page">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-shop me-2"></i>Marketplace</h2>
<h2 class="um-title"><i class="bi bi-shop me-2"></i>${t('marketplace.title')}</h2>
<div class="um-header-right">
<button class="btn btn-sm btn-outline-primary" @click=${() => this._goCatalog()}>
<i class="bi bi-arrow-left me-1"></i>Catalog
<i class="bi bi-arrow-left me-1"></i>${t('marketplace.btn.catalog')}
</button>
${this._isAdmin ? html`
<button class="um-btn-icon ms-1" title="Refetch the feed"
<button class="um-btn-icon ms-1" title=${t('marketplace.action.refetch')}
@click=${() => this._loadFeed(true)}><i class="bi bi-arrow-clockwise"></i></button>
` : nothing}
</div>
@@ -156,28 +165,23 @@ export class MarketplacePage extends LightElement {
${this._me && !this._isAdmin ? html`
<div class="um-empty" style="padding:2rem">
<i class="bi bi-shield-lock"></i>
<p>The marketplace is managed by the admin.</p>
<p>${t('marketplace.not_admin')}</p>
<p style="font-size:.8rem;opacity:.7">
Connectors the admin has installed appear on the
<a href="#connectors" @click=${(e) => { e.preventDefault();
history.pushState({ page: 'connectors' }, '', '#connectors');
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'connectors' } })); }}>Connectors</a> page.
</p>
${unsafeHTML(t('marketplace.not_admin_link'))}</p>
</div>
` : html`
<div class="text-muted mt-3 mb-3" style="font-size:.8rem">
Vetted connectors you can add to this box's catalog. Installing does not
activate anything — it makes a connector <em>available</em>.
${unsafeHTML(t('marketplace.desc'))}
</div>
${this._feedErr ? html`
<div class="alert alert-warning py-2" style="font-size:.82rem">
<i class="bi bi-wifi-off me-1"></i>Marketplace unreachable${this._feedErr}
<i class="bi bi-wifi-off me-1"></i>${t('marketplace.feed_unreachable', { error: this._feedErr })}
</div>` : nothing}
${this._renderFilters()}
${loading ? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-hourglass-split"></i><p>Loading feed…</p></div>`
${loading ? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-hourglass-split"></i><p>${t('marketplace.loading')}</p></div>`
: this._renderGrid()}
`}
</div>
@@ -202,13 +206,13 @@ export class MarketplacePage extends LightElement {
<div class="connector-filters">
<div class="connector-search">
<i class="bi bi-search"></i>
<input class="form-control form-control-sm" placeholder="Search connectors…"
<input class="form-control form-control-sm" placeholder=${t('marketplace.filter.search')}
.value=${this._q} @input=${(e) => { this._q = e.target.value; }} />
</div>
${this._segment('Scope', this._scope, (v) => { this._scope = v; },
[['All', 'all'], ['Global', 'global'], ['Per-user', 'per_user']])}
${this._segment('Type', this._source, (v) => { this._source = v; },
[['All', 'all'], ['Remote', 'remote'], ['Local', 'local_script']])}
${this._segment(t('marketplace.filter.scope'), this._scope, (v) => { this._scope = v; },
[[t('marketplace.filter.all'), 'all'], [t('marketplace.filter.global'), 'global'], [t('marketplace.filter.per_user'), 'per_user']])}
${this._segment(t('marketplace.filter.type'), this._source, (v) => { this._source = v; },
[[t('marketplace.filter.all'), 'all'], [t('marketplace.filter.remote'), 'remote'], [t('marketplace.filter.local'), 'local_script']])}
</div>`;
}
@@ -218,7 +222,7 @@ export class MarketplacePage extends LightElement {
if (cards.length === 0) {
return html`
<div class="um-empty" style="padding:1rem"><i class="bi bi-search"></i>
<p>${total === 0 ? 'The feed is empty.' : 'No connector matches these filters.'}</p></div>`;
<p>${total === 0 ? t('marketplace.grid.empty_feed') : t('marketplace.grid.no_match')}</p></div>`;
}
return html`
<div class="connector-grid">
@@ -243,7 +247,7 @@ export class MarketplacePage extends LightElement {
<div class="connector-card-name">${c.name}</div>
<div class="connector-card-sub">${c.id}${c.version ? ` · v${c.version}` : ''}</div>
</div>
${c.installed ? html`<span class="connector-chip connector-chip--ok">installed</span>` : nothing}
${c.installed ? html`<span class="connector-chip connector-chip--ok">${t('marketplace.card.installed')}</span>` : nothing}
</div>
${c.user_description ? html`<div class="connector-card-desc">${c.user_description}</div>` : nothing}
@@ -251,11 +255,11 @@ export class MarketplacePage extends LightElement {
<div class="connector-chips">
<span class="connector-chip connector-chip--scope">
<i class="bi ${c.scope === 'global' ? 'bi-globe' : 'bi-person'}"></i>
${c.scope === 'global' ? 'global' : 'per-user'}
${c.scope === 'global' ? t('marketplace.card.scope_global') : t('marketplace.card.scope_per_user')}
</span>
<span class="connector-chip ${isScript ? 'connector-chip--script' : ''}">
<i class="bi ${isScript ? 'bi-file-earmark-code' : 'bi-cloud'}"></i>
${isScript ? 'local script' : 'remote'}
${isScript ? t('marketplace.card.type_script') : t('marketplace.card.type_remote')}
</span>
${c.auth_kind !== 'none' ? html`
<span class="connector-chip"><i class="bi bi-key"></i>${c.auth_kind}</span>` : nothing}
@@ -264,24 +268,24 @@ export class MarketplacePage extends LightElement {
${isScript ? html`
<div class="connector-card-note">
<i class="bi bi-shield-check"></i>${c.file_count} file${c.file_count === 1 ? '' : 's'}, SHA-256 verified on install
<i class="bi bi-shield-check"></i>${t(c.file_count === 1 ? 'marketplace.card.files_one' : 'marketplace.card.files_other', { n: c.file_count })}
</div>` : nothing}
${c.oauth_scopes?.length ? html`
<details class="connector-card-scopes">
<summary>Requests ${c.oauth_scopes.length} OAuth scope${c.oauth_scopes.length === 1 ? '' : 's'}</summary>
<summary>${t(c.oauth_scopes.length === 1 ? 'marketplace.card.oauth_scopes_one' : 'marketplace.card.oauth_scopes_other', { n: c.oauth_scopes.length })}</summary>
${c.oauth_scopes.map((s) => html`<code>${s}</code>`)}
</details>` : nothing}
<div class="connector-card-actions">
<button class="btn btn-sm ${c.installed ? 'btn-outline-primary' : 'btn-primary'}"
?disabled=${busy} @click=${() => this._install(c)}>
${busy ? html`<i class="bi bi-hourglass-split me-1"></i>Installing`
: c.installed ? html`<i class="bi bi-arrow-repeat me-1"></i>Reinstall`
: html`<i class="bi bi-download me-1"></i>Install`}
${busy ? html`<i class="bi bi-hourglass-split me-1"></i>${t('marketplace.card.installing')}`
: c.installed ? html`<i class="bi bi-arrow-repeat me-1"></i>${t('marketplace.card.reinstall')}`
: html`<i class="bi bi-download me-1"></i>${t('marketplace.card.install')}`}
</button>
${c.homepage ? html`
<a class="btn btn-sm btn-outline-primary"
href=${c.homepage} target="_blank" rel="noopener noreferrer" title="Homepage">
href=${c.homepage} target="_blank" rel="noopener noreferrer" title=${t('marketplace.card.homepage')}>
<i class="bi bi-box-arrow-up-right"></i></a>` : nothing}
</div>
</div>`;
+7 -6
View File
@@ -1,4 +1,5 @@
import { LitElement, html, nothing } from 'lit';
import { t } from '../lib/i18n.js';
import './shared/inbox-page.js';
import './shared/chat-page.js';
import './shared/projects-page.js';
@@ -199,18 +200,18 @@ class MobileApp extends LitElement {
${['notifications', 'settings'].includes(s) ? html`
<div class="mobile-coming-soon">
<i class="bi bi-tools"></i>
<p>Coming soon</p>
<p>${t('mobile.coming_soon')}</p>
</div>
` : ''}
</div>
${this._native ? nothing : html`
<nav class="mobile-nav">
${item('inbox', 'bi-inbox', 'Inbox')}
${item('projects', 'bi-folder2-open', 'Projects')}
${item('chat', '', 'Chat', 'chat-btn')}
${item('notifications', 'bi-bell', 'Alerts')}
${item('settings', 'bi-sliders', 'Settings')}
${item('inbox', 'bi-inbox', t('mobile.nav.inbox'))}
${item('projects', 'bi-folder2-open', t('mobile.nav.projects'))}
${item('chat', '', t('mobile.nav.chat'), 'chat-btn')}
${item('notifications', 'bi-bell', t('mobile.nav.alerts'))}
${item('settings', 'bi-sliders', t('mobile.nav.settings'))}
</nav>
`}
</div>
+29 -21
View File
@@ -1,30 +1,31 @@
import { html } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const CARDS = [
{
id: 'llm',
icon: 'bi-cpu',
title: 'LLM',
desc: 'Chat & completion models for agents and tools',
id: 'llm',
icon: 'bi-cpu',
titleKey: 'models.hub.card.llm.title',
descKey: 'models.hub.card.llm.desc',
},
{
id: 'transcribe',
icon: 'bi-mic',
title: 'Transcription',
desc: 'Speech-to-text models via cloud or local plugin',
id: 'transcribe',
icon: 'bi-mic',
titleKey: 'models.hub.card.transcribe.title',
descKey: 'models.hub.card.transcribe.desc',
},
{
id: 'image',
icon: 'bi-image',
title: 'Image Generation',
desc: 'Text-to-image models via cloud API',
id: 'image',
icon: 'bi-image',
titleKey: 'models.hub.card.image.title',
descKey: 'models.hub.card.image.desc',
},
{
id: 'tts',
icon: 'bi-volume-up',
title: 'Text-to-Speech',
desc: 'Speech synthesis models via cloud or local plugin',
id: 'tts',
icon: 'bi-volume-up',
titleKey: 'models.hub.card.tts.title',
descKey: 'models.hub.card.tts.desc',
},
];
@@ -44,6 +45,8 @@ export class ModelsHubPage extends LightElement {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
const open = e.detail.page === 'models';
this.style.display = open ? 'flex' : 'none';
@@ -54,6 +57,11 @@ export class ModelsHubPage extends LightElement {
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
_sectionFromHash() {
const parts = location.hash.slice(1).split('/');
if (parts[0] === 'models' && parts[1]) {
@@ -103,7 +111,7 @@ export class ModelsHubPage extends LightElement {
_countLabel(id) {
const n = this._counts[id] ?? 0;
return n === 0 ? 'No models' : n === 1 ? '1 model' : `${n} models`;
return n === 0 ? t('models.hub.count.none') : n === 1 ? t('models.hub.count.one') : t('models.hub.count.many', { n });
}
render() {
@@ -118,9 +126,9 @@ export class ModelsHubPage extends LightElement {
return html`
<div class="models-hub">
<h2 class="llm-page-title">Models</h2>
<h2 class="llm-page-title">${t('models.hub.title')}</h2>
<p class="text-muted" style="font-size:0.88rem;margin-top:0.25rem">
Configure LLM, transcription, and image generation providers.
${t('models.hub.subtitle')}
</p>
<div class="models-hub-grid">
${CARDS.map(card => html`
@@ -128,8 +136,8 @@ export class ModelsHubPage extends LightElement {
<div class="models-type-card-icon">
<i class="bi ${card.icon}"></i>
</div>
<div class="models-type-card-title">${card.title}</div>
<div class="models-type-card-desc">${card.desc}</div>
<div class="models-type-card-title">${t(card.titleKey)}</div>
<div class="models-type-card-desc">${t(card.descKey)}</div>
<div class="models-type-card-count ${this._counts[card.id] > 0 ? 'has-models' : ''}">
${this._loading ? '…' : this._countLabel(card.id)}
</div>
+37 -29
View File
@@ -1,5 +1,7 @@
import { html } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
function emptyIgForm() {
return { provider_id: '', model_id: '', name: '', priority: 100 };
@@ -31,9 +33,16 @@ export class ModelsImageSection extends LightElement {
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([
@@ -88,7 +97,7 @@ export class ModelsImageSection extends LightElement {
// ── Delete ───────────────────────────────────────────────────────────────────
async _delete(m) {
if (!confirm(`Delete image model "${m.name}"?`)) return;
if (!confirm(t('models.confirm_delete', { type: t('models.hub.card.image.title'), name: m.name }))) return;
try {
const res = await fetch(`/api/image-generate/models/${m.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
@@ -167,19 +176,19 @@ export class ModelsImageSection extends LightElement {
<div class="llm-card">
<div class="llm-card-row1">
${isPlugin
? html`<span class="ig-source-badge ig-source-plugin">Plugin</span>`
: html`<span class="ig-source-badge ig-source-cloud">Cloud</span>`}
? 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="Managed by plugin" style="cursor:default;opacity:0.4">
<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="Edit" @click=${() => this._openEdit(m)}>
<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="Delete" @click=${() => this._delete(m)}>
<button class="llm-btn-icon llm-btn-delete" title=${t('models.delete')} @click=${() => this._delete(m)}>
<i class="bi bi-trash"></i>
</button>
`}
@@ -189,7 +198,7 @@ export class ModelsImageSection extends LightElement {
<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>
<span class="ig-priority-tag" title="Priority">#${m.priority}</span>
<span class="ig-priority-tag" title=${t('models.priority')}>#${m.priority}</span>
</div>
${m.description ? html`
@@ -208,7 +217,7 @@ export class ModelsImageSection extends LightElement {
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 Image Model — Choose Provider</div>
<div class="llm-modal-title">${t('models.add_model_provider', { type: t('models.hub.card.image.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">
${igProviders.map(p => html`
@@ -219,7 +228,7 @@ export class ModelsImageSection extends LightElement {
`)}
</div>
<div class="agent-dialog-actions mt-3">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
</div>
</div>
</div>
@@ -232,8 +241,8 @@ export class ModelsImageSection extends LightElement {
const f = this._form;
const p = this._provider;
const title = isEdit
? html`Edit <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.name}</span>`
: html`Add Image Model <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>`;
? 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.image.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(); }}>
@@ -244,35 +253,35 @@ export class ModelsImageSection extends LightElement {
<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>
${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="e.g. x-ai/grok-2-vision"
placeholder=${t('models.ph.model_id_image')}
?disabled=${isEdit}
@input=${(e) => this._form = { ...this._form, model_id: e.target.value }} />
${isEdit ? html`<div class="form-text">Model ID cannot be changed after creation.</div>` : ''}
${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">
Name / Alias <span class="text-muted fw-normal">(used as provider_id in the LLM tool)</span>
${unsafeHTML(t('models.form.name_as_provider'))}
</label>
<input type="text" class="form-control form-control-sm" .value=${f.name}
placeholder=${f.model_id || 'same as model ID'}
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">Priority</label>
<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">Lower number = tried first. Default: 100.</div>
<div class="form-text">${t('models.form.priority_img')}</div>
</div>
<div class="agent-dialog-actions">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
<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 ? 'Saving' : isEdit ? 'Save changes' : 'Add model'}
${this._saving ? t('models.saving') : isEdit ? t('models.save_changes') : t('models.add_model')}
</button>
</div>
</form>
@@ -292,17 +301,17 @@ export class ModelsImageSection extends LightElement {
<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}>
<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">Image Generation Models</h2>
<span class="llm-page-count">${this._models.length} model${this._models.length !== 1 ? 's' : ''}</span>
<h2 class="llm-page-title">${t('models.image.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>Add
<i class="bi bi-plus-lg me-1"></i>${t('models.add')}
</button>
</div>
@@ -310,7 +319,7 @@ export class ModelsImageSection extends LightElement {
<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">No provider supports image generation yet. Add an <strong>OpenRouter</strong> provider first.</p>
<p class="mb-0">${t('models.no_providers_image')}</p>
</div>
</div>
` : ''}
@@ -319,8 +328,7 @@ export class ModelsImageSection extends LightElement {
<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">Models with the <strong>Plugin</strong> badge are read-only — managed automatically by the plugin that registered them.
To add, modify, or remove them, ask the agent directly: it has all the documentation it needs.</p>
<p class="mb-0">${t('models.readonly_plugin_full')}</p>
</div>
</div>
` : ''}
@@ -333,10 +341,10 @@ export class ModelsImageSection extends LightElement {
${this._models.length === 0 ? html`
<div class="llm-empty-state">
<i class="bi bi-image"></i>
<p>No image generation models configured.</p>
<p>${t('models.list_empty_image')}</p>
${canAdd ? html`
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
<i class="bi bi-plus-lg me-1"></i>Add your first model
<i class="bi bi-plus-lg me-1"></i>${t('models.add_first')}
</button>
` : ''}
</div>
+71 -61
View File
@@ -1,5 +1,7 @@
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',
@@ -77,9 +79,16 @@ export class ModelsLlmSection extends LightElement {
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([
@@ -144,7 +153,7 @@ export class ModelsLlmSection extends LightElement {
));
await this._load();
} catch (e) {
this._error = `Failed to save order: ${e.message}`;
this._error = t('models.error.save_order', { msg: e.message });
await this._load();
}
}
@@ -183,7 +192,7 @@ export class ModelsLlmSection extends LightElement {
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}`;
this._error = t('models.error.load_models', { msg: e.message });
} finally {
this._orLoading = false;
}
@@ -226,7 +235,7 @@ export class ModelsLlmSection extends LightElement {
// ── Delete ───────────────────────────────────────────────────────────────────
async _delete(model) {
if (!confirm(`Delete model "${model.name}"?`)) return;
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());
@@ -248,7 +257,7 @@ export class ModelsLlmSection extends LightElement {
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; }
catch { this._error = t('models.error.invalid_json'); this._saving = false; return; }
}
try {
@@ -282,7 +291,7 @@ export class ModelsLlmSection extends LightElement {
async _submitCatalog(e) {
e.preventDefault();
if (this._saving) return;
if (!this._orForm.model_id) { this._error = 'Select a model'; return; }
if (!this._orForm.model_id) { this._error = t('models.error.select_model'); return; }
this._saving = true;
this._error = null;
@@ -334,7 +343,7 @@ export class ModelsLlmSection extends LightElement {
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; }
catch { this._error = t('models.error.invalid_json'); this._saving = false; return; }
}
try {
@@ -415,26 +424,27 @@ export class ModelsLlmSection extends LightElement {
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">
<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>`;
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=${STRENGTH_LABELS[strength] ?? strength}></span>
title=${label}></span>
`;
}
_renderStatus(status) {
const cfg = {
healthy: { color: '#22c55e', title: 'Healthy' },
degraded: { color: '#eab308', title: 'Degraded' },
down: { color: '#ef4444', title: 'Down' },
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>`;
}
@@ -446,12 +456,12 @@ export class ModelsLlmSection extends LightElement {
<div class="llm-card">
<div class="llm-card-row1">
<div class="llm-move-btns">
<button class="llm-move-btn" title="Move up"
<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="Move down"
<button class="llm-move-btn" title=${t('models.move_down')}
?disabled=${last}
@click=${() => this._moveDown(i)}>
<i class="bi bi-chevron-down"></i>
@@ -460,12 +470,12 @@ export class ModelsLlmSection extends LightElement {
${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>` : ''}
${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="Edit" @click=${() => this._openEdit(m)}>
<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="Delete" @click=${() => this._delete(m)}>
<button class="llm-btn-icon llm-btn-delete" title=${t('models.delete')} @click=${() => this._delete(m)}>
<i class="bi bi-trash"></i>
</button>
</div>
@@ -480,7 +490,7 @@ export class ModelsLlmSection extends LightElement {
${(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>` : ''}
${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>
@@ -496,10 +506,10 @@ export class ModelsLlmSection extends LightElement {
if (mode.type === 'value_set') {
return html`
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Reasoning</label>
<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}>— off —</option>
<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>
`)}
@@ -515,7 +525,7 @@ export class ModelsLlmSection extends LightElement {
.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)
${t('models.reasoning.thinking')}
</label>
</div>
${enabled ? html`
@@ -535,24 +545,24 @@ export class ModelsLlmSection extends LightElement {
${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>
<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="">— none —</option>
<option value="">${t('models.strength.none')}</option>
${STRENGTH_OPTIONS.map(s => html`
<option value=${s} ?selected=${form.strength === s}>${STRENGTH_LABELS[s]}</option>
<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">Priority</label>
<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">Scope</label>
<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">
@@ -568,7 +578,7 @@ export class ModelsLlmSection extends LightElement {
<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>
<label class="form-check-label" for="m-is-default" style="font-size:0.82rem">${t('models.default_model')}</label>
</div>
</div>
`;
@@ -580,7 +590,7 @@ export class ModelsLlmSection extends LightElement {
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>
<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`
@@ -591,7 +601,7 @@ export class ModelsLlmSection extends LightElement {
`)}
</div>
<div class="agent-dialog-actions mt-3">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
</div>
</div>
</div>
@@ -607,27 +617,27 @@ export class ModelsLlmSection extends LightElement {
<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
${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">Model ID <span class="text-muted fw-normal">(sent to API)</span></label>
<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="e.g. gpt-4o"
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">Name / Alias <span class="text-muted fw-normal">(optional)</span></label>
<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 || 'same as model ID'}
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">
Extra params <span class="text-muted fw-normal">(JSON, optional)</span>
${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}
@@ -636,9 +646,9 @@ export class ModelsLlmSection extends LightElement {
</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="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 ? 'Saving' : 'Add model'}
${this._saving ? t('models.saving') : t('models.add_model')}
</button>
</div>
</form>
@@ -671,25 +681,25 @@ export class ModelsLlmSection extends LightElement {
<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
${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">Model</label>
<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="Search models…"
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">Loading models…</div>`
? 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">No models found</div>`
? 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)}>
@@ -712,17 +722,17 @@ export class ModelsLlmSection extends LightElement {
</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>
<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 || 'same as model ID'}
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">Max output tokens <span class="text-muted fw-normal">(optional)</span></label>
<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 ? `up to ${selected.max_completion_tokens.toLocaleString()}` : ''}
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>
@@ -731,9 +741,9 @@ export class ModelsLlmSection extends LightElement {
${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="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 ? 'Saving' : 'Add model'}
${this._saving ? t('models.saving') : t('models.add_model')}
</button>
</div>
</form>
@@ -751,26 +761,26 @@ export class ModelsLlmSection extends LightElement {
<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
${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">
Model ID and provider cannot be changed. To use a different model, add a new entry.
${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">Name / Alias</label>
<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">Used to reference this model (e.g. in an agent's <code>client</code>). Must be unique.</div>
<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()}>Cancel</button>
<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 ? 'Saving' : 'Save changes'}
${this._saving ? t('models.saving') : t('models.save_changes')}
</button>
</div>
</form>
@@ -785,18 +795,18 @@ export class ModelsLlmSection extends LightElement {
<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}>
<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">LLM Models</h2>
<span class="llm-page-count">${this._models.length} model${this._models.length !== 1 ? 's' : ''}</span>
<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>Add
<i class="bi bi-plus-lg me-1"></i>${t('models.add')}
</button>
</div>
@@ -804,7 +814,7 @@ export class ModelsLlmSection extends LightElement {
<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>
<p class="mb-0">${t('models.no_providers_llm')}</p>
</div>
</div>
` : ''}
@@ -817,9 +827,9 @@ export class ModelsLlmSection extends LightElement {
${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>
<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>Add your first model
<i class="bi bi-plus-lg me-1"></i>${t('models.add_first')}
</button>
</div>
` : this._models.map((m, i) => this._renderCard(m, i))}
+45 -37
View File
@@ -1,5 +1,6 @@
import { html } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
function emptyTForm() {
return { provider_id: '', model_id: '', name: '', language: '', priority: 100 };
@@ -35,9 +36,16 @@ export class ModelsTranscribeSection extends LightElement {
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([
@@ -115,7 +123,7 @@ export class ModelsTranscribeSection extends LightElement {
// ── Delete ───────────────────────────────────────────────────────────────────
async _delete(m) {
if (!confirm(`Delete transcription model "${m.name}"?`)) return;
if (!confirm(t('models.confirm_delete', { type: t('models.hub.card.transcribe.title'), name: m.name }))) return;
try {
const res = await fetch(`/api/transcribe/models/${m.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
@@ -196,23 +204,23 @@ export class ModelsTranscribeSection extends LightElement {
<tr class="llm-row">
<td>
${isPlugin
? html`<span class="badge" style="background:#7c3aed;font-size:0.65rem;font-weight:500">Plugin</span>`
: html`<span class="badge bg-secondary" style="font-size:0.65rem;font-weight:500">Cloud</span>`}
? html`<span class="badge" style="background:#7c3aed;font-size:0.65rem;font-weight:500">${t('models.source_plugin')}</span>`
: html`<span class="badge bg-secondary" style="font-size:0.65rem;font-weight:500">${t('models.source_cloud')}</span>`}
</td>
<td><span class="fw-semibold">${m.name}</span></td>
<td class="text-muted" style="font-size:0.8rem">${isPlugin ? '—' : m.provider_name}</td>
<td class="llm-model" title=${m.model_id}>${m.model_id}</td>
<td style="font-size:0.8rem">${m.language ?? html`<span style="opacity:0.35">auto</span>`}</td>
<td style="font-size:0.8rem">${m.language ?? html`<span style="opacity:0.35">${t('models.language_auto')}</span>`}</td>
<td class="llm-actions">
${isPlugin ? html`
<span class="text-muted" style="font-size:0.75rem" title="Managed by plugin">
<span class="text-muted" style="font-size:0.75rem" title=${t('models.managed_plugin')}>
<i class="bi bi-lock"></i>
</span>
` : html`
<button class="btn btn-sm btn-link" title="Edit" @click=${() => this._openEdit(m)}>
<button class="btn btn-sm btn-link" title=${t('models.edit')} @click=${() => this._openEdit(m)}>
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-link text-danger" title="Delete" @click=${() => this._delete(m)}>
<button class="btn btn-sm btn-link text-danger" title=${t('models.delete')} @click=${() => this._delete(m)}>
<i class="bi bi-trash"></i>
</button>
`}
@@ -228,7 +236,7 @@ export class ModelsTranscribeSection extends LightElement {
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 Transcription Model — Choose Provider</div>
<div class="llm-modal-title">${t('models.add_model_provider', { type: t('models.hub.card.transcribe.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">
${tProviders.map(p => html`
@@ -239,7 +247,7 @@ export class ModelsTranscribeSection extends LightElement {
`)}
</div>
<div class="agent-dialog-actions mt-3">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
</div>
</div>
</div>
@@ -252,12 +260,12 @@ export class ModelsTranscribeSection extends LightElement {
<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 Transcription Model
${t('models.add_model_type', { type: t('models.hub.card.transcribe.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>Loading models…
<div class="spinner-border spinner-border-sm me-2"></div>${t('models.loading')}
</div>
` : html`
<div class="tts-model-pick-list">
@@ -272,9 +280,9 @@ export class ModelsTranscribeSection extends LightElement {
`)}
</div>
<div class="agent-dialog-actions mt-3">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
<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'; }}>
Enter model ID manually
${t('models.enter_id')}
</button>
</div>
`}
@@ -287,8 +295,8 @@ export class ModelsTranscribeSection extends LightElement {
const f = this._form;
const p = this._provider;
const title = isEdit
? html`Edit <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.name}</span>`
: html`Add Transcription Model <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>`;
? 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.transcribe.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(); }}>
@@ -299,44 +307,44 @@ export class ModelsTranscribeSection extends LightElement {
<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>
${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="e.g. openai/whisper-1"
placeholder=${t('models.ph.model_id_transcribe')}
?disabled=${isEdit}
@input=${(e) => this._form = { ...this._form, model_id: e.target.value }} />
${isEdit ? html`<div class="form-text">Model ID cannot be changed after creation.</div>` : ''}
${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">
Name / Alias <span class="text-muted fw-normal">(optional)</span>
${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 || 'same as model ID'}
placeholder=${f.model_id || t('models.ph.name_alias')}
@input=${(e) => this._form = { ...this._form, name: e.target.value }} />
</div>
<div class="row g-3 mb-3">
<div class="col-8">
<label class="form-label fw-semibold" style="font-size:0.82rem">
Language <span class="text-muted fw-normal">(BCP-47, optional)</span>
${t('models.language_col')} <span class="text-muted fw-normal">${t('models.label.bcp47')}</span>
</label>
<input type="text" class="form-control form-control-sm" .value=${f.language}
placeholder="e.g. it, en — leave blank for auto-detect"
placeholder=${t('models.ph.language')}
@input=${(e) => this._form = { ...this._form, language: e.target.value }} />
</div>
<div class="col-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Priority</label>
<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>
</div>
<div class="agent-dialog-actions">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
<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 ? 'Saving' : isEdit ? 'Save changes' : 'Add model'}
${this._saving ? t('models.saving') : isEdit ? t('models.save_changes') : t('models.add_model')}
</button>
</div>
</form>
@@ -356,14 +364,14 @@ export class ModelsTranscribeSection extends LightElement {
<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}>
<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>
` : ''}
<h2 class="llm-page-title">Transcription Models</h2>
<h2 class="llm-page-title">${t('models.transcribe.title')}</h2>
</div>
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()} ?disabled=${!canAdd}>
<i class="bi bi-plus-lg me-1"></i>Add
<i class="bi bi-plus-lg me-1"></i>${t('models.add')}
</button>
</div>
@@ -371,7 +379,7 @@ export class ModelsTranscribeSection extends LightElement {
<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">No provider supports transcription yet. Add an <strong>OpenAI</strong> or <strong>OpenRouter</strong> provider first.</p>
<p class="mb-0">${t('models.no_providers_transcribe')}</p>
</div>
</div>
` : ''}
@@ -382,20 +390,20 @@ export class ModelsTranscribeSection extends LightElement {
${this._models.length === 0 ? html`
<p class="text-muted" style="font-size:0.9rem">
No transcription models configured.
${canAdd ? html`Click <strong>Add</strong> to add a cloud model.` : ''}
Activate the <strong>Whisper Local</strong> plugin for on-device transcription.
${t('models.list_empty_transcribe')}
${canAdd ? html` ${t('models.list_empty_add_hint')}` : ''}
${t('models.list_empty_whisper')}
</p>
` : html`
<div class="table-responsive">
<table class="table llm-table mb-0">
<thead>
<tr>
<th style="width:5rem">Source</th>
<th>Name</th>
<th>Provider</th>
<th>Model ID</th>
<th>Language</th>
<th style="width:5rem">${t('models.source')}</th>
<th>${t('models.name_col')}</th>
<th>${t('models.provider_col')}</th>
<th>${t('models.model_id_col')}</th>
<th>${t('models.language_col')}</th>
<th></th>
</tr>
</thead>
+55 -52
View File
@@ -1,5 +1,7 @@
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'];
@@ -38,9 +40,16 @@ export class ModelsTtsSection extends LightElement {
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([
@@ -123,7 +132,7 @@ export class ModelsTtsSection extends LightElement {
// ── Delete ────────────────────────────────────────────────────────────────────
async _delete(m) {
if (!confirm(`Delete TTS model "${m.name}"?`)) return;
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());
@@ -202,19 +211,19 @@ export class ModelsTtsSection extends LightElement {
<div class="llm-card">
<div class="llm-card-row1">
${isPlugin
? html`<span class="ig-source-badge ig-source-plugin">Plugin</span>`
: html`<span class="ig-source-badge ig-source-cloud">Cloud</span>`}
? 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="Managed by plugin" style="cursor:default;opacity:0.4">
<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="Edit" @click=${() => this._openEdit(m)}>
<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="Delete" @click=${() => this._delete(m)}>
<button class="llm-btn-icon llm-btn-delete" title=${t('models.delete')} @click=${() => this._delete(m)}>
<i class="bi bi-trash"></i>
</button>
`}
@@ -224,9 +233,9 @@ export class ModelsTtsSection extends LightElement {
<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="Voice ID">${m.voice_id}</span>` : ''}
${m.response_format ? html`<span class="llm-model-id" style="opacity:0.6" title="Response format">${m.response_format}</span>` : ''}
${!isPlugin ? html`<span class="ig-priority-tag" title="Priority">#${m.priority}</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`
@@ -249,7 +258,7 @@ export class ModelsTtsSection extends LightElement {
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 TTS Model — Choose Provider</div>
<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`
@@ -260,7 +269,7 @@ export class ModelsTtsSection extends LightElement {
`)}
</div>
<div class="agent-dialog-actions mt-3">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>${t('models.cancel')}</button>
</div>
</div>
</div>
@@ -275,12 +284,12 @@ export class ModelsTtsSection extends LightElement {
<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 TTS Model
${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>Loading models…
<div class="spinner-border spinner-border-sm me-2"></div>${t('models.loading')}
</div>
` : html`
<div class="tts-model-pick-list">
@@ -289,7 +298,7 @@ export class ModelsTtsSection extends LightElement {
<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="Cost multiplier relative to base rate">×${m.cost_factor.toFixed(1)}</span>
<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>` : ''}
@@ -300,9 +309,9 @@ export class ModelsTtsSection extends LightElement {
`)}
</div>
<div class="agent-dialog-actions mt-3">
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
<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'; }}>
Enter model ID manually
${t('models.enter_id')}
</button>
</div>
`}
@@ -317,8 +326,8 @@ export class ModelsTtsSection extends LightElement {
const f = this._form;
const p = this._provider;
const title = isEdit
? html`Edit <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.name}</span>`
: html`Add TTS Model <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>`;
? 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(); }}>
@@ -329,82 +338,76 @@ export class ModelsTtsSection extends LightElement {
<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>
${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="e.g. tts-1-hd"
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">Model ID cannot be changed after creation.</div>` : ''}
${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">
Voice ID <span class="text-muted fw-normal">(optional — required for ElevenLabs)</span>
${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="e.g. alloy, Kore, 21m00Tcm4TlvDq8ikWAM"
placeholder=${t('models.ph.voice_id')}
@input=${(e) => this._form = { ...this._form, voice_id: e.target.value }} />
<div class="form-text">
Speaker voice. OpenAI: <code>alloy</code>/<code>echo</code>/<code>nova</code>… (default <code>alloy</code> if empty);
Gemini: <code>Kore</code>/<code>Puck</code>/<code>Zephyr</code>…; ElevenLabs: the voice ID.
</div>
<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">Name / Alias</label>
<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 || 'same as model ID'}
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">
Description <span class="text-muted fw-normal">(optional)</span>
${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="e.g. High quality, slow — best for long responses"
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">
Instructions <span class="text-muted fw-normal">(optional — shown to LLM)</span>
${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="e.g. Speak in a calm, neutral tone. Pause slightly between sentences."
placeholder=${t('models.ph.instructions')}
@input=${(e) => this._form = { ...this._form, instructions: e.target.value }}></textarea>
<div class="form-text">Voice/tone guidance injected into the LLM system prompt when this model is active.</div>
<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">
Response format <span class="text-muted fw-normal">(optional)</span>
${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="">Provider default (mp3)</option>
<option value="">${t('models.form.response_default')}</option>
${TTS_RESPONSE_FORMATS.map(fmt => html`
<option value=${fmt}>${fmt}</option>
`)}
</select>
<div class="form-text">
Audio format requested from the provider. Leave empty unless the model requires
a specific one — e.g. Gemini TTS only accepts <code>pcm</code>.
</div>
<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">Priority</label>
<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">Lower number = used first. Default: 100.</div>
<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()}>Cancel</button>
<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 ? 'Saving' : isEdit ? 'Save changes' : 'Add model'}
${this._saving ? t('models.saving') : isEdit ? t('models.save_changes') : t('models.add_model')}
</button>
</div>
</form>
@@ -424,17 +427,17 @@ export class ModelsTtsSection extends LightElement {
<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}>
<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">Text-to-Speech Models</h2>
<span class="llm-page-count">${this._models.length} model${this._models.length !== 1 ? 's' : ''}</span>
<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>Add
<i class="bi bi-plus-lg me-1"></i>${t('models.add')}
</button>
</div>
@@ -442,7 +445,7 @@ export class ModelsTtsSection extends LightElement {
<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">No provider supports TTS yet. Add an <strong>OpenAI</strong> provider first.</p>
<p class="mb-0">${t('models.no_providers_tts')}</p>
</div>
</div>
` : ''}
@@ -451,7 +454,7 @@ export class ModelsTtsSection extends LightElement {
<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">Models with the <strong>Plugin</strong> badge are read-only — managed automatically by the plugin that registered them.</p>
<p class="mb-0">${t('models.readonly_plugin')}</p>
</div>
</div>
` : ''}
@@ -464,10 +467,10 @@ export class ModelsTtsSection extends LightElement {
${this._models.length === 0 ? html`
<div class="llm-empty-state">
<i class="bi bi-volume-up"></i>
<p>No TTS models configured.</p>
<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>Add your first model
<i class="bi bi-plus-lg me-1"></i>${t('models.add_first')}
</button>
` : ''}
</div>
+63 -20
View File
@@ -1,7 +1,8 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t, I18nMixin, LOCALES, setLocale, getLocale } from '../lib/i18n.js';
export class ProfilePage extends LightElement {
export class ProfilePage extends I18nMixin(LightElement) {
static get properties() {
return {
@@ -10,6 +11,8 @@ export class ProfilePage extends LightElement {
_displayName: { state: true },
_savingName: { state: true },
_nameMsg: { state: true },
_locale: { state: true },
_localeMsg: { state: true },
_pwCurrent: { state: true },
_pwNew: { state: true },
_pwConfirm: { state: true },
@@ -25,6 +28,8 @@ export class ProfilePage extends LightElement {
this._displayName = '';
this._savingName = false;
this._nameMsg = null;
this._locale = '';
this._localeMsg = null;
this._pwCurrent = '';
this._pwNew = '';
this._pwConfirm = '';
@@ -47,6 +52,7 @@ export class ProfilePage extends LightElement {
if (res.ok) {
this._me = await res.json();
this._displayName = this._me.display_name ?? '';
this._locale = this._me.locale ?? '';
}
} catch { /* ignore */ }
}
@@ -62,7 +68,7 @@ export class ProfilePage extends LightElement {
body: JSON.stringify({ display_name: this._displayName.trim() || null }),
});
if (!res.ok) throw new Error(await res.text());
this._nameMsg = { type: 'ok', text: 'Saved.' };
this._nameMsg = { type: 'ok', text: t('profile.saved') };
await this._load();
} catch (e) {
this._nameMsg = { type: 'err', text: e.message };
@@ -71,15 +77,34 @@ export class ProfilePage extends LightElement {
}
}
// '' → back to the instance default; otherwise a concrete locale id.
async _changeLocale(value) {
this._localeMsg = null;
try {
const res = await fetch('/api/auth/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale: value === '' ? null : value }),
});
if (!res.ok) throw new Error(await res.text());
this._locale = value;
// Apply immediately: the explicit choice, or the instance default when reset.
setLocale(value === '' ? (this._me?.default_locale || 'en') : value);
this._localeMsg = { type: 'ok', text: t('profile.saved') };
} catch (e) {
this._localeMsg = { type: 'err', text: e.message };
}
}
async _savePassword() {
if (this._savingPw) return;
this._pwMsg = null;
if (this._pwNew.length < 4) {
this._pwMsg = { type: 'err', text: 'Password must be at least 4 characters.' };
this._pwMsg = { type: 'err', text: t('profile.pw.short') };
return;
}
if (this._pwNew !== this._pwConfirm) {
this._pwMsg = { type: 'err', text: 'Passwords do not match.' };
this._pwMsg = { type: 'err', text: t('profile.pw.mismatch') };
return;
}
this._savingPw = true;
@@ -93,7 +118,7 @@ export class ProfilePage extends LightElement {
}),
});
if (!res.ok) throw new Error(await res.text());
this._pwMsg = { type: 'ok', text: 'Password changed.' };
this._pwMsg = { type: 'ok', text: t('profile.pw.changed') };
this._pwCurrent = '';
this._pwNew = '';
this._pwConfirm = '';
@@ -107,71 +132,89 @@ export class ProfilePage extends LightElement {
render() {
if (!this._open) return nothing;
const me = this._me;
const defaultLabel = LOCALES.find(l => l.id === me?.default_locale)?.label ?? me?.default_locale ?? 'English';
return html`
<div class="um-page" style="display:flex">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-person-circle me-2"></i>Profile</h2>
<h2 class="um-title"><i class="bi bi-person-circle me-2"></i>${t('profile.title')}</h2>
</div>
<div style="padding:0 24px 48px;max-width:480px">
${me ? html`
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:8px">
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:var(--radius-md)">
<div class="card-body">
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">Account</h6>
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">${t('profile.account')}</h6>
<div class="mb-2">
<label class="form-label" style="font-size:.82rem;font-weight:600">Username</label>
<label class="form-label" style="font-size:.82rem;font-weight:600">${t('profile.username')}</label>
<input class="form-control" .value=${me.username} disabled />
</div>
<div class="mb-2">
<label class="form-label" style="font-size:.82rem;font-weight:600">Role</label>
<label class="form-label" style="font-size:.82rem;font-weight:600">${t('profile.role')}</label>
<input class="form-control" .value=${me.role_id} disabled />
</div>
</div>
</div>
` : nothing}
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:8px">
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:var(--radius-md)">
<div class="card-body">
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">Display name</h6>
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">${t('profile.name')}</h6>
<div class="mb-3">
<input class="form-control" placeholder="Your name"
<input class="form-control" placeholder=${t('profile.name.ph')}
.value=${this._displayName}
@input=${e => this._displayName = e.target.value} />
</div>
${this._nameMsg ? html`<div class="alert alert-${this._nameMsg.type === 'ok' ? 'success' : 'danger'} py-2" style="font-size:.82rem">${this._nameMsg.text}</div>` : nothing}
<button class="btn btn-sm btn-primary" @click=${() => this._saveName()} ?disabled=${this._savingName}>
${this._savingName ? 'Saving' : 'Save'}
${this._savingName ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:8px">
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:var(--radius-md)">
<div class="card-body">
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">Change password</h6>
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">${t('profile.language')}</h6>
<div class="mb-3">
<select class="form-select"
.value=${this._locale}
@change=${e => this._changeLocale(e.target.value)}>
<option value="">${t('profile.language.default', { locale: defaultLabel })}</option>
${LOCALES.map(l => html`
<option value=${l.id} ?selected=${this._locale === l.id}>${l.label}</option>
`)}
</select>
</div>
${this._localeMsg ? html`<div class="alert alert-${this._localeMsg.type === 'ok' ? 'success' : 'danger'} py-2" style="font-size:.82rem">${this._localeMsg.text}</div>` : nothing}
</div>
</div>
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:var(--radius-md)">
<div class="card-body">
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">${t('profile.pw')}</h6>
${me?.role_id === 'admin' || me?.encrypted ? html`
<div class="mb-3">
<label class="form-label" style="font-size:.82rem;font-weight:600">Current password</label>
<label class="form-label" style="font-size:.82rem;font-weight:600">${t('profile.pw.current')}</label>
<input type="password" class="form-control" autocomplete="current-password"
.value=${this._pwCurrent}
@input=${e => this._pwCurrent = e.target.value} />
</div>
` : nothing}
<div class="mb-3">
<label class="form-label" style="font-size:.82rem;font-weight:600">New password</label>
<label class="form-label" style="font-size:.82rem;font-weight:600">${t('profile.pw.new')}</label>
<input type="password" class="form-control" autocomplete="new-password"
.value=${this._pwNew}
@input=${e => this._pwNew = e.target.value} />
</div>
<div class="mb-3">
<label class="form-label" style="font-size:.82rem;font-weight:600">Confirm new password</label>
<label class="form-label" style="font-size:.82rem;font-weight:600">${t('profile.pw.confirm')}</label>
<input type="password" class="form-control" autocomplete="new-password"
.value=${this._pwConfirm}
@input=${e => this._pwConfirm = e.target.value} />
</div>
${this._pwMsg ? html`<div class="alert alert-${this._pwMsg.type === 'ok' ? 'success' : 'danger'} py-2" style="font-size:.82rem">${this._pwMsg.text}</div>` : nothing}
<button class="btn btn-sm btn-primary" @click=${() => this._savePassword()} ?disabled=${this._savingPw}>
${this._savingPw ? 'Changing' : 'Change password'}
${this._savingPw ? t('common.saving') : t('profile.pw.submit')}
</button>
</div>
</div>
+33 -25
View File
@@ -1,6 +1,7 @@
import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement, renderMarkdown } from '../../lib/base.js';
import { t } from '../../lib/i18n.js';
import { formatDate } from '../tasks/utils.js';
export class ProjectBoardSection extends LightElement {
@@ -35,7 +36,14 @@ export class ProjectBoardSection extends LightElement {
this._activeTab = 'tickets';
}
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
this._stopPolling();
}
@@ -166,7 +174,7 @@ export class ProjectBoardSection extends LightElement {
}
async _deleteTicket(ticket) {
if (!confirm(`Delete ticket "${ticket.title}"?`)) return;
if (!confirm(t('project_board.confirm.delete', { title: ticket.title }))) return;
try {
const res = await fetch(
`/api/projects/${ticket.project_id}/tickets/${ticket.id}`,
@@ -272,7 +280,7 @@ export class ProjectBoardSection extends LightElement {
${ticket.status === 'todo' ? html`
<button class="btn btn-sm btn-outline-primary ticket-card-btn"
@click=${() => this._startTicket(ticket)}>
<i class="bi bi-play-fill me-1"></i>Start
<i class="bi bi-play-fill me-1"></i>${t('project_board.ticket.start')}
</button>
<button class="btn btn-sm btn-outline-danger ticket-card-btn"
@click=${() => this._deleteTicket(ticket)}>
@@ -281,7 +289,7 @@ export class ProjectBoardSection extends LightElement {
` : nothing}
${isRunning ? html`
<span class="ticket-card-running-label">Running…</span>
<span class="ticket-card-running-label">${t('project_board.ticket.running')}</span>
${ticket.session_id != null ? html`
<a href="#session/${ticket.session_id}" class="ticket-card-session-link">
<i class="bi bi-chat-text me-1"></i>#${ticket.session_id}
@@ -292,12 +300,12 @@ export class ProjectBoardSection extends LightElement {
${isCompleted ? html`
<button class="btn btn-sm btn-outline-secondary ticket-card-btn"
@click=${() => this._resetTicket(ticket)}>
<i class="bi bi-arrow-counterclockwise me-1"></i>Reset
<i class="bi bi-arrow-counterclockwise me-1"></i>${t('project_board.ticket.reset')}
</button>
<button class="btn btn-sm ticket-card-btn ${isDone ? 'btn-outline-success' : 'btn-outline-danger'}"
@click=${() => this._toggleExpand(ticket.id)}>
<i class="bi bi-${isExpanded ? 'chevron-up' : 'chevron-down'} me-1"></i>
${isDone ? 'Result' : 'Error'}
${isDone ? t('project_board.ticket.result') : t('project_board.ticket.error')}
</button>
${ticket.session_id != null ? html`
<a href="#session/${ticket.session_id}"
@@ -312,9 +320,9 @@ export class ProjectBoardSection extends LightElement {
<div class="ticket-card-result ticket-card-result--${isDone ? 'success' : 'error'}">
${isDone
? html`<div class="ticket-result-markdown copilot-markdown">
${unsafeHTML(renderMarkdown(ticket.result ?? '(no output)'))}
${unsafeHTML(renderMarkdown(ticket.result ?? t('project_board.ticket.no_output')))}
</div>`
: html`<pre class="ticket-result-error">${ticket.error ?? '(no error message)'}</pre>`}
: html`<pre class="ticket-result-error">${ticket.error ?? t('project_board.ticket.no_error')}</pre>`}
</div>
` : nothing}
</div>
@@ -341,7 +349,7 @@ export class ProjectBoardSection extends LightElement {
<button
class="project-tab ${this._activeTab === 'tickets' ? 'project-tab--active' : ''}"
@click=${() => { this._activeTab = 'tickets'; }}>
<i class="bi bi-card-list me-1"></i>Tickets
<i class="bi bi-card-list me-1"></i>${t('project_board.tab.tickets')}
</button>
</div>
`;
@@ -351,9 +359,9 @@ export class ProjectBoardSection extends LightElement {
const { running, todo, completed } = this._groupTickets();
return html`
<div class="ticket-list">
${this._renderSection('Running', 'activity', 'ticket-section-header--running', running, 'No tickets running')}
${this._renderSection('Todo', 'circle', '', todo, 'No tickets to do')}
${this._renderSection('Completed', 'check-circle', 'ticket-section-header--completed', completed, 'No completed tickets')}
${this._renderSection(t('project_board.section.running'), 'activity', 'ticket-section-header--running', running, t('project_board.section.running_empty'))}
${this._renderSection(t('project_board.section.todo'), 'circle', '', todo, t('project_board.section.todo_empty'))}
${this._renderSection(t('project_board.section.completed'), 'check-circle', 'ticket-section-header--completed', completed, t('project_board.section.completed_empty'))}
</div>
`;
}
@@ -364,7 +372,7 @@ export class ProjectBoardSection extends LightElement {
<div class="agent-dialog agent-dialog--ticket">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem">
<i class="bi bi-card-text"></i>
<span style="font-weight:600">New Ticket</span>
<span style="font-weight:600">${t('project_board.modal.title')}</span>
<button type="button" style="margin-left:auto;border:none;background:none;cursor:pointer;font-size:1.1rem"
@click=${() => this._modal = null}>
<i class="bi bi-x"></i>
@@ -377,21 +385,21 @@ export class ProjectBoardSection extends LightElement {
<form @submit=${e => this._createTicket(e)}>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Title</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.title_label')}</label>
<input type="text" class="form-control form-control-sm" required
placeholder="What needs to be done"
placeholder=${t('project_board.modal.title_ph')}
.value=${this._form.title}
@input=${e => this._form = { ...this._form, title: e.target.value }} />
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Description / Prompt</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.desc_label')}</label>
<textarea class="form-control form-control-sm" rows="4"
placeholder="Detailed instructions for the agent…"
placeholder=${t('project_board.modal.desc_ph')}
.value=${this._form.description}
@input=${e => this._form = { ...this._form, description: e.target.value }}></textarea>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Agent</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.agent')}</label>
<select class="form-select form-select-sm"
.value=${this._form.agent_id}
@change=${e => this._form = { ...this._form, agent_id: e.target.value }}>
@@ -401,11 +409,11 @@ export class ProjectBoardSection extends LightElement {
</select>
</div>
<div class="mb-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Security Group</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.security_group')}</label>
<select class="form-select form-select-sm"
.value=${this._form.security_group}
@change=${e => this._form = { ...this._form, security_group: e.target.value }}>
<option value="">— inherit from project —</option>
<option value="">${t('project_board.modal.inherit')}</option>
${this._groups.map(g => html`
<option value=${g.id} ?selected=${this._form.security_group === g.id}>${g.name}</option>
`)}
@@ -413,11 +421,11 @@ export class ProjectBoardSection extends LightElement {
</div>
<div style="display:flex;justify-content:flex-end;gap:0.5rem">
<button type="button" class="btn btn-sm btn-outline-secondary"
@click=${() => this._modal = null}>Cancel</button>
@click=${() => this._modal = null}>${t('project_board.modal.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
: html`<i class="bi bi-check-lg me-1"></i>Create`}
? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('project_board.modal.saving')}`
: html`<i class="bi bi-check-lg me-1"></i>${t('project_board.modal.create')}`}
</button>
</div>
</form>
@@ -440,7 +448,7 @@ export class ProjectBoardSection extends LightElement {
<div class="project-page-header">
<div style="display:flex;align-items:center;gap:12px">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}>
<i class="bi bi-arrow-left me-1"></i>Projects
<i class="bi bi-arrow-left me-1"></i>${t('project_board.back')}
</button>
<h2 class="project-page-title">
<i class="bi bi-folder2"></i>${this._project.name}
@@ -448,11 +456,11 @@ export class ProjectBoardSection extends LightElement {
</div>
<div style="display:flex;gap:0.5rem">
<button class="btn btn-sm btn-outline-primary" @click=${() => this._openChat()}>
<i class="bi bi-chat-dots me-1"></i>Open Chat
<i class="bi bi-chat-dots me-1"></i>${t('project_board.open_chat')}
</button>
<button class="btn btn-sm btn-primary"
@click=${() => { this._form = this._emptyForm(); this._error = null; this._modal = { mode: 'add' }; this._loadModalData(); }}>
<i class="bi bi-plus-lg me-1"></i>New Ticket
<i class="bi bi-plus-lg me-1"></i>${t('project_board.new_ticket')}
</button>
</div>
</div>
+29 -17
View File
@@ -1,5 +1,6 @@
import { html, nothing } from 'lit';
import { LightElement } from '../../lib/base.js';
import { t } from '../../lib/i18n.js';
import { formatDate } from '../tasks/utils.js';
export class ProjectListSection extends LightElement {
@@ -20,6 +21,17 @@ export class ProjectListSection extends LightElement {
this._error = null;
}
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
_emptyForm() {
return { name: '', path: '', description: '' };
}
@@ -76,7 +88,7 @@ export class ProjectListSection extends LightElement {
}
async _delete(project) {
if (!confirm(`Delete project "${project.name}"?\nAll tickets will also be deleted.`)) return;
if (!confirm(t('projects.confirm.delete', { name: project.name }))) return;
try {
const res = await fetch(`/api/projects/${project.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
@@ -104,7 +116,7 @@ export class ProjectListSection extends LightElement {
<div class="agent-dialog">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem">
<i class="bi bi-kanban"></i>
<span style="font-weight:600">${isEdit ? 'Edit Project' : 'New Project'}</span>
<span style="font-weight:600">${isEdit ? t('projects.modal.title_edit') : t('projects.modal.title_new')}</span>
<button type="button" style="margin-left:auto;border:none;background:none;cursor:pointer;font-size:1.1rem"
@click=${() => this._closeModal()}>
<i class="bi bi-x"></i>
@@ -117,33 +129,33 @@ export class ProjectListSection extends LightElement {
<form @submit=${e => this._submit(e)}>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Name</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('projects.modal.name')}</label>
<input type="text" class="form-control form-control-sm" required
placeholder="My Project"
placeholder=${t('projects.modal.name_ph')}
.value=${this._form.name}
@input=${e => this._setField('name', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.82rem">Path</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('projects.modal.path')}</label>
<input type="text" class="form-control form-control-sm" required
placeholder="/path/to/project"
placeholder=${t('projects.modal.path_ph')}
.value=${this._form.path}
@input=${e => this._setField('path', e.target.value)} />
</div>
<div class="mb-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">Description</label>
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('projects.modal.desc')}</label>
<textarea class="form-control form-control-sm" rows="2"
placeholder="What this project is about"
placeholder=${t('projects.modal.desc_ph')}
.value=${this._form.description}
@input=${e => this._setField('description', e.target.value)}></textarea>
</div>
<div style="display:flex;justify-content:flex-end;gap:0.5rem">
<button type="button" class="btn btn-sm btn-outline-secondary"
@click=${() => this._closeModal()}>Cancel</button>
@click=${() => this._closeModal()}>${t('projects.modal.cancel')}</button>
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
${this._saving
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
: html`<i class="bi bi-check-lg me-1"></i>${isEdit ? 'Save' : 'Create'}`}
? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('projects.modal.saving')}`
: html`<i class="bi bi-check-lg me-1"></i>${isEdit ? t('projects.modal.save') : t('projects.modal.create')}`}
</button>
</div>
</form>
@@ -158,11 +170,11 @@ export class ProjectListSection extends LightElement {
<div class="project-card-header">
<div class="project-card-title">${project.name}</div>
<div class="project-card-actions" @click=${e => e.stopPropagation()}>
<button class="project-card-icon-btn" title="Edit"
<button class="project-card-icon-btn" title=${t('projects.action.edit')}
@click=${() => this._openEdit(project)}>
<i class="bi bi-pencil"></i>
</button>
<button class="project-card-icon-btn project-card-icon-btn--danger" title="Delete"
<button class="project-card-icon-btn project-card-icon-btn--danger" title=${t('projects.action.delete')}
@click=${() => this._delete(project)}>
<i class="bi bi-trash"></i>
</button>
@@ -172,7 +184,7 @@ export class ProjectListSection extends LightElement {
${project.description
? html`<div class="project-card-desc">${project.description}</div>`
: nothing}
<div class="project-card-meta">Updated ${formatDate(project.updated_at)}</div>
<div class="project-card-meta">${t('projects.card.updated')} ${formatDate(project.updated_at)}</div>
</div>
`;
}
@@ -181,9 +193,9 @@ export class ProjectListSection extends LightElement {
return html`
<div class="project-page">
<div class="project-page-header">
<h2 class="project-page-title"><i class="bi bi-kanban"></i> Projects</h2>
<h2 class="project-page-title"><i class="bi bi-kanban"></i> ${t('projects.title')}</h2>
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
<i class="bi bi-plus-lg me-1"></i>New Project
<i class="bi bi-plus-lg me-1"></i>${t('projects.btn.new')}
</button>
</div>
@@ -194,7 +206,7 @@ export class ProjectListSection extends LightElement {
${this._projects.length === 0 ? html`
<div class="task-empty">
<i class="bi bi-kanban"></i>
<p>No projects yet. Create one to get started.</p>
<p>${t('projects.empty')}</p>
</div>
` : html`
<div class="project-grid">
+65 -29
View File
@@ -1,5 +1,7 @@
import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const ADMIN_ID = 'admin';
@@ -26,6 +28,8 @@ export class RolesPage extends LightElement {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'roles';
this.style.display = this._open ? 'flex' : 'none';
@@ -33,6 +37,11 @@ export class RolesPage extends LightElement {
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _load() {
this._error = null;
try {
@@ -40,8 +49,8 @@ export class RolesPage extends LightElement {
fetch('/api/roles'),
fetch('/api/tool-permission-groups'),
]);
if (!rRes.ok) throw new Error(`Roles: HTTP ${rRes.status}`);
if (!gRes.ok) throw new Error(`Groups: HTTP ${gRes.status}`);
if (!rRes.ok) throw new Error(`HTTP ${rRes.status}`);
if (!gRes.ok) throw new Error(`HTTP ${gRes.status}`);
this._roles = await rRes.json();
this._groups = await gRes.json();
} catch (e) {
@@ -51,10 +60,25 @@ export class RolesPage extends LightElement {
// ── Modal helpers ────────────────────────────────────────────────────────────
// `ui_mode` lives in the free-form attrs JSON (data-driven, §0.1): the UI
// surfaces it as a first-class select without hardcoding any role semantics.
_attrsUiMode(attrs) {
try { return JSON.parse(attrs || '{}').ui_mode === 'simple' ? 'simple' : 'full'; }
catch { return 'full'; }
}
_mergeAttrs(attrs, uiMode) {
let o = {};
try { o = JSON.parse(attrs || '{}') ?? {}; } catch { o = {}; }
if (uiMode === 'simple') o.ui_mode = 'simple'; else delete o.ui_mode;
const keys = Object.keys(o);
return keys.length ? JSON.stringify(o) : null;
}
_openCreate() {
this._modal = {
mode: 'create',
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '' },
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full' },
};
}
@@ -62,7 +86,7 @@ export class RolesPage extends LightElement {
this._modal = {
mode: 'edit',
role,
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '' },
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs) },
};
}
@@ -79,7 +103,7 @@ export class RolesPage extends LightElement {
this._error = null;
if (mode === 'create') {
if (!form.id.trim() || !form.label.trim()) { this._error = 'ID and label are required.'; return; }
if (!form.id.trim() || !form.label.trim()) { this._error = t('roles.error.id_label'); return; }
try {
const res = await fetch('/api/roles', {
method: 'POST',
@@ -88,7 +112,7 @@ export class RolesPage extends LightElement {
id: form.id.trim(),
label: form.label.trim(),
permission_group: form.permission_group,
attrs: form.attrs.trim() || null,
attrs: this._mergeAttrs(form.attrs, form.ui_mode),
}),
});
if (!res.ok) throw new Error(await res.text());
@@ -97,7 +121,7 @@ export class RolesPage extends LightElement {
} catch (e) { this._error = e.message; }
} else {
const { role } = this._modal;
if (!form.label.trim()) { this._error = 'Label is required.'; return; }
if (!form.label.trim()) { this._error = t('roles.error.label'); return; }
try {
const res = await fetch(`/api/roles/${role.id}`, {
method: 'PUT',
@@ -105,7 +129,7 @@ export class RolesPage extends LightElement {
body: JSON.stringify({
label: form.label.trim(),
permission_group: form.permission_group,
attrs: form.attrs.trim() || null,
attrs: this._mergeAttrs(form.attrs, form.ui_mode),
}),
});
if (!res.ok) throw new Error(await res.text());
@@ -116,7 +140,7 @@ export class RolesPage extends LightElement {
}
async _delete(role) {
if (!confirm(`Delete role "${role.label}"?`)) return;
if (!confirm(t('roles.confirm.delete', { name: role.label }))) return;
try {
const res = await fetch(`/api/roles/${role.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
@@ -133,7 +157,7 @@ export class RolesPage extends LightElement {
_renderModal() {
if (!this._modal) return nothing;
const { mode, form, role } = this._modal;
const title = mode === 'create' ? 'New role' : `Edit ${role.label}`;
const title = mode === 'create' ? t('roles.form.new') : t('roles.form.edit', { name: role.label });
return html`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
@@ -148,33 +172,41 @@ export class RolesPage extends LightElement {
${mode === 'create' ? html`
<div class="mb-3">
<label class="form-label">ID <span class="text-muted">(slug)</span></label>
<input class="form-control font-monospace" placeholder="e.g. editor" .value=${form.id}
<label class="form-label">${t('roles.form.id')} <span class="text-muted">${t('roles.form.id_hint')}</span></label>
<input class="form-control font-monospace" placeholder=${t('roles.form.id_ph')} .value=${form.id}
@input=${e => this._patch('id', e.target.value)} />
<div class="form-text" style="font-size:.75rem">Lowercase, no spaces. Cannot be changed later.</div>
<div class="form-text" style="font-size:.75rem">${t('roles.form.id_desc')}</div>
</div>
` : nothing}
<div class="mb-3">
<label class="form-label">Label</label>
<label class="form-label">${t('roles.form.label')}</label>
<input class="form-control" .value=${form.label} @input=${e => this._patch('label', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label">Permission group</label>
<label class="form-label">${t('roles.form.group')}</label>
<select class="form-select" @change=${e => this._patch('permission_group', e.target.value)}>
${(this._groups ?? []).map(g => html`<option value=${g.id} ?selected=${form.permission_group === g.id}>${g.name}</option>`)}
</select>
</div>
<div class="mb-3">
<label class="form-label">Attrs <span class="text-muted">(JSON, optional)</span></label>
<input class="form-control font-monospace" placeholder="{}" .value=${form.attrs}
<label class="form-label">${t('roles.form.interface')}</label>
<select class="form-select" @change=${e => this._patch('ui_mode', e.target.value)}>
<option value="full" ?selected=${form.ui_mode === 'full'}>${t('roles.form.interface_full')}</option>
<option value="simple" ?selected=${form.ui_mode === 'simple'}>${t('roles.form.interface_simple')}</option>
</select>
<div class="form-text" style="font-size:.75rem">${unsafeHTML(t('roles.form.interface_hint'))}</div>
</div>
<div class="mb-3">
<label class="form-label">${t('roles.form.attrs')} <span class="text-muted">${t('roles.form.attrs_hint')}</span></label>
<input class="form-control font-monospace" placeholder=${t('roles.form.attrs_ph')} .value=${form.attrs}
@input=${e => this._patch('attrs', e.target.value)} />
</div>
</div>
<div class="um-modal-footer">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>Cancel</button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>${t('roles.form.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._save()}>
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? 'Create' : 'Save'}
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? t('roles.form.create') : t('roles.form.save')}
</button>
</div>
</div>
@@ -190,11 +222,11 @@ export class RolesPage extends LightElement {
return html`
<div class="um-page">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-tags me-2"></i>Roles</h2>
<h2 class="um-title"><i class="bi bi-tags me-2"></i>${t('roles.title')}</h2>
<div class="um-header-right">
<span class="um-header-count">${roles.length} role${roles.length === 1 ? '' : 's'}</span>
<span class="um-header-count">${roles.length === 1 ? t('roles.count', { n: roles.length }) : t('roles.count_plural', { n: roles.length })}</span>
<button class="btn btn-sm btn-primary" @click=${() => this._openCreate()}>
<i class="bi bi-plus-lg me-1"></i>New role
<i class="bi bi-plus-lg me-1"></i>${t('roles.new_role')}
</button>
</div>
</div>
@@ -204,15 +236,16 @@ export class RolesPage extends LightElement {
` : nothing}
<div class="um-table-wrap">
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : roles.length === 0 ? html`
<div class="um-empty"><i class="bi bi-tags"></i><p>No roles.</p></div>
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('roles.loading')}</div>` : roles.length === 0 ? html`
<div class="um-empty"><i class="bi bi-tags"></i><p>${t('roles.empty')}</p></div>
` : html`
<table class="um-table">
<thead>
<tr>
<th>ID</th>
<th>Label</th>
<th>Permission group</th>
<th>${t('roles.col.id')}</th>
<th>${t('roles.col.label')}</th>
<th>${t('roles.col.group')}</th>
<th>${t('roles.col.interface')}</th>
<th></th>
</tr>
</thead>
@@ -224,14 +257,17 @@ export class RolesPage extends LightElement {
<td><code>${r.id}</code></td>
<td><strong>${r.label}</strong></td>
<td>${this._groupLabel(r.permission_group)}</td>
<td>${this._attrsUiMode(r.attrs) === 'simple'
? html`<span class="badge" style="background:var(--accent-soft);color:var(--accent)">${t('roles.badge.simple')}</span>`
: html`<span class="badge bg-secondary">${t('roles.badge.full')}</span>`}</td>
<td>
<div class="um-actions">
<button class="um-btn-icon" title=${isAdmin ? 'Built-in role — locked' : 'Edit'}
<button class="um-btn-icon" title=${isAdmin ? t('roles.tooltip.locked') : t('roles.tooltip.edit')}
?disabled=${isAdmin}
@click=${() => !isAdmin && this._openEdit(r)}>
<i class="bi bi-pencil"></i>
</button>
<button class="um-btn-icon" title=${isAdmin ? 'Built-in role — locked' : 'Delete'}
<button class="um-btn-icon" title=${isAdmin ? t('roles.tooltip.locked') : t('roles.tooltip.delete')}
?disabled=${isAdmin}
@click=${() => !isAdmin && this._delete(r)}>
<i class="bi bi-trash"></i>
+32 -22
View File
@@ -1,5 +1,7 @@
import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const PAGE_ID = 'session';
@@ -57,6 +59,8 @@ export class SessionDetailPage extends LightElement {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none';
@@ -68,6 +72,12 @@ export class SessionDetailPage extends LightElement {
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
this._closeWs();
}
disconnectedCallback() {
super.disconnectedCallback();
this._closeWs();
@@ -250,15 +260,15 @@ export class SessionDetailPage extends LightElement {
<div class="sd-session-header">
<div class="d-flex align-items-center gap-2 flex-wrap">
<button class="btn btn-sm btn-outline-secondary sd-back-btn" @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i> Back
<i class="bi bi-arrow-left"></i> ${t('session.back')}
</button>
<span class="badge ${sourceBadgeClass(session.source)}">${session.source}</span>
<span class="fw-semibold font-monospace">agent: ${session.agent_id}</span>
<span class="text-secondary small">id: ${session.id}</span>
${session.is_ephemeral ? html`<span class="badge bg-light text-dark border">ephemeral</span>` : nothing}
${!session.is_interactive ? html`<span class="badge bg-light text-dark border">automated</span>` : nothing}
<span class="fw-semibold font-monospace">${t('session.agent')} ${session.agent_id}</span>
<span class="text-secondary small">${t('session.id')} ${session.id}</span>
${session.is_ephemeral ? html`<span class="badge bg-light text-dark border">${t('session.ephemeral')}</span>` : nothing}
${!session.is_interactive ? html`<span class="badge bg-light text-dark border">${t('session.automated')}</span>` : nothing}
${this._live
? html`<span class="sd-live-badge"><span class="sd-live-dot"></span>live</span>`
? html`<span class="sd-live-badge"><span class="sd-live-dot"></span>${t('session.live')}</span>`
: nothing}
</div>
<div class="text-secondary small mt-1">${formatDate(session.created_at)}</div>
@@ -272,13 +282,13 @@ export class SessionDetailPage extends LightElement {
<div class="sd-msg sd-msg--user ${item.is_synthetic ? 'sd-msg--synthetic' : ''}">
<div class="sd-msg-role">
${item.is_synthetic
? html`<span class="badge bg-warning text-dark me-1" style="font-size:0.65rem">synthetic</span>`
? html`<span class="badge bg-warning text-dark me-1" style="font-size:0.65rem">${t('session.synthetic')}</span>`
: nothing}
<span>User</span>
<span>${t('session.user_role')}</span>
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
</div>
<div class="sd-msg-content">${item.content}</div>
${item.failed ? html`<div class="sd-msg-failed">failed</div>` : nothing}
${item.failed ? html`<div class="sd-msg-failed">${t('session.failed')}</div>` : nothing}
</div>
`;
}
@@ -291,14 +301,14 @@ export class SessionDetailPage extends LightElement {
return html`
<div class="sd-msg sd-msg--assistant ${item.failed ? 'sd-msg--failed' : ''}">
<div class="sd-msg-role">
Assistant
${t('session.assistant_role')}
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
${item.input_tokens != null ? html`<span class="sd-tokens">${item.input_tokens}${item.output_tokens}↓</span>` : nothing}
</div>
${hasReasoning ? html`
<div class="sd-reasoning-toggle" @click=${() => this._toggleReason(key)}>
<i class="bi bi-brain me-1"></i>
Reasoning
${t('session.reasoning_label')}
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i>
</div>
${expanded ? html`<pre class="sd-reasoning-block">${item.reasoning}</pre>` : nothing}
@@ -316,14 +326,14 @@ export class SessionDetailPage extends LightElement {
return html`
<div class="sd-msg sd-msg--thinking ${item.failed ? 'sd-msg--failed' : ''}">
<div class="sd-msg-role">
<i class="bi bi-lightning-charge me-1"></i>Thinking
<i class="bi bi-lightning-charge me-1"></i>${t('session.thinking_role')}
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
${item.input_tokens != null ? html`<span class="sd-tokens">${item.input_tokens}${item.output_tokens}↓</span>` : nothing}
</div>
${hasReasoning ? html`
<div class="sd-reasoning-toggle" @click=${() => this._toggleReason(key)}>
<i class="bi bi-brain me-1"></i>
Reasoning
${t('session.reasoning_label')}
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i>
</div>
${expanded ? html`<pre class="sd-reasoning-block">${item.reasoning}</pre>` : nothing}
@@ -351,10 +361,10 @@ export class SessionDetailPage extends LightElement {
${item.label_full && item.label_full !== item.label_short
? html`<div class="sd-tool-label-full text-secondary small mb-2">${item.label_full}</div>`
: nothing}
<div class="sd-tool-section-label">Arguments</div>
<div class="sd-tool-section-label">${t('session.tool_args')}</div>
<pre class="sd-code-block">${jsonPretty(item.arguments)}</pre>
<div class="sd-tool-section-label mt-2">
${item.status === 'error' ? 'Error' : 'Result'}
${item.status === 'error' ? t('session.tool_error') : t('session.tool_result')}
</div>
<pre class="sd-code-block ${item.status === 'error' ? 'sd-code-block--error' : ''}">${
item.result ?? item.error ?? '—'
@@ -369,14 +379,14 @@ export class SessionDetailPage extends LightElement {
return html`
<div class="sd-agent-frame-start">
<i class="bi bi-robot me-1"></i>
<span>Sub-agent: <strong>${item.agent_id}</strong></span>
<span class="text-secondary small ms-2">depth ${item.depth}</span>
<span>${t('session.sub_agent')} <strong>${item.agent_id}</strong></span>
<span class="text-secondary small ms-2">${t('session.depth', { n: item.depth })}</span>
</div>
`;
}
_renderAgentFrameEnd(item) {
return html`<div class="sd-agent-frame-end">end of ${item.agent_id}</div>`;
return html`<div class="sd-agent-frame-end">${t('session.end_of')} ${item.agent_id}</div>`;
}
_renderMessage(item, idx) {
@@ -576,18 +586,18 @@ export class SessionDetailPage extends LightElement {
<div class="sd-container">
${this._loading ? html`
<div class="text-center text-secondary py-5">
<div class="spinner-border spinner-border-sm me-2"></div>Loading session…
<div class="spinner-border spinner-border-sm me-2"></div>${t('session.loading')}
</div>
` : this._error ? html`
<div class="alert alert-danger">${this._error}</div>
` : !this._data ? html`
<div class="text-secondary text-center py-5">No session loaded.<br>
<span class="small">Navigate to <code>#session/{id}</code> to view a session.</span>
<div class="text-secondary text-center py-5">${t('session.no_session')}<br>
<span class="small">${unsafeHTML(t('session.no_session_hint'))}</span>
</div>
` : html`
${this._renderSessionHeader(this._data.session)}
${this._data.messages.length === 0
? html`<div class="text-secondary text-center py-4">No messages in this session.</div>`
? html`<div class="text-secondary text-center py-4">${t('session.empty')}</div>`
: this._data.messages.map((m, i) => this._renderMessage(m, i))
}
`}
+31 -16
View File
@@ -1,7 +1,8 @@
import { html } from 'lit';
import { LightElement } from '../lib/base.js';
import { t, I18nMixin, LOCALES, getLocale, setLocale } from '../lib/i18n.js';
export class SetupPage extends LightElement {
export class SetupPage extends I18nMixin(LightElement) {
static get properties() {
return {
@@ -9,6 +10,7 @@ export class SetupPage extends LightElement {
_password: { state: true },
_confirm: { state: true },
_encrypted: { state: true },
_locale: { state: true },
_error: { state: true },
_busy: { state: true },
};
@@ -20,6 +22,7 @@ export class SetupPage extends LightElement {
this._password = '';
this._confirm = '';
this._encrypted = true;
this._locale = getLocale();
this._error = null;
this._busy = false;
}
@@ -31,15 +34,15 @@ export class SetupPage extends LightElement {
this._error = null;
if (!this._username.trim()) {
this._error = 'Choose a username.';
this._error = t('setup.username');
return;
}
if (this._password.length < 4) {
this._error = 'Password must be at least 4 characters.';
this._error = t('setup.pw.short');
return;
}
if (this._password !== this._confirm) {
this._error = 'The two passwords do not match.';
this._error = t('setup.pw.mismatch');
return;
}
@@ -56,6 +59,7 @@ export class SetupPage extends LightElement {
username: this._username.trim(),
password: this._password,
encrypted: this._encrypted,
locale: this._locale,
}),
});
if (!res.ok) {
@@ -66,7 +70,7 @@ export class SetupPage extends LightElement {
// First user created — reload into the app.
window.location.reload();
} catch {
this._error = 'Network error — please try again.';
this._error = t('setup.network');
} finally {
this._busy = false;
}
@@ -74,8 +78,8 @@ export class SetupPage extends LightElement {
render() {
const btnLabel = this._busy
? html`<span class="setup-spinner"></span>Creating…`
: 'Create account';
? html`<span class="setup-spinner"></span>${t('setup.creating')}`
: t('setup.submit');
return html`
<div class="setup-page">
@@ -83,13 +87,13 @@ export class SetupPage extends LightElement {
<div class="setup-logo">
<img src="/assets/icons/icon-192.png" alt="Skald" />
</div>
<h1 class="setup-title">Welcome to Skald</h1>
<p class="setup-subtitle">Create the admin account to get started.</p>
<h1 class="setup-title">${t('setup.title')}</h1>
<p class="setup-subtitle">${t('setup.subtitle')}</p>
${this._error ? html`<div class="setup-error">${this._error}</div>` : null}
<div class="mb-3">
<label class="form-label">Username</label>
<label class="form-label">${t('login.username')}</label>
<input
type="text"
class="form-control"
@@ -100,7 +104,7 @@ export class SetupPage extends LightElement {
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<label class="form-label">${t('login.password')}</label>
<input
type="password"
class="form-control"
@@ -111,7 +115,7 @@ export class SetupPage extends LightElement {
</div>
<div class="mb-3">
<label class="form-label">Confirm password</label>
<label class="form-label">${t('setup.confirm')}</label>
<input
type="password"
class="form-control"
@@ -121,6 +125,19 @@ export class SetupPage extends LightElement {
required />
</div>
<div class="mb-3">
<label class="form-label">${t('setup.language')}</label>
<select
class="form-select"
.value=${this._locale}
@change=${e => { this._locale = e.target.value; setLocale(this._locale); }}
?disabled=${this._busy}>
${LOCALES.map(l => html`
<option value=${l.id} ?selected=${this._locale === l.id}>${l.label}</option>
`)}
</select>
</div>
<div class="form-check">
<input
class="form-check-input"
@@ -130,15 +147,13 @@ export class SetupPage extends LightElement {
@change=${e => this._encrypted = e.target.checked}
?disabled=${this._busy} />
<label class="form-check-label" for="encrypt-chk">
Encrypt my conversation history
${t('setup.encrypt')}
</label>
</div>
${this._encrypted ? html`
<div class="setup-warn">
<strong>Warning:</strong> your password derives the encryption key.
If you forget it, <strong>your entire conversation history will be
permanently lost</strong> — there is no recovery.
<strong>${t('setup.warn.strong')}</strong> ${t('setup.warn')}
</div>
` : null}
+356
View File
@@ -0,0 +1,356 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
// Shared on-disk folders (blueprint §6). Admin-only surface: create a folder,
// describe what it holds (the description is fed to the assistant's system
// context), and grant members read-only or read-write access. There is no owner —
// a folder is just a name + a membership list (contrast: Projects, which will have
// an owner). Renaming is intentionally not offered (it would remount + move the
// on-disk directory). Reuses the `um-*` (users/roles) and `connector-card` styles.
async function jf(url, opts) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
const ct = res.headers.get('content-type') || '';
return ct.includes('application/json') ? res.json() : null;
}
export class SharedFoldersPage extends LightElement {
static get properties() {
return {
_open: { state: true },
_folders: { state: true }, // [{ id, folder_name, description, members:[{user_id,can_write}] }]
_users: { state: true }, // /api/users — for the member picker + labels
_error: { state: true },
_modal: { state: true }, // null | { mode:'create'|'edit', folder?, form:{folder_name,description} }
_add: { state: true }, // { [folderId]: { user_id, can_write } } — in-progress add-row
};
}
constructor() {
super();
this._open = false;
this._folders = null;
this._users = null;
this._error = null;
this._modal = null;
this._add = {};
}
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'shared-folders';
this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._load();
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _load() {
this._error = null;
try {
const [folders, users] = await Promise.all([
jf('/api/shared-folders'),
jf('/api/users'),
]);
this._folders = folders;
this._users = users;
} catch (e) {
this._error = e.message;
this._folders = this._folders ?? [];
}
}
_userLabel(id) {
const u = (this._users ?? []).find(x => x.id === id);
return u ? (u.display_name || u.username) : id;
}
// Users not yet members of this folder (and active) — the add-picker's options.
_candidates(folder) {
const members = new Set(folder.members.map(m => m.user_id));
return (this._users ?? []).filter(u => u.active && !members.has(u.id));
}
// ── create / edit-description modal ──────────────────────────────────────────
_openCreate() {
this._modal = { mode: 'create', form: { folder_name: '', description: '' } };
this._error = null;
}
_openEditDesc(folder) {
this._modal = { mode: 'edit', folder, form: { folder_name: folder.folder_name, description: folder.description } };
this._error = null;
}
_closeModal() { this._modal = null; this._error = null; }
_patch(field, value) {
this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } };
}
async _save() {
const { mode, form, folder } = this._modal;
this._error = null;
try {
if (mode === 'create') {
if (!form.folder_name.trim()) { this._error = t('sf.error.name'); return; }
await jf('/api/shared-folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_name: form.folder_name.trim(), description: form.description.trim() }),
});
} else {
await jf(`/api/shared-folders/${folder.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: form.description.trim() }),
});
}
this._closeModal();
await this._load();
} catch (e) { this._error = e.message; }
}
async _delete(folder) {
if (!confirm(t('sf.confirm.delete', { name: folder.folder_name }))) return;
this._error = null;
try {
await jf(`/api/shared-folders/${folder.id}`, { method: 'DELETE' });
await this._load();
} catch (e) { this._error = e.message; }
}
// ── membership ───────────────────────────────────────────────────────────────
_draft(folderId) { return this._add[folderId] ?? { user_id: '', can_write: false }; }
_setAdd(folderId, patch) {
this._add = { ...this._add, [folderId]: { ...this._draft(folderId), ...patch } };
}
async _addMember(folder) {
const draft = this._draft(folder.id);
if (!draft.user_id) return;
this._error = null;
try {
await jf(`/api/shared-folders/${folder.id}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: draft.user_id, can_write: !!draft.can_write }),
});
this._add = { ...this._add, [folder.id]: { user_id: '', can_write: false } };
await this._load();
} catch (e) { this._error = e.message; }
}
// Re-grant with a new capability — the POST upserts on (folder, user).
async _setAccess(folder, userId, canWrite) {
this._error = null;
try {
await jf(`/api/shared-folders/${folder.id}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, can_write: canWrite }),
});
await this._load();
} catch (e) { this._error = e.message; }
}
async _removeMember(folder, userId) {
if (!confirm(t('sf.confirm.remove_member', { name: this._userLabel(userId), folder: folder.folder_name }))) return;
this._error = null;
try {
await jf(`/api/shared-folders/${folder.id}/members/${encodeURIComponent(userId)}`, { method: 'DELETE' });
await this._load();
} catch (e) { this._error = e.message; }
}
// ── render ───────────────────────────────────────────────────────────────────
render() {
if (!this._open) return nothing;
const folders = this._folders ?? [];
const loading = this._folders === null;
return html`
<div class="um-page">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-folder-symlink me-2"></i>${t('sf.title')}</h2>
<div class="um-header-right">
<span class="um-header-count">
${folders.length === 1 ? t('sf.count', { n: folders.length }) : t('sf.count_plural', { n: folders.length })}
</span>
<button class="btn btn-sm btn-primary" @click=${() => this._openCreate()}>
<i class="bi bi-plus-lg me-1"></i>${t('sf.new')}
</button>
</div>
</div>
${this._error && !this._modal ? html`
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>` : nothing}
<div style="padding:0 1.25rem 1.5rem; overflow:auto">
<div class="text-muted mb-3" style="font-size:.78rem">
<i class="bi bi-info-circle me-1"></i>${t('sf.note.propagation')}
</div>
${loading
? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('sf.loading')}</div>`
: folders.length === 0
? html`
<div class="um-empty">
<i class="bi bi-folder-symlink"></i>
<p>${t('sf.empty')}</p>
<p style="font-size:.8rem;opacity:.7">${t('sf.empty_hint')}</p>
</div>`
: html`<div class="d-flex flex-column gap-3">${folders.map(f => this._renderFolder(f))}</div>`}
</div>
${this._renderModal()}
</div>`;
}
_renderFolder(f) {
const draft = this._draft(f.id);
const candidates = this._candidates(f);
return html`
<div class="connector-card" style="cursor:default">
<div class="d-flex align-items-start justify-content-between">
<div style="min-width:0">
<div style="font-weight:600;font-size:.95rem">
<i class="bi bi-folder2 me-1" style="opacity:.6"></i>${f.folder_name}
</div>
<code class="text-muted" style="font-size:.7rem">shared/${f.folder_name}</code>
</div>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-outline-secondary" title=${t('sf.edit_desc')} @click=${() => this._openEditDesc(f)}>
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" title=${t('sf.delete')} @click=${() => this._delete(f)}>
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="mt-2 mb-3" style="font-size:.82rem">
${f.description
? html`<span>${f.description}</span>`
: html`<span class="text-muted fst-italic">${t('sf.no_desc')}</span>`}
</div>
<div style="border-top:1px solid var(--bs-border-color,#333);padding-top:.75rem">
<div class="text-muted mb-2" style="font-size:.72rem;text-transform:uppercase;letter-spacing:.04em">
${t('sf.members')}
</div>
${f.members.length === 0
? html`<div class="text-muted mb-2" style="font-size:.8rem">${t('sf.no_members')}</div>`
: html`<div class="d-flex flex-column gap-1 mb-2">${f.members.map(m => this._renderMember(f, m))}</div>`}
${candidates.length > 0 ? html`
<div class="d-flex gap-2 align-items-center flex-wrap">
<select class="form-select form-select-sm" style="max-width:16rem"
@change=${(e) => this._setAdd(f.id, { user_id: e.target.value })}>
<option value="" ?selected=${!draft.user_id}>${t('sf.choose_user')}</option>
${candidates.map(u => html`
<option value=${u.id} ?selected=${draft.user_id === u.id}>${u.display_name || u.username}</option>`)}
</select>
<select class="form-select form-select-sm" style="max-width:11rem"
@change=${(e) => this._setAdd(f.id, { can_write: e.target.value === 'write' })}>
<option value="read" ?selected=${!draft.can_write}>${t('sf.access.readonly')}</option>
<option value="write" ?selected=${draft.can_write}>${t('sf.access.readwrite')}</option>
</select>
<button class="btn btn-sm btn-primary" ?disabled=${!draft.user_id} @click=${() => this._addMember(f)}>
<i class="bi bi-plus-lg me-1"></i>${t('sf.add')}
</button>
</div>`
: html`<div class="text-muted" style="font-size:.78rem">${t('sf.all_added')}</div>`}
</div>
</div>`;
}
_renderMember(f, m) {
return html`
<div class="d-flex align-items-center justify-content-between p-2 rounded"
style="border:1px solid var(--bs-border-color,#333)">
<div style="min-width:0;font-size:.85rem">
<i class="bi bi-person-circle me-1" style="opacity:.6"></i>${this._userLabel(m.user_id)}
</div>
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm" role="group" aria-label=${t('sf.access.label')}>
<button class="btn ${!m.can_write ? 'btn-secondary' : 'btn-outline-secondary'}"
title=${t('sf.access.readonly')}
@click=${() => m.can_write && this._setAccess(f, m.user_id, false)}>
<i class="bi bi-eye me-1"></i>${t('sf.access.read')}
</button>
<button class="btn ${m.can_write ? 'btn-secondary' : 'btn-outline-secondary'}"
title=${t('sf.access.readwrite')}
@click=${() => !m.can_write && this._setAccess(f, m.user_id, true)}>
<i class="bi bi-pencil me-1"></i>${t('sf.access.write')}
</button>
</div>
<button class="um-btn-icon" title=${t('sf.remove')} @click=${() => this._removeMember(f, m.user_id)}>
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>`;
}
_renderModal() {
if (!this._modal) return nothing;
const { mode, form, folder } = this._modal;
const title = mode === 'create' ? t('sf.form.new') : t('sf.form.edit', { name: folder.folder_name });
return html`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
<div class="um-modal">
<div class="um-modal-header">
<i class="bi ${mode === 'create' ? 'bi-folder-plus' : 'bi-pencil-square'}"></i>
<span>${title}</span>
<button class="um-btn-icon ms-auto" @click=${() => this._closeModal()}><i class="bi bi-x-lg"></i></button>
</div>
<div class="um-modal-body">
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
${mode === 'create' ? html`
<div class="mb-3">
<label class="form-label">${t('sf.form.name')} <span class="text-muted">${t('sf.form.name_hint')}</span></label>
<input class="form-control font-monospace" placeholder=${t('sf.form.name_ph')} .value=${form.folder_name}
@input=${e => this._patch('folder_name', e.target.value)} />
<div class="form-text" style="font-size:.75rem">${t('sf.form.name_desc')}</div>
</div>
` : html`
<div class="mb-3">
<label class="form-label">${t('sf.form.name')}</label>
<div><code>shared/${folder.folder_name}</code></div>
</div>
`}
<div class="mb-3">
<label class="form-label">${t('sf.form.desc')}</label>
<textarea class="form-control" rows="4" placeholder=${t('sf.form.desc_ph')} .value=${form.description}
@input=${e => this._patch('description', e.target.value)}></textarea>
<div class="form-text" style="font-size:.75rem">${t('sf.form.desc_desc')}</div>
</div>
</div>
<div class="um-modal-footer">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>${t('sf.form.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._save()}>
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? t('sf.form.create') : t('sf.form.save')}
</button>
</div>
</div>
</div>`;
}
}
+12 -11
View File
@@ -1,5 +1,6 @@
import { html, nothing } from 'lit';
import { ChatSession } from '../../lib/chat-session.js';
import { t } from '../../lib/i18n.js';
import { renderMsg, renderAttachmentChips } from '../copilot-render.js';
export class ChatPage extends ChatSession {
@@ -105,17 +106,17 @@ export class ChatPage extends ChatSession {
<div class="mobile-section-header">
<span class="mobile-section-title">
${this._inProject ? html`
<button class="chat-page-back" title="Back to General"
<button class="chat-page-back" title=${t('chat.mobile.back_general')}
@click=${() => this._exitProject()}>
<i class="bi bi-chevron-left"></i>
</button>
<i class="bi bi-folder2-open"></i> ${this.label || 'Project'}
` : html`<i class="bi bi-chat-dots-fill"></i> Chat`}
<i class="bi bi-folder2-open"></i> ${this.label || t('chat.mobile.project')}
` : html`<i class="bi bi-chat-dots-fill"></i> ${t('chat.mobile.chat')}`}
</span>
<div class="chat-page-header-actions">
<button
class="btn btn-sm btn-outline-secondary"
title="New conversation"
title=${t('chat.new_session')}
@click=${() => this._startNewSession()}
><i class="bi bi-trash"></i></button>
</div>
@@ -125,14 +126,14 @@ export class ChatPage extends ChatSession {
${this._messages.length === 0 ? html`
<div class="chat-page-empty">
<i class="bi bi-stars"></i>
<p>Ask me anything</p>
<p>${t('chat.mobile.ask')}</p>
</div>
` : this._messages.map(m => renderMsg(this, m))}
${this._waiting ? html`
<div class="copilot-msg assistant copilot-thinking">
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Thinking…
${t('chat.thinking')}
</div>
` : nothing}
</div>
@@ -152,7 +153,7 @@ export class ChatPage extends ChatSession {
<textarea
class="chat-page-textarea"
rows="1"
placeholder="Type a message…"
placeholder=${t('chat.mobile.placeholder')}
@input=${(e) => this._autoResize(e.target)}
@paste=${(e) => this._onPaste(e)}
></textarea>
@@ -160,7 +161,7 @@ export class ChatPage extends ChatSession {
<div class="chat-page-toolbar-left">
<button
class="btn btn-sm btn-outline-secondary chat-page-attach-btn"
title="Attach files"
title=${t('chat.attach')}
@click=${() => this.querySelector('.chat-page-file-input')?.click()}
><i class="bi bi-paperclip"></i></button>
${this._providers.length > 1 ? html`
@@ -179,7 +180,7 @@ export class ChatPage extends ChatSession {
${this._hasTranscribe ? html`
<button
class="chat-page-mic-btn ${this._recording ? 'chat-page-mic-btn--recording' : ''}"
title="${this._recording ? 'Stop recording' : 'Record voice'}"
title=${this._recording ? t('chat.mobile.stop_record') : t('chat.mobile.record_voice')}
@click=${() => this._toggleRecording()}
>
<i class="bi ${this._recording ? 'bi-stop-circle-fill' : 'bi-mic-fill'}"></i>
@@ -189,13 +190,13 @@ export class ChatPage extends ChatSession {
? html`<button
class="chat-page-send chat-page-send--stop"
@click=${() => this._cancel()}
title="Stop"
title=${t('chat.stop')}
><i class="bi bi-stop-fill"></i></button>`
: nothing}
<button
class="chat-page-send"
@click=${() => this._send()}
title="Send"
title=${t('chat.send')}
><i class="bi bi-send-fill"></i></button>
</div>
</div>
+19 -6
View File
@@ -1,3 +1,5 @@
import { t } from '../../lib/i18n.js';
// Shared vocabulary for the Connectors list and a connector's own page.
//
// Both surfaces have to answer "what state is this connector in?" and both draw the
@@ -13,14 +15,25 @@ export function connectorIconUrl(name, size = 'sm') {
/// How each status reads on a chip. `tone` maps to the `connector-chip--*` accents
/// in `web/css/connectors.css`.
export const STATUS_LABEL = {
active: { text: 'active', tone: 'ok' },
pending: { text: 'needs fix', tone: 'script' },
needs_login: { text: 'needs sign-in', tone: 'script' },
enabled: { text: 'enabled', tone: 'scope' },
off: { text: 'off', tone: '' },
available: { text: 'available', tone: '' },
active: { tone: 'ok' },
pending: { tone: 'script' },
needs_login: { tone: 'script' },
enabled: { tone: 'scope' },
off: { tone: '' },
available: { tone: '' },
};
export function statusText(status) {
return {
active: t('connectors.status.active'),
pending: t('connectors.status.needs_fix'),
needs_login: t('connectors.status.needs_signin'),
enabled: t('connectors.status.enabled'),
off: t('connectors.status.off'),
available: t('connectors.status.available'),
}[status] ?? status;
}
/// The one place that decides what a connector's state *is*, from whichever runtime
/// rows exist for it.
///
+4 -3
View File
@@ -2,6 +2,7 @@ import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement, renderMarkdown } from '../../lib/base.js';
import { fileWatcher } from '../../lib/file-watcher.js';
import { t } from '../../lib/i18n.js';
/**
* Shared file-viewer engine. Holds all of the fetch / kind-detection /
@@ -346,7 +347,7 @@ export class FileViewerBase extends LightElement {
const showingSource = this._htmlMode === 'source';
return html`<button
class=${btnClass}
title=${showingSource ? 'Show preview' : 'Show source'}
title=${showingSource ? t('fv.mode_preview') : t('fv.mode_source')}
@click=${() => this._toggleHtmlMode()}>
<i class="bi ${showingSource ? 'bi-eye' : 'bi-code-slash'}"></i>
</button>`;
@@ -389,7 +390,7 @@ export class FileViewerBase extends LightElement {
if (this._kind === 'binary') {
return html`<div class="fv-state text-muted">
<i class="bi bi-file-earmark-binary fs-3 d-block mb-2"></i>
Preview not available for this file type.
${t('fv.binary_unavailable')}
</div>`;
}
if (this._kind === 'html') {
@@ -417,7 +418,7 @@ export class FileViewerBase extends LightElement {
return html`
${this._compileError
? html`<details class="fv-compile-error">
<summary><i class="bi bi-exclamation-triangle text-warning"></i>&nbsp;LaTeX compilation failed — showing source instead</summary>
<summary><i class="bi bi-exclamation-triangle text-warning"></i>&nbsp;${t('fv.latex_failed')}</summary>
<pre>${this._compileError}</pre>
</details>`
: nothing}
+3 -2
View File
@@ -1,4 +1,5 @@
import { html, nothing } from 'lit';
import { t } from '../../lib/i18n.js';
import { FileViewerBase } from './file-viewer-base.js';
/**
@@ -43,14 +44,14 @@ export class MobileFileViewerPage extends FileViewerBase {
<div class="mobile-file-viewer">
<div class="mobile-section-header">
<span class="mobile-section-title">
<button class="chat-page-back" title="Back" @click=${() => this._back()}>
<button class="chat-page-back" title=${t('fv.back')} @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i>
</button>
<span class="fv-mobile-name" title=${this.path ?? ''}><bdi>${this._basename()}</bdi></span>
</span>
<span class="fv-header-actions">
${this._renderModeToggle('chat-page-back')}
<button class="chat-page-back" title="Download" @click=${() => this._download()}>
<button class="chat-page-back" title=${t('fv.download')} @click=${() => this._download()}>
<i class="bi bi-download"></i>
</button>
</span>
+44 -24
View File
@@ -1,8 +1,9 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t, I18nMixin } from '../lib/i18n.js';
export class AppSidebar extends LightElement {
export class AppSidebar extends I18nMixin(LightElement) {
static properties = {
_activePage: { state: true },
_tasksSection: { state: true },
@@ -120,7 +121,7 @@ export class AppSidebar extends LightElement {
const match = hash.match(/^([^/?]+)/);
const segment = match ? match[1] : '';
// `connector` (singular) is the per-connector detail page, `connectors` the list.
return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home';
return ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home';
}
_tasksSectionFromHash() {
@@ -183,7 +184,7 @@ export class AppSidebar extends LightElement {
class="sidebar-link ${active ? 'active' : ''}"
@click=${(e) => this._openTaskManager(e)}>
<i class="bi bi-lightning-charge"></i>
<span class="sidebar-link-name">Task Manager</span>
<span class="sidebar-link-name">${t('nav.tasks')}</span>
<i class="bi bi-chevron-${active ? 'up' : 'down'} sidebar-link-chevron"></i>
</a>
${active ? html`
@@ -191,22 +192,22 @@ export class AppSidebar extends LightElement {
<a href="#tasks/running"
class="sidebar-sublink ${sec === 'running' ? 'active' : ''}"
@click=${(e) => this._navigateTasksSection('running', e)}>
<i class="bi bi-activity"></i> Running Tasks
<i class="bi bi-activity"></i> ${t('nav.tasks.running')}
</a>
<a href="#tasks/cron"
class="sidebar-sublink ${sec === 'cron' ? 'active' : ''}"
@click=${(e) => this._navigateTasksSection('cron', e)}>
<i class="bi bi-repeat"></i> Cron Jobs
<i class="bi bi-repeat"></i> ${t('nav.tasks.cron')}
</a>
<a href="#tasks/scheduled"
class="sidebar-sublink ${sec === 'scheduled' ? 'active' : ''}"
@click=${(e) => this._navigateTasksSection('scheduled', e)}>
<i class="bi bi-clock"></i> Scheduled Tasks
<i class="bi bi-clock"></i> ${t('nav.tasks.scheduled')}
</a>
<a href="#tasks/history"
class="sidebar-sublink ${sec === 'history' ? 'active' : ''}"
@click=${(e) => this._navigateTasksSection('history', e)}>
<i class="bi bi-journal-text"></i> History
<i class="bi bi-journal-text"></i> ${t('nav.tasks.history')}
</a>
</div>
` : nothing}
@@ -223,7 +224,7 @@ export class AppSidebar extends LightElement {
<i class="bi bi-folder2" style="font-size:0.78rem;opacity:0.65;flex-shrink:0"></i>
<span class="sidebar-project-name">${p.name}</span>
<button class="sidebar-project-chat-btn"
title="Open chat"
title=${t('topbar.open_chat')}
@click=${(e) => this._openProjectChat(p.id, p.name, e)}>
<i class="bi bi-chat-dots"></i>
</button>
@@ -234,10 +235,14 @@ export class AppSidebar extends LightElement {
}
render() {
// Simplified interface (role attrs `ui_mode: "simple"`): chat + inbox only.
// Hiding links is not access control — every route stays capability-gated
// server-side; this only shapes the navigation for less technical members.
const simple = this._me?.ui_mode === 'simple';
return html`
<div class="sidebar-brand">
<img src="/assets/icons/icon-1024.png" alt="" class="sidebar-brand-icon" />
<span>Skald</span>
<span>${t('topbar.brand')}</span>
</div>
<hr class="sidebar-divider" />
@@ -245,26 +250,34 @@ export class AppSidebar extends LightElement {
<nav class="sidebar-nav">
<a href="#" class="sidebar-link ${this._activePage === 'home' ? 'active' : ''}"
@click=${(e) => this._togglePage('home', e)}>
<i class="bi bi-house-door"></i>
<span class="sidebar-link-name">Home</span>
<i class="bi bi-chat-dots"></i>
<span class="sidebar-link-name">${t('nav.chat')}</span>
</a>
<a href="#inbox" class="sidebar-link ${this._activePage === 'inbox' ? 'active' : ''}"
@click=${(e) => this._togglePage('inbox', e)}>
<i class="bi bi-inbox"></i>
<span class="sidebar-link-name">
Inbox
${t('nav.inbox')}
${this._inboxCount > 0
? html`<span class="badge bg-danger ms-1" style="font-size:0.65rem">${this._inboxCount}</span>`
: ''}
</span>
</a>
${simple ? nothing : html`
<a href="#dashboard"
class="sidebar-link ${this._activePage === 'dashboard' ? 'active' : ''}"
@click=${(e) => this._togglePage('dashboard', e)}>
<i class="bi bi-speedometer2"></i>
<span class="sidebar-link-name">${t('nav.dashboard')}</span>
</a>
<a href="#projects"
class="sidebar-link ${this._activePage === 'projects' ? 'active' : ''}"
@click=${(e) => this._togglePage('projects', e)}>
<i class="bi bi-kanban"></i>
<span class="sidebar-link-name">Projects</span>
<span class="sidebar-link-name">${t('nav.projects')}</span>
</a>
${this._renderRecentProjects()}
@@ -273,48 +286,54 @@ export class AppSidebar extends LightElement {
<a href="#" class="sidebar-link ${this._activePage === 'models' ? 'active' : ''}"
@click=${(e) => this._togglePage('models', e)}>
<i class="bi bi-cpu"></i>
<span class="sidebar-link-name">Models</span>
<span class="sidebar-link-name">${t('nav.models')}</span>
</a>
<a href="#" class="sidebar-link ${this._activePage === 'providers' ? 'active' : ''}"
@click=${(e) => this._togglePage('providers', e)}>
<i class="bi bi-plug"></i>
<span class="sidebar-link-name">Providers</span>
<span class="sidebar-link-name">${t('nav.providers')}</span>
</a>
<a href="#" class="sidebar-link ${this._activePage === 'approval' ? 'active' : ''}"
@click=${(e) => this._togglePage('approval', e)}>
<i class="bi bi-shield-check"></i>
<span class="sidebar-link-name">Security</span>
<span class="sidebar-link-name">${t('nav.security')}</span>
</a>
<a href="#" class="sidebar-link ${this._activePage === 'agents' ? 'active' : ''}"
@click=${(e) => this._togglePage('agents', e)}>
<i class="bi bi-people"></i>
<span class="sidebar-link-name">Agents</span>
<span class="sidebar-link-name">${t('nav.agents')}</span>
</a>
<a href="#" class="sidebar-link ${this._activePage === 'users' ? 'active' : ''}"
@click=${(e) => this._togglePage('users', e)}>
<i class="bi bi-person-badge"></i>
<span class="sidebar-link-name">Users</span>
<span class="sidebar-link-name">${t('nav.users')}</span>
</a>
<a href="#" class="sidebar-link ${this._activePage === 'roles' ? 'active' : ''}"
@click=${(e) => this._togglePage('roles', e)}>
<i class="bi bi-tags"></i>
<span class="sidebar-link-name">Roles</span>
<span class="sidebar-link-name">${t('nav.roles')}</span>
</a>
${this._me?.role_id === 'admin' ? html`
<a href="#" class="sidebar-link ${this._activePage === 'shared-folders' ? 'active' : ''}"
@click=${(e) => this._togglePage('shared-folders', e)}>
<i class="bi bi-folder-symlink"></i>
<span class="sidebar-link-name">${t('nav.shared_folders')}</span>
</a>` : nothing}
<a href="#" class="sidebar-link ${this._activePage === 'connectors' || this._activePage === 'connector' ? 'active' : ''}"
@click=${(e) => this._togglePage('connectors', e)}>
<i class="bi bi-plug"></i>
<span class="sidebar-link-name">Connectors</span>
<span class="sidebar-link-name">${t('nav.connectors')}</span>
</a>
${this._me?.role_id === 'admin' ? html`
<a href="#" class="sidebar-link ${this._activePage === 'catalog' || this._activePage === 'marketplace' ? 'active' : ''}"
@click=${(e) => this._togglePage('catalog', e)}>
<i class="bi bi-journal-text"></i>
<span class="sidebar-link-name">Catalog</span>
<span class="sidebar-link-name">${t('nav.catalog')}</span>
</a>` : nothing}
<a href="#" class="sidebar-link ${this._activePage === 'config' ? 'active' : ''}"
@click=${(e) => this._togglePage('config', e)}>
<i class="bi bi-gear"></i>
<span class="sidebar-link-name">Config</span>
<span class="sidebar-link-name">${t('nav.config')}</span>
</a>
${this._debugMode ? html`
@@ -323,15 +342,16 @@ export class AppSidebar extends LightElement {
class="sidebar-link ${this._activePage === 'llm-requests' ? 'active' : ''}"
@click=${(e) => this._togglePage('llm-requests', e)}>
<i class="bi bi-journal-code"></i>
<span class="sidebar-link-name">LLM Requests</span>
<span class="sidebar-link-name">${t('nav.llm_requests')}</span>
</a>
<a href="#tic"
class="sidebar-link ${this._activePage === 'tic' ? 'active' : ''}"
@click=${(e) => this._togglePage('tic', e)}>
<i class="bi bi-bell"></i>
<span class="sidebar-link-name">TIC Sessions</span>
<span class="sidebar-link-name">${t('nav.tic')}</span>
</a>
` : nothing}
`}
</nav>
`;
+27 -14
View File
@@ -1,7 +1,9 @@
import { html, nothing } from 'lit';
import { LightElement } from '../../lib/base.js';
import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../../lib/base.js';
import { toString as cronToString } from 'cronstrue';
import { formatDate } from './utils.js';
import { t } from '../../lib/i18n.js';
export class CronJobsSection extends LightElement {
static properties = {
@@ -15,6 +17,17 @@ export class CronJobsSection extends LightElement {
this._error = null;
}
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async load() {
this._error = null;
try {
@@ -28,7 +41,7 @@ export class CronJobsSection extends LightElement {
}
async _delete(job) {
if (!confirm(`Delete job "${job.title}"?`)) return;
if (!confirm(t('cron.confirm.delete', { title: job.title }))) return;
try {
const res = await fetch(`/api/cron/jobs/${job.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
@@ -50,10 +63,10 @@ export class CronJobsSection extends LightElement {
_statusBadge(job) {
if (job.running_session_id != null)
return html`<span class="task-badge task-badge--running">running</span>`;
return html`<span class="task-badge task-badge--running">${t('cron.badge.running')}</span>`;
if (!job.enabled)
return html`<span class="task-badge task-badge--disabled">disabled</span>`;
return html`<span class="task-badge task-badge--idle">idle</span>`;
return html`<span class="task-badge task-badge--disabled">${t('cron.badge.disabled')}</span>`;
return html`<span class="task-badge task-badge--idle">${t('cron.badge.idle')}</span>`;
}
_renderCard(job) {
@@ -64,7 +77,7 @@ export class CronJobsSection extends LightElement {
<span class="task-card-title">${job.title}</span>
${this._statusBadge(job)}
</div>
<button class="task-card-delete" title="Delete" @click=${() => this._delete(job)}>
<button class="task-card-delete" title=${t('cron.action.delete')} @click=${() => this._delete(job)}>
<i class="bi bi-trash"></i>
</button>
</div>
@@ -81,15 +94,15 @@ export class CronJobsSection extends LightElement {
<div class="task-card-meta">
<div class="task-card-meta-item">
<span class="task-card-meta-label">Agent</span>
<span class="task-card-meta-label">${t('cron.card.label_agent')}</span>
<span class="task-card-meta-value">${job.agent_id}</span>
</div>
<div class="task-card-meta-item">
<span class="task-card-meta-label">Last run</span>
<span class="task-card-meta-label">${t('cron.card.label_last_run')}</span>
<span class="task-card-meta-value">${formatDate(job.last_run_at)}</span>
</div>
<div class="task-card-meta-item">
<span class="task-card-meta-label">Next run</span>
<span class="task-card-meta-label">${t('cron.card.label_next_run')}</span>
<span class="task-card-meta-value">${formatDate(job.next_run_at)}</span>
</div>
</div>
@@ -99,7 +112,7 @@ export class CronJobsSection extends LightElement {
<input class="form-check-input" type="checkbox" role="switch"
.checked=${job.enabled}
@change=${() => this._toggle(job)} />
<span class="task-card-toggle-label">${job.enabled ? 'Enabled' : 'Disabled'}</span>
<span class="task-card-toggle-label">${job.enabled ? t('cron.card.enabled') : t('cron.card.disabled')}</span>
</div>
</div>
</div>
@@ -110,9 +123,9 @@ export class CronJobsSection extends LightElement {
return html`
<div class="task-page">
<div class="task-page-header">
<h2 class="task-page-title"><i class="bi bi-repeat"></i> Cron Jobs</h2>
<h2 class="task-page-title"><i class="bi bi-repeat"></i> ${t('cron.title')}</h2>
<div style="font-size:0.82rem;color:var(--bs-secondary-color)">
${this._jobs.length} job${this._jobs.length !== 1 ? 's' : ''}
${t(this._jobs.length === 1 ? 'cron.count_one' : 'cron.count_other', { n: this._jobs.length })}
</div>
</div>
@@ -123,7 +136,7 @@ export class CronJobsSection extends LightElement {
${this._jobs.length === 0 ? html`
<div class="task-empty">
<i class="bi bi-repeat"></i>
<p>No recurring cron jobs. Ask the agent to create one with <code>execute_task</code>.</p>
<p>${t('cron.empty.title')} ${unsafeHTML(t('cron.empty.hint'))}</p>
</div>
` : html`
<div class="task-grid">
+18 -10
View File
@@ -1,5 +1,6 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const PAGE_ID = 'tic';
const PER_PAGE = 20;
@@ -42,6 +43,8 @@ export class TicSessionsPage extends LightElement {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none';
@@ -49,6 +52,11 @@ export class TicSessionsPage extends LightElement {
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _fetch(page) {
this._loading = true;
this._error = null;
@@ -77,7 +85,7 @@ export class TicSessionsPage extends LightElement {
if (this._loading) return html`
<div class="tic-state">
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
<span>Loading…</span>
<span>${t('tic.loading')}</span>
</div>
`;
if (this._error) return html`
@@ -89,7 +97,7 @@ export class TicSessionsPage extends LightElement {
if (this._items.length === 0) return html`
<div class="tic-state">
<i class="bi bi-inbox"></i>
<span>No TIC sessions found.</span>
<span>${t('tic.empty')}</span>
</div>
`;
@@ -99,10 +107,10 @@ export class TicSessionsPage extends LightElement {
<thead>
<tr>
<th>#</th>
<th>Agent</th>
<th>Started</th>
<th class="text-end">Messages</th>
<th>Last activity</th>
<th>${t('tic.table.agent')}</th>
<th>${t('tic.table.started')}</th>
<th class="text-end">${t('tic.table.messages')}</th>
<th>${t('tic.table.last_activity')}</th>
</tr>
</thead>
<tbody>
@@ -131,7 +139,7 @@ export class TicSessionsPage extends LightElement {
@click=${() => this._fetch(cur - 1)}>
<i class="bi bi-chevron-left"></i>
</button>
<span class="tic-page-info">Page ${cur} of ${pages} &mdash; ${this._total} sessions</span>
<span class="tic-page-info">${t('tic.pagination', { cur, pages, total: this._total })}</span>
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur >= pages}
@click=${() => this._fetch(cur + 1)}>
<i class="bi bi-chevron-right"></i>
@@ -231,12 +239,12 @@ export class TicSessionsPage extends LightElement {
<div class="tic-page">
<div class="tic-header">
<h2 class="tic-title"><i class="bi bi-bell"></i> TIC Sessions</h2>
<span class="tic-total-badge">${this._total} total</span>
<h2 class="tic-title"><i class="bi bi-bell"></i> ${t('tic.title')}</h2>
<span class="tic-total-badge">${t('tic.total', { n: this._total })}</span>
<button class="btn btn-sm btn-outline-secondary tic-refresh-btn"
?disabled=${this._loading}
@click=${() => this._fetch(this._page)}>
<i class="bi bi-arrow-clockwise"></i> Refresh
<i class="bi bi-arrow-clockwise"></i> ${t('tic.refresh')}
</button>
</div>
${this._renderTable()}
+20 -7
View File
@@ -1,7 +1,15 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t, I18nMixin } from '../lib/i18n.js';
export class AppTopbar extends LightElement {
// Stable per-user avatar color: same user, same hue, everywhere.
function avatarColor(name) {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return `hsl(${h % 360}, 55%, 52%)`;
}
export class AppTopbar extends I18nMixin(LightElement) {
static properties = {
_theme: { state: true },
_copilotCollapsed: { state: true },
@@ -65,23 +73,28 @@ export class AppTopbar extends LightElement {
return name.charAt(0).toUpperCase();
}
get _avatarColor() {
const name = this._me?.username || '';
return name ? avatarColor(name) : 'var(--accent)';
}
render() {
const isDark = this._theme === 'dark';
return html`
<span class="topbar-title">Skald</span>
<span class="topbar-title">${t('topbar.brand')}</span>
<span class="topbar-spacer"></span>
${this._copilotCollapsed ? html`
<button class="topbar-copilot-btn" title="Open copilot"
<button class="topbar-copilot-btn" title=${t('topbar.open_chat')}
@click=${() => window.dispatchEvent(new CustomEvent('copilot-open'))}>
<i class="bi bi-stars"></i>
</button>
` : ''}
<button class="topbar-theme-btn" title="${isDark ? 'Switch to light mode' : 'Switch to dark mode'}"
<button class="topbar-theme-btn" title="${isDark ? t('topbar.to_light') : t('topbar.to_dark')}"
@click=${() => this._toggleTheme()}>
<i class="bi ${isDark ? 'bi-sun' : 'bi-moon-stars'}"></i>
</button>
<div class="topbar-profile-wrapper">
<button class="topbar-avatar" title="Account" @click=${(e) => this._toggleMenu(e)}>
<button class="topbar-avatar" style="background:${this._avatarColor}" title=${t('topbar.account')} @click=${(e) => this._toggleMenu(e)}>
${this._initial}
</button>
${this._menuOpen ? html`
@@ -91,10 +104,10 @@ export class AppTopbar extends LightElement {
<div class="topbar-dropdown-sub">@${this._me?.username || ''}</div>
</div>
<button class="topbar-dropdown-item" @click=${() => this._goProfile()}>
<i class="bi bi-person"></i> Profile
<i class="bi bi-person"></i> ${t('topbar.profile')}
</button>
<button class="topbar-dropdown-item topbar-dropdown-logout" @click=${() => this._logout()}>
<i class="bi bi-box-arrow-right"></i> Logout
<i class="bi bi-box-arrow-right"></i> ${t('topbar.logout')}
</button>
</div>
` : nothing}
+49 -43
View File
@@ -1,5 +1,7 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
export class UsersPage extends LightElement {
@@ -24,6 +26,8 @@ export class UsersPage extends LightElement {
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'users';
this.style.display = this._open ? 'flex' : 'none';
@@ -31,6 +35,11 @@ export class UsersPage extends LightElement {
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _load() {
this._error = null;
try {
@@ -81,7 +90,7 @@ export class UsersPage extends LightElement {
this._error = null;
if (mode === 'create') {
if (!form.username.trim() || !form.password) { this._error = 'Username and password are required.'; return; }
if (!form.username.trim() || !form.password) { this._error = t('users.error.required_username_pw'); return; }
try {
const res = await fetch('/api/users', {
method: 'POST',
@@ -100,7 +109,7 @@ export class UsersPage extends LightElement {
} catch (e) { this._error = e.message; }
} else if (mode === 'edit') {
const { user } = this._modal;
if (!form.username.trim()) { this._error = 'Username is required.'; return; }
if (!form.username.trim()) { this._error = t('users.error.required_username'); return; }
try {
const res = await fetch(`/api/users/${user.id}`, {
method: 'PUT',
@@ -118,7 +127,7 @@ export class UsersPage extends LightElement {
} catch (e) { this._error = e.message; }
} else if (mode === 'password') {
const { user } = this._modal;
if (!form.password) { this._error = 'Password must not be empty.'; return; }
if (!form.password) { this._error = t('users.error.password_empty'); return; }
try {
const res = await fetch(`/api/users/${user.id}/password`, {
method: 'POST',
@@ -132,7 +141,7 @@ export class UsersPage extends LightElement {
}
async _delete(user) {
if (!confirm(`Delete user "${user.username}"? This permanently erases their database and all conversation history.`)) return;
if (!confirm(t('users.confirm.delete', { username: user.username }))) return;
try {
const res = await fetch(`/api/users/${user.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
@@ -149,9 +158,9 @@ export class UsersPage extends LightElement {
_renderModal() {
if (!this._modal) return nothing;
const { mode, form, user } = this._modal;
const title = mode === 'create' ? 'New user'
: mode === 'edit' ? `Edit ${user.username}`
: `Reset password — ${user.username}`;
const title = mode === 'create' ? t('users.modal.create_title')
: mode === 'edit' ? t('users.modal.edit_title', { username: user.username })
: t('users.modal.reset_title', { username: user.username });
return html`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
@@ -166,45 +175,43 @@ export class UsersPage extends LightElement {
${mode === 'create' ? html`
<div class="mb-3">
<label class="form-label">Username</label>
<label class="form-label">${t('users.modal.username')}</label>
<input class="form-control" .value=${form.username} @input=${e => this._patch('username', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label">Display name <span class="text-muted">(optional)</span></label>
<label class="form-label">${t('users.modal.display_name')} <span class="text-muted">${t('users.modal.optional')}</span></label>
<input class="form-control" .value=${form.display_name} @input=${e => this._patch('display_name', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label">Role</label>
<label class="form-label">${t('users.modal.role')}</label>
<select class="form-select" @change=${e => this._patch('role_id', e.target.value)}>
${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${form.role_id === r.id}>${r.label}</option>`)}
</select>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<label class="form-label">${t('users.modal.password')}</label>
<input type="password" class="form-control" .value=${form.password} @input=${e => this._patch('password', e.target.value)} />
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="um-enc"
.checked=${form.encrypted}
@change=${e => this._patch('encrypted', e.target.checked)} />
<label class="form-check-label" for="um-enc">Encrypt conversation history</label>
<label class="form-check-label" for="um-enc">${t('users.modal.encrypt')}</label>
</div>
${form.encrypted ? html`
<div class="setup-warn mt-2">
<strong>Warning:</strong> if the password is lost, the conversation history is permanently unrecoverable.
</div>
<div class="setup-warn mt-2">${unsafeHTML(t('users.modal.encrypt_warn'))}</div>
` : nothing}
` : mode === 'edit' ? html`
<div class="mb-3">
<label class="form-label">Username</label>
<label class="form-label">${t('users.modal.username')}</label>
<input class="form-control" .value=${form.username} @input=${e => this._patch('username', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label">Display name <span class="text-muted">(optional)</span></label>
<label class="form-label">${t('users.modal.display_name')} <span class="text-muted">${t('users.modal.optional')}</span></label>
<input class="form-control" .value=${form.display_name} @input=${e => this._patch('display_name', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label">Role</label>
<label class="form-label">${t('users.modal.role')}</label>
<select class="form-select" @change=${e => this._patch('role_id', e.target.value)}>
${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${form.role_id === r.id}>${r.label}</option>`)}
</select>
@@ -213,23 +220,22 @@ export class UsersPage extends LightElement {
<input class="form-check-input" type="checkbox" id="um-active"
.checked=${form.active}
@change=${e => this._patch('active', e.target.checked)} />
<label class="form-check-label" for="um-active">Active</label>
<label class="form-check-label" for="um-active">${t('users.modal.active')}</label>
</div>
` : html`
<div class="alert alert-warning py-2 mb-3" style="font-size:.82rem">
<i class="bi bi-exclamation-triangle me-1"></i>
Only works for cleartext (non-encrypted) users.
<i class="bi bi-exclamation-triangle me-1"></i>${t('users.modal.only_cleartext')}
</div>
<div class="mb-3">
<label class="form-label">New password</label>
<label class="form-label">${t('users.modal.new_password')}</label>
<input type="password" class="form-control" .value=${form.password} @input=${e => this._patch('password', e.target.value)} />
</div>
`}
</div>
<div class="um-modal-footer">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>Cancel</button>
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>${t('users.modal.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._save()}>
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? 'Create' : mode === 'edit' ? 'Save' : 'Reset'}
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? t('users.modal.create_btn') : mode === 'edit' ? t('users.modal.save_btn') : t('users.modal.reset_btn')}
</button>
</div>
</div>
@@ -245,11 +251,11 @@ export class UsersPage extends LightElement {
return html`
<div class="um-page">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-people-fill me-2"></i>Users</h2>
<h2 class="um-title"><i class="bi bi-people-fill me-2"></i>${t('users.title')}</h2>
<div class="um-header-right">
<span class="um-header-count">${users.length} user${users.length === 1 ? '' : 's'}</span>
<span class="um-header-count">${t(users.length === 1 ? 'users.count_one' : 'users.count_other', { n: users.length })}</span>
<button class="btn btn-sm btn-primary" @click=${() => this._openCreate()}>
<i class="bi bi-plus-lg me-1"></i>New user
<i class="bi bi-plus-lg me-1"></i>${t('users.btn.new')}
</button>
</div>
</div>
@@ -259,17 +265,17 @@ export class UsersPage extends LightElement {
` : nothing}
<div class="um-table-wrap">
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : users.length === 0 ? html`
<div class="um-empty"><i class="bi bi-people"></i><p>No users.</p></div>
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('users.loading')}</div>` : users.length === 0 ? html`
<div class="um-empty"><i class="bi bi-people"></i><p>${t('users.empty')}</p></div>
` : html`
<table class="um-table">
<thead>
<tr>
<th>Username</th>
<th>Display name</th>
<th>Role</th>
<th>DB</th>
<th>Status</th>
<th>${t('users.table.username')}</th>
<th>${t('users.table.display_name')}</th>
<th>${t('users.table.role')}</th>
<th>${t('users.table.db')}</th>
<th>${t('users.table.status')}</th>
<th></th>
</tr>
</thead>
@@ -280,20 +286,20 @@ export class UsersPage extends LightElement {
<td>${u.display_name ?? '—'}</td>
<td>${this._roleLabel(u.role_id)}</td>
<td>${u.encrypted
? html`<span class="um-badge um-badge-encrypted">Encrypted</span>`
: html`<span class="um-badge um-badge-clear">Cleartext</span>`}</td>
? html`<span class="um-badge um-badge-encrypted">${t('users.badge.encrypted')}</span>`
: html`<span class="um-badge um-badge-clear">${t('users.badge.cleartext')}</span>`}</td>
<td>${u.active
? html`<span class="um-badge um-badge-active">Active</span>`
: html`<span class="um-badge um-badge-inactive">Inactive</span>`}</td>
? html`<span class="um-badge um-badge-active">${t('users.badge.active')}</span>`
: html`<span class="um-badge um-badge-inactive">${t('users.badge.inactive')}</span>`}</td>
<td>
<div class="um-actions">
<button class="um-btn-icon" title="Reset password" @click=${() => this._openPassword(u)}>
<button class="um-btn-icon" title=${t('users.action.reset_pw')} @click=${() => this._openPassword(u)}>
<i class="bi bi-key"></i>
</button>
<button class="um-btn-icon" title="Edit" @click=${() => this._openEdit(u)}>
<button class="um-btn-icon" title=${t('users.action.edit')} @click=${() => this._openEdit(u)}>
<i class="bi bi-pencil"></i>
</button>
<button class="um-btn-icon" title="Delete" @click=${() => this._delete(u)}>
<button class="um-btn-icon" title=${t('users.action.delete')} @click=${() => this._delete(u)}>
<i class="bi bi-trash"></i>
</button>
</div>
+18 -18
View File
@@ -17,8 +17,8 @@
}
.copilot-composer:focus-within {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12);
}
.copilot-textarea {
@@ -26,9 +26,9 @@
border: none;
outline: none;
background: transparent;
font-size: 0.85rem;
line-height: 1.55;
padding: 0.6rem 0.75rem 0.4rem;
font-size: 0.95rem;
line-height: 1.6;
padding: 0.65rem 0.85rem 0.45rem;
min-height: 2.6rem;
max-height: 14rem;
overflow-y: auto;
@@ -59,11 +59,11 @@
display: flex;
align-items: center;
justify-content: center;
width: 1.8rem;
height: 1.8rem;
width: 2rem;
height: 2rem;
padding: 0;
border: none;
border-radius: 0.4rem;
border-radius: 0.5rem;
background: transparent;
color: var(--placeholder-color);
font-size: 0.85rem;
@@ -99,14 +99,14 @@
.copilot-model-pill:hover,
.copilot-model-pill.open {
background: rgba(99, 102, 241, 0.08);
border-color: rgba(99, 102, 241, 0.3);
color: #6366f1;
background: rgba(var(--accent-rgb), 0.08);
border-color: rgba(var(--accent-rgb), 0.3);
color: var(--accent);
}
.copilot-model-pill i:first-child {
font-size: 0.75rem;
color: #6366f1;
color: var(--accent);
}
.copilot-model-overlay {
@@ -141,8 +141,8 @@
transition: background 0.1s;
}
.copilot-model-item:hover { background: rgba(99, 102, 241, 0.07); }
.copilot-model-item.active { color: #6366f1; font-weight: 600; }
.copilot-model-item:hover { background: rgba(var(--accent-rgb), 0.07); }
.copilot-model-item.active { color: var(--accent); font-weight: 600; }
/* ── Slash-command autocomplete ─────────────────────────────────────────────── */
@@ -177,9 +177,9 @@
}
.copilot-cmd-item:hover,
.copilot-cmd-item.active { background: rgba(99, 102, 241, 0.1); }
.copilot-cmd-item.active { background: rgba(var(--accent-rgb), 0.1); }
.copilot-cmd-name { font-weight: 600; color: #6366f1; white-space: nowrap; }
.copilot-cmd-name { font-weight: 600; color: var(--accent); white-space: nowrap; }
.copilot-cmd-desc {
color: var(--text-muted, #888);
@@ -199,7 +199,7 @@
padding: 0;
border: none;
border-radius: 0.45rem;
background: #6366f1;
background: var(--accent);
color: #fff;
font-size: 0.8rem;
cursor: pointer;
@@ -207,7 +207,7 @@
flex-shrink: 0;
}
.copilot-send-btn:hover { background: #4f46e5; }
.copilot-send-btn:hover { background: var(--accent-hover); }
.copilot-send-btn--stop { background: #dc2626; }
.copilot-send-btn--stop:hover { background: #b91c1c; }
.copilot-send-btn--recording { background: #dc2626; animation: copilot-pulse 1s ease-in-out infinite; }
+20 -20
View File
@@ -3,19 +3,19 @@
.copilot-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
gap: 0.7rem;
}
/* ── Message bubbles ───────────────────────────────────────────────────────── */
.copilot-msg {
padding: 0.6rem 0.9rem;
border-radius: 0.75rem;
font-size: 0.85rem;
line-height: 1.55;
padding: 0.65rem 1rem;
border-radius: 1rem;
font-size: 0.95rem;
line-height: 1.6;
max-width: 88%;
}
@@ -159,17 +159,17 @@
.copilot-tool-path {
font-family: var(--bs-font-monospace);
font-size: inherit;
color: #6366f1;
color: var(--accent);
cursor: pointer;
border-radius: 0.2rem;
padding: 0 0.25em;
background: rgba(99,102,241,0.10);
background: rgba(var(--accent-rgb), 0.10);
text-decoration: none;
}
.copilot-tool-path:hover {
text-decoration: underline;
background: rgba(99,102,241,0.18);
background: rgba(var(--accent-rgb), 0.18);
}
.copilot-tool-body {
@@ -285,7 +285,7 @@
.copilot-markdown pre code { background: none; padding: 0; font-size: inherit; }
.copilot-markdown blockquote {
border-left: 3px solid #6366f1;
border-left: 3px solid var(--accent);
margin: 0.5rem 0;
padding: 0.3rem 0.75rem;
color: var(--placeholder-color);
@@ -298,7 +298,7 @@
margin: 0.6rem 0;
}
.copilot-markdown a { color: #6366f1; text-decoration: underline; }
.copilot-markdown a { color: var(--accent); text-decoration: underline; }
.copilot-markdown strong { font-weight: 700; }
.copilot-markdown em { font-style: italic; }
@@ -343,7 +343,7 @@
.copilot-approval-path {
font-family: var(--bs-font-monospace);
font-size: 0.75rem;
color: #6366f1;
color: var(--accent);
}
.copilot-approval-actions {
@@ -450,7 +450,7 @@
.copilot-agent,
.copilot-agent-end {
border: 1px solid rgba(99, 102, 241, 0.2);
border: 1px solid rgba(var(--accent-rgb), 0.2);
border-radius: 0.5rem;
font-size: 0.78rem;
overflow: clip;
@@ -462,18 +462,18 @@
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.65rem;
background: rgba(99, 102, 241, 0.12);
background: rgba(var(--accent-rgb), 0.12);
color: var(--msg-assistant-text);
}
.copilot-agent-header i {
font-size: 0.85rem;
flex-shrink: 0;
color: #6366f1;
color: var(--accent);
}
.copilot-agent-header strong {
color: #6366f1;
color: var(--accent);
font-weight: 600;
}
@@ -505,10 +505,10 @@
margin: 0;
padding: 0.45rem 0.65rem;
color: var(--placeholder-color);
border-top: 1px solid rgba(99, 102, 241, 0.15);
border-top: 1px solid rgba(var(--accent-rgb), 0.15);
max-height: 120px;
overflow-y: auto;
background: rgba(99, 102, 241, 0.05);
background: rgba(var(--accent-rgb), 0.05);
}
.copilot-agent-preview--result {
@@ -519,7 +519,7 @@
@media (prefers-color-scheme: dark) {
.copilot-agent-badge.running { background: rgba(234, 179, 8, 0.2); color: #fbbf24; }
.copilot-agent-badge.done { background: rgba(22, 163, 74, 0.18); color: #4ade80; }
.copilot-agent-header { background: rgba(99, 102, 241, 0.1); }
.copilot-agent-header { background: rgba(var(--accent-rgb), 0.1); }
}
/* ── Attachment chips (composer pending + sent user bubble) ──────────────────── */
@@ -550,7 +550,7 @@
}
.attach-chip--clickable { cursor: pointer; }
.attach-chip--clickable:hover { border-color: #6366f1; }
.attach-chip--clickable:hover { border-color: var(--accent); }
.attach-chip--uploading { opacity: 0.7; }
.attach-chip .bi { font-size: 0.85rem; flex-shrink: 0; }
+118 -5
View File
@@ -11,13 +11,126 @@ app-copilot {
flex-shrink: 0;
}
app-copilot.collapsed {
app-copilot.collapsed:not([mode="full"]) {
width: 0;
min-width: 0;
border: none;
overflow: hidden;
}
/* ── Full mode (home route): the chat fills the workspace ──────────────────── */
app-copilot[mode="full"] {
flex: 1;
width: auto;
min-width: 0;
border-left: none;
}
/* Center the conversation in a readable column on wide screens. */
app-copilot[mode="full"] .copilot-header,
app-copilot[mode="full"] .copilot-tabs,
app-copilot[mode="full"] .copilot-messages,
app-copilot[mode="full"] .copilot-input-area {
padding-left: max(1.25rem, calc((100% - 860px) / 2));
padding-right: max(1.25rem, calc((100% - 860px) / 2));
}
app-copilot[mode="full"] .copilot-header {
font-size: 1rem;
padding-top: 0.9rem;
padding-bottom: 0.9rem;
}
app-copilot[mode="full"] .copilot-msg {
max-width: 80%;
}
/* ── Welcome hero (empty state, full mode) ─────────────────────────────────── */
.chat-hero {
margin: auto;
text-align: center;
max-width: 560px;
padding: 2rem 1rem;
}
.chat-hero-logo {
width: 88px;
height: 88px;
border-radius: 24px;
box-shadow: var(--card-shadow);
}
.chat-hero-title {
font-size: 1.6rem;
font-weight: 700;
margin: 1.1rem 0 0.3rem;
}
.chat-hero-sub {
color: var(--placeholder-color);
font-size: 1rem;
margin: 0 0 1.75rem;
}
.chat-suggestions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
text-align: left;
}
.chat-suggestion {
display: flex;
align-items: center;
gap: 0.6rem;
border: 1px solid var(--card-border);
background: var(--card-bg);
color: var(--msg-assistant-text);
border-radius: var(--radius-md);
padding: 0.75rem 0.95rem;
font-size: 0.92rem;
cursor: pointer;
transition: border-color 0.15s, transform 0.12s, box-shadow 0.15s;
}
.chat-suggestion i {
color: var(--accent);
font-size: 1rem;
flex-shrink: 0;
}
.chat-suggestion:hover {
border-color: var(--accent);
transform: translateY(-1px);
box-shadow: var(--card-shadow);
}
@media (max-width: 560px) {
.chat-suggestions { grid-template-columns: 1fr; }
}
/* ── Privacy chip (chat header) ────────────────────────────────────────────── */
.chat-privacy {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-left: 0.5rem;
font-size: 0.72rem;
font-weight: 600;
padding: 0.18rem 0.6rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
cursor: default;
}
.chat-privacy i {
font-size: 0.68rem;
}
.copilot-resize-handle {
position: absolute;
left: 0;
@@ -31,7 +144,7 @@ app-copilot.collapsed {
.copilot-resize-handle:hover,
.copilot-resize-handle:active {
background: rgba(99, 102, 241, 0.35);
background: rgba(var(--accent-rgb), 0.35);
}
.copilot-expand-btn {
@@ -43,7 +156,7 @@ app-copilot.collapsed {
background: transparent;
border: none;
cursor: pointer;
color: #6366f1;
color: var(--accent);
font-size: 1.1rem;
writing-mode: vertical-rl;
padding: 1rem 0;
@@ -72,7 +185,7 @@ app-copilot.collapsed {
.copilot-header i {
font-size: 1rem;
color: #6366f1;
color: var(--accent);
}
/* ── Tabs (General + project chats) ─────────────────────────────────────────── */
@@ -105,7 +218,7 @@ app-copilot.collapsed {
.copilot-tab--active {
color: var(--bs-body-color, inherit);
border-bottom-color: #6366f1;
border-bottom-color: var(--accent);
font-weight: 600;
}
+1 -1
View File
@@ -102,7 +102,7 @@
}
.fv-md hr { border: none; border-top: 1px solid var(--bs-border-color); margin: 1.25rem 0; }
.fv-md a { color: #6366f1; text-decoration: underline; }
.fv-md a { color: var(--accent); text-decoration: underline; }
.fv-md strong { font-weight: 700; }
.fv-md em { font-style: italic; }
+2 -30
View File
@@ -1,6 +1,6 @@
/* ── Home page ─────────────────────────────────────────────────────────────── */
/* ── Dashboard page ────────────────────────────────────────────────────────── */
home-page {
dashboard-page {
display: none;
flex-direction: column;
flex: 1;
@@ -13,34 +13,6 @@ home-page {
box-sizing: border-box;
}
/* ── Debug toggle ──────────────────────────────────────────────────────────── */
.home-debug-bar {
display: flex;
justify-content: flex-end;
margin-bottom: 0.75rem;
}
.home-debug-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
font-weight: 500;
color: var(--bs-secondary-color);
cursor: pointer;
user-select: none;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.15s;
}
.home-debug-toggle:hover {
background: var(--bs-tertiary-bg);
}
.home-debug-toggle i { font-size: 0.82rem; }
/* ── Hero ──────────────────────────────────────────────────────────────────── */
.home-hero {
+4 -4
View File
@@ -371,7 +371,7 @@ mobile-app[data-native] .chat-page-input-area { padding-bottom: 10px; }
.chat-page-composer:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12);
}
.chat-page-textarea {
@@ -428,7 +428,7 @@ mobile-app[data-native] .chat-page-input-area { padding-bottom: 10px; }
.chat-page-model-pill:focus,
.chat-page-model-pill:hover {
border-color: rgba(99, 102, 241, 0.3);
border-color: rgba(var(--accent-rgb), 0.3);
}
/* ── Mic + send buttons ────────────────────────────────────────────────────── */
@@ -559,8 +559,8 @@ mobile-app[data-native] .chat-page-input-area { padding-bottom: 10px; }
justify-content: center;
font-size: 1.3rem;
color: #fff;
background: linear-gradient(135deg, var(--accent, #6366f1), var(--accent-hover, #4f46e5));
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.35);
background: linear-gradient(135deg, var(--accent, var(--accent)), var(--accent-hover, var(--accent-hover)));
box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.35);
}
.project-card-main {
+2 -2
View File
@@ -164,7 +164,7 @@
padding: 0.15em 0.5em;
border-radius: 4px;
background: var(--bs-primary-bg-subtle, #eef2ff);
color: var(--bs-primary, #4f46e5);
color: var(--bs-primary, var(--accent-hover));
flex-shrink: 0;
white-space: nowrap;
}
@@ -261,7 +261,7 @@
}
.llm-params-pill {
background: #6366f1 !important;
background: var(--accent) !important;
color: #fff !important;
}
+1
View File
@@ -73,6 +73,7 @@ file-viewer-page {
users-page,
roles-page,
shared-folders-page,
connectors-page,
connector-detail-page,
marketplace-page,
+10 -10
View File
@@ -17,8 +17,8 @@ app-sidebar {
gap: 0.55rem;
padding: 0 1rem 1.25rem;
color: var(--sidebar-brand-color);
font-size: 0.95rem;
font-weight: 600;
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.01em;
}
@@ -89,11 +89,11 @@ app-sidebar {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.20rem 0.30rem;
padding: 0.4rem 0.55rem;
color: var(--sidebar-text);
text-decoration: none;
font-size: 0.875rem;
border-radius: 0.25rem;
font-size: 0.92rem;
border-radius: 0.5rem;
transition: background 0.12s, color 0.12s;
}
@@ -119,8 +119,8 @@ app-sidebar {
.sidebar-link.active {
background: var(--sidebar-active-bg);
color: var(--sidebar-text-active);
font-weight: 500;
border-radius: 0.25rem;
font-weight: 600;
border-radius: 0.5rem;
}
.sidebar-link.active i {
@@ -183,11 +183,11 @@ app-sidebar {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.18rem 0.40rem;
padding: 0.32rem 0.55rem;
color: var(--sidebar-text);
text-decoration: none;
font-size: 0.84rem;
border-radius: 0.25rem;
font-size: 0.86rem;
border-radius: 0.5rem;
transition: background 0.12s, color 0.12s;
}
+4 -4
View File
@@ -130,13 +130,13 @@
}
.task-badge--cron {
background: rgba(99, 102, 241, 0.12);
color: var(--bs-primary, #6366f1);
background: rgba(var(--accent-rgb), 0.12);
color: var(--bs-primary, var(--accent));
}
.task-badge--sync {
background: rgba(99, 102, 241, 0.12);
color: var(--bs-primary, #6366f1);
background: rgba(var(--accent-rgb), 0.12);
color: var(--bs-primary, var(--accent));
}
.task-badge--async {
+14 -14
View File
@@ -12,8 +12,8 @@ app-topbar {
}
.topbar-title {
font-size: 0.8rem;
font-weight: 600;
font-size: 0.9rem;
font-weight: 700;
color: var(--sidebar-brand-color);
letter-spacing: 0.02em;
}
@@ -26,10 +26,10 @@ app-topbar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
width: 30px;
height: 30px;
border: none;
border-radius: 5px;
border-radius: 8px;
background: transparent;
color: var(--sidebar-text);
cursor: pointer;
@@ -50,12 +50,12 @@ app-topbar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
width: 30px;
height: 30px;
border: none;
border-radius: 5px;
border-radius: 8px;
background: transparent;
color: #6366f1;
color: var(--accent);
cursor: pointer;
transition: color 0.15s, background 0.15s;
padding: 0;
@@ -81,13 +81,13 @@ app-topbar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
width: 30px;
height: 30px;
border: none;
border-radius: 50%;
background: var(--accent);
color: #fff;
font-size: 0.72rem;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
@@ -95,7 +95,7 @@ app-topbar {
}
.topbar-avatar:hover {
background: var(--accent-hover);
filter: brightness(0.9);
}
.topbar-dropdown {
@@ -105,7 +105,7 @@ app-topbar {
min-width: 200px;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 8px;
border-radius: var(--radius-md);
box-shadow: var(--card-shadow);
padding: 6px;
z-index: 100;
+1 -1
View File
@@ -71,7 +71,7 @@
font-weight: 600;
}
.um-badge-encrypted { background: rgba(99, 102, 241, .15); color: #6366f1; }
.um-badge-encrypted { background: rgba(var(--accent-rgb), .15); color: var(--accent); }
.um-badge-clear { background: rgba(108, 117, 125, .15); color: #6c757d; }
.um-badge-active { background: rgba(25, 135, 84, .15); color: #198754; }
.um-badge-inactive { background: rgba(220, 53, 69, .15); color: #dc3545; }
+96 -58
View File
@@ -1,87 +1,104 @@
/* ── Layout variables ──────────────────────────────────────────────────────── */
:root {
--topbar-height: 32px;
--sidebar-width: 220px;
--topbar-height: 40px;
--sidebar-width: 240px;
--copilot-width: 420px;
/* Brand accent */
--accent: #6366f1;
--accent-hover: #4f46e5;
/* Brand accent — warm terracotta */
--accent: #d95d4e;
--accent-rgb: 217, 93, 78;
--accent-hover: #c04a3c;
--accent-soft: rgba(217, 93, 78, 0.12);
--accent-ring: rgba(217, 93, 78, 0.28);
/* Sidebar — indigo, dark in both modes */
--sidebar-bg: #1a1740;
--sidebar-hover: rgba(255, 255, 255, 0.06);
--sidebar-active-bg: rgba(99, 102, 241, 0.22);
--sidebar-label-color: #4a4880;
--sidebar-text: #9b99d4;
--sidebar-text-active: #e0e7ff;
--sidebar-divider: rgba(255, 255, 255, 0.07);
--sidebar-brand-color: #e0e7ff;
/* Radius scale — friendly, generous */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
/* Sidebar — warm cream */
--sidebar-bg: #f6f0e6;
--sidebar-hover: rgba(63, 52, 40, 0.06);
--sidebar-active-bg: rgba(217, 93, 78, 0.13);
--sidebar-label-color: #9c8a70;
--sidebar-text: #7a6a58;
--sidebar-text-active: #a63f31;
--sidebar-divider: #e9dfcf;
--sidebar-brand-color: #3f3428;
/* Main area — light mode */
--toolbar-border: #e2e8f0;
--placeholder-color: #94a3b8;
--copilot-bg: #f8fafc;
--toolbar-border: #e9dfd3;
--placeholder-color: #a89a85;
--copilot-bg: #fbf8f3;
/* Copilot messages — light mode */
--msg-assistant-bg: #e8ecf8;
--msg-assistant-text: #1e293b;
--msg-user-bg: #6366f1;
--msg-assistant-bg: #f4ede2;
--msg-assistant-text: #3f3428;
--msg-user-bg: #d95d4e;
--msg-user-text: #ffffff;
/* Card surfaces */
--card-bg: #ffffff;
--card-border: #dde1f0;
--card-shadow: 0 1px 3px rgba(99, 102, 241, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05);
--card-radius: 3px;
--card-border: #e9dfd3;
--card-shadow: 0 1px 3px rgba(63, 52, 40, 0.06), 0 1px 2px rgba(63, 52, 40, 0.04);
--card-radius: var(--radius-md);
/* Bootstrap overrides */
--bs-primary: #6366f1;
--bs-primary-rgb: 99, 102, 241;
--bs-link-color: #6366f1;
--bs-link-color-rgb: 99, 102, 241;
--bs-body-bg: #eef0f8;
--bs-body-bg-rgb: 238, 240, 248;
--bs-secondary-bg: #e4e7f2;
--bs-tertiary-bg: #f4f5fb;
--bs-border-color: #dde1f0;
--bs-primary: #d95d4e;
--bs-primary-rgb: 217, 93, 78;
--bs-link-color: #c04a3c;
--bs-link-color-rgb: 192, 74, 60;
--bs-body-bg: #faf7f2;
--bs-body-bg-rgb: 250, 247, 242;
--bs-secondary-bg: #f0e9dc;
--bs-tertiary-bg: #f6f0e6;
--bs-border-color: #e9dfd3;
--bs-border-radius: 0.6rem;
--bs-border-radius-sm: 0.45rem;
--bs-border-radius-lg: 0.9rem;
}
[data-bs-theme="dark"] {
--sidebar-bg: #0e0c22;
--sidebar-hover: rgba(255, 255, 255, 0.05);
--sidebar-active-bg: rgba(99, 102, 241, 0.18);
--sidebar-label-color: #2a2860;
--sidebar-text: #6360b8;
--sidebar-text-active: #c7d2fe;
--sidebar-divider: rgba(255, 255, 255, 0.06);
--sidebar-brand-color: #c7d2fe;
--accent: #e8836f;
--accent-rgb: 232, 131, 111;
--accent-hover: #f0967f;
--accent-soft: rgba(232, 131, 111, 0.16);
--accent-ring: rgba(232, 131, 111, 0.35);
--toolbar-border: #1e1b3a;
--placeholder-color: #4a4880;
--copilot-bg: #13112a;
--sidebar-bg: #241f1a;
--sidebar-hover: rgba(236, 225, 211, 0.06);
--sidebar-active-bg: rgba(232, 131, 111, 0.16);
--sidebar-label-color: #7a6a55;
--sidebar-text: #b3a28c;
--sidebar-text-active: #f0a495;
--sidebar-divider: #3d332a;
--sidebar-brand-color: #ece1d3;
--msg-assistant-bg: #1a1830;
--msg-assistant-text: #c7d2fe;
--msg-user-bg: #4f46e5;
--toolbar-border: #3d332a;
--placeholder-color: #8a7a66;
--copilot-bg: #211c17;
--msg-assistant-bg: #2e2721;
--msg-assistant-text: #ece1d3;
--msg-user-bg: #b6493b;
--msg-user-text: #ffffff;
--bs-primary: #818cf8;
--bs-primary-rgb: 129, 140, 248;
--bs-link-color: #818cf8;
--bs-link-color-rgb: 129, 140, 248;
--bs-primary: #e8836f;
--bs-primary-rgb: 232, 131, 111;
--bs-link-color: #e8836f;
--bs-link-color-rgb: 232, 131, 111;
--bs-body-bg: #111128;
--bs-body-bg-rgb: 17, 17, 40;
--bs-secondary-bg: #1a1838;
--bs-tertiary-bg: #1e1c3e;
--bs-border-color: #2a2850;
--bs-body-bg: #1c1814;
--bs-body-bg-rgb: 28, 24, 20;
--bs-secondary-bg: #2e2721;
--bs-tertiary-bg: #2e2721;
--bs-border-color: #3d332a;
/* Card surfaces — dark mode */
--card-bg: #1d1b3e;
--card-border: #2e2b58;
--card-shadow: 0 2px 10px rgba(0, 0, 0, 0.5), 0 1px 3px rgba(0, 0, 0, 0.4);
--card-bg: #27211b;
--card-border: #3d332a;
--card-shadow: 0 2px 10px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* ── Reset ─────────────────────────────────────────────────────────────────── */
@@ -98,6 +115,27 @@ body {
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
::selection {
background: var(--accent-soft);
}
/* Keyboard users get a clear, warm focus ring everywhere. */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Respect users who ask for less motion. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ── Root layout ───────────────────────────────────────────────────────────── */
#app {
+1078
View File
File diff suppressed because it is too large Load Diff
+1078
View File
File diff suppressed because it is too large Load Diff
+1078
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -93,6 +93,7 @@
<agents-page></agents-page>
<users-page></users-page>
<roles-page></roles-page>
<shared-folders-page></shared-folders-page>
<connectors-page></connectors-page>
<connector-detail-page></connector-detail-page>
<marketplace-page></marketplace-page>
@@ -104,7 +105,7 @@
<approval-groups-page></approval-groups-page>
<approval-rules-page></approval-rules-page>
<config-page></config-page>
<home-page></home-page>
<dashboard-page></dashboard-page>
<agent-inbox-page></agent-inbox-page>
<llm-requests-page></llm-requests-page>
<session-detail-page style="display:none"></session-detail-page>
+4 -3
View File
@@ -1,4 +1,5 @@
import { LightElement } from './base.js';
import { t } from './i18n.js';
// Slash commands handled entirely server-side: they reply with a `Done` and never
// echo back as a `user_message`, so they are the only commands rendered
@@ -270,7 +271,7 @@ export class ChatSession extends LightElement {
if (approved) {
this._updateTool(tool_call_id, { status: 'running', request_id: null });
} else {
this._updateTool(tool_call_id, { status: 'rejected', error: 'Rifiutato.' });
this._updateTool(tool_call_id, { status: 'rejected', error: t('chat.rejected') });
}
const expanded = new Set(this._expanded);
expanded.delete(tool_call_id);
@@ -320,7 +321,7 @@ export class ChatSession extends LightElement {
}
case 'truncated':
this._pushError(`Risposta troncata dal limite di token (↓${msg.output_tokens?.toLocaleString() ?? '?'} tok).`);
this._pushError(`${t('chat.truncated', { tokens: msg.output_tokens?.toLocaleString() ?? '?' })}`);
break;
case 'error':
@@ -592,7 +593,7 @@ export class ChatSession extends LightElement {
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'reject_tool', request_id: msg.request_id, note: this._rejectNote }));
}
this._updateTool(msg.tool_call_id, { status: 'rejected', error: "Rifiutato dall'utente." });
this._updateTool(msg.tool_call_id, { status: 'rejected', error: t('chat.rejected_by_user') });
this._rejectingId = null;
}
+70
View File
@@ -0,0 +1,70 @@
import en from '../i18n/en.js';
import it from '../i18n/it.js';
import fr from '../i18n/fr.js';
const DICTS = { en, it, fr };
export const LOCALES = [
{ id: 'en', label: 'English' },
{ id: 'it', label: 'Italiano' },
{ id: 'fr', label: 'Français' },
];
// Pre-auth the last choice is cached in localStorage (the login page can be
// localized before any session exists); after login the server is the source
// of truth: the user's own `locale` wins over the instance default.
let _locale = localStorage.getItem('locale') || 'en';
export function t(key, params) {
let s = DICTS[_locale]?.[key] ?? DICTS.en[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) s = s.replaceAll(`{${k}}`, String(v));
}
return s;
}
export function getLocale() { return _locale; }
export function setLocale(locale, { persist = false } = {}) {
if (!DICTS[locale]) locale = 'en';
const changed = locale !== _locale;
_locale = locale;
localStorage.setItem('locale', locale);
document.documentElement.lang = locale;
if (changed) window.dispatchEvent(new CustomEvent('locale-changed', { detail: { locale } }));
if (persist) {
fetch('/api/auth/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale }),
}).catch(() => {});
}
}
/**
* Resolves the effective locale once the session is known:
* user preference (users.locale) instance default (config `ui_locale`)
* cached/browser default. Pre-auth (login/setup) keeps the cached locale.
*/
export async function initI18n() {
try {
const res = await fetch('/api/auth/me');
if (!res.ok) return;
const me = await res.json();
const eff = me.locale || me.default_locale;
if (eff) setLocale(eff);
} catch { /* keep cached locale */ }
}
/** Re-renders the host component whenever the locale changes. */
export const I18nMixin = (Base) => class extends Base {
connectedCallback() {
super.connectedCallback?.();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback?.();
}
};
+7 -6
View File
@@ -1,10 +1,11 @@
import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { renderMarkdown } from './base.js';
import { t } from './i18n.js';
/**
* InboxMixin shared fetch, action, and render logic for the agent inbox.
* Used by AgentInboxPage (full page) and HomePage (embedded section).
* Used by AgentInboxPage (full page) and DashboardPage (embedded section).
*/
export const InboxMixin = (Base) => class extends Base {
@@ -68,7 +69,7 @@ export const InboxMixin = (Base) => class extends Base {
}
_rejectWithNote(requestId, toolCallId = null) {
const note = prompt('Rejection reason (optional):') ?? '';
const note = prompt(t('inbox.reject_prompt')) ?? '';
this._resolveApproval(requestId, 'reject', note, null, null, toolCallId);
}
@@ -208,11 +209,11 @@ export const InboxMixin = (Base) => class extends Base {
<div class="inbox-card-footer approval-footer">
<button class="btn btn-success"
@click=${() => this._resolveApproval(item.request_id, 'approve', '', null, null, item.tool_call_id)}>
<i class="bi bi-check-lg"></i> Approve
<i class="bi bi-check-lg"></i> ${t('approval.approve')}
</button>
<button class="btn btn-outline-danger"
@click=${() => this._rejectWithNote(item.request_id, item.tool_call_id)}>
<i class="bi bi-x-lg"></i> Reject
<i class="bi bi-x-lg"></i> ${t('approval.reject')}
</button>
${item.request_id ? html`
@@ -233,7 +234,7 @@ export const InboxMixin = (Base) => class extends Base {
<button class="btn btn-outline-secondary"
@click=${() => this._approveWithBypass(item, 0)}
title="Approve and don't ask again for this session">
title=${t('approval.bypass_all')}>
<i class="bi bi-shield-check"></i> Sessione
</button>
` : nothing}
@@ -360,7 +361,7 @@ export const InboxMixin = (Base) => class extends Base {
${total === 0 ? html`
<div class="inbox-empty">
<i class="bi bi-inbox"></i>
<p>No pending requests</p>
<p>${t('inbox.empty')}</p>
</div>
` : html`
<div class="inbox-grid">