First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+72
View File
@@ -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>
`;
}
}
+307
View File
@@ -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>
`;
}
}
+398
View File
@@ -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
+181
View File
@@ -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>`;
}
}
+449
View File
@@ -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 &nbsp;↓${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 &nbsp;↓${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>`;
}
}
+400
View File
@@ -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>
`;
}
}
+77
View File
@@ -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>
`;
}
}
+562
View File
@@ -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>
`;
}
}
+334
View File
@@ -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() : ''}
`;
}
}
+564
View File
@@ -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>
`;
}
}
+288
View File
@@ -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} &mdash; ${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>
`;
}
}
+221
View File
@@ -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);
+142
View File
@@ -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>
`;
}
}
+352
View File
@@ -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) : ''}
`;
}
}
+830
View File
@@ -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() : ''}
`;
}
}
+416
View File
@@ -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) : ''}
`;
}
}
+484
View File
@@ -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) : ''}
`;
}
}
+93
View File
@@ -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);
+472
View File
@@ -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>
`;
}
}
+209
View File
@@ -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>
`;
}
}
+597
View File
@@ -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>
`;
}
}
View File
+210
View File
@@ -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);
+429
View File
@@ -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>&nbsp;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);
+271
View File
@@ -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);
+108
View File
@@ -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);
+304
View File
@@ -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>
`;
}
}
+136
View File
@@ -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>
`;
}
}
+126
View File
@@ -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>
`;
}
}
+82
View File
@@ -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);
+151
View File
@@ -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>
`;
}
}
+126
View File
@@ -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>
`;
}
}
+33
View File
@@ -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`;
}
+247
View File
@@ -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} &mdash; ${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>
`;
}
}
+47
View File
@@ -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>
`;
}
}