First Version
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { InboxMixin } from '../lib/inbox-mixin.js';
|
||||
|
||||
export class AgentInboxPage extends InboxMixin(LightElement) {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
...super.properties,
|
||||
_open: { state: true },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._pollTimer = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'inbox';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) {
|
||||
this._loadInbox();
|
||||
this._startPolling();
|
||||
} else {
|
||||
this._stopPolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._stopPolling();
|
||||
}
|
||||
|
||||
_startPolling() {
|
||||
this._stopPolling();
|
||||
this._pollTimer = setInterval(() => this._loadInbox(), 8000);
|
||||
}
|
||||
|
||||
_stopPolling() {
|
||||
if (this._pollTimer) {
|
||||
clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const approvals = this._inboxData?.approvals ?? [];
|
||||
const clarifications = this._inboxData?.clarifications ?? [];
|
||||
const elicitations = this._inboxData?.elicitations ?? [];
|
||||
const total = approvals.length + clarifications.length + elicitations.length;
|
||||
|
||||
return html`
|
||||
<div class="page-panel">
|
||||
<div class="page-panel-header">
|
||||
<h5 class="mb-0">
|
||||
Agent 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()}>
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
${this._renderInboxSection()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { html } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement, renderMarkdown } from '../lib/base.js';
|
||||
|
||||
const STRENGTH_COLORS = {
|
||||
very_high: '#ef4444',
|
||||
high: '#f97316',
|
||||
average: '#eab308',
|
||||
low: '#84cc16',
|
||||
very_low: '#22c55e',
|
||||
};
|
||||
|
||||
const STRENGTH_LABELS = {
|
||||
very_high: 'Very High',
|
||||
high: 'High',
|
||||
average: 'Average',
|
||||
low: 'Low',
|
||||
very_low: 'Very Low',
|
||||
};
|
||||
|
||||
export class AgentsPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_agents: { state: true },
|
||||
_detail: { state: true }, // null | { meta, prompt, models }
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._agents = [];
|
||||
this._detail = null;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'agents';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open && this._agents.length === 0) this._loadList();
|
||||
if (!this._open) this._detail = null;
|
||||
});
|
||||
}
|
||||
|
||||
async _loadList() {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch('/api/agents');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._agents = await res.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _openDetail(agent) {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/agents/${agent.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._detail = await res.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_back() {
|
||||
this._detail = null;
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
// ── Render helpers ────────────────────────────────────────────────────────
|
||||
|
||||
_strengthDot(strength, size = '0.62rem') {
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
_scopePill(scope) {
|
||||
return html`<span class="agent-scope-pill">${scope}</span>`;
|
||||
}
|
||||
|
||||
// ── List view ─────────────────────────────────────────────────────────────
|
||||
|
||||
_renderCard(agent) {
|
||||
return html`
|
||||
<div class="agent-card" @click=${() => this._openDetail(agent)}>
|
||||
<div class="agent-card-body">
|
||||
${agent.icon ? html`
|
||||
<img class="agent-card-icon" src="/api/agents/${agent.id}/icon" alt="${agent.name}" loading="lazy">
|
||||
` : ''}
|
||||
<div class="agent-card-content">
|
||||
<div class="agent-card-header">
|
||||
<span class="agent-card-name">${agent.name}</span>
|
||||
<span class="agent-card-id text-muted">${agent.id}</span>
|
||||
</div>
|
||||
<p class="agent-card-desc text-muted">${agent.friendly_description ?? agent.description}</p>
|
||||
<div class="agent-card-meta">
|
||||
${agent.strength ? html`
|
||||
<span class="agent-meta-item">
|
||||
${this._strengthDot(agent.strength)}
|
||||
<span>${STRENGTH_LABELS[agent.strength] ?? agent.strength}</span>
|
||||
</span>
|
||||
` : ''}
|
||||
${agent.scope ? html`${this._scopePill(agent.scope)}` : ''}
|
||||
${agent.client ? html`
|
||||
<span class="agent-meta-item text-muted" style="font-size:0.75rem">
|
||||
<i class="bi bi-pin-fill me-1" style="font-size:0.65rem"></i>${agent.client}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderSection(title, agents) {
|
||||
if (agents.length === 0) return '';
|
||||
return html`
|
||||
<section class="agent-group">
|
||||
<h3 class="agent-group-title">${title}</h3>
|
||||
<div class="agent-grid">
|
||||
${agents.map(a => this._renderCard(a))}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderList() {
|
||||
if (this._loading) return html`<div class="text-muted py-4 text-center">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).
|
||||
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)}
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Detail view ───────────────────────────────────────────────────────────
|
||||
|
||||
_renderModelRow(m, i) {
|
||||
const isFirst = i === 0;
|
||||
return html`
|
||||
<tr class="${isFirst ? 'agent-model-row--first' : ''}">
|
||||
<td class="agent-model-rank text-muted">${i + 1}</td>
|
||||
<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>` : ''}
|
||||
</td>
|
||||
<td class="text-muted agent-model-id">${m.model_id}</td>
|
||||
<td>
|
||||
${(m.scope ?? []).map(s => this._scopePill(s))}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderDetail() {
|
||||
if (this._loading && !this._detail) return html`<div class="text-muted py-4 text-center">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
|
||||
</button>
|
||||
<div class="agent-detail-title-row">
|
||||
${meta.icon ? html`
|
||||
<img class="agent-detail-icon" src="/api/agents/${meta.id}/icon" alt="${meta.name}">
|
||||
` : ''}
|
||||
<div>
|
||||
<h2 class="agent-detail-title">${meta.name}</h2>
|
||||
<p class="text-muted mb-0" style="font-size:0.9rem">${meta.friendly_description ?? meta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${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>
|
||||
<table class="agent-meta-table">
|
||||
<tbody>
|
||||
<tr><td class="agent-meta-key">ID</td><td><code>${meta.id}</code></td></tr>
|
||||
${meta.strength ? html`
|
||||
<tr><td class="agent-meta-key">Strength</td>
|
||||
<td class="d-flex align-items-center gap-2">
|
||||
${this._strengthDot(meta.strength)}
|
||||
${STRENGTH_LABELS[meta.strength] ?? meta.strength}
|
||||
</td>
|
||||
</tr>
|
||||
` : ''}
|
||||
${meta.scope ? html`
|
||||
<tr><td class="agent-meta-key">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>
|
||||
` : ''}
|
||||
${meta.inject_memory?.length ? html`
|
||||
<tr><td class="agent-meta-key">Memory files</td>
|
||||
<td>${meta.inject_memory.map(f => html`<div style="font-size:0.8rem"><code>${f}</code></div>`)}</td>
|
||||
</tr>
|
||||
` : ''}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Model resolution order -->
|
||||
<section class="agent-section">
|
||||
<h3 class="agent-section-title">Model resolution 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.
|
||||
</p>
|
||||
${models.length === 0
|
||||
? html`<p class="text-muted" style="font-size:0.85rem">No models configured.</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${models.map((m, i) => this._renderModelRow(m, i))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- System prompt -->
|
||||
<section class="agent-section">
|
||||
<h3 class="agent-section-title">System prompt</h3>
|
||||
<div class="agent-prompt-body markdown-body">
|
||||
${unsafeHTML(renderMarkdown(prompt))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Root render ───────────────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="agents-page">
|
||||
${this._detail
|
||||
? this._renderDetail()
|
||||
: html`
|
||||
<div class="agents-page-header">
|
||||
<h2 class="llm-page-title">Agents</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._renderList()}
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
export class ApprovalGroupsPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_groups: { state: true },
|
||||
_rules: { state: true },
|
||||
_error: { state: true },
|
||||
_groupEditId: { state: true },
|
||||
_groupForm: { state: true },
|
||||
_groupSaving: { state: true },
|
||||
_duplicateOf: { state: true }, // group being duplicated, or null
|
||||
_dupForm: { state: true }, // { id, name }
|
||||
_dupSaving: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._groups = [];
|
||||
this._rules = [];
|
||||
this._error = null;
|
||||
this._groupEditId = null;
|
||||
this._groupForm = { id: '', name: '', description: '' };
|
||||
this._groupSaving = false;
|
||||
this._duplicateOf = null;
|
||||
this._dupForm = { id: '', name: '' };
|
||||
this._dupSaving = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', async (e) => {
|
||||
this._open = e.detail.page === 'approval';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (!this._open) return;
|
||||
await this._load();
|
||||
const match = window.location.hash.match(/^#approval\/(.+)$/);
|
||||
if (match) {
|
||||
const group = this._groups.find(g => g.id === decodeURIComponent(match[1]));
|
||||
if (group) { this._navigateTo(group); return; }
|
||||
}
|
||||
});
|
||||
window.addEventListener('approval-navigate', (e) => {
|
||||
if (e.detail.group !== null) return;
|
||||
// Returning from rules view — show groups again
|
||||
this._open = true;
|
||||
this.style.display = 'flex';
|
||||
this._load();
|
||||
});
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (!this._open) return;
|
||||
const match = window.location.hash.match(/^#approval\/(.+)$/);
|
||||
if (!match) return;
|
||||
const group = this._groups.find(g => g.id === decodeURIComponent(match[1]));
|
||||
if (group) this._navigateTo(group);
|
||||
});
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._error = null;
|
||||
try {
|
||||
const [gRes, rRes] = await Promise.all([
|
||||
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}`);
|
||||
const groups = await gRes.json();
|
||||
this._groups = groups.sort((a, b) => {
|
||||
if (a.id === 'default') return -1;
|
||||
if (b.id === 'default') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
this._rules = await rRes.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
_rulesForGroup(groupId) {
|
||||
return this._rules.filter(r => (r.group_id ?? 'default') === groupId);
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||
|
||||
_navigateTo(group) {
|
||||
this._open = false;
|
||||
this.style.display = 'none';
|
||||
window.location.hash = `approval/${group.id}`;
|
||||
window.dispatchEvent(new CustomEvent('approval-navigate', { detail: { group } }));
|
||||
}
|
||||
|
||||
// ── Group management ──────────────────────────────────────────────────────────
|
||||
|
||||
_startNewGroup() {
|
||||
this._groupEditId = 'new';
|
||||
this._groupForm = { id: '', name: '', description: '' };
|
||||
this._duplicateOf = null;
|
||||
}
|
||||
|
||||
_startEditGroup(group) {
|
||||
this._groupEditId = group.id;
|
||||
this._groupForm = { id: group.id, name: group.name, description: group.description ?? '' };
|
||||
this._duplicateOf = null;
|
||||
}
|
||||
|
||||
_cancelGroupEdit() { this._groupEditId = null; }
|
||||
|
||||
_patchGroup(field, value) {
|
||||
this._groupForm = { ...this._groupForm, [field]: value };
|
||||
}
|
||||
|
||||
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; }
|
||||
this._groupSaving = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const body = isNew
|
||||
? { id: this._groupForm.id.trim(), name: this._groupForm.name.trim(), description: this._groupForm.description.trim() || null }
|
||||
: { name: this._groupForm.name.trim(), description: this._groupForm.description.trim() || null };
|
||||
const url = isNew ? '/api/tool-permission-groups' : `/api/tool-permission-groups/${this._groupEditId}`;
|
||||
const res = await fetch(url, {
|
||||
method: isNew ? 'POST' : 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._groupEditId = null;
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._groupSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
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}"?`;
|
||||
if (!confirm(msg)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/tool-permission-groups/${group.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Duplicate group ───────────────────────────────────────────────────────────
|
||||
|
||||
_startDuplicate(group) {
|
||||
this._duplicateOf = group;
|
||||
this._dupForm = {
|
||||
id: `${group.id}_copy`,
|
||||
name: `Copy of ${group.name}`,
|
||||
};
|
||||
this._groupEditId = null; // close any open create/rename form
|
||||
}
|
||||
|
||||
_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; }
|
||||
this._dupSaving = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/tool-permission-groups/${this._duplicateOf.id}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: this._dupForm.id.trim(), name: this._dupForm.name.trim() }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._duplicateOf = null;
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._dupSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group form ────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderGroupForm() {
|
||||
const isNew = this._groupEditId === 'new';
|
||||
const f = this._groupForm;
|
||||
return html`
|
||||
<div class="apr-form">
|
||||
<div class="apr-form-header">
|
||||
<i class="bi bi-collection"></i>
|
||||
<span>${isNew ? 'New group' : 'Rename group'}</span>
|
||||
<button class="apr-form-close" @click=${() => this._cancelGroupEdit()}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="apr-form-body">
|
||||
<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>
|
||||
<input
|
||||
class="form-control form-control-sm font-monospace"
|
||||
placeholder="e.g. cron_strict"
|
||||
.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>
|
||||
` : nothing}
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Name <span class="text-danger">*</span></label>
|
||||
<input
|
||||
class="form-control form-control-sm"
|
||||
placeholder="e.g. Cron strict"
|
||||
.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>
|
||||
<input
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Short description…"
|
||||
.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 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`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Duplicate form ────────────────────────────────────────────────────────────
|
||||
|
||||
_renderDuplicateForm() {
|
||||
const src = this._duplicateOf;
|
||||
const f = this._dupForm;
|
||||
return html`
|
||||
<div class="apr-form">
|
||||
<div class="apr-form-header">
|
||||
<i class="bi bi-copy"></i>
|
||||
<span>Duplicate <strong>${src.name}</strong></span>
|
||||
<button class="apr-form-close" @click=${() => this._cancelDuplicate()}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
<input
|
||||
class="form-control form-control-sm"
|
||||
.value=${f.name}
|
||||
@input=${(e) => { this._dupForm = { ...this._dupForm, name: e.target.value }; }}
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">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>
|
||||
</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.
|
||||
</div>
|
||||
</div>
|
||||
<div class="apr-form-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelDuplicate()}>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`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Group card ────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderGroupCard(group) {
|
||||
const count = this._rulesForGroup(group.id).length;
|
||||
const isDefault = group.id === 'default';
|
||||
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}
|
||||
<span class="apr-group-name">${group.name}</span>
|
||||
<span class="apr-priority-badge ms-auto" title="${count} rule${count === 1 ? '' : 's'}">
|
||||
<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"
|
||||
@click=${(e) => { e.stopPropagation(); this._startDuplicate(group); }}>
|
||||
<i class="bi bi-copy"></i>
|
||||
</button>
|
||||
<button class="apr-btn-icon apr-btn-edit" title="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'}
|
||||
?disabled=${isDefault}
|
||||
@click=${(e) => { e.stopPropagation(); if (!isDefault) this._deleteGroup(group); }}
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${group.description ? html`
|
||||
<div class="apr-card-row3">
|
||||
<span class="apr-tag"><i class="bi bi-text-left"></i>${group.description}</span>
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Groups view ───────────────────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="apr-page">
|
||||
<div class="apr-header">
|
||||
<h2 class="apr-title">
|
||||
<i class="bi bi-shield-check me-2"></i>Security
|
||||
</h2>
|
||||
<div class="apr-header-right">
|
||||
<span class="apr-header-count">${this._groups.length} group${this._groups.length === 1 ? '' : 's'}</span>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._startNewGroup()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>New group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mt-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${this._groupEditId !== null ? this._renderGroupForm() : nothing}
|
||||
${this._duplicateOf !== null ? this._renderDuplicateForm() : nothing}
|
||||
|
||||
<div class="apr-card-list">
|
||||
${this._groups.length === 0 ? html`
|
||||
<div class="apr-empty">
|
||||
<i class="bi bi-collection"></i>
|
||||
<p>No groups yet.</p>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._startNewGroup()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Create first group
|
||||
</button>
|
||||
</div>
|
||||
` : this._groups.map(g => this._renderGroupCard(g))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
export class ConfigPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_properties: { state: true },
|
||||
_values: { state: true }, // { [key]: string }
|
||||
_saving: { state: true }, // Set<key>
|
||||
_saved: { state: true }, // Set<key> (brief flash)
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._properties = [];
|
||||
this._values = {};
|
||||
this._saving = new Set();
|
||||
this._saved = new Set();
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'config';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._load();
|
||||
});
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch('/api/config');
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
this._properties = data.sets ?? [];
|
||||
const vals = {};
|
||||
for (const s of this._properties)
|
||||
for (const p of s.properties) vals[p.key] = p.value ?? '';
|
||||
this._values = vals;
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
_setValue(key, val) {
|
||||
this._values = { ...this._values, [key]: val };
|
||||
}
|
||||
|
||||
async _save(prop) {
|
||||
const key = prop.key;
|
||||
const value = this._values[key] ?? '';
|
||||
|
||||
this._saving = new Set([...this._saving, key]);
|
||||
this.requestUpdate();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/config/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
this._saved = new Set([...this._saved, key]);
|
||||
setTimeout(() => {
|
||||
this._saved = new Set([...this._saved].filter(k => k !== key));
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
alert(`Error saving ${prop.name}: ${e.message}`);
|
||||
} finally {
|
||||
this._saving = new Set([...this._saving].filter(k => k !== key));
|
||||
}
|
||||
}
|
||||
|
||||
_renderInput(prop) {
|
||||
const val = this._values[prop.key] ?? '';
|
||||
|
||||
if (prop.property_type === 'bool') {
|
||||
const effective = val !== '' ? val : (prop.default_value ?? 'true');
|
||||
const checked = effective !== 'false';
|
||||
return html`
|
||||
<div class="form-check form-switch config-bool-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="cfg-${prop.key}"
|
||||
.checked=${checked}
|
||||
@change=${e => { this._setValue(prop.key, e.target.checked ? 'true' : 'false'); this._save(prop); }} />
|
||||
<label class="form-check-label" for="cfg-${prop.key}">
|
||||
${checked ? 'Enabled' : 'Disabled'}
|
||||
</label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (prop.property_type === 'int') {
|
||||
return html`
|
||||
<input type="number" step="1" min="1"
|
||||
class="form-control form-control-sm config-input"
|
||||
.value=${val}
|
||||
placeholder=${prop.default_value ?? ''}
|
||||
@input=${e => this._setValue(prop.key, e.target.value)} />`;
|
||||
}
|
||||
|
||||
if (prop.property_type === 'security_group') {
|
||||
const groups = prop.options ?? [];
|
||||
return html`
|
||||
<select class="form-select form-select-sm config-input"
|
||||
.value=${val}
|
||||
@change=${e => this._setValue(prop.key, e.target.value)}>
|
||||
<option value="">— default —</option>
|
||||
${groups.map(g => html`
|
||||
<option value=${g.id} ?selected=${val === g.id}>${g.name}</option>`)}
|
||||
</select>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<input type="text"
|
||||
class="form-control form-control-sm config-input"
|
||||
.value=${val}
|
||||
placeholder=${prop.default_value ?? ''}
|
||||
@input=${e => this._setValue(prop.key, e.target.value)} />`;
|
||||
}
|
||||
|
||||
_renderSet(set) {
|
||||
return html`
|
||||
<div class="config-set">
|
||||
<div class="config-set-header">
|
||||
<div class="config-set-name">${set.name}</div>
|
||||
<div class="config-set-desc">${set.description}</div>
|
||||
</div>
|
||||
<div class="config-rows">
|
||||
${set.properties.map(p => this._renderRow(p))}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderRow(prop) {
|
||||
const saving = this._saving.has(prop.key);
|
||||
const saved = this._saved.has(prop.key);
|
||||
|
||||
return html`
|
||||
<div class="config-row">
|
||||
<div class="config-row-meta">
|
||||
<div class="config-row-name">${prop.name}</div>
|
||||
<div class="config-row-desc">${prop.description}</div>
|
||||
</div>
|
||||
<div class="config-row-control">
|
||||
${this._renderInput(prop)}
|
||||
${prop.property_type !== 'bool' ? html`
|
||||
<button class="btn btn-sm ${saved ? 'btn-success' : 'btn-primary'} config-save-btn"
|
||||
?disabled=${saving}
|
||||
@click=${() => this._save(prop)}>
|
||||
${saving
|
||||
? html`<span class="spinner-border spinner-border-sm"></span>`
|
||||
: saved ? 'Saved' : 'Save'}
|
||||
</button>` : nothing}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="config-page">
|
||||
<div class="config-page-header">
|
||||
<h2 class="llm-page-title">Config</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}
|
||||
|
||||
<div class="config-sets">
|
||||
${this._properties.map(s => this._renderSet(s))}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
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';
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render a tool label (with backtick-wrapped arguments) as Lit nodes. Each
|
||||
* backtick segment becomes a `<code>`; the segment that exactly matches `path`
|
||||
* — the file a file-targeting tool acts on, supplied by the backend
|
||||
* `target_path` — instead becomes a link that opens it in the file viewer.
|
||||
* Lit auto-escapes text, so no manual HTML escaping is needed.
|
||||
*/
|
||||
function renderLabel(label, path) {
|
||||
const out = [];
|
||||
let rest = label || '';
|
||||
while (rest.length) {
|
||||
const open = rest.indexOf('`');
|
||||
if (open === -1) { out.push(rest); break; }
|
||||
if (open > 0) out.push(rest.slice(0, open));
|
||||
rest = rest.slice(open + 1);
|
||||
const close = rest.indexOf('`');
|
||||
if (close === -1) { out.push('`' + rest); break; }
|
||||
out.push(renderPath(rest.slice(0, close), path));
|
||||
rest = rest.slice(close + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A backtick segment: a clickable file link when it is the call's target path, else plain `<code>`. */
|
||||
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"
|
||||
@click=${open}
|
||||
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); } }}
|
||||
>${seg}</span>`;
|
||||
}
|
||||
|
||||
export function truncate(s, max = 400) {
|
||||
if (!s) return '';
|
||||
const str = typeof s === 'string' ? s : JSON.stringify(s, null, 2);
|
||||
return str.length > max ? str.slice(0, max) + '\n…' : str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-prints a structured (`result_type === 'json'`) tool result for display.
|
||||
* The backend stores `structuredContent` as a compact JSON string; re-indent it
|
||||
* for readability, falling back to the raw string if it isn't valid JSON.
|
||||
*/
|
||||
function prettyJson(s) {
|
||||
try { return JSON.stringify(JSON.parse(s), null, 2); }
|
||||
catch { return s; }
|
||||
}
|
||||
|
||||
// ── Diff ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function renderDiff(oldText, newText) {
|
||||
const oldLines = (oldText || '').split('\n');
|
||||
const newLines = (newText || '').split('\n');
|
||||
|
||||
const m = oldLines.length, n = newLines.length;
|
||||
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = 1; i <= m; i++)
|
||||
for (let j = 1; j <= n; j++)
|
||||
dp[i][j] = oldLines[i-1] === newLines[j-1]
|
||||
? dp[i-1][j-1] + 1
|
||||
: Math.max(dp[i-1][j], dp[i][j-1]);
|
||||
|
||||
const ops = [];
|
||||
let i = m, j = n;
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && oldLines[i-1] === newLines[j-1]) {
|
||||
ops.push({ type: 'eq', text: oldLines[i-1] }); i--; j--;
|
||||
} else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
|
||||
ops.push({ type: 'add', text: newLines[j-1] }); j--;
|
||||
} else {
|
||||
ops.push({ type: 'del', text: oldLines[i-1] }); i--;
|
||||
}
|
||||
}
|
||||
ops.reverse();
|
||||
|
||||
const result = [];
|
||||
let eqBuf = [];
|
||||
const flushEq = () => {
|
||||
if (eqBuf.length === 0) return;
|
||||
if (eqBuf.length <= 6) {
|
||||
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-unchanged">\n${eqBuf.slice(-3).join('\n')}\n</span>`);
|
||||
}
|
||||
eqBuf = [];
|
||||
};
|
||||
for (const op of ops) {
|
||||
if (op.type === 'eq') {
|
||||
eqBuf.push(op.text);
|
||||
} else {
|
||||
flushEq();
|
||||
const cls = op.type === 'add' ? 'diff-added' : 'diff-removed';
|
||||
result.push(html`<span class="${cls}">${op.text}\n</span>`);
|
||||
}
|
||||
}
|
||||
flushEq();
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Message renderers ────────────────────────────────────────────────────────
|
||||
|
||||
export function renderPendingWrite(host, msg) {
|
||||
console.debug('[renderPendingWrite]', msg.path, 'old_len=' + (msg.old_content?.length ?? 0), 'new_len=' + (msg.new_content?.length ?? 0));
|
||||
const isRejecting = host._rejectingId === msg.request_id;
|
||||
return html`
|
||||
<div class="copilot-approval copilot-approval--${msg.status}">
|
||||
<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"
|
||||
@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>`
|
||||
: msg.status === 'approved'
|
||||
? html`<span class="badge bg-success ms-auto">Approved</span>`
|
||||
: html`<span class="badge bg-danger ms-auto">Rejected</span>`}
|
||||
</div>
|
||||
|
||||
<pre class="copilot-diff">${renderDiff(msg.old_content, msg.new_content)}</pre>
|
||||
|
||||
${msg.status === 'pending' ? html`
|
||||
<div class="copilot-approval-actions">
|
||||
${isRejecting ? html`
|
||||
<textarea
|
||||
class="form-control form-control-sm copilot-reject-note"
|
||||
rows="2"
|
||||
placeholder="Optional: explain why you rejected this (sent to the LLM)"
|
||||
.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
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => { host._rejectingId = null; }}>
|
||||
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
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @click=${() => host._startReject(msg)}>
|
||||
<i class="bi bi-x-circle me-1"></i>Reject
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip similar approvals for 15 minutes"
|
||||
@click=${() => host._approveWriteBypass(msg, 900)}>
|
||||
<i class="bi bi-clock me-1"></i>15 min
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip all approvals for this session"
|
||||
@click=${() => host._approveWriteBypass(msg, 0)}>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Session
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderTool(host, msg) {
|
||||
const isOpen = host._expanded.has(msg.tool_call_id);
|
||||
const argsStr = truncate(msg.arguments);
|
||||
const isPending = msg.status === 'pending';
|
||||
const isRejecting = isPending && host._rejectingId === msg.tool_call_id;
|
||||
|
||||
const statusIcon =
|
||||
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>`
|
||||
: 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>`
|
||||
: msg.status === 'rejected'
|
||||
? html`<i class="bi bi-shield-fill-x text-warning" title="Denied by policy"></i>`
|
||||
: html`<i class="bi bi-x-circle-fill text-danger"></i>`;
|
||||
|
||||
return html`
|
||||
<div class="copilot-tool ${isPending ? 'copilot-tool--pending' : ''}">
|
||||
<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}
|
||||
<i class="bi bi-chevron-${isOpen ? 'up' : 'down'} ms-auto"></i>
|
||||
</button>
|
||||
${isOpen ? html`
|
||||
<div class="copilot-tool-body">
|
||||
${!(isPending && msg.name === 'ask_user_clarification') ? html`
|
||||
<div class="copilot-tool-section">
|
||||
<span class="copilot-tool-label">args</span>
|
||||
<pre class="copilot-tool-pre">${argsStr}</pre>
|
||||
</div>
|
||||
` : nothing}
|
||||
${isPending ? (msg.name === 'ask_user_clarification' ? html`
|
||||
<div class="copilot-approval-actions">
|
||||
${msg.question_title ? html`<div class="copilot-clarification-title">${msg.question_title}</div>` : nothing}
|
||||
<div class="copilot-clarification-question copilot-markdown">${unsafeHTML(renderMarkdown(msg.question ?? msg.arguments?.question ?? ''))}</div>
|
||||
${(msg.suggested_answers ?? []).length > 0 ? html`
|
||||
<div class="copilot-clarification-chips">
|
||||
${(msg.suggested_answers ?? []).map(s => html`
|
||||
<button class="btn btn-sm btn-outline-secondary copilot-chip"
|
||||
@click=${() => { host._clarificationAnswer = s; }}>
|
||||
${s}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
<div class="copilot-clarification-input-row">
|
||||
<textarea
|
||||
class="form-control form-control-sm copilot-reject-note"
|
||||
rows="2"
|
||||
placeholder="Type your answer…"
|
||||
.value=${host._clarificationAnswer}
|
||||
@input=${(e) => { host._clarificationAnswer = e.target.value; }}
|
||||
@keydown=${(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); host._answerQuestion(msg); } }}
|
||||
></textarea>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="copilot-approval-actions">
|
||||
${isRejecting ? html`
|
||||
<textarea
|
||||
class="form-control form-control-sm copilot-reject-note"
|
||||
rows="2"
|
||||
placeholder="Reason for rejection (optional, sent to the LLM)"
|
||||
.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
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
@click=${() => { host._rejectingId = null; }}>
|
||||
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
|
||||
</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
|
||||
</button>
|
||||
${msg.request_id != null ? html`
|
||||
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip similar approvals for 15 minutes"
|
||||
@click=${(e) => { e.stopPropagation(); host._approveWsToolBypass(msg, 900); }}>
|
||||
<i class="bi bi-clock me-1"></i>15 min
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip all approvals for this session"
|
||||
@click=${(e) => { e.stopPropagation(); host._approveWsToolBypass(msg, 0); }}>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Session
|
||||
</button>
|
||||
` : nothing}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`) : 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>
|
||||
<pre class="copilot-tool-pre copilot-tool-pre--done copilot-tool-pre--json">${
|
||||
truncate(prettyJson(msg.result))
|
||||
}</pre>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="copilot-tool-section">
|
||||
<span class="copilot-tool-label copilot-tool-label--${msg.status}">
|
||||
${msg.status === 'done' ? 'result' : 'error'}
|
||||
</span>
|
||||
<pre class="copilot-tool-pre copilot-tool-pre--${msg.status}">${
|
||||
truncate(msg.status === 'done' ? msg.result : msg.error)
|
||||
}</pre>
|
||||
</div>
|
||||
`) : nothing}
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderAgent(msg) {
|
||||
const icon = msg.done ? 'check2-all' : 'arrow-right-circle';
|
||||
return html`
|
||||
<div class="copilot-agent" style="--agent-depth:${Math.min(msg.depth, 4)}">
|
||||
<div class="copilot-agent-header">
|
||||
<i class="bi bi-${icon}"></i>
|
||||
<span>
|
||||
<strong>${msg.parent_agent_id ?? 'main'}</strong>
|
||||
<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>`}
|
||||
</div>
|
||||
${msg.prompt_preview ? html`
|
||||
<pre class="copilot-agent-preview">${msg.prompt_preview}</pre>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderAgentEnd(msg) {
|
||||
return html`
|
||||
<div class="copilot-agent-end" style="--agent-depth:${Math.min(msg.depth, 4)}">
|
||||
<div class="copilot-agent-header">
|
||||
<i class="bi bi-arrow-return-left"></i>
|
||||
<span>
|
||||
<strong>${msg.agent_id}</strong>
|
||||
<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>
|
||||
</div>
|
||||
${msg.result_preview ? html`
|
||||
<pre class="copilot-agent-preview copilot-agent-preview--result">${msg.result_preview}</pre>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function failedBadge() {
|
||||
return html`<span class="copilot-failed-badge" title="This message is not sent to the LLM">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// ── Attachment chips ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Bootstrap icon class for a file based on its MIME type / extension. */
|
||||
function attachmentIcon(att) {
|
||||
const m = (att.mimetype || '').toLowerCase();
|
||||
const n = (att.name || '').toLowerCase();
|
||||
if (m.startsWith('image/')) return 'bi-file-earmark-image';
|
||||
if (m === 'application/pdf' || n.endsWith('.pdf')) return 'bi-file-earmark-pdf';
|
||||
if (m.startsWith('audio/')) return 'bi-file-earmark-music';
|
||||
if (m.startsWith('video/')) return 'bi-file-earmark-play';
|
||||
if (m.startsWith('text/') || /\.(md|txt|csv|json|ya?ml|rs|js|ts|py)$/.test(n)) return 'bi-file-earmark-text';
|
||||
return 'bi-file-earmark';
|
||||
}
|
||||
|
||||
/** Human-readable file size, e.g. "1.2 MB". */
|
||||
function fmtSize(bytes) {
|
||||
if (bytes == null) return '';
|
||||
const u = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0, n = bytes;
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${u[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a list of attachment chips. Used both above the composer (pending
|
||||
* uploads — `removable`, with an × button and a spinner while uploading) and
|
||||
* inside a sent user bubble (clickable to open the file). `host` must provide
|
||||
* `_removeAttachment(i)` when `removable` is true.
|
||||
*/
|
||||
export function renderAttachmentChips(host, attachments, { removable = false } = {}) {
|
||||
if (!attachments?.length) return nothing;
|
||||
return html`
|
||||
<div class="attach-chips">
|
||||
${attachments.map((att, i) => html`
|
||||
<div class="attach-chip ${att.uploading ? 'attach-chip--uploading' : ''} ${!removable && att.path ? 'attach-chip--clickable' : ''}"
|
||||
title=${att.name}
|
||||
@click=${() => { if (!removable && att.path) openFile(att.path); }}>
|
||||
${att.uploading
|
||||
? html`<span class="spinner-border spinner-border-sm"></span>`
|
||||
: html`<i class="bi ${attachmentIcon(att)}"></i>`}
|
||||
<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"
|
||||
@click=${(e) => { e.stopPropagation(); host._removeAttachment(i); }}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>` : nothing}
|
||||
</div>
|
||||
`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderMsg(host, msg) {
|
||||
try {
|
||||
switch (msg.kind) {
|
||||
case 'user':
|
||||
return html`<div class="copilot-msg user ${msg.failed ? 'copilot-msg--failed' : ''}" style="white-space:pre-wrap">${msg.failed ? failedBadge() : nothing}${msg.content}${renderAttachmentChips(host, msg.attachments)}</div>`;
|
||||
case 'thinking':
|
||||
return html`
|
||||
<div class="copilot-msg assistant copilot-markdown ${msg.failed ? 'copilot-msg--failed' : ''}">
|
||||
${msg.failed ? failedBadge() : nothing}
|
||||
${unsafeHTML(renderMarkdown(msg.content))}
|
||||
${msg.input_tokens != null ? html`<div class="copilot-token-count">↑${msg.input_tokens.toLocaleString()} tok ↓${msg.output_tokens?.toLocaleString()} tok</div>` : nothing}
|
||||
</div>`;
|
||||
case 'assistant':
|
||||
return html`
|
||||
<div class="copilot-msg assistant copilot-markdown ${msg.failed ? 'copilot-msg--failed' : ''}">
|
||||
${msg.failed ? failedBadge() : nothing}
|
||||
${unsafeHTML(renderMarkdown(msg.content))}
|
||||
${msg.input_tokens != null ? html`<div class="copilot-token-count">↑${msg.input_tokens.toLocaleString()} tok ↓${msg.output_tokens?.toLocaleString()} tok</div>` : nothing}
|
||||
</div>`;
|
||||
case 'error':
|
||||
return html`
|
||||
<div class="copilot-msg error">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>${msg.content}
|
||||
</div>`;
|
||||
case 'info':
|
||||
return html`
|
||||
<div class="copilot-msg info">
|
||||
${msg.content}
|
||||
</div>`;
|
||||
case 'pending_write':
|
||||
return renderPendingWrite(host, msg);
|
||||
case 'tool':
|
||||
return renderTool(host, msg);
|
||||
case 'agent':
|
||||
return renderAgent(msg);
|
||||
case 'agent_end':
|
||||
return renderAgentEnd(msg);
|
||||
default:
|
||||
return nothing;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[renderMsg] kind=' + msg.kind, err);
|
||||
return html`<div class="copilot-msg error">Render error [${msg.kind}]: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { ChatSession } from '../lib/chat-session.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' },
|
||||
];
|
||||
|
||||
export class AppCopilot extends ChatSession {
|
||||
static properties = {
|
||||
_collapsed: { state: true },
|
||||
_modelOpen: { state: true },
|
||||
_tabs: { state: true },
|
||||
_activeSource: { state: true },
|
||||
_cmdMenu: { state: true },
|
||||
_cmdSel: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._collapsed = false;
|
||||
this._modelOpen = false;
|
||||
this._resizing = false;
|
||||
// Slash-command autocomplete: `_cmdMenu` is the filtered list currently shown
|
||||
// (null = hidden), `_cmdSel` the highlighted index, `_allCommands` the merged
|
||||
// system + custom list fetched once from `/api/commands`.
|
||||
this._cmdMenu = null;
|
||||
this._cmdSel = 0;
|
||||
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._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);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback?.();
|
||||
this._restoreState();
|
||||
this._loadCommands();
|
||||
window.addEventListener('keydown', this._onKeydown);
|
||||
window.addEventListener('keyup', this._onKeyup);
|
||||
window.addEventListener('project-chat-open', this._onProjectChatOpen);
|
||||
window.addEventListener('copilot-open', this._onCopilotOpen);
|
||||
}
|
||||
|
||||
_restoreState() {
|
||||
const w = localStorage.getItem('copilot-width');
|
||||
if (w) document.documentElement.style.setProperty('--copilot-width', w);
|
||||
if (localStorage.getItem('copilot-collapsed') === 'true') {
|
||||
this._setCollapsed(true);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback?.();
|
||||
window.removeEventListener('keydown', this._onKeydown);
|
||||
window.removeEventListener('keyup', this._onKeyup);
|
||||
window.removeEventListener('project-chat-open', this._onProjectChatOpen);
|
||||
window.removeEventListener('copilot-open', this._onCopilotOpen);
|
||||
}
|
||||
|
||||
_onCopilotOpen() {
|
||||
this._setCollapsed(false);
|
||||
}
|
||||
|
||||
_setCollapsed(value) {
|
||||
this._collapsed = value;
|
||||
this.classList.toggle('collapsed', value);
|
||||
localStorage.setItem('copilot-collapsed', value);
|
||||
window.dispatchEvent(new CustomEvent('copilot-collapsed', { detail: { collapsed: value } }));
|
||||
}
|
||||
|
||||
// ── Tabs ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// A project chat was opened elsewhere (e.g. the project board): add its tab if
|
||||
// new, expand the copilot, and switch the live connection to it.
|
||||
_onProjectChatOpen(e) {
|
||||
const { source, label } = e.detail ?? {};
|
||||
if (!source) return;
|
||||
if (!this._tabs.some(t => t.source === source)) {
|
||||
this._tabs = [...this._tabs, { source, label: label || source }];
|
||||
}
|
||||
this._setCollapsed(false);
|
||||
this._selectTab(source);
|
||||
}
|
||||
|
||||
_selectTab(source) {
|
||||
if (source === this._source) return;
|
||||
this._switchSource(source); // base: tear down WS, reload history, reconnect
|
||||
}
|
||||
|
||||
// Close a project tab (UI only — the session persists server-side and can be
|
||||
// reopened from the board). The 'web'/General tab is never closable.
|
||||
_closeTab(source, e) {
|
||||
e?.stopPropagation();
|
||||
if (source === 'web') return;
|
||||
const wasActive = source === this._source;
|
||||
this._tabs = this._tabs.filter(t => t.source !== source);
|
||||
if (wasActive) this._switchSource('web');
|
||||
}
|
||||
|
||||
// ── DOM hooks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_inputEl() {
|
||||
return this.querySelector('.copilot-textarea');
|
||||
}
|
||||
|
||||
_scrollToBottom() {
|
||||
this.updateComplete.then(() => {
|
||||
const el = this.querySelector('.copilot-messages');
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
_onMessagePushed(item) {
|
||||
if (item.kind === 'pending_write') {
|
||||
this.updateComplete.then(() => {
|
||||
const panels = this.querySelectorAll('.copilot-approval');
|
||||
const el = panels[panels.length - 1];
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
});
|
||||
} else {
|
||||
this._scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resize ────────────────────────────────────────────────────────────────────
|
||||
|
||||
_startResize(e) {
|
||||
this._resizing = true;
|
||||
this._resizeStartX = e.clientX;
|
||||
this._resizeStartW = this.offsetWidth;
|
||||
window.addEventListener('mousemove', this._onResizeMove);
|
||||
window.addEventListener('mouseup', this._onResizeUp);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
_onResizeMove(e) {
|
||||
if (!this._resizing) return;
|
||||
const delta = this._resizeStartX - e.clientX;
|
||||
const newWidth = Math.max(260, Math.min(720, this._resizeStartW + delta));
|
||||
document.documentElement.style.setProperty('--copilot-width', `${newWidth}px`);
|
||||
}
|
||||
|
||||
_onResizeUp() {
|
||||
this._resizing = false;
|
||||
window.removeEventListener('mousemove', this._onResizeMove);
|
||||
window.removeEventListener('mouseup', this._onResizeUp);
|
||||
const w = getComputedStyle(document.documentElement).getPropertyValue('--copilot-width').trim();
|
||||
if (w) localStorage.setItem('copilot-width', w);
|
||||
}
|
||||
|
||||
// ── Input ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
_handleKeydown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this._send();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slash-command autocomplete ────────────────────────────────────────────────
|
||||
|
||||
/** Fetch custom commands once and merge them below the built-in system ones. */
|
||||
async _loadCommands() {
|
||||
try {
|
||||
const res = await fetch('/api/commands');
|
||||
const custom = res.ok ? await res.json() : [];
|
||||
this._allCommands = [...SYSTEM_COMMAND_ITEMS, ...custom];
|
||||
} catch {
|
||||
this._allCommands = [...SYSTEM_COMMAND_ITEMS];
|
||||
}
|
||||
}
|
||||
|
||||
/** Recompute the menu from the current input value. Shown only while typing the
|
||||
* command name (a leading `/` with no whitespace yet); hidden once args start. */
|
||||
_updateCmdMenu(value) {
|
||||
const m = /^\/([a-z0-9_-]*)$/i.exec(value);
|
||||
if (!m) { if (this._cmdMenu) this._cmdMenu = null; return; }
|
||||
const prefix = m[1].toLowerCase();
|
||||
const items = (this._allCommands || SYSTEM_COMMAND_ITEMS)
|
||||
.filter(c => c.name.toLowerCase().startsWith(prefix));
|
||||
this._cmdMenu = items.length ? items : null;
|
||||
this._cmdSel = 0;
|
||||
}
|
||||
|
||||
/** Insert the chosen command (`/name `) and close the menu, ready for arguments. */
|
||||
_applyCmd(name) {
|
||||
const el = this._inputEl();
|
||||
if (el) {
|
||||
el.value = `/${name} `;
|
||||
el.focus();
|
||||
this._autoResize(el);
|
||||
}
|
||||
this._cmdMenu = null;
|
||||
}
|
||||
|
||||
/** Composer keydown: drive the menu when open, else fall back to send-on-Enter. */
|
||||
_composerKeydown(e) {
|
||||
const menu = this._cmdMenu;
|
||||
if (menu && menu.length) {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); this._cmdSel = (this._cmdSel + 1) % menu.length; return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); this._cmdSel = (this._cmdSel - 1 + menu.length) % menu.length; return; }
|
||||
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); this._applyCmd(menu[this._cmdSel].name); return; }
|
||||
if (e.key === 'Escape') { e.preventDefault(); this._cmdMenu = null; return; }
|
||||
}
|
||||
this._handleKeydown(e);
|
||||
}
|
||||
|
||||
// ── Ctrl+Space push-to-talk shortcut (desktop only) ──────────────────────────
|
||||
// Voice recording + transcription is owned by the ChatSession base class; the
|
||||
// only desktop-specific bit is the global Ctrl+Space hold-to-record shortcut.
|
||||
|
||||
_onKeydown(e) {
|
||||
if (!this._hasTranscribe) return;
|
||||
if (e.code === 'Space' && e.ctrlKey && !e.repeat) {
|
||||
e.preventDefault();
|
||||
if (!this._recording) this._startRecording(true);
|
||||
}
|
||||
}
|
||||
|
||||
_onKeyup(e) {
|
||||
if (!this._hasTranscribe) return;
|
||||
if (e.code === 'Space' && this._recording && this._shortcutRecording) {
|
||||
e.preventDefault();
|
||||
this._stopRecording();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
_toggleExpand(id) {
|
||||
const next = new Set(this._expanded);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
this._expanded = next;
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
if (this._collapsed) return nothing;
|
||||
|
||||
return html`
|
||||
<div class="copilot-resize-handle" @mousedown=${(e) => this._startResize(e)}></div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
${this._tabs.length > 1 ? html`
|
||||
<div class="copilot-tabs">
|
||||
${this._tabs.map(t => html`
|
||||
<div
|
||||
class="copilot-tab ${t.source === this._source ? 'copilot-tab--active' : ''}"
|
||||
@click=${() => this._selectTab(t.source)}
|
||||
title=${t.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)}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
` : nothing}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
` : 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._waiting ? html`
|
||||
<div class="copilot-msg assistant copilot-thinking">
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Thinking…
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
<div class="copilot-input-area">
|
||||
<div class="copilot-composer"
|
||||
@dragover=${(e) => e.preventDefault()}
|
||||
@drop=${(e) => this._onDrop(e)}>
|
||||
${this._cmdMenu?.length ? html`
|
||||
<div class="copilot-cmd-menu">
|
||||
${this._cmdMenu.map((c, i) => html`
|
||||
<button
|
||||
class="copilot-cmd-item ${i === this._cmdSel ? 'active' : ''}"
|
||||
@mousedown=${(e) => { e.preventDefault(); this._applyCmd(c.name); }}
|
||||
>
|
||||
<span class="copilot-cmd-name">/${c.name}</span>
|
||||
<span class="copilot-cmd-desc">${c.description}</span>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
${renderAttachmentChips(this, this._attachments, { removable: true })}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
class="copilot-file-input"
|
||||
style="display:none"
|
||||
@change=${(e) => { this._addFiles(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<textarea
|
||||
class="copilot-textarea"
|
||||
rows="1"
|
||||
placeholder="Ask the copilot… (Enter to send, Shift+Enter for new line)"
|
||||
@keydown=${this._composerKeydown}
|
||||
@input=${(e) => { this._autoResize(e.target); this._updateCmdMenu(e.target.value); }}
|
||||
@paste=${(e) => this._onPaste(e)}
|
||||
></textarea>
|
||||
<div class="copilot-toolbar">
|
||||
<div class="copilot-toolbar-left">
|
||||
<button
|
||||
class="copilot-toolbar-btn"
|
||||
title="Attach files"
|
||||
@click=${() => this.querySelector('.copilot-file-input')?.click()}
|
||||
><i class="bi bi-paperclip"></i></button>
|
||||
${this._providers.length > 1 ? html`
|
||||
<div class="copilot-model-wrap">
|
||||
${this._modelOpen ? html`
|
||||
<div class="copilot-model-overlay" @click=${() => { this._modelOpen = false; }}></div>
|
||||
<div class="copilot-model-dropdown">
|
||||
${this._providers.map(p => html`
|
||||
<button
|
||||
class="copilot-model-item ${p === this._selectedClient ? 'active' : ''}"
|
||||
@click=${() => { this._selectClient(p); this._modelOpen = false; }}
|
||||
>${p}</button>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
<button class="copilot-model-pill" @click=${() => { this._modelOpen = !this._modelOpen; }}>
|
||||
<i class="bi bi-stars"></i>
|
||||
<span>${this._selectedClient ?? 'auto'}</span>
|
||||
<i class="bi bi-chevron-${this._modelOpen ? 'down' : 'up'}"></i>
|
||||
</button>
|
||||
</div>
|
||||
` : nothing}
|
||||
<button
|
||||
class="copilot-toolbar-btn"
|
||||
title="New session"
|
||||
@click=${() => this._startNewSession()}
|
||||
><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
<div class="copilot-toolbar-right">
|
||||
${this._hasTranscribe ? html`
|
||||
<button
|
||||
class="copilot-send-btn ${this._recording ? 'copilot-send-btn--recording' : ''}"
|
||||
title="${this._recording ? 'Stop recording' : 'Record voice (Ctrl+Space)'}"
|
||||
@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">
|
||||
<i class="bi bi-stop-fill"></i>
|
||||
</button>`
|
||||
: nothing}
|
||||
<button class="copilot-send-btn" @click=${() => this._send()} title="Send">
|
||||
<i class="bi bi-send-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { FileViewerBase } from './shared/file-viewer-base.js';
|
||||
|
||||
const PAGE_ID = 'file_viewer';
|
||||
|
||||
function pathFromHash() {
|
||||
const h = location.hash;
|
||||
const prefix = `#${PAGE_ID}?path=`;
|
||||
if (!h.startsWith(prefix)) return null;
|
||||
try {
|
||||
return decodeURIComponent(h.slice(prefix.length));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop file-viewer page. Self-routes off the hash (`#file_viewer?path=...`):
|
||||
* the sidebar's `llm-page-change` event toggles visibility and `hashchange`
|
||||
* re-loads. All fetch/render/watch logic lives in `FileViewerBase`; this
|
||||
* subclass only adds the desktop chrome and the hash wiring.
|
||||
*/
|
||||
export class FileViewerPage extends FileViewerBase {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === PAGE_ID;
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._loadFromHash();
|
||||
else this._hide();
|
||||
});
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (this._open) this._loadFromHash();
|
||||
});
|
||||
}
|
||||
|
||||
_loadFromHash() {
|
||||
const path = pathFromHash();
|
||||
if (path) this._show(path);
|
||||
}
|
||||
|
||||
_back() {
|
||||
history.back();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
return html`
|
||||
<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()}>
|
||||
<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()}>
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fv-body">${this._renderBody()}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.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) {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
...super.properties,
|
||||
_open: { state: true },
|
||||
_models: { state: true },
|
||||
_plugins: { state: true },
|
||||
_debugMode: { state: true },
|
||||
_debugLoading: { state: true },
|
||||
_stats: { state: true },
|
||||
_statsRange: { state: true },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
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 = {};
|
||||
this._statsTimer = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'home';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) {
|
||||
this._loadAll();
|
||||
this._loadStats();
|
||||
this._startPolling();
|
||||
} else {
|
||||
this._stopPolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._stopPolling();
|
||||
this._destroyCharts();
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
super.updated?.(changed);
|
||||
if (changed.has('_stats') && this._stats !== null) {
|
||||
requestAnimationFrame(() => this._initCharts());
|
||||
}
|
||||
}
|
||||
|
||||
_startPolling() {
|
||||
this._stopPolling();
|
||||
this._pollTimer = setInterval(() => this._loadAll(), 10_000);
|
||||
this._statsTimer = setInterval(() => this._loadStats(), 180_000);
|
||||
}
|
||||
|
||||
_stopPolling() {
|
||||
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
|
||||
if (this._statsTimer) { clearInterval(this._statsTimer); this._statsTimer = null; }
|
||||
}
|
||||
|
||||
async _loadAll() {
|
||||
await Promise.all([
|
||||
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');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._models = await res.json();
|
||||
} catch {
|
||||
this._models = [];
|
||||
}
|
||||
}
|
||||
|
||||
async _loadPlugins() {
|
||||
try {
|
||||
const res = await fetch('/api/plugins');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._plugins = await res.json();
|
||||
} catch {
|
||||
this._plugins = [];
|
||||
}
|
||||
}
|
||||
|
||||
async _loadStats() {
|
||||
try {
|
||||
const res = await fetch(`/api/stats/llm?range=${this._statsRange}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._stats = await res.json();
|
||||
} catch {
|
||||
this._stats = { daily: [], models: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async _setRange(range) {
|
||||
if (range === this._statsRange) return;
|
||||
this._statsRange = range;
|
||||
this._stats = null;
|
||||
await this._loadStats();
|
||||
}
|
||||
|
||||
get _honchoActive() {
|
||||
return this._plugins?.some(p => p.id === 'honcho' && p.enabled && p.running) ?? false;
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
_nav(page) {
|
||||
const url = page === 'home' ? location.pathname : '#' + page;
|
||||
history.pushState({ page }, '', url);
|
||||
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } }));
|
||||
}
|
||||
|
||||
// ── Charts ────────────────────────────────────────────────────────────────
|
||||
|
||||
_destroyCharts() {
|
||||
for (const c of Object.values(this._chartInstances)) {
|
||||
c.destroy();
|
||||
}
|
||||
this._chartInstances = {};
|
||||
}
|
||||
|
||||
_shortModelName(name) {
|
||||
return name
|
||||
.replace(/^claude-/, '')
|
||||
.replace(/^gpt-/, '')
|
||||
.replace(/-\d{8}$/, '');
|
||||
}
|
||||
|
||||
get _periodLabel() {
|
||||
return { hour: '/ min', day: '/ hour', week: '/ day', month: '/ day' }[this._statsRange] ?? '/ day';
|
||||
}
|
||||
|
||||
// Generates the full sequence of expected slots for the current range and
|
||||
// merges with backend data, filling missing slots with zeros.
|
||||
_fillGaps(daily) {
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const slots = [];
|
||||
|
||||
if (this._statsRange === 'hour') {
|
||||
for (let i = 59; i >= 0; i--) {
|
||||
const d = new Date(now - i * 60_000);
|
||||
slots.push(`${pad(d.getHours())}:${pad(d.getMinutes())}`);
|
||||
}
|
||||
} else if (this._statsRange === 'day') {
|
||||
for (let i = 23; i >= 0; i--) {
|
||||
const d = new Date(now - i * 3_600_000);
|
||||
slots.push(`${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:00`);
|
||||
}
|
||||
} else {
|
||||
const count = this._statsRange === 'week' ? 7 : 30;
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
const d = new Date(now - i * 86_400_000);
|
||||
slots.push(`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`);
|
||||
}
|
||||
}
|
||||
|
||||
const index = new Map(daily.map(d => [d.day, d]));
|
||||
const zero = { requests: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, avg_duration_ms: 0 };
|
||||
return slots.map(s => ({ day: s, ...(index.get(s) ?? zero) }));
|
||||
}
|
||||
|
||||
_initCharts() {
|
||||
if (!window.Chart || !this._stats) return;
|
||||
this._destroyCharts();
|
||||
|
||||
const dark = document.documentElement.getAttribute('data-bs-theme') === 'dark';
|
||||
const gridColor = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.06)';
|
||||
const textColor = dark ? '#adb5bd' : '#6c757d';
|
||||
const isHour = this._statsRange === 'hour';
|
||||
|
||||
const filled = this._fillGaps(this._stats.daily);
|
||||
const days = filled.map(d => d.day);
|
||||
const req = filled.map(d => d.requests);
|
||||
const inp = filled.map(d => d.input_tokens);
|
||||
const out = filled.map(d => d.output_tokens);
|
||||
const cache = filled.map(d => d.cache_read_tokens);
|
||||
// null for empty slots so the latency line doesn't touch zero where there were no requests
|
||||
const lat = filled.map(d => d.requests > 0 ? Math.round(d.avg_duration_ms) : null);
|
||||
const models = this._stats.models;
|
||||
|
||||
const axisDefaults = () => ({
|
||||
ticks: { color: textColor, font: { size: 11 } },
|
||||
grid: { color: gridColor },
|
||||
border: { color: gridColor },
|
||||
});
|
||||
|
||||
const xAxis = () => ({
|
||||
...axisDefaults(),
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { size: 11 },
|
||||
maxTicksLimit: isHour ? 7 : 15,
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const baseOpts = (extraPlugins = {}) => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 300 },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
...extraPlugins,
|
||||
},
|
||||
scales: {
|
||||
x: xAxis(),
|
||||
y: { ...axisDefaults(), beginAtZero: true },
|
||||
},
|
||||
});
|
||||
|
||||
const barDs = (data, color) => ({
|
||||
data, backgroundColor: color, borderRadius: 4, borderSkipped: false,
|
||||
});
|
||||
|
||||
const lineDs = (data, borderColor, bgColor, opts = {}) => ({
|
||||
data, borderColor, backgroundColor: bgColor,
|
||||
fill: true, tension: 0.3, pointRadius: 0, borderWidth: 2,
|
||||
...opts,
|
||||
});
|
||||
|
||||
const type = isHour ? 'line' : 'bar';
|
||||
const get = id => this.querySelector(`#${id}`);
|
||||
|
||||
// Requests
|
||||
const c1 = get('chart-requests');
|
||||
if (c1) this._chartInstances.requests = new Chart(c1, {
|
||||
type,
|
||||
data: {
|
||||
labels: days,
|
||||
datasets: [isHour
|
||||
? lineDs(req, '#3b82f6', 'rgba(59,130,246,0.12)')
|
||||
: barDs(req, '#3b82f6')],
|
||||
},
|
||||
options: baseOpts(),
|
||||
});
|
||||
|
||||
// Tokens
|
||||
const c2 = get('chart-tokens');
|
||||
if (c2) this._chartInstances.tokens = new Chart(c2, {
|
||||
type,
|
||||
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' }),
|
||||
] : (() => {
|
||||
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 },
|
||||
];
|
||||
})(),
|
||||
},
|
||||
options: baseOpts({
|
||||
legend: {
|
||||
display: true,
|
||||
labels: { color: textColor, boxWidth: 10, font: { size: 11 } },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
footer(items) {
|
||||
const idx = items[0]?.dataIndex;
|
||||
const total = inp[idx] ?? 0;
|
||||
if (!total) return '';
|
||||
const pct = Math.round((cache[idx] ?? 0) / total * 100);
|
||||
return `Cache hit: ${pct}%`;
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Latency
|
||||
const c3 = get('chart-latency');
|
||||
if (c3) this._chartInstances.latency = new Chart(c3, {
|
||||
type,
|
||||
data: {
|
||||
labels: days,
|
||||
datasets: [isHour
|
||||
? lineDs(lat, '#8b5cf6', 'rgba(139,92,246,0.12)', { spanGaps: false })
|
||||
: barDs(lat.map(v => v ?? 0), '#8b5cf6')],
|
||||
},
|
||||
options: baseOpts(),
|
||||
});
|
||||
|
||||
// Models — always horizontal bar
|
||||
const c4 = get('chart-models');
|
||||
if (c4) this._chartInstances.models = new Chart(c4, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: models.map(m => this._shortModelName(m.model_name)),
|
||||
datasets: [{
|
||||
data: models.map(m => m.requests),
|
||||
backgroundColor: ['#3b82f6','#10b981','#f59e0b','#8b5cf6','#ef4444','#06b6d4'],
|
||||
borderRadius: 4,
|
||||
borderSkipped: false,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
...baseOpts(),
|
||||
indexAxis: 'y',
|
||||
scales: {
|
||||
x: { ...axisDefaults(), beginAtZero: true },
|
||||
y: { ...axisDefaults(), ticks: { color: textColor, font: { size: 10 } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderStats() {
|
||||
if (this._stats === null) {
|
||||
return html`<div class="home-stats-loading"><i class="bi bi-hourglass-split"></i> Loading stats…</div>`;
|
||||
}
|
||||
|
||||
const empty = this._stats.daily.length === 0 && this._stats.models.length === 0;
|
||||
if (empty) {
|
||||
return html`
|
||||
<div class="home-stats-empty">
|
||||
<i class="bi bi-bar-chart"></i>
|
||||
<span>No LLM requests in the selected range.</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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-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-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-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-canvas-wrap"><canvas id="chart-models"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const st = this._statusInfo;
|
||||
const noModels = this._models !== null && this._models.length === 0;
|
||||
const approvals = this._inboxData?.approvals ?? [];
|
||||
const clarifs = this._inboxData?.clarifications ?? [];
|
||||
const elicits = this._inboxData?.elicitations ?? [];
|
||||
const inboxTotal = approvals.length + clarifs.length + elicits.length;
|
||||
|
||||
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" />
|
||||
</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>
|
||||
<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}
|
||||
<span>${st.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── No-models banner ── -->
|
||||
${noModels ? html`
|
||||
<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.
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" @click=${() => this._nav('providers')}>
|
||||
Add a provider
|
||||
</button>
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<!-- ── LLM Stats ── -->
|
||||
<div class="home-section-title">
|
||||
<i class="bi bi-bar-chart-fill"></i>
|
||||
<span>LLM Stats</span>
|
||||
<div class="home-stats-range ms-auto">
|
||||
${[['hour','1h'],['day','24h'],['week','7d'],['month','30d']].map(([r, label]) => html`
|
||||
<button class="home-stats-range-btn ${this._statsRange === r ? 'active' : ''}"
|
||||
@click=${() => this._setRange(r)}>${label}</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
${this._renderStats()}
|
||||
|
||||
<!-- ── Pending inbox ── -->
|
||||
<div class="home-section-title">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<span>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()}>
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
${this._renderInboxSection()}
|
||||
|
||||
<!-- ── Honcho tip ── -->
|
||||
${!this._honchoActive ? html`
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<!-- ── Quick guide ── -->
|
||||
<div class="home-section-title">
|
||||
<i class="bi bi-map"></i>
|
||||
<span>Quick guide</span>
|
||||
</div>
|
||||
<div class="home-guide">
|
||||
${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>
|
||||
</div>
|
||||
<div class="home-card-body">
|
||||
<h6 class="home-card-title">${s.title}</h6>
|
||||
<p class="home-card-desc">${s.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
function emptyForm(firstTypeId = '') {
|
||||
return { name: '', type: firstTypeId, api_key: '', base_url: '', description: '' };
|
||||
}
|
||||
|
||||
export class LlmProvidersPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_providers: { state: true },
|
||||
_providerTypes: { state: true },
|
||||
_modelCounts: { state: true },
|
||||
_modal: { state: true },
|
||||
_saving: { state: true },
|
||||
_error: { state: true },
|
||||
_form: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._providers = [];
|
||||
this._providerTypes = [];
|
||||
this._modelCounts = {};
|
||||
this._modal = null;
|
||||
this._saving = false;
|
||||
this._error = null;
|
||||
this._form = emptyForm();
|
||||
}
|
||||
|
||||
_typeMeta(typeId) {
|
||||
return this._providerTypes.find(t => t.type_id === typeId) ?? { display_name: typeId, color: '#888', icon: 'bi-box', fields: [] };
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'providers';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._load();
|
||||
});
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const [typesRes, provRes, modelsRes] = await Promise.all([
|
||||
fetch('/api/llm/providers/types'),
|
||||
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}`);
|
||||
|
||||
const providerTypes = await typesRes.json();
|
||||
const providers = await provRes.json();
|
||||
const models = await modelsRes.json();
|
||||
|
||||
const counts = {};
|
||||
for (const m of models) {
|
||||
const pid = String(m.provider_id);
|
||||
counts[pid] = (counts[pid] || 0) + 1;
|
||||
}
|
||||
|
||||
this._providerTypes = providerTypes;
|
||||
this._providers = providers;
|
||||
this._modelCounts = counts;
|
||||
|
||||
// Set default form type to first available provider type
|
||||
if (!this._form.type && providerTypes.length > 0) {
|
||||
this._form = { ...this._form, type: providerTypes[0].type_id };
|
||||
}
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_openAdd() {
|
||||
this._error = null;
|
||||
this._form = emptyForm(this._providerTypes[0]?.type_id ?? '');
|
||||
this._modal = { mode: 'add' };
|
||||
}
|
||||
|
||||
async _openEdit(provider) {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/llm/providers/${provider.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const record = await res.json();
|
||||
this._form = {
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
api_key: record.api_key ?? '',
|
||||
base_url: record.base_url ?? '',
|
||||
description: record.description ?? '',
|
||||
};
|
||||
this._modal = { mode: 'edit', id: record.id };
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _delete(provider) {
|
||||
if (!confirm(`Delete provider "${provider.name}"? All associated models will be deleted too.`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/llm/providers/${provider.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _onSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
|
||||
const f = this._form;
|
||||
const meta = this._typeMeta(f.type);
|
||||
const needsBaseUrl = meta.fields.some(field => field.key === 'base_url');
|
||||
const payload = {
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
api_key: f.api_key || null,
|
||||
base_url: needsBaseUrl ? (f.base_url || null) : null,
|
||||
description: f.description || null,
|
||||
};
|
||||
|
||||
const isEdit = this._modal?.mode === 'edit';
|
||||
const url = isEdit ? `/api/llm/providers/${this._modal.id}` : '/api/llm/providers';
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: isEdit ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
_setField(field, value) {
|
||||
this._form = { ...this._form, [field]: value };
|
||||
}
|
||||
|
||||
_closeModal() { this._modal = null; this._error = null; }
|
||||
|
||||
// ── Render helpers ────────────────────────────────────────────────────────
|
||||
|
||||
_renderCard(p) {
|
||||
const meta = this._typeMeta(p.type);
|
||||
const color = meta.color;
|
||||
const icon = meta.icon;
|
||||
const label = meta.display_name;
|
||||
const count = this._modelCounts[String(p.id)];
|
||||
const hasKey = Boolean(p.api_key);
|
||||
const needsUrl = meta.fields.some(f => f.key === 'base_url');
|
||||
|
||||
return html`
|
||||
<div class="pv-card" style="--pv-color: ${color}">
|
||||
<div class="pv-card-row1">
|
||||
<div class="pv-card-icon" style="background: color-mix(in srgb, ${color} 14%, transparent); color: ${color}">
|
||||
<i class="bi ${icon}"></i>
|
||||
</div>
|
||||
<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">
|
||||
<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)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="pv-btn-icon pv-btn-delete" title="Delete" @click=${() => this._delete(p)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${p.description ? html`
|
||||
<div class="pv-card-row2">
|
||||
<span class="pv-card-desc">${p.description}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<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'}
|
||||
</span>
|
||||
${needsUrl && p.base_url ? html`
|
||||
<span class="pv-card-tag pv-tag-url" title="Base URL">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
<span class="pv-card-url-text">${p.base_url}</span>
|
||||
</span>
|
||||
` : ''}
|
||||
${p.created_at ? html`
|
||||
<span class="pv-card-tag pv-tag-date">
|
||||
<i class="bi bi-calendar3"></i>
|
||||
${new Date(p.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderModal() {
|
||||
const isEdit = this._modal?.mode === 'edit';
|
||||
const f = this._form;
|
||||
const meta = this._typeMeta(f.type);
|
||||
const needsKey = meta.fields.some(field => field.key === 'api_key');
|
||||
const needsUrl = meta.fields.some(field => field.key === 'base_url');
|
||||
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog pv-modal">
|
||||
<div class="pv-modal-header">
|
||||
<i class="bi bi-plug"></i>
|
||||
<span>${isEdit ? 'Edit Provider' : 'Add Provider'}</span>
|
||||
<button type="button" class="pv-modal-close" @click=${() => this._closeModal()}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</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._onSubmit(e)}>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">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)} />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">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>`)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
${needsKey ? html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">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' : ''}
|
||||
@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>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.base_url}
|
||||
placeholder=${f.type === 'ollama' ? 'http://localhost:11434' : 'http://localhost:1234/v1'}
|
||||
@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>
|
||||
<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="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'}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Main render ───────────────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="pv-page">
|
||||
<div class="pv-header">
|
||||
<h2 class="pv-title">
|
||||
<i class="bi bi-plug me-2"></i>Providers
|
||||
</h2>
|
||||
<div class="pv-header-right">
|
||||
<span class="pv-header-count">${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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error && !this._modal ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : ''}
|
||||
|
||||
<div class="pv-card-list">
|
||||
${this._providers.length === 0 ? html`
|
||||
<div class="pv-empty">
|
||||
<i class="bi bi-plug"></i>
|
||||
<p>No providers configured yet.</p>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add your first provider
|
||||
</button>
|
||||
</div>
|
||||
` : this._providers.map(p => this._renderCard(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._modal ? this._renderModal() : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement, renderMarkdown } from '../lib/base.js';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function fmtTokens(n) {
|
||||
if (n == null) return '—';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function cacheHitPct(item) {
|
||||
if (item.cache_read_tokens == null || !item.input_tokens) return '—';
|
||||
return (item.cache_read_tokens / item.input_tokens * 100).toFixed(0) + '%';
|
||||
}
|
||||
|
||||
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`);
|
||||
return parts.length ? parts.join(' | ') : '';
|
||||
}
|
||||
|
||||
function parseJson(str) {
|
||||
if (!str) return null;
|
||||
try { return JSON.parse(str); } catch { return null; }
|
||||
}
|
||||
|
||||
function extractSystem(req) {
|
||||
if (!req) return null;
|
||||
// Anthropic: top-level system field
|
||||
if (req.system != null) {
|
||||
if (typeof req.system === 'string') return req.system;
|
||||
if (Array.isArray(req.system)) {
|
||||
return req.system.map(b => (typeof b === 'string' ? b : b.text ?? '')).join('\n\n');
|
||||
}
|
||||
}
|
||||
// OpenAI: only the very first message if it is role=system
|
||||
if (Array.isArray(req.messages) && req.messages[0]?.role === 'system') {
|
||||
return typeof req.messages[0].content === 'string' ? req.messages[0].content : '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractMessages(req) {
|
||||
if (!req || !Array.isArray(req.messages)) return [];
|
||||
let msgs = req.messages;
|
||||
// For OpenAI format: skip the first message if it was already shown as the System Prompt
|
||||
if (!req.system && msgs[0]?.role === 'system') msgs = msgs.slice(1);
|
||||
// All remaining messages are kept, including mid-conversation role=system inserts
|
||||
return msgs;
|
||||
}
|
||||
|
||||
function extractParams(req) {
|
||||
if (!req) return [];
|
||||
const skip = new Set(['model', 'messages', 'system', 'tools', 'tool_choice', 'stream']);
|
||||
return Object.entries(req)
|
||||
.filter(([k]) => !skip.has(k))
|
||||
.map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : String(v)]);
|
||||
}
|
||||
|
||||
function extractTools(req) {
|
||||
return req?.tools ?? [];
|
||||
}
|
||||
|
||||
function extractRespBlocks(resp) {
|
||||
if (!resp) return [];
|
||||
if (Array.isArray(resp.content)) return resp.content; // Anthropic
|
||||
const msg = resp?.choices?.[0]?.message;
|
||||
if (msg) return contentBlocks(msg); // OpenAI — reuse helper (handles reasoning_content, tool_calls)
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractRespMeta(resp) {
|
||||
if (!resp) return [];
|
||||
const pairs = [];
|
||||
const skip = new Set(['content', 'choices', 'type', 'role', 'object', 'usage']);
|
||||
for (const [k, v] of Object.entries(resp)) {
|
||||
if (skip.has(k)) continue;
|
||||
pairs.push([k, typeof v === 'object' ? JSON.stringify(v) : String(v)]);
|
||||
}
|
||||
// OpenAI: finish_reason lives inside choices
|
||||
const choice = resp?.choices?.[0];
|
||||
if (choice?.finish_reason && !resp.stop_reason) {
|
||||
pairs.push(['finish_reason', choice.finish_reason]);
|
||||
}
|
||||
// usage — flatten sub-keys
|
||||
if (resp.usage && typeof resp.usage === 'object') {
|
||||
for (const [k, v] of Object.entries(resp.usage)) {
|
||||
if (v != null) pairs.push([`usage.${k}`, typeof v === 'object' ? JSON.stringify(v) : String(v)]);
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function paramsPreview(input) {
|
||||
if (!input || Object.keys(input).length === 0) return '';
|
||||
const str = JSON.stringify(input);
|
||||
return str.length > 80 ? str.slice(0, 77) + '…' : str;
|
||||
}
|
||||
|
||||
function normalizeToolResultContent(block) {
|
||||
if (Array.isArray(block.content))
|
||||
return block.content.map(b => b.text ?? JSON.stringify(b)).join('\n');
|
||||
if (typeof block.content === 'string') return block.content;
|
||||
return JSON.stringify(block.content ?? '');
|
||||
}
|
||||
|
||||
function buildToolResultMap(msgs) {
|
||||
const map = new Map(); // tool_use_id → { content, is_error }
|
||||
for (const msg of msgs) {
|
||||
// Anthropic format: tool_result blocks inside user message content
|
||||
for (const block of contentBlocks(msg)) {
|
||||
if (block.type === 'tool_result') {
|
||||
map.set(block.tool_use_id, {
|
||||
content: normalizeToolResultContent(block),
|
||||
is_error: !!block.is_error,
|
||||
});
|
||||
}
|
||||
}
|
||||
// OpenAI format: role='tool' messages carry the result directly
|
||||
if (msg.role === 'tool' && msg.tool_call_id) {
|
||||
const content = typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: (Array.isArray(msg.content) ? msg.content.map(b => b.text ?? JSON.stringify(b)).join('\n') : JSON.stringify(msg.content ?? ''));
|
||||
map.set(msg.tool_call_id, { content, is_error: false });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function contentBlocks(msg) {
|
||||
const blocks = [];
|
||||
if (typeof msg.reasoning_content === 'string' && msg.reasoning_content) {
|
||||
blocks.push({ type: 'reasoning', text: msg.reasoning_content });
|
||||
}
|
||||
if (msg.content) {
|
||||
if (typeof msg.content === 'string') blocks.push({ type: 'text', text: msg.content });
|
||||
else if (Array.isArray(msg.content)) blocks.push(...msg.content);
|
||||
}
|
||||
// OpenAI format: tool calls live in tool_calls[], not in content
|
||||
if (Array.isArray(msg.tool_calls)) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
let input = {};
|
||||
try { input = JSON.parse(tc.function?.arguments ?? '{}'); } catch { /* ignore */ }
|
||||
blocks.push({ type: 'tool_use', id: tc.id, name: tc.function?.name ?? '?', input });
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export class LlmRequestDetail extends LightElement {
|
||||
static properties = {
|
||||
detailId: { type: Number },
|
||||
_detail: { state: true },
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
_openSections: { state: true },
|
||||
_expandedTools: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.detailId = null;
|
||||
this._detail = null;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this._openSections = new Set(['response']);
|
||||
this._expandedTools = new Set();
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
if (changed.has('detailId') && this.detailId != null) {
|
||||
this._detail = null;
|
||||
this._error = null;
|
||||
this._openSections = new Set(['response']);
|
||||
this._expandedTools = new Set();
|
||||
this._fetch(this.detailId);
|
||||
}
|
||||
}
|
||||
|
||||
async _fetch(id) {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/dev/llm-requests/${id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._detail = await res.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_back() {
|
||||
this.dispatchEvent(new CustomEvent('detail-back', { bubbles: true }));
|
||||
}
|
||||
|
||||
_toggleSection(name) {
|
||||
const next = new Set(this._openSections);
|
||||
next.has(name) ? next.delete(name) : next.add(name);
|
||||
this._openSections = next;
|
||||
}
|
||||
|
||||
_toggleToolExpand(key) {
|
||||
const next = new Set(this._expandedTools);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
this._expandedTools = next;
|
||||
}
|
||||
|
||||
// ── Section wrapper ─────────────────────────────────────────────────────────
|
||||
|
||||
_renderSection(id, title, content, badge = null) {
|
||||
const open = this._openSections.has(id);
|
||||
return html`
|
||||
<div class="llmr-section ${open ? 'llmr-section--open' : ''}">
|
||||
<div class="llmr-section-header" @click=${() => this._toggleSection(id)}>
|
||||
<span class="llmr-section-chevron">
|
||||
<i class="bi bi-chevron-${open ? 'down' : 'right'}"></i>
|
||||
</span>
|
||||
<span class="llmr-section-title">${title}</span>
|
||||
${badge != null ? html`<span class="llmr-section-badge">${badge}</span>` : nothing}
|
||||
</div>
|
||||
<div class="llmr-section-body">
|
||||
${content}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Key-value table ──────────────────────────────────────────────────────────
|
||||
|
||||
_renderKvTable(pairs) {
|
||||
if (!pairs || pairs.length === 0) return html`<p class="text-secondary small mb-0">—</p>`;
|
||||
return html`
|
||||
<table class="llmr-kv-table">
|
||||
<tbody>
|
||||
${pairs.map(([k, v]) => html`
|
||||
<tr>
|
||||
<td class="llmr-kv-key">${k}</td>
|
||||
<td class="llmr-kv-val">${v}</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Stat bar ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderStatBar(d) {
|
||||
return html`
|
||||
<div class="llmr-detail-statbar">
|
||||
<span class="llmr-badge-agent">${d.agent_id ?? '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">
|
||||
<i class="bi bi-arrow-up-circle"></i> ${fmtTokens(d.input_tokens)}
|
||||
</span>
|
||||
<span class="llmr-detail-stat" title="Output tokens">
|
||||
<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)}
|
||||
</span>
|
||||
` : nothing}
|
||||
<span class="llmr-detail-stat">
|
||||
<i class="bi bi-clock"></i> ${d.duration_ms} ms
|
||||
</span>
|
||||
<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
|
||||
</span>
|
||||
` : nothing}
|
||||
</div>
|
||||
${d.error_text ? html`
|
||||
<div class="llmr-detail-error-box">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> ${d.error_text}
|
||||
</div>
|
||||
` : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Content blocks ───────────────────────────────────────────────────────────
|
||||
|
||||
// toolResultMap: Map<tool_use_id, {content, is_error}> — null when not available
|
||||
_renderContentBlock(block, keyPrefix, toolResultMap = null) {
|
||||
if (!block) return nothing;
|
||||
const type = block.type;
|
||||
|
||||
if (type === 'reasoning') {
|
||||
const key = `${keyPrefix}-reasoning`;
|
||||
const open = this._expandedTools.has(key);
|
||||
const text = block.text ?? '';
|
||||
return html`
|
||||
<div class="llmr-reasoning-block">
|
||||
<div class="llmr-reasoning-header" @click=${() => this._toggleToolExpand(key)}>
|
||||
<i class="bi bi-lightbulb"></i>
|
||||
<span>reasoning</span>
|
||||
<span class="llmr-tool-toggle ms-auto">
|
||||
<i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i>
|
||||
</span>
|
||||
</div>
|
||||
${open ? html`<pre class="llmr-text-block llmr-reasoning-body">${text}</pre>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === 'text') {
|
||||
const text = block.text ?? '';
|
||||
if (!text) return nothing;
|
||||
return html`<pre class="llmr-text-block">${text}</pre>`;
|
||||
}
|
||||
|
||||
if (type === 'tool_use') {
|
||||
const key = `${keyPrefix}-use-${block.id ?? block.name}`;
|
||||
const open = this._expandedTools.has(key);
|
||||
const args = block.input != null ? JSON.stringify(block.input, null, 2) : '{}';
|
||||
const preview = paramsPreview(block.input);
|
||||
const result = toolResultMap?.get(block.id);
|
||||
return html`
|
||||
<div class="llmr-tool-block llmr-tool-block--use ${result?.is_error ? 'llmr-tool-block--error' : ''}">
|
||||
<div class="llmr-tool-block-header" @click=${() => this._toggleToolExpand(key)}>
|
||||
<i class="bi bi-wrench"></i>
|
||||
<span class="llmr-tool-name">${block.name}</span>
|
||||
${preview ? html`<span class="llmr-tool-preview">${preview}</span>` : nothing}
|
||||
<span class="llmr-tool-toggle ms-auto">
|
||||
<i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i>
|
||||
</span>
|
||||
</div>
|
||||
${open ? html`
|
||||
<div class="llmr-tool-expanded">
|
||||
<div class="llmr-tool-section-label">Parameters</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}
|
||||
</div>
|
||||
<pre class="llmr-tool-pre">${result.content}</pre>
|
||||
` : nothing}
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// tool_result: only rendered when there is no toolResultMap (i.e. not in conversation context)
|
||||
if (type === 'tool_result') {
|
||||
if (toolResultMap != null) return nothing; // shown inline inside the tool_use block
|
||||
const key = `${keyPrefix}-result-${block.tool_use_id}`;
|
||||
const open = this._expandedTools.has(key);
|
||||
const content = normalizeToolResultContent(block);
|
||||
return html`
|
||||
<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-id">${block.tool_use_id ?? ''}</span>
|
||||
${block.is_error ? html`<span class="badge bg-danger ms-1">error</span>` : nothing}
|
||||
<span class="llmr-tool-toggle ms-auto">
|
||||
<i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i>
|
||||
</span>
|
||||
</div>
|
||||
${open ? html`<pre class="llmr-tool-pre">${content}</pre>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`<pre class="llmr-tool-pre">${JSON.stringify(block, null, 2)}</pre>`;
|
||||
}
|
||||
|
||||
_renderMessage(msg, idx, toolResultMap) {
|
||||
const role = msg.role ?? 'unknown';
|
||||
const blocks = contentBlocks(msg);
|
||||
|
||||
// skip messages that are entirely tool results (shown inline inside the tool_use block)
|
||||
// Anthropic: role=user messages whose content is all tool_result blocks
|
||||
// OpenAI: role=tool messages
|
||||
if (role === 'tool') return nothing;
|
||||
if (role === 'user' && blocks.length > 0 && blocks.every(b => b.type === 'tool_result')) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
// mid-conversation system prompt: render with markdown and a distinct style
|
||||
if (role === 'system') {
|
||||
const text = typeof msg.content === 'string' ? msg.content : '';
|
||||
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-body">
|
||||
<div class="llmr-system-md copilot-markdown">${unsafeHTML(renderMarkdown(text))}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="llmr-msg llmr-msg--${role}">
|
||||
<div class="llmr-msg-role">${role}</div>
|
||||
<div class="llmr-msg-body">
|
||||
${blocks.map((b, bi) => this._renderContentBlock(b, `msg-${idx}-${bi}`, toolResultMap))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderResponseBlock(block, idx) {
|
||||
if (!block) return nothing;
|
||||
// Delegate to _renderContentBlock for shared block types (reasoning, text, tool_use, tool_result)
|
||||
return this._renderContentBlock(block, `resp-${idx}`);
|
||||
}
|
||||
|
||||
// ── Main render ──────────────────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
if (this._loading) return html`
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
<div class="llmr-state">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||||
<span>Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this._error) return html`
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
<div class="llmr-state llmr-state--error">
|
||||
<i class="bi bi-exclamation-circle"></i>
|
||||
<span>${this._error}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!this._detail) return nothing;
|
||||
|
||||
const d = this._detail;
|
||||
const req = parseJson(d.request_json);
|
||||
const resp = parseJson(d.response_json);
|
||||
const hdrs = parseJson(d.request_headers);
|
||||
const respHdrs = parseJson(d.response_headers);
|
||||
const system = extractSystem(req);
|
||||
const msgs = extractMessages(req);
|
||||
const params = extractParams(req);
|
||||
const tools = extractTools(req);
|
||||
|
||||
const payloadMissing = !req && !resp;
|
||||
const respBlocks = extractRespBlocks(resp);
|
||||
const respMeta = extractRespMeta(resp);
|
||||
const toolResultMap = buildToolResultMap(msgs);
|
||||
|
||||
return html`
|
||||
<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
|
||||
</button>
|
||||
<span class="llmr-detail-title">
|
||||
<i class="bi bi-journal-code"></i> Request <span class="llmr-detail-id">#${d.id}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
${this._renderStatBar(d)}
|
||||
|
||||
${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.
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
${hdrs ? this._renderSection('req-headers', 'Request Headers',
|
||||
this._renderKvTable(Object.entries(hdrs))
|
||||
) : nothing}
|
||||
|
||||
${respHdrs ? this._renderSection('resp-headers', 'Response Headers',
|
||||
this._renderKvTable(Object.entries(respHdrs))
|
||||
) : nothing}
|
||||
|
||||
${params.length ? this._renderSection('params', 'Parameters',
|
||||
this._renderKvTable(params)
|
||||
) : nothing}
|
||||
|
||||
${system ? this._renderSection('system', 'System Prompt',
|
||||
html`<div class="llmr-system-md copilot-markdown">${unsafeHTML(renderMarkdown(system))}</div>`
|
||||
) : nothing}
|
||||
|
||||
${msgs.length ? this._renderSection('conversation', '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',
|
||||
html`<div class="llmr-tool-def-list">
|
||||
${tools.map((t, i) => {
|
||||
// Anthropic: { name, description, input_schema }
|
||||
// OpenAI: { type: 'function', function: { name, description, parameters } }
|
||||
const name = t.name ?? t.function?.name ?? '(unknown)';
|
||||
const desc = t.description ?? t.function?.description ?? '';
|
||||
const schema = t.input_schema ?? t.function?.parameters ?? null;
|
||||
const key = `tooldef-${i}-${name}`;
|
||||
const open = this._expandedTools.has(key);
|
||||
return html`
|
||||
<div class="llmr-tool-def ${open ? 'llmr-tool-def--open' : ''}">
|
||||
<div class="llmr-tool-def-header" @click=${() => this._toggleToolExpand(key)}>
|
||||
<i class="bi bi-wrench" style="color:#0891b2;font-size:.75rem"></i>
|
||||
<span class="llmr-tool-name">${name}</span>
|
||||
<span class="llmr-tool-def-desc">${desc}</span>
|
||||
${schema ? html`
|
||||
<span class="llmr-tool-toggle ms-auto">
|
||||
<i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i>
|
||||
</span>
|
||||
` : nothing}
|
||||
</div>
|
||||
${open && schema ? html`
|
||||
<pre class="llmr-tool-pre">${JSON.stringify(schema, null, 2)}</pre>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>`,
|
||||
tools.length
|
||||
) : nothing}
|
||||
|
||||
${resp ? this._renderSection('response', 'Response',
|
||||
html`
|
||||
${respMeta.length ? this._renderKvTable(respMeta) : nothing}
|
||||
<div class="llmr-msg-list llmr-msg-list--resp">
|
||||
${respBlocks.map((b, i) => this._renderResponseBlock(b, i))}
|
||||
</div>
|
||||
`
|
||||
) : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
const PAGE_ID = 'llm-requests';
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function fmtTokens(n) {
|
||||
if (n == null) return '—';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function cacheHitPct(item) {
|
||||
if (item.cache_read_tokens == null || !item.input_tokens) return '—';
|
||||
return (item.cache_read_tokens / item.input_tokens * 100).toFixed(0) + '%';
|
||||
}
|
||||
|
||||
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`);
|
||||
return parts.length ? parts.join(' | ') : '';
|
||||
}
|
||||
|
||||
export class LlmRequestsPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_items: { state: true },
|
||||
_total: { state: true },
|
||||
_page: { state: true },
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
_agentId: { state: true },
|
||||
_source: { state: true },
|
||||
_from: { state: true },
|
||||
_to: { state: true },
|
||||
_applied: { state: true },
|
||||
_detailId: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._items = [];
|
||||
this._total = 0;
|
||||
this._page = 1;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this._agentId = '';
|
||||
this._source = '';
|
||||
this._from = '';
|
||||
this._to = '';
|
||||
this._applied = {};
|
||||
this._detailId = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === PAGE_ID;
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) {
|
||||
const id = this._idFromHash();
|
||||
this._detailId = id;
|
||||
if (id == null && this._items.length === 0) this._fetch(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_idFromHash() {
|
||||
const parts = location.hash.replace('#', '').split('/');
|
||||
if (parts[0] === PAGE_ID && parts[1]) {
|
||||
const n = Number(parts[1]);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_openDetail(id) {
|
||||
this._detailId = id;
|
||||
history.pushState({}, '', `#${PAGE_ID}/${id}`);
|
||||
}
|
||||
|
||||
_back() {
|
||||
this._detailId = null;
|
||||
history.pushState({}, '', `#${PAGE_ID}`);
|
||||
if (this._items.length === 0) this._fetch(1);
|
||||
}
|
||||
|
||||
// ── List fetching ────────────────────────────────────────────────────────────
|
||||
|
||||
async _fetch(page) {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
const params = new URLSearchParams({ page });
|
||||
if (this._agentId) params.set('agent_id', this._agentId);
|
||||
if (this._source) params.set('source', this._source);
|
||||
if (this._from) params.set('from', this._from);
|
||||
if (this._to) params.set('to', this._to);
|
||||
this._applied = { agentId: this._agentId, source: this._source, from: this._from, to: this._to };
|
||||
try {
|
||||
const res = await fetch(`/api/dev/llm-requests?${params}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
this._items = data.items;
|
||||
this._total = data.total;
|
||||
this._page = data.page;
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_apply() { this._fetch(1); }
|
||||
|
||||
_reset() {
|
||||
this._agentId = '';
|
||||
this._source = '';
|
||||
this._from = '';
|
||||
this._to = '';
|
||||
this._fetch(1);
|
||||
}
|
||||
|
||||
get _totalPages() { return Math.max(1, Math.ceil(this._total / PAGE_SIZE)); }
|
||||
|
||||
// ── Renders ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderFilters() {
|
||||
return html`
|
||||
<div class="llmr-filters">
|
||||
<div class="llmr-filter-group">
|
||||
<label class="llmr-filter-label">Agent ID</label>
|
||||
<input class="form-control form-control-sm" type="text"
|
||||
placeholder="e.g. main"
|
||||
.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>
|
||||
<input class="form-control form-control-sm" type="text"
|
||||
placeholder="e.g. web, tic, cron"
|
||||
.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>
|
||||
<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>
|
||||
<input class="form-control form-control-sm" type="date"
|
||||
.value=${this._to}
|
||||
@change=${e => this._to = e.target.value} />
|
||||
</div>
|
||||
<div class="llmr-filter-actions">
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._apply()}
|
||||
?disabled=${this._loading}>
|
||||
Apply
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._reset()}
|
||||
?disabled=${this._loading}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderTable() {
|
||||
if (this._loading) return html`
|
||||
<div class="llmr-state">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||||
<span>Loading…</span>
|
||||
</div>
|
||||
`;
|
||||
if (this._error) return html`
|
||||
<div class="llmr-state llmr-state--error">
|
||||
<i class="bi bi-exclamation-circle"></i>
|
||||
<span>${this._error}</span>
|
||||
</div>
|
||||
`;
|
||||
if (this._items.length === 0) return html`
|
||||
<div class="llmr-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<span>No requests found.</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="llmr-table-wrap">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this._items.map(r => html`
|
||||
<tr class="${r.error_text ? 'llmr-row--error' : ''} llmr-row--clickable"
|
||||
@click=${() => this._openDetail(r.id)}>
|
||||
<td><span class="llmr-badge-agent">${r.agent_id ?? '—'}</span></td>
|
||||
<td><span class="llmr-badge-source">${r.source ?? '—'}</span></td>
|
||||
<td class="llmr-model">${r.model_name}</td>
|
||||
<td class="llmr-date">${formatDate(r.created_at)}</td>
|
||||
<td class="text-end llmr-num">${fmtTokens(r.input_tokens)}</td>
|
||||
<td class="text-end llmr-num">${fmtTokens(r.output_tokens)}</td>
|
||||
<td class="text-end llmr-num ${r.cache_read_tokens > 0 ? 'llmr-cache-hit' : ''}"
|
||||
title=${cacheTooltip(r)}>
|
||||
${cacheHitPct(r)}
|
||||
</td>
|
||||
<td class="text-end llmr-num">${r.duration_ms}</td>
|
||||
</tr>
|
||||
${r.error_text ? html`
|
||||
<tr class="llmr-row--error-detail">
|
||||
<td colspan="8">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> ${r.error_text}
|
||||
</td>
|
||||
</tr>
|
||||
` : nothing}
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderPagination() {
|
||||
if (this._totalPages <= 1) return nothing;
|
||||
const pages = this._totalPages;
|
||||
const cur = this._page;
|
||||
return html`
|
||||
<div class="llmr-pagination">
|
||||
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur <= 1}
|
||||
@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>
|
||||
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur >= pages}
|
||||
@click=${() => this._fetch(cur + 1)}>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._detailId != null) {
|
||||
return html`
|
||||
<llm-request-detail
|
||||
.detailId=${this._detailId}
|
||||
@detail-back=${() => this._back()}>
|
||||
</llm-request-detail>
|
||||
`;
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
${this._renderFilters()}
|
||||
${this._renderTable()}
|
||||
${this._renderPagination()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { LitElement, html, nothing } from 'lit';
|
||||
import './shared/inbox-page.js';
|
||||
import './shared/chat-page.js';
|
||||
import './shared/projects-page.js';
|
||||
import './shared/file-viewer-mobile.js';
|
||||
|
||||
// Sections addressable via the URL hash — same routing style as the desktop
|
||||
// sidebar (web/components/sidebar.js). The native iOS shell and mobile browsers
|
||||
// share this router so the URL always reflects the active section: native menu
|
||||
// sync, deep links, and back/refresh restoration all flow from one place.
|
||||
// `file_viewer` is not a tab — it's opened from content (a clickable tool path
|
||||
// via openFile() → `#file_viewer?path=...`) and so has no bottom-nav entry.
|
||||
const VALID_SECTIONS = ['inbox', 'projects', 'chat', 'notifications', 'settings', 'file_viewer'];
|
||||
|
||||
class MobileApp extends LitElement {
|
||||
// No shadow DOM — lets external CSS and Bootstrap Icons apply directly.
|
||||
createRenderRoot() { return this; }
|
||||
|
||||
static properties = {
|
||||
_section: { state: true },
|
||||
// Source the chat is bound to: 'mobile' (main) or 'project-{id}'.
|
||||
_chatSource: { state: true },
|
||||
// Label shown in the chat header when inside a project.
|
||||
_chatLabel: { state: true },
|
||||
// File shown by the file_viewer section (from `#file_viewer?path=...`).
|
||||
_filePath: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._section = 'chat';
|
||||
this._chatSource = 'mobile';
|
||||
this._chatLabel = '';
|
||||
this._filePath = null;
|
||||
// id → name cache, so a cold deep-link (#chat/project-<id> opened by the
|
||||
// native shell) can resolve its header label without the project list open.
|
||||
this._projectLabels = {};
|
||||
// Native shell mode (?native=true): the HTML bottom nav is hidden — a native
|
||||
// tab bar drives navigation via location.hash. Mark the host so CSS can drop
|
||||
// the safe-area insets the native chrome already provides.
|
||||
this._native = new URLSearchParams(location.search).get('native') === 'true';
|
||||
if (this._native) this.setAttribute('data-native', '');
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._onHashChange = () => this._applyHash();
|
||||
window.addEventListener('hashchange', this._onHashChange);
|
||||
window.addEventListener('popstate', this._onHashChange);
|
||||
// Default route when no hash is present (replaceState: no history entry).
|
||||
if (!location.hash) history.replaceState(null, '', '#chat');
|
||||
this._applyHash();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
window.removeEventListener('popstate', this._onHashChange);
|
||||
}
|
||||
|
||||
// ── Hash routing ───────────────────────────────────────────────────────────
|
||||
|
||||
// { section, projectId, filePath } parsed from location.hash. Forms:
|
||||
// #projects → section 'projects'
|
||||
// #chat → section 'chat' (main mobile session)
|
||||
// #chat/project-<id> → section 'chat' bound to a project's session
|
||||
// #file_viewer?path=<enc> → section 'file_viewer' showing a file
|
||||
_readHash() {
|
||||
const raw = location.hash.slice(1);
|
||||
if (!raw) return { section: 'chat', projectId: null, filePath: null };
|
||||
// Segment ends at the first `/` (project sub-route) or `?` (query, e.g. the
|
||||
// file viewer's `?path=`).
|
||||
const cut = raw.search(/[/?]/);
|
||||
const seg = cut === -1 ? raw : raw.slice(0, cut);
|
||||
const section = VALID_SECTIONS.includes(seg) ? seg : 'chat';
|
||||
if (section === 'file_viewer') {
|
||||
let filePath = null;
|
||||
const m = raw.match(/[?&]path=([^&]*)/);
|
||||
if (m) { try { filePath = decodeURIComponent(m[1]); } catch { /* keep null */ } }
|
||||
return { section, projectId: null, filePath };
|
||||
}
|
||||
const slash = raw.indexOf('/');
|
||||
const sub = slash === -1 ? '' : raw.slice(slash + 1);
|
||||
let projectId = null;
|
||||
if (section === 'chat' && sub.startsWith('project-')) {
|
||||
projectId = sub.slice('project-'.length) || null;
|
||||
}
|
||||
return { section, projectId, filePath: null };
|
||||
}
|
||||
|
||||
_applyHash() {
|
||||
const { section, projectId, filePath } = this._readHash();
|
||||
this._section = section;
|
||||
this._filePath = filePath;
|
||||
if (projectId) {
|
||||
const source = 'project-' + projectId;
|
||||
if (this._chatSource !== source) this._chatSource = source;
|
||||
this._resolveLabel(projectId);
|
||||
} else if (section === 'chat') {
|
||||
if (this._chatSource !== 'mobile') this._chatSource = 'mobile';
|
||||
this._chatLabel = '';
|
||||
}
|
||||
// Tell the native shell which section is active. For the file viewer we also
|
||||
// pass the document path, so the iOS "Doc" tab can highlight itself and
|
||||
// remember the last opened file (re-opened when the user taps Doc again).
|
||||
this._notifyNative(
|
||||
section,
|
||||
projectId ? 'project-' + projectId : null,
|
||||
section === 'file_viewer' ? filePath : null,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the display label for a project id (shown in the chat header). Cached
|
||||
// in _projectLabels; fetched once from /api/projects when first needed (e.g. a
|
||||
// cold native deep-link), then served from cache on every subsequent switch.
|
||||
async _resolveLabel(projectId) {
|
||||
if (this._projectLabels[projectId] != null) {
|
||||
this._chatLabel = this._projectLabels[projectId];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
for (const p of await res.json()) this._projectLabels[p.id] = p.name;
|
||||
} catch { /* keep whatever label we have */ }
|
||||
this._chatLabel = this._projectLabels[projectId] ?? projectId;
|
||||
}
|
||||
|
||||
_nav(section) {
|
||||
const target = '#' + section;
|
||||
// Setting the hash to the current value fires no event; only change when it
|
||||
// differs (also covers exiting a project sub-route via the chat tab).
|
||||
if (location.hash !== target) location.hash = target;
|
||||
}
|
||||
|
||||
// A project was tapped in the projects list: re-point the chat to its source
|
||||
// and push the full project route so back/refresh keep the user in the project.
|
||||
_onProjectOpen(e) {
|
||||
const { source, label } = e.detail ?? {};
|
||||
if (!source || !source.startsWith('project-')) return;
|
||||
const id = source.slice('project-'.length);
|
||||
if (label) this._projectLabels[id] = label;
|
||||
location.hash = '#chat/' + source;
|
||||
}
|
||||
|
||||
// Back-out from a project chat: re-point to the main mobile session.
|
||||
_onProjectExit() {
|
||||
location.hash = '#chat';
|
||||
}
|
||||
|
||||
// ── Native bridge ──────────────────────────────────────────────────────────
|
||||
|
||||
// Web → Native: tell the iOS shell which section/project is active so it can
|
||||
// highlight the matching native tab. `path` is only set for the file viewer
|
||||
// (the document being shown). No-op outside WKWebView — the `skaldNav` message
|
||||
// handler only exists when the shell registered it.
|
||||
_notifyNative(section, project, path = null) {
|
||||
try {
|
||||
window.webkit?.messageHandlers?.skaldNav?.postMessage({ section, project, path });
|
||||
} catch { /* not in native shell */ }
|
||||
}
|
||||
|
||||
render() {
|
||||
const s = this._section;
|
||||
const item = (id, icon, label, extraClass = '') => html`
|
||||
<div class="mobile-nav-item ${extraClass} ${s === id ? 'active' : ''}"
|
||||
@click=${() => this._nav(id)}>
|
||||
${id === 'chat'
|
||||
? html`<div class="chat-fab"><i class="bi bi-chat-dots-fill"></i></div>`
|
||||
: html`<i class="bi ${icon}"></i>`}
|
||||
<span>${label}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div id="mobile-root">
|
||||
<div class="mobile-content">
|
||||
<inbox-page
|
||||
.visible=${s === 'inbox'}
|
||||
style=${s === 'inbox' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'}
|
||||
></inbox-page>
|
||||
<chat-page
|
||||
.visible=${s === 'chat'}
|
||||
.source=${this._chatSource}
|
||||
.label=${this._chatLabel}
|
||||
@project-exit=${() => this._onProjectExit()}
|
||||
style=${s === 'chat' ? 'flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column' : 'display:none'}
|
||||
></chat-page>
|
||||
<projects-page
|
||||
.visible=${s === 'projects'}
|
||||
@project-open=${(e) => this._onProjectOpen(e)}
|
||||
style=${s === 'projects' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'}
|
||||
></projects-page>
|
||||
<mobile-file-viewer-page
|
||||
.visible=${s === 'file_viewer'}
|
||||
.path=${this._filePath}
|
||||
style=${s === 'file_viewer' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'}
|
||||
></mobile-file-viewer-page>
|
||||
${['notifications', 'settings'].includes(s) ? html`
|
||||
<div class="mobile-coming-soon">
|
||||
<i class="bi bi-tools"></i>
|
||||
<p>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')}
|
||||
</nav>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('mobile-app', MobileApp);
|
||||
@@ -0,0 +1,142 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
id: 'llm',
|
||||
icon: 'bi-cpu',
|
||||
title: 'LLM',
|
||||
desc: 'Chat & completion models for agents and tools',
|
||||
},
|
||||
{
|
||||
id: 'transcribe',
|
||||
icon: 'bi-mic',
|
||||
title: 'Transcription',
|
||||
desc: 'Speech-to-text models via cloud or local plugin',
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
icon: 'bi-image',
|
||||
title: 'Image Generation',
|
||||
desc: 'Text-to-image models via cloud API',
|
||||
},
|
||||
{
|
||||
id: 'tts',
|
||||
icon: 'bi-volume-up',
|
||||
title: 'Text-to-Speech',
|
||||
desc: 'Speech synthesis models via cloud or local plugin',
|
||||
},
|
||||
];
|
||||
|
||||
export class ModelsHubPage extends LightElement {
|
||||
static properties = {
|
||||
_section: { state: true },
|
||||
_counts: { state: true },
|
||||
_loading: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._section = null;
|
||||
this._counts = { llm: 0, transcribe: 0, image: 0, tts: 0 };
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
const open = e.detail.page === 'models';
|
||||
this.style.display = open ? 'flex' : 'none';
|
||||
if (open) {
|
||||
this._section = this._sectionFromHash();
|
||||
if (!this._section) this._loadCounts();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_sectionFromHash() {
|
||||
const parts = location.hash.slice(1).split('/');
|
||||
if (parts[0] === 'models' && parts[1]) {
|
||||
return ['llm', 'transcribe', 'image', 'tts'].includes(parts[1]) ? parts[1] : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async _loadCounts() {
|
||||
this._loading = true;
|
||||
try {
|
||||
const [llmRes, tRes, igRes, ttsRes] = await Promise.all([
|
||||
fetch('/api/llm/models'),
|
||||
fetch('/api/transcribe/models'),
|
||||
fetch('/api/image-generate/models'),
|
||||
fetch('/api/tts/models'),
|
||||
]);
|
||||
const [llm, transcribe, image, tts] = await Promise.all([
|
||||
llmRes.ok ? llmRes.json() : [],
|
||||
tRes.ok ? tRes.json() : [],
|
||||
igRes.ok ? igRes.json() : [],
|
||||
ttsRes.ok ? ttsRes.json() : [],
|
||||
]);
|
||||
this._counts = {
|
||||
llm: llm.length,
|
||||
transcribe: transcribe.length,
|
||||
image: image.length,
|
||||
tts: tts.length,
|
||||
};
|
||||
} catch {
|
||||
// counts stay at 0 on error — non-critical
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_openSection(id) {
|
||||
this._section = id;
|
||||
history.pushState({ page: 'models', section: id }, '', `#models/${id}`);
|
||||
}
|
||||
|
||||
_goBack() {
|
||||
this._section = null;
|
||||
this._loadCounts();
|
||||
history.replaceState({ page: 'models' }, '', '#models');
|
||||
}
|
||||
|
||||
_countLabel(id) {
|
||||
const n = this._counts[id] ?? 0;
|
||||
return n === 0 ? 'No models' : n === 1 ? '1 model' : `${n} models`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._section) {
|
||||
return html`
|
||||
${this._section === 'llm' ? html`<models-llm-section .onback=${() => this._goBack()}></models-llm-section>` : ''}
|
||||
${this._section === 'transcribe' ? html`<models-transcribe-section .onback=${() => this._goBack()}></models-transcribe-section>` : ''}
|
||||
${this._section === 'image' ? html`<models-image-section .onback=${() => this._goBack()}></models-image-section>` : ''}
|
||||
${this._section === 'tts' ? html`<models-tts-section .onback=${() => this._goBack()}></models-tts-section>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="models-hub">
|
||||
<h2 class="llm-page-title">Models</h2>
|
||||
<p class="text-muted" style="font-size:0.88rem;margin-top:0.25rem">
|
||||
Configure LLM, transcription, and image generation providers.
|
||||
</p>
|
||||
<div class="models-hub-grid">
|
||||
${CARDS.map(card => html`
|
||||
<button class="models-type-card" @click=${() => this._openSection(card.id)}>
|
||||
<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-count ${this._counts[card.id] > 0 ? 'has-models' : ''}">
|
||||
${this._loading ? '…' : this._countLabel(card.id)}
|
||||
</div>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
function emptyIgForm() {
|
||||
return { provider_id: '', model_id: '', name: '', priority: 100 };
|
||||
}
|
||||
|
||||
export class ModelsImageSection extends LightElement {
|
||||
static properties = {
|
||||
onback: { attribute: false },
|
||||
_models: { state: true },
|
||||
_providers: { state: true },
|
||||
_modal: { state: true },
|
||||
_form: { state: true },
|
||||
_saving: { state: true },
|
||||
_error: { state: true },
|
||||
_provider: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.onback = null;
|
||||
this._models = [];
|
||||
this._providers = [];
|
||||
this._modal = null;
|
||||
this._form = emptyIgForm();
|
||||
this._saving = false;
|
||||
this._error = null;
|
||||
this._provider = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._load();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const [modelsRes, providersRes] = await Promise.all([
|
||||
fetch('/api/image-generate/models'),
|
||||
fetch('/api/llm/providers'),
|
||||
]);
|
||||
if (!modelsRes.ok) throw new Error(`models: HTTP ${modelsRes.status}`);
|
||||
if (!providersRes.ok) throw new Error(`providers: HTTP ${providersRes.status}`);
|
||||
this._models = await modelsRes.json();
|
||||
this._providers = await providersRes.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_openAdd() {
|
||||
this._error = null;
|
||||
this._provider = null;
|
||||
this._form = emptyIgForm();
|
||||
this._modal = 'pick-provider';
|
||||
}
|
||||
|
||||
_pickProvider(provider) {
|
||||
this._provider = provider;
|
||||
this._form = { ...emptyIgForm(), provider_id: provider.id };
|
||||
this._modal = 'add';
|
||||
}
|
||||
|
||||
// ── Edit flow ────────────────────────────────────────────────────────────────
|
||||
|
||||
async _openEdit(m) {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/image-generate/models/${m.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const r = await res.json();
|
||||
this._provider = this._providers.find(p => p.id === r.provider_id) ?? null;
|
||||
this._form = {
|
||||
provider_id: r.provider_id,
|
||||
model_id: r.model_id,
|
||||
name: r.name,
|
||||
priority: r.priority,
|
||||
};
|
||||
this._modal = { mode: 'edit', id: r.id, name: r.name };
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async _delete(m) {
|
||||
if (!confirm(`Delete image model "${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());
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit add ───────────────────────────────────────────────────────────────
|
||||
|
||||
async _submitAdd(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
const f = this._form;
|
||||
try {
|
||||
const res = await fetch('/api/image-generate/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider_id: Number(f.provider_id),
|
||||
model_id: f.model_id,
|
||||
name: f.name || f.model_id,
|
||||
priority: Number(f.priority) || 100,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit edit ──────────────────────────────────────────────────────────────
|
||||
|
||||
async _submitEdit(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
const f = this._form;
|
||||
const id = this._modal.id;
|
||||
try {
|
||||
const res = await fetch(`/api/image-generate/models/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider_id: Number(f.provider_id),
|
||||
model_id: f.model_id,
|
||||
name: f.name || f.model_id,
|
||||
priority: Number(f.priority) || 100,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
_closeModal() { this._modal = null; this._error = null; }
|
||||
|
||||
// ── Render card ──────────────────────────────────────────────────────────────
|
||||
|
||||
_renderCard(m) {
|
||||
const isPlugin = m.from_plugin;
|
||||
return html`
|
||||
<div class="llm-card">
|
||||
<div class="llm-card-row1">
|
||||
${isPlugin
|
||||
? html`<span class="ig-source-badge ig-source-plugin">Plugin</span>`
|
||||
: html`<span class="ig-source-badge ig-source-cloud">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">
|
||||
<i class="bi bi-lock"></i>
|
||||
</span>
|
||||
` : html`
|
||||
<button class="llm-btn-icon llm-btn-edit" title="Edit" @click=${() => this._openEdit(m)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="llm-btn-icon llm-btn-delete" title="Delete" @click=${() => this._delete(m)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="llm-card-row2">
|
||||
${!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>
|
||||
</div>
|
||||
|
||||
${m.description ? html`
|
||||
<div class="ig-card-desc">${m.description}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: pick provider ──────────────────────────────────────────────────────
|
||||
|
||||
_renderPickProvider() {
|
||||
const igProviders = this._providers.filter(p =>
|
||||
Array.isArray(p.supported_types) && p.supported_types.includes('image_generate')
|
||||
);
|
||||
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>
|
||||
${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`
|
||||
<button class="llm-provider-card" @click=${() => this._pickProvider(p)}>
|
||||
<div class="llm-provider-card-name">${p.name}</div>
|
||||
<div class="llm-provider-card-type text-muted" style="font-size:0.75rem">${p.type}</div>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="agent-dialog-actions mt-3">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: add / edit form ────────────────────────────────────────────────────
|
||||
|
||||
_renderForm(isEdit = false) {
|
||||
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>`;
|
||||
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">${title}</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<form @submit=${(e) => isEdit ? this._submitEdit(e) : this._submitAdd(e)}>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Model ID <span class="text-muted fw-normal">(sent to API)</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.model_id} required
|
||||
placeholder="e.g. x-ai/grok-2-vision"
|
||||
?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>` : ''}
|
||||
</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>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.name}
|
||||
placeholder=${f.model_id || 'same as model ID'}
|
||||
@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>
|
||||
<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>
|
||||
|
||||
<div class="agent-dialog-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
|
||||
${this._saving ? 'Saving…' : isEdit ? 'Save changes' : 'Add model'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const igProviders = this._providers.filter(p =>
|
||||
Array.isArray(p.supported_types) && p.supported_types.includes('image_generate')
|
||||
);
|
||||
const canAdd = igProviders.length > 0;
|
||||
|
||||
return html`
|
||||
<div class="llm-page">
|
||||
<div class="llm-page-header">
|
||||
<div class="llm-header-left">
|
||||
${this.onback ? html`
|
||||
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back to models" @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>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()} ?disabled=${!canAdd}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${!canAdd ? html`
|
||||
<div class="agent-info-banner">
|
||||
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="agent-info-banner-body">
|
||||
<p class="mb-0">No provider supports image generation yet. Add an <strong>OpenRouter</strong> provider first.</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this._models.some(m => m.from_plugin) ? html`
|
||||
<div class="agent-info-banner">
|
||||
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="agent-info-banner-body">
|
||||
<p class="mb-0">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>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this._error && !this._modal ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : ''}
|
||||
|
||||
<div class="llm-card-list">
|
||||
${this._models.length === 0 ? html`
|
||||
<div class="llm-empty-state">
|
||||
<i class="bi bi-image"></i>
|
||||
<p>No image generation models configured.</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
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
` : this._models.map(m => this._renderCard(m))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._modal === 'pick-provider' ? this._renderPickProvider() : ''}
|
||||
${this._modal === 'add' ? this._renderForm(false) : ''}
|
||||
${this._modal?.mode === 'edit' ? this._renderForm(true) : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
const STRENGTH_COLORS = {
|
||||
very_high: '#ef4444',
|
||||
high: '#f97316',
|
||||
average: '#eab308',
|
||||
low: '#84cc16',
|
||||
very_low: '#22c55e',
|
||||
};
|
||||
|
||||
const STRENGTH_LABELS = {
|
||||
very_high: 'Very High',
|
||||
high: 'High',
|
||||
average: 'Average',
|
||||
low: 'Low',
|
||||
very_low: 'Very Low',
|
||||
};
|
||||
|
||||
const STRENGTH_OPTIONS = ['very_low', 'low', 'average', 'high', 'very_high'];
|
||||
const SCOPE_OPTIONS = ['coding', 'writing', 'reasoning', 'math', 'basic', 'search'];
|
||||
|
||||
function emptyMeta() {
|
||||
// `reasoning` is the selected reasoning value: a string for a ValueSet mode,
|
||||
// a number for a Range mode, or null (off). Interpreted per provider.
|
||||
return { strength: '', scope: [], priority: 100, is_default: false, reasoning: null };
|
||||
}
|
||||
|
||||
function emptyOrForm() {
|
||||
return {
|
||||
model_id: '',
|
||||
name: '',
|
||||
max_tokens: '',
|
||||
...emptyMeta(),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyDefaultForm() {
|
||||
return { model_id: '', name: '', extra_params: '', ...emptyMeta() };
|
||||
}
|
||||
|
||||
export class ModelsLlmSection extends LightElement {
|
||||
static properties = {
|
||||
onback: { attribute: false },
|
||||
_models: { state: true },
|
||||
_providers: { state: true },
|
||||
_modal: { state: true },
|
||||
_saving: { state: true },
|
||||
_error: { state: true },
|
||||
_orModels: { state: true },
|
||||
_orLoading: { state: true },
|
||||
_orSearch: { state: true },
|
||||
_orForm: { state: true },
|
||||
_form: { state: true },
|
||||
_pickedProvider: { state: true },
|
||||
_reasoningMode: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.onback = null;
|
||||
this._models = [];
|
||||
this._providers = [];
|
||||
this._modal = null;
|
||||
this._saving = false;
|
||||
this._error = null;
|
||||
this._orModels = [];
|
||||
this._orLoading = false;
|
||||
this._orSearch = '';
|
||||
this._orForm = emptyOrForm();
|
||||
this._form = emptyDefaultForm();
|
||||
this._pickedProvider = null;
|
||||
this._reasoningMode = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._load();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const [modelsRes, providersRes] = await Promise.all([
|
||||
fetch('/api/llm/models'),
|
||||
fetch('/api/llm/providers'),
|
||||
]);
|
||||
if (!modelsRes.ok) throw new Error(`models: HTTP ${modelsRes.status}`);
|
||||
if (!providersRes.ok) throw new Error(`providers: HTTP ${providersRes.status}`);
|
||||
this._models = await modelsRes.json();
|
||||
this._providers = await providersRes.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Move up/down ─────────────────────────────────────────────────────────────
|
||||
|
||||
async _moveUp(i) {
|
||||
if (i <= 0) return;
|
||||
const models = [...this._models];
|
||||
[models[i - 1], models[i]] = [models[i], models[i - 1]];
|
||||
this._models = models;
|
||||
await this._savePriorities();
|
||||
}
|
||||
|
||||
async _moveDown(i) {
|
||||
if (i >= this._models.length - 1) return;
|
||||
const models = [...this._models];
|
||||
[models[i], models[i + 1]] = [models[i + 1], models[i]];
|
||||
this._models = models;
|
||||
await this._savePriorities();
|
||||
}
|
||||
|
||||
async _savePriorities() {
|
||||
try {
|
||||
await Promise.all(this._models.map((m, i) =>
|
||||
fetch(`/api/llm/models/${m.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// A PUT overwrites every column, so carry through all preserved
|
||||
// fields (catalog metadata + reasoning) — omitting them would wipe
|
||||
// them on reorder.
|
||||
body: JSON.stringify({
|
||||
provider_id: m.provider_id,
|
||||
model_id: m.model_id,
|
||||
name: m.name,
|
||||
strength: m.strength ?? null,
|
||||
scope: m.scope,
|
||||
is_default: m.is_default,
|
||||
priority: (i + 1) * 10,
|
||||
extra_params: m.extra_params ?? null,
|
||||
context_length: m.context_length ?? null,
|
||||
max_output_tokens: m.max_output_tokens ?? null,
|
||||
knowledge_cutoff: m.knowledge_cutoff ?? null,
|
||||
capabilities: m.capabilities ?? null,
|
||||
reasoning: m.reasoning ?? null,
|
||||
}),
|
||||
})
|
||||
));
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = `Failed to save order: ${e.message}`;
|
||||
await this._load();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_openAdd() {
|
||||
this._error = null;
|
||||
this._pickedProvider = null;
|
||||
this._modal = 'provider-pick';
|
||||
}
|
||||
|
||||
async _pickProvider(provider) {
|
||||
this._error = null;
|
||||
this._pickedProvider = provider;
|
||||
this._reasoningMode = null;
|
||||
|
||||
const hasModelPicker = ['openrouter', 'ollama', 'lm_studio', 'deepseek', 'zai'].includes(provider.type);
|
||||
|
||||
if (hasModelPicker) {
|
||||
this._orForm = emptyOrForm();
|
||||
this._orSearch = '';
|
||||
this._orModels = [];
|
||||
this._modal = 'add-openrouter';
|
||||
await this._loadOrModels(provider.id);
|
||||
} else {
|
||||
this._form = { ...emptyDefaultForm(), provider_id: provider.id };
|
||||
this._modal = 'add-default';
|
||||
}
|
||||
}
|
||||
|
||||
async _loadOrModels(providerId) {
|
||||
this._orLoading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/llm/providers/${providerId}/models`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
this._orModels = await res.json();
|
||||
} catch (e) {
|
||||
this._error = `Failed to load models: ${e.message}`;
|
||||
} finally {
|
||||
this._orLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit flow ────────────────────────────────────────────────────────────────
|
||||
|
||||
async _openEdit(model) {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/llm/models/${model.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const record = await res.json();
|
||||
this._form = {
|
||||
strength: record.strength ?? '',
|
||||
scope: record.scope ?? [],
|
||||
priority: record.priority,
|
||||
is_default: record.is_default,
|
||||
provider_id: record.provider_id,
|
||||
model_id: record.model_id,
|
||||
name: record.name,
|
||||
extra_params: record.extra_params ? JSON.stringify(record.extra_params) : '',
|
||||
reasoning: record.reasoning ?? null,
|
||||
// Preserved so the PUT doesn't wipe catalog metadata (see _submitEdit).
|
||||
context_length: record.context_length ?? null,
|
||||
max_output_tokens: record.max_output_tokens ?? null,
|
||||
knowledge_cutoff: record.knowledge_cutoff ?? null,
|
||||
capabilities: record.capabilities ?? null,
|
||||
};
|
||||
// The reasoning control descriptor lives on the list item (LlmModelInfo).
|
||||
this._modal = {
|
||||
mode: 'edit', id: record.id, name: record.name, model_id: record.model_id,
|
||||
reasoning_mode: model.reasoning_mode ?? null,
|
||||
};
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async _delete(model) {
|
||||
if (!confirm(`Delete model "${model.name}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/llm/models/${model.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit: add default ──────────────────────────────────────────────────────
|
||||
|
||||
async _submitDefault(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
|
||||
const f = this._form;
|
||||
let extra_params = null;
|
||||
if (f.extra_params && f.extra_params.trim()) {
|
||||
try { extra_params = JSON.parse(f.extra_params); }
|
||||
catch { this._error = 'Extra params: invalid JSON'; this._saving = false; return; }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/llm/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider_id: Number(this._pickedProvider.id),
|
||||
model_id: f.model_id,
|
||||
name: f.name || f.model_id,
|
||||
strength: f.strength || null,
|
||||
scope: f.scope,
|
||||
is_default: f.is_default,
|
||||
priority: Number(f.priority),
|
||||
extra_params,
|
||||
reasoning: f.reasoning ?? null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit: add openrouter ───────────────────────────────────────────────────
|
||||
|
||||
async _submitCatalog(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
if (!this._orForm.model_id) { this._error = 'Select a model'; return; }
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
|
||||
const f = this._orForm;
|
||||
const extra_params = {};
|
||||
if (f.max_tokens) extra_params.max_tokens = Number(f.max_tokens);
|
||||
|
||||
const selected = this._orModels.find(m => m.id === f.model_id);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/llm/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider_id: Number(this._pickedProvider.id),
|
||||
model_id: f.model_id,
|
||||
name: f.name || f.model_id,
|
||||
strength: f.strength || null,
|
||||
scope: f.scope,
|
||||
is_default: f.is_default,
|
||||
priority: Number(f.priority),
|
||||
extra_params: Object.keys(extra_params).length ? extra_params : null,
|
||||
context_length: selected?.context_length ?? null,
|
||||
max_output_tokens: selected?.max_completion_tokens ?? null,
|
||||
knowledge_cutoff: selected?.knowledge_cutoff ?? null,
|
||||
capabilities: selected?.capabilities?.length ? selected.capabilities : null,
|
||||
reasoning: f.reasoning ?? null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit: edit ─────────────────────────────────────────────────────────────
|
||||
|
||||
async _submitEdit(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
|
||||
const f = this._form;
|
||||
let extra_params = null;
|
||||
if (f.extra_params && f.extra_params.trim()) {
|
||||
try { extra_params = JSON.parse(f.extra_params); }
|
||||
catch { this._error = 'Extra params: invalid JSON'; this._saving = false; return; }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/llm/models/${this._modal.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider_id: f.provider_id,
|
||||
model_id: f.model_id,
|
||||
name: f.name,
|
||||
strength: f.strength || null,
|
||||
scope: f.scope,
|
||||
is_default: f.is_default,
|
||||
priority: Number(f.priority),
|
||||
extra_params,
|
||||
reasoning: f.reasoning ?? null,
|
||||
context_length: f.context_length ?? null,
|
||||
max_output_tokens: f.max_output_tokens ?? null,
|
||||
knowledge_cutoff: f.knowledge_cutoff ?? null,
|
||||
capabilities: f.capabilities ?? null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Field helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
_setField(field, value) {
|
||||
this._form = { ...this._form, [field]: value };
|
||||
}
|
||||
|
||||
// Manual "add model" flow: the reasoning control descriptor depends on the
|
||||
// model_id, so fetch it (per provider) when the field settles.
|
||||
async _fetchDefaultReasoning(modelId) {
|
||||
this._reasoningMode = null;
|
||||
this._setField('reasoning', null);
|
||||
const pid = this._pickedProvider?.id;
|
||||
if (!pid || !modelId.trim()) return;
|
||||
try {
|
||||
const res = await fetch(`/api/llm/providers/${pid}/reasoning-mode?model_id=${encodeURIComponent(modelId)}`);
|
||||
if (res.ok) this._reasoningMode = await res.json();
|
||||
} catch { /* descriptor is optional; ignore */ }
|
||||
}
|
||||
|
||||
_setOrField(field, value) {
|
||||
this._orForm = { ...this._orForm, [field]: value };
|
||||
}
|
||||
|
||||
_toggleScope(scope, isOr = false) {
|
||||
if (isOr) {
|
||||
const s = this._orForm.scope;
|
||||
this._orForm = { ...this._orForm, scope: s.includes(scope) ? s.filter(x => x !== scope) : [...s, scope] };
|
||||
} else {
|
||||
const s = this._form.scope;
|
||||
this._form = { ...this._form, scope: s.includes(scope) ? s.filter(x => x !== scope) : [...s, scope] };
|
||||
}
|
||||
}
|
||||
|
||||
_closeModal() { this._modal = null; this._error = null; }
|
||||
|
||||
// ── Render helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
_fmtP(p) {
|
||||
if (p == null) return null;
|
||||
if (p === 0) return '$0';
|
||||
return p < 0.01 ? `$${p.toFixed(4)}` : `$${p.toFixed(3)}`;
|
||||
}
|
||||
|
||||
_renderPriceCell(model) {
|
||||
const inp = this._fmtP(model.price_input_per_million);
|
||||
const out = this._fmtP(model.price_output_per_million);
|
||||
if (!inp && !out) return html`<span style="opacity:0.3">—</span>`;
|
||||
return html`
|
||||
<span class="llm-price-tag" title="Input/Output per 1M tokens">
|
||||
${inp ?? '?'} <span style="opacity:0.45">→</span> ${out ?? '?'}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderStrengthDot(strength) {
|
||||
if (!strength) return html`<span style="opacity:0.3">—</span>`;
|
||||
return html`
|
||||
<span class="llm-strength-dot"
|
||||
style="background:${STRENGTH_COLORS[strength] ?? '#888'}"
|
||||
title=${STRENGTH_LABELS[strength] ?? strength}></span>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderStatus(status) {
|
||||
const cfg = {
|
||||
healthy: { color: '#22c55e', title: 'Healthy' },
|
||||
degraded: { color: '#eab308', title: 'Degraded' },
|
||||
down: { color: '#ef4444', title: 'Down' },
|
||||
}[status] ?? { color: '#888', title: status };
|
||||
return html`<span class="llm-strength-dot" style="background:${cfg.color}" title=${cfg.title}></span>`;
|
||||
}
|
||||
|
||||
_renderCard(m, i) {
|
||||
const first = i === 0;
|
||||
const last = i === this._models.length - 1;
|
||||
return html`
|
||||
<div class="llm-card">
|
||||
<div class="llm-card-row1">
|
||||
<div class="llm-move-btns">
|
||||
<button class="llm-move-btn" title="Move up"
|
||||
?disabled=${first}
|
||||
@click=${() => this._moveUp(i)}>
|
||||
<i class="bi bi-chevron-up"></i>
|
||||
</button>
|
||||
<button class="llm-move-btn" title="Move down"
|
||||
?disabled=${last}
|
||||
@click=${() => this._moveDown(i)}>
|
||||
<i class="bi bi-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
${this._renderStrengthDot(m.strength)}
|
||||
${this._renderStatus(m.status)}
|
||||
<span class="llm-card-name">${m.name}</span>
|
||||
${m.is_default ? html`<span class="llm-card-badge">default</span>` : ''}
|
||||
<div class="llm-card-actions">
|
||||
<button class="llm-btn-icon llm-btn-edit" title="Edit" @click=${() => this._openEdit(m)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="llm-btn-icon llm-btn-delete" title="Delete" @click=${() => this._delete(m)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="llm-card-row2">
|
||||
<span class="llm-provider-name">${m.provider_name}</span>
|
||||
<span class="llm-model-id">${m.model_id}</span>
|
||||
${this._renderPriceCell(m)}
|
||||
</div>
|
||||
|
||||
${(m.scope ?? []).length > 0 || m.extra_params ? html`
|
||||
<div class="llm-card-row3">
|
||||
${(m.scope ?? []).map(s => html`<span class="llm-scope-pill">${s}</span>`)}
|
||||
${m.extra_params ? html`<span class="llm-scope-pill llm-params-pill" title=${JSON.stringify(m.extra_params)}>+params</span>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Data-driven reasoning control. `mode` is the provider-supplied descriptor
|
||||
// (ReasoningMode): a `value_set` (discrete choices → dropdown) or a `range`
|
||||
// (numeric budget → checkbox + number input). `form.reasoning` holds the
|
||||
// current value (string for value_set, number for range, or null = off).
|
||||
_renderReasoning(form, setField, mode) {
|
||||
if (!mode) return '';
|
||||
if (mode.type === 'value_set') {
|
||||
return html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Reasoning</label>
|
||||
<select class="form-select form-select-sm"
|
||||
@change=${(e) => setField('reasoning', e.target.value || null)}>
|
||||
<option value="" ?selected=${form.reasoning == null}>— off —</option>
|
||||
${mode.values.map(v => html`
|
||||
<option value=${v} ?selected=${form.reasoning === v}>${v}</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>`;
|
||||
}
|
||||
// range
|
||||
const enabled = form.reasoning != null;
|
||||
return html`
|
||||
<div class="mb-3">
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="m-reasoning-on"
|
||||
.checked=${enabled}
|
||||
@change=${(e) => setField('reasoning', e.target.checked ? (mode.default ?? mode.min) : null)} />
|
||||
<label class="form-check-label fw-semibold" for="m-reasoning-on" style="font-size:0.82rem">
|
||||
Reasoning (thinking)
|
||||
</label>
|
||||
</div>
|
||||
${enabled ? html`
|
||||
<div class="d-flex align-items-center gap-2 ms-3">
|
||||
<input type="number" class="form-control form-control-sm" style="max-width:140px"
|
||||
min=${mode.min} max=${mode.max} step=${mode.step ?? 1}
|
||||
.value=${String(form.reasoning)}
|
||||
@input=${(e) => setField('reasoning', e.target.value === '' ? null : Number(e.target.value))} />
|
||||
<span class="text-muted" style="font-size:0.78rem">${mode.unit ?? ''}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderMetaFields(form, setField, toggleScope, reasoningMode) {
|
||||
return html`
|
||||
${this._renderReasoning(form, setField, reasoningMode)}
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-8">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Strength</label>
|
||||
<select class="form-select form-select-sm"
|
||||
@change=${(e) => setField('strength', e.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
${STRENGTH_OPTIONS.map(s => html`
|
||||
<option value=${s} ?selected=${form.strength === s}>${STRENGTH_LABELS[s]}</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Priority</label>
|
||||
<input type="number" class="form-control form-control-sm" .value=${String(form.priority)} min="1"
|
||||
@input=${(e) => setField('priority', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Scope</label>
|
||||
<div class="llm-scope-grid">
|
||||
${SCOPE_OPTIONS.map(s => html`
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="scope-${s}"
|
||||
.checked=${form.scope.includes(s)} @change=${() => toggleScope(s)} />
|
||||
<label class="form-check-label" for="scope-${s}" style="font-size:0.82rem">${s}</label>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="m-is-default"
|
||||
.checked=${form.is_default} @change=${(e) => setField('is_default', e.target.checked)} />
|
||||
<label class="form-check-label" for="m-is-default" style="font-size:0.82rem">Default model</label>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: provider picker ───────────────────────────────────────────────────
|
||||
|
||||
_renderProviderPick() {
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">Add Model — Choose Provider</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<div class="llm-provider-grid">
|
||||
${this._providers.filter(p => (p.supported_types ?? []).includes('llm')).map(p => html`
|
||||
<button class="llm-provider-card" @click=${() => this._pickProvider(p)}>
|
||||
<div class="llm-provider-card-name">${p.name}</div>
|
||||
<div class="llm-provider-card-type text-muted" style="font-size:0.75rem">${p.type}</div>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="agent-dialog-actions mt-3">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: add default ───────────────────────────────────────────────────────
|
||||
|
||||
_renderAddDefault() {
|
||||
const f = this._form;
|
||||
const p = this._pickedProvider;
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">
|
||||
Add Model
|
||||
<span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>
|
||||
</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<form @submit=${(e) => this._submitDefault(e)}>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Model ID <span class="text-muted fw-normal">(sent to API)</span></label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.model_id} required
|
||||
placeholder="e.g. gpt-4o"
|
||||
@input=${(e) => this._setField('model_id', e.target.value)}
|
||||
@change=${(e) => this._fetchDefaultReasoning(e.target.value)} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Name / Alias <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.name}
|
||||
placeholder=${f.model_id || 'same as model ID'}
|
||||
@input=${(e) => this._setField('name', e.target.value)} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Extra params <span class="text-muted fw-normal">(JSON, optional)</span>
|
||||
</label>
|
||||
<textarea class="form-control form-control-sm font-monospace" rows="3"
|
||||
.value=${f.extra_params}
|
||||
@input=${(e) => this._setField('extra_params', e.target.value)}
|
||||
style="font-size:0.78rem;resize:vertical"></textarea>
|
||||
</div>
|
||||
${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._reasoningMode)}
|
||||
<div class="agent-dialog-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
|
||||
${this._saving ? 'Saving…' : 'Add model'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: add model from catalog ────────────────────────────────────────────
|
||||
|
||||
_renderAddCatalog() {
|
||||
const f = this._orForm;
|
||||
const p = this._pickedProvider;
|
||||
const search = this._orSearch.toLowerCase();
|
||||
const filtered = this._orModels.filter(m =>
|
||||
!search || m.id.toLowerCase().includes(search) || m.name.toLowerCase().includes(search)
|
||||
);
|
||||
|
||||
const selected = this._orModels.find(m => m.id === f.model_id);
|
||||
const supportsMaxTokens = selected?.capabilities?.includes('max_tokens') ?? true;
|
||||
|
||||
const formatPrice = (m) => {
|
||||
const i = this._fmtP(m.price_input_per_million);
|
||||
const o = this._fmtP(m.price_output_per_million);
|
||||
if (!i && !o) return null;
|
||||
return `${i ?? '?'} → ${o ?? '?'}/M`;
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal llm-modal-wide">
|
||||
<div class="llm-modal-title">
|
||||
Add Model
|
||||
<span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>
|
||||
</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
|
||||
<form @submit=${(e) => this._submitCatalog(e)}>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Model</label>
|
||||
<input type="text" class="form-control form-control-sm mb-1"
|
||||
placeholder="Search models…"
|
||||
.value=${this._orSearch}
|
||||
@input=${(e) => { this._orSearch = e.target.value; }} />
|
||||
|
||||
${this._orLoading
|
||||
? html`<div class="text-muted py-2" style="font-size:0.82rem">Loading models…</div>`
|
||||
: html`
|
||||
<div class="llm-or-model-list">
|
||||
${filtered.length === 0
|
||||
? html`<div class="text-muted px-2 py-1" style="font-size:0.82rem">No models found</div>`
|
||||
: filtered.map(m => html`
|
||||
<div class="llm-or-model-row ${f.model_id === m.id ? 'selected' : ''}"
|
||||
@click=${() => this._setOrField('model_id', m.id)}>
|
||||
<div class="llm-or-model-name">${m.name}</div>
|
||||
<div class="llm-or-model-meta">
|
||||
<span class="text-muted" style="font-size:0.72rem">${m.id}</span>
|
||||
${(m.price_input_per_million != null || m.price_output_per_million != null) ? html`
|
||||
<span class="llm-or-price">${formatPrice(m)}</span>
|
||||
` : ''}
|
||||
${m.context_length ? html`
|
||||
<span class="text-muted" style="font-size:0.72rem">${(m.context_length / 1000).toFixed(0)}k ctx</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Name / Alias <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.name}
|
||||
placeholder=${selected?.name || f.model_id || 'same as model ID'}
|
||||
@input=${(e) => this._setOrField('name', e.target.value)} />
|
||||
</div>
|
||||
|
||||
${supportsMaxTokens ? html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Max output tokens <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<input type="number" class="form-control form-control-sm" .value=${f.max_tokens}
|
||||
placeholder=${selected?.max_completion_tokens ? `up to ${selected.max_completion_tokens.toLocaleString()}` : ''}
|
||||
min="1"
|
||||
@input=${(e) => this._setOrField('max_tokens', e.target.value)} />
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this._renderMetaFields(f, (k, v) => this._setOrField(k, v), (s) => this._toggleScope(s, true), selected?.reasoning)}
|
||||
|
||||
<div class="agent-dialog-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving || !f.model_id}>
|
||||
${this._saving ? 'Saving…' : 'Add model'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: edit ──────────────────────────────────────────────────────────────
|
||||
|
||||
_renderEdit() {
|
||||
const m = this._modal;
|
||||
const f = this._form;
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">
|
||||
Edit
|
||||
<span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${m.name}</span>
|
||||
</div>
|
||||
<p class="text-muted mb-3" style="font-size:0.8rem">
|
||||
Model ID and provider cannot be changed. To use a different model, add a new entry.
|
||||
</p>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<form @submit=${(e) => this._submitEdit(e)}>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Name / Alias</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.name}
|
||||
placeholder=${m.model_id || 'model name'}
|
||||
@input=${(e) => this._setField('name', e.target.value)} />
|
||||
<div class="form-text" style="font-size:0.75rem">Used to reference this model (e.g. in an agent's <code>client</code>). Must be unique.</div>
|
||||
</div>
|
||||
${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._modal.reasoning_mode)}
|
||||
<div class="agent-dialog-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
|
||||
${this._saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="llm-page">
|
||||
<div class="llm-page-header">
|
||||
<div class="llm-header-left">
|
||||
${this.onback ? html`
|
||||
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back to models" @click=${this.onback}>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<div>
|
||||
<h2 class="llm-page-title">LLM Models</h2>
|
||||
<span class="llm-page-count">${this._models.length} model${this._models.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}
|
||||
?disabled=${this._providers.length === 0}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._providers.length === 0 ? html`
|
||||
<div class="agent-info-banner">
|
||||
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="agent-info-banner-body">
|
||||
<p class="mb-0">Add a <strong>Provider</strong> first, then come back here to add models.</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this._error && !this._modal ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : ''}
|
||||
|
||||
<div class="llm-card-list">
|
||||
${this._models.length === 0 && this._providers.length > 0 ? html`
|
||||
<div class="llm-empty-state">
|
||||
<i class="bi bi-cpu"></i>
|
||||
<p>No models configured yet.</p>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add your first model
|
||||
</button>
|
||||
</div>
|
||||
` : this._models.map((m, i) => this._renderCard(m, i))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._modal === 'provider-pick' ? this._renderProviderPick() : ''}
|
||||
${this._modal === 'add-openrouter' ? this._renderAddCatalog() : ''}
|
||||
${this._modal === 'add-default' ? this._renderAddDefault() : ''}
|
||||
${this._modal?.mode === 'edit' ? this._renderEdit() : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
function emptyTForm() {
|
||||
return { provider_id: '', model_id: '', name: '', language: '', priority: 100 };
|
||||
}
|
||||
|
||||
export class ModelsTranscribeSection extends LightElement {
|
||||
static properties = {
|
||||
onback: { attribute: false },
|
||||
_models: { state: true },
|
||||
_providers: { state: true },
|
||||
_modal: { state: true },
|
||||
_form: { state: true },
|
||||
_saving: { state: true },
|
||||
_error: { state: true },
|
||||
_provider: { state: true },
|
||||
_remoteModels: { state: true },
|
||||
_loadingModels: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.onback = null;
|
||||
this._models = [];
|
||||
this._providers = [];
|
||||
this._modal = null;
|
||||
this._form = emptyTForm();
|
||||
this._saving = false;
|
||||
this._error = null;
|
||||
this._provider = null;
|
||||
this._remoteModels = null;
|
||||
this._loadingModels = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._load();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const [modelsRes, providersRes] = await Promise.all([
|
||||
fetch('/api/transcribe/models'),
|
||||
fetch('/api/llm/providers'),
|
||||
]);
|
||||
if (!modelsRes.ok) throw new Error(`models: HTTP ${modelsRes.status}`);
|
||||
if (!providersRes.ok) throw new Error(`providers: HTTP ${providersRes.status}`);
|
||||
this._models = await modelsRes.json();
|
||||
this._providers = await providersRes.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_openAdd() {
|
||||
this._error = null;
|
||||
this._provider = null;
|
||||
this._form = emptyTForm();
|
||||
this._modal = 'pick-provider';
|
||||
}
|
||||
|
||||
async _pickProvider(provider) {
|
||||
this._provider = provider;
|
||||
this._remoteModels = null;
|
||||
this._form = { ...emptyTForm(), provider_id: provider.id };
|
||||
this._loadingModels = true;
|
||||
this._modal = 'pick-model';
|
||||
try {
|
||||
const res = await fetch(`/api/transcribe/providers/${provider.id}/models`);
|
||||
this._remoteModels = res.ok ? await res.json() : null;
|
||||
} catch {
|
||||
this._remoteModels = null;
|
||||
} finally {
|
||||
this._loadingModels = false;
|
||||
if (!this._remoteModels || this._remoteModels.length === 0) {
|
||||
this._modal = 'add';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_pickRemoteModel(remote) {
|
||||
this._form = {
|
||||
...this._form,
|
||||
model_id: remote.id,
|
||||
name: remote.name,
|
||||
};
|
||||
this._modal = 'add';
|
||||
}
|
||||
|
||||
// ── Edit flow ────────────────────────────────────────────────────────────────
|
||||
|
||||
async _openEdit(m) {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/transcribe/models/${m.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const r = await res.json();
|
||||
this._provider = this._providers.find(p => p.id === r.provider_id) ?? null;
|
||||
this._form = {
|
||||
provider_id: r.provider_id,
|
||||
model_id: r.model_id,
|
||||
name: r.name,
|
||||
language: r.language ?? '',
|
||||
priority: r.priority,
|
||||
};
|
||||
this._modal = { mode: 'edit', id: r.id, name: r.name };
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async _delete(m) {
|
||||
if (!confirm(`Delete transcription model "${m.name}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/transcribe/models/${m.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit add ───────────────────────────────────────────────────────────────
|
||||
|
||||
async _submitAdd(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
const f = this._form;
|
||||
try {
|
||||
const res = await fetch('/api/transcribe/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider_id: Number(f.provider_id),
|
||||
model_id: f.model_id,
|
||||
name: f.name || f.model_id,
|
||||
language: f.language || null,
|
||||
priority: Number(f.priority) || 100,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit edit ──────────────────────────────────────────────────────────────
|
||||
|
||||
async _submitEdit(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
const f = this._form;
|
||||
const id = this._modal.id;
|
||||
try {
|
||||
const res = await fetch(`/api/transcribe/models/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider_id: Number(f.provider_id),
|
||||
model_id: f.model_id,
|
||||
name: f.name || f.model_id,
|
||||
language: f.language || null,
|
||||
priority: Number(f.priority) || 100,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
_closeModal() { this._modal = null; this._error = null; }
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderRow(m) {
|
||||
const isPlugin = m.from_plugin;
|
||||
return html`
|
||||
<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>`}
|
||||
</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 class="llm-actions">
|
||||
${isPlugin ? html`
|
||||
<span class="text-muted" style="font-size:0.75rem" title="Managed by plugin">
|
||||
<i class="bi bi-lock"></i>
|
||||
</span>
|
||||
` : html`
|
||||
<button class="btn btn-sm btn-link" title="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)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
`}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderPickProvider() {
|
||||
const tProviders = this._providers.filter(p =>
|
||||
Array.isArray(p.supported_types) && p.supported_types.includes('transcribe')
|
||||
);
|
||||
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>
|
||||
${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`
|
||||
<button class="llm-provider-card" @click=${() => this._pickProvider(p)}>
|
||||
<div class="llm-provider-card-name">${p.name}</div>
|
||||
<div class="llm-provider-card-type text-muted" style="font-size:0.75rem">${p.type}</div>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="agent-dialog-actions mt-3">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderPickModel() {
|
||||
const p = this._provider;
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">
|
||||
Add Transcription Model
|
||||
<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>
|
||||
` : html`
|
||||
<div class="tts-model-pick-list">
|
||||
${(this._remoteModels ?? []).map(m => html`
|
||||
<button class="tts-model-pick-item" @click=${() => this._pickRemoteModel(m)}>
|
||||
<div class="tts-model-pick-name">${m.name}</div>
|
||||
${m.description ? html`<div class="tts-model-pick-desc">${m.description}</div>` : ''}
|
||||
${m.languages?.length ? html`
|
||||
<div class="tts-model-pick-langs">${m.languages.slice(0, 6).join(', ')}${m.languages.length > 6 ? ` +${m.languages.length - 6}` : ''}</div>
|
||||
` : ''}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="agent-dialog-actions mt-3">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => { this._modal = 'add'; }}>
|
||||
Enter model ID manually
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderForm(isEdit = false) {
|
||||
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>`;
|
||||
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">${title}</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<form @submit=${(e) => isEdit ? this._submitEdit(e) : this._submitAdd(e)}>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Model ID <span class="text-muted fw-normal">(sent to API)</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.model_id} required
|
||||
placeholder="e.g. openai/whisper-1"
|
||||
?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>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Name / Alias <span class="text-muted fw-normal">(optional)</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.name}
|
||||
placeholder=${f.model_id || 'same as model ID'}
|
||||
@input=${(e) => this._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>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.language}
|
||||
placeholder="e.g. it, en — leave blank for auto-detect"
|
||||
@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>
|
||||
<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="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
|
||||
${this._saving ? 'Saving…' : isEdit ? 'Save changes' : 'Add model'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const tProviders = this._providers.filter(p =>
|
||||
Array.isArray(p.supported_types) && p.supported_types.includes('transcribe')
|
||||
);
|
||||
const canAdd = tProviders.length > 0;
|
||||
|
||||
return html`
|
||||
<div class="llm-page">
|
||||
<div class="llm-page-header">
|
||||
<div class="llm-header-left">
|
||||
${this.onback ? html`
|
||||
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back to models" @click=${this.onback}>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<h2 class="llm-page-title">Transcription Models</h2>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()} ?disabled=${!canAdd}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${!canAdd ? html`
|
||||
<div class="agent-info-banner">
|
||||
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="agent-info-banner-body">
|
||||
<p class="mb-0">No provider supports transcription yet. Add an <strong>OpenAI</strong> or <strong>OpenRouter</strong> provider first.</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this._error && !this._modal ? html`
|
||||
<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>
|
||||
` : ''}
|
||||
|
||||
${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.
|
||||
</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></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this._models.map(m => this._renderRow(m))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
${this._modal === 'pick-provider' ? this._renderPickProvider() : ''}
|
||||
${this._modal === 'pick-model' ? this._renderPickModel() : ''}
|
||||
${this._modal === 'add' ? this._renderForm(false) : ''}
|
||||
${this._modal?.mode === 'edit' ? this._renderForm(true) : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
// Audio formats accepted by the OpenAI-compatible `/audio/speech` endpoint.
|
||||
const TTS_RESPONSE_FORMATS = ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm'];
|
||||
|
||||
function emptyTtsForm() {
|
||||
return { provider_id: '', model_id: '', voice_id: '', name: '', description: '', instructions: '', response_format: '', priority: 100 };
|
||||
}
|
||||
|
||||
export class ModelsTtsSection extends LightElement {
|
||||
static properties = {
|
||||
onback: { attribute: false },
|
||||
_models: { state: true },
|
||||
_providers: { state: true },
|
||||
_modal: { state: true },
|
||||
_form: { state: true },
|
||||
_saving: { state: true },
|
||||
_error: { state: true },
|
||||
_provider: { state: true },
|
||||
_remoteModels: { state: true },
|
||||
_loadingModels: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.onback = null;
|
||||
this._models = [];
|
||||
this._providers = [];
|
||||
this._modal = null;
|
||||
this._form = emptyTtsForm();
|
||||
this._saving = false;
|
||||
this._error = null;
|
||||
this._provider = null;
|
||||
this._remoteModels = null;
|
||||
this._loadingModels = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._load();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const [modelsRes, providersRes] = await Promise.all([
|
||||
fetch('/api/tts/models'),
|
||||
fetch('/api/llm/providers'),
|
||||
]);
|
||||
if (!modelsRes.ok) throw new Error(`models: HTTP ${modelsRes.status}`);
|
||||
if (!providersRes.ok) throw new Error(`providers: HTTP ${providersRes.status}`);
|
||||
this._models = await modelsRes.json();
|
||||
this._providers = await providersRes.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add flow ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_openAdd() {
|
||||
this._error = null;
|
||||
this._provider = null;
|
||||
this._form = emptyTtsForm();
|
||||
this._modal = 'pick-provider';
|
||||
}
|
||||
|
||||
async _pickProvider(provider) {
|
||||
this._provider = provider;
|
||||
this._remoteModels = null;
|
||||
this._form = { ...emptyTtsForm(), provider_id: provider.id };
|
||||
this._loadingModels = true;
|
||||
this._modal = 'pick-model';
|
||||
try {
|
||||
const res = await fetch(`/api/tts/providers/${provider.id}/models`);
|
||||
this._remoteModels = res.ok ? await res.json() : null;
|
||||
} catch {
|
||||
this._remoteModels = null;
|
||||
} finally {
|
||||
this._loadingModels = false;
|
||||
if (!this._remoteModels || this._remoteModels.length === 0) {
|
||||
this._modal = 'add';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_pickRemoteModel(remote) {
|
||||
this._form = {
|
||||
...this._form,
|
||||
model_id: remote.id,
|
||||
name: remote.name,
|
||||
description: remote.description ?? '',
|
||||
instructions: remote.instructions ?? '',
|
||||
};
|
||||
this._modal = 'add';
|
||||
}
|
||||
|
||||
// ── Edit flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async _openEdit(m) {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/tts/models/${m.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const r = await res.json();
|
||||
this._provider = this._providers.find(p => p.id === r.provider_id) ?? null;
|
||||
this._form = {
|
||||
provider_id: r.provider_id,
|
||||
model_id: r.model_id,
|
||||
voice_id: r.voice_id ?? '',
|
||||
name: r.name,
|
||||
description: r.description ?? '',
|
||||
instructions: r.instructions ?? '',
|
||||
response_format: r.response_format ?? '',
|
||||
priority: r.priority,
|
||||
};
|
||||
this._modal = { mode: 'edit', id: r.id, name: r.name };
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async _delete(m) {
|
||||
if (!confirm(`Delete TTS model "${m.name}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/tts/models/${m.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────────────────
|
||||
|
||||
_payload() {
|
||||
const f = this._form;
|
||||
return {
|
||||
provider_id: Number(f.provider_id),
|
||||
model_id: f.model_id,
|
||||
voice_id: f.voice_id || null,
|
||||
name: f.name || f.model_id,
|
||||
description: f.description || null,
|
||||
instructions: f.instructions || null,
|
||||
response_format: f.response_format || null,
|
||||
priority: Number(f.priority) || 100,
|
||||
};
|
||||
}
|
||||
|
||||
async _submitAdd(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch('/api/tts/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this._payload()),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _submitEdit(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
const id = this._modal.id;
|
||||
try {
|
||||
const res = await fetch(`/api/tts/models/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this._payload()),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
_closeModal() { this._modal = null; this._error = null; }
|
||||
|
||||
// ── Render card ───────────────────────────────────────────────────────────────
|
||||
|
||||
_renderCard(m) {
|
||||
const isPlugin = m.from_plugin;
|
||||
return html`
|
||||
<div class="llm-card">
|
||||
<div class="llm-card-row1">
|
||||
${isPlugin
|
||||
? html`<span class="ig-source-badge ig-source-plugin">Plugin</span>`
|
||||
: html`<span class="ig-source-badge ig-source-cloud">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">
|
||||
<i class="bi bi-lock"></i>
|
||||
</span>
|
||||
` : html`
|
||||
<button class="llm-btn-icon llm-btn-edit" title="Edit" @click=${() => this._openEdit(m)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="llm-btn-icon llm-btn-delete" title="Delete" @click=${() => this._delete(m)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="llm-card-row2">
|
||||
${!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>` : ''}
|
||||
</div>
|
||||
|
||||
${m.description ? html`
|
||||
<div class="ig-card-desc">${m.description}</div>
|
||||
` : ''}
|
||||
|
||||
${m.instructions ? html`
|
||||
<div class="ig-card-desc" style="font-style:italic;color:var(--bs-secondary)">${m.instructions}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: pick provider ──────────────────────────────────────────────────────
|
||||
|
||||
_renderPickProvider() {
|
||||
const ttsProviders = this._providers.filter(p =>
|
||||
Array.isArray(p.supported_types) && p.supported_types.includes('tts')
|
||||
);
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">Add TTS Model — Choose Provider</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<div class="llm-provider-grid">
|
||||
${ttsProviders.map(p => html`
|
||||
<button class="llm-provider-card" @click=${() => this._pickProvider(p)}>
|
||||
<div class="llm-provider-card-name">${p.name}</div>
|
||||
<div class="llm-provider-card-type text-muted" style="font-size:0.75rem">${p.type}</div>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="agent-dialog-actions mt-3">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: pick remote model ──────────────────────────────────────────────────
|
||||
|
||||
_renderPickModel() {
|
||||
const p = this._provider;
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">
|
||||
Add TTS Model
|
||||
<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>
|
||||
` : html`
|
||||
<div class="tts-model-pick-list">
|
||||
${(this._remoteModels ?? []).map(m => html`
|
||||
<button class="tts-model-pick-item" @click=${() => this._pickRemoteModel(m)}>
|
||||
<div class="tts-model-pick-row1">
|
||||
<span class="tts-model-pick-name">${m.name}</span>
|
||||
${m.cost_factor != null ? html`
|
||||
<span class="tts-model-pick-cost" title="Cost multiplier relative to base rate">×${m.cost_factor.toFixed(1)}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
${m.description ? html`<div class="tts-model-pick-desc">${m.description}</div>` : ''}
|
||||
${m.languages?.length ? html`
|
||||
<div class="tts-model-pick-langs">${m.languages.slice(0, 6).join(', ')}${m.languages.length > 6 ? ` +${m.languages.length - 6}` : ''}</div>
|
||||
` : ''}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="agent-dialog-actions mt-3">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => { this._modal = 'add'; }}>
|
||||
Enter model ID manually
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: add / edit form ────────────────────────────────────────────────────
|
||||
|
||||
_renderForm(isEdit = false) {
|
||||
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>`;
|
||||
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">${title}</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<form @submit=${(e) => isEdit ? this._submitEdit(e) : this._submitAdd(e)}>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Model ID <span class="text-muted fw-normal">(sent to API)</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.model_id} required
|
||||
placeholder="e.g. tts-1-hd"
|
||||
?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>` : ''}
|
||||
</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>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.voice_id}
|
||||
placeholder="e.g. alloy, Kore, 21m00Tcm4TlvDq8ikWAM"
|
||||
@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>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Name / Alias</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.name}
|
||||
placeholder=${f.model_id || 'same as model ID'}
|
||||
@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>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.description}
|
||||
placeholder="e.g. High quality, slow — best for long responses"
|
||||
@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>
|
||||
</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."
|
||||
@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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
${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>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">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>
|
||||
|
||||
<div class="agent-dialog-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
|
||||
${this._saving ? 'Saving…' : isEdit ? 'Save changes' : 'Add model'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const ttsProviders = this._providers.filter(p =>
|
||||
Array.isArray(p.supported_types) && p.supported_types.includes('tts')
|
||||
);
|
||||
const canAdd = ttsProviders.length > 0;
|
||||
|
||||
return html`
|
||||
<div class="llm-page">
|
||||
<div class="llm-page-header">
|
||||
<div class="llm-header-left">
|
||||
${this.onback ? html`
|
||||
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back to models" @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>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()} ?disabled=${!canAdd}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${!canAdd ? html`
|
||||
<div class="agent-info-banner">
|
||||
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="agent-info-banner-body">
|
||||
<p class="mb-0">No provider supports TTS yet. Add an <strong>OpenAI</strong> provider first.</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this._models.some(m => m.from_plugin) ? html`
|
||||
<div class="agent-info-banner">
|
||||
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="agent-info-banner-body">
|
||||
<p class="mb-0">Models with the <strong>Plugin</strong> badge are read-only — managed automatically by the plugin that registered them.</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this._error && !this._modal ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : ''}
|
||||
|
||||
<div class="llm-card-list">
|
||||
${this._models.length === 0 ? html`
|
||||
<div class="llm-empty-state">
|
||||
<i class="bi bi-volume-up"></i>
|
||||
<p>No TTS models configured.</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
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
` : this._models.map(m => this._renderCard(m))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._modal === 'pick-provider' ? this._renderPickProvider() : ''}
|
||||
${this._modal === 'pick-model' ? this._renderPickModel() : ''}
|
||||
${this._modal === 'add' ? this._renderForm(false) : ''}
|
||||
${this._modal?.mode === 'edit' ? this._renderForm(true) : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { ProjectListSection } from './project-list.js';
|
||||
import { ProjectBoardSection } from './project-board.js';
|
||||
|
||||
export class ProjectsPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_view: { state: true },
|
||||
_projectId: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._view = 'list';
|
||||
this._projectId = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
const open = e.detail.page === 'projects';
|
||||
this._open = open;
|
||||
this.style.display = open ? 'flex' : 'none';
|
||||
if (open) {
|
||||
const { view, id } = this._parseHash();
|
||||
this._view = view;
|
||||
this._projectId = id;
|
||||
this._loadCurrent();
|
||||
}
|
||||
});
|
||||
window.addEventListener('sidebar-open-project', (e) => {
|
||||
this._open = true;
|
||||
this.style.display = 'flex';
|
||||
this._navigateToBoard(e.detail.id);
|
||||
});
|
||||
}
|
||||
|
||||
_parseHash() {
|
||||
const parts = location.hash.slice(1).split('/');
|
||||
if (parts[0] === 'projects' && parts[1] && /^\d+$/.test(parts[1])) {
|
||||
return { view: 'board', id: parseInt(parts[1], 10) };
|
||||
}
|
||||
return { view: 'list', id: null };
|
||||
}
|
||||
|
||||
_loadCurrent() {
|
||||
this.updateComplete.then(() => {
|
||||
if (this._view === 'list') {
|
||||
this.querySelector('project-list-section')?.load();
|
||||
} else {
|
||||
this.querySelector('project-board-section')?.load(this._projectId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_navigateToBoard(id) {
|
||||
this._view = 'board';
|
||||
this._projectId = id;
|
||||
history.pushState({ page: 'projects', id }, '', `#projects/${id}`);
|
||||
this.updateComplete.then(() => {
|
||||
this.querySelector('project-board-section')?.load(id);
|
||||
});
|
||||
}
|
||||
|
||||
_navigateToList() {
|
||||
this._view = 'list';
|
||||
this._projectId = null;
|
||||
history.pushState({ page: 'projects' }, '', '#projects');
|
||||
this.updateComplete.then(() => {
|
||||
this.querySelector('project-list-section')?.load();
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
return html`
|
||||
${this._view === 'list' ? html`
|
||||
<project-list-section
|
||||
@project-navigate=${e => this._navigateToBoard(e.detail.id)}
|
||||
></project-list-section>
|
||||
` : html`
|
||||
<project-board-section
|
||||
@project-back=${() => this._navigateToList()}
|
||||
></project-board-section>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('project-list-section', ProjectListSection);
|
||||
customElements.define('project-board-section', ProjectBoardSection);
|
||||
@@ -0,0 +1,472 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement, renderMarkdown } from '../../lib/base.js';
|
||||
import { formatDate } from '../tasks/utils.js';
|
||||
|
||||
export class ProjectBoardSection extends LightElement {
|
||||
static properties = {
|
||||
_project: { state: true },
|
||||
_tickets: { state: true },
|
||||
_modal: { state: true },
|
||||
_form: { state: true },
|
||||
_saving: { state: true },
|
||||
_error: { state: true },
|
||||
_expanded: { state: true },
|
||||
_expandedDesc: { state: true },
|
||||
_agents: { state: true },
|
||||
_groups: { state: true },
|
||||
_activeTab: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._project = null;
|
||||
this._tickets = [];
|
||||
this._modal = null;
|
||||
this._form = this._emptyForm();
|
||||
this._saving = false;
|
||||
this._error = null;
|
||||
this._expanded = null;
|
||||
this._expandedDesc = {};
|
||||
this._pollTimer = null;
|
||||
this._projectId = null;
|
||||
this._agents = [];
|
||||
this._groups = [];
|
||||
this._activeTab = 'tickets';
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._stopPolling();
|
||||
}
|
||||
|
||||
_emptyForm() {
|
||||
// No default agent — a ticket runs a `task` agent, picked once the list loads.
|
||||
return { title: '', description: '', agent_id: '', security_group: '' };
|
||||
}
|
||||
|
||||
async load(projectId) {
|
||||
this._projectId = projectId;
|
||||
this._project = null;
|
||||
this._error = null;
|
||||
try {
|
||||
const [projRes, tickRes] = await Promise.all([
|
||||
fetch(`/api/projects/${projectId}`),
|
||||
fetch(`/api/projects/${projectId}/tickets`),
|
||||
]);
|
||||
if (!projRes.ok) throw new Error(`HTTP ${projRes.status}`);
|
||||
if (!tickRes.ok) throw new Error(`HTTP ${tickRes.status}`);
|
||||
this._project = await projRes.json();
|
||||
this._tickets = await tickRes.json();
|
||||
this._updatePolling();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _loadTickets() {
|
||||
if (!this._projectId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${this._projectId}/tickets`);
|
||||
if (res.ok) {
|
||||
this._tickets = await res.json();
|
||||
this._updatePolling();
|
||||
}
|
||||
} catch { /* ignore transient errors during poll */ }
|
||||
}
|
||||
|
||||
_hasActiveTickets() {
|
||||
return this._tickets.some(t => t.status === 'pending' || t.status === 'in_progress');
|
||||
}
|
||||
|
||||
_updatePolling() {
|
||||
if (this._hasActiveTickets()) {
|
||||
this._startPolling();
|
||||
} else {
|
||||
this._stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
_startPolling() {
|
||||
if (this._pollTimer) return;
|
||||
this._pollTimer = setInterval(() => this._loadTickets(), 5000);
|
||||
}
|
||||
|
||||
_stopPolling() {
|
||||
if (this._pollTimer) {
|
||||
clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_groupTickets() {
|
||||
const running = [];
|
||||
const todo = [];
|
||||
const completed = [];
|
||||
|
||||
for (const t of this._tickets) {
|
||||
if (t.status === 'pending' || t.status === 'in_progress') {
|
||||
running.push(t);
|
||||
} else if (t.status === 'todo') {
|
||||
todo.push(t);
|
||||
} else {
|
||||
completed.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
todo.sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''));
|
||||
completed.sort((a, b) => (b.completed_at ?? '').localeCompare(a.completed_at ?? ''));
|
||||
|
||||
return { running, todo, completed };
|
||||
}
|
||||
|
||||
async _loadModalData() {
|
||||
try {
|
||||
const [agentsRes, groupsRes] = await Promise.all([
|
||||
fetch('/api/agents'),
|
||||
fetch('/api/tool-permission-groups'),
|
||||
]);
|
||||
if (agentsRes.ok) this._agents = await agentsRes.json();
|
||||
if (groupsRes.ok) this._groups = await groupsRes.json();
|
||||
// Tickets run task agents only; pre-select the first one so a valid value is sent.
|
||||
if (!this._form.agent_id) {
|
||||
const first = this._agents.find(a => a.type === 'task');
|
||||
if (first) this._form = { ...this._form, agent_id: first.id };
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async _startTicket(ticket) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${ticket.project_id}/tickets/${ticket.id}/start`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._loadTickets();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _resetTicket(ticket) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${ticket.project_id}/tickets/${ticket.id}/reset`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (this._expanded === ticket.id) this._expanded = null;
|
||||
await this._loadTickets();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _deleteTicket(ticket) {
|
||||
if (!confirm(`Delete ticket "${ticket.title}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${ticket.project_id}/tickets/${ticket.id}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._loadTickets();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _createTicket(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const payload = { ...this._form };
|
||||
if (!payload.security_group) delete payload.security_group;
|
||||
const res = await fetch(`/api/projects/${this._projectId}/tickets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._loadTickets();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
_back() {
|
||||
this._stopPolling();
|
||||
this.dispatchEvent(new CustomEvent('project-back', { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
async _openChat() {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${this._projectId}/session`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const { source } = await res.json();
|
||||
window.dispatchEvent(new CustomEvent('project-chat-open', {
|
||||
detail: { source, label: this._project?.name ?? `Project ${this._projectId}` },
|
||||
}));
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
_toggleExpand(id) {
|
||||
this._expanded = this._expanded === id ? null : id;
|
||||
}
|
||||
|
||||
_toggleDesc(id) {
|
||||
this._expandedDesc = { ...this._expandedDesc, [id]: !this._expandedDesc[id] };
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderTicketCard(ticket) {
|
||||
const isRunning = ticket.status === 'pending' || ticket.status === 'in_progress';
|
||||
const isDone = ticket.status === 'done';
|
||||
const isFailed = ticket.status === 'failed';
|
||||
const isCompleted = isDone || isFailed;
|
||||
const isExpanded = this._expanded === ticket.id;
|
||||
|
||||
const cardClass = isRunning ? 'ticket-card ticket-card--running'
|
||||
: isDone ? 'ticket-card ticket-card--done'
|
||||
: isFailed ? 'ticket-card ticket-card--failed'
|
||||
: 'ticket-card';
|
||||
|
||||
return html`
|
||||
<div class="${cardClass}">
|
||||
<div class="ticket-card-header">
|
||||
<span class="ticket-card-title">${ticket.title}</span>
|
||||
${isRunning ? html`
|
||||
<span class="spinner-border spinner-border-sm text-primary"
|
||||
style="width:0.7rem;height:0.7rem;flex-shrink:0"></span>
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
${ticket.description
|
||||
? html`<div class="ticket-card-desc ${this._expandedDesc[ticket.id] ? 'ticket-card-desc--expanded' : ''}"
|
||||
@click=${() => { if (!window.getSelection().toString()) this._toggleDesc(ticket.id); }}>${ticket.description}</div>`
|
||||
: nothing}
|
||||
<div class="ticket-card-meta">
|
||||
<span><i class="bi bi-person me-1"></i>${ticket.agent_id}</span>
|
||||
${ticket.started_at ? html`
|
||||
<span><i class="bi bi-clock me-1"></i>${formatDate(ticket.started_at)}</span>
|
||||
` : html`
|
||||
<span><i class="bi bi-calendar me-1"></i>${formatDate(ticket.created_at)}</span>
|
||||
`}
|
||||
${isCompleted && ticket.completed_at ? html`
|
||||
<span><i class="bi bi-check2 me-1"></i>${formatDate(ticket.completed_at)}</span>
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
<div class="ticket-card-actions">
|
||||
${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
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger ticket-card-btn"
|
||||
@click=${() => this._deleteTicket(ticket)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
` : nothing}
|
||||
|
||||
${isRunning ? html`
|
||||
<span class="ticket-card-running-label">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}
|
||||
</a>
|
||||
` : nothing}
|
||||
` : nothing}
|
||||
|
||||
${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
|
||||
</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'}
|
||||
</button>
|
||||
${ticket.session_id != null ? html`
|
||||
<a href="#session/${ticket.session_id}"
|
||||
class="btn btn-sm btn-outline-secondary ticket-card-btn ticket-card-session-btn">
|
||||
<i class="bi bi-chat-text me-1"></i>#${ticket.session_id}
|
||||
</a>
|
||||
` : nothing}
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
${isCompleted && isExpanded ? html`
|
||||
<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)'))}
|
||||
</div>`
|
||||
: html`<pre class="ticket-result-error">${ticket.error ?? '(no error message)'}</pre>`}
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderSection(label, icon, colorClass, tickets, emptyLabel) {
|
||||
return html`
|
||||
<div class="ticket-section">
|
||||
<div class="ticket-section-header ${colorClass}">
|
||||
<span><i class="bi bi-${icon} me-1"></i>${label}</span>
|
||||
<span class="badge bg-secondary ms-2">${tickets.length}</span>
|
||||
</div>
|
||||
${tickets.length === 0
|
||||
? html`<div class="ticket-section-empty">${emptyLabel}</div>`
|
||||
: tickets.map(t => this._renderTicketCard(t))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderTabBar() {
|
||||
return html`
|
||||
<div class="project-tab-bar">
|
||||
<button
|
||||
class="project-tab ${this._activeTab === 'tickets' ? 'project-tab--active' : ''}"
|
||||
@click=${() => { this._activeTab = 'tickets'; }}>
|
||||
<i class="bi bi-card-list me-1"></i>Tickets
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderTicketsTab() {
|
||||
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')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderModal() {
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop">
|
||||
<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>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
<form @submit=${e => this._createTicket(e)}>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Title</label>
|
||||
<input type="text" class="form-control form-control-sm" required
|
||||
placeholder="What needs to be done"
|
||||
.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>
|
||||
<textarea class="form-control form-control-sm" rows="4"
|
||||
placeholder="Detailed instructions for the agent…"
|
||||
.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>
|
||||
<select class="form-select form-select-sm"
|
||||
.value=${this._form.agent_id}
|
||||
@change=${e => this._form = { ...this._form, agent_id: e.target.value }}>
|
||||
${this._agents.filter(a => a.type === 'task').map(a => html`
|
||||
<option value=${a.id} ?selected=${this._form.agent_id === a.id}>${a.name || a.id}</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">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>
|
||||
${this._groups.map(g => html`
|
||||
<option value=${g.id} ?selected=${this._form.security_group === g.id}>${g.name}</option>
|
||||
`)}
|
||||
</select>
|
||||
</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>
|
||||
<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`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._project) {
|
||||
return html`
|
||||
<div style="display:flex;align-items:center;justify-content:center;flex:1">
|
||||
<span class="spinner-border text-primary"></span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="project-page">
|
||||
<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
|
||||
</button>
|
||||
<h2 class="project-page-title">
|
||||
<i class="bi bi-folder2"></i>${this._project.name}
|
||||
</h2>
|
||||
</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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._renderTabBar()}
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mt-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${this._activeTab === 'tickets' ? this._renderTicketsTab() : nothing}
|
||||
|
||||
${this._modal ? this._renderModal() : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { formatDate } from '../tasks/utils.js';
|
||||
|
||||
export class ProjectListSection extends LightElement {
|
||||
static properties = {
|
||||
_projects: { state: true },
|
||||
_modal: { state: true },
|
||||
_form: { state: true },
|
||||
_saving: { state: true },
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._projects = [];
|
||||
this._modal = null;
|
||||
this._form = this._emptyForm();
|
||||
this._saving = false;
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
_emptyForm() {
|
||||
return { name: '', path: '', description: '' };
|
||||
}
|
||||
|
||||
async load() {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._projects = await res.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
_openAdd() {
|
||||
this._form = this._emptyForm();
|
||||
this._error = null;
|
||||
this._modal = { mode: 'add' };
|
||||
}
|
||||
|
||||
_openEdit(project) {
|
||||
this._form = { name: project.name, path: project.path, description: project.description ?? '' };
|
||||
this._error = null;
|
||||
this._modal = { mode: 'edit', project };
|
||||
}
|
||||
|
||||
_closeModal() {
|
||||
this._modal = null;
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
async _submit(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
const isEdit = this._modal?.mode === 'edit';
|
||||
const url = isEdit ? `/api/projects/${this._modal.project.id}` : '/api/projects';
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: isEdit ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this._form),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _delete(project) {
|
||||
if (!confirm(`Delete project "${project.name}"?\nAll tickets will also be deleted.`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${project.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
_navigate(project) {
|
||||
this.dispatchEvent(new CustomEvent('project-navigate', {
|
||||
bubbles: true, composed: true, detail: { id: project.id },
|
||||
}));
|
||||
}
|
||||
|
||||
_setField(f, v) {
|
||||
this._form = { ...this._form, [f]: v };
|
||||
}
|
||||
|
||||
_renderModal() {
|
||||
const isEdit = this._modal?.mode === 'edit';
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop"
|
||||
@click=${e => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<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>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
<form @submit=${e => this._submit(e)}>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" required
|
||||
placeholder="My Project"
|
||||
.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>
|
||||
<input type="text" class="form-control form-control-sm" required
|
||||
placeholder="/path/to/project"
|
||||
.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>
|
||||
<textarea class="form-control form-control-sm" rows="2"
|
||||
placeholder="What this project is about"
|
||||
.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>
|
||||
<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'}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderCard(project) {
|
||||
return html`
|
||||
<div class="project-card" @click=${() => this._navigate(project)}>
|
||||
<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"
|
||||
@click=${() => this._openEdit(project)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="project-card-icon-btn project-card-icon-btn--danger" title="Delete"
|
||||
@click=${() => this._delete(project)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-card-path"><i class="bi bi-folder2 me-1"></i>${project.path}</div>
|
||||
${project.description
|
||||
? html`<div class="project-card-desc">${project.description}</div>`
|
||||
: nothing}
|
||||
<div class="project-card-meta">Updated ${formatDate(project.updated_at)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="project-page">
|
||||
<div class="project-page-header">
|
||||
<h2 class="project-page-title"><i class="bi bi-kanban"></i> Projects</h2>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>New Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mt-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${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>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="project-grid">
|
||||
${this._projects.map(p => this._renderCard(p))}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${this._modal ? this._renderModal() : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
const PAGE_ID = 'session';
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return null;
|
||||
return new Date(iso).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function sourceBadgeClass(source) {
|
||||
const map = { tic: 'bg-warning text-dark', cron: 'bg-info text-dark', web: 'bg-primary', telegram: 'bg-success', mobile: 'bg-secondary' };
|
||||
return map[source] ?? 'bg-secondary';
|
||||
}
|
||||
|
||||
function jsonPretty(val) {
|
||||
if (val == null) return '—';
|
||||
if (typeof val === 'string') return val;
|
||||
return JSON.stringify(val, null, 2);
|
||||
}
|
||||
|
||||
export class SessionDetailPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_sessionId: { state: true },
|
||||
_data: { state: true },
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
_live: { state: true },
|
||||
_expandedTools: { state: true },
|
||||
_expandedReasons: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._sessionId = null;
|
||||
this._data = null;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this._live = false;
|
||||
this._expandedTools = new Set();
|
||||
this._expandedReasons = new Set();
|
||||
this._ws = null;
|
||||
this._wsReconnectTimer = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === PAGE_ID;
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._loadFromHash();
|
||||
else this._closeWs();
|
||||
});
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (this._open) this._loadFromHash();
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._closeWs();
|
||||
}
|
||||
|
||||
_idFromHash() {
|
||||
const parts = location.hash.replace('#', '').split('/');
|
||||
if (parts[0] === PAGE_ID && parts[1]) return parseInt(parts[1], 10);
|
||||
return null;
|
||||
}
|
||||
|
||||
_loadFromHash() {
|
||||
const id = this._idFromHash();
|
||||
if (id != null && id !== this._sessionId) {
|
||||
this._sessionId = id;
|
||||
this._closeWs();
|
||||
this._fetch(id);
|
||||
}
|
||||
}
|
||||
|
||||
async _fetch(id) {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
this._data = null;
|
||||
this._expandedTools = new Set();
|
||||
this._expandedReasons = new Set();
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._data = await res.json();
|
||||
this._connectWs(id);
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Live WebSocket ─────────────────────────────────────────────────────────
|
||||
|
||||
_connectWs(id) {
|
||||
this._closeWs();
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${proto}://${location.host}/api/ws/session/${id}`);
|
||||
this._ws = ws;
|
||||
|
||||
ws.onopen = () => { this._live = true; };
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try { this._handleEvent(JSON.parse(e.data)); } catch {}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
this._live = false;
|
||||
this._ws = null;
|
||||
// Reconnect after 3 s if the page is still open and showing this session.
|
||||
if (this._open && this._sessionId === id) {
|
||||
this._wsReconnectTimer = setTimeout(() => this._connectWs(id), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
|
||||
_closeWs() {
|
||||
clearTimeout(this._wsReconnectTimer);
|
||||
if (this._ws) { this._ws.onclose = null; this._ws.close(); this._ws = null; }
|
||||
this._live = false;
|
||||
}
|
||||
|
||||
_handleEvent(ev) {
|
||||
if (!this._data) return;
|
||||
const msgs = [...this._data.messages];
|
||||
const now = new Date().toISOString();
|
||||
|
||||
switch (ev.type) {
|
||||
case 'tool_start':
|
||||
msgs.push({
|
||||
kind: 'tool',
|
||||
tool_call_id: ev.tool_call_id,
|
||||
message_id: ev.message_id,
|
||||
name: ev.name,
|
||||
label_short: ev.label_short,
|
||||
label_full: ev.label_full,
|
||||
arguments: ev.arguments,
|
||||
result: null,
|
||||
error: null,
|
||||
status: 'pending',
|
||||
created_at: now,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'tool_done': {
|
||||
const i = msgs.findIndex(m => m.kind === 'tool' && m.tool_call_id === ev.tool_call_id);
|
||||
if (i >= 0) msgs[i] = { ...msgs[i], result: ev.result, status: 'done' };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_error': {
|
||||
const i = msgs.findIndex(m => m.kind === 'tool' && m.tool_call_id === ev.tool_call_id);
|
||||
if (i >= 0) msgs[i] = { ...msgs[i], error: ev.error, status: 'error' };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'thinking':
|
||||
msgs.push({
|
||||
kind: 'thinking',
|
||||
message_id: ev.message_id,
|
||||
content: ev.content,
|
||||
reasoning: '',
|
||||
input_tokens: ev.input_tokens ?? null,
|
||||
output_tokens: ev.output_tokens ?? null,
|
||||
created_at: now,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
msgs.push({
|
||||
kind: 'assistant',
|
||||
message_id: ev.message_id,
|
||||
content: ev.content,
|
||||
reasoning: '',
|
||||
input_tokens: ev.input_tokens ?? null,
|
||||
output_tokens: ev.output_tokens ?? null,
|
||||
created_at: now,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'user_message':
|
||||
msgs.push({
|
||||
kind: 'user',
|
||||
content: ev.content,
|
||||
is_synthetic: false,
|
||||
created_at: now,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'agent_start':
|
||||
msgs.push({
|
||||
kind: 'agent',
|
||||
agent_id: ev.agent_id,
|
||||
depth: ev.depth,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'agent_done':
|
||||
msgs.push({
|
||||
kind: 'agent_end',
|
||||
agent_id: ev.agent_id,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
return; // ignore unknown events
|
||||
}
|
||||
|
||||
this._data = { ...this._data, messages: msgs };
|
||||
}
|
||||
|
||||
_toggleTool(id) {
|
||||
const next = new Set(this._expandedTools);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
this._expandedTools = next;
|
||||
}
|
||||
|
||||
_toggleReason(id) {
|
||||
const next = new Set(this._expandedReasons);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
this._expandedReasons = next;
|
||||
}
|
||||
|
||||
// ── Renderers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_back() {
|
||||
history.back();
|
||||
}
|
||||
|
||||
_renderSessionHeader(session) {
|
||||
return html`
|
||||
<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
|
||||
</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}
|
||||
${this._live
|
||||
? html`<span class="sd-live-badge"><span class="sd-live-dot"></span>live</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="text-secondary small mt-1">${formatDate(session.created_at)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderUserMsg(item, idx) {
|
||||
const time = formatTime(item.created_at);
|
||||
return html`
|
||||
<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>`
|
||||
: nothing}
|
||||
<span>User</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}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderAssistantMsg(item, idx) {
|
||||
const key = `ast-${idx}`;
|
||||
const hasReasoning = item.reasoning && item.reasoning.trim().length > 0;
|
||||
const expanded = this._expandedReasons.has(key);
|
||||
const time = formatTime(item.created_at);
|
||||
return html`
|
||||
<div class="sd-msg sd-msg--assistant ${item.failed ? 'sd-msg--failed' : ''}">
|
||||
<div class="sd-msg-role">
|
||||
Assistant
|
||||
${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
|
||||
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i>
|
||||
</div>
|
||||
${expanded ? html`<pre class="sd-reasoning-block">${item.reasoning}</pre>` : nothing}
|
||||
` : nothing}
|
||||
${item.content ? html`<div class="sd-msg-content">${item.content}</div>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderThinkingMsg(item, idx) {
|
||||
const key = `think-${item.message_id ?? idx}`;
|
||||
const hasReasoning = item.reasoning && item.reasoning.trim().length > 0;
|
||||
const expanded = this._expandedReasons.has(key);
|
||||
const time = formatTime(item.created_at);
|
||||
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
|
||||
${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
|
||||
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i>
|
||||
</div>
|
||||
${expanded ? html`<pre class="sd-reasoning-block">${item.reasoning}</pre>` : nothing}
|
||||
` : nothing}
|
||||
${item.content ? html`<div class="sd-msg-content">${item.content}</div>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderToolMsg(item, idx) {
|
||||
const key = item.tool_call_id ?? idx;
|
||||
const expanded = this._expandedTools.has(key);
|
||||
const statusClass = { done: 'sd-tool--done', error: 'sd-tool--error', pending: 'sd-tool--pending' }[item.status] ?? '';
|
||||
return html`
|
||||
<div class="sd-tool ${statusClass}">
|
||||
<div class="sd-tool-header" @click=${() => this._toggleTool(key)}>
|
||||
<span class="sd-tool-icon">
|
||||
<i class="bi bi-${item.status === 'done' ? 'check-circle' : item.status === 'error' ? 'x-circle' : 'hourglass-split'}"></i>
|
||||
</span>
|
||||
<span class="sd-tool-name">${item.label_short ?? item.name}</span>
|
||||
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-auto"></i>
|
||||
</div>
|
||||
${expanded ? html`
|
||||
<div class="sd-tool-body">
|
||||
${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>
|
||||
<pre class="sd-code-block">${jsonPretty(item.arguments)}</pre>
|
||||
<div class="sd-tool-section-label mt-2">
|
||||
${item.status === 'error' ? 'Error' : 'Result'}
|
||||
</div>
|
||||
<pre class="sd-code-block ${item.status === 'error' ? 'sd-code-block--error' : ''}">${
|
||||
item.result ?? item.error ?? '—'
|
||||
}</pre>
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderAgentFrame(item) {
|
||||
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>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderAgentFrameEnd(item) {
|
||||
return html`<div class="sd-agent-frame-end">end of ${item.agent_id}</div>`;
|
||||
}
|
||||
|
||||
_renderMessage(item, idx) {
|
||||
switch (item.kind) {
|
||||
case 'user': return this._renderUserMsg(item, idx);
|
||||
case 'assistant': return this._renderAssistantMsg(item, idx);
|
||||
case 'thinking': return this._renderThinkingMsg(item, idx);
|
||||
case 'tool': return this._renderToolMsg(item, idx);
|
||||
case 'agent': return this._renderAgentFrame(item);
|
||||
case 'agent_end': return this._renderAgentFrameEnd(item);
|
||||
default: return nothing;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<style>
|
||||
.sd-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
gap: 0;
|
||||
}
|
||||
.sd-back-btn {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
.sd-session-header {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.sd-msg {
|
||||
border-left: 3px solid transparent;
|
||||
padding: 0.625rem 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
.sd-msg--user { border-left-color: var(--bs-primary); background: var(--bs-tertiary-bg); }
|
||||
.sd-msg--synthetic { opacity: 0.75; border-left-color: var(--bs-warning); }
|
||||
.sd-msg--assistant { border-left-color: var(--bs-success); }
|
||||
.sd-msg--thinking { border-left-color: var(--bs-info); background: var(--bs-tertiary-bg); }
|
||||
.sd-msg--failed { border-left-color: var(--bs-danger) !important; opacity: 0.7; }
|
||||
.sd-msg-role {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.sd-msg-time {
|
||||
font-weight: 400;
|
||||
font-size: 0.65rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
.sd-tokens {
|
||||
font-weight: 400;
|
||||
font-size: 0.65rem;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-left: auto;
|
||||
}
|
||||
.sd-msg-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.sd-msg-failed { font-size: 0.7rem; color: var(--bs-danger); margin-top: 0.25rem; }
|
||||
.sd-reasoning-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
color: var(--bs-secondary-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 999px;
|
||||
padding: 0.125rem 0.5rem;
|
||||
margin-bottom: 0.375rem;
|
||||
user-select: none;
|
||||
}
|
||||
.sd-reasoning-toggle:hover { background: var(--bs-tertiary-bg); }
|
||||
.sd-reasoning-block {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin-bottom: 0.5rem;
|
||||
max-height: 20rem;
|
||||
overflow-y: auto;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.sd-tool {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
.sd-tool--done { border-left: 3px solid var(--bs-success); }
|
||||
.sd-tool--error { border-left: 3px solid var(--bs-danger); }
|
||||
.sd-tool--pending { border-left: 3px solid var(--bs-warning); }
|
||||
.sd-tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sd-tool-header:hover { background: var(--bs-tertiary-bg); }
|
||||
.sd-tool-icon { color: var(--bs-secondary-color); }
|
||||
.sd-tool-name { font-family: var(--bs-font-monospace); font-size: 0.8rem; }
|
||||
.sd-tool-body {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
.sd-tool-section-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.sd-tool-label-full { font-style: italic; }
|
||||
.sd-code-block {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
.sd-code-block--error { border-color: var(--bs-danger); color: var(--bs-danger); }
|
||||
.sd-agent-frame-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--bs-secondary-color);
|
||||
border-top: 1px dashed var(--bs-border-color);
|
||||
padding: 0.375rem 0;
|
||||
margin: 0.25rem 0 0.25rem 1rem;
|
||||
}
|
||||
.sd-agent-frame-end {
|
||||
font-size: 0.72rem;
|
||||
color: var(--bs-secondary-color);
|
||||
border-bottom: 1px dashed var(--bs-border-color);
|
||||
padding: 0.25rem 0;
|
||||
margin: 0 0 0.375rem 1rem;
|
||||
}
|
||||
.sd-live-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-success);
|
||||
border: 1px solid var(--bs-success);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
.sd-live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--bs-success);
|
||||
animation: sd-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes sd-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.25; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<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>
|
||||
` : 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>
|
||||
` : 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>`
|
||||
: this._data.messages.map((m, i) => this._renderMessage(m, i))
|
||||
}
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { ChatSession } from '../../lib/chat-session.js';
|
||||
import { renderMsg, renderAttachmentChips } from '../copilot-render.js';
|
||||
|
||||
export class ChatPage extends ChatSession {
|
||||
static properties = {
|
||||
visible: { type: Boolean },
|
||||
// Target source. Defaults to the main mobile session; set to `project-{id}`
|
||||
// to bind this chat to a project's coordinator session.
|
||||
source: { type: String },
|
||||
// Human-readable label for the active source (e.g. the project name), shown
|
||||
// in the header when inside a project.
|
||||
label: { type: String },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.visible = false;
|
||||
this.source = 'mobile';
|
||||
this.label = '';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Honour the initial `source` prop on the first connect so a cold deep-link
|
||||
// (e.g. the native shell opening #chat/project-<id>) connects straight to it,
|
||||
// instead of connecting to the 'mobile' default and switching a tick later
|
||||
// (which would briefly open two WebSockets). Later `source` prop changes are
|
||||
// still handled by `updated` below.
|
||||
if (this.source && this.source !== this._wsSource) this._activeSource = this.source;
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
if (changed.has('visible') && this.visible) {
|
||||
this._scrollToBottom();
|
||||
}
|
||||
// The owner (mobile-app) re-points this chat by changing `source`. Switch the
|
||||
// live connection — base `_switchSource` tears down the WS, reloads that
|
||||
// source's history, and reconnects. The guard skips the initial no-op render.
|
||||
if (changed.has('source') && this.source !== this._source) {
|
||||
this._switchSource(this.source);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Source identity ────────────────────────────────────────────────────────
|
||||
|
||||
// Static fallback used only before the first `source` prop is applied.
|
||||
get _wsSource() { return 'mobile'; }
|
||||
|
||||
get _inProject() {
|
||||
return typeof this.source === 'string' && this.source.startsWith('project-');
|
||||
}
|
||||
|
||||
_exitProject() {
|
||||
this.dispatchEvent(new CustomEvent('project-exit', { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
// ── DOM hooks ──────────────────────────────────────────────────────────────
|
||||
|
||||
_inputEl() {
|
||||
return this.querySelector('.chat-page-textarea');
|
||||
}
|
||||
|
||||
_scrollToBottom() {
|
||||
this.updateComplete.then(() => {
|
||||
const el = this.querySelector('.chat-page-messages');
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
_onMessagePushed(item) {
|
||||
if (item.kind === 'pending_write') {
|
||||
this.updateComplete.then(() => {
|
||||
const panels = this.querySelectorAll('.copilot-approval');
|
||||
const el = panels[panels.length - 1];
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
});
|
||||
} else {
|
||||
this._scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Input ──────────────────────────────────────────────────────────────────
|
||||
// Note: unlike the desktop copilot, Enter does NOT send here. On mobile there
|
||||
// is no practical Shift+Enter, so Enter inserts a newline (the textarea's
|
||||
// default) and the explicit send button is the only way to submit — making
|
||||
// multi-line messages possible.
|
||||
|
||||
// ── Toggle expand ──────────────────────────────────────────────────────────
|
||||
|
||||
_toggleExpand(id) {
|
||||
const next = new Set(this._expanded);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
this._expanded = next;
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
if (!this.visible) return nothing;
|
||||
|
||||
return html`
|
||||
<div class="chat-page">
|
||||
|
||||
<div class="mobile-section-header">
|
||||
<span class="mobile-section-title">
|
||||
${this._inProject ? html`
|
||||
<button class="chat-page-back" title="Back to 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`}
|
||||
</span>
|
||||
<div class="chat-page-header-actions">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
title="New conversation"
|
||||
@click=${() => this._startNewSession()}
|
||||
><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-page-messages">
|
||||
${this._messages.length === 0 ? html`
|
||||
<div class="chat-page-empty">
|
||||
<i class="bi bi-stars"></i>
|
||||
<p>Ask me anything</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…
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
<div class="chat-page-input-area">
|
||||
<div class="chat-page-composer"
|
||||
@dragover=${(e) => e.preventDefault()}
|
||||
@drop=${(e) => this._onDrop(e)}>
|
||||
${renderAttachmentChips(this, this._attachments, { removable: true })}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
class="chat-page-file-input"
|
||||
style="display:none"
|
||||
@change=${(e) => { this._addFiles(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<textarea
|
||||
class="chat-page-textarea"
|
||||
rows="1"
|
||||
placeholder="Type a message…"
|
||||
@input=${(e) => this._autoResize(e.target)}
|
||||
@paste=${(e) => this._onPaste(e)}
|
||||
></textarea>
|
||||
<div class="chat-page-toolbar">
|
||||
<div class="chat-page-toolbar-left">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary chat-page-attach-btn"
|
||||
title="Attach files"
|
||||
@click=${() => this.querySelector('.chat-page-file-input')?.click()}
|
||||
><i class="bi bi-paperclip"></i></button>
|
||||
${this._providers.length > 1 ? html`
|
||||
<select
|
||||
class="chat-page-model-pill"
|
||||
.value=${this._selectedClient ?? 'auto'}
|
||||
@change=${(e) => { this._selectClient(e.target.value); }}
|
||||
>
|
||||
${this._providers.map(p => html`
|
||||
<option value=${p} ?selected=${p === (this._selectedClient ?? 'auto')}>${p}</option>
|
||||
`)}
|
||||
</select>
|
||||
` : nothing}
|
||||
</div>
|
||||
<div class="chat-page-toolbar-right">
|
||||
${this._hasTranscribe ? html`
|
||||
<button
|
||||
class="chat-page-mic-btn ${this._recording ? 'chat-page-mic-btn--recording' : ''}"
|
||||
title="${this._recording ? 'Stop recording' : 'Record voice'}"
|
||||
@click=${() => this._toggleRecording()}
|
||||
>
|
||||
<i class="bi ${this._recording ? 'bi-stop-circle-fill' : 'bi-mic-fill'}"></i>
|
||||
</button>
|
||||
` : nothing}
|
||||
${this._waiting
|
||||
? html`<button
|
||||
class="chat-page-send chat-page-send--stop"
|
||||
@click=${() => this._cancel()}
|
||||
title="Stop"
|
||||
><i class="bi bi-stop-fill"></i></button>`
|
||||
: nothing}
|
||||
<button
|
||||
class="chat-page-send"
|
||||
@click=${() => this._send()}
|
||||
title="Send"
|
||||
><i class="bi bi-send-fill"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('chat-page', ChatPage);
|
||||
@@ -0,0 +1,429 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* Shared file-viewer engine. Holds all of the fetch / kind-detection /
|
||||
* markdown-asset-rewriting / LaTeX-compile / live-watch logic plus `_renderBody`,
|
||||
* driven purely by two methods: `_show(path)` and `_hide()`. It carries no
|
||||
* navigation or page chrome of its own — subclasses (desktop `<file-viewer-page>`
|
||||
* and mobile `<mobile-file-viewer-page>`) wire visibility/path to those methods
|
||||
* and provide their own `render()` header.
|
||||
*/
|
||||
|
||||
const IMG_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'avif'];
|
||||
const LATEX_EXTS = ['tex', 'latex'];
|
||||
const TEXT_EXTS = [
|
||||
'txt', 'md', 'markdown', 'rs', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx',
|
||||
'py', 'json', 'yml', 'yaml', 'toml', 'sh', 'bash', 'zsh', 'fish',
|
||||
'css', 'scss', 'less',
|
||||
'sql', 'go', 'java', 'c', 'h', 'cpp', 'hpp', 'cc', 'kt', 'scala',
|
||||
'lua', 'pl', 'php', 'rb', 'swift', 'dart',
|
||||
'xml', 'csv', 'tsv', 'log', 'env', 'ini', 'cfg', 'conf',
|
||||
'gitignore', 'dockerignore', 'editorconfig',
|
||||
'vue', 'svelte', 'astro',
|
||||
// LaTeX is also kept here as the fallback when compilation fails — kindFor
|
||||
// still routes it to 'latex' so the viewer knows to attempt a compile first.
|
||||
'tex', 'latex',
|
||||
];
|
||||
|
||||
export function extOf(path) {
|
||||
if (!path) return '';
|
||||
const dot = path.lastIndexOf('.');
|
||||
if (dot < 0) return '';
|
||||
// Reject dots that are inside a directory segment, not the file extension.
|
||||
if (path.indexOf('/', dot + 1) >= 0) return '';
|
||||
return path.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
export function kindFor(path) {
|
||||
const ext = extOf(path);
|
||||
// SVG is excluded from IMG_EXTS on purpose: rendered in a sandboxed iframe
|
||||
// (not <img>), which both scales viewBox-only SVGs to fill the viewport and
|
||||
// isolates any embedded <script> from the host page.
|
||||
if (ext === 'svg') return 'svg';
|
||||
if (IMG_EXTS.includes(ext)) return 'image';
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
// HTML is rendered live in a script-enabled but origin-isolated iframe
|
||||
// (srcdoc + sandbox="allow-scripts", no allow-same-origin) — see _renderBody.
|
||||
if (ext === 'html' || ext === 'htm') return 'html';
|
||||
if (LATEX_EXTS.includes(ext)) return 'latex';
|
||||
if (TEXT_EXTS.includes(ext)) return 'text';
|
||||
return 'binary';
|
||||
}
|
||||
|
||||
/** Directory portion of a path: `docs/guide.md` → `docs`, `guide.md` → ``. */
|
||||
function dirOf(path) {
|
||||
const i = path.lastIndexOf('/');
|
||||
return i < 0 ? '' : path.slice(0, i);
|
||||
}
|
||||
|
||||
/** Lexically resolve `.`/`..` segments, preserving a leading slash for absolute paths. */
|
||||
function normalizePath(p) {
|
||||
const abs = p.startsWith('/');
|
||||
const out = [];
|
||||
for (const seg of p.split('/')) {
|
||||
if (seg === '' || seg === '.') continue;
|
||||
if (seg === '..') { out.pop(); continue; }
|
||||
out.push(seg);
|
||||
}
|
||||
return (abs ? '/' : '') + out.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an asset reference found inside a markdown file. External URLs, data
|
||||
* URIs, protocol-relative and root-relative paths are left untouched; a path
|
||||
* relative to the markdown file's directory is routed through `/api/file` so it
|
||||
* loads from disk instead of resolving against the SPA origin.
|
||||
*/
|
||||
function resolveAssetSrc(src, baseDir) {
|
||||
if (!src || /^([a-z][a-z0-9+.-]*:|\/\/|#|\/)/i.test(src)) return src;
|
||||
const joined = baseDir ? `${baseDir}/${src}` : src;
|
||||
return `/api/file?path=${encodeURIComponent(normalizePath(joined))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite relative `<img>` sources in rendered markdown HTML so they resolve
|
||||
* against the markdown file's location on disk (via `/api/file`). Parsed in an
|
||||
* inert <template> so the original (broken) URLs never trigger a fetch.
|
||||
*/
|
||||
function rewriteMarkdownAssets(htmlStr, baseDir) {
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = htmlStr;
|
||||
let changed = false;
|
||||
for (const img of tpl.content.querySelectorAll('img[src]')) {
|
||||
const src = img.getAttribute('src');
|
||||
const resolved = resolveAssetSrc(src, baseDir);
|
||||
if (resolved !== src) { img.setAttribute('src', resolved); changed = true; }
|
||||
}
|
||||
return changed ? tpl.innerHTML : htmlStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distil a raw latexmk / xelatex log into its actionable error block.
|
||||
*
|
||||
* The 422 body carries the *full* log. Under `-file-line-error` the meaningful
|
||||
* `path:line: message` errors (and `! TeX error` lines) sit deep in the log —
|
||||
* the opening lines are only the engine banner and package preamble. Slicing
|
||||
* the first N characters therefore hid the real error; instead we extract the
|
||||
* error lines plus a few trailing context lines (LaTeX echoes the offending
|
||||
* source line right after) so the user can read it — or paste it straight into
|
||||
* an agent. Falls back to the log tail when no error line is recognised.
|
||||
*/
|
||||
function formatLatexError(log) {
|
||||
if (!log) return '';
|
||||
const lines = log.split('\n');
|
||||
const blocks = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/:\d+: /.test(lines[i]) || lines[i].startsWith('! ')) {
|
||||
blocks.push(lines.slice(i, i + 4).join('\n').trimEnd());
|
||||
}
|
||||
}
|
||||
const excerpt = blocks.join('\n\n').trim();
|
||||
if (excerpt) return excerpt;
|
||||
return (log.length > 4000 ? log.slice(-4000) : log).trim();
|
||||
}
|
||||
|
||||
export class FileViewerBase extends LightElement {
|
||||
static properties = {
|
||||
_path: { state: true },
|
||||
_kind: { state: true },
|
||||
_content: { state: true },
|
||||
_blobUrl: { state: true },
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
_compileError: { state: true },
|
||||
_htmlMode: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._path = null;
|
||||
this._kind = null;
|
||||
this._content = '';
|
||||
this._blobUrl = null;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this._compileError = null;
|
||||
this._htmlMode = 'preview'; // HTML view: 'preview' (live iframe) | 'source'
|
||||
this._watchPath = null; // path currently being watched (async-verified)
|
||||
this._watchUnsub = null; // unsubscribe function returned by fileWatcher
|
||||
this._reloadTimer = null; // debounce timer for change-triggered reloads
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._teardownWatch();
|
||||
if (this._reloadTimer) clearTimeout(this._reloadTimer);
|
||||
this._revokeBlobUrl();
|
||||
}
|
||||
|
||||
// ── Drivers used by subclasses ──────────────────────────────────────────────
|
||||
|
||||
/** Show `path`: (re)subscribe the watcher and load it. No-op if unchanged. */
|
||||
_show(path) {
|
||||
if (!path) return;
|
||||
if (path === this._path && !this._error) return; // already loaded
|
||||
this._setupWatch(path);
|
||||
this._load(path);
|
||||
}
|
||||
|
||||
/** Hide: drop the content and release the watcher. */
|
||||
_hide() {
|
||||
this._reset();
|
||||
this._teardownWatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the current file. LaTeX sources always download the compiled PDF
|
||||
* (`compile-latex=true`); every kind is served with `force_download=true` so
|
||||
* the server sets `Content-Disposition: attachment` and the browser saves it
|
||||
* (with the server-supplied name) instead of rendering inline.
|
||||
*/
|
||||
_download() {
|
||||
const path = this._path;
|
||||
if (!path) return;
|
||||
const params = new URLSearchParams({ path });
|
||||
if (this._kind === 'latex') params.set('compile-latex', 'true');
|
||||
params.set('force_download', 'true');
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/file?${params.toString()}`;
|
||||
a.download = ''; // server Content-Disposition supplies the name
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
_revokeBlobUrl() {
|
||||
if (this._blobUrl) {
|
||||
URL.revokeObjectURL(this._blobUrl);
|
||||
this._blobUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
_reset() {
|
||||
this._path = null;
|
||||
this._kind = null;
|
||||
this._content = '';
|
||||
this._error = null;
|
||||
this._compileError = null;
|
||||
this._htmlMode = 'preview';
|
||||
this._revokeBlobUrl();
|
||||
}
|
||||
|
||||
async _load(path, silent = false) {
|
||||
if (!silent) {
|
||||
this._path = path;
|
||||
this._kind = kindFor(path);
|
||||
this._content = '';
|
||||
this._error = null;
|
||||
this._compileError = null;
|
||||
this._revokeBlobUrl();
|
||||
this._loading = true;
|
||||
} else {
|
||||
// Silent reload (file changed externally): keep showing the old content
|
||||
// until the new fetch lands; only update visible state on success.
|
||||
this._error = null;
|
||||
}
|
||||
try {
|
||||
const url = `/api/file?path=${encodeURIComponent(path)}`;
|
||||
if (this._kind === 'image' || this._kind === 'pdf' || this._kind === 'svg') {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
// Swap URLs only after the new blob is ready so the preview never flickers.
|
||||
const oldUrl = this._blobUrl;
|
||||
this._blobUrl = URL.createObjectURL(blob);
|
||||
if (oldUrl) URL.revokeObjectURL(oldUrl);
|
||||
} else if (this._kind === 'latex') {
|
||||
await this._loadLatex(path);
|
||||
} else if (this._kind === 'text' || this._kind === 'html') {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._content = await res.text();
|
||||
}
|
||||
// binary: nothing to fetch
|
||||
} catch (e) {
|
||||
this._error = e.message || String(e);
|
||||
} finally {
|
||||
if (!silent) this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a `.tex` / `.latex` file. Tries to compile to PDF server-side first;
|
||||
* on any non-OK response (422 compilation error, 501 no latexmk, etc.) it
|
||||
* falls back to showing the raw source as plain text, preserving the error
|
||||
* message so the user can see why the compile failed.
|
||||
*/
|
||||
async _loadLatex(path) {
|
||||
const compileUrl = `/api/file?path=${encodeURIComponent(path)}&compile-latex=true`;
|
||||
try {
|
||||
const res = await fetch(compileUrl);
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const oldUrl = this._blobUrl;
|
||||
this._blobUrl = URL.createObjectURL(blob);
|
||||
if (oldUrl) URL.revokeObjectURL(oldUrl);
|
||||
this._content = '';
|
||||
this._compileError = null;
|
||||
return;
|
||||
}
|
||||
// The 422 body is the full latexmk log. Extract the actionable error
|
||||
// block (see formatLatexError) instead of slicing from the top, which
|
||||
// under -file-line-error only shows the preamble and hides the real error.
|
||||
let detail = '';
|
||||
try { detail = formatLatexError(await res.text()); } catch { /* ignore */ }
|
||||
this._compileError = detail || `HTTP ${res.status}`;
|
||||
} catch (e) {
|
||||
this._compileError = e.message || String(e);
|
||||
}
|
||||
// Fallback: fetch the raw .tex source.
|
||||
this._revokeBlobUrl();
|
||||
const res = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._content = await res.text();
|
||||
}
|
||||
|
||||
// ── File watcher ────────────────────────────────────────────────────────────
|
||||
|
||||
_setupWatch(path) {
|
||||
this._teardownWatch();
|
||||
if (!path) return;
|
||||
this._watchPath = path;
|
||||
fileWatcher.watch(path, () => this._onFileChanged())
|
||||
.then(unsub => {
|
||||
// Race: if the path changed or the page closed while awaiting, release
|
||||
// the subscription immediately so the OS watcher is torn down.
|
||||
if (this._watchPath !== path) {
|
||||
try { unsub(); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
this._watchUnsub = unsub;
|
||||
})
|
||||
.catch(() => { /* WS error; client auto-reconnects and re-subscribes */ });
|
||||
}
|
||||
|
||||
_teardownWatch() {
|
||||
if (this._reloadTimer) {
|
||||
clearTimeout(this._reloadTimer);
|
||||
this._reloadTimer = null;
|
||||
}
|
||||
if (this._watchUnsub) {
|
||||
try { this._watchUnsub(); } catch { /* ignore */ }
|
||||
this._watchUnsub = null;
|
||||
}
|
||||
this._watchPath = null;
|
||||
}
|
||||
|
||||
_onFileChanged() {
|
||||
// Debounce: collapse bursts of FS events into a single reload. `_watchPath`
|
||||
// is cleared by `_teardownWatch` (called on hide/path-change), so a queued
|
||||
// change never reloads a file the viewer has already navigated away from.
|
||||
if (this._reloadTimer) return;
|
||||
this._reloadTimer = setTimeout(() => {
|
||||
this._reloadTimer = null;
|
||||
const path = this._watchPath;
|
||||
if (path) this._load(path, true);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ── HTML preview/source toggle ──────────────────────────────────────────────
|
||||
|
||||
_toggleHtmlMode() {
|
||||
this._htmlMode = this._htmlMode === 'preview' ? 'source' : 'preview';
|
||||
}
|
||||
|
||||
/**
|
||||
* Header button that flips an HTML file between the live preview and its raw
|
||||
* source. Returns `nothing` for every other kind, so subclasses can drop it
|
||||
* unconditionally into their header. `btnClass` carries the chrome-specific
|
||||
* button styling (desktop vs mobile use different classes).
|
||||
*/
|
||||
_renderModeToggle(btnClass) {
|
||||
if (this._kind !== 'html') return nothing;
|
||||
const showingSource = this._htmlMode === 'source';
|
||||
return html`<button
|
||||
class=${btnClass}
|
||||
title=${showingSource ? 'Show preview' : 'Show source'}
|
||||
@click=${() => this._toggleHtmlMode()}>
|
||||
<i class="bi ${showingSource ? 'bi-eye' : 'bi-code-slash'}"></i>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// ── Body rendering (shared by both chromes) ─────────────────────────────────
|
||||
|
||||
_renderBody() {
|
||||
// Spinner while loading, and also in the pre-load window: the mobile viewer
|
||||
// is prop-driven, so Lit runs render() (visible just flipped true) before
|
||||
// `updated()` kicks off `_show()` — at that point no kind/content exists yet.
|
||||
if (this._loading || (!this._kind && !this._error)) {
|
||||
return html`<div class="fv-state"><span class="spinner-border"></span></div>`;
|
||||
}
|
||||
if (this._error) {
|
||||
return html`<div class="fv-state text-danger">
|
||||
<i class="bi bi-exclamation-triangle fs-3 d-block mb-2"></i>${this._error}
|
||||
</div>`;
|
||||
}
|
||||
if (this._kind === 'image' && this._blobUrl) {
|
||||
return html`<div class="fv-image-wrap"><img src=${this._blobUrl} alt=${this._path} class="fv-image" /></div>`;
|
||||
}
|
||||
if (this._kind === 'pdf' && this._blobUrl) {
|
||||
return html`<iframe class="fv-pdf" src=${this._blobUrl} title=${this._path}></iframe>`;
|
||||
}
|
||||
if (this._kind === 'latex' && this._blobUrl) {
|
||||
// Successfully compiled server-side — render the resulting PDF the same
|
||||
// way a native .pdf would be rendered.
|
||||
return html`<iframe class="fv-pdf" src=${this._blobUrl} title=${this._path}></iframe>`;
|
||||
}
|
||||
if (this._kind === 'svg' && this._blobUrl) {
|
||||
// `allow-same-origin` (and nothing else) is required so the iframe can load
|
||||
// the blob: URL — those are only readable from their creating origin. With
|
||||
// `allow-scripts` absent, any <script> inside the SVG still cannot execute,
|
||||
// so this stays an isolated, script-free render.
|
||||
return html`<div class="fv-image-wrap">
|
||||
<iframe class="fv-svg" sandbox="allow-same-origin" src=${this._blobUrl} title=${this._path}></iframe>
|
||||
</div>`;
|
||||
}
|
||||
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.
|
||||
</div>`;
|
||||
}
|
||||
if (this._kind === 'html') {
|
||||
if (this._htmlMode === 'source') {
|
||||
return html`<pre class="fv-code"><code>${this._content}</code></pre>`;
|
||||
}
|
||||
// Live render. `srcdoc` (not a blob: src) gives the frame a unique opaque
|
||||
// origin, so `allow-scripts` can run the page's JS while it stays fully
|
||||
// isolated from the app origin — no `allow-same-origin`, so it cannot read
|
||||
// cookies/localStorage or reach `/api/*`. Never add allow-same-origin here:
|
||||
// combined with allow-scripts it lets the frame remove its own sandbox.
|
||||
return html`<iframe
|
||||
class="fv-html"
|
||||
sandbox="allow-scripts allow-forms allow-modals allow-popups"
|
||||
srcdoc=${this._content}
|
||||
title=${this._path}></iframe>`;
|
||||
}
|
||||
const ext = extOf(this._path);
|
||||
if (ext === 'md' || ext === 'markdown') {
|
||||
const rendered = rewriteMarkdownAssets(renderMarkdown(this._content), dirOf(this._path || ''));
|
||||
return html`<div class="fv-md">${unsafeHTML(rendered)}</div>`;
|
||||
}
|
||||
if (this._kind === 'latex') {
|
||||
// Compile failed — show why, then fall back to the source.
|
||||
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>
|
||||
<pre>${this._compileError}</pre>
|
||||
</details>`
|
||||
: nothing}
|
||||
<pre class="fv-code"><code>${this._content}</code></pre>
|
||||
`;
|
||||
}
|
||||
return html`<pre class="fv-code"><code>${this._content}</code></pre>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { FileViewerBase } from './file-viewer-base.js';
|
||||
|
||||
/**
|
||||
* Mobile file-viewer page. Same engine as the desktop `<file-viewer-page>`, but
|
||||
* prop-driven: `<mobile-app>` binds `visible` / `path` from its hash router
|
||||
* (`#file_viewer?path=...`) instead of the component listening to the hash. The
|
||||
* back button returns to the previous mobile section via history.
|
||||
*/
|
||||
export class MobileFileViewerPage extends FileViewerBase {
|
||||
static properties = {
|
||||
visible: { type: Boolean },
|
||||
path: { type: String },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.visible = false;
|
||||
this.path = null;
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
if (changed.has('visible') || changed.has('path')) {
|
||||
if (this.visible && this.path) this._show(this.path);
|
||||
else if (!this.visible) this._hide();
|
||||
}
|
||||
}
|
||||
|
||||
_back() {
|
||||
history.back();
|
||||
}
|
||||
|
||||
// Filename portion of the path, for the compact mobile header title.
|
||||
_basename() {
|
||||
const p = this.path || '';
|
||||
const i = p.lastIndexOf('/');
|
||||
return i < 0 ? p : p.slice(i + 1);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.visible) return nothing;
|
||||
return html`
|
||||
<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()}>
|
||||
<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()}>
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="fv-body">${this._renderBody()}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('mobile-file-viewer-page', MobileFileViewerPage);
|
||||
@@ -0,0 +1,271 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
|
||||
export class InboxPage extends LightElement {
|
||||
static properties = {
|
||||
visible: { type: Boolean },
|
||||
_data: { state: true },
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.visible = false;
|
||||
this._data = null;
|
||||
this._error = null;
|
||||
this._pollTimer = null;
|
||||
this._expanded = new Set();
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
if (!changed.has('visible')) return;
|
||||
if (this.visible) {
|
||||
this._load();
|
||||
this._startPolling();
|
||||
} else {
|
||||
this._stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._stopPolling();
|
||||
}
|
||||
|
||||
_startPolling() {
|
||||
this._stopPolling();
|
||||
this._pollTimer = setInterval(() => this._load(), 8000);
|
||||
}
|
||||
|
||||
_stopPolling() {
|
||||
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const res = await fetch('/api/inbox');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._data = await res.json();
|
||||
this._error = null;
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _resolveApproval(requestId, action, note = '', toolCallId = null) {
|
||||
try {
|
||||
// Live items resolve by request_id; DB-persisted (post-restart) items carry
|
||||
// request_id 0 → resolve by the durable, source-agnostic tool_call_id.
|
||||
const url = requestId
|
||||
? `/api/inbox/approvals/${requestId}/resolve`
|
||||
: `/api/tools/${toolCallId}/resolve`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, note }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
_rejectWithNote(requestId, toolCallId) {
|
||||
const note = prompt('Rejection reason (optional):') ?? '';
|
||||
this._resolveApproval(requestId, 'reject', note, toolCallId);
|
||||
}
|
||||
|
||||
async _resolveClarification(requestId, inputEl) {
|
||||
const answer = inputEl.value.trim();
|
||||
if (!answer) return;
|
||||
try {
|
||||
const res = await fetch(`/api/inbox/clarifications/${requestId}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answer }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
_toggleRaw(id) {
|
||||
if (this._expanded.has(id)) this._expanded.delete(id);
|
||||
else this._expanded.add(id);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_keyArgs(args) {
|
||||
const entries = [];
|
||||
for (const key of ['path', 'command', 'url', 'origin', 'destination', 'name', 'message', 'query']) {
|
||||
if (args[key] !== undefined) {
|
||||
let val = args[key];
|
||||
if (typeof val === 'object') val = JSON.stringify(val);
|
||||
entries.push({ key, value: String(val) });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
_fmt(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
_renderApprovalCard(item) {
|
||||
const id = `raw-${item.request_id}`;
|
||||
const open = this._expanded.has(id);
|
||||
const label = item.context_label ?? item.source;
|
||||
const args = item.arguments ?? {};
|
||||
const keyArgs = this._keyArgs(args);
|
||||
|
||||
return html`
|
||||
<div class="inbox-card approval-card">
|
||||
<div class="inbox-card-header">
|
||||
<span class="badge bg-warning text-dark">Approval</span>
|
||||
<span class="inbox-card-origin" title="${label}">${label}</span>
|
||||
<span class="inbox-card-time">${this._fmt(item.created_at)}</span>
|
||||
</div>
|
||||
|
||||
<div class="inbox-card-body">
|
||||
<div class="inbox-tool-name">
|
||||
<i class="bi bi-tools"></i>
|
||||
<strong>${item.tool_name}</strong>
|
||||
<span class="inbox-agent-tag">
|
||||
<i class="bi bi-person"></i> ${item.agent_id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
${keyArgs.length > 0 ? html`
|
||||
<div class="inbox-args-structured">
|
||||
${keyArgs.map(kv => html`
|
||||
<div class="inbox-arg-row">
|
||||
<span class="inbox-arg-key">${kv.key}</span>
|
||||
<span class="inbox-arg-value">${kv.value}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<button class="inbox-args-toggle" @click=${() => this._toggleRaw(id)}>
|
||||
<i class="bi ${open ? 'bi-chevron-up' : 'bi-chevron-down'}"></i>
|
||||
${open ? 'Hide raw JSON' : 'Show raw JSON'}
|
||||
</button>
|
||||
<pre class="inbox-args-raw ${open ? 'open' : ''}">${JSON.stringify(args, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="inbox-card-footer">
|
||||
<button class="inbox-btn inbox-btn-approve"
|
||||
@click=${() => this._resolveApproval(item.request_id, 'approve', '', item.tool_call_id)}>
|
||||
<i class="bi bi-check-lg"></i> Approve
|
||||
</button>
|
||||
<button class="inbox-btn inbox-btn-reject"
|
||||
@click=${() => this._rejectWithNote(item.request_id, item.tool_call_id)}>
|
||||
<i class="bi bi-x-lg"></i> Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderClarificationCard(item) {
|
||||
const label = item.context_label ?? item.source;
|
||||
|
||||
return html`
|
||||
<div class="inbox-card clarification-card">
|
||||
<div class="inbox-card-header">
|
||||
<span class="badge bg-info text-dark">Question</span>
|
||||
<span class="inbox-card-origin" title="${label}">${label}</span>
|
||||
<span class="inbox-card-time">${this._fmt(item.created_at)}</span>
|
||||
</div>
|
||||
|
||||
<div class="inbox-card-body">
|
||||
<div class="inbox-card-title">${item.title}</div>
|
||||
<div class="inbox-question">${item.question}</div>
|
||||
|
||||
${item.suggested_answers?.length ? html`
|
||||
<div class="inbox-chips">
|
||||
${item.suggested_answers.map(a => html`
|
||||
<button class="inbox-chip"
|
||||
@click=${(e) => {
|
||||
const inp = e.target.closest('.inbox-card')?.querySelector('.inbox-answer-input');
|
||||
if (inp) { inp.value = a; inp.focus(); }
|
||||
}}>${a}</button>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<div class="inbox-answer-area">
|
||||
<textarea class="inbox-answer-input" rows="2" placeholder="Your answer…"
|
||||
@keydown=${(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this._resolveClarification(item.request_id, e.target);
|
||||
}
|
||||
}}></textarea>
|
||||
<button class="inbox-answer-send"
|
||||
@click=${(e) => {
|
||||
const inp = e.target.closest('.inbox-card')?.querySelector('.inbox-answer-input');
|
||||
if (inp) this._resolveClarification(item.request_id, inp);
|
||||
}}>
|
||||
<i class="bi bi-send"></i> Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.visible) return nothing;
|
||||
|
||||
const approvals = this._data?.approvals ?? [];
|
||||
const clarifications = this._data?.clarifications ?? [];
|
||||
const total = approvals.length + clarifications.length;
|
||||
|
||||
return html`
|
||||
<div class="mobile-inbox">
|
||||
<div class="mobile-section-header">
|
||||
<span class="mobile-section-title">
|
||||
Inbox
|
||||
${total > 0 ? html`<span class="badge bg-danger ms-2">${total}</span>` : nothing}
|
||||
</span>
|
||||
<button class="inbox-refresh-btn" @click=${() => this._load()}>
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="mobile-alert-error">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${total === 0 ? html`
|
||||
<div class="inbox-empty">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<p>No pending requests</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="mobile-inbox-list">
|
||||
${approvals.length > 0 ? html`
|
||||
<div class="inbox-section-label">
|
||||
Approvals <span class="badge bg-warning text-dark">${approvals.length}</span>
|
||||
</div>
|
||||
${approvals.map(item => this._renderApprovalCard(item))}
|
||||
` : nothing}
|
||||
|
||||
${clarifications.length > 0 ? html`
|
||||
<div class="inbox-section-label">
|
||||
Questions <span class="badge bg-info text-dark">${clarifications.length}</span>
|
||||
</div>
|
||||
${clarifications.map(item => this._renderClarificationCard(item))}
|
||||
` : nothing}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('inbox-page', InboxPage);
|
||||
@@ -0,0 +1,108 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
|
||||
/**
|
||||
* Mobile project list. Lists projects from `GET /api/projects`; tapping one opens
|
||||
* (or resumes) its coordinator session via `POST /api/projects/{id}/session` and
|
||||
* emits a `project-open` event so the shell can re-point the chat to that source.
|
||||
*
|
||||
* The session machinery is shared with the desktop copilot: the same `project-{id}`
|
||||
* source, the same provisioning (`project-coordinator` + project RunContext), so a
|
||||
* project chat is continuous across desktop, mobile browser, and the native shell.
|
||||
*/
|
||||
export class ProjectsPage extends LightElement {
|
||||
static properties = {
|
||||
visible: { type: Boolean },
|
||||
_data: { state: true },
|
||||
_error: { state: true },
|
||||
_loading: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.visible = false;
|
||||
this._data = null;
|
||||
this._error = null;
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
if (changed.has('visible') && this.visible) this._load();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._loading = true;
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._data = await res.json();
|
||||
this._error = null;
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _open(project) {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${project.id}/session`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { source } = await res.json();
|
||||
this.dispatchEvent(new CustomEvent('project-open', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { source, label: project.name },
|
||||
}));
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.visible) return nothing;
|
||||
const projects = this._data ?? [];
|
||||
|
||||
return html`
|
||||
<div class="mobile-projects">
|
||||
<div class="mobile-section-header">
|
||||
<span class="mobile-section-title">
|
||||
<i class="bi bi-folder2-open"></i> Projects
|
||||
</span>
|
||||
<button class="inbox-refresh-btn" @click=${() => this._load()}>
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="mobile-alert-error">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${projects.length === 0 ? html`
|
||||
<div class="inbox-empty">
|
||||
<i class="bi bi-folder"></i>
|
||||
<p>${this._loading ? 'Loading…' : 'No projects yet'}</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="mobile-projects-list">
|
||||
${projects.map(p => html`
|
||||
<div class="project-card" @click=${() => this._open(p)}>
|
||||
<div class="project-card-icon">
|
||||
<i class="bi bi-folder2-open"></i>
|
||||
</div>
|
||||
<div class="project-card-main">
|
||||
<div class="project-card-name">${p.name}</div>
|
||||
${p.description ? html`
|
||||
<div class="project-card-desc">${p.description}</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
<i class="bi bi-chevron-right project-card-chevron"></i>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('projects-page', ProjectsPage);
|
||||
@@ -0,0 +1,304 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
|
||||
export class AppSidebar extends LightElement {
|
||||
static properties = {
|
||||
_activePage: { state: true },
|
||||
_tasksSection: { state: true },
|
||||
_inboxCount: { state: true },
|
||||
_debugMode: { state: true },
|
||||
_recentProjects: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._activePage = null;
|
||||
this._tasksSection = 'running';
|
||||
this._inboxCount = 0;
|
||||
this._pollTimer = null;
|
||||
this._debugMode = false;
|
||||
this._recentProjects = [];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('popstate', (e) => {
|
||||
const page = e.state?.page ?? this._pageFromHash();
|
||||
if (page === 'tasks') this._tasksSection = this._tasksSectionFromHash();
|
||||
this._applyPage(page);
|
||||
});
|
||||
window.addEventListener('hashchange', () => {
|
||||
const page = this._pageFromHash();
|
||||
if (page === 'tasks') this._tasksSection = this._tasksSectionFromHash();
|
||||
this._applyPage(page);
|
||||
});
|
||||
window.addEventListener('inbox-count', (e) => {
|
||||
this._inboxCount = e.detail.count;
|
||||
});
|
||||
window.addEventListener('debug-mode-change', (e) => {
|
||||
this._debugMode = e.detail.enabled;
|
||||
});
|
||||
// On load: home (root) if no hash, otherwise the matching page
|
||||
setTimeout(() => {
|
||||
const page = this._pageFromHash();
|
||||
if (page === 'tasks') this._tasksSection = this._tasksSectionFromHash();
|
||||
this._applyPage(page);
|
||||
}, 0);
|
||||
// Poll inbox count independently of whether the page is open.
|
||||
this._pollInbox();
|
||||
this._pollTimer = setInterval(() => this._pollInbox(), 10000);
|
||||
this._loadDebugMode();
|
||||
this._loadRecentProjects();
|
||||
window.addEventListener('project-updated', () => this._loadRecentProjects());
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
clearInterval(this._pollTimer);
|
||||
}
|
||||
|
||||
async _loadDebugMode() {
|
||||
try {
|
||||
const res = await fetch('/api/dev/debug_mode');
|
||||
if (res.ok) this._debugMode = (await res.json()).enabled;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async _loadRecentProjects() {
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
if (!res.ok) return;
|
||||
const projects = await res.json();
|
||||
this._recentProjects = projects
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at))
|
||||
.slice(0, 5);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async _openProjectChat(projectId, projectName, e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/session`, { method: 'POST' });
|
||||
if (!res.ok) return;
|
||||
const { source } = await res.json();
|
||||
window.dispatchEvent(new CustomEvent('project-chat-open', {
|
||||
detail: { source, label: projectName },
|
||||
}));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async _pollInbox() {
|
||||
try {
|
||||
const res = await fetch('/api/inbox');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this._inboxCount = data.total ?? 0;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
_pageFromHash() {
|
||||
const hash = location.hash.slice(1);
|
||||
if (!hash) return 'home';
|
||||
// Segment ends at the first `/` (e.g. `#session/123`) or `?` (e.g. `#file_viewer?path=...`).
|
||||
const match = hash.match(/^([^/?]+)/);
|
||||
const segment = match ? match[1] : '';
|
||||
return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home';
|
||||
}
|
||||
|
||||
_tasksSectionFromHash() {
|
||||
const parts = location.hash.slice(1).split('/');
|
||||
if (parts[0] === 'tasks' && parts[1]) {
|
||||
return ['running', 'cron', 'scheduled', 'history'].includes(parts[1]) ? parts[1] : 'running';
|
||||
}
|
||||
return 'running';
|
||||
}
|
||||
|
||||
_applyPage(page) {
|
||||
this._activePage = page;
|
||||
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } }));
|
||||
}
|
||||
|
||||
_togglePage(page, e) {
|
||||
e.preventDefault();
|
||||
if (page === 'home') {
|
||||
history.pushState({ page: 'home' }, '', location.pathname + location.search);
|
||||
this._applyPage('home');
|
||||
return;
|
||||
}
|
||||
if (this._activePage === page) {
|
||||
// In a sub-section (e.g. #models/image) → go back to page root
|
||||
if (location.hash.slice(1) !== page) {
|
||||
history.pushState({ page }, '', '#' + page);
|
||||
this._applyPage(page);
|
||||
}
|
||||
return;
|
||||
}
|
||||
history.pushState({ page }, '', '#' + page);
|
||||
this._applyPage(page);
|
||||
}
|
||||
|
||||
_navigateTasksSection(sec, e) {
|
||||
e.preventDefault();
|
||||
this._tasksSection = sec;
|
||||
history.pushState({ page: 'tasks', section: sec }, '', '#tasks/' + sec);
|
||||
if (this._activePage !== 'tasks') {
|
||||
this._applyPage('tasks');
|
||||
} else {
|
||||
// page already open — tell the TasksPage to switch section
|
||||
window.dispatchEvent(new CustomEvent('tasks-section-change', { detail: { section: sec } }));
|
||||
}
|
||||
}
|
||||
|
||||
_openTaskManager(e) {
|
||||
e.preventDefault();
|
||||
if (this._activePage === 'tasks') return; // already open, submenu visible
|
||||
const sec = this._tasksSection || 'cron';
|
||||
history.pushState({ page: 'tasks', section: sec }, '', '#tasks/' + sec);
|
||||
this._applyPage('tasks');
|
||||
}
|
||||
|
||||
_renderTasksMenu() {
|
||||
const active = this._activePage === 'tasks';
|
||||
const sec = this._tasksSection;
|
||||
return html`
|
||||
<a href="#tasks/cron"
|
||||
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>
|
||||
<i class="bi bi-chevron-${active ? 'up' : 'down'} sidebar-link-chevron"></i>
|
||||
</a>
|
||||
${active ? html`
|
||||
<div class="sidebar-submenu">
|
||||
<a href="#tasks/running"
|
||||
class="sidebar-sublink ${sec === 'running' ? 'active' : ''}"
|
||||
@click=${(e) => this._navigateTasksSection('running', e)}>
|
||||
<i class="bi bi-activity"></i> Running Tasks
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</a>
|
||||
</div>
|
||||
` : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
_renderRecentProjects() {
|
||||
if (!this._recentProjects.length) return nothing;
|
||||
return html`
|
||||
<div class="sidebar-submenu">
|
||||
${this._recentProjects.map(p => html`
|
||||
<div class="sidebar-project-link"
|
||||
@click=${(e) => { e.preventDefault(); history.pushState({ page: 'projects' }, '', '#projects'); this._applyPage('projects'); window.dispatchEvent(new CustomEvent('sidebar-open-project', { detail: { id: p.id } })); }}>
|
||||
<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"
|
||||
@click=${(e) => this._openProjectChat(p.id, p.name, e)}>
|
||||
<i class="bi bi-chat-dots"></i>
|
||||
</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="sidebar-brand">
|
||||
<img src="/assets/icons/icon-1024.png" alt="" class="sidebar-brand-icon" />
|
||||
<span>Skald</span>
|
||||
</div>
|
||||
|
||||
<hr class="sidebar-divider" />
|
||||
|
||||
<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>
|
||||
</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
|
||||
${this._inboxCount > 0
|
||||
? html`<span class="badge bg-danger ms-1" style="font-size:0.65rem">${this._inboxCount}</span>`
|
||||
: ''}
|
||||
</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>
|
||||
</a>
|
||||
${this._renderRecentProjects()}
|
||||
|
||||
${this._renderTasksMenu()}
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</a>
|
||||
<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>
|
||||
</a>
|
||||
|
||||
${this._debugMode ? html`
|
||||
<hr class="sidebar-divider" />
|
||||
<a href="#llm-requests"
|
||||
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>
|
||||
</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>
|
||||
</a>
|
||||
` : nothing}
|
||||
</nav>
|
||||
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { toString as cronToString } from 'cronstrue';
|
||||
import { formatDate } from './utils.js';
|
||||
|
||||
export class CronJobsSection extends LightElement {
|
||||
static properties = {
|
||||
_jobs: { state: true },
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._jobs = [];
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
async load() {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch('/api/cron/jobs');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const allJobs = await res.json();
|
||||
this._jobs = allJobs.filter(j => j.kind === 'cron' && !j.single_run);
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _delete(job) {
|
||||
if (!confirm(`Delete job "${job.title}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/cron/jobs/${job.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this.load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
async _toggle(job) {
|
||||
try {
|
||||
const res = await fetch(`/api/cron/jobs/${job.id}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: !job.enabled }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this.load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
_statusBadge(job) {
|
||||
if (job.running_session_id != null)
|
||||
return html`<span class="task-badge task-badge--running">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>`;
|
||||
}
|
||||
|
||||
_renderCard(job) {
|
||||
return html`
|
||||
<div class="task-card ${job.enabled ? '' : 'task-card--disabled'}">
|
||||
<div class="task-card-header">
|
||||
<div class="task-card-title-row">
|
||||
<span class="task-card-title">${job.title}</span>
|
||||
${this._statusBadge(job)}
|
||||
</div>
|
||||
<button class="task-card-delete" title="Delete" @click=${() => this._delete(job)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${job.description ? html`<div class="task-card-desc">${job.description}</div>` : nothing}
|
||||
|
||||
<div class="task-card-expr">
|
||||
<i class="bi bi-clock"></i>
|
||||
<div class="task-card-expr-text">
|
||||
<span class="task-card-human">${cronToString(job.cron)}</span>
|
||||
<code class="task-card-raw">${job.cron}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-card-meta">
|
||||
<div class="task-card-meta-item">
|
||||
<span class="task-card-meta-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-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-value">${formatDate(job.next_run_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-card-footer">
|
||||
<div class="form-check form-switch mb-0 task-card-toggle">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
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>
|
||||
<div style="font-size:0.82rem;color:var(--bs-secondary-color)">
|
||||
${this._jobs.length} job${this._jobs.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${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>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="task-grid">
|
||||
${this._jobs.map(j => this._renderCard(j))}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { formatDate, formatDuration } from './utils.js';
|
||||
|
||||
export class TaskHistorySection extends LightElement {
|
||||
static properties = {
|
||||
_runs: { state: true },
|
||||
_error: { state: true },
|
||||
_loading: { state: true },
|
||||
_expanded: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._runs = [];
|
||||
this._error = null;
|
||||
this._loading = false;
|
||||
this._expanded = null;
|
||||
}
|
||||
|
||||
async load() {
|
||||
this._error = null;
|
||||
this._loading = true;
|
||||
try {
|
||||
const res = await fetch('/api/cron/runs');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._runs = await res.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_statusClass(status) {
|
||||
return { completed: 'success', failed: 'danger', cancelled: 'warning' }[status] ?? 'secondary';
|
||||
}
|
||||
|
||||
_toggleExpand(id) {
|
||||
this._expanded = this._expanded === id ? null : id;
|
||||
}
|
||||
|
||||
_renderRow(run) {
|
||||
const isExpanded = this._expanded === run.id;
|
||||
return html`
|
||||
<tr class="task-history-row ${isExpanded ? 'task-history-row--expanded' : ''}"
|
||||
@click=${() => this._toggleExpand(run.id)}>
|
||||
<td>
|
||||
<span class="badge bg-${this._statusClass(run.status)}">${run.status}</span>
|
||||
</td>
|
||||
<td class="task-history-title">${run.job_title ?? `Job #${run.job_id}`}</td>
|
||||
<td>${run.agent_id ?? '—'}</td>
|
||||
<td>${formatDate(run.completed_at)}</td>
|
||||
<td>${formatDuration(run.duration_ms)}</td>
|
||||
<td><i class="bi bi-chevron-${isExpanded ? 'up' : 'down'} task-history-chevron"></i></td>
|
||||
</tr>
|
||||
${isExpanded ? html`
|
||||
<tr class="task-history-detail">
|
||||
<td colspan="6">
|
||||
${run.session_id != null ? html`
|
||||
<div style="margin-bottom:0.5rem">
|
||||
<a href="#session/${run.session_id}"
|
||||
style="font-size:0.8rem;font-family:var(--bs-font-monospace)">
|
||||
<i class="bi bi-chat-text me-1"></i>View session #${run.session_id}
|
||||
</a>
|
||||
</div>
|
||||
` : nothing}
|
||||
${run.error ? html`
|
||||
<div class="task-history-error"><strong>Error:</strong> ${run.error}</div>
|
||||
` : nothing}
|
||||
${run.final_response ? html`
|
||||
<div class="task-history-response"><pre>${run.final_response}</pre></div>
|
||||
` : nothing}
|
||||
${!run.error && !run.final_response ? html`
|
||||
<div class="text-muted" style="font-size:0.82rem">No output recorded.</div>
|
||||
` : nothing}
|
||||
</td>
|
||||
</tr>
|
||||
` : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="task-page">
|
||||
<div class="task-page-header">
|
||||
<h2 class="task-page-title"><i class="bi bi-journal-text"></i> History</h2>
|
||||
<div style="font-size:0.82rem;color:var(--bs-secondary-color)">
|
||||
${this._runs.length} run${this._runs.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-2" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${this._loading ? html`
|
||||
<div class="task-empty"><i class="bi bi-hourglass-split"></i><p>Loading…</p></div>
|
||||
` : this._runs.length === 0 ? html`
|
||||
<div class="task-empty">
|
||||
<i class="bi bi-journal-text"></i>
|
||||
<p>No completed runs yet.</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="task-history-table-wrap">
|
||||
<table class="task-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Task</th>
|
||||
<th>Agent</th>
|
||||
<th>Completed</th>
|
||||
<th>Duration</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this._runs.map(r => this._renderRow(r))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { RunningTasksSection } from './running.js';
|
||||
import { CronJobsSection } from './cron.js';
|
||||
import { ScheduledTasksSection } from './scheduled.js';
|
||||
import { TaskHistorySection } from './history.js';
|
||||
|
||||
const SECTIONS = ['running', 'cron', 'scheduled', 'history'];
|
||||
|
||||
export class TasksPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_section: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._section = 'running';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
const open = e.detail.page === 'tasks';
|
||||
this._open = open;
|
||||
this.style.display = open ? 'flex' : 'none';
|
||||
if (open) {
|
||||
const sec = this._sectionFromHash();
|
||||
this._section = sec;
|
||||
this._loadSection(sec);
|
||||
if (!location.hash.includes('/')) {
|
||||
history.replaceState({ page: 'tasks', section: sec }, '', '#tasks/' + sec);
|
||||
}
|
||||
}
|
||||
});
|
||||
window.addEventListener('tasks-section-change', (e) => {
|
||||
if (!this._open) return;
|
||||
const sec = e.detail.section;
|
||||
this._section = sec;
|
||||
this._loadSection(sec);
|
||||
});
|
||||
}
|
||||
|
||||
_sectionFromHash() {
|
||||
const parts = location.hash.slice(1).split('/');
|
||||
if (parts[0] === 'tasks' && parts[1]) {
|
||||
return SECTIONS.includes(parts[1]) ? parts[1] : 'running';
|
||||
}
|
||||
return 'running';
|
||||
}
|
||||
|
||||
_loadSection(sec) {
|
||||
this.updateComplete.then(() => {
|
||||
const el = this.querySelector(`[data-section="${sec}"]`);
|
||||
if (el?.load) el.load();
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
return html`
|
||||
${this._section === 'running'
|
||||
? html`<task-running-section data-section="running"></task-running-section>`
|
||||
: nothing}
|
||||
${this._section === 'cron'
|
||||
? html`<task-cron-jobs-section data-section="cron"></task-cron-jobs-section>`
|
||||
: nothing}
|
||||
${this._section === 'scheduled'
|
||||
? html`<task-scheduled-section data-section="scheduled"></task-scheduled-section>`
|
||||
: nothing}
|
||||
${this._section === 'history'
|
||||
? html`<task-history-section data-section="history"></task-history-section>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('task-running-section', RunningTasksSection);
|
||||
customElements.define('task-cron-jobs-section', CronJobsSection);
|
||||
customElements.define('task-scheduled-section', ScheduledTasksSection);
|
||||
customElements.define('task-history-section', TaskHistorySection);
|
||||
@@ -0,0 +1,151 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { formatDate, formatElapsed } from './utils.js';
|
||||
|
||||
export class RunningTasksSection extends LightElement {
|
||||
static properties = {
|
||||
_jobs: { state: true },
|
||||
_error: { state: true },
|
||||
_tick: { state: true },
|
||||
_killing: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._jobs = [];
|
||||
this._error = null;
|
||||
this._tick = 0;
|
||||
this._killing = new Set();
|
||||
this._timer = null;
|
||||
this._pollTimer = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._timer = setInterval(() => { this._tick++; }, 1000);
|
||||
this._pollTimer = setInterval(() => this.load(), 10000);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
clearInterval(this._timer);
|
||||
clearInterval(this._pollTimer);
|
||||
}
|
||||
|
||||
async load() {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch('/api/cron/jobs');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const all = await res.json();
|
||||
this._jobs = all.filter(j => j.running_session_id != null);
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _kill(job) {
|
||||
const confirmed = window.confirm(
|
||||
`Terminate task "${job.title}"?\n\nThe task will be stopped immediately and recorded as failed.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
this._killing = new Set([...this._killing, job.id]);
|
||||
try {
|
||||
const res = await fetch(`/api/cron/jobs/${job.id}/kill`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
this._error = `Failed to terminate task: ${e.message}`;
|
||||
} finally {
|
||||
const next = new Set(this._killing);
|
||||
next.delete(job.id);
|
||||
this._killing = next;
|
||||
}
|
||||
}
|
||||
|
||||
_kindLabel(job) {
|
||||
if (job.kind === 'cron' && !job.single_run)
|
||||
return html`<span class="task-badge task-badge--cron">cron</span>`;
|
||||
if (job.kind === 'cron' && job.single_run)
|
||||
return html`<span class="task-badge task-badge--oneshot">one-shot</span>`;
|
||||
return html`<span class="task-badge task-badge--async">async</span>`;
|
||||
}
|
||||
|
||||
_renderCard(job) {
|
||||
const killing = this._killing.has(job.id);
|
||||
return html`
|
||||
<div class="task-card task-card--running">
|
||||
<div class="task-card-header">
|
||||
<div class="task-card-title-row">
|
||||
<span class="task-card-title">${job.title}</span>
|
||||
${this._kindLabel(job)}
|
||||
<span class="task-badge task-badge--running">running</span>
|
||||
</div>
|
||||
<button
|
||||
class="task-kill-btn"
|
||||
title="Terminate task"
|
||||
?disabled=${killing}
|
||||
@click=${() => this._kill(job)}
|
||||
>
|
||||
${killing
|
||||
? html`<i class="bi bi-hourglass-split"></i>`
|
||||
: html`<i class="bi bi-x-lg"></i>`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${job.description ? html`<div class="task-card-desc">${job.description}</div>` : nothing}
|
||||
|
||||
<div class="task-running-elapsed">
|
||||
<i class="bi bi-stopwatch"></i>
|
||||
<span>${formatElapsed(job.running_since)}</span>
|
||||
<span class="task-running-since">since ${formatDate(job.running_since)}</span>
|
||||
</div>
|
||||
|
||||
<div class="task-card-meta">
|
||||
<div class="task-card-meta-item">
|
||||
<span class="task-card-meta-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">Session</span>
|
||||
<a href="#session/${job.running_session_id}"
|
||||
class="task-card-meta-value task-card-session-link">#${job.running_session_id}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
void this._tick; // drives re-render every second for live elapsed time
|
||||
return html`
|
||||
<div class="task-page">
|
||||
<div class="task-page-header">
|
||||
<h2 class="task-page-title"><i class="bi bi-activity"></i> Running Tasks</h2>
|
||||
<div style="font-size:0.82rem;color:var(--bs-secondary-color)">
|
||||
${this._jobs.length} running
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${this._jobs.length === 0 ? html`
|
||||
<div class="task-empty">
|
||||
<i class="bi bi-activity"></i>
|
||||
<p>No tasks currently running.</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="task-grid">
|
||||
${this._jobs.map(j => this._renderCard(j))}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { toString as cronToString } from 'cronstrue';
|
||||
import { formatDate } from './utils.js';
|
||||
|
||||
export class ScheduledTasksSection extends LightElement {
|
||||
static properties = {
|
||||
_jobs: { state: true },
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._jobs = [];
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
async load() {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch('/api/cron/jobs');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const all = await res.json();
|
||||
// one-shot scheduled or async/immediate, still active (pending or running)
|
||||
this._jobs = all.filter(j =>
|
||||
(j.kind !== 'cron' || j.single_run) &&
|
||||
(j.enabled || j.running_session_id != null)
|
||||
);
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _delete(job) {
|
||||
if (!confirm(`Delete task "${job.title}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/cron/jobs/${job.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this.load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
_statusBadge(job) {
|
||||
if (job.running_session_id != null)
|
||||
return html`<span class="task-badge task-badge--running">running</span>`;
|
||||
if (job.kind === 'cron' && job.single_run)
|
||||
return html`<span class="task-badge task-badge--pending">pending</span>`;
|
||||
return html`<span class="task-badge task-badge--pending">queued</span>`;
|
||||
}
|
||||
|
||||
_kindLabel(job) {
|
||||
if (job.kind === 'cron' && job.single_run)
|
||||
return html`<span class="task-badge task-badge--oneshot">one-shot</span>`;
|
||||
if (job.kind === 'async')
|
||||
return html`<span class="task-badge task-badge--async">async</span>`;
|
||||
return nothing;
|
||||
}
|
||||
|
||||
_renderCard(job) {
|
||||
return html`
|
||||
<div class="task-card">
|
||||
<div class="task-card-header">
|
||||
<div class="task-card-title-row">
|
||||
<span class="task-card-title">${job.title}</span>
|
||||
${this._kindLabel(job)}
|
||||
${this._statusBadge(job)}
|
||||
</div>
|
||||
<button class="task-card-delete" title="Delete" @click=${() => this._delete(job)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${job.description ? html`<div class="task-card-desc">${job.description}</div>` : nothing}
|
||||
|
||||
${job.kind === 'cron' && job.cron ? html`
|
||||
<div class="task-card-expr">
|
||||
<i class="bi bi-clock"></i>
|
||||
<div class="task-card-expr-text">
|
||||
<span class="task-card-human">${cronToString(job.cron)}</span>
|
||||
<code class="task-card-raw">${job.cron}</code>
|
||||
</div>
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<div class="task-card-meta">
|
||||
<div class="task-card-meta-item">
|
||||
<span class="task-card-meta-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">${job.next_run_at ? 'Scheduled at' : 'Created'}</span>
|
||||
<span class="task-card-meta-value">${formatDate(job.next_run_at || job.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="task-page">
|
||||
<div class="task-page-header">
|
||||
<h2 class="task-page-title"><i class="bi bi-clock"></i> Scheduled Tasks</h2>
|
||||
<div style="font-size:0.82rem;color:var(--bs-secondary-color)">
|
||||
${this._jobs.length} task${this._jobs.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${this._jobs.length === 0 ? html`
|
||||
<div class="task-empty">
|
||||
<i class="bi bi-clock"></i>
|
||||
<p>No pending or running tasks.</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="task-grid">
|
||||
${this._jobs.map(j => this._renderCard(j))}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDuration(ms) {
|
||||
if (ms == null) return '—';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const m = Math.floor(ms / 60000);
|
||||
const s = Math.round((ms % 60000) / 1000);
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
|
||||
// SQLite stores UTC without timezone suffix — normalise before parsing.
|
||||
export function parseUtc(iso) {
|
||||
if (!iso) return null;
|
||||
return new Date(iso.replace(' ', 'T') + 'Z');
|
||||
}
|
||||
|
||||
export function formatElapsed(since) {
|
||||
const d = parseUtc(since);
|
||||
if (!d) return '—';
|
||||
const s = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ${s % 60}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
const PAGE_ID = 'tic';
|
||||
const PER_PAGE = 20;
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateShort(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export class TicSessionsPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_items: { state: true },
|
||||
_total: { state: true },
|
||||
_page: { state: true },
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._items = [];
|
||||
this._total = 0;
|
||||
this._page = 1;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === PAGE_ID;
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open && this._items.length === 0) this._fetch(1);
|
||||
});
|
||||
}
|
||||
|
||||
async _fetch(page) {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const params = new URLSearchParams({ source: 'tic', page, per_page: PER_PAGE });
|
||||
const res = await fetch(`/api/sessions?${params}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
this._items = data.items;
|
||||
this._total = data.total;
|
||||
this._page = data.page;
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_openSession(id) {
|
||||
window.location.hash = `session/${id}`;
|
||||
}
|
||||
|
||||
get _totalPages() { return Math.max(1, Math.ceil(this._total / PER_PAGE)); }
|
||||
|
||||
_renderTable() {
|
||||
if (this._loading) return html`
|
||||
<div class="tic-state">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||||
<span>Loading…</span>
|
||||
</div>
|
||||
`;
|
||||
if (this._error) return html`
|
||||
<div class="tic-state tic-state--error">
|
||||
<i class="bi bi-exclamation-circle"></i>
|
||||
<span>${this._error}</span>
|
||||
</div>
|
||||
`;
|
||||
if (this._items.length === 0) return html`
|
||||
<div class="tic-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<span>No TIC sessions found.</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="tic-table-wrap">
|
||||
<table class="table table-sm tic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Agent</th>
|
||||
<th>Started</th>
|
||||
<th class="text-end">Messages</th>
|
||||
<th>Last activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this._items.map(r => html`
|
||||
<tr class="tic-row--clickable" @click=${() => this._openSession(r.id)}>
|
||||
<td class="tic-id">${r.id}</td>
|
||||
<td><span class="tic-agent">${r.agent_id ?? '—'}</span></td>
|
||||
<td class="tic-date">${formatDateShort(r.created_at)}</td>
|
||||
<td class="text-end tic-num">${r.message_count}</td>
|
||||
<td class="tic-date">${formatDate(r.last_message_at)}</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderPagination() {
|
||||
if (this._totalPages <= 1) return nothing;
|
||||
const pages = this._totalPages;
|
||||
const cur = this._page;
|
||||
return html`
|
||||
<div class="tic-pagination">
|
||||
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur <= 1}
|
||||
@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>
|
||||
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur >= pages}
|
||||
@click=${() => this._fetch(cur + 1)}>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<style>
|
||||
.tic-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tic-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.tic-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.tic-total-badge {
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-secondary-color);
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 1rem;
|
||||
padding: 0.1rem 0.6rem;
|
||||
}
|
||||
.tic-refresh-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
.tic-table-wrap {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tic-table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.tic-row--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.tic-row--clickable:hover td {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
.tic-id {
|
||||
font-family: monospace;
|
||||
font-size: 0.82rem;
|
||||
color: var(--bs-secondary-color);
|
||||
width: 4rem;
|
||||
}
|
||||
.tic-agent {
|
||||
font-family: monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.tic-date {
|
||||
font-size: 0.82rem;
|
||||
color: var(--bs-secondary-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tic-num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.tic-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 3rem;
|
||||
justify-content: center;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.tic-state--error { color: var(--bs-danger); }
|
||||
.tic-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.tic-page-info {
|
||||
font-size: 0.82rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<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>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
${this._renderTable()}
|
||||
${this._renderPagination()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
export class AppTopbar extends LightElement {
|
||||
static properties = {
|
||||
_theme: { state: true },
|
||||
_copilotCollapsed: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._theme = document.documentElement.getAttribute('data-bs-theme') ?? 'light';
|
||||
this._copilotCollapsed = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('copilot-collapsed', (e) => {
|
||||
this._copilotCollapsed = e.detail.collapsed;
|
||||
});
|
||||
}
|
||||
|
||||
_toggleTheme() {
|
||||
const next = this._theme === 'dark' ? 'light' : 'dark';
|
||||
this._theme = next;
|
||||
document.documentElement.setAttribute('data-bs-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
}
|
||||
|
||||
render() {
|
||||
const isDark = this._theme === 'dark';
|
||||
return html`
|
||||
<span class="topbar-title">Skald</span>
|
||||
<span class="topbar-spacer"></span>
|
||||
${this._copilotCollapsed ? html`
|
||||
<button class="topbar-copilot-btn" title="Open copilot"
|
||||
@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'}"
|
||||
@click=${() => this._toggleTheme()}>
|
||||
<i class="bi ${isDark ? 'bi-sun' : 'bi-moon-stars'}"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user