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:
+11
-6
@@ -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 |
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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>< 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 1–999998 · evaluated after per-tool',
|
||||
t('approval.sidebar.lowprio_sub'),
|
||||
lowPrio,
|
||||
this._lowPrioOpen,
|
||||
() => { this._lowPrioOpen = !this._lowPrioOpen; },
|
||||
|
||||
+44
-51
@@ -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>`;
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} — ${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()}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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> LaTeX compilation failed — showing source instead</summary>
|
||||
<summary><i class="bi bi-exclamation-triangle text-warning"></i> ${t('fv.latex_failed')}</summary>
|
||||
<pre>${this._compileError}</pre>
|
||||
</details>`
|
||||
: nothing}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
`;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} — ${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()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ file-viewer-page {
|
||||
|
||||
users-page,
|
||||
roles-page,
|
||||
shared-folders-page,
|
||||
connectors-page,
|
||||
connector-detail-page,
|
||||
marketplace-page,
|
||||
|
||||
+10
-10
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+1078
File diff suppressed because it is too large
Load Diff
+1078
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user